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 编辑器中的操纵器(如平移/旋转/缩放控件),这些内容或许会在后续文章中讲解。
核心优化要点提炼
-
数据驱动的层次结构替代代码矩阵栈
场景图将变换关系存储为节点树,子节点的世界矩阵通过父节点世界矩阵与自身局部矩阵相乘生成。这避免了在代码中手动维护矩阵栈,使得任意子节点的位置、旋转和缩放都能被独立调整,无需修改渲染逻辑。 -
TRS 源抽象与按需计算矩阵
将局部矩阵计算封装为TRS源对象,UI 和动画直接修改translation、rotation、scale数组,通过getMatrix在每帧更新世界矩阵时按需计算。这种设计既清晰又高效,避免了频繁创建临时矩阵对象,并便于扩展不同的变换来源(如四元数、几何代数等)。 -
按需渲染循环与时间步管理
使用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 块绑定 |
| WGSL | WGSL(WebGPU 着色语言) | WebGPU 着色语言,取代 GLSL |
| GLSL | OpenGL 着色语言 | 用于 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 的深度解读。全文在忠实原意的基础上进行了适当精简与整合,保留了核心代码与实现逻辑,并对关键专业术语进行了检查与说明。
