- 图形学
【免费下载链接】webgl-fundamentals
WebGL lessons that start with the basics
场景图(Scene Graph)是几乎所有 3D 引擎的核心数据结构,它把"物体"组织成层级树,让父节点的位移、旋转、缩放自动传递给子节点。这篇指南以 webgl-fundamentals 仓库中 webgl/lessons/ko/webgl-scene-graph.md(韩文版对应英文原文 webgl/lessons/webgl-scene-graph.md)为主体,完整复刻太阳系模拟的全部实现步骤,并结合仓库内的可运行示例(webgl/webgl-scene-graph-solar-system.html、webgl/webgl-scene-graph-block-guy.html)与矩阵数学库 webgl/resources/m4.js 源码,深入讲解localMatrix、worldMatrix、TRS 数据源等概念。读完你不仅能亲手写出一套可复用的场景图,还能理解它与角色骨骼动画的内在联系。
前置知识:本文是 WebGL 系列教程的延续。建议先阅读 绘制多个物体(webgl-drawing-multiple-things.md) 了解
drawInfo、bufferInfo、programInfo的约定,以及 2D 矩阵数学(webgl-2d-matrices.md) 中的矩阵乘法。
什么是场景图:树形结构中的父子矩阵关系
一个直观但不够有用的定义是:场景图是一种树结构,树中的每个节点生成一个矩阵。更实用的理解方式是看例子——大多数 3D 引擎都使用场景图:你把想要显示的东西放进场景图,引擎遍历场景图后得出一个待绘制列表。场景图是分层的,例如做一个宇宙模拟,图结构大致如下(可拖动名称观察层级关系):
은하 (银河) | \ 태양계 태양 | \ 행성계 별들... | 행성 | \ 달 위성...(原始交互图位于 webgl/lessons/resources/planet-diagram.html。)
场景图的头号特性,是它为矩阵提供了父子关系:在简单(但不写实)的宇宙模拟中,星星(子节点)跟着它的星系(父节点)一起动;月亮(子节点)跟着它的行星(父节点)一起动。移动地球,月亮跟着移动;移动银河,里面所有星星都跟着移动。
回顾 2D 矩阵数学,我们用大量矩阵乘法来实现平移、旋转和缩放。场景图提供的正是"决定该给物体应用什么矩阵算式"的结构。
局部空间(local space):为什么月亮只需要关心绕地球转
场景图中的每个Node通常代表一个局部空间(local space)。只要矩阵算式正确,处于该局部空间内的任何东西都可以无视它之上的所有层级——换句话说,月亮只需要关心绕地球转,不需要关心绕太阳转。如果没有场景图,要计算月亮绕太阳的轨迹,你不得不处理复杂得多的数学(其绕日轨道形如复杂的内摆线,见 webgl/lessons/resources/moon-orbit.html)。
有了场景图,你只需把月亮设为地球的子节点,然后让月亮绕地球转(这很简单),场景图会自动处理"地球正绕太阳转"这件事。做法是遍历节点、边遍历边乘矩阵:
worldMatrix = greatGrandParent * grandParent * parent * self(localMatrix)具体到宇宙模拟:
worldMatrixForMoon = galaxyMatrix * starMatrix * planetMatrix * moonMatrix;这可以用一个非常简单的递归函数实现:
function computeWorldMatrix(currentNode, parentWorldMatrix) { // 用父节点的 worldMatrix 乘以本节点 localMatrix,得到本节点的 worldMatrix var worldMatrix = m4.multiply(parentWorldMatrix, currentNode.localMatrix); // 对所有子节点做同样的处理 currentNode.children.forEach(function(child) { computeWorldMatrix(child, worldMatrix); }); }两个核心术语
localMatrix:当前节点的局部矩阵,以自身为原点,把自身及其子节点在局部空间中变换。worldMatrix:对于给定节点,把该节点局部空间里的东西变换到场景图根节点的空间,也就是"放到世界中"。计算月亮的worldMatrix,就能得到上面看到的复杂绕日轨道。
手写一个最小可用的 Node 类
场景图非常容易实现。定义一个简单的Node对象即可——组织场景图的方式有无数种,没有绝对最优解,最常见的是带一个"要绘制的东西"的可选字段:
var node = { localMatrix: ..., // 本节点的"局部"矩阵 worldMatrix: ..., // 本节点的"世界"矩阵 children: [], // 子节点数组 thingToDraw: ??, // 本节点上要绘制的东西 };节点类与 setParent
在 webgl/webgl-scene-graph-solar-system.html 中,节点类与父节点设置函数的真实实现如下:
var Node = function() { this.children = []; this.localMatrix = m4.identity(); this.worldMatrix = m4.identity(); }; Node.prototype.setParent = function(parent) { // 从当前父节点移除自己 if (this.parent) { var ndx = this.parent.children.indexOf(this); if (ndx >= 0) { this.parent.children.splice(ndx, 1); } } // 把自己加入新父节点 if (parent) { parent.children.push(this); } this.parent = parent; };注意这里用的是push(示例文件中的正确写法),文档中的append可视为伪代码示意。setParent先把自己从旧父节点的children数组中摘除,再加入新父节点,天然保证"一个节点同一时刻只有一个父节点"。
updateWorldMatrix:递归传播世界矩阵
基于父子关系,从父节点出发递归访问所有子节点,即可由局部矩阵计算出世界矩阵(见示例文件 webgl/webgl-scene-graph-solar-system.html#L82-L96):
Node.prototype.updateWorldMatrix = function(parentWorldMatrix) { if (parentWorldMatrix) { // 传入矩阵:做乘法,结果存到 this.worldMatrix m4.multiply(parentWorldMatrix, this.localMatrix, this.worldMatrix); } else { // 未传矩阵:直接把 localMatrix 复制为 worldMatrix(根节点情形) m4.copy(this.localMatrix, this.worldMatrix); } // 继续处理所有子节点 var worldMatrix = this.worldMatrix; this.children.forEach(function(child) { child.updateWorldMatrix(worldMatrix); }); };搭建太阳系:太阳、地球与月亮
为保持简单,只用太阳、地球、月亮三颗天体;距离当然是假的,以便能放进屏幕。三个天体共用同一个球体模型,分别染成黄色、蓝绿色和灰色。若对drawInfo、bufferInfo、programInfo不熟悉,请先读 webgl-drawing-multiple-things.md。
创建节点并绑定绘制信息
// 创建所有节点 var sunNode = new Node(); sunNode.localMatrix = m4.translation(0, 0, 0); // 太阳在中心 sunNode.drawInfo = { uniforms: { u_colorOffset: [0.6, 0.6, 0, 1], // 黄色 u_colorMult: [0.4, 0.4, 0, 1], }, programInfo: programInfo, bufferInfo: sphereBufferInfo, }; var earthNode = new Node(); earthNode.localMatrix = m4.translation(100, 0, 0); // 地球距太阳 100 单位 earthNode.drawInfo = { uniforms: { u_colorOffset: [0.2, 0.5, 0.8, 1], // 蓝绿色 u_colorMult: [0.8, 0.5, 0.2, 1], }, programInfo: programInfo, bufferInfo: sphereBufferInfo, }; var moonNode = new Node(); moonNode.localMatrix = m4.translation(20, 0, 0); // 月亮距地球 20 单位 moonNode.drawInfo = { uniforms: { u_colorOffset: [0.6, 0.6, 0.6, 1], // 灰色 u_colorMult: [0.1, 0.1, 0.1, 1], }, programInfo: programInfo, bufferInfo: sphereBufferInfo, };上面的u_colorMult/u_colorOffset对应片段着色器中的v_color * u_colorMult + u_colorOffset(见示例文件头部 GLSL 片段 webgl/webgl-scene-graph-solar-system.html#L34-L46),两个 uniform 叠加即可得到任意纯色。
连接节点
// 连接天体 moonNode.setParent(earthNode); earthNode.setParent(sunNode);绘制列表与渲染循环
再建立"对象列表"与"待绘制对象列表":
var objects = [ sunNode, earthNode, moonNode, ]; var objectsToDraw = [ sunNode.drawInfo, earthNode.drawInfo, moonNode.drawInfo, ];渲染时,每帧先让各物体的局部矩阵略微旋转:
// 更新每个物体的局部矩阵 m4.multiply(m4.yRotation(0.01), sunNode.localMatrix , sunNode.localMatrix); m4.multiply(m4.yRotation(0.01), earthNode.localMatrix, earthNode.localMatrix); m4.multiply(m4.yRotation(0.01), moonNode.localMatrix , moonNode.localMatrix);局部矩阵更新后,更新所有世界矩阵——只需从根节点调用一次:
sunNode.updateWorldMatrix();最后,有了世界矩阵,还需要乘上视图投影矩阵,得到每个物体的世界-视图-投影矩阵(worldViewProjection)(相关概念见 webgl-3d-perspective.md):
// 为渲染计算所有矩阵 objects.forEach(function(object) { object.drawInfo.uniforms.u_matrix = m4.multiply(viewProjectionMatrix, object.worldMatrix); });u_matrix正是顶点着色器中gl_Position = u_matrix * a_position使用的 uniform(webgl/webgl-scene-graph-solar-system.html#L17-L32)。随后的绘制循环与上一篇相同:对objectsToDraw逐个调用gl.useProgram、webglUtils.setBuffersAndAttributes、webglUtils.setUniforms、gl.drawArrays,并以requestAnimationFrame驱动下一帧(webgl/webgl-scene-graph-solar-system.html#L237-L270)。
运行效果见完整示例 webgl/webgl-scene-graph-solar-system.html。
遇到问题:缩放地球,月亮也变大了
你会注意到所有行星大小相同。试着把地球变大:
// 地球距太阳 100 单位 earthNode.localMatrix = m4.translation(100, 0, 0); // 把地球放大两倍 earthNode.localMatrix = m4.scale(earthNode.localMatrix, 2, 2, 2);运行示例 webgl/webgl-scene-graph-solar-system-larger-earth.html 会发现:月亮也跟着变大了——这正是场景图层级传播的本意:父节点的缩放作用于整个子树。要修正,可以手动缩小月亮,但更好的解决方案是往场景图里加更多节点。
用"轨道节点"解耦缩放与公转
不要用这样的图:
태양 (太阳) | 지구 (地球) | 달 (月亮)改成:
태양계 (太阳系) | | | 태양 (太阳) | 지구 궤도 (地球轨道) | | | 지구 (地球) | 달 궤도 (月亮轨道) | 달 (月亮)这样,地球绕太阳系中心公转,而太阳可以独立旋转、缩放且不影响地球;地球也可以与月亮互相独立地旋转。为solarSystem、earthOrbit、moonOrbit新建节点:
var solarSystemNode = new Node(); var earthOrbitNode = new Node(); earthOrbitNode.localMatrix = m4.translation(100, 0, 0); // 地球轨道距太阳 100 单位 var moonOrbitNode = new Node(); moonOrbitNode.localMatrix = m4.translation(20, 0, 0); // 月亮轨道距地球 20 单位把原来的轨道距离从旧节点上移除,地球节点只保留缩放:
var earthNode = new Node(); -// 地球距太阳 100 单位 -earthNode.localMatrix = m4.translation(100, 0, 0); -// 把地球放大两倍 -earthNode.localMatrix = m4.scale(earthNode.localMatrix, 2, 2, 2); +earthNode.localMatrix = m4.scaling(2, 2, 2); // 把地球放大两倍 var moonNode = new Node(); -moonNode.localMatrix = m4.translation(20, 0, 0); // 月亮距地球 20 单位重新连接节点:
// 连接天体 sunNode.setParent(solarSystemNode); earthOrbitNode.setParent(solarSystemNode); earthNode.setParent(earthOrbitNode); moonOrbitNode.setParent(earthOrbitNode); moonNode.setParent(moonOrbitNode);并且只需要更新轨道节点的局部矩阵:
// 更新每个物体的局部矩阵 -m4.multiply(m4.yRotation(0.01), sunNode.localMatrix , sunNode.localMatrix); -m4.multiply(m4.yRotation(0.01), earthNode.localMatrix, earthNode.localMatrix); -m4.multiply(m4.yRotation(0.01), moonNode.localMatrix , moonNode.localMatrix); +m4.multiply(m4.yRotation(0.01), earthOrbitNode.localMatrix, earthOrbitNode.localMatrix); +m4.multiply(m4.yRotation(0.01), moonOrbitNode.localMatrix, moonOrbitNode.localMatrix); // 更新场景图所有世界矩阵 -sunNode.updateWorldMatrix(); +solarSystemNode.updateWorldMatrix();现在地球是两倍大小,月亮则保持原样。运行示例 webgl/webgl-scene-graph-solar-system-larger-earth-fixed.html 验证。同时你会发现太阳和地球不再原地自转——它们现在是相互独立的节点。
更多微调:独立自转与各自缩放
继续调整几处:
-sunNode.localMatrix = m4.translation(0, 0, 0); // 太阳在中心 +sunNode.localMatrix = m4.scaling(5, 5, 5); // 太阳放大 5 倍 ... +moonNode.localMatrix = m4.scaling(0.4, 0.4, 0.4); // 月亮缩小为 0.4 ... // 更新每个物体的局部矩阵 matrixMultiply(earthOrbitNode.localMatrix, m4.yRotation(0.01), earthOrbitNode.localMatrix); matrixMultiply(moonOrbitNode.localMatrix, m4.yRotation(0.01), moonOrbitNode.localMatrix); +// 地球自转 +m4.multiply(m4.yRotation(0.05), earthNode.localMatrix, earthNode.localMatrix); +// 月亮自转(反向) +m4.multiply(m4.yRotation(-0.01), moonNode.localMatrix, moonNode.localMatrix);完整运行效果见 webgl/webgl-scene-graph-solar-system-adjusted.html。在该示例的真实源码中(webgl/webgl-scene-graph-solar-system-adjusted.html#L138-L237),轨道公转由earthOrbitNode/moonOrbitNode的yRotation驱动,自转由earthNode(0.05 rad/帧)与moonNode(-0.01 rad/帧)驱动,公转与自转被彻底分离。
逐帧修改矩阵的隐患与 TRS 数据源
目前的方案是持有localMatrix并在每帧修改它。问题在于:每帧的乘法都会累积一点浮点误差。有一种叫"矩阵正交归一化(ortho-normalizing a matrix)"的修正手段,但也不是总能奏效。举个极端例子:把缩放缩到 0 再放大回来,只对一个值x做:
x = 246; // 第 0 帧,x = 246 scale = 1; x = x * scale // 第 1 帧,x = 246 scale = 0.5; x = x * scale // 第 2 帧,x = 123 scale = 0; x = x * scale // 第 3 帧,x = 0 scale = 0.5; x = x * scale // 第 4 帧,x = 0 糟糕!值丢失了。修复办法:加入另一个类,从其他参数重建矩阵。修改Node定义,引入source字段——如果存在,就向source索要局部矩阵:
var Node = function(source) { this.children = []; this.localMatrix = m4.identity(); this.worldMatrix = m4.identity(); this.source = source; }; Node.prototype.updateWorldMatrix = function(matrix) { var source = this.source; if (source) { source.getMatrix(this.localMatrix); } ... // 其余逻辑不变 };TRS:平移-旋转-缩放的常见数据源
最常见的source提供平移(translation)、旋转(rotation)、缩放(scale):
var TRS = function() { this.translation = [0, 0, 0]; this.rotation = [0, 0, 0]; this.scale = [1, 1, 1]; }; TRS.prototype.getMatrix = function(dst) { dst = dst || new Float32Array(16); var t = this.translation; var r = this.rotation; var s = this.scale; // 由平移、旋转、缩放计算矩阵 m4.translation(t[0], t[1], t[2], dst); matrixMultiply(m4.xRotation(r[0]), dst, dst); matrixMultiply(m4.yRotation(r[1]), dst, dst); matrixMultiply(m4.zRotation(r[2]), dst, dst); matrixMultiply(m4.scaling(s[0], s[1], s[2]), dst, dst); return dst; };在 webgl/webgl-scene-graph-block-guy.html#L60-L77 中可以看到 TRS 的实际实现。使用方法:
// 初始化时,用 source 创建节点 var someTRS = new TRS(); var someNode = new Node(someTRS); // 渲染时 someTRS.rotation[2] += elapsedTime;现在没有累积误差问题了,因为每次都是重新生成矩阵。这些矩阵构造函数(translation、xRotation/yRotation/zRotation、scaling、scale、multiply、copy)都定义在矩阵数学库 webgl/resources/m4.js 中——例如m4.multiply(a, b, dst)在 webgl/resources/m4.js#L91-L142 逐元素实现 4×4 矩阵乘法,m4.translation(tx, ty, tz, dst)在 webgl/resources/m4.js#L519-L540 构造平移矩阵(平移量写入矩阵第 12~14 元素),m4.scaling(sx, sy, sz, dst)在 webgl/resources/m4.js#L962-L983 构造对角缩放矩阵。m4.translation返回的是"新建矩阵"形态,而m4.scale(m, sx, sy, sz)是"在已有矩阵上乘缩放",两者用途不同:TRS 与初始化 localMatrix 时用前者,逐帧复合变换时用后者。
从太阳系到人形:场景图与骨骼动画
"我又不做太阳系,这有什么意义?"——如果你想做人物动画,场景图会是这样一棵树:
pelvis (骨盆) | \ torso left-thigh | \ | neck left-arm ... | head(交互示意图位于 webgl/lessons/resources/person-diagram.html。)
手指和脚趾加多少个关节由你决定:关节越多,计算动画所需算力越大,为所有关节提供数据的动画数据也越多。早期游戏如《VR战士(Virtua Fighter)》大约 15 个关节;2000 年代前中期的主流游戏有 30~70 个关节。如果每根手指都做全关节,每只手至少 20 个关节,光两只手就是 40 个关节。许多要做手部动画的游戏选择"拇指作为一个整体、其余四指作为一个大手指"来节省时间(CPU/GPU 与美术工时)和内存。
仓库里提供了一个用方块拼出的人形示例 webgl/webgl-scene-graph-block-guy.html,每个节点都使用上文提到的TRS作为 source。其节点树描述数据(webgl/webgl-scene-graph-block-guy.html#L165-L257)体现了典型的层级组织:
point between feet(双脚之间的锚点,不绘制,只作为根)waist(腰部,位移 [0, 3, 0])→torso(躯干,位移 [0, 2, 0])→neck(脖子)→head(头)- 躯干下还有
left-arm→left-forearm→left-hand(左臂→左前臂→左手)以及右臂对称链 waist下还有left-leg→left-calf→left-foot(左腿→左小腿→左脚)与右腿对称链
makeNode函数(webgl/webgl-scene-graph-block-guy.html#L259-L283)递归地把这些描述转成"TRS + Node",并用setParent建立层级;每帧通过nodeInfosByName["left-leg"].trs.rotation[0] = adjust之类的赋值(webgl/webgl-scene-graph-block-guy.html#L329-L363)用正弦函数驱动各关节摆动,这就是最朴素的"程序化骨骼动画"。几乎任何 3D 库中你都能找到与这套结构类似的场景图。
附:setParent 与 addChild/removeChild 之争
许多场景图提供node.addChild和node.removeChild函数,而本文选择了node.setParent。哪种更好见仁见智,但有一个支持setParent的客观理由:它让下面这类代码变得不可能:
someParent.addChild(someNode); ... someOtherParent.addChild(someNode);这意味着什么?someNode会被同时加入someParent和someOtherParent吗?在大多数场景图中这不可能。第二次调用会报错吗(ERROR: Already have parent)?还是说它会先把someNode从someParent悄悄移除再加入someOtherParent?如果是后者,光从addChild这个名字完全看不出来。
而setParent没有这个问题:
someNode.setParent(someParent); ... someNode.setParent(someOtherParent);这个场景下发生了什么一目了然:零歧义。这也正是本仓库所有示例(包括 webgl/webgl-scene-graph-solar-system.html 与 webgl/webgl-scene-graph-block-guy.html)统一采用setParent的原因。
小结
- 场景图= 树形结构,每个节点持有
localMatrix与worldMatrix,父节点的变换自动作用于整个子树。 - 核心算法:从根节点出发递归执行
worldMatrix = parentWorldMatrix * localMatrix(即updateWorldMatrix),一次调用即可更新整棵树的全部世界矩阵。 - 解耦技巧:把"公转轨道"与"天体自身"拆成不同节点,缩放/自转互不干扰。
- 工程化改进:用 TRS 之类的
source每帧重建矩阵,规避逐帧乘法的累积误差;这也让"程序化驱动关节"(如 Block Guy 示例)变得极其自然。 - 延伸方向:角色骨骼动画、层级机械结构(如 webgl-scene-graph-solar-system.html 系列的太阳系)本质上都是同一套场景图机制;仓库中的 webgl/lessons/webgl-skinning.md 与 webgl/lessons/webgl-2d-matrix-stack.md 进一步展示了矩阵层级在蒙皮动画与 2D 绘制中的应用。
相关资源:
- 英文原文:webgl/lessons/webgl-scene-graph.md | 韩文版:webgl/lessons/ko/webgl-scene-graph.md
- 可运行示例:webgl/webgl-scene-graph-solar-system.html、webgl/webgl-scene-graph-solar-system-larger-earth.html、webgl/webgl-scene-graph-solar-system-larger-earth-fixed.html、webgl/webgl-scene-graph-solar-system-adjusted.html、webgl/webgl-scene-graph-block-guy.html
- 矩阵数学库:webgl/resources/m4.js
- 交互示意图:webgl/lessons/resources/planet-diagram.html、webgl/lessons/resources/moon-orbit.html、webgl/lessons/resources/person-diagram.html
- 图形学
【免费下载链接】webgl-fundamentals
WebGL lessons that start with the basics
相关推荐
WebGL 场景图(Scene Graph)实战指南:用层级节点矩阵构建太阳系与骨骼动画
WebGL 场景图(Scene Graph)实战指南:用层级节点矩阵构建太阳系与骨骼动画 场景图(Scene Graph)是几乎所有 3D 引擎组织场景对象的基
图形学OpenPose骨骼动画生成:从2D关键点到3D角色动画
OpenPose骨骼动画生成:从2D关键点到3D角色动画 OpenPose是一个强大的开源人体姿态估计库,能够实时检测人体的2D和3D关键点,并生成流畅的骨骼动
人工智能计算机视觉深度学习GDevelop动画系统完全指南:从帧动画到骨骼动画的专业技巧
GDevelop动画系统完全指南:从帧动画到骨骼动画的专业技巧 GDevelop作为一款开源跨平台游戏引擎,其强大的动画系统让游戏开发者能够轻松创建生动的游戏角
游戏开发桌面应用前端低代码
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考