跳转到内容

第 23 章 · 字节打包与增量同步

约 8 分钟 难度:4 动手章

进阶章。读到这里可以先去第五部,做完闭环 4 之后再回来。字节打包是「同步字段已经把 11 KB/s 撑满」之后的工具,不是默认能力。

这一章解决:一份「16 人战场」需要每位玩家 6 个状态位(武器选择 / 是否瞄准 / 是否蹲伏 / 是否在烟雾里 / 队伍 / 角色)。最朴素写法不会立刻撞 manual sync 的约 280 KB 单次上限,但会持续挤占 11 KB/s 级别的总预算,放在 continuous sync 上还会碰到约 200 bytes 的单次限制。这一章给四种最小压缩工具。

先看一下

[UdonSynced] bool flag 在网络层占 1 byte(不是 1 bit)。一个玩家对象上同步 6 个 bool 字段是 6 bytes。16 人是 96 bytes。听起来不多,但加上每位玩家的 Vector3 position(12 bytes)+ Quaternion rotation(16 bytes)+ int hp(4 bytes)+ int ammo(4 bytes)+ 其他几个状态,单玩家 50 bytes 左右。16 人 800 bytes。这只是一帧。每秒同步几次就开始压预算。

字节打包的几个常见手法把 6 个 bool 压到 6 bits(不到 1 byte),把枚举从 4 bytes(int)压到 1 byte 或 2 bits,把位置从精确同步降级到 12 位量化(4 bytes 装 X/Y)。

这一章会拿到什么

  • 四个最小压缩例子:bool 位掩码 / 枚举压缩 / 范围量化 / dirty mask
  • 每个例子的字节前后对比 + 落地代码片段
  • 「该不该压」的判断:先观测再优化
  • 增量同步(dirty mask)的最小落地

