2026-09-09
来到 3D 世界
在 23 年的时候,我曾写过一篇文章 CSS Matrix3D 中的矩阵运算 记录了关于 matrix 变换的原理 ,在那篇文章最后曾提到 “到这里能明显感到我已站在 3D 世界大门前, 只差临门一脚, 意犹未尽”,今天就来完成这最后的临门一脚,开始玩下图形学,本文目标:用 React SVG 实现一个 Cube 方块的绘制。
注:请使用 PC/Mac/iPad 阅读本文,移动端不保证排版效果,先上最终效果:
ABCDEFGH
Camera
eye(1.75, 2.50, 3.00)
target(0.00, 0.00, 0.00)
dist4.28
⬆️ 鼠标 / 触控 / 滚轮 / 捏合 / WASD 移动 / Shift 加速 / 点此复位

# 如何绘制 3D 内容

将 3D 的东西搬到 2D 其实就是找到一个矩阵,使 3D 的 Cube 的 8 个顶点映射到 2D 的屏幕空间内,实现了这个之后在对应的 2D 位置绘制线条和圆圈其实就完成了 3D 的渲染绘制了,在此之前先回顾一下基础概念:常用向量计算、矩阵如何表达变换,以及常用矩阵变换

# 常用向量计算

以下代码给出了常用的向量计算,和类型建模
00// vec.ts
01export type Vec2 = [number, number];
02export type Vec3 = [number, number, number];
03export type Vec4 = [number, number, number, number];
04// 通常也可以用来表示 "点"
05export type Point3 = Vec3;
06
07/** 计算 a + b */
08export function add3(a: Vec3, b: Vec3): Vec3 {
09  return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
10}
11
12/** 计算 a - b */
13export function sub3(a: Vec3, b: Vec3): Vec3 {
14  return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
15}
16
17/** 放大 a */
18export function scale3(a: Vec3, s: number): Vec3 {
19  return [a[0] * s, a[1] * s, a[2] * s];
20}
21
22/** 点乘 a \cdot b */
23export function dot3(a: Vec3, b: Vec3): number {
24  return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
25}
26
27/** 叉乘 a \times b */
28export function cross3(a: Vec3, b: Vec3): Vec3 {
29  return [
30    a[1] * b[2] - a[2] * b[1],
31    a[2] * b[0] - a[0] * b[2],
32    a[0] * b[1] - a[1] * b[0],
33  ];
34}
35
36/** 计算 a 的模 (长度) */
37export function len3(a: Vec3): number {
38  return Math.hypot(a[0], a[1], a[2]);
39}
40
41/** 将 a 向量的长度设置为 1 (归一化) */
42export function normalize3(a: Vec3): Vec3 {
43  const l = len3(a);
44  // 避免除 0 错误,兜一下
45  if (l < 1e-10) return [0, 0, 0];
46  return [a[0] / l, a[1] / l, a[2] / l];
47}
dot3(a, b)θbaOcross3(a, b)aba × bOnormalize3(v)xy11vO

# 用方程组表达变换

细节在前文都有提到,这里复习一下,矩阵代表了一个变换方程组,比如我们有一个矩阵
且以及一个点
此时
点乘的计算则代表将 x y z 做一个变换得到 x' y' z',具体展开如下,意思是经过点乘计算得到另外一个点的坐标
因此,在这个角度来看点乘操作,其实是把点带入到对应的线性方程组,计算出
的变换计算,从而精确描述 “如何做点的变换”,点乘即变换:
于是,对于 2D 点来说,不妨令
,于是平移、缩放、旋转都可以写出来对应的矩阵式子了,特别地
则代表不做任何变换,相当于点乘运算的 "1" :

# 常用矩阵操作

