本文属于系列「游戏中的数学:从坐标到可同步运动」第 6 / 15 篇

游戏中的数学 06:四元数只负责旋转

角色骨骼、相机和网络姿态都要反复组合旋转。真正需要守住的不是四个分量分别落在什么范围,而是组合后的四元数仍位于单位四维球面;一旦模长漂移,后续旋转、插值和压缩都失去共同前提。实验先固定轴角输入,再让两条状态链各组合 10,000 次:一条保留原值,一条每步归一化。

// $WORK_DIR/.tmp/game-math-lab/Program.cs · Chapter06
var axis = Vector3.Normalize(new Vector3(1, 2, 3));
var radians = (float)DegToRad(0.05);
var step = Quaternion.CreateFromAxisAngle(axis, radians);

var raw = Quaternion.Identity;
var stable = Quaternion.Identity;

for (var i = 1; i <= 10_000; i++)
{
    raw = Quaternion.Multiply(raw, step);
    stable = Quaternion.Normalize(
        Quaternion.Multiply(stable, step));
}

运行入口

实验使用同一个无引擎依赖的 .NET 入口,固定不变区域文化并显式指定输出目录。这样数值的小数点、JSON 和 SVG 数据不会随机器区域设置改变。

<!-- .tmp/game-math-lab/GameMathLab.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>
</Project>
// Program.cs · 进程入口
// 固定区域文化后再创建实验对象。
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;

var outputRoot = Path.GetFullPath(
    args.Length == 2 && args[0] == "--out"
        ? args[1]
        : "out");

Directory.CreateDirectory(outputRoot);
var lab = new GameMathLab(outputRoot);
return lab.Run();

旋转合同

轴角到单位四元数的映射是

[ q=\left(\hat a_x\sin\frac{\theta}{2}, \hat a_y\sin\frac{\theta}{2}, \hat a_z\sin\frac{\theta}{2}, \cos\frac{\theta}{2}\right),\quad \lVert q\rVert=1 ]

轴必须先归一化,角度统一用弧度。q 只表达旋转,不携带位置、缩放、父节点或时间;这些状态仍归 Transform 和运动系统所有。这样网络层可以单独量化姿态,渲染层也能在不触碰逻辑位置的情况下插值旋转。

轴角到单位四元数的推导图

// Program.cs · Chapter06:每 100 次采样模长误差
var drift = new List<P>();
for (var i = 1; i <= 10_000; i++)
{
    raw = Quaternion.Multiply(raw, step);
    stable = Quaternion.Normalize(
        Quaternion.Multiply(stable, step));

    if (i % 100 == 0)
        drift.Add(new P(i, Math.Abs(1 - raw.Length())));
}

var rawNormError = Math.Abs(1 - raw.Length());
var stableNormError = Math.Abs(1 - stable.Length());
var expected = Quaternion.CreateFromAxisAngle(
    axis, (float)DegToRad(10_000 * .05));
var orientationError = 2 * Math.Acos(Math.Clamp(
    Math.Abs(Quaternion.Dot(
        Quaternion.Normalize(stable),
        Quaternion.Normalize(expected))), 0, 1));

正常路径

单位四元数的乘法负责按顺序组合局部旋转。浮点乘法会产生舍入误差,因此稳定链在每次写回状态前投影回单位球面。这里的归一化不是视觉平滑,而是恢复代数不变量。正常路径只允许有限分量、非零模长和单位化结果进入下一帧;旋转次序仍由调用方显式决定。

在引擎边界上,动画采样器、物理姿态和网络解码器都可能成为四元数的写入者。最省事的做法不是让每个读取者重复修正,而是在各自写入共享姿态前执行同一验证:先检查分量有限,再检查模长可归一化,最后只发布单位结果。错误因此停在所有权边界,不会等到渲染矩阵出现缩放后才被发现。

// Program.cs · Chapter06:回归断言与证据写出
Check(
    stableNormError < 1e-6,
    "renormalized quaternion stays unit length");

Check(
    Quaternion.Dot(stable, stable) > .999999,
    "unit quaternion dot invariant");

Check(
    orientationError < 1e-3,
    "composition matches analytic total rotation");

WriteData(6, new
{
    rotations = 10_000,
    step_degrees = .05,
    total_degrees = 500,
    raw_norm_error = rawNormError,
    normalized_norm_error = stableNormError,
    orientation_error_rad = orientationError
});

失败路径

失败样本不是“旋转看起来歪了一点”,而是模长合同已经破坏。未归一化链经过 10,000 次组合后误差达到 2.5498867e-4;它未触发崩溃,却会污染下一章以单位四元数为前提的角度计算。断言把这种静默退化变成明确失败。若输入含 NaN、无穷或零轴,生产边界还应在构造前拒绝,本章数据不替这些信任边界背书。

组合次数与四元数模长误差

{
  "rotations": 10000,
  "step_degrees": 0.05,
  "total_degrees": 500,
  "raw_norm_error": 0.00025498867,
  "normalized_norm_error": 0,
  "orientation_error_rad": 0
}

当天实测

命令运行同一可执行实验,15 章共用一个进程;本章计入一个通过项,没有跳过。总耗时用于证明数据来自同一次完整运行,不作为性能结论。

$ cd $WORK_DIR
$ dotnet run --project .tmp/game-math-lab/GameMathLab.csproj -- --out .tmp/game-math-lab/out

CH06 PASS rotations=10000 raw_norm_error=2.550E-004 normalized=0.000E+000 orientation_error=0.000E+000rad
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=112.8
exit_code=0

架构取舍

每步归一化多一次平方根,换来清晰的不变量和确定的故障位置;对高频骨骼批处理,可以测量后改为每 N 次归一化,但阈值必须由误差预算决定,不能凭感觉省掉。矩阵仍适合把旋转与位移、缩放合成一次顶点变换;四元数则适合保存与组合纯旋转。两者不是替代关系。

状态 表示 写回合同
纯旋转 单位四元数 `abs(1-
顶点变换 TRS 矩阵 保持既定乘法顺序
时间推进 Tick/参数 不写入四元数分量

下一章

本章交付的是“写回前已归一化”的姿态。下一章只在两个合法姿态之间讨论 t、Nlerp 和 Slerp;如果输入不满足单位合同,插值器直接继承错误,而不是负责修复所有上游状态。