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 个核心优化要点

  1. 用场景图节点层级解耦相机变换
    将 Pan、Tilt、Dolly 等操作分配给不同的父‑子节点(camTarget → camPitch → camTilt → camExtend → cam),每个节点只控制一个自由度。这种设计避免了手动拼接矩阵的复杂性,易于扩展到 Roll 等新操作,也方便在编辑器 UI 中单独显示和修改每个参数。

  2. 交互采用 delta 模式,避免累积误差并支持模式热切换
    getUpdateHelper() 在每次手势开始时记录初始状态(pan、tilt、radius、target、相机矩阵),后续更新以 delta 形式叠加到初始值,而非在当前值上连续累加。这使得指针移动的微小抖动不会产生累积漂移,且当用户按下 Shift 键从 Pan/Tilt 切换到 Track 时,只需重新调用 updateStartPosition 即可无缝重置基线。

  3. 基于 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 计算,实现了健壮且易扩展的相机控制。