游戏中的数学 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_error 与 normalized_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 不承担上游状态修复;单位合同不成立时,误差会继续传播。