点先旋转再平移,和先平移再旋转,为什么会落在不同位置?若把矩阵相乘的顺序写反,程序通常仍能编译,物体也可能仍在画面里。仅凭“看起来动了”无法判断几何变换正确。

取点 p=(1,0,0)p=(1,0,0),绕 Z 轴旋转 90°,再平移 (1,2,0)(1,2,0)。结果应为 (1,3,0)(1,3,0);反过来执行,结果是 (2,2,0)(-2,2,0)。这两个可手算的结果将成为累计代码的检查样本。第 00 篇检查图像数组,本篇检查进入渲染器之前的几何数据。

三个数字相同,含义未必相同

点表示位置,向量表示位移或方向。例如点 A 与点 B 的差 BAB-A 是从 A 指向 B 的位移;从 A 出发加上位移 v,得到点 A+vA+v。两个位置相加通常不是需要的几何操作,除非同时说明所取的仿射组合,例如中点 (A+B)/2(A+B)/2

坐标依赖参考系。选择原点 O 与三个基向量 ex,ey,eze_x,e_y,e_z 后,点的坐标 (x,y,z)(x,y,z) 表示:

p=O+xex+yey+zez.p=O+xe_x+ye_y+ze_z.

位移不含原点项。平移整个坐标参考原点会改变点的坐标,却不应把同一位移额外加上一段平移。用同一个三元数组保存二者并不自动保留这种区别,调用函数时仍须明确它是哪一种量。

本系列几何使用右手系,ex×ey=eze_x\times e_y=e_z。它与第 00 篇图像数组的 X 向右、Y 向下不是同一坐标空间;屏幕变换将在第 02 篇显式连接二者。这里只讨论正交单位基,不能把下面的坐标点积公式直接套到任意斜基坐标上。

几何长度暂用统一的场景单位。方向归一化后无量纲,旋转角在程序接口中用弧度。把角度数值 90 直接传入 std::sin 不会得到 90° 的正弦,因为 C++ 三角函数采用弧度。

点积测投影,叉积确定垂直方向

在正交单位基下,两个向量的点积是:

ab=axbx+ayby+azbz=abcosθ.a\cdot b=a_xb_x+a_yb_y+a_zb_z=\|a\|\|b\|\cos\theta.

若 b 已归一化,aba\cdot b 给出 a 沿 b 的有符号投影长度。这个条件不能省略:把 b 放大两倍,点积也会放大两倍,但方向未变。后续光照中的法线与光方向如果没有归一化,点积不再直接等于夹角余弦。

a=(1,2,3)a=(1,2,3)b=(4,2,0)b=(4,-2,0),点积为 44+0=04-4+0=0。两者非零,因此相互垂直。零向量与任何向量的点积也为零,但零向量没有方向,不能据此给它定义一个 90° 夹角。

三维叉积展开为:

a×b=(aybzazby, azbxaxbz, axbyaybx).a\times b=(a_yb_z-a_zb_y,\ a_zb_x-a_xb_z,\ a_xb_y-a_yb_x).

其长度是两向量张成的平行四边形面积,方向遵循右手规则。交换输入次序会反号:b×a=(a×b)b\times a=-(a\times b)。因此三角形顶点绕序决定由边向量叉积得到的法线方向。把绕序写反,不是“误差稍大”,而是法线朝向发生翻转。

平行向量的叉积为零。通过 normalized(cross(a,b)) 构造方向时,应先承认这种退化输入可能出现;对零结果继续除以长度,只会得到非有限数。当前实现选择抛出异常,调用方必须另行决定拒绝几何、重选参考轴还是保留退化标记。

归一化也有数值条件

非零向量的单位方向为 v^=v/v\hat v=v/\|v\|。对 (3,4,0)(3,4,0),长度为 5,结果为 (0.6,0.8,0)(0.6,0.8,0)。公式简单,直接计算 sqrt(x*x+y*y+z*z) 却可能在中间平方阶段溢出或下溢。