以下实现了常用的矩阵操作,首先是理解这个 "行主序" 的 Mat4:
00export type Mat4 = [
01  a00: number, a01: number, a02: number, a03: number,
02  a10: number, a11: number, a12: number, a13: number,
03  a20: number, a21: number, a22: number, a23: number,
04  a30: number, a31: number, a32: number, a33: number,
05];
完全对应数学表达:
其他操作:
000/** 计算矩阵乘法 a * b,直接展开,字面量一次求出 */
001export function multiply(a: Mat4, b: Mat4): Mat4 {
002  const [
003    a00, a01, a02, a03,
004    a10, a11, a12, a13,
005    a20, a21, a22, a23,
006    a30, a31, a32, a33,
007  ] = a;
008
009  const [
010    b00, b01, b02, b03,
011    b10, b11, b12, b13,
012    b20, b21, b22, b23,
013    b30, b31, b32, b33,
014  ] = b;
015
016  return [
017    // 第 0 行
018    a00 * b00 + a01 * b10 + a02 * b20 + a03 * b30,
019    a00 * b01 + a01 * b11 + a02 * b21 + a03 * b31,
020    a00 * b02 + a01 * b12 + a02 * b22 + a03 * b32,
021    a00 * b03 + a01 * b13 + a02 * b23 + a03 * b33,
022    // 第 1 行
023    a10 * b00 + a11 * b10 + a12 * b20 + a13 * b30,
024    a10 * b01 + a11 * b11 + a12 * b21 + a13 * b31,
025    a10 * b02 + a11 * b12 + a12 * b22 + a13 * b32,
026    a10 * b03 + a11 * b13 + a12 * b23 + a13 * b33,
027    // 第 2 行
028    a20 * b00 + a21 * b10 + a22 * b20 + a23 * b30,
029    a20 * b01 + a21 * b11 + a22 * b21 + a23 * b31,
030    a20 * b02 + a21 * b12 + a22 * b22 + a23 * b32,
031    a20 * b03 + a21 * b13 + a22 * b23 + a23 * b33,
032    // 第 3 行
033    a30 * b00 + a31 * b10 + a32 * b20 + a33 * b30,
034    a30 * b01 + a31 * b11 + a32 * b21 + a33 * b31,
035    a30 * b02 + a31 * b12 + a32 * b22 + a33 * b32,
036    a30 * b03 + a31 * b13 + a32 * b23 + a33 * b33,
037  ];
038}
039
040/** 位移,类似 css translate3d */
041export function translate(v: Vec3): Mat4 {
042  const [x, y, z] = v;
043  return [
044    1.0, 0.0, 0.0,   x,
045    0.0, 1.0, 0.0,   y,
046    0.0, 0.0, 1.0,   z,
047    0.0, 0.0, 0.0, 1.0,
048  ];
049}
050
051/** 放大,类似 css scale3d */
052export function scale(sx: number, sy: number, sz: number): Mat4 {
053  return [
054     sx, 0.0, 0.0, 0.0,
055    0.0,  sy, 0.0, 0.0,
056    0.0, 0.0,  sz, 0.0,
057    0.0, 0.0, 0.0, 1.0,
058  ]
059}
060
061/** 绕 x 轴转,类似 css rotateX */
062export function rotateX(theta: number): Mat4 {
063  const c = Math.cos(theta);
064  const s = Math.sin(theta);
065  return [
066    1.0, 0.0, 0.0, 0.0,
067    0.0,   c,  -s, 0.0,
068    0.0,   s,   c, 0.0,
069    0.0, 0.0, 0.0, 1.0,
070  ];
071}
072
073/** 绕 y 轴转,类似 css rotateY */
074export function rotateY(theta: number): Mat4 {
075  const c = Math.cos(theta);
076  const s = Math.sin(theta);
077  return [
078      c, 0.0,   s, 0.0,
079    0.0, 1.0, 0.0, 0.0,
080     -s, 0.0,   c, 0.0,
081    0.0, 0.0, 0.0, 1.0,
082  ];
083}
084
085/** 绕 z 轴转,类似 css rotateZ */
086export function rotateZ(theta: number): Mat4 {
087  const c = Math.cos(theta);
088  const s = Math.sin(theta);
089  return [
090      c,  -s, 0.0, 0.0,
091      s,   c, 0.0, 0.0,
092    0.0, 0.0, 1.0, 0.0,
093    0.0, 0.0, 0.0, 1.0,
094  ];
095}
096
097/** 返回点乘 m * v */
098export function transformVec4(m: Mat4, v: Vec4): Vec4 {
099  const x = v[0], y = v[1], z = v[2], w = v[3];
100  return [
101    m[0] * x + m[1] * y + m[2]  * z + m[3]  * w,
102    m[4] * x + m[5] * y + m[6]  * z + m[7]  * w,
103    m[8] * x + m[9] * y + m[10] * z + m[11] * w,
104    m[12] * x + m[13] * y + m[14] * z + m[15] * w,
105  ];
106}

# 理解连续变换与逆变换

多次变换对应为多个矩阵的依次点乘,比如先位移再放大可以表示为:
逆变换则是把变换 undo 掉,比如上面的:
也就是 P 到 P' 到 P'' 然后经过逆矩阵变成 P' 最后变成一开始的 P,做了那么多变换最后回到
:
显然这满足逆矩阵的定义,经过了变换 A' 和 A 后得到的是 I 代表不做任何变换,A 和 A' 互相抵消了:
P (初始状态)PtranslatetranslateP' = translate · PP'scalescaleP'' = scale · P'P''
总之:多个矩阵连续右乘则代表了变换的依次叠加,而变换的逆矩阵则代表了变换的 undo 操作,可以消去叠加的变换。

