WebGPU Scene Graphs(场景图)

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


导言

本文是 3D 数学系列的第 9 篇,前序文章依次介绍了平移、旋转、缩放、矩阵数学、正交投影、透视投影、相机和矩阵栈。上一篇文章我们使用矩阵栈(Matrix Stack)通过代码来构建矩阵变换,以便相对于其他对象进行定位、定向和缩放。而场景图(Scene Graph)在概念上与之类似,但核心区别在于:它不是用代码,而是用数据来构建父子节点图,每个子节点根据其父节点的矩阵来计算自身的世界矩阵。

例如,文件柜场景的场景图大致如下:

root
 +-cabinet0
 |  +-cabinet0-mesh
 |  +-drawer0
 |  |  +-drawer0-drawer-mesh
 |  |  +-drawer0-handle-mesh
 |  +-drawer1
 |  |  +-drawer1-drawer-mesh
 |  |  +-drawer1-handle-mesh
 ...
 +-cabinet1
 |  ...

场景图的优势在于将所有变换关系存储在图的节点中,因此可以轻松地操作图的任意子部分,而无需在代码中递归处理。


核心实现:SceneGraphNode 类

首先,我们需要一个表示场景图节点的类:

class SceneGraphNode {
  constructor(name, source) {
    this.name = name;
    this.children = [];
    this.localMatrix = mat4.identity();
    this.worldMatrix = mat4.identity();
    this.source = source;
  }

  addChild(child) {
    child.setParent(this);
  }

  removeChild(child) {
    child.setParent(null);
  }

  setParent(parent) {
    // 从原父节点移除
    if (this.parent) {
      const ndx = this.parent.children.indexOf(this);
      if (ndx >= 0) {
        this.parent.children.splice(ndx, 1);
      }
    }

    // 添加到新父节点
    if (parent) {
      parent.children.push(this);
    }
    this.parent = parent;
  }

  updateWorldMatrix() {
    // 如果有 source,先更新局部矩阵
    this.source?.getMatrix(this.localMatrix);

    if (this.parent) {
      // 有父节点:世界矩阵 = 父世界矩阵 * 局部矩阵
      mat4.multiply(this.parent.worldMatrix, this.localMatrix, this.worldMatrix);
    } else {
      // 根节点:直接复制局部矩阵为世界矩阵
      mat4.copy(this.localMatrix, this.worldMatrix);
    }

    // 递归更新所有子节点
    this.children.forEach(function(child) {
      child.updateWorldMatrix();
    });
  }
}

每个节点包含:

  • localMatrix:相对于父节点的位置、旋转和缩放。
  • worldMatrix:相对于场景图外部的世界坐标变换。
  • source:可选对象,提供 getMatrix 方法,用于按自定义方式计算局部矩阵。
  • updateWorldMatrix:更新自身及所有子节点的世界矩阵。

TRS 源(Translation, Rotation, Scale)

TRS 是平移(Translation)、旋转(Rotation)、缩放(Scale)的缩写,是场景图中计算局部矩阵的常用方式。本文使用 Float32Array 存储数据,以便利用其 set 方法进行动态更新。

class TRS {
  constructor({
    translation = [0, 0, 0],
    rotation = [0, 0, 0],
    scale = [1, 1, 1],
  } = {}) {
    this.translation = new Float32Array(translation);
    this.rotation = new Float32Array(rotation);
    this.scale = new Float32Array(scale);
  }

  getMatrix(dst) {
    mat4.translation(this.translation, dst);
    mat4.rotateX(dst, this.rotation[0], dst);
    mat4.rotateY(dst, this.rotation[1], dst);
    mat4.rotateZ(dst, this.rotation[2], dst);
    mat4.scale(dst, this.scale, dst);
    return dst;
  }
}

getMatrix 的计算顺序为:translation × rotationX × rotationY × rotationZ × scale。实际项目中常会支持调整旋转顺序(如 ZYX、YZX 等),也可能会使用四元数或几何代数。


构建场景图