例如 (3×10200,4×10200,0)(3\times10^{200},4\times10^{200},0) 的长度 5×102005\times10^{200} 仍可用本机 double 表示,但各分量平方已超出其范围;很小的对应向量又可能被平方成零。本实验使用嵌套 std::hypot 计算长度,再逐分量相除:

1
2
3
4
const double length = std::hypot(std::hypot(a.x, a.y), a.z);
if (!(length > 0) || !std::isfinite(length))
throw std::domain_error("normalization requires finite nonzero length");
return {a.x / length, a.y / length, a.z / length};

它避免了本次大、小尺度样本的中间平方问题,不意味着接受任意浮点输入。零长度、无穷与 NaN 都被拒绝;真实长度超过 double 上限时也拒绝。直接先计算 1/length 再乘分量,在极小长度下还可能使倒数溢出,因此这里保留逐分量除法。

这不是一个通用鲁棒几何库。点积与叉积仍按普通双精度运算,极大输入可能溢出,几乎平行的向量也可能发生严重消减。当前验收限定为明确列出的有限样本;需要精确朝向判定时,应另外设计误差边界,而不是给所有问题统一加一个任意 epsilon。

为什么平移需要第四个分量

三阶矩阵可以表示线性变换,但线性变换一定把零向量映到零向量。平移会把原点移走,所以无法只靠三阶矩阵乘三维坐标表示。

在齐次表示中,点扩成 (x,y,z,1)(x,y,z,1),方向扩成 (x,y,z,0)(x,y,z,0)。平移矩阵写成:

T=[100tx010ty001tz0001].T=\begin{bmatrix} 1&0&0&t_x\\ 0&1&0&t_y\\ 0&0&1&t_z\\ 0&0&0&1 \end{bmatrix}.

这里固定使用列向量,变换写作 p=Tpp'=Tp。将点代入,得到 (x+tx,y+ty,z+tz,1)(x+t_x,y+t_y,z+t_z,1);将方向代入,平移列乘以零,结果仍为 (x,y,z,0)(x,y,z,0)。同一个矩阵因此可以对两类量给出不同且合理的行为。

本篇仅使用仿射矩阵,最后一行为 (0,0,0,1)(0,0,0,1),不会改变这些 w 值。一般投影矩阵不满足这个限制,输出也不能直接当三维位置;透视除法与 w 为零的边界留到第 02 篇。当前代码中的 pointdirection 是构造辅助函数,不是完整的类型系统,不能阻止调用者手动造出错误的四元数组。

乘法次序对应操作次序

绕 Z 轴旋转角 θ\theta 的矩阵为:

R=[cosθsinθ00sinθcosθ0000100001].R=\begin{bmatrix} \cos\theta&-\sin\theta&0&0\\ \sin\theta&\cos\theta&0&0\\ 0&0&1&0\\ 0&0&0&1 \end{bmatrix}.

正角按右手规则定义。取 θ=π/2\theta=\pi/2(1,0,0)(1,0,0) 变成 (0,1,0)(0,1,0)。先旋转后平移,写作 T(Rp)=(TR)pT(Rp)=(TR)p,最右侧的矩阵最先作用。手算得到:

(1,0,0)R(0,1,0)T(1,3,0).(1,0,0)\xrightarrow{R}(0,1,0)\xrightarrow{T}(1,3,0).

若先平移,点先变成 (2,2,0)(2,2,0),再旋转得到 (2,2,0)(-2,2,0)。旋转作用于已经加上的位移,结果自然不同。这个例子证明一般情况下 TRRTTR\ne RT,并不否认某些特殊变换恰好可交换,例如沿同一轴的两次平移。

矩阵乘法满足结合律,实数数学中 (TR)p=T(Rp)(TR)p=T(Rp)。浮点程序改变括号也可能改变舍入,所以检查采用绝对容差 101210^{-12},没有要求比特相等。容差针对本实验数值量级约为 1 的样本;若场景坐标达到十亿,不能不加分析地复用相同标准。