依赖前面

  • 第 6 章网络字节预算(200 bytes / 约 280 KB / 11 KB/s)
  • 第 4 章同步模式与序列化回调(manual sync 的 OnPreSerialization
  • 第 19 章状态 vs 命令五维度

写本章工具前先回到一个问题:真的需要压字节吗?

打开 Debug → Networking 面板(VRChat 内嵌 Debug View 6),看当前实例的 11 KB/s 级别总发送速率走到多少。空场景下接近 0,做了闭环 2 的世界跑一局接近 1–2 KB/s,复杂世界(16 人合作 PvE 满载)可能到 5–8 KB/s。没有接近预算时,通常不必压字节。

观测之后再决定压什么。常见误区是「我看到字段多就开始压」,结果代码可读性掉一档,性能问题没解决(瓶颈在别处,比如 continuous 同步被 manual sync 替代后字节量降一半,不需要压)。

判断顺序:

  1. 测当前的 KB/s。
  2. 如果接近预算,找最大头的同步字段(manual sync 的 OnPostSerialization 给字节数)。
  3. 看这一字段能不能用第 19 章的命令同步替代(高频改动用命令同步通常更省)。
  4. 还不行才上字节打包。

6 个 bool 字段共占 6 bytes。压成 1 个 int 的 6 个 bit 位占 4 bytes(实际只用 6 bit,但 int 是 4 bytes 单位)。压缩比 6:4。

// 朴素版(6 bytes)
[UdonSynced] private bool isAiming;
[UdonSynced] private bool isCrouching;
[UdonSynced] private bool inSmoke;
[UdonSynced] private bool isReloading;
[UdonSynced] private bool isUsingMelee;
[UdonSynced] private bool isStunned;
// 位掩码版(4 bytes)
[UdonSynced] private uint flags;
const uint F_AIMING = 1 << 0;
const uint F_CROUCHING = 1 << 1;
const uint F_IN_SMOKE = 1 << 2;
const uint F_RELOADING = 1 << 3;
const uint F_MELEE = 1 << 4;
const uint F_STUNNED = 1 << 5;
// 写
private void SetAiming(bool v) { if (v) flags |= F_AIMING; else flags &= ~F_AIMING; }
// 读
private bool IsAiming() => (flags & F_AIMING) != 0;

在 16 人场景下 6 bytes × 16 = 96 bytes 压成 4 bytes × 16 = 64 bytes,省 32 bytes。如果状态有 16 个 bool,压缩比变 16:4。

uint 装 32 个 bit,超过 32 个用 ulong(64 bit)。再多就拆成 uint flags0 + uint flags1,按业务分组(比如 flags0 战斗状态、flags1 社交状态)。


int 是 4 bytes。一个枚举只有 4 种取值时用 int 浪费。

// 朴素版(4 bytes)
[UdonSynced] private int weaponType; // 0=Pistol, 1=Rifle, 2=Shotgun, 3=Melee
// byte 版(1 bytes)
[UdonSynced] private byte weaponType;
// bit 位版(合并到 flags 里,0 bytes 单独占)
const uint F_WEAPON_MASK = 0b11 << 6; // 占 flags 的第 6、7 位
const uint F_WEAPON_SHIFT = 6;
private void SetWeaponType(byte v) { flags = (flags & ~F_WEAPON_MASK) | ((uint)(v & 0b11) << F_WEAPON_SHIFT); }
private byte GetWeaponType() => (byte)((flags >> F_WEAPON_SHIFT) & 0b11);

byte 版本 4:1。bit 版本把 weaponType 装进现成的 flags 字段,新增 0 bytes(只用了 flags 的两个空闲位)。

枚举值多于 256 时不能用 byte,但 256 在游戏里通常已经够用(256 种武器、256 个角色、256 个关卡 ID)。


Vector3 position 是 12 bytes(3 × float)。如果地图大小是 100 × 100 × 50 米,且玩家位置精度 1 厘米够用,每个轴的有效值范围是 10000 / 5000 / 5000 个离散位置,每个轴 14 bit 够装。三个轴总 42 bit,装在 uint(32 bit)+ ushort(16 bit)= 48 bit = 6 bytes。压缩比 12:6。

// 朴素版(12 bytes)
[UdonSynced] private Vector3 position;
// 量化版(6 bytes)
[UdonSynced] private uint positionXY; // 14 bit X + 14 bit Y + 4 bit padding
[UdonSynced] private ushort positionZ; // 14 bit Z + 2 bit padding
const float MAP_X = 100f, MAP_Y = 100f, MAP_Z = 50f;
const float QUANT = 0.01f; // 1 cm
private void EncodePosition(Vector3 p)
{
uint qx = (uint)Mathf.Clamp((p.x / QUANT), 0, 16383); // 14 bit max = 16383
uint qy = (uint)Mathf.Clamp((p.y / QUANT), 0, 16383);
positionXY = (qx & 0x3FFF) | ((qy & 0x3FFF) << 14);
positionZ = (ushort)Mathf.Clamp((p.z / QUANT), 0, 16383);
}
private Vector3 DecodePosition()
{
float x = (positionXY & 0x3FFF) * QUANT;
float y = ((positionXY >> 14) & 0x3FFF) * QUANT;
float z = positionZ * QUANT;
return new Vector3(x, y, z);
}

字节代价从 12 降到 6,精度从 float(实际有效 6–7 位有效数字)降到固定 1 cm。1 cm 对 FPS 命中判定通常够用,对手指级别精度(捏小物件、桌游摆放)不够用。

量化是有代价的取舍。本章不推荐默认用量化,只在测出位置同步占大头时用。日常用 VRC Object Sync(自身就有压缩)或者直接 Vector3 同步。


闭环 2 的 GameState 字段每次 RequestSerialization 都把一批字段作为同一次提交。业务层只知道「收到了一批新状态」,不知道这一批里哪几个字段是真正的语义变化。

dirty mask 把「哪几个字段变了」显式写进同步数据。它不等于让网络层只发这些字段,主要价值是让远端按变化项更新 UI、日志和局部逻辑:

[UdonSynced] private uint dirtyMask;
const uint D_PHASE = 1 << 0;
const uint D_TOTAL_SCORE = 1 << 1;
const uint D_PHASE_TIME = 1 << 2;
const uint D_STATE_VERSION = 1 << 3;
// Owner 改字段时
private void SetPhase(byte v)
{
phase = v;
dirtyMask |= D_PHASE;
}
private void OnPreSerialization()
{
stateVersion += 1;
dirtyMask |= D_STATE_VERSION;
}
private void OnPostSerialization(SerializationResult result)
{
if (result.success) dirtyMask = 0; // 清零
}
// 远端
public override void OnDeserialization()
{
if ((dirtyMask & D_TOTAL_SCORE) != 0) onScoreChanged?.Invoke();
// ...
}

dirty mask 的两个用途:

  1. 远端选择性更新 UI。哪一项变了就刷哪一项的 UI,不重渲染整个面板。性能优化(特别是 UI 用 TextMeshPro 等开销高的组件时)。
  2. 配合状态版本号做局部 gating。第 20 章 stateVersion 的 gating 是整批应用,dirty mask 让客户端能区分「这一批里只有 totalScore 变了」,做更精细的反应。

dirty mask 本身是 4 bytes。在字段数 < 32 的 GameState 上字节代价很小,CPU 代价也很小(位运算)。真正的收益通常不是少发 4 bytes,而是远端少做无意义 UI 刷新、日志记录和局部重算。


这四个工具都让代码读起来更累。bool 看不出来是 bool(藏在 uint 里),枚举值要解码,位置要量化反量化。可读性是真实代价。

判断该压谁:

  1. 撞预算的字段才压。前面观测已经定位最大头。
  2. 多人乘倍的字段优先压。一人 1 byte 在 16 人场景下变 16 bytes,压成 1 bit 直接降 8 倍。
  3. 变化频繁的字段才压。一帧改一次的字段比一局改一次的字段更值得压。
  4. 业务稳定的字段才压。位掩码定下后,加新字段要重排 bit 位,业务还在变就先用 bool

把整个 GameState 都压成位掩码 + 量化,每改一个字段都要回头算 bit 位,调试时还要解码才能看,是常见的过度优化反例。


挑一条试。

  • 把闭环 2 的 phasebyte,0/1/2 三态)压进 flags 的两个 bit 位。原地省了多少字节?这一改值不值?
  • 设计一个同步字段:每位玩家在战场上的「最近被击中过的部位」(头 / 胸 / 腹 / 左臂 / 右臂 / 左腿 / 右腿,共 7 种)。用 byte、用 3 bit、用一个 byte 装两位玩家的部位。三种写法各占多少字节?哪一种调试最方便?
  • 跑一次闭环 2 的 5 分钟比赛,开 Debug View 6 看 KB/s 走到多少。把闭环 2 切到 manual sync 看变化。本章工具有没有必要?

场景预期实际
1. bool 位掩码读写一致性6 个 Set / Is 跑过来不串味
2. 枚举压缩跨 4 种值4 个枚举值各自能编码 / 解码
3. 量化位置 1 米误差量化前后差异 ≤ 0.01 m
4. dirty mask 单字段变化只有 D_PHASE 被置 1,远端只刷 phase UI
5. 多客户端 Build & Test字节数(看 OnPostSerialization)相比朴素版降 30% 以上

第 5 行的「30% 以上」是这一章工具值不值的判断线。压完只省 5%(3–4 KB/s 预算从 3.0 降到 2.85),可读性下降的代价更大。


  • 能默写「先观测再优化」的四步顺序
  • 能识别四个工具各自适合的字段类型
  • 能解释为什么不要把整个 GameState 都压成位掩码
  • 能在「16 人场景」下选出哪一类字段最值得压
  • 能说出本章工具不能解决的问题(需要 varint / zstd 时怎么办)