定义辅助函数,将 SceneGraphNode 与 TRS 源结合:

function addTRSSceneGraphNode(name, parent, trs) {
  const node = new SceneGraphNode(name, new TRS(trs));
  if (parent) {
    node.setParent(parent);
  }
  return node;
}

添加网格(mesh)的辅助函数:

const meshes = [];
function addMesh(node, vertices, color) {
  const mesh = { node, vertices, color };
  meshes.push(mesh);
  return mesh;
}

添加立方体节点:

function addCubeNode(name, parent, trs, color) {
  const node = addTRSSceneGraphNode(name, parent, trs);
  return addMesh(node, cubeVertices, color);
}

文件柜场景图

创建根节点,并循环添加文件柜:

const root = new SceneGraphNode('root');
for (let cabinetNdx = 0; cabinetNdx < kNumCabinets; ++cabinetNdx) {
  addCabinet(root, cabinetNdx);
}

addCabinet 与 addDrawer 的实现:

function addCabinet(parent, cabinetNdx) {
  const cabinetName = `cabinet${cabinetNdx}`;
  const cabinet = addTRSSceneGraphNode(
    cabinetName, parent, {
      translation: [cabinetNdx * kCabinetSpacing, 0, 0],
    });

  const kCabinetSize = [
    kDrawerSize[kWidth] + 6,
    kDrawerSpacing * kNumDrawersPerCabinet + 6,
    kDrawerSize[kDepth] + 4,
  ];
  addCubeNode(`${cabinetName}-mesh`, cabinet, { scale: kCabinetSize }, kCabinetColor);

  for (let drawerNdx = 0; drawerNdx < kNumDrawersPerCabinet; ++drawerNdx) {
    addDrawer(cabinet, drawerNdx);
  }
}

function addDrawer(parent, drawerNdx) {
  const drawerName = `drawer${drawerNdx}`;
  const drawer = addTRSSceneGraphNode(
    drawerName, parent, {
      translation: [3, drawerNdx * kDrawerSpacing + 5, 1],
    });

  addCubeNode(`${drawerName}-drawer-mesh`, drawer, { scale: kDrawerSize }, kDrawerColor);
  addCubeNode(`${drawerName}-handle-mesh`, drawer, {
    translation: kHandlePosition,
    scale: kHandleSize,
  }, kHandleColor);
}

渲染循环调整

用场景图替代原来的矩阵栈调用:

// 旧代码(矩阵栈)
// stack.save();
// stack.rotateY(settings.baseRotation);
// stack.translate([(kNumCabinets - 0.5) * kCabinetSpacing * -0.5, 0, 0]);
// const ctx = { pass, stack, viewProjectionMatrix };
// drawCabinets(ctx, kNumCabinets);
// stack.restore();

// 新代码(场景图)
const ctx = { pass, viewProjectionMatrix };
root.updateWorldMatrix();
for (const mesh of meshes) {
  drawMesh(ctx, mesh);
}

相机代码也改为基于矩阵求逆:

const cameraMatrix = mat4.identity();
mat4.translate(cameraMatrix, [120, 100, 0], cameraMatrix);
mat4.rotateY(cameraMatrix, settings.cameraRotation, cameraMatrix);
mat4.translate(cameraMatrix, [60, 0, 300], cameraMatrix);

const viewMatrix = mat4.inverse(cameraMatrix);
const viewProjectionMatrix = mat4.multiply(projection, viewMatrix);

添加 GUI 控制

场景图的核心价值之一是:因为它是数据,所以可以很方便地通过 UI 操作。我们添加了一个 TRSUIHelper 类,用于将任意 TRS 暴露给 GUI 进行编辑。

class TRSUIHelper {
  #trs = new TRS();

  setTRS(trs) {
    this.#trs = trs;
  }

