threejs 实现3D游戏(13) —— 高效多人同屏:数据压缩

0 阅读10分钟

概述

上次我们实现多人同屏的功能时,主要实现了一个大的框架,仅仅同步了玩家的位置(通过本地运算2次位置的差值来获取方向、判断玩家动画)。

这种最简实现是为了尽快的熟悉同步操作的流程,这次我们正式来优化这块逻辑。

这次不仅要同步玩家的位置、状态、旋转方向,还要根据同步数据来设计二进制压缩协议来对数据进行压缩,同时对同步数据驱动的显示其他玩家的组件进行重构。

回顾

我们使用 react-three-fiber 搭建了整体框架。
使用 @react-three/rapier 实现物理效果。
使用 ecctrl 库进行人物控制。

使用socket来进行数据同步,后端使用nodejs来对socket进行消息转发具体看这篇多人同屏

image.png

获取玩家的四元数

使用ecctrl库的矛盾点

该库有可以方便实现玩家控制,但当只有使用autoBalance时,才能获得玩家的旋转信息,否则获取不到。但是其自动平衡实现有问题:很容易受到环境影响,导致人物疯狂旋转,截至发文为止,它的最新版本中疯狂旋转有所好转,但实测偶尔会触发方向失调。所以以防万一关闭autoBalance

必须找到在autoBalance关闭的情况下获取人物旋转的方法。

查看ecctrl源代码

查看ecctrl库的源代码,发现它在autoBalance打开时旋转的是RigidBody,关闭时直接旋转的人物模型。

image.png 如上图,图中的characterRef绑定的是RigidBody,这就是为什么当autoBalance关闭时无法获取旋转,因为它直接禁用了旋转。

image.png 上图中,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,π]。

3D_Spherical.svg

注意:球坐标只能表示方向,无法描述绕该方向的旋转角度。
它定义了一个指向方向的向量,但没有描述绕这个方向的旋转角度。

当你的角色只需要同步朝向(如面向目标)时,可以只同步方向(球坐标)。

空间旋转与四元数

为了表示一个物体在三维空间中的完整旋转,我们需要描述三个要素:

  1. 旋转轴(方向) :可以使用球坐标描述。
  2. 旋转角度:绕旋转轴旋转的角度。
  3. 旋转顺序:旋转是依次应用的,这在欧拉角中尤为重要。

v2-5480232b0c74e8d236044529bd170834_b.webp

一种高效表示旋转的方式是四元数

  • 四元数 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 };
}

image.png

去除冗余依赖

我之前使用的是socket.io的库,可是这个库有个问题,当传递二进制数据时它需要一个占位消息用于携带二进制数据,猜测应该和它的事件监听功能有关。这个占位消息长度是45B是我们的消息体的近6倍。

image.png

我反复尝试也不能去掉这个占位消息,所以直接去除了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后续可优化点

  1. 完全使用二进制数据,需要添加上socket事件的字节,我当前的socket事件很少,通过字节长度的判断就够了。如果你有很多事件的话,最好在协议中添加一个字节用来存放socket事件,然后在封装的socket中解码协议拿到相关的事件,再将消息传递给事件对应的callback函数。
  2. 可以实现模拟正常的api请求和返回功能。
  3. 实现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多个玩家同时在线,如果超过,就会踢人。

效果

压缩.gif

源码地址

本次的代码在game分支中,如果想看到以前的海岛场景,需要切换路由到/island

🔗 源码地址

结语

最近看到丁真一夜而起的视频,甚为感慨,作诗一首,供诸位品鉴!

断壁垣中新风吹,蝉鸣须弥只余蜕。
天下英雄岂我辈,夜风萧瑟落叶堆。