8.2 矩阵、行/列主序与左/右手坐标系
8.1 把向量存进了连续内存。一个变换要做的,是把向量搬到新位置——旋转、缩放、平移,都用 4x4 矩阵表达。矩阵的推导站内既有文章讲得很透,本节只盯一个几乎人人踩过的坑:同一组 16 个数字,换一种读法就是另一个变换。
这个坑的危险之处在于它不报错、不崩溃,只是让物体出现在错的位置。等你在屏幕上看出不对劲时,可能已经排查了好几层代码。
同一组 16 个数字,两种读法
一个 4x4 矩阵有 16 个元素。写下来是 4 行 4 列,但塞进一维数组时,必须先约定「谁排前面」。两种约定:
- 行主序(row-major):按行排,元素
(r, c)落在下标r * 4 + c。 - 列主序(column-major):按列排,元素
(r, c)落在下标c * 4 + r。
两者互为转置。数学上,行主序的矩阵 M 和列主序的同一个变换 Mᵀ 是同一件事——只要后续的乘法和取用也按同样的约定来。陷阱在于:一旦存储和取用用了不同的约定,结果就悄悄错了,而且不会报错。
拿一个具体变换来看。设 M = T(10,20,30) * S(2,3,4)(先缩放后平移,逻辑上的矩阵是 [[2,0,0,10],[0,3,0,20],[0,0,4,30],[0,0,0,1]])。这 16 个元素在一维数组里:
| 读法 | 16 个数字 |
|---|---|
列主序存储 [...m] | 2, 0, 0, 0, 0, 3, 0, 0, 0, 0, 4, 0, 10, 20, 30, 1 |
| 行主序读法 | 2, 0, 0, 10, 0, 3, 0, 20, 0, 0, 4, 30, 0, 0, 0, 1 |
差别一眼可见:平移量 10, 20, 30 在列主序里挤在下标 12–14(最后一列),在行主序里散在下标 3、7、11(最后一列但跨行)。同一块内存,换个读法,平移就从「最后一列」跑到了「最后一列但按行排」——数值位置全变了。
索引映射可以写死成两张对照表,写代码时随手一查就不会错:
逻辑位置 (r, c) | 行主序下标 r*4+c | 列主序下标 c*4+r |
|---|---|---|
(0, 1) | 1 | 4 |
(0, 3)(平移 x) | 3 | 12 |
(1, 3)(平移 y) | 7 | 13 |
(3, 3) | 15 | 15 |
只有对角元素 (i, i) 在两种主序下下标相同——(3,3) 都是 15。其余元素一律错位,这就是为什么「平移写在最后一列」在两种主序下的下标完全不同。
WebGPU / WGSL 是列主序
helio 的矩阵存储跟随 WebGPU 的约定:列主序。WGSL 里的 mat4x4<f32> 由 4 个 vec4 列组成,m[0] 就是第一列。把矩阵上传到 uniform buffer 时,直接把 Float32Array 的 16 个 float 按顺序写进去即可,不需要任何转置——因为 CPU 侧的存储顺序和 GPU 侧的解读顺序是一致的。
这条约定必须记牢,因为它是「转置陷阱」的根源:
- 如果你的 CPU 代码按行主序存,却把 buffer 直接喂给 WGSL,GPU 会把它当列主序读——等于偷偷转置了一次。
- 如果 CPU 侧做向量乘法时又按行主序取元素,两次错误可能相互抵消,也可能不抵消,取决于代码路径。这种「有时对有时错」最难查。
统一口径是第一原则:helio 从存储、乘法到上传,全程列主序,一处都不例外。
为什么 WebGPU 会选列主序?因为它继承了 OpenGL / SPIR-V 一脉的约定,而这条链上的数学库(GLM、gl-matrix)大多也是列主序。跨引擎移植代码时,第一件事就是确认两边的主序是否一致——不一致就在上传前 transpose 一次,把「存储约定」对齐。helio 的选择是全程不转置:CPU 存列主序,乘法按列主序,上传直接写 buffer,链路最短,出错点最少。
常见坑
围绕主序的坑几乎都出在「两个地方用了不同约定」:
- CPU 行主序、GPU 列主序,直接上传。结果是整个场景被转置,物体位置全乱,但程序不报错。
- 数学库用行主序,自己写的乘法用列主序。两边各自都对,合在一起就错;查的时候还以为是乘法公式写错了。
- 以为
transpose是免费操作。它要重排 16 个数并分配新数组;热路径里每帧转置一次矩阵,就是在稳定制造垃圾。 - 把「行/列主序」和「行/列向量」搞混。主序讲的是 16 个数怎么排;行/列向量讲的是矩阵乘向量时向量放左边还是右边。两件事独立,但常常一起出现,容易互相带偏。
- 手性混用。一半代码按右手系算叉积,一半按左手系,法线方向就会时对时错。
- 在日志里打印矩阵时没标注主序。打印出来的 16 个数没有主序信息,事后回看根本没法判断对不对。
- 把主序写进了函数签名却忘了实现。声明「本函数接收列主序」,实现里却按
r*4+c取元素,等于签名和实现各说各话。
第 1 条最隐蔽:它只在「矩阵含非对称分量(平移、错切)」时才显形,纯旋转矩阵转置后甚至看着差不多,很容易蒙混过关。8.3 的逆变换会再次用到转置——那时它是有意的、正确的用法。
这些坑有一个共同的预防手段:给矩阵类型起个带主序的名字。把 Mat4 明确成「列主序 Mat4」,所有函数的注释都写清它接收和返回的是哪一种,跨边界的转置处单独标注。命名不能防住所有错,但能让「这里的主序是什么」这个问题在每次阅读代码时都被问到。
还有一个容易忽略的点:序列化也会带上主序。矩阵存进存档或网络包时,写出去的是 16 个 float 的排列顺序;读回来的一方必须按同一个约定解析。版本迁移时如果改了主序,就得在迁移逻辑里补一次转置,否则旧存档的物体位置全错。这个坑留到第 17 章讲序列化时还会再提。
左手系还是右手系:约定比正确更重要
可运行产物:列主序 Mat4
下面是完整可编译的 src/core/mat4.ts。所有函数都按列主序实现,multiply(out, a, b) 表示 out = a * b(作用于列向量,先应用 b 再应用 a)。层级 L1,Node 直跑。
// file: src/core/mat4.ts
// tier: L1
// verify: npx vitest run src/core/mat4.test.ts
//
// helio 内核:4x4 矩阵。存储采用 WebGPU / WGSL 的约定——列主序(column-major),
// 即逻辑上第 c 列第 r 行落在 data[c * 4 + r]。这个约定和「把 16 个数按行读」的
// 直觉相反,是本文件最容易出错的地方。
/** 列主序 4x4 矩阵:长度 16 的 Float32Array。 */
export type Mat4 = Float32Array;
export function identity(): Mat4 {
const m = new Float32Array(16);
m[0] = 1;
m[5] = 1;
m[10] = 1;
m[15] = 1;
return m;
}
export function fromTranslation(tx: number, ty: number, tz: number): Mat4 {
const m = identity();
m[12] = tx;
m[13] = ty;
m[14] = tz;
return m;
}
export function fromScale(sx: number, sy: number, sz: number): Mat4 {
const m = new Float32Array(16);
m[0] = sx;
m[5] = sy;
m[10] = sz;
m[15] = 1;
return m;
}
/** out = a * b(列主序,作用于列向量:先应用 b,再应用 a)。out 不能与 a / b 同一块。 */
export function multiply(out: Mat4, a: Mat4, b: Mat4): Mat4 {
for (let c = 0; c < 4; c++) {
const b0 = b[c * 4 + 0]!;
const b1 = b[c * 4 + 1]!;
const b2 = b[c * 4 + 2]!;
const b3 = b[c * 4 + 3]!;
out[c * 4 + 0] = a[0]! * b0 + a[4]! * b1 + a[8]! * b2 + a[12]! * b3;
out[c * 4 + 1] = a[1]! * b0 + a[5]! * b1 + a[9]! * b2 + a[13]! * b3;
out[c * 4 + 2] = a[2]! * b0 + a[6]! * b1 + a[10]! * b2 + a[14]! * b3;
out[c * 4 + 3] = a[3]! * b0 + a[7]! * b1 + a[11]! * b2 + a[15]! * b3;
}
return out;
}
/** 用列主序矩阵变换一个点:结果 = 各列按 x / y / z / 1 加权求和。 */
export function transformPoint(m: Mat4, x: number, y: number, z: number): [number, number, number] {
return [
m[0]! * x + m[4]! * y + m[8]! * z + m[12]!,
m[1]! * x + m[5]! * y + m[9]! * z + m[13]!,
m[2]! * x + m[6]! * y + m[10]! * z + m[14]!,
];
}
/** 转置:把「列主序解释」与「行主序解释」互换。 */
export function transpose(m: Mat4): Mat4 {
const t = new Float32Array(16);
for (let c = 0; c < 4; c++) {
for (let r = 0; r < 4; r++) {
t[c * 4 + r] = m[r * 4 + c]!;
}
}
return t;
}
/** 把 16 个数按「第 0 行、第 1 行……」的顺序列出(行主序读法),用于和存储顺序对照。 */
export function toRowMajorNumbers(m: Mat4): number[] {
const out: number[] = [];
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
out.push(m[c * 4 + r]!);
}
}
return out;
}
transformPoint 是列主序的核心证据:它把 x 乘上第 0 列、y 乘上第 1 列、z 乘上第 2 列,再加第 3 列——因为列主序里「列」才是连续存储的 vec4。如果按行主序取,索引就要整体转置,那就是另一个函数了。
测试与实测数字:转置陷阱
下面这个用例把「同一组数字两种解释」直接变成断言。注意最后一条:同一个矩阵,列主序解释给出 [12, 23, 34],行主序解释给出 [2, 3, 4]——两者都不报错,但只有一个是对的。
// file: src/core/mat4.test.ts
// tier: L1
// verify: npx vitest run src/core/mat4.test.ts
import { describe, expect, it } from 'vitest';
import {
fromScale,
fromTranslation,
identity,
multiply,
toRowMajorNumbers,
transformPoint,
transpose,
} from './mat4.ts';
describe('列主序矩阵', () => {
it('单位阵不动点', () => {
expect(transformPoint(identity(), 3, 4, 5)).toEqual([3, 4, 5]);
});
it('平移矩阵作用在点上', () => {
expect(transformPoint(fromTranslation(10, 20, 30), 1, 2, 3)).toEqual([11, 22, 33]);
});
it('复合顺序:先缩放后平移', () => {
const m = multiply(identity(), fromTranslation(10, 20, 30), fromScale(2, 3, 4));
expect(transformPoint(m, 1, 1, 1)).toEqual([12, 23, 34]);
});
it('交换顺序结果不同', () => {
const ts = multiply(identity(), fromTranslation(10, 20, 30), fromScale(2, 3, 4));
const st = multiply(identity(), fromScale(2, 3, 4), fromTranslation(10, 20, 30));
expect(transformPoint(ts, 1, 1, 1)).toEqual([12, 23, 34]);
expect(transformPoint(st, 1, 1, 1)).toEqual([22, 63, 124]);
});
it('转置陷阱:同一组数字两种解释给出不同点', () => {
const m = multiply(identity(), fromTranslation(10, 20, 30), fromScale(2, 3, 4));
const asColumn = transformPoint(m, 1, 1, 1); // 列主序解释:正确
const asRow = transformPoint(transpose(m), 1, 1, 1); // 行主序解释:错误
expect(asColumn).toEqual([12, 23, 34]);
expect(asRow).not.toEqual(asColumn);
});
it('行主序读法与列主序存储的排列差异', () => {
const m = multiply(identity(), fromTranslation(10, 20, 30), fromScale(2, 3, 4));
expect([...m]).toEqual([2, 0, 0, 0, 0, 3, 0, 0, 0, 0, 4, 0, 10, 20, 30, 1]);
expect(toRowMajorNumbers(m)).toEqual([2, 0, 0, 10, 0, 3, 0, 20, 0, 0, 4, 30, 0, 0, 0, 1]);
});
});
本机实测:npx vitest run src/core/mat4.test.ts 六个用例全过。一个打印脚本给出:列主序解释作用在点 (1,1,1) 上是 [12, 23, 34],行主序解释是 [2, 3, 4]。复合顺序同样有实测——T*S 作用在 (1,1,1) 上是 [12, 23, 34](先缩放成 (2,3,4) 再平移),S*T 是 [22, 63, 124](先平移成 (11,21,31) 再缩放)。矩阵乘法不满足交换律,顺序写反结果就错。
与行主序的数学库对接
站内既有图形学文章、以及不少 C++ 数学库(GLM 这类)默认走行主序。把它们的代码或直觉搬进 helio 时,只有两条路:
- 在上传 GPU 前转置一次,让 GPU 侧始终拿到列主序。转置只发生在一处(上传点),内部代码不受影响。
- 整个引擎都按行主序重写乘法与取用,只在最后上传时保持行主序存储。
两条路都能跑通,但不能混用。helio 选第 1 种,因为转置点收敛在一处,好审计;内部若散落着两种主序,出问题时根本不知道从哪查。选哪种不重要,重要的是整个项目只选一种,并且把选择写进文档。
如果不得不两种共存(比如接入一个只发行主序的第三方库),那就约定一条铁律:数据一旦进入 helio 的存储,就是列主序;跨边界的那一次转置必须显式写出,不允许「隐式假设」。
左手系还是右手系:约定比正确更重要
主序之外,还有第二个约定:坐标系的手性。右手系里 x × y = z,左手系里 x × y = -z。选哪个都不会让数学「错」,但必须全项目统一——混用会导致模型翻转、背面剔除方向反了、法线指向内侧。
helio 的做法:
- 世界坐标系用右手系,与站内既有图形学文章一致,
y向上,相机默认朝-z看。 - NDC(裁剪空间)跟随 WebGPU:
x、y落在[-1, 1],z落在[0, 1]。注意z区间是[0, 1],和 OpenGL 的[-1, 1]不同——写投影矩阵时这一点必须对齐,否则深度全挤在一端。 - 主序、手性、NDC 区间是三个独立的约定,各自都能单独踩坑。把它们混在一起记,是 bug 的温床。
这三点里,手性最容易被「顺手」破坏:从别处抄来一个相机矩阵、一个叉积方向,如果对方用的是左手系,抄进右手系项目就会让模型前后翻转。反过来,只要全项目统一,选左手还是右手都不影响正确性——约定本身没有对错,不统一才有错。
投影矩阵的具体形式留到卷二《渲染引擎》,这里只需记住:引擎里的每一个约定都要写进文档、被所有模块遵守,因为约定错了编译器不会提醒你。
一个自查办法:随手拿一个含平移的矩阵,把它作用在原点 (0,0,0) 上。列主序下正确的结果应该正好是平移量;如果拿到的是别的数,八成是主序错位。这个「原点探针」比对着 16 个数字瞪眼快得多,也更容易在调试时一眼定位问题。
helio 的矩阵约定至此就完整了:列主序存储、右手系世界、WebGPU 的 z ∈ [0, 1] 裁剪空间。三条都写进内核文档,所有数学模块统一遵守。下一节把单个矩阵用起来——复合出物体的世界变换,再求出它的逆。
矩阵能表示单个变换了。8.3 把它们复合起来:一个物体的最终位置,是平移、旋转、缩放按固定顺序叠出来的,而「怎么叠」和「怎么求逆」都有讲究。
阅读导航:上一节:8.1 向量代数与 TypedArray 布局 · 下一节:8.3 变换复合与逆变换 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。