WebGL 场景图(Scene Graph)完全指南:从太阳系模拟到角色骨骼动画

发布时间:2026/10/15 16:40:06
WebGL 场景图(Scene Graph)完全指南:从太阳系模拟到角色骨骼动画
图形学【免费下载链接】webgl-fundamentalsWebGL lessons that start with the basics项目地址https://gitcode.com/gh_mirrors/we/webgl-fundamentals点击查看免费下载场景图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-L96Node.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使用的 uniformwebgl/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驱动自转由earthNode0.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、缩放scalevar 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-fundamentalsWebGL lessons that start with the basics项目地址https://gitcode.com/gh_mirrors/we/webgl-fundamentals点击查看免费下载相关推荐WebGL 场景图Scene Graph实战指南用层级节点矩阵构建太阳系与骨骼动画WebGL 场景图Scene Graph实战指南用层级节点矩阵构建太阳系与骨骼动画 场景图Scene Graph是几乎所有 3D 引擎组织场景对象的基图形学OpenPose骨骼动画生成从2D关键点到3D角色动画OpenPose骨骼动画生成从2D关键点到3D角色动画 OpenPose是一个强大的开源人体姿态估计库能够实时检测人体的2D和3D关键点并生成流畅的骨骼动人工智能计算机视觉深度学习GDevelop动画系统完全指南从帧动画到骨骼动画的专业技巧GDevelop动画系统完全指南从帧动画到骨骼动画的专业技巧 GDevelop作为一款开源跨平台游戏引擎其强大的动画系统让游戏开发者能够轻松创建生动的游戏角游戏开发桌面应用前端低代码创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考