# Cube 的坐标系和 8 个顶点

00// cube.ts
01import { Vec3 } from './vec';
02
03export const VERTEX_LABELS = [
04  'A', 'B', 'C', 'D',
05  'E', 'F', 'G', 'H',
06] as const;
07
08export const CUBE_VERTICES: Vec3[] = [
09  [ -1, -1, -1 ],
10  [  1, -1, -1 ],
11  [  1, -1,  1 ],
12  [ -1, -1,  1 ],
13  [ -1,  1, -1 ],
14  [  1,  1, -1 ],
15  [  1,  1,  1 ],
16  [ -1,  1,  1 ],
17];
18
19// 奇数项连接偶数项
20export const CUBE_LINES = [
21  0, 1,   // v0-v1
22  1, 2,   // v1-v2
23  2, 3,   // v2-v3
24  3, 0,   // v3-v0
25  4, 5,   // ...
26  5, 6,
27  6, 7,
28  7, 4,
29  0, 4,
30  1, 5,
31  2, 6,
32  3, 7
33];
正方体,我们可以用左边的 8 个顶点和边来定义它,这实际上就是 cube 的 “模型”。
此处的坐标系称为模型坐标系,如果需要将其摆放出来,实际上就是对每一个顶点做变换将其放置在世界坐标中的某一个位置,比如可以放大缩小,位移平移旋转等。
V4V5V6V7V0V1V2V3
我们的终极目标是将 3D 场景渲染到 2D 屏幕上,意味着我们必须经过一系列变换将模型坐标系内的 8 个顶点经过各种变换后抵达 2D 的屏幕坐标系上,并通过 lineTo 将这些顶点连接起来实现将 3D 的 cube 绘制到 2D 的屏幕上。

# M 矩阵:从模型坐标到世界坐标

为了将模型放置到世界,需要对世界建立坐标系,称为世界坐标,此时把 cube 的顶点放置到世界里其实就是对每一个顶点做一个变换,比如放置在 (1,2,3) 处,则对应这个变换矩阵:
此时模型顶点 (-1, -1, -1) 在世界坐标的坐标
可以这样计算 (第四行不在本文展开):

# Camera 与透视除法

我们的终极目标是将 3D 场景渲染到 2D 屏幕上,意味着我们必须经过某种变换将 8 个顶点精准的对应到屏幕坐标系上,实际上这就是拍照的过程,形成了下图这样的称为视锥体的结构,其中 FOV 代表了一个夹角(这对应游戏里的视场角后文会展开)NEAR PLANE 上经过的光线成像后就是我们想要的了,可以先思考一下 Nnear Plane 上能投影出什么东西?

为了能分析 Nnear Plane 上的数值,我们最好引入一个方便的坐标系,考虑到投影,我们不妨以摄像机自己作为参考系来建系,令摄像机始终望向 -z 方向,那么此时的 8 个顶点可以直接到投影 xy 平面上,即下图的左部; 而右部则是考虑到近大远小后的透视效果,即 Near Plane 上投影展示的影像,此时原点是透视消失点,在最后理解此处蓝色和红色矩形的数值几何关系就是理解透视的数学本质的关键:
xyO -zV0, V4V1, V5V2, V6V3, V7xyO -zV4V5V6V7V0V1V2V3
左侧是单纯的 xy 投影,丢弃了 z 值,因此会重叠; 而右侧则带上了近大远小的透视效果,这正是我们要的 3D 投影到 2D 的效果,如何计算呢?下图是其另外一个视角,考察 y' 跟原始 y 的几何关系:
-z 方向yONear Plane (z = d)P₁(y, z₁)yP₂(y, z₂)yy'₁y'₂dz₁z₂ (更远)
显然注意到图中的相似三角形的关系,不难得到:
特别地、将 Near Plane 放置在离相机 1 单位距离的平面上,即令 d=1 时,就能拿到 3D 物体投影到 Near Plane 时,对应坐标 x' y' 的计算公式,注意相机望向 -z 方向,所以取 -z 作为绝对值:
对的,也就是顶点的 z 值代表距离摄像机的距离,离相机越远的 z 值自然就越小,摄像机看向 -z,我们拿它依据几何关系用来做一个除法即可实现透视投影计算,此时离相机越远的 z 的绝对值越大,就实现了近大远小的透视效果,更重要的是当 z 值趋于无穷大时,投影点会趋近于原点,即实现了透视消失点,这种除法称为 “透视除法”,此处用 uv 表达 Near Plane 上的坐标系,写作:
剩下最关键的问题是:如何将世界坐标变换到摄像机坐标系?搞定了这个我们就能正确实现 3D 绘制了!

