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)构建相机的三个正交轴,进而得到相机矩阵。
构建步骤:
- Z 轴:
zAxis = normalize(eye - target)(对于相机,通常指向 -Z) - X 轴:
xAxis = normalize(cross(up, zAxis)) - Y 轴:
yAxis = normalize(cross(zAxis, xAxis)) - 将三个轴写入矩阵的旋转部分,将
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 渲染状态与着色器配置的对象 |
WGSL | WebGPU Shading Language | WebGPU 的着色语言,对应 GLSL(WebGL) |
检查结论:文中术语与 WebGPU / 3D 数学标准一致,无发现不准确或易混淆的翻译。中文表述中需注意区分 cameraAim(返回相机矩阵)与 lookAt(返回视图矩阵),以及 aim 的 +Z 方向约定。
结语
本文从投影矩阵的限制出发,系统介绍了 WebGPU 中相机与视图矩阵的实现方式,并通过 inverse、cameraAim、lookAt 和 aim 等工具逐步简化相机控制。核心优化思想在于合并矩阵、复用数学工具、目标驱动定位,这些原则同样适用于 WebGL、OpenGL 或其他 3D 图形编程。
本文为深度解读,并非逐字翻译。完整代码与交互示例请访问原文链接。
