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 的连接建立需要信令服务器交换 SDP(Session Description Protocol)信息和 ICE(Interactive 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)。在 N=3-5 的小型画廊场景中,全网状拓扑可行且延迟最低。但 N>10 时上行带宽成为瓶颈。
对于大型场景,应该使用 SFU(Selective Forwarding Unit)架构——一个服务器中继节点接收所有用户的数据并选择性转发给视野内的用户。但在本文讨论的 DApp 场景中(通常为 2-10 人协同),全网状拓扑足够。
三、代码实践:Three.js + WebRTC 多人同步系统
/**
* Three.js + WebRTC DataChannel 多人 3D 同步系统
*
* 设计决策:
* 1. 双通道分离:
* - unreliable_channel: 位置/旋转更新(UDP 语义,允许丢包)
* - reliable_channel: 交互事件/聊天(TCP 语义,保证送达)
* 避免高频位置更新在可靠通道中触发重传风暴。
*
* 2. 插值缓冲而非直接应用:
* 每个远程玩家维护位置历史缓冲区 100ms(3 帧),
* 渲染时对缓冲区做线性插值,消除网络抖动造成的视觉卡顿。
*
* 3. 数据压缩:
* 位置(3x float32 = 12 bytes)→ 量化为 uint16(6 bytes),
* 旋转四元数(4x float32 = 16 bytes)→ 量化为 uint8(4 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: Record<string, 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 Map<PeerId, {
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): Promise<RTCPeerConnection> {
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(): Map<PeerId, StateSnapshot> {
const result = new Map<PeerId, 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 Map<PeerId, 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: false+maxRetransmits: 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 的混合拓扑才是可持续的扩展路径。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_40635035/article/details/162941866



