本节摘要:场景图是引擎每帧遍历的节点树,父节点的平移、旋转、缩放会沿树枝自动传递给子节点。本节讲清 TransformNode 的挂靠玩法、局部变换与世界矩阵的累乘关系,并用一辆"车身带四轮"的小车完成从手动同步到树形挂靠的重构实验。承接第1章的帧流水线第2站,为 2.4 的模型挂载与第5章的场景冻结打底。
"场景图"这个词来自图形学的老传统:把世界里所有有位置的东西组织成一棵树,每个节点只记自己相对于父节点的变换。这样设计的直接收益是"联动免费"——太阳系模型里,行星挂在恒星节点下、卫星挂在行星节点下,恒星一移动,整条树枝自动跟上,你不需要写任何同步代码。
反过来,不用树会怎样?想象手动维护:每帧回调里先把车身转 5 度,再算出四个轮子相对车身的新位置,一个不漏地赋值。轮子五个部件还好,一辆坦克四十多个履带节呢?树把这种"牵一发动全身"的关系交给数据结构本身,你只描述局部关系,引擎在每帧第2站遍历时自根向叶累乘出每个节点的世界矩阵。
先看最小实验,三行代码体会联动:
// 父节点:一个只做变换管理、自身不渲染的空节点 const carRoot = new BABYLON.TransformNode("carRoot", scene); carRoot.position.y = 0.5; // 整车抬升,下面挂的所有部件都会跟着抬 // 子节点:网格挂到父节点名下 const body = BABYLON.MeshBuilder.CreateBox("body", { width: 2, height: 0.6, depth: 4 }, scene); body.parent = carRoot; // 挂靠之后,body 的 position 变成"相对 carRoot"的局部坐标 // 验证联动:父节点转,子节点世界位置立即改变 carRoot.rotation.y = Math.PI / 4; console.log(body.absolutePosition); // 世界坐标已随父节点旋转而变 console.log(body.position); // 局部坐标不变:仍是挂靠时的相对值

树上每个节点维护两套坐标。局部坐标是你赋值的 position、rotation、scaling,描述"相对父节点"的关系;世界坐标是引擎累乘出来的结果,存在节点的世界矩阵里。上节 1.2 已经演示过:改局部属性不立刻改世界结果,因为重算发生在下一帧的遍历站点,除非你手动调用重算。
旋转与缩放沿树传递时有两个工程要点。其一,父节点缩放会非均匀地"压扁"子节点的几何——父节点 x 方向放大 2 倍,子节点球体会变成椭球。这不是缺陷,是矩阵累乘的必然,建模时把缩放尽量放在叶子网格上可避免意外。其二,旋转的单位是弧度不是度,写 45 就成了绕轴七圈多;引擎提供角度转弧度的工具函数救急:
// 角度与弧度:引擎的旋转属性一律用弧度 carRoot.rotation.y = BABYLON.Angle.FromDegrees(45).radians(); // 等价写法:自己除以 180 乘圆周率 carRoot.rotation.y = 45 * Math.PI / 180; // 想直接用四元数避免万向节锁,用 rotationQuaternion carRoot.rotationQuaternion = BABYLON.Quaternion.FromEulerAngles(0, Math.PI / 4, 0);
挂靠关系不是一次性的,可以运行时改嫁。轮子爆了换备胎,就是改一次 parent;被拆下来的节点局部坐标保持不变,所以"拆下来瞬间跳位"是常见现象,拆前先算好世界坐标再重挂可以避免。
背景:一辆演示车,车身加四个轮子。第一版没用树,每帧回调里手动同步轮子位置,车一转弯轮子就漂移,维护代码比业务代码还长。
操作:重构三步。第一步建 carRoot 空节点接管整车变换;第二步把车身与轮子全部挂到它名下,轮子的 position 改写成相对车身的局部坐标;第三步删掉全部手动同步代码,只保留"轮子自转"这一项属于轮子自己的动画:
// 重构后:整车移动只需动父节点,轮子自转是各自的局部动画 const carRoot = new BABYLON.TransformNode("car", scene); const wheels = []; for (const [x, z] of [[1, 1.4], [-1, 1.4], [1, -1.4], [-1, -1.4]]) { const w = BABYLON.MeshBuilder.CreateCylinder("wheel", { diameter: 0.8, height: 0.3 }, scene); w.rotation.x = Math.PI / 2; // 圆柱躺倒当轮子:只动自己的局部旋转 w.position.set(x, -0.1, z); w.parent = carRoot; wheels.push(w); } scene.onBeforeRenderObservable.add(() => { const dt = engine.getDeltaTime() / 1000; carRoot.position.z += 2 * dt; // 整车前进:一行搞定 for (const w of wheels) w.rotation.y += 3 * dt; // 轮子各自自转 });
结果:同步代码从三十多行降到零行,转弯、爬坡、整车缩放的动画全部免费获得,帧率无差别。
解读:树形挂靠的本质是把"关系"从代码搬进数据结构。凡是要"跟着动"的组合——车身与轮子、角色与武器、地球与月球——都优先考虑用 parent 表达,而不是每帧手算。
变式:角色手持武器瞄准时,武器还要相对手部再偏转,就再嵌一层节点(手部节点挂角色、武器挂手部),局部性一层层叠加;这也是 2.4 节 glTF 模型内部的标准结构,模型文件的节点树会原样映射到场景图。
⚠️ 常见坑:把网格直接 parent 到另一个网格当然可行,但更好的习惯是挂到 TransformNode 这类纯变换节点上——网格被合并或销毁时,树的结构不受牵连。
💡 关键直觉:调试变换问题的口诀是"先查树再查数"。位置不对,先沿父链逐级打印 absolutePosition 找出哪一级开始偏,再核对那一级的局部三件套,几乎不用猜。
树立起来了,但一帧要画出什么,还取决于从哪里看、被什么照亮。下一节把相机与灯光这两个每帧数据源装上流水线。