WebGPU 相机(WebGPU Cameras)

原文作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-cameras.html

引言

本文是 WebGPU 3D 数学系列的第 7 篇,核心主题是相机(Camera)与视图矩阵(View Matrix)。上一篇我们使用透视投影矩阵时必须将物体手动移动到视锥体(frustum)的 -Z 轴前方,因为该矩阵将观察点(眼睛)固定在原点 (0, 0, 0)。现实中我们习惯移动相机去拍摄物体,但在投影矩阵的约束下,正确的做法是反过来移动整个世界,使相机等效地位于原点。这正是本文要解决的核心问题,并通过逆矩阵、lookAt 以及 aim 函数逐步优化相机控制的实现方式。


一、从移动相机到移动世界

在 3D 渲染中,投影矩阵(如 mat4.perspective)假定相机位于原点,看向 -Z 方向。为了模拟“相机移动”的效果,我们需要计算一个视图矩阵(View Matrix),该矩阵可以将场景中的物体变换到相机坐标系中。视图矩阵本质上就是相机世界变换矩阵的逆矩阵(Inverse Matrix)。

  • 如果相机在世界上平移了 (123, 0, 0),那么视图矩阵需要将全部物体平移 (-123, 0, 0)。
  • 如果相机绕 X 轴旋转了 30°,视图矩阵就旋转 -30°。
  • 如果相机缩放了 5 倍,视图矩阵就缩放 1/5。

文章首先将原来的 2D「F」顶点数据适配为 3D 坐标系(Y 轴向上),并翻转三角形绕序(winding order)以配合背面剔除(back-face culling)。随后实现了通用的 4×4 逆矩阵计算函数,并利用它为每个物体创建独立的 uniform buffer 和 bind group。

核心代码:逆矩阵 mat4.inverse

const mat4 = {
  // ...
  inverse(m, dst) {
    dst = dst || new Float32Array(16);

    const m00 = m[0 * 4 + 0];
    const m01 = m[0 * 4 + 1];
    const m02 = m[0 * 4 + 2];
    const m03 = m[0 * 4 + 3];
    const m10 = m[1 * 4 + 0];
    const m11 = m[1 * 4 + 1];
    const m12 = m[1 * 4 + 2];
    const m13 = m[1 * 4 + 3];
    const m20 = m[2 * 4 + 0];
    const m21 = m[2 * 4 + 1];
    const m22 = m[2 * 4 + 2];
    const m23 = m[2 * 4 + 3];
    const m30 = m[3 * 4 + 0];
    const m31 = m[3 * 4 + 1];
    const m32 = m[3 * 4 + 2];
    const m33 = m[3 * 4 + 3];

    const tmp0 = m22 * m33;
    const tmp1 = m32 * m23;
    const tmp2 = m12 * m33;
    const tmp3 = m32 * m13;
    const tmp4 = m12 * m23;
    const tmp5 = m22 * m13;
    const tmp6 = m02 * m33;
    const tmp7 = m32 * m03;
    const tmp8 = m02 * m23;
    const tmp9 = m22 * m03;
    const tmp10 = m02 * m13;
    const tmp11 = m12 * m03;
    const tmp12 = m20 * m31;
    const tmp13 = m30 * m21;
    const tmp14 = m10 * m31;
    const tmp15 = m30 * m11;
    const tmp16 = m10 * m21;
    const tmp17 = m20 * m11;
    const tmp18 = m00 * m31;
    const tmp19 = m30 * m01;
    const tmp20 = m00 * m21;
    const tmp21 = m20 * m01;
    const tmp22 = m00 * m11;
    const tmp23 = m10 * m01;

    const t0 = (tmp0 * m11 + tmp3 * m21 + tmp4 * m31) -
               (tmp1 * m11 + tmp2 * m21 + tmp5 * m31);
    const t1 = (tmp1 * m01 + tmp6 * m21 + tmp9 * m31) -
               (tmp0 * m01 + tmp7 * m21 + tmp8 * m31);
    const t2 = (tmp2 * m01 + tmp7 * m11 + tmp10 * m31) -
               (tmp3 * m01 + tmp6 * m11 + tmp11 * m31);
    const t3 = (tmp5 * m01 + tmp8 * m11 + tmp11 * m21) -
               (tmp4 * m01 + tmp9 * m11 + tmp10 * m21);

    const d = 1 / (m00 * t0 + m10 * t1 + m20 * t2 + m30 * t3);

    dst[0] = d * t0;
    dst[1] = d * t1;
    dst[2] = d * t2;
    dst[3] = d * t3;

    dst[4] = d * ((tmp1 * m10 + tmp2 * m20 + tmp5 * m30) -
                  (tmp0 * m10 + tmp3 * m20 + tmp4 * m30));
    dst[5] = d * ((tmp0 * m00 + tmp7 * m20 + tmp8 * m30) -
                  (tmp1 * m00 + tmp6 * m20 + tmp9 * m30));
    dst[6] = d * ((tmp3 * m00 + tmp6 * m10 + tmp11 * m30) -
                  (tmp2 * m00 + tmp7 * m10 + tmp10 * m30));
    dst[7] = d * ((tmp4 * m00 + tmp9 * m10 + tmp10 * m20) -
                  (tmp5 * m00 + tmp8 * m10 + tmp11 * m20));

    dst[8] = d * ((tmp12 * m13 + tmp15 * m23 + tmp16 * m33) -
                  (tmp13 * m13 + tmp14 * m23 + tmp17 * m33));
    dst[9] = d * ((tmp13 * m03 + tmp18 * m23 + tmp21 * m33) -
                  (tmp12 * m03 + tmp19 * m23 + tmp20 * m33));
    dst[10] = d * ((tmp14 * m03 + tmp19 * m13 + tmp22 * m33) -
                   (tmp15 * m03 + tmp18 * m13 + tmp23 * m33));
    dst[11] = d * ((tmp17 * m03 + tmp20 * m13 + tmp23 * m23) -
                   (tmp16 * m03 + tmp21 * m13 + tmp22 * m23));

    dst[12] = d * ((tmp14 * m22 + tmp17 * m32 + tmp13 * m12) -
                   (tmp16 * m32 + tmp12 * m12 + tmp15 * m22));
    dst[13] = d * ((tmp20 * m32 + tmp12 * m02 + tmp19 * m22) -
                   (tmp18 * m22 + tmp21 * m32 + tmp13 * m02));
    dst[14] = d * ((tmp18 * m12 + tmp23 * m32 + tmp15 * m02) -
                   (tmp22 * m32 + tmp14 * m02 + tmp19 * m12));
    dst[15] = d * ((tmp22 * m22 + tmp16 * m02 + tmp21 * m12) -
                   (tmp20 * m12 + tmp23 * m22 + tmp17 * m02));
    return dst;
  },
  // ...
};

