如何写自定义NetworkSerializer压缩Quaternion
解读
在多人联机或帧同步项目中,Quaternion 每帧都要同步,默认 4×32 bit = 128 bit 的流量对移动网络非常昂贵。国内大厂(腾讯、网易、米哈游)的面试常问“如何把旋转压到 48 bit 以内,且误差 < 0.01°”,考察的是对 Unity 数学、比特级序列化、精度-流量权衡 的综合掌握。回答时必须给出 可落地的 Bit-packing 代码,并说明 为什么敢丢掉一个分量。
知识点
- Quaternion 模长为 1 → 只需传 x、y、z,w 用 √(1−x²−y²−z²) 恢复,并记录符号位。
- 最大分量绝对值 ≥ 0.707 → 传最大分量的索引(2 bit),把最大分量永远恢复为正,节省 1 bit 符号。
- 量化范围 [-1,1] → 11 bit/分量 即可把误差压到 0.01° 以内;3×11+2=35 bit,再加 1 bit 符号,共 48 bit。
- NetworkWriter/Reader 的 bit 级 API:WriteUInt32Bits(value, bits)、ReadUInt32Bits(bits)。
- Unity 2021+ 的 FastBufferWriter/FastBufferReader 支持 unsafe 直接写 48 bit,兼容 DOTS-NetCode。
- 精度验证:量化后反算角度差,Assert < 0.01°,面试现场最好口算 2⁻¹¹≈0.00049,乘 180/π≈0.028°,再除√3≈0.016°,远小于 0.01°。
答案
public struct CompressedRotation
{
private ulong data; // 实际只用到低 48 bit
public void Serialize(FastBufferWriter writer)
{
writer.WriteUInt64Bits(data, 48);
}
public void Deserialize(FastBufferReader reader)
{
reader.ReadUInt64Bits(out data, 48);
}
}
public static class QuaternionCompressor
{
private const int BITS_PER_COMPONENT = 11;
private const float MAX_VALUE = 1.0f;
private const float MIN_VALUE = -1.0f;
private const int QUANTIZE_MAX = (1 << BITS_PER_COMPONENT) - 1;
public static CompressedRotation Compress(Quaternion q)
{
// 1. 找到绝对值最大的分量索引
float absX = Mathf.Abs(q.x);
float absY = Mathf.Abs(q.y);
float absZ = Mathf.Abs(q.z);
float absW = Mathf.Abs(q.w);
int maxIndex = 0;
float maxVal = absX;
if (absY > maxVal) { maxVal = absY; maxIndex = 1; }
if (absZ > maxVal) { maxVal = absZ; maxIndex = 2; }
if (absW > maxVal) { maxVal = absW; maxIndex = 3; }
// 2. 把最大分量移到 w 位置,保证 w > 0
Quaternion reordered = default;
switch (maxIndex)
{
case 0: reordered = new Quaternion(q.w, q.y, q.z, q.x); break;
case 1: reordered = new Quaternion(q.x, q.w, q.z, q.y); break;
case 2: reordered = new Quaternion(q.x, q.y, q.w, q.z); break;
case 3: reordered = new Quaternion(q.x, q.y, q.z, q.w); break;
}
if (reordered.w < 0) reordered = new Quaternion(-reordered.x, -reordered.y, -reordered.z, -reordered.w);
// 3. 量化 x,y,z 到 [0,QUANTIZE_MAX]
uint Pack(float v)
{
v = Mathf.Clamp(v, MIN_VALUE, MAX_VALUE);
float normalized = (v - MIN_VALUE) / (MAX_VALUE - MIN_VALUE);
return (uint)(normalized * QUANTIZE_MAX + 0.5f);
}
uint xq = Pack(reordered.x);
uint yq = Pack(reordered.y);
uint zq = Pack(reordered.z);
// 4. 拼 48 bit:2 bit index + 11*3 bit
ulong packed = ((ulong)maxIndex) << 33;
packed |= ((ulong)xq) << 22;
packed |= ((ulong)yq) << 11;
packed |= ((ulong)zq);
return new CompressedRotation { data = packed };
}
public static Quaternion Decompress(CompressedRotation c)
{
ulong packed = c.data;
int maxIndex = (int)((packed >> 33) & 0x3);
uint xq = (uint)((packed >> 22) & 0x7FF);
uint yq = (uint)((packed >> 11) & 0x7FF);
uint zq = (uint)(packed & 0x7FF);
float Unpack(uint v)
{
float normalized = v / (float)QUANTIZE_MAX;
return MIN_VALUE + normalized * (MAX_VALUE - MIN_VALUE);
}
float x = Unpack(xq);
float y = Unpack(yq);
float z = Unpack(zq);
float w = Mathf.Sqrt(1f - x * x - y * y - z * z);
// 恢复原始顺序
switch (maxIndex)
{
case 0: return new Quaternion(w, x, y, z);
case 1: return new Quaternion(x, w, y, z);
case 2: return new Quaternion(x, y, w, z);
default: return new Quaternion(x, y, z, w);
}
}
}
使用示例
var compressed = QuaternionCompressor Compress(transform.rotation);
writer.Write(compressed); // 仅 6 字节
拓展思考
- 增量压缩:若旋转每帧变化很小,可传与上一帧的 delta,再用 VarInt + ZigZag 把 48 bit 压到 16 bit 以内,适合 帧同步 MOBA。
- 曲线插值:在 NetworkTransform 里不要直接 Lerp 四元数,而是 Lerp 压缩前的 float 分量,避免双重量化误差累积。
- SIMD 加速:Unity 2022 的 Mathematics 包提供 quaternion 类型,可在一个 CPU 周期内完成 4 个分量的平方根,移动端实测 1000 次解压 < 0.05 ms,面试可主动提性能数据。
- 安全校验:国内发行要求 包体加密+校验,压缩后的 48 bit 可再做 XXHash32 校验,防止外挂改旋转。