第 23 章 · 字节打包与增量同步
进阶章。读到这里可以先去第五部,做完闭环 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 命令五维度
先观测再优化
Section titled “先观测再优化”写本章工具前先回到一个问题:真的需要压字节吗?
打开 Debug → Networking 面板(VRChat 内嵌 Debug View 6),看当前实例的 11 KB/s 级别总发送速率走到多少。空场景下接近 0,做了闭环 2 的世界跑一局接近 1–2 KB/s,复杂世界(16 人合作 PvE 满载)可能到 5–8 KB/s。没有接近预算时,通常不必压字节。
观测之后再决定压什么。常见误区是「我看到字段多就开始压」,结果代码可读性掉一档,性能问题没解决(瓶颈在别处,比如 continuous 同步被 manual sync 替代后字节量降一半,不需要压)。
判断顺序:
- 测当前的 KB/s。
- 如果接近预算,找最大头的同步字段(manual sync 的
OnPostSerialization给字节数)。 - 看这一字段能不能用第 19 章的命令同步替代(高频改动用命令同步通常更省)。
- 还不行才上字节打包。
例子 1:bool 位掩码
Section titled “例子 1:bool 位掩码”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 社交状态)。
例子 2:枚举压缩
Section titled “例子 2:枚举压缩”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)。
例子 3:范围量化
Section titled “例子 3:范围量化”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 同步。
例子 4:dirty mask(增量同步)
Section titled “例子 4:dirty mask(增量同步)”闭环 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 的两个用途:
- 远端选择性更新 UI。哪一项变了就刷哪一项的 UI,不重渲染整个面板。性能优化(特别是 UI 用
TextMeshPro等开销高的组件时)。 - 配合状态版本号做局部 gating。第 20 章
stateVersion的 gating 是整批应用,dirty mask 让客户端能区分「这一批里只有totalScore变了」,做更精细的反应。
dirty mask 本身是 4 bytes。在字段数 < 32 的 GameState 上字节代价很小,CPU 代价也很小(位运算)。真正的收益通常不是少发 4 bytes,而是远端少做无意义 UI 刷新、日志记录和局部重算。
这四个工具都让代码读起来更累。bool 看不出来是 bool(藏在 uint 里),枚举值要解码,位置要量化反量化。可读性是真实代价。
判断该压谁:
- 撞预算的字段才压。前面观测已经定位最大头。
- 多人乘倍的字段优先压。一人 1 byte 在 16 人场景下变 16 bytes,压成 1 bit 直接降 8 倍。
- 变化频繁的字段才压。一帧改一次的字段比一局改一次的字段更值得压。
- 业务稳定的字段才压。位掩码定下后,加新字段要重排 bit 位,业务还在变就先用
bool。
把整个 GameState 都压成位掩码 + 量化,每改一个字段都要回头算 bit 位,调试时还要解码才能看,是常见的过度优化反例。
挑一条试。
- 把闭环 2 的
phase(byte,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时怎么办)
- VRChat Creator Docs · Network Specs and Tips — 字节预算、单包大小、speed limit。
- 第 6 章 · 网络字节预算 — 11 KB/s / 200 bytes / 约 280 KB 三个数字的来源。
- 第 19 章 · 状态同步还是命令同步 — 高频改动可能更适合命令同步。
- 第 22 章 · 命令模式与事件日志 — 命令 payload 多参时的字段切分。
- 第 24 章 · JSON、DataDictionary 与 DataList — 复杂结构数据如何选通道。
- 附录 · 术语表 —
Bit Packing/Range Quantization/Dirty Mask的客观定义。