渲染流程:计算视图矩阵并合并投影矩阵

function render() {
  // 计算投影矩阵(每帧一次)
  const aspect = canvas.clientWidth / canvas.clientHeight;
  const projection = mat4.perspective(
      settings.fieldOfView,
      aspect,
      1,      // zNear
      2000,   // zFar
  );

  // 根据 cameraAngle 计算相机矩阵(绕 Y 轴旋转并向外平移)
  const cameraMatrix = mat4.rotationY(settings.cameraAngle);
  mat4.translate(cameraMatrix, [0, 0, radius * 1.5], cameraMatrix);

  // 通过逆矩阵得到视图矩阵
  const viewMatrix = mat4.inverse(cameraMatrix);

  // 合并视图矩阵与投影矩阵(重要优化)
  const viewProjectionMatrix = mat4.multiply(projection, viewMatrix);

  objectInfos.forEach(({ matrixValue, uniformBuffer, uniformValues, bindGroup }, i) => {
    const angle = i / numFs * Math.PI * 2;
    const x = Math.cos(angle) * radius;
    const z = Math.sin(angle) * radius;

    // 对每个物体,从 viewProjectionMatrix 开始再添加平移
    mat4.translate(viewProjectionMatrix, [x, 0, z], matrixValue);

    device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
    pass.setBindGroup(0, bindGroup);
    pass.draw(numVertices);
  });
}

二、使用 lookAt / cameraAim 简化相机定位

直接使用 rotationY + translate 来调整相机指向特定物体并不直观。文章介绍了更通用的方法:给定眼睛位置(eye)、目标位置(target) 和上方向(up),通过叉积(cross product)构建相机的三个正交轴,进而得到相机矩阵。

