Three.js 网络同步的 WebRTC 实践:P2P 数据通道在多人协同 3D DApp 中的应用 Three.js 网络同步的 WebRTC 实践P2P 数据通道在多人协同 3D DApp 中的应用一、3D DApp 的实时同步困境考虑一个链上 3D 场景多人在线画廊每个用户有一个 3D 化身可以在空间中自由移动、查看 NFT 艺术品、与其他用户实时对话。Web3 的元素NFT 所有权验证、链上交易只是骨架真正的用户体验在于 3D 场景的交互流畅度——当你的化身向右移动时其他用户应该在 100ms 内看到这个移动而非等待链上区块确认。传统的 WebSocket 集中式架构在这种场景下面临两个瓶颈服务器瓶颈所有用户的移动、旋转、交互数据都经由中心服务器转发。10 个用户每秒发送 30 帧的位置信息300 条/秒不算什么但 100 个用户就是 3000 条/秒的广播风暴供服务器处理。延迟路径客户端→服务器→其他客户端多了一跳。如果服务器在弗吉尼亚而两个用户都在东京数据却要绕过半个地球。WebRTC 的 P2P 数据通道为这个问题提供了一个架构级解决方案用户在 3D 场景中的空间数据位置、旋转、交互状态直接在浏览器之间传输链上合约只负责最终资产状态的验证。这是一种实时状态 P2P、资产结算链上的混合拓扑。二、WebRTC DataChannel 在 3D 同步中的适配2.1 信令层的无状态设计WebRTC 的连接建立需要信令服务器交换 SDPSession Description Protocol信息和 ICEInteractive Connectivity Establishment候选。信令服务器的职责限于引入阶段一旦 P2P 连接建立信令服务器即可退出数据流。在 3D DApp 中信令服务器可以用一个简单的 WebSocket 服务实现不维护任何游戏状态。它只做三件事接收新用户的 SDP offer 并广播给房间内其他用户转发 ICE candidate维护房间中的在线用户列表连接建立后所有状态同步都走 DataChannel信令服务器只处理用户加入/离开的通知负载极低。2.2 DataChannel 的消息优先级设计DataChannel 支持两种传输模式可靠有序ordered: true和不可靠无序ordered: false,maxRetransmits: 0。在 3D 同步中不同类型的消息需要不同的传输保证位置/旋转更新高频每秒 20-30 次、低延迟要求、允许丢帧。使用不可靠无序通道。丢了一个位置包没关系下一个位置包 33ms 后就会到达。交互事件拾取物品、打开面板、发送聊天消息低频、必须可靠到达。使用可靠有序通道。状态同步场景初始化、新用户加入必须可靠、有序。使用可靠有序通道。为每个通道分配独立的 DataChannel 有助于分离优先级避免高频位置更新阻塞低频交互事件。2.3 全网状拓扑 vs SFU对于 N 个用户的 3D 场景全网状拓扑意味着每个用户有 N-1 个 DataChannel 连接上行带宽需求为 O(N)。在 N3-5 的小型画廊场景中全网状拓扑可行且延迟最低。但 N10 时上行带宽成为瓶颈。对于大型场景应该使用 SFUSelective Forwarding Unit架构——一个服务器中继节点接收所有用户的数据并选择性转发给视野内的用户。但在本文讨论的 DApp 场景中通常为 2-10 人协同全网状拓扑足够。三、代码实践Three.js WebRTC 多人同步系统/** * Three.js WebRTC DataChannel 多人 3D 同步系统 * * 设计决策 * 1. 双通道分离 * - unreliable_channel: 位置/旋转更新UDP 语义允许丢包 * - reliable_channel: 交互事件/聊天TCP 语义保证送达 * 避免高频位置更新在可靠通道中触发重传风暴。 * * 2. 插值缓冲而非直接应用 * 每个远程玩家维护位置历史缓冲区 100ms3 帧 * 渲染时对缓冲区做线性插值消除网络抖动造成的视觉卡顿。 * * 3. 数据压缩 * 位置3x float32 12 bytes→ 量化为 uint166 bytes * 旋转四元数4x float32 16 bytes→ 量化为 uint84 bytes。 * 单帧状态包从 28 bytes 压缩到 10 bytes。 */ // ── 类型定义 ── type PeerId string; interface CompressedAvatarState { // 位置世界坐标量化为 0-65535 范围精度约 1.5mm在 100m 场景中 posX: number; // uint16 posY: number; posZ: number; // 旋转四元数量化为 0-255 范围 rotX: number; // uint8 rotY: number; rotZ: number; rotW: number; // Y 轴旋转euler.y用于快速旋转更新 rotationY: number; // uint16, 0-65535 → 0-2π timestamp: number; // 发送时间戳 } interface InteractionEvent { type: pickup | chat | emote | portal; payload: Recordstring, unknown; } // ── 压缩/解压工具 ── const SCENE_SIZE 100; // 场景范围 100m x 100m x 100m function compressAvatarState( position: THREE.Vector3, quaternion: THREE.Quaternion, rotationY: number, ): CompressedAvatarState { return { posX: Math.round(((position.x SCENE_SIZE / 2) / SCENE_SIZE) * 65535), posY: Math.round(((position.y) / SCENE_SIZE) * 65535), posZ: Math.round(((position.z SCENE_SIZE / 2) / SCENE_SIZE) * 65535), rotX: Math.round(((quaternion.x 1) / 2) * 255), rotY: Math.round(((quaternion.y 1) / 2) * 255), rotZ: Math.round(((quaternion.z 1) / 2) * 255), rotW: Math.round(((quaternion.w 1) / 2) * 255), rotationY: Math.round((rotationY / (2 * Math.PI)) * 65535), timestamp: Date.now(), }; } function decompressAvatarState(state: CompressedAvatarState) { return { position: new THREE.Vector3( (state.posX / 65535) * SCENE_SIZE - SCENE_SIZE / 2, (state.posY / 65535) * SCENE_SIZE, (state.posZ / 65535) * SCENE_SIZE - SCENE_SIZE / 2, ), quaternion: new THREE.Quaternion( (state.rotX / 255) * 2 - 1, (state.rotY / 255) * 2 - 1, (state.rotZ / 255) * 2 - 1, (state.rotW / 255) * 2 - 1, ), timestamp: state.timestamp, }; } // ── 插值缓冲区 ── interface StateSnapshot { position: THREE.Vector3; quaternion: THREE.Quaternion; timestamp: number; } class InterpolationBuffer { private buffer: StateSnapshot[] []; private readonly bufferTimeMs: number; // 缓冲时间窗口 constructor(bufferTimeMs: number 100) { this.bufferTimeMs bufferTimeMs; } addSnapshot(position: THREE.Vector3, quaternion: THREE.Quaternion, timestamp: number) { this.buffer.push({ position, quaternion, timestamp }); // 保持缓冲区在缓冲时间窗口内 const cutoff timestamp - this.bufferTimeMs; this.buffer this.buffer.filter(s s.timestamp cutoff); } getInterpolatedState(renderTimestamp: number): StateSnapshot | null { // 渲染时间戳回退缓冲时间以获得平滑插值 const targetTime renderTimestamp - this.bufferTimeMs; if (this.buffer.length 2) return this.buffer[0] || null; // 找到目标时间左右的两个快照 let left this.buffer[0]; let right this.buffer[this.buffer.length - 1]; for (let i 0; i this.buffer.length - 1; i) { if (this.buffer[i].timestamp targetTime this.buffer[i 1].timestamp targetTime) { left this.buffer[i]; right this.buffer[i 1]; break; } } const range right.timestamp - left.timestamp; if (range 0) return left; const t (targetTime - left.timestamp) / range; const clampedT Math.max(0, Math.min(1, t)); return { position: new THREE.Vector3().lerpVectors(left.position, right.position, clampedT), quaternion: new THREE.Quaternion().slerpQuaternions(left.quaternion, right.quaternion, clampedT), timestamp: targetTime, }; } } // ── WebRTC 连接管理器 ── class PeerConnectionManager { private peers new MapPeerId, { connection: RTCPeerConnection; unreliableChannel: RTCDataChannel | null; reliableChannel: RTCDataChannel | null; interpolationBuffer: InterpolationBuffer; }(); private onAvatarUpdate?: (peerId: PeerId, state: StateSnapshot) void; private onInteraction?: (peerId: PeerId, event: InteractionEvent) void; constructor( private signalingServer: WebSocket, private localPeerId: PeerId, ) { this.setupSignaling(); } private setupSignaling() { this.signalingServer.onmessage async (event) { const msg JSON.parse(event.data); switch (msg.type) { case offer: await this.handleOffer(msg.from, msg.sdp); break; case answer: await this.handleAnswer(msg.from, msg.sdp); break; case ice-candidate: await this.handleIceCandidate(msg.from, msg.candidate); break; } }; } async joinRoom(roomId: string) { this.signalingServer.send(JSON.stringify({ type: join, roomId, peerId: this.localPeerId, })); } private async createPeerConnection(remotePeerId: PeerId): PromiseRTCPeerConnection { const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.l.google.com:19302 }, // 生产环境添加 TURN 服务器以穿越对称 NAT { urls: turn:turn.example.com:3478, username: turnuser, credential: turnpass, }, ], }); // 创建不可靠通道位置同步 const unreliableChannel pc.createDataChannel(avatar-state, { ordered: false, // 不需要有序 maxRetransmits: 0, // 不重传丢失就丢失 }); // 创建可靠通道交互事件 const reliableChannel pc.createDataChannel(interaction, { ordered: true, // 需要有序 // 默认可靠传输 }); this.peers.set(remotePeerId, { connection: pc, unreliableChannel: null, reliableChannel: null, interpolationBuffer: new InterpolationBuffer(100), }); // 接收对端发来的不可靠/可靠通道 pc.ondatachannel (event) { const channel event.channel; if (channel.label avatar-state) { const peer this.peers.get(remotePeerId)!; peer.unreliableChannel channel; channel.onmessage (e) this.handleAvatarState(remotePeerId, e.data); } else if (channel.label interaction) { const peer this.peers.get(remotePeerId)!; peer.reliableChannel channel; channel.onmessage (e) this.handleInteraction(remotePeerId, e.data); } }; // 自身创建的通道也需要设置 onmessage因为 addTrack 在 createOffer 前 unreliableChannel.onmessage (e) this.handleAvatarState(remotePeerId, e.data); reliableChannel.onmessage (e) this.handleInteraction(remotePeerId, e.data); this.peers.get(remotePeerId)!.unreliableChannel unreliableChannel; this.peers.get(remotePeerId)!.reliableChannel reliableChannel; // ICE 候选发送到信令服务器 pc.onicecandidate (event) { if (event.candidate) { this.signalingServer.send(JSON.stringify({ type: ice-candidate, to: remotePeerId, from: this.localPeerId, candidate: event.candidate, })); } }; return pc; } private handleAvatarState(peerId: PeerId, rawData: any) { // 解析二进制数据 const view new DataView(rawData instanceof ArrayBuffer ? rawData : rawData.buffer); const state: CompressedAvatarState { posX: view.getUint16(0), posY: view.getUint16(2), posZ: view.getUint16(4), rotX: view.getUint8(6), rotY: view.getUint8(7), rotZ: view.getUint8(8), rotW: view.getUint8(9), rotationY: view.getUint16(10), timestamp: Date.now(), }; const decompressed decompressAvatarState(state); const peer this.peers.get(peerId); if (peer) { peer.interpolationBuffer.addSnapshot( decompressed.position, decompressed.quaternion, decompressed.timestamp, ); } } private handleInteraction(peerId: PeerId, data: string) { try { const event JSON.parse(data) as InteractionEvent; this.onInteraction?.(peerId, event); } catch { // 丢弃无效消息 } } // 发送压缩后的化身状态 sendAvatarState(position: THREE.Vector3, quaternion: THREE.Quaternion, rotationY: number) { const compressed compressAvatarState(position, quaternion, rotationY); // 打包为 12 字节 ArrayBuffer const buffer new ArrayBuffer(12); const view new DataView(buffer); view.setUint16(0, compressed.posX); view.setUint16(2, compressed.posY); view.setUint16(4, compressed.posZ); view.setUint8(6, compressed.rotX); view.setUint8(7, compressed.rotY); view.setUint8(8, compressed.rotZ); view.setUint8(9, compressed.rotW); view.setUint16(10, compressed.rotationY); // 广播给所有对等节点 for (const [peerId, peer] of this.peers) { if (peer.unreliableChannel?.readyState open) { peer.unreliableChannel.send(buffer); } } } // 获取远程化的插值状态每帧调用 getRemoteAvatarStates(): MapPeerId, StateSnapshot { const result new MapPeerId, StateSnapshot(); const now Date.now(); for (const [peerId, peer] of this.peers) { const interpolated peer.interpolationBuffer.getInterpolatedState(now); if (interpolated) { result.set(peerId, interpolated); } } return result; } } // ── Three.js 渲染循环集成 ── class ThreeJSMultiplayerScene { private renderer: THREE.WebGLRenderer; private scene: THREE.Scene; private camera: THREE.PerspectiveCamera; private localAvatar: THREE.Object3D; private remoteAvatars new MapPeerId, THREE.Object3D(); constructor( private peerManager: PeerConnectionManager, ) { this.renderer new THREE.WebGLRenderer(); this.scene new THREE.Scene(); this.camera new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); this.localAvatar new THREE.Mesh(new THREE.BoxGeometry(1, 2, 1), new THREE.MeshPhongMaterial({ color: 0x00ff00 })); this.scene.add(this.localAvatar); this.animate(); } private animate () { requestAnimationFrame(this.animate); // 更新本地化身实际应用中从鼠标/键盘输入获取 this.localAvatar.position.set(0, 1, 0); this.localAvatar.rotation.y 0.01; // 发送本地状态 this.peerManager.sendAvatarState( this.localAvatar.position, this.localAvatar.quaternion, this.localAvatar.rotation.y, ); // 获取远程化身的插值状态并更新 3D 对象 const remoteStates this.peerManager.getRemoteAvatarStates(); for (const [peerId, state] of remoteStates) { let avatar this.remoteAvatars.get(peerId); if (!avatar) { // 为新加入的对等节点创建化身 avatar new THREE.Mesh( new THREE.BoxGeometry(1, 2, 1), new THREE.MeshPhongMaterial({ color: 0xff0000 }) ); this.scene.add(avatar); this.remoteAvatars.set(peerId, avatar); } avatar.position.copy(state.position); avatar.quaternion.copy(state.quaternion); } this.renderer.render(this.scene, this.camera); }; }关键设计说明压缩收益位置从 12 字节 float32 压缩为 6 字节 uint16旋转从 16 字节压缩为 5 字节单次状态更新从 28 字节减少到 12 字节含 timestamp 字段。在 10 个用户 x 每秒 30 帧的场景中每个用户的上行带宽从 8.4 KB/s 降到 3.6 KB/s。双通道分离avatar-state通道使用ordered: falsemaxRetransmits: 0模拟 UDP 语义。高频位置更新不会因为偶尔的丢包触发 SCTP 重传风暴。interaction通道使用默认可靠有序传输保证聊天和拾取事件不丢失。插值缓冲区100ms 的缓冲窗口提供了约 3 帧的缓冲空间足以平滑 50ms 以下的网络抖动。大于 100ms 的延迟仍会导致卡顿这超出了纯客户端插值的解决范围需要服务端补偿或预测。四、边界分析NAT 穿越失败WebRTC 的理想场景是 P2P 直连但对称 NAT如企业网络、部分移动网络会导致 ICE 协商失败需要通过 TURN 中继服务器中转。TURN 中继重新引入了服务器带宽瓶颈所有数据经过服务器但这是对称 NAT 环境下不可回避的妥协。建议为 TURN 服务器预留足够的带宽至少 50 Mbps 上行。全网状拓扑的扩展上限N 个用户意味着每个用户上行 N-1 路 DataChannel 流。当 N 10 时浏览器进程的 WebRTC 连接管理开销SCTP 心跳、ICE 保活会明显增加。解决方案是空间分区——用户只与视野范围内的对等节点建立 DataChannel如半径 50m 内的用户。视野外的用户通过一个轻量状态代理SFU获取低频位置更新。二进制协议的版本兼容DataChannel 的二进制消息没有内置版本标识如果消息格式在客户端升级后发生变化旧版本客户端解析失败。必须在消息头部增加 1 字节的版本号[version: uint8, payload: ...]接收时根据版本选择不同的解析逻辑。浏览器标签页后台的 CPU 节流当用户切换到其他标签页时浏览器会大幅降低requestAnimationFrame的执行频率最低 1 Hz。Three.js 渲染循环被暂停WebRTC DataChannel 的数据发送也被迫中断。其他用户会看到这个用户的化身冻结在原地。解决方案是在visibilitychange事件中广播一个AFK状态并在标签页恢复时重新同步完整状态非增量。链上状态与 P2P 状态的不一致WebRTC 同步的是 3D 场景中的临时状态化身位置、聊天消息链上合约维护的是资产所有权。两者必须通过显示层和状态层隔离。一个用户拾取地板上的 NFT 道具P2P 通知其他用户该道具被拾取显示层快速交易上链确认所有权转移状态层慢。如果链上交易失败UI 必须正确回退显示——道具回到地板用户收到失败通知。五、总结Three.js WebRTC 的组合在 3D DApp 中解决了两个问题3D 渲染的视觉效果和实时多人交互的网络延迟。WebRTC 的 DataChannel 通过 P2P 直连消除了中心服务器的转发延迟双通道设计分离了高频状态同步和低频事件通信插值缓冲区在视觉层面抹平了网络抖动。这种架构的核心原则可以总结为显示即 P2P、资产即链上。用户的视觉体验不需要等待区块链——化身移动、物品拾取的视觉效果在 WebRTC 的 100ms 内同步完成。链上合约只需要关心那些真正需要所有权证明的操作大幅降低了对链性能的依赖。对于 2-10 人的小型协同 3D 场景画廊参观、会议室、虚拟展厅这套方案足够高效且部署简单——只需要一个轻量信令服务器和一个 TURN 中继应对 NAT不需要专门的游戏服务器。当场景规模增长到 50 人以上时空间分区 SFU 的混合拓扑才是可持续的扩展路径。