WebGPU 相机控制(Orbit Camera)
原文标题:WebGPU Camera Controls
原文作者:webgpufundamentals Contributors
原文链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-camera-controls.html
本文是“构建 3D 编辑器组件”系列的第二篇,聚焦于**轨道相机(Orbit Camera)**的实现。文章通过场景图(Scene Graph)构建相机“关节臂”(Rig),将平移(Pan)、倾斜(Tilt)、推拉(Dolly)、跟踪(Track)等操作解耦为独立的变换节点,并逐步添加指针、滚轮、触摸手势等交互。最后还讨论了“框选选中”(Frame Selected)功能以及 UX 决策。以下为深度编译,保留核心代码并提炼优化要点。
一、轨道相机与场景图 Rig
轨道相机是大多数 3D 建模软件(Blender、Unity、Maya、3DSMax、Unreal)编辑器使用的相机类型:按住某个键或点击图标后,拖动指针即可围绕世界中的某个点旋转视角。
文中定义了几个摄影/航空术语:
- Pan:水平旋转(绕 Y 轴)
- Tilt:上下俯仰(绕 X 轴)
- Roll:侧倾(绕 Z 轴,类似歪头)
- Dolly:推拉,相机靠近/远离目标(常被误称为“缩放”)
- Track:垂直于视线方向平移相机
为了解耦这些操作,作者使用场景图节点层级构建相机 Rig:
+-camTarget (锚定旋转中心,即目标点)
+-camPitch (水平旋转,Pan)
+-camTilt (垂直旋转,Tilt)
+-camExtend (沿 Z 轴平移,控制半径,Dolly)
+-cam (最终相机矩阵)
这种层级结构使每种相机操作只影响一个节点的局部变换,极大简化了逻辑。
二、核心代码:OrbitCamera 类
以下是基于场景图节点构建的 OrbitCamera 类核心实现:
class OrbitCamera {
#camTarget;
#camPan;
#camTilt;
#camExtend;
#cam;
constructor() {
// 创建相机 Rig
this.#camTarget = addTRSSceneGraphNode("cam-target");
this.#camPan = addTRSSceneGraphNode("cam-pan", this.#camTarget);
this.#camTilt = addTRSSceneGraphNode("cam-tilt", this.#camPan);
this.#camExtend = addTRSSceneGraphNode("cam-extend", this.#camTilt);
this.#cam = addTRSSceneGraphNode("cam", this.#camExtend);
// 为 UI 配置显示哪些 TRS 通道
nodeToUISettings.set(this.#camTarget, { trs: [0, 1, 2] });
nodeToUISettings.set(this.#camPan, { trs: [4] }); // 旋转 Y
nodeToUISettings.set(this.#camTilt, { trs: [3] }); // 旋转 X
nodeToUISettings.set(this.#camExtend, { trs: [2] }); // 平移 Z
nodeToUISettings.set(this.#cam, { trs: [] });
}
setParent(parent) {
this.#camTarget.setParent(parent);
}
getCameraMatrix() {
return this.#cam.worldMatrix;
}
get pan() {
return this.#camPan.source.rotation[1];
}
set pan(v) {
this.#camPan.source.rotation[1] = v;
}
get tilt() {
return this.#camTilt.source.rotation[0];
}
set tilt(v) {
this.#camTilt.source.rotation[0] = v;
}
get radius() {
return this.#camExtend.source.translation[2];
}
set radius(v) {
this.#camExtend.source.translation[2] = v;
}
get target() {
return vec3.copy(this.#camTarget.source.translation);
}
set target(v) {
vec3.copy(v, this.#camTarget.source.translation);
}
}
可以看到,所有矩阵运算都被隐藏在场景图节点内部,相机类本身几乎不包含数学计算,只需读写节点的局部变换。
三、指针交互:Pan/Tilt、Track、Dolly、Pinch、Double Tap
1. Pan 和 Tilt
通过 getUpdateHelper 记录起始状态,然后以 delta 形式更新:
getUpdateHelper() {
const startTilt = this.tilt;
const startPan = this.pan;
const startCameraMatrix = mat4.copy(this.getCameraMatrix());
const startTarget = vec3.copy(this.target);
return {
panAndTilt: (deltaPan, deltaTilt) => {
this.tilt = startTilt - deltaTilt;
this.pan = startPan - deltaPan;
},
// ... 其他方法
};
}
指针事件监听:
function addOrbitCameraEventListeners(cam, elem) {
let startX, startY, lastMode, camHelper;
// ...
const onMove = (e) => {
if (!canvas.hasPointerCapture(e.pointerId)) return;
const mode = e.shiftKey || (e.buttons & 4) !== 0 ? "track" : "panAndTilt";
if (mode !== lastMode) {
lastMode = mode;
updateStartPosition(e);
}
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
switch (mode) {
case "track": {
const s = cam.radius * 0.001;
camHelper.track(-deltaX * s, deltaY * s);
break;
}
case "panAndTilt":
camHelper.panAndTilt(deltaX * 0.01, deltaY * 0.01);
break;
}
render();
};
// ...
}
2. Track(跟踪)
按住 Shift 或鼠标中键拖动即可平移相机。Track 通过将指针 delta 乘以相机矩阵的 3×3 部分(旋转部分),将屏幕空间的移动转换为世界空间方向,再叠加到 target 上。
track: (deltaX, deltaY) => {
const direction = vec3.transformMat3([deltaX, deltaY, 0], startCameraMatrix);
this.target = vec3.add(startTarget, direction);
},
3. Dolly(滚轮缩放)
滚轮事件会调整半径(radius),缩放量按半径的千分之一缩放,使近处和远处的速度感一致。
const onWheel = (e) => {
e.preventDefault();
const helper = cam.getUpdateHelper();
helper.dolly(cam.radius * 0.001 * e.deltaY);
render();
};
4. Pinch(双指缩放)与 Double Tap Zoom
移动端支持双指缩放,通过跟踪多个指针位置计算距离变化:
const computePinchDistance = () => {
const pos = [...pointerToLastPosition.values()];
const dx = pos[0].x - pos[1].x;
const dy = pos[0].y - pos[1].y;
return Math.hypot(dx, dy);
};
双击拖拽缩放(模仿 Google Maps)则通过检测两次 pointerdown 的时间间隔实现。
四、处理相机不在根节点的情况
如果 OrbitCamera 不是场景图的根节点,而是附着在某个倾斜物体上(例如倒塌的塔),则 Track 操作需要特殊处理:世界空间中的 delta 必须转换到相机的父节点空间。
修改 track 函数:
track: (deltaX, deltaY) => {
const worldDirection = vec3.transformMat3([deltaX, deltaY, 0], startCameraMatrix);
const inv = mat4.inverse(this.#camTarget.parent?.worldMatrix ?? mat4.identity());
const cameraDirection = vec3.transformMat3(worldDirection, inv);
vec3.add(startTarget, cameraDirection, this.#camTarget.source.translation);
},
同时将 target setter 改为 setTarget,内部先将世界坐标转换到父节点局部坐标。
五、Frame Selected(框选选中)
该功能让相机自动调整位置和距离,使选中的对象完整出现在视野中。核心是计算选中网格的 轴对齐包围盒(AABB),然后根据视场角(FOV)计算合适的相机距离。
关键代码:
function computeAABBForMesh(mesh) {
const mat = mesh.node.worldMatrix;
const p0 = mesh.vertices.aabb.min;
const p1 = mesh.vertices.aabb.max;
let min, max;
for (let i = 0; i < 8; ++i) {
const p = [
i & 1 ? p0[0] : p1[0],
i & 2 ? p0[1] : p1[1],
i & 4 ? p0[2] : p1[2],
];
vec3.transformMat4(p, mat, p);
if (i === 0) {
min = p.slice();
max = p.slice();
} else {
vec3.min(min, p, min);
vec3.max(max, p, max);
}
}
return { min, max };
}
function frameSelected() {
if (selectedMeshes.length === 0) return;
const aabb = getAABBForSelectedMeshes();
const extent = vec3.subtract(aabb.max, aabb.min);
const diameter = vec3.distance(aabb.min, aabb.max);
const aspect = canvas.clientWidth / canvas.clientHeight;
const fieldOfViewH = 2 * Math.atan(Math.tan(settings.fieldOfView) * aspect);
const fov = Math.min(fieldOfViewH, settings.fieldOfView);
const zoomScale = 1.5;
const halfSize = diameter * zoomScale * 0.5;
const distance = halfSize / Math.tan(fov * 0.5);
orbitCamera.radius = distance;
const center = vec3.addScaled(aabb.min, extent, 0.5);
orbitCamera.setTarget(center);
render();
}
六、无需场景图的精简实现
如果不想依赖场景图,可以直接使用矩阵运算实现相同功能:
class OrbitCamera {
#target = vec3.create();
#pan = 0;
#tilt = 0;
#radius = 0;
getCameraMatrix(parentMatrix) {
const mat = mat4.copy(parentMatrix ?? mat4.identity());
mat4.translate(mat, this.#target, mat);
mat4.rotateY(mat, this.#pan, mat);
mat4.rotateX(mat, this.#tilt, mat);
mat4.translate(mat, [0, 0, this.#radius], mat);
return mat;
}
// ... 其余类似
}
这种方式代码更短,但失去了场景图可视化、层级编辑等优势。
七、3 个核心优化要点
-
用场景图节点层级解耦相机变换
将 Pan、Tilt、Dolly 等操作分配给不同的父‑子节点(camTarget → camPitch → camTilt → camExtend → cam),每个节点只控制一个自由度。这种设计避免了手动拼接矩阵的复杂性,易于扩展到 Roll 等新操作,也方便在编辑器 UI 中单独显示和修改每个参数。 -
交互采用 delta 模式,避免累积误差并支持模式热切换
getUpdateHelper()在每次手势开始时记录初始状态(pan、tilt、radius、target、相机矩阵),后续更新以delta形式叠加到初始值,而非在当前值上连续累加。这使得指针移动的微小抖动不会产生累积漂移,且当用户按下 Shift 键从 Pan/Tilt 切换到 Track 时,只需重新调用updateStartPosition即可无缝重置基线。 -
基于 AABB 和视场角自动计算“框选”距离
计算选中物体的世界空间 AABB,取其对角线长度作为包围球直径,再结合相机垂直视场角和屏幕宽高比,通过distance = (diameter * 1.5 / 2) / tan(fov/2)求得相机应处距离,并将 target 设为 AABB 中心。该方法保证了物体在不同屏幕比例下均能被完整框选,且留有一定余量。
八、术语检查(GLSL/WebGL 对照)
原文使用的是 WebGPU,其着色语言为 WGSL,而非 GLSL。但文中涉及大量 3D 数学与渲染管线术语,容易与 WebGL/GLSL 混淆。以下列出关键术语及对照:
| 英文术语 | 中文翻译 | 说明 |
|---|---|---|
| Orbit Camera | 轨道相机 | 围绕目标点旋转的相机 |
| Pan / Yaw | 水平旋转 / 偏航 | 绕 Y 轴旋转 |
| Tilt / Pitch | 俯仰 | 绕 X 轴旋转 |
| Roll | 滚转 | 绕 Z 轴旋转 |
| Dolly | 推拉 | 沿视线方向前后移动 |
| Track | 平行移动 | 垂直于视线平移 |
| Scene Graph | 场景图 | 层次化节点变换结构 |
| AABB (Axis Aligned Bounding Box) | 轴对齐包围盒 | 用于近似物体边界 |
| View Matrix | 视图矩阵 | 将世界坐标变换到相机空间 |
| Projection Matrix | 投影矩阵 | 透视或正交投影 |
| Field of View (FOV) | 视场角 | 垂直或水平可视角度 |
| View Projection Matrix | 视图投影矩阵 | 视图矩阵 × 投影矩阵 |
⚠️ 注意:原文中的 mat4.perspective、mat4.rotateY、vec3.transformMat3 等函数来自自定义 JavaScript 数学库,并非 GLSL 内建函数。在 WebGPU 中,WGSL 的对应操作会使用 mat4x4f、vec3f 等类型,且矩阵乘法等操作与 GLSL 语法不同。
九、总结
本文通过场景图构建轨道相机,实现了一套完整的编辑器相机交互方案:Pan/Tilt、Shift/中键 Track、滚轮 Dolly、双指 Pinch、双击拖拽 Zoom,并支持非根节点下的正确 Track 行为以及基于 AABB 的“框选选中”。核心思想是用场景图将相机操作参数化,配合 delta 更新和 AABB 计算,实现了健壮且易扩展的相机控制。
