《游戏引擎实现原理》8.2 矩阵、行/列主序与左/右手坐标系

同一组 16 个数字,按行主序读和按列主序读会得到两个不同的变换。本节讲清主序、手性与转置陷阱,说明 WebGPU / WGSL 的列主序约定与 CPU 到 GPU 的统一口径做法,给出 helio 可编译的列主序 Mat4,并用实测数字展示同一矩阵两种解释下的结果差异。

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)14
(0, 3)(平移 x)312
(1, 3)(平移 y)713
(3, 3)1515

只有对角元素 (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 时,只有两条路:

  1. 在上传 GPU 前转置一次,让 GPU 侧始终拿到列主序。转置只发生在一处(上传点),内部代码不受影响。
  2. 整个引擎都按行主序重写乘法与取用,只在最后上传时保持行主序存储。

两条路都能跑通,但不能混用。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 变换复合与逆变换 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径