构建步骤:

  1. Z 轴:zAxis = normalize(eye - target) (对于相机,通常指向 -Z)
  2. X 轴:xAxis = normalize(cross(up, zAxis))
  3. Y 轴:yAxis = normalize(cross(zAxis, xAxis))
  4. 将三个轴写入矩阵的旋转部分,将 eye 写入平移部分。

核心代码:向量叉积、减法与归一化

const vec3 = {
  cross(a, b, dst) {
    dst = dst || new Float32Array(3);
    const t0 = a[1] * b[2] - a[2] * b[1];
    const t1 = a[2] * b[0] - a[0] * b[2];
    const t2 = a[0] * b[1] - a[1] * b[0];
    dst[0] = t0;
    dst[1] = t1;
    dst[2] = t2;
    return dst;
  },
  subtract(a, b, dst) {
    dst = dst || new Float32Array(3);
    dst[0] = a[0] - b[0];
    dst[1] = a[1] - b[1];
    dst[2] = a[2] - b[2];
    return dst;
  },
  normalize(v, dst) {
    dst = dst || new Float32Array(3);
    const length = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
    if (length > 0.00001) {
      dst[0] = v[0] / length;
      dst[1] = v[1] / length;
      dst[2] = v[2] / length;
    } else {
      dst[0] = 0;
      dst[1] = 0;
      dst[2] = 0;
    }
    return dst;
  },
};

核心代码:cameraAim 与 lookAt

const mat4 = {
  // 相机瞄准矩阵(直接返回相机矩阵,而非视图矩阵)
  cameraAim(eye, target, up, dst) {
    dst = dst || new Float32Array(16);

    const zAxis = vec3.normalize(vec3.subtract(eye, target));
    const xAxis = vec3.normalize(vec3.cross(up, zAxis));
    const yAxis = vec3.normalize(vec3.cross(zAxis, xAxis));

    dst[ 0] = xAxis[0];  dst[ 1] = xAxis[1];  dst[ 2] = xAxis[2];  dst[ 3] = 0;
    dst[ 4] = yAxis[0];  dst[ 5] = yAxis[1];  dst[ 6] = yAxis[2];  dst[ 7] = 0;
    dst[ 8] = zAxis[0];  dst[ 9] = zAxis[1];  dst[10] = zAxis[2];  dst[11] = 0;
    dst[12] = eye[0];    dst[13] = eye[1];    dst[14] = eye[2];    dst[15] = 1;

    return dst;
  },

  // lookAt 返回视图矩阵(cameraAim 的逆矩阵)
  lookAt(eye, target, up, dst) {
    return mat4.inverse(mat4.cameraAim(eye, target, up, dst), dst);
  },
};

使用方式:

// 直接计算视图矩阵
const viewMatrix = mat4.lookAt(eye, fPosition, up);
// 合并为视图投影矩阵
const viewProjectionMatrix = mat4.multiply(projection, viewMatrix);

三、aim 函数:让物体朝向目标

cameraAim 适用于相机(默认看向 -Z),而 aim 函数则用于让普通物体朝向目标(默认指向 +Z)。其区别仅在于 Z 轴的方向:

const mat4 = {
  // 物体瞄准矩阵:使物体 +Z 轴指向目标
  aim(eye, target, up, dst) {
    dst = dst || new Float32Array(16);

    const zAxis = vec3.normalize(vec3.subtract(target, eye)); // 注意与 cameraAim 相反
    const xAxis = vec3.normalize(vec3.cross(up, zAxis));
    const yAxis = vec3.normalize(vec3.cross(zAxis, xAxis));

    dst[ 0] = xAxis[0];  dst[ 1] = xAxis[1];  dst[ 2] = xAxis[2];  dst[ 3] = 0;
    dst[ 4] = yAxis[0];  dst[ 5] = yAxis[1];  dst[ 6] = yAxis[2];  dst[ 7] = 0;
    dst[ 8] = zAxis[0];  dst[ 9] = zAxis[1];  dst[10] = zAxis[2];  dst[11] = 0;
    dst[12] = eye[0];    dst[13] = eye[1];    dst[14] = eye[2];    dst[15] = 1;

    return dst;
  },
  // cameraAim 保留用于相机
};

文章最后演示了让 25 个「F」同时瞄准第 26 个「F」的效果,充分展示了 aim 的威力。


四、三个核心优化要点

1. 合并视图矩阵与投影矩阵,避免逐物体重复乘法

