news 2026/10/15 16:40:06

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
WebGL 场景图(Scene Graph)完全指南:从太阳系模拟到角色骨骼动画
  • 图形学

【免费下载链接】webgl-fundamentals

WebGL 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-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

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 16:39:40

Windows下VLC-Qt编译与Qt多媒体开发集成实战指南

简介:Windows平台编译VLC-Qt的最新资源包,面向需要在C/Qt应用中集成VLC播放能力的开发者,尤其适合对源码编译流程不熟悉、希望直接获得可用库文件的中高级程序员。包内提供VLC 3.0.0 Win64运行库、VLC-Qt 1.1.1源码,以及作者编译完…

作者头像 李华
网站建设 2026/10/15 16:37:40

工业级多源融合定位系统:IMU主干+GNSS校准+激光约束

1. 项目概述:这不是登月任务,而是一套高精度工业级实时定位与建图系统“Apollo-11”这个名称一出现,很多人第一反应是1969年的载人登月——但今天我们要聊的,是某自动驾驶实验室内部代号为“Apollo-11”的一套嵌入式多源融合定位系…

作者头像 李华
网站建设 2026/10/15 16:37:29

Delphi AlphaControls 14.24 懒人包:VCL 皮肤机制与 WebBrowser 皮肤激活实战

简介:AlphaControls 14.24 懒人包面向使用 Delphi 与 CBuilder 的界面开发者,尤其适合需要在项目中快速集成皮肤控件、又不想被商业皮肤提示打扰的中高级程序员。该版本已包含全部组件,并激活 WebBrowser 皮肤,同时去除商业皮肤提…

作者头像 李华
网站建设 2026/10/15 16:36:46

5分钟拉伸运动

人机协作,仅供参考。非专业建议,建议咨询专业人士。这套拉伸运动只需5分钟,适合工作间隙或运动前后进行,能活动关节、放松肌肉。两臂左右拉伸:两臂向左右两侧伸展,左右各做30下,充分打开肩胸。两…

作者头像 李华