概述
上次我们实现多人同屏的功能时,主要实现了一个大的框架,仅仅同步了玩家的位置(通过本地运算2次位置的差值来获取方向、判断玩家动画)。
这种最简实现是为了尽快的熟悉同步操作的流程,这次我们正式来优化这块逻辑。
这次不仅要同步玩家的位置、状态、旋转方向,还要根据同步数据来设计二进制压缩协议来对数据进行压缩,同时对同步数据驱动的显示其他玩家的组件进行重构。
回顾
我们使用 react-three-fiber 搭建了整体框架。
使用 @react-three/rapier 实现物理效果。
使用 ecctrl 库进行人物控制。
使用socket来进行数据同步,后端使用nodejs来对socket进行消息转发具体看这篇多人同屏。
获取玩家的四元数
使用ecctrl库的矛盾点
该库有可以方便实现玩家控制,但当只有使用autoBalance
时,才能获得玩家的旋转信息,否则获取不到。但是其自动平衡实现有问题:很容易受到环境影响,导致人物疯狂旋转,截至发文为止,它的最新版本中疯狂旋转有所好转,但实测偶尔会触发方向失调。所以以防万一关闭autoBalance
。
必须找到在autoBalance
关闭的情况下获取人物旋转的方法。
查看ecctrl源代码
查看ecctrl
库的源代码,发现它在autoBalance
打开时旋转的是RigidBody
,关闭时直接旋转的人物模型。
如上图,图中的
characterRef
绑定的是RigidBody
,这就是为什么当autoBalance
关闭时无法获取旋转,因为它直接禁用了旋转。
上图中,
characterModelRef
绑定的是包裹玩家模型的gruop
元素。也就是说它通过旋转gruop
元素来旋转玩家。
获取玩家的旋转
这样我们就可以直接从玩家模型上获得旋转了,使用getWorldQuaternion
获取其相对于世界坐标系的四元数(因为其是作为group的子元素一起旋转的,自身没有旋转信息)。
...
const { scene, animations } = useGLTF(PATH);
const rigidRef = useRef<RapierRigidBody>(null); // 玩家所在刚体
const modelRef = useRef<THREE.Group>(null); // 人物模型
// 核心代码
useFrame(() => {
const quat = new THREE.Quaternion();
modelRef.current?.getWorldQuaternion(quat);
console.log('quat',quat)
});
return <Ecctrl
autoBalance={false}
name="player"
ref={rigidRef}
>
<Suspense fallback={null} >
<Animation animations={animations} animationSet={ANIMATION_MAP}>
// 人物模型
<primitive
castShadow
object={scene}
ref={modelRef}
/>
</Animation>
</Suspense>
<PositionalAudio ref={stepsRef} url={AUDIOS.steps} distance={1} loop />
</Ecctrl>
旋转数据
在同步数据以前,我们必须要了解一些概念,以便后续进行数据压缩时使用。
空间方向表示
在三维空间中,方向可以用球坐标系表示:
- (r,θ,φ)其中:
- r 是半径,表示向量的长度(在单位方向向量中 r=1)。
- θ 是方位角,表示在 xy 平面上与 x 轴正向的夹角,范围 [0,2π)。
- φ 是极角,表示与 z 轴正向的夹角,范围 [0,π]。
注意:球坐标只能表示方向,无法描述绕该方向的旋转角度。
它定义了一个指向方向的向量,但没有描述绕这个方向的旋转角度。
当你的角色只需要同步朝向(如面向目标)时,可以只同步方向(球坐标)。
空间旋转与四元数
为了表示一个物体在三维空间中的完整旋转,我们需要描述三个要素:
- 旋转轴(方向) :可以使用球坐标描述。
- 旋转角度:绕旋转轴旋转的角度。
- 旋转顺序:旋转是依次应用的,这在欧拉角中尤为重要。
一种高效表示旋转的方式是四元数:
- 四元数 q=(w,x,y,z)是一种不会发生万向节死锁的旋转表示方法。
- 它同时描述了旋转轴和旋转角度,能够高效且稳定地处理三维旋转。
- 单位四元数可以表述为:w^2+x^2+y^2+z^2=1
高效数据压缩
我们使用socket来同步数据,为了减轻多人在线时的服务器压力,必须对数据进行压缩。为了让消息体尽可能的小,我们二进制数据来编码。这里我们将所有要同步的数据的精度统一定为0.01。
- 一个字节(8 位):可以表示 256 个数值
- 两个字节(16 位):可以表示 65,536 个数值。
压缩位置数据
- 坐标参数:x, y, z,均为必需。
- 取值范围:[-300.00, 300.00],精度 0.01,覆盖 60,000 个可能数值。
- 数据类型:每个坐标使用 2 字节(Int16)。
- 总开销:3 × Int16 = 6 字节。
压缩旋转
旋转是四元数,是为了完整表达旋转,共有四个值。 但当前我们的角色仅仅有绕y轴(和unity一样threejs y轴向上)的旋转,所以 x=0,z=0。 而w可以通过公式计算获得:w^2=1-y^2。所以我们只需要同步单位四元数中的y值即可。
- 旋转模型:单位四元数(仅同步 y 轴旋转)。
- 取值范围:[-1.00, 1.00],精度 0.01,共 200 个可能值。
- 数据类型:1 字节(Int8)。
- 未来扩展:如需完整四元数,添加x,z的值。修改协议添加一个字节。
- 总开销:1 字节。
压缩玩家状态
- 状态种类:如静止、行走、奔跑、跳跃、攻击、防御等。
- 种类限制:不超过 256 种。
- 数据类型:1 字节(Int8)。
压缩玩家id
服务端会在二进制头部添加id,再分发给所有人,方便本地更新其他在线玩家的数据。
- 取值范围:使用自增id,[1-999]
- 数据类型:2 字节(Int16)
总开销
- 玩家id:2字节
- 位置数据:6 字节
- 旋转数据:1 字节
- 状态数据:1 字节
- 总开销:每个玩家 上传 8 字节,同步 10 字节。
二进制编码协议
我们在每次同步数据到服务端时调用压缩协议,从服务端获取其他玩家的数据时调用解压协议。
/**
* socket消息体压缩协议
*/
export function encodeMsg(
pos: Vector,
quatY: number,
anim: number
): ArrayBuffer {
const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);
// 位置(Int16 x3,±327.67)
[pos.x, pos.y, pos.z].forEach((v, i) => {
view.setInt16(i * 2, Math.round(v * 100), true);
});
// Y旋转([-1.00, 1.00])
view.setInt8(6, Math.round(quatY * 100));
// 动画状态: [0-255]
view.setInt8(7, anim & 0x0f);
return buffer;
}
/**
* socket消息体解压缩协议
*/
export function decodeMsg(buffer: ArrayBuffer): ServerMessage {
if (buffer.byteLength !== 10) {
throw new Error(`无效数据长度: ${buffer.byteLength} 字节`);
}
const view = new DataView(buffer);
// 获取服务端id(0-1)
const id = view.getUint16(0, true);
// 解析位置(2-7)
const pos = {
x: view.getInt16(2, true) / 100,
y: view.getInt16(4, true) / 100,
z: view.getInt16(6, true) / 100,
};
// 解析旋转(8)
const quatY = view.getInt8(8) / 100;
const quat = { x: 0, y: quatY, z: 0, w: Math.sqrt(1 - quatY * quatY) };
// 解析动画(9)
const anim = view.getInt8(9) & 0x0f;
return { id, pos, quat, anim };
}
去除冗余依赖
我之前使用的是socket.io
的库,可是这个库有个问题,当传递二进制数据时它需要一个占位消息用于携带二进制数据,猜测应该和它的事件监听功能有关。这个占位消息长度是45B是我们的消息体的近6倍。
我反复尝试也不能去掉这个占位消息,所以直接去除了socket.io
库,改用原生socket,并简单的封装,后续再慢慢完善其功能,代码如下。
主要是要设置 binaryType= "arraybuffer"
, 其他和一般的socket没有差别,只是接受和发送的都是二进制消息。
type EventCallback = (data: ArrayBuffer) => void;
class WebSocketClient {
private ws!: WebSocket;
private listeners: { [key: string]: EventCallback[] } = {};
private reconnectInterval = 5000;
constructor(url: string) {
this.connect(url);
}
private connect(url: string) {
this.ws = new WebSocket(url);
this.ws.binaryType = "arraybuffer";
this.ws.onopen = () => {
this.emit("open");
};
this.ws.onmessage = (event) => this.handleMessage(event.data);
this.ws.onclose = () => {
this.emit("disconnect");
console.log("socket连接已断开,尝试重连...");
setTimeout(() => this.connect(url), this.reconnectInterval);
};
this.ws.onerror = (e) => {
console.error("WebSocket 错误:", e);
this.emit("error", e);
};
}
sendMessage(buffer: Uint8Array | ArrayBuffer) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(buffer);
}
}
private handleMessage(data: ArrayBuffer) {
if (data.byteLength === 2) {
this.emit("disconnect", data);
} else {
this.emit("message", data);
}
}
on(event: string, callback: EventCallback) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(callback);
}
off(event: string, callback: EventCallback) {
if (this.listeners[event]) {
this.listeners[event] = this.listeners[event].filter(
(cb) => cb !== callback
);
}
}
private emit(event: string, data?: unknown) {
if (this.listeners[event]) {
this.listeners[event].forEach((cb) => cb(data as ArrayBuffer));
}
}
}
socket后续可优化点
- 完全使用二进制数据,需要添加上socket事件的字节,我当前的socket事件很少,通过字节长度的判断就够了。如果你有很多事件的话,最好在协议中添加一个字节用来存放socket事件,然后在封装的socket中解码协议拿到相关的事件,再将消息传递给事件对应的callback函数。
- 可以实现模拟正常的api请求和返回功能。
- 实现socket单例化
服务端驱动的人物组件
我们之前由服务数据驱动的组件,基本是将自己本地的玩家角色组件修改了下使用,它的好处在于保留了物理效果,如果后续有相关处理的话,该组件上有大量的集成功能。坏处就是额外的性能开销。
我们保留这个组件,开发一个新的仅仅用于视觉展示的组件,它没有任何物理效果,仅仅由服务端传来的数据进行驱动。
它是一个纯组件,接受3个参数:位置、旋转和状态。只有当位置、旋转或状态改变时才会更新组件。
这里的模型资源是克隆的,react-three-fiber不会自动管理,因为不是从useGLTF
上获取的,所以要手动执行资源回收。
const Actor = memo(
function Actor({ position, status, rotation }: UserStatus) {
const { scene, animations } = useGLTF(CH_PATH);
const clone = useMemo(() => SkeletonUtils.clone(scene), [scene]);
const { ref, actions } = useAnimations(animations);
const groupRef = useRef<THREE.Group>(null);
useAction(actions, status); // 实现的动画播放的hook
useDispose(clone) // 手动回收资源的hook
useFrame((_, delta) => {
if (!groupRef.current || delta > 0.1) return;
// 使用更平滑的插值方式
groupRef.current.position.lerp(position, 0.34);
groupRef.current.quaternion.slerp(rotation, 0.34);
});
return (
<group dispose={null} ref={groupRef}>
<primitive
ref={ref}
object={clone}
// 坐标中心在脚底的模型,下移半个身位
position={[0, -CHARACTER.height / 2, 0]}
scale={0.5}
/>
</group>
);
},
(prev, next) =>
prev.position.equals(next.position) &&
prev.rotation.equals(next.rotation)&&
prev.status === next.status
);
为了方便将来管理这种需要手动回收的资源,我们可以实现一个hook专门处理这种情况,具体实现可以去我的代码中看。
服务数据渲染远端玩家
这块和之前的逻辑一样,几乎没有变化,只是添加了解码的步骤。我们使用Map数据结构 用玩家的id作为键,记录和更新远端玩家的数据。
function Remotes() {
const [actors, setActors] = useState<Map<number, UserStatus>>(new Map());
useEffect(() => {
if (!socket) return;
socket.on(SocketEvents.MSG, handleMove);
socket.on(SocketEvents.OFF, handleOffline);
return () => {
socket.off(SocketEvents.MSG, handleMove);
socket.off(SocketEvents.OFF, handleOffline);
};
}, []);
function handleMove(buffer: ArrayBuffer) {
const { id, pos, quat, anim } = decodeMsg(buffer);
const status = PLAYER_STATUS[anim] as keyof typeof PLAYER_STATUS;
setActors((prev) => {
const newMap = new Map(prev);
const cur = newMap.get(id);
// 重用 Vector3 实例避免内存抖动
if (cur?.position) {
cur.position.copy(pos);
cur.rotation.copy(quat);
cur.status = status;
} else {
newMap.set(id, {
position: new THREE.Vector3(pos.x, pos.y, pos.z),
rotation: new THREE.Quaternion(quat.x, quat.y, quat.z, quat.w),
status,
});
}
return newMap;
});
}
function handleOffline(buffer: ArrayBuffer) {
const view = new DataView(buffer);
const id = view.getUint16(0, true);
setActors((prev) => {
const newMap = new Map(prev);
newMap.delete(id);
return newMap;
});
}
return Array.from(actors.entries()).map(([id, player]) => (
<Actor
key={id}
position={player.position}
status={player.status}
rotation={player.rotation}
/>
));
}
尾声
后记
之前为了压缩数据,我们在传递旋转信息时仅仅使用了y的分量,w是用公式: Math.sqrt(1 - quatY * quatY) }
计算,但是这个计算有一个小问题,那就是有该运算有根号,所以取值有±2种情况,因为没法确定正负,所以其在传递旋转时,当w为负数时,因计算问题,会导致旋转角度错误。
所以最后调整协议,把w分量也传递过来。二进制编码数据的长度在原来的基础上+1,同时为了平衡这一字节的开销,我将玩家id的字节压缩到了1字节(id取值在1-256之间)。所以后端不会支持超过256多个玩家同时在线,如果超过,就会踢人。
效果
源码地址
本次的代码在game分支中,如果想看到以前的海岛场景,需要切换路由到/island
🔗 源码地址
结语
最近看到丁真一夜而起的视频,甚为感慨,作诗一首,供诸位品鉴!
断壁垣中新风吹,蝉鸣须弥只余蜕。天下英雄岂我辈,夜风萧瑟落叶堆。