四元数只传三个分量:省略最大项才能安全开平方
角色旋转每帧都随快照发送时,四个 32 位浮点数很快变成固定带宽。直接删掉 W 看似能省四分之一,接近 180 度旋转时却要从 sqrt(1-x²-y²-z²) 恢复一个接近零的数;三个量化误差相加后,根号内部可能越过零,最脆弱的分量恰好被选来重建。
更稳妥的做法是每次省略绝对值最大的分量。单位四元数至少有一项绝对值不小于 0.5,剩余三项必在 [-1/√2, 1/√2]。索引用一个字节,三个保留项各用一个有符号 16 位整数,固定记录共七字节。本文用独立 C# 探针验证编码、重建、双覆盖确定性和量化误差,不把它包装成实际网络吞吐测试。

先消除 q 与负 q 的二义性
四元数 q 与 -q 表示同一旋转。若编码器不先统一符号,同一个姿态可能因上游插值或归一化路径不同而产生两份字节序列,快照差分、状态哈希和回放比较都会看到无意义的变化。
探针找到绝对值最大的分量后,若该项为负,就把四项一起取反。被省略项因此恒为非负,解码时只需取正平方根,不再额外传一个符号位。固定输入归一化后为 (0.3531,-0.2018,0.1009,-0.9080),最大项是 W;整体翻转后,编码结果为索引 3 和三个整数 (-16363,9350,-4675)。原输入与整体取负后的输入得到完全相同的 PackedRotation。

QuaternionCompression.cs 的 Encode → Quantize 与 Decode 是完整编解码链。范围缩放使用 short.MaxValue / (1/√2);解码将三个分量放回原位置,再由单位长度约束恢复被省略项:
using System.Numerics;
public readonly record struct PackedRotation(byte LargestIndex, short A, short B, short C);
public static class QuaternionCompression
{
private const float Range = 0.7071067811865476f;
private const float Scale = short.MaxValue / Range;
public static PackedRotation Encode(Quaternion value)
{
if (value.LengthSquared() < 1e-12f) throw new ArgumentException("四元数不能为零", nameof(value));
Quaternion q = Quaternion.Normalize(value);
float[] v = [q.X, q.Y, q.Z, q.W];
int largest = 0;
for (int i = 1; i < 4; i++) if (MathF.Abs(v[i]) > MathF.Abs(v[largest])) largest = i;
if (v[largest] < 0f) for (int i = 0; i < 4; i++) v[i] = -v[i];
short[] kept = new short[3];
for (int src = 0, dst = 0; src < 4; src++)
if (src != largest) kept[dst++] = Quantize(v[src]);
return new((byte)largest, kept[0], kept[1], kept[2]);
}
public static Quaternion Decode(PackedRotation packed)
{
if (packed.LargestIndex > 3) throw new ArgumentOutOfRangeException(nameof(packed));
short[] encoded = [packed.A, packed.B, packed.C];
float[] v = new float[4];
float sum = 0f;
for (int dst = 0, src = 0; dst < 4; dst++)
if (dst != packed.LargestIndex) { float x = encoded[src++] / Scale; v[dst] = x; sum += x * x; }
v[packed.LargestIndex] = MathF.Sqrt(MathF.Max(0f, 1f - sum));
return Quaternion.Normalize(new(v[0], v[1], v[2], v[3]));
}
private static short Quantize(float value) =>
(short)Math.Clamp(MathF.Round(value * Scale), short.MinValue, short.MaxValue);
}
解码中的 max(0, 1-sum) 是量化边界保护。它只吸收最后几个量化单位可能造成的轻微越界,不能替代输入校验;编码前仍拒绝零四元数并执行归一化,非法索引也直接报错。若协议允许任意损坏数据进入这里,还需要在包解析层校验三个整数是否落在编码器可产生的范围内。
固定记录长度不等于可以省略协议定义
当前 PackedRotation 在内存里的布局不等于网络布局。七字节结论来自显式字段合同:一个最大项索引,加三个小端 int16。实际序列化必须逐字段写入,不能直接复制结构体内存,因为运行时可能插入填充字节,也可能采用不同端序。索引只需要两位,但这里保留一整字节,让记录边界清楚;需要继续压缩时,可把索引并入相邻标志位,但必须同步修改版本和位序测试。
QuaternionProgram.Main 检查固定输入、单位旋转、零输入、非法索引,并用种子 20260920 生成十万个归一化四元数。每个样本同时验证 q 与 -q 的编码完全一致,再以 2*acos(abs(dot(q,decoded))) 计算角误差。当天在 .NET 9 Release 下执行:
dotnet build "$LAB_DIR/QuaternionCompression.csproj" -c Release
dotnet run --project "$LAB_DIR/QuaternionCompression.csproj" -c Release --no-build
构建和运行退出码均为 0,构建零警告、零错误。决定性输出为:
passed=8 failed=0 skipped=0 elapsedMs=201.0399
samples=100000 bytes=7
largestIndex=3 packed=(-16363,9350,-4675)
fixedErrorDegrees=0 maxErrorDegrees=0.06852804
这组最大误差只描述指定伪随机样本、当前浮点实现和三个 16 位量化字段,不是所有旋转的形式化上界。它足以锁住实现回归:改动范围、舍入、符号统一或重建顺序后,确定性与误差断言会立即重新计算。若项目需要严格上界,应对量化单元传播做解析推导,并加入覆盖分量边界与等大分量的构造样本。
省下的九字节要由版本合同守住
相对四个 float 的 16 字节记录,这个布局每个旋转少九字节;每帧对象数与发送频率可以据此复算原始载荷差额,但本文没有测量包头、压缩、丢包重传或 CPU 成本,因此不宣称端到端带宽或性能收益。发送端和接收端还必须固定归一化规则、最大项并列时选择较小索引、舍入方式、端序与协议版本。任一项漂移,都可能让相同姿态产生不同字节。
开头那种直接删除 W 的方案把最不稳定的分量留给平方根。省略绝对值最大项后,被恢复值始终远离零;统一半球又让同一旋转只有一种编码。七字节并不是四元数压缩的唯一选择,但在需要固定长度、可回放和可逐字段审计的游戏快照中,它提供了清楚的误差与确定性边界。若后续要降到更少位数,应先保留这里的固定样本和双覆盖断言,再改变量化宽度,而不是同时更换布局与比较规则。