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
名词

  1. 一个锥体或金字塔,其顶部被一个与其底面平行的平面截去。

我可以详细讲解修复它的数学推导,但你可以用推导 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 过大且方向错误

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 就进入了视锥体。同时我们还旋转了它,使它正向朝上。

F 正向朝上

(不按比例)


WebGPU Perspective Projection

核心优化要点

  1. 利用 GPU 自动透视除法,减少着色器显式计算
    WebGPU(以及其他现代图形 API)会自动将顶点着色器输出 @builtin(position) 的 xyz 坐标除以 w 分量。通过将深度相关因子放入 w(例如把 z_to_divide_by = 1 + z * fudgeFactor 写入 position.w),可以避免在着色器中执行手动除法,简化着色器代码并减少指令开销。

  2. 使用标准透视投影矩阵统一所有透视变换
    将透视投影封装为一个矩阵函数(mat4.perspective(fovY, aspect, zNear, zFar)),一次性完成以下转换:根据垂直视场角缩放 X/Y,以适应裁剪空间;将 Z 从 [zNear, zFar] 映射到 [0, 1];并将深度写入 w 分量以触发自动除法。这取代了零散的 fudgeFactor 手动调整,使参数更直观、更可控。

  3. 合理设置视锥参数并调整模型到视锥体内
    透视投影假定相机位于原点,朝向负 Z,正 Y 朝上。在从像素空间切换到 3D 空间时,要重新计算模型位置(例如将 F 移至 -120 的 Z 位置)并选择合适的 zNear、zFar 和 fieldOfView,确保物体完全包含在视锥体内,避免被近/远裁剪面截断或视图过大过小。


Why did we move the F so far in Z (-120)?

术语检查: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
mat4x4f4×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 调用和着色器语法。

Why did we move the F so far in Z (-120)?