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

游戏中的数学 06:四元数旋转与单位模约束

上一章已经固定可逆 Transform 的组合顺序与层级所有权,本章仅替换其中的旋转表示。角色骨骼、相机和网络姿态都会反复组合旋转,其核心不变量是 |q|=1,而非四个分量分别满足经验区间。模长漂移会同时破坏旋转矩阵、姿态插值和网络压缩的前置条件。验证采用两条 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));
}

运行条件

数值证据以可复算为前提。入口固定区域文化并显式接收输出目录,避免小数点、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));

归一化方法

四元数乘法按既定顺序组合局部旋转,浮点舍入会使结果逐步偏离单位球面。因此稳定链在每次写回共享姿态前执行归一化。该操作用于恢复代数不变量,而非视觉平滑;进入下一 Tick 的结果要求分量有限、模长非零且已经单位化,乘法次序仍由调用方明确指定。

动画采样器、物理姿态和网络解码器都可能写入四元数。修正位于写边界,避免每个读取者重复处理:依次检查分量有限性、模长可归一化性,并仅发布单位结果。异常由此停止在所有权边界,而不会延迟到渲染矩阵产生意外缩放时才暴露。

// 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
}

实验结果

断言、JSON 和图均来自同一组 10,000 次旋转组合。raw_norm_errornormalized_norm_error 用于判断归一化是否满足误差合同;运行时间不参与算法结论。

$ 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=148.7
exit_code=0

表示与代价

每步归一化增加一次平方根计算,同时提供明确的不变量和故障位置。高频骨骼批处理可以在基准测量后改为每 N 次归一化,但 N 由模长与角度误差预算推导。矩阵负责将旋转、位移和缩放合成为顶点变换;四元数负责保存与组合纯旋转,两种表示承担不同职责。

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

插值前提

写回前归一化为下一章的姿态插值提供前置条件:插值器只处理两个合法端点与参数 t。Nlerp 和 Slerp 不承担上游状态修复;单位合同不成立时,误差会继续传播。