主题
数学板块导览
屏幕上每一个像素、每一次旋转、每一片光影,最后都会落到几个数字上:顶点坐标是向量,位置/朝向/大小是矩阵,光照强弱是点积,曲线是插值。图形学数学不是"另一门课",而是把画面翻译成 GPU 能算的数字的那套语言。
这一页是整个板块的地图:先说明每类数学各自负责什么,再给出与 WebGL / WebGPU API 的对应关系,最后交代本板块统一的符号约定。后面每一页都从这里的分工出发。
为什么图形学要数学
| 你想实现的效果 | 背后的数学 | 本站相关页面 |
|---|---|---|
| 让物体转起来、放大缩小 | 旋转 / 缩放矩阵、四元数 | 矩阵与变换、3D 变换与相机 |
| 近大远小、能走进场景 | 透视投影矩阵、视锥裁剪 | 3D 变换与相机 |
| 知道一个面朝向哪边、光有多强 | 点积 n·l、叉积与法线 | 向量 |
| 判断三角形是正面还是背面 | 叉积(行列式)的符号 | 向量 |
| 把鼠标位置换算到场景里 | 坐标系与屏幕空间反算 | 坐标系与空间 |
| 动画平滑过渡 | 线性插值、缓动曲线 | 矩阵与变换 的复合变换一节 |
| 物体姿态不出现万向锁 | 四元数、旋转矩阵 | 由同系列「四元数与旋转」一节承接 |
一句话概括这个板块的三条主线:
- 空间(坐标系):数据现在处在哪个坐标系里?原点在哪、y 朝上还是朝下、z 的范围是多少?
- 变换(向量与矩阵):怎么把它挪到下一个坐标系?平移、旋转、缩放的组合如何用一个矩阵表达?
- 采样(插值 / 噪声 / 颜色):两个数之间如何平滑过渡?随机与规律的边界在哪里?
前 5 页只讲前两条主线——它们是所有渲染代码的地基。
本板块地图
| 页面 | 回答的问题 |
|---|---|
| 数学板块导览 | 数学分别用在哪里、和 API 怎么对应、符号怎么约定 |
| 坐标系与空间 | 右手系 vs 左手系、y 轴方向、模型→屏幕的完整链路、z 的两种区间 |
| 向量 | 加减、模、归一化、点积(投影/夹角)、叉积(法线/面积)、反射与折射 |
| 矩阵与变换 | 列主序内存布局、乘法顺序、T/R/S 推导、齐次坐标、逆与法线矩阵 |
| 3D 变换与相机 | lookAt 推导、透视投影各元素、fov/near/far 的影响、深度精度 |
本板块还有若干页由同系列章节承接:四元数与旋转、插值/缓动与曲线、几何(总览/图元/检测/方程)、颜色与色彩空间、噪声与随机。它们的共同前提正是上面这 5 页,所以按顺序读收益最大。
几何部分拆成了 4 页,建议按这个顺序:几何总览 → 几何图元(怎么表示)→ 几何方程与解方程(怎么列式求解)→ 几何检测(怎么判定与加速)。
为什么把「导览」放在第一页
图形学数学最容易劝退的地方不是公式难,而是不知道这个公式属于链路的哪一环。先记住"模型 → 世界 → 视图 → 裁剪 → NDC → 屏幕"这条链路,之后每个公式都能挂到它的位置上。
与各 API 的对应
同一份数学,在不同 API 里的落点略有差别。这张表是后面几页反复要用的对照:
| 数学概念 | WebGL 1 / 2 | WebGPU | 差异点 |
|---|---|---|---|
mat4(16 个浮点、列主序) | gl.uniformMatrix4fv(loc, false, m) | uniform buffer 里的 mat4x4<f32> 成员 | WebGL 的 transpose 只能传 false;WebGPU 用 WGSL 结构体,还受 16 字节对齐约束 |
vec3 / vec4 | 顶点属性 gl.vertexAttribPointer | @location(n) vec3<f32> | WebGPU 的属性布局要显式写进 vertex state |
| 位置 → 裁剪空间 | gl_Position(vec4,GLSL) | @builtin(position)(WGSL) | 名字不同,含义一致:都是未经除法的齐次坐标 |
| NDC 的深度范围 | z ∈ [-1, 1] | z ∈ [0, 1] | 最容易踩的跨 API 差异,投影矩阵必须配套使用 |
| 视口与 dpr | gl.viewport(0, 0, canvas.width, canvas.height) | context.configure() + 画布尺寸 | 后备存储按 devicePixelRatio 放大,CSS 尺寸单独控制 |
| 颜色分量 | 着色器里 0.0 ~ 1.0 的浮点 | 同左 | 与 CSS 的 0 ~ 255 之间要换算,见 hexToRgb() |
投影矩阵不能跨 API 混用
mat4.perspective()(本站共用工具)产出的是 z ∈ [-1, 1] 的 OpenGL / WebGL 约定。直接把它用到 WebGPU 里(或反过来),深度会整体偏移一半:近处物体被裁掉、深度测试看起来"永远相等"。两套约定之间的换算只是 z_wgpu = (z_gl + 1) / 2,但投影矩阵本身要按约定生成。
学习顺序建议
按依赖关系走,每一步都只用得到上一步的知识:
坐标系 ──► 向量 ──► 矩阵 ──► 3D 变换与相机 ──► 四元数 ──┐
│ │ │
└────── 屏幕换算 ◄────┘ 插值 / 曲线 ◄───────┘1
2
3
2
3
- 坐标系与空间:先把"同一个点在不同空间里有不同坐标"这件事建立起来,否则后面每个矩阵都会觉得是魔法。
- 向量:点积与叉积是着色器里出现频率最高的两个运算(光照、法线、朝向判断)。
- 矩阵与变换:把向量的运算组织成一次变换,理解列主序与乘法顺序。
- 3D 变换与相机:视图矩阵与投影矩阵,也就是把前 3 步拼成完整的渲染管线。
- 之后的四元数、插值、几何相交,都建立在"向量 + 矩阵"之上。
每一步都要动手
数学部分最容易"看懂了但写不出来"。本板块每一页都配了可交互示例:拖动、改参数、看数值同步变化——比在纸上推公式有效得多。
符号约定
后面所有页面都遵循这套约定,避免"同一件事两种写法":
| 约定 | 本站写法 |
|---|---|
| 矩阵存储 | 列主序:m[col * 4 + row],平移分量在 m[12]、m[13]、m[14] |
| 坐标系 | 右手系:+X 向右、+Y 向上、+Z 指向观察者;相机默认看向 −Z |
| 向量 | 写作列向量 (x, y, z),变换写作 M·v(矩阵在左) |
| 乘法顺序 | 从右往左作用:M = T·R·S 表示"先缩放、再旋转、最后平移" |
| 角度 | 一律用弧度参与运算,界面上的度数用 degToRad() 换算 |
| 点积 / 叉积 | `a·b = |
| 深度区间 | 提到 NDC 时默认 WebGL 的 z ∈ [-1, 1],WebGPU 单独标注 z ∈ [0, 1] |
行向量约定会把公式反过来
有些教材与数学库用行向量(v·M,矩阵在右),此时矩阵的转置就是列向量约定下的同一个变换。抄公式时如果发现"平移跑到了最后一行",先确认对方用的是哪种约定——这类错误不会报错,只会让画面悄悄变形。
亲手试一下:交互式向量运算台
下面这块画布把两个向量放在同一个平面上:拖动箭头端点,左侧的几何图形与右侧的数值同时变化。先不用管公式,重点是建立"数值变化 ↔ 图形变化"的直觉:
交互式向量运算台拖动 a、b 的端点:点积的符号、叉积的面积、夹角与投影同步更新
examples/math/vector-ops.jsts
/**
* 【图形学数学 · TypeScript】交互式向量运算台
* ------------------------------------------------------------------
* 拖动两个向量的箭头端点,数值与几何图形**并排**更新:
* · 点积 a·b 的符号决定夹角是锐角 / 直角 / 钝角;
* · 2D 叉积(a×b 的 z 分量)的绝对值 = 两个向量张成的平行四边形面积;
* · 投影 proj_a(b) = (a·b / |a|²)·a 是从 b 的端点向 a 所作垂线的落点。
*
* 书写约定(与 shared/math.js 一致):
* · 数学坐标 y 轴向上,1 个单位 = 66 CSS 像素,两个向量都在 z = 0 平面上;
* · 变换与运算全部走共享的 vec3 工具,保证类型与运行期行为都与文档一致。
*/
import { create2D, rafLoop, cleanupAll } from '../shared/runtime.js'
import { createPanel } from '../shared/ui.js'
import { vec3 } from '../shared/math.js'
import type { Vec3 } from '../shared/math.js'
const RAD_TO_DEG = 180 / Math.PI
/** 吸附步长 15° */
const SNAP = Math.PI / 12
const MIN_LEN = 0.35
const MAX_LEN = 2.4
const HIT_RADIUS = 26
export default function (container: HTMLElement): () => void {
const view = create2D(container, { width: 720, height: 380, background: '#0b1220' })
const { ctx, canvas } = view
/* 几何区:原点在左侧,1 个单位 = SCALE 像素;数值区从 panelLeft 开始 */
const SCALE = 66
const originX = 196
const originY = 200
const plotRadius = 160
const panelLeft = 430
let a: Vec3 = [1.75, 0.7, 0]
let b: Vec3 = [-0.55, 1.6, 0]
let dragging: 'a' | 'b' | null = null
let snapAngle = true
let showProjection = true
let showArea = true
let lastSummary = ''
const toScreen = (v: Vec3): [number, number] => [originX + v[0] * SCALE, originY - v[1] * SCALE]
const toWorld = (px: number, py: number): Vec3 => [(px - originX) / SCALE, (originY - py) / SCALE, 0]
/** 把长度夹到 [MIN_LEN, MAX_LEN],避免拖到原点后方向失去意义 */
function clampLength(v: Vec3): Vec3 {
const len = vec3.length(v) || 1e-6
const capped = Math.max(MIN_LEN, Math.min(len, MAX_LEN))
return vec3.scale(v, capped / len)
}
/** 吸附:只改方向不改长度;太短时方向不稳定,直接跳过 */
function snapDirection(v: Vec3): Vec3 {
const len = vec3.length(v)
if (!snapAngle || len < MIN_LEN * 1.5) return v
const angle = Math.round(Math.atan2(v[1], v[0]) / SNAP) * SNAP
return [Math.cos(angle) * len, Math.sin(angle) * len, 0]
}
/** 指针位置 -> 画布逻辑像素(画布宽度会自适应容器,所以要按 rect 换算) */
function pointerAt(event: PointerEvent): [number, number] {
const rect = canvas.getBoundingClientRect()
const sx = view.width / (rect.width || view.width)
const sy = view.height / (rect.height || view.height)
return [(event.clientX - rect.left) * sx, (event.clientY - rect.top) * sy]
}
function nearestHandle(px: number, py: number): 'a' | 'b' | null {
const pa = toScreen(a)
const pb = toScreen(b)
const da = Math.hypot(px - pa[0], py - pa[1])
const db = Math.hypot(px - pb[0], py - pb[1])
if (Math.min(da, db) > HIT_RADIUS) return null
return da <= db ? 'a' : 'b'
}
/** 带箭头的线段(dashed = true 时画虚线,用于投影的垂线) */
function arrow(from: [number, number], to: [number, number], color: string, width: number, dashed = false): void {
const [x0, y0] = from
const [x1, y1] = to
const angle = Math.atan2(y1 - y0, x1 - x0)
const head = 10
ctx.save()
ctx.setLineDash(dashed ? [5, 4] : [])
ctx.strokeStyle = color
ctx.fillStyle = color
ctx.lineWidth = width
ctx.beginPath()
ctx.moveTo(x0, y0)
ctx.lineTo(x1, y1)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x1 - head * Math.cos(angle - 0.42), y1 - head * Math.sin(angle - 0.42))
ctx.lineTo(x1 - head * Math.cos(angle + 0.42), y1 - head * Math.sin(angle + 0.42))
ctx.closePath()
ctx.fill()
ctx.restore()
}
function drawGrid(): void {
ctx.save()
ctx.strokeStyle = 'rgba(122, 162, 255, 0.14)'
ctx.lineWidth = 1
ctx.beginPath()
for (let u = -3; u <= 3; u++) {
const gx = originX + u * SCALE
const gy = originY + u * SCALE
ctx.moveTo(gx, originY - plotRadius)
ctx.lineTo(gx, originY + plotRadius)
ctx.moveTo(originX - plotRadius, gy)
ctx.lineTo(originX + plotRadius, gy)
}
ctx.stroke()
ctx.strokeStyle = 'rgba(230, 237, 247, 0.32)'
ctx.beginPath()
ctx.moveTo(originX - plotRadius, originY)
ctx.lineTo(originX + plotRadius, originY)
ctx.moveTo(originX, originY - plotRadius)
ctx.lineTo(originX, originY + plotRadius)
ctx.stroke()
/* 单位圆:用来直观估计「长度 = 1」的尺度 */
ctx.setLineDash([4, 4])
ctx.strokeStyle = 'rgba(230, 237, 247, 0.18)'
ctx.beginPath()
ctx.arc(originX, originY, SCALE, 0, Math.PI * 2)
ctx.stroke()
ctx.restore()
}
function drawNumbers(rows: ReadonlyArray<readonly [string, string]>): void {
const { width: w, height: h } = view
ctx.save()
ctx.strokeStyle = 'rgba(122, 162, 255, 0.22)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(panelLeft - 22, 18)
ctx.lineTo(panelLeft - 22, h - 18)
ctx.stroke()
ctx.font = '12px ui-monospace, monospace'
ctx.fillStyle = 'rgba(230, 237, 247, 0.5)'
ctx.fillText('实时数值(拖动左图端点)', panelLeft, 32)
rows.forEach(([label, value], i) => {
const y = 62 + i * 25
ctx.font = '12px ui-monospace, monospace'
ctx.textAlign = 'left'
ctx.fillStyle = 'rgba(230, 237, 247, 0.58)'
ctx.fillText(label, panelLeft, y)
ctx.textAlign = 'right'
ctx.fillStyle = '#e6edf7'
ctx.fillText(value, w - 16, y)
})
ctx.restore()
}
function draw(): void {
const { width: w, height: h } = view
ctx.fillStyle = '#0b1220'
ctx.fillRect(0, 0, w, h)
drawGrid()
const dot = vec3.dot(a, b)
const cross = vec3.cross(a, b)[2]
const lenA = vec3.length(a)
const lenB = vec3.length(b)
const angle = Math.acos(Math.max(-1, Math.min(1, dot / ((lenA * lenB) || 1)))) * RAD_TO_DEG
const proj = vec3.scale(a, dot / (vec3.dot(a, a) || 1))
const pa = toScreen(a)
const pb = toScreen(b)
const pp = toScreen(proj)
/* 1) 叉积面积:以 a、b 为邻边的平行四边形 */
if (showArea) {
ctx.save()
ctx.beginPath()
ctx.moveTo(originX, originY)
ctx.lineTo(pa[0], pa[1])
ctx.lineTo(pa[0] + pb[0] - originX, pa[1] + pb[1] - originY)
ctx.lineTo(pb[0], pb[1])
ctx.closePath()
ctx.fillStyle = 'rgba(255, 209, 102, 0.14)'
ctx.fill()
ctx.setLineDash([5, 4])
ctx.strokeStyle = 'rgba(255, 209, 102, 0.5)'
ctx.lineWidth = 1.2
ctx.stroke()
ctx.restore()
}
/* 2) 投影:从 b 的端点向 a 所在直线作垂线 */
if (showProjection && lenA > 0.05) {
ctx.save()
ctx.setLineDash([5, 4])
ctx.strokeStyle = 'rgba(196, 181, 253, 0.75)'
ctx.lineWidth = 1.4
ctx.beginPath()
ctx.moveTo(pb[0], pb[1])
ctx.lineTo(pp[0], pp[1])
ctx.stroke()
ctx.restore()
arrow([originX, originY], pp, '#c4b5fd', 4)
}
/* 3) 夹角圆弧:跨过 ±180° 时按最短一侧画 */
if (lenA > 0.05 && lenB > 0.05) {
const a0 = Math.atan2(a[1], a[0])
const a1 = Math.atan2(b[1], b[0])
let delta = a1 - a0
while (delta > Math.PI) delta -= Math.PI * 2
while (delta < -Math.PI) delta += Math.PI * 2
ctx.save()
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'
ctx.lineWidth = 1.6
ctx.beginPath()
ctx.arc(originX, originY, 44, -a0, -(a0 + delta), delta > 0)
ctx.stroke()
ctx.restore()
const mid = a0 + delta / 2
ctx.font = '12px ui-monospace, monospace'
ctx.fillStyle = 'rgba(255, 255, 255, 0.78)'
ctx.textAlign = 'center'
ctx.fillText(`${angle.toFixed(1)}°`, originX + Math.cos(mid) * 68, originY - Math.sin(mid) * 68 + 4)
ctx.textAlign = 'left'
}
/* 4) 两个向量本身 */
arrow([originX, originY], pa, '#ff8f6b', 3)
arrow([originX, originY], pb, '#7ee7ce', 3)
ctx.font = 'bold 13px ui-monospace, monospace'
ctx.fillStyle = '#ff8f6b'
ctx.fillText('a', pa[0] + 9, pa[1] - 7)
ctx.fillStyle = '#7ee7ce'
ctx.fillText('b', pb[0] + 9, pb[1] - 7)
ctx.font = '12px ui-monospace, monospace'
ctx.fillStyle = 'rgba(230, 237, 247, 0.42)'
ctx.fillText('1 个单位 = 1 格 · 灰色虚线圆半径为 1', 20, h - 14)
const relation = dot > 1e-6 ? '锐角' : dot < -1e-6 ? '钝角' : '垂直'
drawNumbers([
['a', `(${a[0].toFixed(2)}, ${a[1].toFixed(2)})`],
['b', `(${b[0].toFixed(2)}, ${b[1].toFixed(2)})`],
['|a|', lenA.toFixed(3)],
['|b|', lenB.toFixed(3)],
['a·b = |a||b|·cosθ', `${dot.toFixed(3)} → ${relation}`],
['a×b 的 z 分量', cross.toFixed(3)],
['|a×b| = 平行四边形面积', Math.abs(cross).toFixed(3)],
['θ = acos(a·b / (|a||b|))', `${angle.toFixed(1)}°`],
['proj_a(b) = (a·b/|a|²)·a', `(${proj[0].toFixed(2)}, ${proj[1].toFixed(2)})`],
['|proj_a(b)|', vec3.length(proj).toFixed(3)]
])
const summary = `a·b = ${dot.toFixed(3)} · a×b = ${cross.toFixed(3)} · θ = ${angle.toFixed(1)}°`
if (summary !== lastSummary) {
lastSummary = summary
readout.set(summary)
}
}
const ac = new AbortController()
const opts = { signal: ac.signal }
canvas.addEventListener(
'pointerdown',
(event) => {
const [px, py] = pointerAt(event)
const handle = nearestHandle(px, py)
if (!handle) return
dragging = handle
canvas.setPointerCapture(event.pointerId)
},
opts
)
canvas.addEventListener(
'pointermove',
(event) => {
const [px, py] = pointerAt(event)
if (dragging) {
const next = snapDirection(clampLength(toWorld(px, py)))
if (dragging === 'a') a = next
else b = next
return
}
canvas.style.cursor = nearestHandle(px, py) ? 'grab' : 'default'
},
opts
)
const stopDrag = (): void => {
dragging = null
}
canvas.addEventListener('pointerup', stopDrag, opts)
canvas.addEventListener('pointercancel', stopDrag, opts)
const panel = createPanel(container, { title: '向量运算台' })
const readout = panel.readout('拖动 a、b 的端点')
panel.checkbox({ label: '吸附到 15°', value: snapAngle, onChange: (v) => (snapAngle = v) })
panel.checkbox({ label: '显示投影 proj_a(b)', value: showProjection, onChange: (v) => (showProjection = v) })
panel.checkbox({ label: '显示叉积平行四边形', value: showArea, onChange: (v) => (showArea = v) })
panel.button({
label: '重置向量',
onClick: () => {
a = [1.75, 0.7, 0]
b = [-0.55, 1.6, 0]
}
})
panel.note('点积的符号决定夹角类型;叉积的 z 分量等于平行四边形面积,符号表示 b 在 a 的哪一侧。')
const loop = rafLoop(draw)
return cleanupAll(loop, () => ac.abort(), view, panel)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
示例里有三个值得留意的实现细节:
- 两个向量都存成
Vec3(类型来自shared/math.d.ts),z 分量固定为 0,因此 2D 叉积就是vec3.cross(a, b)[2],与 3D 公式完全一致; - 投影用的是
proj_a(b) = (a·b / |a|²)·a:分母是a·a而不是|a|,省掉一次开方(这类细节见向量的性能提示); - 数学坐标 y 轴向上,画布坐标 y 轴向下,因此换算时对 y 取反——这正是坐标系与空间要解决的问题。
常见坑 / 易错点
度数直接喂给三角函数
Math.sin(90) 得到的是 90 弧度的正弦(约 0.894),不是 1。所有旋转矩阵、fov、角度插值都必须先 degToRad()。表现是"物体转了一点点就歪了",而不是报错。
行列主序搞混
把按行主序排列的 16 个数交给 gl.uniformMatrix4fv,画面的表现往往是"平移方向不对""旋转轴像被换了"。排查办法:打印 m[12]、m[13]、m[14],它们应该等于平移量。
NDC 的 z 区间跨 API 混用
WebGL 的 z ∈ [-1, 1] 与 WebGPU 的 z ∈ [0, 1] 不能共用同一个投影矩阵。混用之后的典型现象是深度测试恒定通过或恒定失败,而不是画面全黑,所以要专门留意。
排查顺序:先空间、再变换、最后公式
画面不对时,按"这个点现在在哪个空间?→ 这个矩阵把哪个空间映到哪个空间?→ 公式的分量顺序对不对?"的顺序查,比盯着公式找错快得多。
小结
- 图形学数学的三条主线是空间、变换、采样;本板块前 5 页讲前两条。
- 数学对象与 API 的对应关系是固定的:
mat4→ WebGL uniform / WebGPU uniform buffer,vec3→ 顶点属性或 uniform。 - 两套 API 最大的差异是 NDC 的 z 区间:WebGL
[-1, 1],WebGPU[0, 1]。 - 全站统一约定:列主序、右手系、列向量、弧度、矩阵乘法从右往左作用。
- 建议顺序:坐标系 → 向量 → 矩阵 → 3D 变换与相机 → 再进入四元数等后续章节。