皆様こんにちは。
AMDlab Webエンジニアの塚田です。
今回はyjsというライブラリについて学習してみました。
Yjsとは
さらに、通信層は独立しており、WebSocket や WebRTC、IndexedDB などのプロバイダを組み合わせて使えるため、小規模なP2Pアプリから大規模なクラウドアプリまで幅広く対応できます。代表的な活用例として、Google Docs のような文書エディタやホワイトボード、リアルタイムチャットが挙げられます。
本記事ではWebRTC(y-webrtc)を使い、React Three Fiber上のキューブの位置と回転を複数端末で同時に操作できる簡単な例を題材に、Yjsの使い方を紹介します。
https://yjs.dev/
※注: React Three Fiber 等の詳細な説明は割愛し、Yjs の実装を中心に説明します。
Yjs の実装(共有ストアとAPI)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import * as Y from "yjs"; import { WebrtcProvider } from "y-webrtc"; export const ydoc = new Y.Doc(); export const ROOM_NAME = "r3f-yjs-demo"; export const provider = new WebrtcProvider(ROOM_NAME, ydoc); export const yCube = ydoc.getMap("cube"); export type Vec3 = [number, number, number]; export function setCubePosition(position: Vec3) { yCube.set("position", position); } export function getCubePosition(): Vec3 { const pos = yCube.get("position") as Vec3 | undefined; return pos ?? [0, 0, 0]; } export function setCubeRotation(rotation: Vec3) { yCube.set("rotation", rotation); } export function getCubeRotation(): Vec3 { const rot = yCube.get("rotation") as Vec3 | undefined; return rot ?? [0, 0, 0]; } |
- 伝送レイヤ: y-webrtc の WebrtcProvider で同一ルームのピア同士を接続(今回は ROOM_NAME を r3f-yjs-demo に固定)
- 共有データ構造: yCube = ydoc.getMap("cube") にキューブの状態(位置・回転)を格納
- ヘルパ: getCubePosition / setCubePosition(位置)、getCubeRotation / setCubeRotation(回転)
シーン側の実装(変更監視・ドラッグ・ボタン回転)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
import { useEffect, useRef, useState } from "react"; import { Mesh, Plane, Vector3 } from "three"; import { useThree, useFrame } from "@react-three/fiber"; import type { ThreeEvent } from "@react-three/fiber"; import { OrbitControls, GizmoHelper, GizmoViewport } from "@react-three/drei"; import { getCubePosition, getCubeRotation, setCubePosition, setCubeRotation, yCube, type Vec3, } from "../yjs"; type DraggableCubeProps = { color?: string; }; export function CollaborativeScene() { return ( <> <ambientLight intensity={0.7} /> <directionalLight position={[5, 5, 5]} intensity={0.8} /> <gridHelper args={[20, 20]} /> <axesHelper args={[5]} /> <DraggableCube color="#f97316" /> <OrbitControls makeDefault enableDamping /> <GizmoHelper alignment="bottom-right" margin={[80, 80] as [number, number]} > <GizmoViewport axisColors={["#f87171", "#34d399", "#60a5fa"]} labelColor="#111827" /> </GizmoHelper> </> ); } function DraggableCube({ color = "#f97316" }: DraggableCubeProps) { const meshRef = useRef<Mesh | null>(null); const dragPlaneRef = useRef<Plane>(new Plane()); const dragOffsetRef = useRef<Vector3>(new Vector3()); const { controls, camera } = useThree((s) => ({ viewport: s.viewport, size: s.size, controls: s.controls, camera: s.camera, })); const [position, setPositionState] = useState<Vec3>(getCubePosition()); const [rotation, setRotationState] = useState<Vec3>(getCubeRotation()); const [isDragging, setIsDragging] = useState(false); // Sync from Yjs on remote updates useEffect(() => { const observer = () => { setPositionState(getCubePosition()); setRotationState(getCubeRotation()); }; yCube.observe(observer); return () => yCube.unobserve(observer); }, []); // Apply rotation animation if not dragging useFrame((_, delta) => { if (!meshRef.current) return; if (!isDragging) { const r = meshRef.current.rotation; const nextRot: Vec3 = [r.x + delta * 0.3, r.y + delta * 0.5, r.z]; setRotationState(nextRot); setCubeRotation(nextRot); } }); // Drag: make cube follow the pointer on a plane parallel to the camera at the cube's depth const onPointerDown = (e: ThreeEvent<PointerEvent>) => { e.stopPropagation(); const targetEl = e.target as Element & { setPointerCapture?: (pointerId: number) => void; }; targetEl.setPointerCapture?.(e.pointerId); setIsDragging(true); const c = controls as { enabled?: boolean } | undefined; if (c && typeof c.enabled === "boolean") c.enabled = false; if (meshRef.current) { const normal = new Vector3(); camera.getWorldDirection(normal); // Plane through current position, normal facing camera direction const pos = meshRef.current.position.clone(); dragPlaneRef.current.setFromNormalAndCoplanarPoint(normal, pos); const hit = e.ray.intersectPlane(dragPlaneRef.current, new Vector3()); if (hit) { dragOffsetRef.current.copy(pos.sub(hit)); } else { dragOffsetRef.current.set(0, 0, 0); } } }; const onPointerUp = (e: ThreeEvent<PointerEvent>) => { e.stopPropagation(); const targetEl = e.target as Element & { releasePointerCapture?: (pointerId: number) => void; }; targetEl.releasePointerCapture?.(e.pointerId); setIsDragging(false); const c = controls as { enabled?: boolean } | undefined; if (c && typeof c.enabled === "boolean") c.enabled = true; }; const onPointerMove = (e: ThreeEvent<PointerEvent>) => { if (!isDragging) return; e.stopPropagation(); // Compute intersection with drag plane and follow cursor const hit = e.ray.intersectPlane(dragPlaneRef.current, new Vector3()); if (!hit) return; const next = hit.add(dragOffsetRef.current); const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max); const nextPos: Vec3 = [clamp(next.x, -8, 8), clamp(next.y, -5, 5), next.z]; setPositionState(nextPos); setCubePosition(nextPos); }; return ( <mesh ref={meshRef} position={position as unknown as [number, number, number]} rotation={rotation as unknown as [number, number, number]} castShadow onPointerDown={onPointerDown} onPointerUp={onPointerUp} onPointerOut={onPointerUp} onPointerMove={onPointerMove} > <boxGeometry args={[1, 1, 1]} /> <meshStandardMaterial color={color} /> </mesh> ); } export default CollaborativeScene; |
- yCube.observe で変更監視
- 位置はドラッグで更新して setCubePosition へ書き込み、他クライアントへ同期。
- 回転は画面左上のボタンで「カメラ基準(画面座標系)」に10度刻みで更新し、setCubeRotation に反映、他クライアントへ同期。
結果
同一クライアントではありますが、別タブで同期されることを確認しました。
回転
移動
終わりに
今回の記事では、Yjs を使ったシンプルな WebRTC(P2P)による位置、回転の共有の仕組みを紹介しました。
今後は WebSocket などを利用したより実践的なものを作成しようと思っています。
AMDlabでは、開発に力を貸していただけるエンジニアさんを大募集しております。
少しでもご興味をお持ちいただけましたら、カジュアルにお話するだけでも大丈夫ですのでお気軽にご連絡ください!
中途求人ページ: https://www.amd-lab.com/recruit-list/mid-career
カジュアル面談がエントリーフォームからできるようになりました。
採用種別を「カジュアル面談(オンライン)」にして必要事項を記載の上送信してください!
エントリーフォーム: https://www.amd-lab.com/entry
AMDlabのSNSアカウントです!ぜひフォローお願いします✨
■ X(旧Twitter):https://x.com/amdlabinc
■ Instagram:https://www.instagram.com/amdlabinc/
■ Facebook:https://www.facebook.com/amdlab.lnc/
COMMENTS