在渲染循环中,对每个物体都需要执行一次 模型矩阵 × 视图矩阵 × 投影矩阵。如果视图矩阵和投影矩阵保持不变,则可以先计算一次 viewProjectionMatrix = projection * view,然后对每个物体只需执行 model * viewProjectionMatrix。这减少了每个物体的矩阵乘法次数,尤其当绘制大量物体时性能提升明显。

代码体现:

const viewProjectionMatrix = mat4.multiply(projection, viewMatrix);
// 循环内:
mat4.translate(viewProjectionMatrix, [x, 0, z], matrixValue);

2. 利用逆矩阵生成视图矩阵,代替手动反向计算

相机矩阵描述的是“相机在世界中的位置与朝向”,而视图矩阵需要的是“将世界反向变换到相机坐标系”。直接使用 mat4.inverse(cameraMatrix) 得到视图矩阵,避免了手动拼接“反向平移、反向旋转、反向缩放”的复杂逻辑。这是矩阵运算机械化的典型应用,也减少了出错概率。

3. 使用 lookAt / aim 构建矩阵,替代旋转+平移组合

当相机或物体需要持续指向某个目标时,使用旋转矩阵的组合(rotateX+rotateY+rotateZ)极难计算且不直观。而 lookAt(eye, target, up) 只需要提供眼睛、目标和上方向,通过叉积构建正交基,即可生成正确的视图矩阵;aim 同理适用于物体瞄准。这种“目标驱动”的方法不仅代码简洁,而且可扩展用于头部跟随、炮塔瞄准、路径追踪等常见场景。


五、GLSL / WebGL 专业术语检查

本文涉及 WebGPU 与 3D 数学概念,以下对关键术语进行校对与解释,确保中文语境下的准确对应:

英文术语中文翻译说明
camera / eye相机 / 眼睛观察者的位置
target目标点相机或物体要瞄准的位置
up vector上方向向量通常为 (0, 1, 0),用于确定相机的 “上” 方向
view matrix视图矩阵将世界坐标变换到相机坐标系的矩阵
projection matrix投影矩阵将相机坐标系变换到裁剪空间(如透视投影)
view projection matrix视图投影矩阵视图矩阵与投影矩阵的乘积,常合并使用
inverse matrix逆矩阵用于“撤销”原矩阵变换
lookAt观察矩阵 / 视线矩阵由 eye、target、up 生成视图矩阵的函数
cameraAim相机瞄准矩阵生成相机旋转矩阵,不包含逆运算
aim瞄准矩阵使物体 +Z 轴指向目标的矩阵
cross product叉积 / 向量积生成垂直于两个输入向量的向量
normalize归一化 / 标准化将向量缩放为单位长度
frustum视锥体透视投影的可见空间形状
field of view视场角水平/垂直方向的可视角度
aspect ratio宽高比画布宽高比,影响投影水平/垂直缩放
zNear / zFar近裁剪面 / 远裁剪面视锥体前后裁剪平面距离
cull mode剔除模式决定渲染正面还是背面三角形(front / back / none)
winding order三角形绕序顶点顺序(顺时针或逆时针),影响面朝向
depth buffer / depthStencil深度缓冲区 / 深度模板存储深度值,用于深度测试
uniform buffer统一缓冲区GPU 中用于存储着色器 uniform 数据的缓冲区
bind group绑定组将资源(缓冲区、纹理等)绑定到着色器阶段的集合
vertex buffer顶点缓冲区存储顶点数据的缓冲区
render pipeline渲染管线描述 GPU 渲染状态与着色器配置的对象
WGSLWebGPU Shading LanguageWebGPU 的着色语言,对应 GLSL(WebGL)

检查结论:文中术语与 WebGPU / 3D 数学标准一致,无发现不准确或易混淆的翻译。中文表述中需注意区分 cameraAim(返回相机矩阵)与 lookAt(返回视图矩阵),以及 aim 的 +Z 方向约定。


结语

本文从投影矩阵的限制出发,系统介绍了 WebGPU 中相机与视图矩阵的实现方式,并通过 inverse、cameraAim、lookAt 和 aim 等工具逐步简化相机控制。核心优化思想在于合并矩阵、复用数学工具、目标驱动定位,这些原则同样适用于 WebGL、OpenGL 或其他 3D 图形编程。

本文为深度解读,并非逐字翻译。完整代码与交互示例请访问原文链接。