WebSocket 多人移动同步:Protobuf 差分上传、双缓冲插值与状态复现
最近读了一份用 Three.js 做的画风多人游戏源码,它的 WebSocket 移动同步方案设计得很干净,从连接管理到数据编码再到客户端插值复现,各层拆得很清楚。这篇文章把核心链路梳理出来。
整体数据流
本地玩家物理引擎
↓ 每帧计算 position / rotation
characters._update()
↓ 写入 _connection._data 对象
setInterval(updateRate)
↓ _retrieveChangedData() 差分检测
↓ protobuf 编码
WebSocket.send() ────────→ 服务器中继 ────────→ 远程客户端
↓ WebSocket.onmessage
↓ 前 N 字节 = 客户端 ID
↓ 剩余 = protobuf 解码
↓ _addClient / 更新 _clients Map
↓ 每帧 lerp/slerp 平滑插值
↓ BatchedMesh 渲染
一、连接管理:MicroRealmConnection
整个多人系统围绕 MicroRealmConnection 类构建,它封装了 WebSocket 的生命周期。
连接建立:使用 permessage-deflate 压缩,binaryType 设为 arraybuffer:
this._socket = new WebSocket(
this._servers[this._serverIndex],
"permessage-deflate"
);
this._socket.binaryType = "arraybuffer";
混合协议:同一连接上走两种数据格式:
| 用途 | 格式 | 说明 |
|---|---|---|
| 握手、房间加入、ping/pong | JSON | {"r": ["prefix", "roomName"]} |
| 高频位置/状态同步 | Protobuf 二进制 | 前 N 字节是客户端 ID 字符串,剩余是编码后的状态数据 |
自动重连:断开后根据场景选择重试策略——如果从未连上过(_serverFirstConnection === true),切换下一个服务器地址重试;如果曾经连上过但本地数据有变化,也会重试。
二、Protobuf 动态 Schema
没有手写 .proto 文件,而是用 protobufjs 在运行时根据初始化数据动态生成消息结构:
// 根据 data 对象的字段类型推断 proto schema
const TYPEMAP = { number: "double", string: "string", boolean: "bool" };
function inferJSON(name, data, types) {
// 遍历 data 的 key/value,生成 proto3 message 定义
// 支持 repeated 数组字段
}
const schema = inferJSON("RealmData", this._data, this._dataTypes);
const root = new protobuf.Root();
protobuf.parse(schema, root);
this._protoMsg = root.lookupType("RealmData");
实际同步的关键字段:
p: float[] // 位置 [x, y, z]
r: float[] // 欧拉角 [x, y, z]
medium: uint // 地面 / 空中 / 水中
animation: uint // 当前动画 ID
bonesFile / modelFiles / animationFiles: string // 角色外观
tag: string // 玩家名称标签
networkEvent: string // 自定义事件(如 emoji 表情)
三、移动上传:差分 + 定时发送
3.1 每帧写入数据对象
物理引擎每帧计算出的位置和旋转,直接写入连接持有的数据对象:
// 位置:保留 2 位小数,减少传输体积
this._connection._data.p = this._localObject.position.toArray()
.map(v => +Number(v).toFixed(2));
// 旋转
this._connection._data.r = this._localObject.rotation.toArray()
.slice(0, 3).map(v => +Number(v).toFixed(2));
// 其他状态字段
Object.keys(this._localObject.userData).forEach(key => {
this._connection._data[key] = this._localObject.userData[key];
});
3.2 差分检测
不是每帧都发送全部数据。用一个定时器以固定频率(updateRate,默认 35ms,约 28fps)触发 _relay(),它会比较当前数据与上次发送时的快照,只发送变化的字段:
_retrieveChangedData() {
const prev = JSON.parse(this._prevData);
const changes = {};
Object.keys(this._data).forEach(key => {
if (JSON.stringify(prev[key]) !== JSON.stringify(this._data[key])) {
changes[key] = this._data[key];
}
});
return changes;
}
如果没有任何字段变化,这一帧就跳过发送。
3.3 Protobuf 编码发送
变化的字段通过 Protobuf 编码为二进制 ArrayBuffer 发送:
_sendRelayedData(data) {
const msg = this._protoMsg.create(data);
const buf = this._protoMsg.encode(msg).finish();
this._socket.send(buf); // 二进制发送,比 JSON 省带宽
}
四、移动复现:双缓冲插值 + 分级更新
4.1 消息接收
收到二进制消息时,前 _localIDLength 字节是客户端 ID 字符串,剩余部分用 Protobuf 解码为状态对象:
_message(event) {
if (typeof event.data !== "string") {
// 二进制 Protobuf 消息
const clientId = decoder.decode(
event.data.slice(0, this._localIDLength)
);
const state = this._protoMsg.decode(
new Uint8Array(event.data.slice(this._localIDLength))
);
const existing = this._clients.get(clientId);
if (existing) {
// 已存在:更新状态
Object.keys(state).forEach(key => {
existing[key] = state[key];
});
} else {
// 新客户端:创建远程对象
this._clients.set(clientId, state);
this._addClient(clientId, state);
}
}
}
4.2 双缓冲平滑插值
每个远程角色维护 prev / next 双缓冲,结合物理子步(substep)的累积器做线性插值:
// 在物理子步循环中更新
remote.nextPosition.fromArray(data.p);
remote.prevPosition.copy(remote.nextPosition);
// 渲染时用子步累积器做插值 (0~1)
const t = this._collisionPhysics._deltaRatioAccumulator;
remote.position.lerpVectors(
remote.prevPosition, // 上一物理帧位置
remote.nextPosition, // 当前目标位置
t
);
// 旋转用 SLERP
remote.quaternion.slerpQuaternions(
remote.prevRotation,
remote.nextRotation,
t
);
这种方法的好处是:
- 位置和旋转的过渡与本地物理帧率解耦
- 即使网络包到达间隔不均匀,渲染依然平滑
- 不需要维护复杂的快照缓冲区
4.3 位置快照(瞬移保护)
如果远程位置跳变过大(超过 _positionDeltaLimitSnap),说明网络出现了严重断档,此时跳过平滑插值,直接瞬移到目标位置:
if (nextPosition.distanceTo(prevPosition) > this._positionDeltaLimitSnap) {
// 跳过平滑,直接设置
remote.position.copy(nextPosition);
}
4.4 分级更新频率
动画更新不是所有角色一视同仁。根据与本地玩家的距离做分级:
// 距离越远,更新间隔越长
const mult = math.fit(
distance,
UPDATE_DISTANCE * 0.1, // 近处阈值
UPDATE_DISTANCE, // 最大距离
0, // 近处不跳帧
UPDATE_DISTANCE_MULT // 远处最大跳帧倍数
);
if (renderInfo.time - lastUpdate < mult) {
return; // 跳过本帧更新
}
这样远处 NPC 不会浪费 CPU 做高频动画混合。
五、连接断开处理
5.1 角色移除动画
当服务器通知某个客户端离开(leave 字段),不是直接删除对象,而是播放一个缩小消失动画:
remote.isBeingRemoved = true;
createTween(remote.scale, {
to: { x: 0, y: 0, z: 0 },
ease: "power2.out",
duration: 0.15,
onComplete: () => {
this._charactersObjects.delete(id);
}
});
5.2 网络事件传递
自定义事件(如 emoji 表情)通过 networkEvent 字段传递。为了避免状态污染,这个字段用 Object.defineProperty 做成了”即用即焚”:
Object.defineProperty(data, "networkEvent", {
enumerable: true,
get: () => "",
set: (value) => {
if (value && typeof value === "string") {
remote.networkEvents.push(value); // 推入事件队列
}
}
});
每帧处理事件队列,清空后触发游戏逻辑,这样就避免了”同一事件被重复消费”的问题。
六、性能优化细节
| 优化点 | 实现 |
|---|---|
| 位置精度 | toFixed(2) 保留两位小数 |
| 数据压缩 | WebSocket permessage-deflate |
| 传输格式 | Protobuf 二进制,比 JSON 小约 60-70% |
| 发送频率 | 差分检测 + 定时器控制,无变化不发送 |
| 渲染频率 | 根据距离分级跳帧 |
| 对象池 | BatchedMesh 合并所有角色到一个 draw call |
总结
这套方案用几个非常务实的策略实现了流畅的多人移动同步:
- 混合协议:JSON 管握手,Protobuf 管高频数据
- 差分上传:只发送变化的字段,配合定时器控制频率
- 双缓冲插值:prev/next + 子步累积器,与物理帧率解耦
- 位置快照:跳变过大时直接瞬移,防止”飘移”
- 分级更新:远处角色降低动画更新频率
- 优雅断连:角色离开播放缩小消失动画
和 Phaser 那篇的 “Replay Path + Snapshot 插值” 方案相比,这套方案更轻量——它不需要服务器下发精确的时间戳路径,而是依赖客户端的双缓冲平滑。两种方案各有适用场景:Phaser 方案更适合路径明确的网格移动(格子游戏),这套方案更适合物理驱动的自由移动(3D 开放世界)。
本文基于对画风游戏
messenger.abeto.co客户端代码的分析。源码注释头/* by abeto - https://abeto.co */。
WebSocket
Protobuf
Multiplayer
Three.js
Network Sync
]