游戏中的数学 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;如果输入不满足单位合同,插值器直接继承错误,而不是负责修复所有上游状态。