# 世界坐标系中的摄像机

首先摄像机肯定有自己的世界坐标
作为眼睛,以及它正在看向的点
,想象一下摄像机挂在
上,此时摄像机可以在 PT 这根直线上旋转,因此还需要另外一个向量控制其旋转,称为
,特别地当 U 笔直朝上时,我们认为摄像机此时不会歪,也就是 U 和 PT 构成的平面垂直于大地:
因此,Camera 由三个 Vec3 参数唯一确定:
00type Vec3 = [number, number, number];
01export interface Camera {
02  eye: Vec3;
03  target: Vec3;
04  up: Vec3;
05}
为了将世界坐标变换到摄像机坐标系,我们必须要给出这样一个 lookAt 函数,它依据相机参数生成变换矩阵,将世界坐标转移到摄像机坐标系:
如果你注意力够好的话可以尝试应用多次的基础变换来逐步构造 lookUp,通过多次变换还原出将摄像机摆放到世界坐标系内,比如先移动 eye,然后两次绕轴旋转确定 target 方向,最后再来一次旋转确定 up,通过这种多次变换的方式来实现 lookUp,但是三维的抽象和繁复,一下子就注意力涣散了:
没错,注意力涣散!最后整条公式很可能会变得异常复杂和低效

# 更好的数学思想: Basis Vector is all you nned

如果还用传统几何的观点去处理 3D 问题很容易遇到注意力涣散的问题,需要换一种数学思想去考虑这个问题,考虑一下,当我们给定一个点 (x,y,z) 时,其三个分量是什么?
所以应该把坐标理解为某一个点 P 在其三个基向量上的有符号投影长度。
换坐标系时,点是还是那个点,只是其基向量变了,此时变换坐标系这个问题被转换成:在调整了 basis-vector 后,只需要求在新的基向量上的投影,比如 v2(new) 的长度其实就是新坐标系下 P 的 y 值,求 P 在 v2(new) 方向上的投影即可:
推广到三个维度,将世界坐标转换 Camera 坐标的关键在于找到在世界坐标系里找到 Camera 坐标系的三个基向量,可以肯定的是其中的 Up PT(Forward) 已经确定了,我们只需要找到 Right 即可,配合叉乘求出
所确定的平面的法向量就行, 注意 PT(Forward) 朝向 -z:
最后对 Right / Forward / Up 做 length=1 的 normalize 处理,即可得到 lookAt 的三个基向量,注意 Forward 朝向的是 -z 方向,我们需要基向量应该指向 +z 所以是 Eye - Target:

# V 矩阵:从世界坐标到摄像机坐标

有了基向量,就能实现将世界坐标转到摄像机坐标了,整理推广之前的二维结论:
00export function lookAt(eye: Vec3, target: Vec3, up: Vec3): Mat4 {
01  const f = normalize3(sub3(eye, target));
02  const r = normalize3(cross3(up, f));
03  const u = cross3(f, r);
04
05  const [fx, fy, fz] = f;
06  const [ux, uy, uz] = u;
07  const [rx, ry, rz] = r;
08
09  return [
10     rx,  ry,  rz, -dot3(r, eye),
11     ux,  uy,  uz, -dot3(u, eye),
12     fx,  fy,  fz, -dot3(f, eye),
13      0,   0,  0,              1,
14  ];
15}

# 用 SVG 渲染一个 3D Cube