  get translationX() { return this.#trs.translation[0]; }
  set translationX(x) { this.#trs.translation[0] = x; }
  // ... 其余 translation/rotation/scale 的 getter/setter
}

同时,通过遍历场景图生成节点选择按钮:

function setCurrentSceneGraphNode(node) {
  trsUIHelper.setTRS(node.source);
  trsFolder.name(`orientation: ${node.name}`);
  trsFolder.updateDisplay();
  // 标记选中的节点按钮
  for (const b of nodeButtons) {
    const name = b.button.getName().replace(prefixRE, '');
    b.button.name(`${b.node === node ? kSelected : kUnelected}${name}`);
  }
}

默认只显示部分 TRS 控件(对文件柜而言,主要关注 translation z)。


动画

场景图的另一个优点是很容易对节点应用动画。我们将抽屉节点放入 animNodes 数组,然后根据时间更新其 translation z:

const animNodes = [];

function addDrawer(parent, drawerNdx) {
  // ... 创建节点后
  animNodes.push(drawer);
}

function animate(time) {
  animNodes.forEach((node, i) => {
    const source = node.source;
    const t = time + i * 1;
    const l = Math.sin(t) * 0.5 + 0.5;
    source.translation[2] = lerp(1, kDrawerSize[2] * 0.8, l);
  });
}

为了支持动画循环,我们实现了 requestRender 和 wasRunning 时间步管理,避免暂停期间的时间跳变。


制作手部场景图

我们构建了一个由立方体组成的简单手部模型,场景图结构如下:

root
 +-wrist
    +-palm
    |  +-thumb
    |  |  +-thumb-mesh
    |  |  +-thumb-1
    |  |     +-thumb-1-mesh
    |  +-index finger
    |  |  +-index finger-mesh
    |  |  +-index finger-1
    |  |     +-index finger-1-mesh
    |  |     +-index finger-2
    |  |        +-index finger-2-mesh
    ...
    +-palm-mesh

addFinger 函数创建手指的多个关节:

function addFinger(name, parent, segments, segmentHeight, trs) {
  const nodes = [];
  const baseName = name;
  for (let i = 0; i < segments; ++i) {
    const node = addTRSSceneGraphNode(name, parent, trs);
    nodes.push(node);
    const meshNode = addTRSSceneGraphNode(`${name}-mesh`, node, { scale: [10, segmentHeight, 10] });
    addMesh(meshNode, cubeVertices, kWhite);
    parent = node;
    name = `${baseName}-${i + 1}`;
    trs = {
      translation: [0, segmentHeight, 0],
      rotation: [degToRad(15), 0, 0],
    };
  }
  return nodes;
}

发射投射物

场景图还能轻松获取任意节点的位置和朝向。例如,我们从食指末端发射投射物。首先在 SceneGraphNode 中添加 find 方法:

find(name) {
  if (this.name === name) {
    return this;
  }
  for (const child of this.children) {
    const found = child.find(name);
    if (found) {
      return found;
    }
  }
  return undefined;
}

然后找到食指最后一个关节,并添加一个代表指尖的子节点:

const fingerTip = addTRSSceneGraphNode('finger-tip', root.find('index finger-2'), { translation: [0, 30, 0] });

发射投射物的逻辑:

const kShotVelocity = 100; // 单位/秒
const shots = [];
let shotId = 0;

function fireShot() {
  const node = new SceneGraphNode(`shot-${shotId++}`);
  node.setParent(root);
  mat4.translate(fingerTip.worldMatrix, [0, 20, 0], node.localMatrix);
  const mesh = addMesh(node, shotVertices, kWhite);
  const velocity = vec3.mulScalar(
    vec3.normalize(vec3.getAxis(fingerTip.worldMatrix, 1)),
    kShotVelocity);
  shots.push({
    node,
    mesh,
    velocity,
    endTime: performance.now() * 0.001 + 5,
  });
  requestRender();
}

投射物更新与清理:

function processShots(now, deltaTime) {
  if (shots.length > 0) {
    requestRender();
    while (shots.length && shots[0].endTime <= now) {
      const shot = shots.shift();
      shot.node.setParent(null);
      removeMesh(shot.mesh);
    }
    for (const shot of shots) {
      const v = vec3.mulScalar(shot.velocity, deltaTime);
      mat4.multiply(mat4.translation(v), shot.node.localMatrix, shot.node.localMatrix);
    }
  }
}

总结

本文介绍了场景图的基本概念与实现。场景图是大多数 3D 引擎(如 Unity、Unreal、Blender、Three.js 等)的基础结构,它将变换关系组织为节点树,便于操作、动画和变换查询。相机通常也是场景图的一部分,以便作为子节点被父节点影响(例如车载相机或旋转监控相机)。场景图也有助于实现 3D 编辑器中的操纵器(如平移/旋转/缩放控件),这些内容或许会在后续文章中讲解。


核心优化要点提炼

  1. 数据驱动的层次结构替代代码矩阵栈
    场景图将变换关系存储为节点树,子节点的世界矩阵通过父节点世界矩阵与自身局部矩阵相乘生成。这避免了在代码中手动维护矩阵栈,使得任意子节点的位置、旋转和缩放都能被独立调整,无需修改渲染逻辑。

  2. TRS 源抽象与按需计算矩阵
    将局部矩阵计算封装为 TRS 源对象,UI 和动画直接修改 translation、rotation、scale 数组,通过 getMatrix 在每帧更新世界矩阵时按需计算。这种设计既清晰又高效,避免了频繁创建临时矩阵对象,并便于扩展不同的变换来源(如四元数、几何代数等)。

  3. 按需渲染循环与时间步管理
    使用 requestRender 管理渲染请求,仅当 GUI 变更、动画激活或存在活跃投射物时才请求下一帧。利用 wasRunning 标志处理动画时钟,避免暂停期间的时间跳变,保证动画连续性的同时最小化不必要的渲染开销。


GLSL/WebGL 专业术语检查

本文内容基于 WebGPU,文中着色器语言为 WGSL(WebGPU Shading Language),而非 GLSL。但在术语上,WebGPU 与 WebGL 有许多对应或相似之处,以下列出关键术语对照,便于读者理解:

英文术语中文翻译备注 / WebGL 对应
Scene Graph场景图与 WebGL 中概念相同
Node节点场景图的基本单元
Local Matrix / World Matrix局部矩阵 / 世界矩阵与 WebGL 中的模型矩阵概念一致
Translation / Rotation / Scale平移 / 旋转 / 缩放3D 变换基础
TRS平移旋转缩放(变换)常见变换组合
Matrix Stack矩阵栈WebGL 旧版固定管线中常见(如 glPushMatrix/glPopMatrix),现代 WebGL 需自行实现
View Matrix / Projection Matrix视图矩阵 / 投影矩阵与 WebGL 中一致
Vertex Buffer顶点缓冲区WebGPU 使用 GPUBuffer,WebGL 使用 WebGLBuffer
Uniform Buffer / Uniforms统一变量缓冲区 / 统一变量WebGPU 中通过 Bind Group 提供,WebGL 使用 uniform 变量
Bind Group绑定组WebGPU 特有概念,类似 WebGL 中的 uniform 块绑定
WGSLWGSL(WebGPU 着色语言)WebGPU 着色语言,取代 GLSL
GLSLOpenGL 着色语言用于 WebGL 和 OpenGL,WebGPU 不再使用
Pass / Render Pass渲染通道WebGPU 概念,对应 WebGL 的绘制循环
Compute Shader计算着色器两者均支持,但编程语言不同

注意:本文代码中使用的矩阵运算函数(如 mat4.multiply、mat4.rotateY 等)来自自定义的 mat4 数学库,并非 WebGPU 或 GLSL 内建。在 WebGPU 中,实际顶点变换通常写在 WGSL 着色器内(使用 mat4x4<f32> 等类型),而非在 JavaScript 中计算。本文为了教学目的,将矩阵运算放在 CPU 端完成,便于理解场景图的数据流动。


以上即为对 WebGPU Scene Graphs 的深度解读。全文在忠实原意的基础上进行了适当精简与整合,保留了核心代码与实现逻辑,并对关键专业术语进行了检查与说明。