内存布局不是乘法约定

math.hppstd::array<std::array<double,4>,4> 保存矩阵,m[i][j] 是第 i 行第 j 列,行连续存放。乘列向量时,第 i 个输出是这一行与输入向量的点积:

ri=j=03mijvj.r_i=\sum_{j=0}^{3}m_{ij}v_j.

矩阵相乘则是 cij=kaikbkjc_{ij}=\sum_k a_{ik}b_{kj}。存储按行还是按列,是字节排列问题;使用列向量还是行向量,是代数表达问题。当前实现完全可以用行存储计算列向量,不能因为看见“row-major”就自动把公式转置。

后续上传 WGSL 矩阵时会显式整理内存布局,不直接复制这个 C++ 对象。上传格式还涉及浮点宽度与对齐,单凭两边都称为“四阶矩阵”不能断言二进制兼容。

运行结果与失败路径

累计工程新增 math.hppmath_check.cpp,图像输出仍沿用第 00 篇的 image.hpp。在仓库根目录运行:

1
2
make -C examples/computer-graphics check
examples/computer-graphics/build/math_check --help

make check 同时复跑第 00 篇检查,防止新增代码破坏已有输出。本次 Apple Clang 21、C++17 构建无编译警告,实际输出为:

1
2
3
4
TRp = 1,3,0,1
RTp = -2,2,0,1
PASS column vectors, right-handed rotation, direction w=0, composition
PASS dot/cross, normalization scales 1/1e-200/1e200, rejected 4 invalid inputs

检查的预期坐标来自前面的手算,没有用待测矩阵函数生成另一份“期望值”。程序还检查平移不改变方向、矩阵组合与逐步作用相符,以及叉积 ex×ey=eze_x\times e_y=e_z

归一化采用三个确定尺度:1、1020010^{-200}1020010^{200},都应得到 (0.6,0.8,0)(0.6,0.8,0)。四个拒绝样本分别是零向量、含无穷的向量、含 NaN 的向量和平行向量的叉积。非法命令行参数返回非零并输出用法错误。这里没有随机采样、性能计时或图像质量结论;本篇验收对象是可逐项核对的几何数值。

练习与自检

练习一:改变平移方向。 仍使用绕 Z 轴 90° 的旋转,将平移改为 (0,0,2)(0,0,2)。对点 (1,0,0)(1,0,0),两种顺序分别得到什么?这能否证明旋转和平移总可交换?

答案:先旋转得到 (0,1,0)(0,1,0),再平移得到 (0,1,2)(0,1,2);先平移得到 (1,0,2)(1,0,2),再旋转也得到 (0,1,2)(0,1,2)。这次平移沿旋转轴,旋转没有改变该平移向量,因此可交换。它是特殊情形,不能推翻前文 (1,2,0)(1,2,0) 的反例。

练习二:法线为何成为 NaN。 三点 A=(0,0,0)、B=(1,0,0)、C=(2,0,0),程序以 normalized(cross(B-A,C-A)) 构造法线。哪里失去定义?换成很小的非零常数作为分母是否修复了几何问题?

答案:三点共线,两边向量叉积为零,三角形面积为零,没有唯一的面法线方向。换分母只可能得到零向量或依赖误差的方向,没有恢复缺失的面。应按使用场景拒绝退化三角形或记录退化状态;本实现抛出异常,让调用方明确处理。

系列导航与资料

前篇:00:从一个像素到一张图像,也是系列入口。下一篇:02:相机怎样把空间投到屏幕

资料核对日期为 2026-09-19。源码位于 examples/computer-graphics/,原始输出位于 writing-plans/computer-graphics/evidence/01-cpu.txt。文中测试规模与失败策略是本系列自行设计,不是 PBRT 的完整测试或数值精度保证。