(3/3) DOOM3 网络架构
本文是系列的第三篇:
本文绝大部分为较简短的记录,进一步的描述请参考原文。
架构
客户端把输入采样等玩家动作发给服务器,服务器回之以 PVS 内的压缩后的状态快照。
C/S 架构图
Doom3 做到了同样的玩家输入序列总是能产生同样的结果,因为以下两点得到了保证:
- 除玩家输入外整个系统的确定性 (system-wide deterministic)
- 不管渲染性能如何,整个游戏的逻辑状态总是以 60 fps 的频率更新
C/S 时间线
服务器以 10-20 Hz 的频率向客户端发状态快照。由于快照是一个 rtt 之前的状态,客户端需要回到那个时间点上去处理这个“过去的”状态,然后再基于这个状态重新预测并刷新所有物体在当下的状态,如下图:
预测示意图 (Prediction at the client with a snapshot rate at 20Hz and a ping of around 80 milliseconds)
由于玩家输入的频率 (input per second) 远低于逻辑处理的频率 (60 Hz),一个合理的推论是,最接近当下的几个逻辑帧,继续沿用与之前同样的输入一般是安全的。客户端使用服务器同步过来的其他玩家的输入来预测其接下来的运动,这些物理响应的机制与服务器上的真实逻辑是一致的。
与 Quake 3 不同的是,玩家在屏幕上看到的渲染结果与真实的逻辑状态是无时差的 (注意是无时差而不是 100% 绝对准确),因此不需要像 Q3 那样在本地延时比较大时需要充分考虑提前量,因为系统把下发同步的预测也完全实现了。系统的确定性保证了服务器和客户端可以运行完全一致的逻辑 (dead reckoning),因此得到至少与服务器上一样好的行为预测结果。Quake 3 的 bot 已经展示了通过算法来预测玩家移动可以达到什么样的程度,即使用慢速导弹武器 (火箭筒 RL) 也可以非常精确地命中。(Q3 bot 使用考虑碰撞检测的简化物理逻辑来预测玩家在之后的位置)
与 Quake 3 不同,Doom 3 的服务器和客户端使用同一份代码来更新/预测实体的状态,这样不用担心早先提到的互相干扰,开发新的单人模式 (并兼容多人) 也变得更简单了。
通信
基于 UDP 的轻量级 reliable / unreliable 实现 (最小化额外负担)
对于大多数状态同步而言,像 TCP 那样重发价值不大,因为被重发的状态十有八九因为过期已经不再有意义。
Doom 3 实现了下面这样一个基于 UDP 特性的 FPS 通信架构
层次结构
上行和下行均为单连接,同时可发送 reliable & unreliable 的消息 (前者确保抵达),后者用于输入 (c2s) 和状态 (s2c) 的同步,只有非常特定和关键的消息使用可靠方式发送。
这个网络系统被设计为不间断地生成一个不可靠消息流 (unreliable stream) (包括 10-20Hz 的状态同步和更高频的输入同步),可靠消息被驼运 (piggy back) 在这个不可靠消息流上 (蚂蚁搬家)。具体实现上,可靠消息被先缓存在队列里,每一个都由一个不可靠消息搭载着发出,ack 后再发下一个 (ack 直接借用了对面过来的 unreliable stream) 这样整个信道实现了最重要的保证:(通过1:1的驼载)任何一条可靠消息总是能在首个紧接着的不可靠消息之前抵达。 (the message channel guarantees that a reliable message arrives before the first next unreliable messages comes through)
此外,对于不可靠的信息流,客户端的发送频率比服务器高3-4倍 (可靠消息的运输和响应能力),这样的话来自服务器的可靠消息是不需要 timeout 机制的,因为接下来的几个客户端消息没有 ack 的话,服务器就可以直接重发了。
Unreliable Message Headers
整个系统的大部分信息是来自服务器的状态快照 (Snapshots) 和来自客户端的玩家输入 (User Commands),这些业务数据都通过 unreliable message 传递。(message header 如下图所示)
服务器:
- 32 位 game id 里包含了游戏本身的识别 id,地图信息和关键的业务设置
- 8 位的 message type 用来区分本条消息的类型。
客户端:
- 首个 seq id 是最近收到的服务器消息的序列号 (用于 ack),unreliable message 本身是不需要 ack 的,但是当需要的时候,服务器可以在特定的时间点上用这个 seq id 检查客户端是否有及时的反馈。
- game id 用于环境的合法性校验,没通过校验的话,服务器会追加一条完全配置信息,用于指导客户端去尝试进入正确的环境。
- 快照的 seq id 用于差异压缩 (delta compression)
- 同样也有 message type。
快照 (snapshots)
下图是下发快照的构成和完整的操作序列:
快照包含的几项关键信息:
- 序列号 (seq id)
- 帧编号 (frame id)
- 帧时刻 (frame time)
- 客户端领先的时间量 (client ahead time, 参考客户端最近一次发上来的时刻及延时)
实际的业务数据信息 (以下信息均做了差异压缩):
- entity states 是与上次快照相比较的状态变化
- pvs bit string 是 pvs 的完整可见状态列表 (这个信息由服务器随时下发更新)
- pvs 无关的游戏状态更新
- 其他玩家的指令信息
用户指令 (User Commands)
下图是上行的用户指令构成和完整的操作序列:
- 调试用的客户端预测毫秒数
- 这一组用户指令中,第一个的所在帧编号
- 后续的每个 user command 对应接下来的一帧,反映了输入的变化差异
压缩
Bit Packing
- 移除逻辑上的无用位。
- 如 Health (HP) 虽然是 32 位整形,但实际在 0-100之间,只需 7 位就够了。
- 浮点精度大部分取值范围不大的情况下 (如实体的移动速度,角度,朝向等) 只需要半精度。
差异压缩 (Delta Compression)
- 变量级的差异压缩。如果一个变量没变过,就写一个 0 (1 bits) 如果变过,就写 1 (1 bits) + 实际变量内容 (bit packed)
- 实体级的差异压缩。
- 快照之间的差异比较
- 基于一个包含完全实体信息的公共基 (common base)
- 当进出某个客户端的 pvs 时开始/结束同步
- pvs 差异压缩。
- 每个实体 1 bit 则 4096 个完整信息会消耗 512 字节
- 由于不同帧之间 pvs 变化不大,可以按组压缩,每组 32 bits
- 如果任何一组没有实体进出 pvs,写个 0 (1 bits)
- 假设 pvs 没有任何变化,4096 个对象只需要 16 字节
客户端随着 User Commands 上报的 ack 频率远高于下发快照的频率,所以丢包也没关系。服务器一旦收到 ack 就可以更新公共基并用 reliable message 通知客户端做同样的改动,驼运机制保证了 reliable message 总是先于新快照抵达客户端,这样被 ack 的快照总是能在处理新快照前被用于更新客户端的公共基。这样,公共基的状态维护就可以保证是整体上同步的
消息压缩 (0-compressor)
上面的差异压缩会产生大量的 0 (没有变化),所以开销最小也最有效的压缩是针对 0 的特殊处理。
每次处理 3 位,如果中间有一位不为 0 就保持不变,否则继续读,直到遇到不为零的情况,此时写下三个零 (3 bits) 和重复次数 (3 bits)
最大压缩比为 4:1,这里可以用不同的位数但 3 被验证为实际压缩比最高的。
举个例子:
000'000'000'010'000'000'000'000'110'000'000'000'000'000
会被压缩为
000'011'010'000'100'110'000'101
这个例子里压缩比为 14:8。
反过来也可以针对这种压缩方式对快照中的变量排列进行优化。把变量按照改变频率分组放在一起,以促使产生更多的连续 0。
效果
- bit packing: 10-15%
- delta compression: 90%+
- zero-compressing: 15-50%
更多的潜在改进
- 快照的公共基是从空状态开始的,实际上对于任何一个已加载地图,可以从一个已完全初始化的状态开始,避免一上来的流量开销
- 一些使用 reliable 的事件只要不影响游戏的逻辑进行 (如特效,光照等) 可以改成 unreliable 并缓存一下
- 一些本来是同步过来的实体本质上只是游戏逻辑的衍生,是可以由客户端自行维护的
- 客户端的预测可以改得更加细粒度
- 开发者应可更容易指定哪些不需要预测 (直接使用快照的插值)
- 可以随时关掉某个实体的同步 (比如挂了的怪物) 纯粹由客户端接管
- 可以把所有的实体以同样的频率更新加以改进,让那些不那么重要的实体以较低的频率更新 (LOD-syncing)
- 对于不重要的实体,客户端的多帧预测往往可以合并为较少的较大帧 (降低运算量)
[系列完]
(全文完)