WebGPU 透视投影
原作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-perspective-projection.html
本文是 3D 数学系列教程的第六篇,每一篇都建立在上一篇的基础之上,建议按顺序阅读。在前一篇文章中我们介绍了三维变换,但那并没有任何透视效果——它使用的是所谓的“正交”视图。正交投影有它的用途,但通常人们说到“3D”时指的并不是它。我们需要添加透视。什么是透视?基本上就是“距离越远的物体看起来越小”。
观察上图可以看到,远处的物体绘制得更小。基于我们当前的示例,一个简单的方法让远处物体变小,就是将裁剪空间(clip space)中的 X 和 Y 除以 Z。
可以这样理解:如果有一条从 (10, 15) 到 (20, 15) 的线段,长度是 10 个单位。在当前示例中,它会被绘制成 10 个像素长。但如果除以 Z,例如 Z = 1 时:
10 / 1 = 10
20 / 1 = 20
abs(10 - 20) = 10
它仍然是 10 个像素长;当 Z = 2 时:
10 / 2 = 5
20 / 2 = 10
abs(5 - 10) = 5
变成 5 个像素长;当 Z = 3 时:
10 / 3 = 3.333
20 / 3 = 6.666
abs(3.333 - 6.666) = 3.333
可以看出,随着 Z 增大(物体变远),屏幕上的线段变短,物体看起来更远。如果在裁剪空间中进行除法,因为 Z 是较小的数(0 到 +1),效果会更好。我们还可以添加一个“调节因子”(fudgeFactor)在除以 Z 之前与之相乘,从而调整在给定距离上物体变小的程度。
先尝试在顶点着色器中将位置乘以矩阵之后再除以 Z。
struct Uniforms {
matrix: mat4x4f,
fudgeFactor: f32,
};
struct Vertex {
@location(0) position: vec4f,
@location(1) color: vec4f,
};
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) color: vec4f,
};
@group(0) @binding(0) `var<uniform>` uni: Uniforms;
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
let position = uni.matrix * vert.position;
let zToDivideBy = 1.0 + position.z * uni.fudgeFactor;
vsOut.position = vec4f(
position.xy / zToDivideBy,
position.zw);
vsOut.color = vert.color;
return vsOut;
}
注意:加上 1 之后,当 fudgeFactor 为 0 时 zToDivideBy 就等于 1,这就相当于没有除以 Z,方便对比。
同时更新 JavaScript 代码以设置 fudgeFactor:
// matrix, fudgeFactor, padding
const uniformBufferSize = (16 + 1 + 3) * 4;
const uniformBuffer = device.createBuffer({
label: 'uniforms',
size: uniformBufferSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const uniformValues = new Float32Array(uniformBufferSize / 4);
// offsets to the various uniform values in float32 indices
const kMatrixOffset = 0;
const kFudgeFactorOffset = 16;
const matrixValue = uniformValues.subarray(kMatrixOffset, kMatrixOffset + 16);
const fudgeFactorValue = uniformValues.subarray(kFudgeFactorOffset, kFudgeFactorOffset + 1);
// ...
const settings = {
translation: [canvas.clientWidth / 2 - 200, canvas.clientHeight / 2 - 75, -1000],
rotation: [degToRad(40), degToRad(25), degToRad(325)],
scale: [3, 3, 3],
fudgeFactor: 0.5,
};
// ...
const gui = new GUI();
gui.onChange(render);
gui.add(settings.translation, '0', 0, 1000).name('translation.x');
gui.add(settings.translation, '1', 0, 1000).name('translation.y');
gui.add(settings.translation, '2', -1000, 1000).name('translation.z');
gui.add(settings.rotation, '0', radToDegOptions).name('rotation.x');
gui.add(settings.rotation, '1', radToDegOptions).name('rotation.y');
gui.add(settings.rotation, '2', radToDegOptions).name('rotation.z');
gui.add(settings.scale, '0', -5, 5).name('scale.x');
gui.add(settings.scale, '1', -5, 5).name('scale.y');
gui.add(settings.scale, '2', -5, 5).name('scale.z');
gui.add(settings, 'fudgeFactor', 0, 50);
// ...
function render() {
// ...
mat4.ortho(
0, // left
canvas.clientWidth, // right
canvas.clientHeight, // bottom
0, // top
1200, // near
-1000, // far
matrixValue, // dst
);
mat4.translate(matrixValue, settings.translation, matrixValue);
mat4.rotateX(matrixValue, settings.rotation[0], matrixValue);
mat4.rotateY(matrixValue, settings.rotation[1], matrixValue);
mat4.rotateZ(matrixValue, settings.rotation[2], matrixValue);
mat4.scale(matrixValue, settings.scale, matrixValue);
fudgeFactorValue[0] = settings.fudgeFactor;
}
调整后的 settings 使效果更容易观察:
const settings = {
translation: [canvas.clientWidth / 2 - 200, canvas.clientHeight / 2 - 75, -1000],
rotation: [degToRad(40), degToRad(25), degToRad(325)],
scale: [3, 3, 3],
fudgeFactor: 10,
};
运行结果如下:
点击此处在新窗口打开。
如果效果不明显,把“fudgeFactor”滑块从 10.0 拖到 0.0,就能看到未添加透视除法之前的样子。
正交 vs 透视
事实证明,WebGPU 会自动将我们赋给 @builtin(position) 的 x、y、z、w 值除以 w。我们可以很容易地验证这一点:修改着色器,不再手动除法,而是将 zToDivideBy 放入 vsOut.position.w。
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
let position = uni.matrix * vert.position;
let zToDivideBy = 1.0 + position.z * uni.fudgeFactor;
vsOut.position = vec4f(position.xyz, zToDivideBy);
vsOut.color = vert.color;
return vsOut;
}
渲染结果完全相同。
点击此处在新窗口打开。
为什么 WebGPU 自动除以 W 会很有用?因为借助矩阵的威力,我们可以使用另一个矩阵把 z 复制到 w。
如下矩阵:
1 0 0 0
0 1 0 0
0 0 1 0
0 0 1 0
就可以将 z 复制到 w。把每一行看作:
x_out = x_in * 1 + y_in * 0 + z_in * 0 + w_in * 0;
y_out = x_in * 0 + y_in * 1 + z_in * 0 + w_in * 0;
z_out = x_in * 0 + y_in * 0 + z_in * 1 + w_in * 0;
w_out = x_in * 0 + y_in * 0 + z_in * 1 + w_in * 0;
简化后就是:
x_out = x_in;
y_out = y_in;
z_out = z_in;
w_out = z_in;
我们可以在矩阵中加上之前的“加 1”操作,因为 w_in 始终是 1.0:
1 0 0 0
0 1 0 0
0 0 1 0
0 0 1 1
这样 w 的计算就变为:
w_out = x_in * 0 + y_in * 0 + z_in * 1 + w_in * 1;
又因为 w_in = 1.0,实际上就是:
w_out = z_in + 1;
最后把 fudgeFactor 也并入矩阵:
1 0 0 0
0 1 0 0
0 0 1 0
0 0 fudgeFactor 1
这意味着:
w_out = x_in * 0 + y_in * 0 + z_in * fudgeFactor + w_in * 1;
简化后:
w_out = z_in * fudgeFactor + 1;
现在让我们修改程序,完全使用矩阵完成。
先将顶点着色器恢复简单形式:
struct Uniforms {
matrix: mat4x4f,
};
struct Vertex {
@location(0) position: vec4f,
@location(1) color: vec4f,
};
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) color: vec4f,
};
@group(0) @binding(0) `var<uniform>` uni: Uniforms;
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
vsOut.position = uni.matrix * vert.position;
vsOut.color = vert.color;
return vsOut;
}
@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
return vsOut.color;
}
然后添加一个生成 “Z → W” 矩阵的函数:
function makeZToWMatrix(fudgeFactor) {
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, fudgeFactor,
0, 0, 0, 1,
];
}
修改 render 函数使用它:
const projection = mat4.ortho(
0, // left
canvas.clientWidth, // right
canvas.clientHeight, // bottom
0, // top
1200, // near
-1000, // far
);
mat4.multiply(makeZToWMatrix(settings.fudgeFactor), projection, matrixValue);
mat4.translate(matrixValue, settings.translation, matrixValue);
mat4.rotateX(matrixValue, settings.rotation[0], matrixValue);
mat4.rotateY(matrixValue, settings.rotation[1], matrixValue);
mat4.rotateZ(matrixValue, settings.rotation[2], matrixValue);
mat4.scale(matrixValue, settings.scale, matrixValue);
同样,渲染结果完全相同。
点击此处在新窗口打开。
上述内容基本上就是为了说明:除以 Z 可以带来透视效果,而 WebGPU 很方便地自动执行了这一除法。
但仍有一些问题。例如,当把 Z 设置到大约 -1100 时,会看到类似下面动画的现象:
发生了什么?为什么 “F” 提前消失了?就像 WebGPU 裁剪 X 和 Y 到 -1 到 +1 一样,它也会裁剪 Z。与 X、Y 不同,Z 裁剪范围是 0 到 +1。我们看到的是裁剪空间中的 Z < 0。
由于除以 W 的存在,矩阵运算加上除以 W 定义了一个视锥体(frustum)。视锥体的前端是 Z = 0,后端是 Z = 1,超出这个范围的部分都会被裁剪掉。
frustum
名词
- 一个锥体或金字塔,其顶部被一个与其底面平行的平面截去。
我可以详细讲解修复它的数学推导,但你可以用推导 2D 投影相同的方式得到它。我们需要对 Z 做平移和缩放,从而把任意范围重新映射到 0 到 +1。
所有这些步骤都可以在一个矩阵中完成。更好的是,我们不再使用 fudgeFactor,而是决定一个 fieldOfView(视场角)并计算出合适的值。
下面是构建矩阵的函数:
const mat4 = {
// ...
perspective(fieldOfViewYInRadians, aspect, zNear, zFar, dst) {
dst = dst || new Float32Array(16);
const f = Math.tan(Math.PI * 0.5 - 0.5 * fieldOfViewYInRadians);
const rangeInv = 1 / (zNear - zFar);
dst[0] = f / aspect;
dst[1] = 0;
dst[2] = 0;
dst[3] = 0;
dst[4] = 0;
dst[5] = f;
dst[6] = 0;
dst[7] = 0;
dst[8] = 0;
dst[9] = 0;
dst[10] = zFar * rangeInv;
dst[11] = -1;
dst[12] = 0;
dst[13] = 0;
dst[14] = zNear * zFar * rangeInv;
dst[15] = 0;
return dst;
}
};
这个矩阵会完成所有转换:调整单位到裁剪空间,根据视场角计算缩放,并让我们选择 Z 裁剪范围。它假设在原点 (0,0,0) 处有一个眼睛或相机,给定 zNear 和 fieldOfView,它会计算出:位于 zNear 的物体最终在 Z = 0 处;zNear 处上下偏离中心 fieldOfView/2 的物体最终在 Y = -1 和 Y = 1 处。X 方向的缩放直接乘上 aspect,通常设为显示区域的 width / height。最后,它计算出 Z 的缩放量,使 zFar 处的物体最终在 Z = 1。
下面是矩阵作用示意图:
该矩阵将视锥体内的空间转换到裁剪空间。zNear 决定前端裁剪的位置,zFar 决定后端裁剪的位置。把 zNear 设为 23,会看到旋转立方体的前部被裁剪;把 zFar 设为 24,会看到立方体的后部被裁剪。
在示例中使用这个函数:
const settings = {
fieldOfView: degToRad(100),
translation: [canvas.clientWidth / 2 - 200, canvas.clientHeight / 2 - 75, -1000],
rotation: [degToRad(40), degToRad(25), degToRad(325)],
scale: [3, 3, 3],
};
const radToDegOptions = { min: -360, max: 360, step: 1, converters: GUI.converters.radToDeg };
const gui = new GUI();
gui.onChange(render);
gui.add(settings, 'fieldOfView', {min: 1, max: 179, converters: GUI.converters.radToDeg});
gui.add(settings.translation, '0', 0, 1000).name('translation.x');
gui.add(settings.translation, '1', 0, 1000).name('translation.y');
gui.add(settings.translation, '2', -1400, 1000).name('translation.z');
gui.add(settings.rotation, '0', radToDegOptions).name('rotation.x');
gui.add(settings.rotation, '1', radToDegOptions).name('rotation.y');
gui.add(settings.rotation, '2', radToDegOptions).name('rotation.z');
gui.add(settings.scale, '0', -5, 5).name('scale.x');
gui.add(settings.scale, '1', -5, 5).name('scale.y');
gui.add(settings.scale, '2', -5, 5).name('scale.z');
// ...
function render() {
// ...
const aspect = canvas.clientWidth / canvas.clientHeight;
mat4.perspective(
settings.fieldOfView,
aspect,
1, // zNear
2000, // zFar
matrixValue,
);
mat4.translate(matrixValue, settings.translation, matrixValue);
mat4.rotateX(matrixValue, settings.rotation[0], matrixValue);
mat4.rotateY(matrixValue, settings.rotation[1], matrixValue);
mat4.rotateZ(matrixValue, settings.rotation[2], matrixValue);
mat4.scale(matrixValue, settings.scale, matrixValue);
}
现在只剩一个问题。这个透视投影矩阵假设观察者位于 (0,0,0),朝向负 Z 方向,且正 Y 朝上。我们之前的矩阵设置与此不同。我们的 F 高 150 单位、宽 100 单位、厚 30 单位,需要将它放到某个 -Z 位置,并且足够远以便完全落在视锥体内。上面定义的视锥体(zNear = 1)在距离 1 个单位时上下只能看到约 2.4 个单位,所以我们的 F 将有 98% 在屏幕之外。
经过试调,我得到了这些设置:
const settings = {
fieldOfView: degToRad(100),
translation: [-65, 0, -120],
rotation: [degToRad(220), degToRad(25), degToRad(325)],
scale: [1, 1, 1],
};
同时调整 UI,去掉缩放控件以免界面杂乱:
const gui = new GUI();
gui.onChange(render);
gui.add(settings, 'fieldOfView', {min: 1, max: 179, converters: GUI.converters.radToDeg});
gui.add(settings.translation, '0', -1000, 1000).name('translation.x');
gui.add(settings.translation, '1', -1000, 1000).name('translation.y');
gui.add(settings.translation, '2', -1400, -100).name('translation.z');
gui.add(settings.rotation, '0', radToDegOptions).name('rotation.x');
gui.add(settings.rotation, '1', radToDegOptions).name('rotation.y');
gui.add(settings.rotation, '2', radToDegOptions).name('rotation.z');
去掉网格,因为我们不再处于“像素空间”:
:root {
--bg-color: #fff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #000;
}
}
canvas {
display: block; /* make the canvas act like a block */
width: 100%; /* make the canvas fill its container */
height: 100%;
}
最终结果:
点击此处在新窗口打开。
着色器中又只有一个矩阵乘法,而且我们既获得了视场角控制,也获得了可选的 Z 空间。
接下来将介绍相机。
为什么要把 F 移到 Z = -120 那么远?
在其他示例中,F 位于 (45, 100, 0),但在最后一个示例中被移到 (-65, 0, -120)。为什么需要移得那么远?
原因是直到上一个示例为止,我们的投影函数是从像素空间转换到裁剪空间。这意味着显示区域某种程度上代表像素。但在 3D 中使用“像素”并没有意义,因为它只在距离相机特定距离处才代表像素。
换句话说,使用新的透视投影矩阵时,如果尝试将 F 放在 (0,0,0) 并且旋转为 0,0,0,会出现以下情况:
F 的左上角在原点。透视投影矩阵看向负 Z 方向,但我们的 F 构建在正 Z 方向。透视投影矩阵正 Y 向上,但我们的 F 是正 Y 向下。
新的投影只能看到蓝色视锥体内的内容。当 -zNear = 1 且视场角为 100 度时,在 Z = -1 处视锥体仅高 2.38 个单位,宽 2.38 * aspect 个单位。在 Z = -2000(-zFar)处,高度为 4767 个单位。由于我们的 F 有 150 个单位大小,而视锥在前端只能看到 2.38 个单位,因此需要将其移到更远的地方才能完整看到它。
将它在 Z 方向移动 -120 个单位,F 就进入了视锥体。同时我们还旋转了它,使它正向朝上。
(不按比例)
核心优化要点
-
利用 GPU 自动透视除法,减少着色器显式计算
WebGPU(以及其他现代图形 API)会自动将顶点着色器输出@builtin(position)的 xyz 坐标除以 w 分量。通过将深度相关因子放入 w(例如把z_to_divide_by = 1 + z * fudgeFactor写入position.w),可以避免在着色器中执行手动除法,简化着色器代码并减少指令开销。 -
使用标准透视投影矩阵统一所有透视变换
将透视投影封装为一个矩阵函数(mat4.perspective(fovY, aspect, zNear, zFar)),一次性完成以下转换:根据垂直视场角缩放 X/Y,以适应裁剪空间;将 Z 从 [zNear, zFar] 映射到 [0, 1];并将深度写入 w 分量以触发自动除法。这取代了零散的fudgeFactor手动调整,使参数更直观、更可控。 -
合理设置视锥参数并调整模型到视锥体内
透视投影假定相机位于原点,朝向负 Z,正 Y 朝上。在从像素空间切换到 3D 空间时,要重新计算模型位置(例如将 F 移至-120的 Z 位置)并选择合适的zNear、zFar和fieldOfView,确保物体完全包含在视锥体内,避免被近/远裁剪面截断或视图过大过小。
术语检查:WGSL/WebGPU 与 GLSL/WebGL 对照
原文代码使用的是 WGSL(WebGPU Shader Language) 而非 GLSL(OpenGL Shading Language / WebGL)。虽然概念相通,但 API 和语法存在差异。下表列出本文涉及的重要术语及对应的 GLSL/WebGL 概念:
| 英文术语(WGSL / WebGPU) | 中文术语 | 对应 GLSL / WebGL 概念 |
|---|---|---|
| clip space | 裁剪空间 | clip space(相同) |
| frustum | 视锥体 | frustum(相同) |
| field of view (FOV) | 视场角 | field of view(相同) |
| zNear / zFar | 近裁剪面 / 远裁剪面 | near / far plane |
| orthographic projection | 正交投影 | orthographic projection |
| perspective projection | 透视投影 | perspective projection |
| @builtin(position) | 内置 position 变量 | gl_Position |
| @location(n) | 位置属性 | layout(location = n) |
| @group(n) @binding(m) | 绑定组 / 绑定点 | uniform / buffer 绑定,概念不同 |
| vec4f | 四分量浮点向量 | vec4 |
| mat4x4f | 4×4 浮点矩阵 | mat4 |
| uniform buffer | 统一缓冲区 | uniform buffer object (UBO) |
| vertex shader / fragment shader | 顶点着色器 / 片元着色器 | vertex shader / fragment shader |
| GPUBufferUsage.UNIFORM | 统一缓冲区用途标志 | UNIFORM_BUFFER 目标 |
| GPUBufferUsage.COPY_DST | 拷贝目标用途标志 | 对应 gl.bufferData 的写操作 |
注意:虽然 WebGPU 的管线及绑定模型与 WebGL 差异很大,但图形学中的数学概念(矩阵变换、透视除法、视锥体裁剪等)是完全通用的。从 WebGL/GLSL 迁移到 WebGPU/WGSL 时,核心数学和投影矩阵逻辑可以复用,只需调整 API 调用和着色器语法。