我们现在已经掌握了最核心的变换矩阵 M 和 V 了,现在尝试用 SVG 绘制一个 3D Cube, 我们先定义画布空间,和 svg render wrapper:
00const VIEWBOX_WIDTH = 600;
01const VIEWBOX_HEIGHT = 600;
02const VIEWBOX = `0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`;
03export function RenderCube(props: { style?: React.CSSProperties }) {
04  // Camera 望向原点,up 垂直于大地
05  const [eye, setEye] = React.useState<Vec3>([1.75, 2.5, 3]);
06  const [target, setTarget] = React.useState<Vec3>([0, 0, 0]);
07  const [up, setUp] = React.useState<Vec3>([0, 1, 0]);
08  // 合成 V 矩阵
09  const V = lookAt(eye, target, up);
10  
11  // position 代表 cube 的中心点,初始放置在原点
12  const [position, setPosition] = React.useState<Vec3>([0, 0, 0]);
13  // 合成 M 矩阵
14  const M: Mat4 = [
15    1.0, 0.0, 0.0, position[0],
16    0.0, 1.0, 0.0, position[1],
17    0.0, 0.0, 1.0, position[2],
18    0.0, 0.0, 0.0, 1.0,
19  ]
20
21  // 用 svg 作为渲染后端,计算顶点并绘制 cube
22  const commands: React.JSX.Element[] = [];
23  renderCube(commands, M, V);
24
25  return (
26    <svg viewBox={VIEWBOX}
27      style={{
28        border: '1px solid #000',
29        margin: '1em auto',
30        display: 'block',
31        ...props.style,
32      }}>
33      {commands}
34    </svg>
35  );
36}
然后实现 renderCube, 核心实现是依据 M 和 V 确定顶点在画布中的 2D 位置,并依据 viewBox 做好缩放和居中:
00export function renderCube(
01  commands: React.JSX.Element[],
02  M: Mat4,
03  V: Mat4,
04) {
05  const final = multiply(V, M);
06  
07  // 模型本身是 1 单位的大小,这里做一个放大和居中,使其适合显示到 VIEWBOX 内
08  // 此外 cube 建模的时候,左下角是 xy 原点,所以要做一个负数转换
09  const transformToScreen = (v: Vec3 | Vec4): Vec3 => [
10    v[0] * VIEWBOX_WIDTH + VIEWBOX_WIDTH / 2,
11    (-v[1]) * VIEWBOX_HEIGHT + VIEWBOX_HEIGHT / 2,
12    v[2]
13  ];
14  
15  // 顶点坐标转画布坐标
16  const points: Vec3[] = CUBE_VERTICES.map((v1, idx) => {
17    const v4 = transformVec4(final, [v1[0], v1[1], v1[2], 1]);
18    console.log(VERTEX_LABELS[idx]!, v4);
19    const w = -v4[2]; // -z
20    const x = v4[0] / w;
21    const y = v4[1] / w;
22
23    return transformToScreen([x, y, w])
24  });
25
26  // 查表划线
27  for (let i = 0; i < CUBE_LINES.length; i += 2) {
28    const a = points[CUBE_LINES[i]];
29    const b = points[CUBE_LINES[i + 1]];
30    if (!a || !b) continue;
31
32    commands.push(
33      <line key={commands.length} stroke="black"
34        x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]} />
35    );
36  }
37
38  // 画顶点和标签
39  points.forEach((p, idx) => {
40    if (!p) return;
41    commands.push(
42      <circle key={commands.length}
43        cx={p[0]} cy={p[1]}
44        r={6} fill="#f87171" />
45    );
46
47    commands.push(
48      <text
49        key={commands.length}
50        x={p[0] + 10} y={p[1] + 0}
51        // 离得近的顶点标注的更大
52        fontSize={Math.max(10, (300 / p[2]) - 10)}
53        fontWeight="bold" fontFamily="monospace" fill="#2424ff"
54      >
55        {VERTEX_LABELS[idx]!}
56      </text>
57    );
58  });
59
60  // 最后绘制一下 green 原点
61  const o = transformToScreen(transformVec4(final, [0, 0, 0, 1]));
62  commands.push(
63    <circle
64      key={commands.length}
65      cx={o[0]} cy={o[1]}
66      r={6} fill="#0F0" // green
67    />
68  );
69}
最后,效果如下:
ABCDEFGH

# Gaming Cube

用鼠标操作从不同角度查看 3D Cube 其实就是用不同的 M 和 V 矩阵,因此我们应该将渲染整成一个受控组件:
00function RenderCube(props: { M: Mat4, V: Mat4 }) {
01  const commands: React.JSX.Element[] = [];
02  renderCube(commands, props.M, props.V);
03  return <>{commands}</>
04}
然后绑一下 DOM 操作,依据操作修改 Camera 设定,简单写一下顶点可见性检查,使其看起来更像游戏:
备注: React 表示 “什么?停下!你让我来渲染 3D 吗 ?”

# EOF

将 3D 场景渲染到 2D 屏幕上没有想象中的复杂,理解 MV 变换的原理即可实现上屏绘制,本文给出了一个粗糙的实现。
但实际上还有很多细节问题需要考虑,比如你可以尝试滚轮放大到最大或操作 WASD 走出 cube,然后背对着 cube 看看,能看到很多貌似是渲染的问题,实际上要解决这些必须重新审视视锥体和透视成像 P 矩阵的具体细节,本文限于篇幅和个人精力就不展开了 (估计还是会写233,等我有空)