第 5 篇:Object3D、Group 与场景树

第 5 篇:Object3D、Group 与场景树

本文是"使用 Three.js 构建转炉数字孪生系统"系列的第 5 篇。

本篇将系统介绍 Object3DMeshGroup、父子节点、场景树遍历和模型层级设计。

在前一篇中,我们学习了:

text 复制代码
position
rotation
scale
局部坐标
世界坐标

但这些属性并不是孤立存在的。

Three.js 会把场景中的对象组织成父子层级,并让父节点的变换继续影响子节点。

例如:

text 复制代码
转炉整体位置发生变化
→ 炉体、耳轴、炉口和标签一起移动

耳轴旋转节点发生旋转
→ 转炉炉体围绕耳轴倾动

氧枪系统整体移动
→ 导轨、枪体和枪头一起移动

钢包车移动
→ 钢包、吊耳和状态标签一起移动

Three.js 官方手册将场景图称为 Three.js 的核心之一。场景图中的每个节点都代表一个局部空间,子节点在父节点的局部空间中定义自己的位置、旋转和缩放。

因此,场景树不仅是一种代码分类方式,更是一套三维变换系统。


一、本文要解决的问题

完成本文后,应当能够回答:

  • Object3D 是什么;
  • 为什么 MeshGroup、Camera 和 Light 都能拥有 position
  • MeshGroup 有什么区别;
  • 父节点如何影响子节点;
  • add()remove() 实际做了什么;
  • 一个对象能否同时属于两个父节点;
  • 重新挂载节点后为什么世界位置可能变化;
  • traverse() 适合处理哪些任务;
  • 为什么不应在 traverse() 回调中直接修改场景树;
  • getObjectByName() 为什么可能返回错误节点;
  • 如何给转炉数字孪生设计稳定、可控制的节点层级;
  • 如何把导入模型的视觉结构与程序控制结构分开。

二、什么是场景树

1. 从 Scene 开始的父子层级

Three.js 中,Scene 通常是整个三维场景的根节点。

一个转炉数字孪生场景可能具有下面的结构:

text 复制代码
Scene
├─ EnvironmentRoot
│  ├─ WorkshopModel
│  ├─ Ground
│  └─ LightingRoot
├─ EquipmentRoot
│  ├─ ConverterSystem
│  ├─ OxygenLanceSystem
│  ├─ LadleSystem
│  └─ CraneSystem
├─ EffectsRoot
│  ├─ FlameEffect
│  ├─ SmokeEffect
│  └─ SparkEffect
└─ HelperRoot
   ├─ AxesHelper
   ├─ GridHelper
   └─ DebugMarkers

每一个节点都可以拥有:

text 复制代码
父节点
子节点
局部位置
局部旋转
局部缩放

从根节点向下,就形成了一棵层级树。


2. 为什么通常称为"场景图"

Three.js 官方通常使用:

text 复制代码
Scene Graph

也就是"场景图"。

但在 Object3D 的父子关系中,一个对象同一时间最多只有一个父节点,因此大多数实际结构表现为树形层级。

每个 Object3D 都有:

javascript 复制代码
object.parent
object.children

其中:

text 复制代码
parent:当前父节点
children:直接子节点数组

根节点 Scene 通常没有父节点:

javascript 复制代码
console.log(scene.parent);

结果一般为:

text 复制代码
null

3. 场景树不是文件目录

它看起来像目录结构,但它不仅用于整理对象。

例如:

text 复制代码
ConverterRoot
└─ ConverterPivot
   └─ ConverterModel

如果修改:

javascript 复制代码
converterRoot.position.x = 10;

那么:

text 复制代码
ConverterPivot
ConverterModel

都会跟随移动。

如果修改:

javascript 复制代码
converterPivot.rotation.z =
  THREE.MathUtils.degToRad(-45);

那么 ConverterModel 会围绕 ConverterPivot 的局部原点旋转。

所以,父子关系会参与实际的空间计算。

这就是场景树和普通对象分类表的根本区别。


三、Object3D

4. Object3D 是什么

Object3D 是 Three.js 中大多数三维对象的基础类。

Three.js 官方文档将它定义为:

Three.js 中大多数对象的基类,提供在三维空间中操作对象的一组属性和方法。

许多常见类型都继承自 Object3D

text 复制代码
Scene
Group
Mesh
Camera
Light
Line
Points
AxesHelper
GLTF 模型中的节点

因此,它们通常都具有:

javascript 复制代码
position
rotation
quaternion
scale
parent
children
visible
name
userData
matrix
matrixWorld

以及:

javascript 复制代码
add()
remove()
traverse()
getObjectByName()
getWorldPosition()
localToWorld()
worldToLocal()

5. 创建 Object3D

可以直接创建一个空的 Object3D

javascript 复制代码
const pivot = new THREE.Object3D();

它本身没有:

text 复制代码
Geometry
Material

因此,默认不会显示任何可见表面。

但它仍然可以:

  • 保存位置;
  • 保存旋转;
  • 保存缩放;
  • 成为父节点;
  • 作为旋转中心;
  • 作为相机挂点;
  • 作为标签锚点;
  • 组织其他对象。

例如:

javascript 复制代码
const converterPivot =
  new THREE.Object3D();

converterPivot.position.set(
  0,
  6,
  0
);

scene.add(converterPivot);

它可以作为转炉耳轴旋转中心。


6. Object3D 是一个局部空间

每个 Object3D 都定义了自己的局部空间。

当子对象加入父对象:

javascript 复制代码
parent.add(child);

此后:

javascript 复制代码
child.position
child.rotation
child.scale

都表示相对于 parent 的局部变换。

例如:

javascript 复制代码
const workshopRoot =
  new THREE.Object3D();

workshopRoot.position.set(
  100,
  0,
  -50
);

scene.add(workshopRoot);

const converterRoot =
  new THREE.Object3D();

converterRoot.position.set(
  20,
  0,
  10
);

workshopRoot.add(converterRoot);

此时:

text 复制代码
converterRoot 局部位置:(20, 0, 10)
workshopRoot 世界位置:(100, 0, -50)

如果父节点没有旋转和缩放,那么 converterRoot 的世界位置为:

text 复制代码
(120, 0, -40)

但如果父节点存在旋转或缩放,世界位置就不能只做简单加法,而要通过完整的变换矩阵计算。


7. Object3D 常用属性

name

javascript 复制代码
converterRoot.name =
  "ConverterRoot";

用于:

  • 调试;
  • 查找节点;
  • 输出场景树;
  • 建立模型节点约定。

visible

javascript 复制代码
converterRoot.visible = false;

父节点不可见时,其后代也不会被正常渲染。

userData

javascript 复制代码
converterRoot.userData = {
  equipmentId: "BOF-01",
  equipmentType: "converter",
  selectable: true
};

适合保存与三维节点相关的轻量业务元数据。

不要把大型实时数据、复杂服务对象或不可序列化对象全部塞进 userData

children

javascript 复制代码
console.log(
  converterRoot.children
);

只包含直接子节点,不包含更深层后代。

parent

javascript 复制代码
console.log(
  converterModel.parent
);

可以确认模型当前挂在哪个节点下。


四、Mesh

8. Mesh 是什么

Mesh 是最常见的可渲染三维对象。

它通常由两部分组成:

text 复制代码
Geometry
+
Material

创建:

javascript 复制代码
const geometry =
  new THREE.BoxGeometry(
    2,
    2,
    2
  );

const material =
  new THREE.MeshStandardMaterial({
    color: 0xf97316
  });

const mesh =
  new THREE.Mesh(
    geometry,
    material
  );

加入场景:

javascript 复制代码
scene.add(mesh);

9. Mesh 也是 Object3D

Mesh 继承自 Object3D

因此,Mesh 不只是一个可见表面,它同时也是场景树节点。

可以写:

javascript 复制代码
mesh.position.set(1, 2, 3);

mesh.rotation.y =
  THREE.MathUtils.degToRad(45);

mesh.scale.setScalar(2);

mesh.add(childObject);

也就是说,Mesh 自己也可以拥有子节点。

例如,可以把一个标签锚点挂到炉体 Mesh 上:

javascript 复制代码
const labelAnchor =
  new THREE.Object3D();

labelAnchor.position.set(
  0,
  3,
  0
);

converterMesh.add(
  labelAnchor
);

当炉体移动和旋转时,标签锚点会跟随它。


10. 为什么不建议让可见 Mesh 承担所有父节点职责

虽然 Mesh 可以拥有子节点,但不代表所有控制层都应该直接使用 Mesh。

例如:

text 复制代码
ConverterMesh
├─ OxygenLance
├─ SmokeEffect
├─ AlarmLabel
└─ CameraAnchor

如果为了修改转炉外观而缩放 ConverterMesh,所有子节点都会受到影响。

更合理的结构是:

text 复制代码
ConverterRoot
├─ ConverterMotionPivot
│  └─ ConverterVisualRoot
│     └─ ConverterMesh
├─ ConverterEffectRoot
└─ ConverterAnchorRoot

这样:

text 复制代码
运动控制
模型显示
特效
标签锚点

可以分别管理。


五、Group

11. Group 是什么

Group 与普通 Object3D 非常接近。

Three.js 官方文档说明,Group 的主要作用是让"这一节点用于组织一组对象"的语义更加明确。

创建:

javascript 复制代码
const group =
  new THREE.Group();

添加多个对象:

javascript 复制代码
group.add(
  meshA,
  meshB,
  meshC
);

再把组加入场景:

javascript 复制代码
scene.add(group);

此后可以整体操作:

javascript 复制代码
group.position.x = 10;

group.rotation.y =
  THREE.MathUtils.degToRad(30);

group.scale.setScalar(2);

三个 Mesh 都会受到影响。


12. Group 与 Object3D 的区别

从实际变换能力看:

text 复制代码
Group
Object3D

非常接近。

两者都能:

  • 作为父节点;
  • 保存 position;
  • 保存 rotation;
  • 保存 scale;
  • add 子节点;
  • remove 子节点;
  • traverse 后代。

主要区别是代码语义。

使用:

javascript 复制代码
const equipmentGroup =
  new THREE.Group();

读者会直观理解:

这个节点用于组织一组对象。

使用:

javascript 复制代码
const converterPivot =
  new THREE.Object3D();

或者:

javascript 复制代码
const converterPivot =
  new THREE.Group();

都可以实现旋转中心。

项目中可以采用以下约定:

text 复制代码
用于分类和整体管理
→ Group

用于旋转中心、挂点和抽象空间节点
→ Object3D

可见模型
→ Mesh

这不是 Three.js 强制规则,但有助于保持代码清晰。


13. Group 的典型用途

设备系统分组

javascript 复制代码
const equipmentRoot =
  new THREE.Group();

equipmentRoot.name =
  "EquipmentRoot";

scene.add(equipmentRoot);

转炉系统分组

javascript 复制代码
const converterSystem =
  new THREE.Group();

converterSystem.name =
  "ConverterSystem";

equipmentRoot.add(
  converterSystem
);

特效分组

javascript 复制代码
const effectRoot =
  new THREE.Group();

effectRoot.name =
  "EffectRoot";

scene.add(effectRoot);

调试辅助器分组

javascript 复制代码
const helperRoot =
  new THREE.Group();

helperRoot.name =
  "HelperRoot";

scene.add(helperRoot);

统一隐藏:

javascript 复制代码
helperRoot.visible = false;

六、父子节点

14. 建立父子关系

使用:

javascript 复制代码
parent.add(child);

例如:

javascript 复制代码
scene.add(
  converterRoot
);

converterRoot.add(
  converterPivot
);

converterPivot.add(
  converterModel
);

结构为:

text 复制代码
Scene
└─ ConverterRoot
   └─ ConverterPivot
      └─ ConverterModel

15. 一个对象最多只有一个父节点

这是非常重要的规则。

假设:

javascript 复制代码
groupA.add(mesh);

此时:

javascript 复制代码
mesh.parent === groupA

之后执行:

javascript 复制代码
groupB.add(mesh);

Three.js 会先把 meshgroupA 移除,再加入 groupB

最终:

javascript 复制代码
mesh.parent === groupB

一个 Object3D 不会同时出现在两个父节点下。

如果确实需要在不同位置显示相同模型,通常需要:

  • 克隆对象;
  • 创建多个 Mesh;
  • 复用 Geometry 和 Material;
  • 使用 InstancedMesh;
  • 根据动画和骨骼情况使用 SkeletonUtils。

不能把同一个 Mesh 同时 add() 到两个 Group 中。


16. 父节点的变换会传递给子节点

假设:

javascript 复制代码
parent.position.x = 10;
child.position.x = 2;

如果没有其他旋转和缩放,子节点世界 X 位置为:

text 复制代码
12

如果父节点旋转:

javascript 复制代码
parent.rotation.y =
  THREE.MathUtils.degToRad(90);

子节点的局部 X 方向也会跟随父节点转向。

如果父节点缩放:

javascript 复制代码
parent.scale.setScalar(2);

子节点自身尺寸以及它相对父节点的空间距离都会受到影响。

这就是场景图能够自动实现复杂机械联动的原因。


17. 父子关系表示"随动关系"

在数字孪生系统中,应根据真实机械随动关系建立层级。

例如:

text 复制代码
天车移动
→ 吊钩跟随天车

吊钩升降
→ 钢包跟随吊钩

钢包自身倾翻
→ 钢包模型围绕自身轴旋转

可以设计为:

text 复制代码
CraneRoot
└─ TrolleyRoot
   └─ HookLiftRoot
      └─ LadleTiltPivot
         └─ LadleModel

分别控制:

javascript 复制代码
craneRoot.position.x =
  craneTravel;

trolleyRoot.position.z =
  trolleyTravel;

hookLiftRoot.position.y =
  -hookDistance;

ladleTiltPivot.rotation.z =
  THREE.MathUtils.degToRad(
    ladleAngle
  );

不需要手动把每一级运动都重新计算给钢包模型。


七、局部变换与世界变换

18. 局部变换

对象的:

javascript 复制代码
position
rotation
scale

默认表示相对于父节点的局部变换。

例如:

javascript 复制代码
converterModel.position.set(
  0,
  -1.8,
  0
);

表示转炉模型相对于 converterPivot 向下偏移 1.8 个单位。

它并不表示模型在整个 Scene 中的绝对高度是 -1.8


19. 世界变换

对象的最终世界变换由整条祖先链共同决定。

例如:

text 复制代码
Scene
└─ WorkshopRoot
   └─ ConverterRoot
      └─ ConverterPivot
         └─ ConverterModel

ConverterModel 的世界变换由以下节点共同决定:

text 复制代码
WorkshopRoot
ConverterRoot
ConverterPivot
ConverterModel

Three.js 会把这些局部变换组合到:

javascript 复制代码
object.matrixWorld

中。


20. 获取世界位置

javascript 复制代码
const worldPosition =
  new THREE.Vector3();

converterModel.getWorldPosition(
  worldPosition
);

console.log(
  worldPosition.toArray()
);

21. 获取世界旋转和缩放

世界旋转通常通过四元数获取:

javascript 复制代码
const worldQuaternion =
  new THREE.Quaternion();

converterModel.getWorldQuaternion(
  worldQuaternion
);

世界缩放:

javascript 复制代码
const worldScale =
  new THREE.Vector3();

converterModel.getWorldScale(
  worldScale
);

22. matrix 与 matrixWorld

每个 Object3D 都有:

javascript 复制代码
object.matrix
object.matrixWorld

可以简单理解为:

text 复制代码
matrix
→ 对象自身的局部变换矩阵

matrixWorld
→ 对象组合全部父节点之后的世界变换矩阵

通常不需要手动编辑它们。

Three.js 默认根据:

text 复制代码
position
rotation / quaternion
scale

自动计算矩阵。


23. matrixAutoUpdate

默认情况下:

javascript 复制代码
object.matrixAutoUpdate === true

Three.js 会自动更新对象局部矩阵。

如果为了极端性能优化关闭:

javascript 复制代码
object.matrixAutoUpdate = false;

修改变换后就需要主动调用:

javascript 复制代码
object.updateMatrix();

初学阶段和普通数字孪生项目中,不要过早关闭自动更新。

先保证结构和数据正确,再根据性能分析决定是否优化。


八、add()

24. 添加一个子节点

javascript 复制代码
parent.add(child);

返回的是父节点本身,因此可以链式使用,但复杂层级建议分开写,以保持可读性。


25. 一次添加多个节点

javascript 复制代码
group.add(
  meshA,
  meshB,
  meshC
);

适合添加同级节点。


26. add() 会重新设置父节点

例如:

javascript 复制代码
groupA.add(mesh);
groupB.add(mesh);

第二次 add() 后,mesh 不再属于 groupA

可以检查:

javascript 复制代码
console.log(
  groupA.children.includes(mesh)
);

console.log(
  groupB.children.includes(mesh)
);

结果通常为:

text 复制代码
false
true

27. add() 不会保持原世界变换

假设:

javascript 复制代码
groupA.position.x = 10;
groupB.position.x = -20;

groupA.add(mesh);
mesh.position.x = 2;

此时 mesh 世界 X 位置可能是:

text 复制代码
12

然后执行:

javascript 复制代码
groupB.add(mesh);

mesh.position.x 的局部值仍可能保持为:

text 复制代码
2

但它的新父节点位于 -20,因此世界位置可能变成:

text 复制代码
-18

这说明:

add() 保留对象自身局部变换,但更换父空间后,世界变换通常会改变。


28. 保持世界变换地重新挂载

Three.js 还提供:

javascript 复制代码
newParent.attach(object);

它会把对象重新挂到新父节点,同时尽量保持对象当前世界变换。

例如:

javascript 复制代码
newParent.attach(
  converterModel
);

需要注意:

  • attach() 涉及矩阵转换;
  • 存在非均匀缩放的场景树时不受完整支持;
  • 复杂工业层级最好在初始化阶段设计稳定;
  • 不应频繁依靠动态重新挂载修补错误结构。

对于设备运行,推荐使用固定场景树,只更新变换数据。


九、remove()

29. 从父节点中移除对象

javascript 复制代码
parent.remove(child);

移除后:

javascript 复制代码
child.parent === null

它不再跟随原父节点,也不会继续作为原场景树的一部分渲染。


30. 对象自行离开父节点

javascript 复制代码
child.removeFromParent();

不需要提前知道父节点引用。


31. 移除多个对象

javascript 复制代码
parent.remove(
  childA,
  childB,
  childC
);

32. 清空直接子节点

javascript 复制代码
group.clear();

它会移除该节点下的全部直接子节点。


33. remove() 不等于释放资源

这是常见误区。

执行:

javascript 复制代码
scene.remove(model);

只表示:

把 model 从场景树中移除。

它不会自动释放:

  • Geometry;
  • Material;
  • Texture;
  • RenderTarget;
  • GPU 缓冲区。

如果模型确定不再使用,还需要根据资源所有权主动释放。

例如:

javascript 复制代码
model.traverse((object) => {
  if (!object.isMesh) {
    return;
  }

  object.geometry?.dispose();

  const materials =
    Array.isArray(object.material)
      ? object.material
      : [object.material];

  for (const material of materials) {
    material?.dispose();
  }
});

model.removeFromParent();

如果多个模型共享同一个 Geometry、Material 或 Texture,不能在其中一个模型移除时直接释放共享资源。

资源释放必须由统一的资源管理策略决定。


十、traverse()

34. traverse() 是什么

traverse() 会从当前对象开始,依次对当前节点和全部后代执行回调。

javascript 复制代码
root.traverse((object) => {
  console.log(object.name);
});

注意:

回调会先处理 root 自己,然后处理其后代。


35. 使用 traverse() 查找所有 Mesh

javascript 复制代码
model.traverse((object) => {
  if (object.isMesh) {
    console.log(
      "发现 Mesh:",
      object.name
    );
  }
});

相比:

javascript 复制代码
object instanceof THREE.Mesh

Three.js 项目中常用:

javascript 复制代码
object.isMesh

进行类型判断。


36. 批量设置阴影

javascript 复制代码
model.traverse((object) => {
  if (!object.isMesh) {
    return;
  }

  object.castShadow = true;
  object.receiveShadow = true;
});

这在加载 GLTF 模型后非常常见。


37. 批量设置材质属性

javascript 复制代码
model.traverse((object) => {
  if (!object.isMesh) {
    return;
  }

  const materials =
    Array.isArray(object.material)
      ? object.material
      : [object.material];

  for (const material of materials) {
    material.roughness = 0.65;
    material.metalness = 0.35;
  }
});

需要考虑一个 Mesh 可能拥有材质数组。


38. 收集节点

javascript 复制代码
const meshes = [];

model.traverse((object) => {
  if (object.isMesh) {
    meshes.push(object);
  }
});

之后可以统一处理。


39. 输出模型层级

javascript 复制代码
function printSceneTree(root) {
  root.traverse((object) => {
    let depth = 0;
    let current = object.parent;

    while (current) {
      depth += 1;
      current = current.parent;
    }

    console.log(
      `${"  ".repeat(depth)}${object.name || object.type}`
    );
  });
}

调用:

javascript 复制代码
printSceneTree(gltf.scene);

这对于检查导入模型节点名称非常有用。


40. 不要在 traverse() 中直接修改场景树

Three.js 官方文档明确提醒:

不建议在 traverse() 回调内部修改场景图。

错误示例:

javascript 复制代码
root.traverse((object) => {
  if (object.userData.shouldRemove) {
    object.removeFromParent();
  }
});

因为遍历过程中修改 children 数组,可能导致:

  • 节点被跳过;
  • 遍历顺序混乱;
  • 访问不存在的项;
  • 结果难以预测。

正确方式是先收集,再修改:

javascript 复制代码
const objectsToRemove = [];

root.traverse((object) => {
  if (object.userData.shouldRemove) {
    objectsToRemove.push(object);
  }
});

for (const object of objectsToRemove) {
  object.removeFromParent();
}

41. traverseVisible()

如果只需要遍历可见节点,可以使用:

javascript 复制代码
root.traverseVisible(
  (object) => {
    console.log(object.name);
  }
);

如果某个父节点:

javascript 复制代码
parent.visible = false;

它的后代不会继续被 traverseVisible() 处理。


42. traverseAncestors()

从当前对象向上处理祖先节点:

javascript 复制代码
object.traverseAncestors(
  (ancestor) => {
    console.log(
      ancestor.name
    );
  }
);

可用于:

  • 从点击的 Mesh 查找所属设备根节点;
  • 判断对象位于哪个系统分组;
  • 查找最近的可选择设备;
  • 沿父链读取设备 ID。

十一、getObjectByName()

43. 设置节点名称

javascript 复制代码
converterPivot.name =
  "ConverterPivot";

模型加载时也可能自带节点名称:

text 复制代码
Converter_Body
Trunnion_Left
Trunnion_Right
Oxygen_Lance
Hood

44. 按名称查找节点

javascript 复制代码
const converterPivot =
  root.getObjectByName(
    "ConverterPivot"
  );

返回类型可能是:

text 复制代码
Object3D
undefined

因此必须检查:

javascript 复制代码
if (!converterPivot) {
  throw new Error(
    "未找到 ConverterPivot 节点"
  );
}

不要直接写:

javascript 复制代码
root
  .getObjectByName(
    "ConverterPivot"
  )
  .rotation.z = angle;

如果名称错误,会得到难以理解的空值错误。


45. getObjectByName() 会向下递归查找

它会从当前节点开始,搜索:

text 复制代码
当前节点
当前节点的子节点
更深层后代

例如:

javascript 复制代码
gltf.scene.getObjectByName(
  "OxygenLance"
);

不需要手动逐层查找。


46. 只返回第一个同名节点

如果场景中有多个名称相同的节点:

text 复制代码
Bolt
Bolt
Bolt

调用:

javascript 复制代码
root.getObjectByName("Bolt");

只会返回第一个匹配项。

因此,重要控制节点必须使用唯一名称。

例如:

text 复制代码
BOF01_ConverterPivot
BOF01_OxygenLance
BOF01_Hood
BOF02_ConverterPivot

或者在各自设备根节点下局部查找:

javascript 复制代码
const bof01Pivot =
  bof01Root.getObjectByName(
    "ConverterPivot"
  );

47. 名称不是稳定数据库主键

节点名称适合:

  • 建模约定;
  • 调试;
  • 初始化绑定;
  • 小型项目查找。

但大型系统不应只依赖随意名称作为所有业务关联依据。

可以结合:

javascript 复制代码
object.userData.equipmentId
object.userData.nodeRole

例如:

javascript 复制代码
converterPivot.userData = {
  equipmentId: "BOF-01",
  nodeRole: "motion-pivot"
};

然后建立显式引用表:

javascript 复制代码
const equipmentNodes = {
  converterRoot,
  converterPivot,
  converterModel,
  lanceRoot,
  oxygenLance
};

运行过程中优先使用已经缓存的引用,不要每一帧反复调用 getObjectByName()


十二、模型层级设计

48. 原始模型层级和程序控制层级

GLTF 模型加载后,通常已经自带一套层级。

例如:

text 复制代码
ImportedScene
└─ Workshop
   └─ Converter
      ├─ Shell
      ├─ TrunnionLeft
      ├─ TrunnionRight
      └─ Mouth

但原始层级不一定适合程序控制。

原因可能包括:

  • 原点不在耳轴中心;
  • 模型整体采用毫米单位;
  • 模型坐标轴与 Three.js 不一致;
  • 节点名称不稳定;
  • 多个可见网格被建模软件自动拆分;
  • 设备和环境混在同一层;
  • 材质节点层级不适合作为运动节点。

因此,推荐在导入模型外部建立程序控制层。


49. 推荐的转炉节点结构

text 复制代码
ConverterRoot
├─ ConverterMotionPivot
│  └─ ConverterOrientationRoot
│     └─ ConverterScaleRoot
│        └─ ImportedConverterModel
├─ ConverterEffectRoot
├─ ConverterAnchorRoot
└─ ConverterHelperRoot

各层职责如下。

ConverterRoot

负责:

text 复制代码
转炉设备在车间中的整体位置
设备显隐
设备业务标识
设备整体启用状态

ConverterMotionPivot

负责:

text 复制代码
围绕真实耳轴中心倾动

ConverterOrientationRoot

负责:

text 复制代码
修正导入模型的轴向

ConverterScaleRoot

负责:

text 复制代码
统一毫米到米等模型单位

ImportedConverterModel

负责:

text 复制代码
保存原始视觉模型层级

ConverterEffectRoot

负责:

text 复制代码
火焰
烟气
火花
高温效果

ConverterAnchorRoot

负责:

text 复制代码
标签锚点
相机观察点
数据标注点

ConverterHelperRoot

负责:

text 复制代码
AxesHelper
BoxHelper
调试标记

50. 推荐的氧枪节点结构

text 复制代码
OxygenLanceSystemRoot
├─ LanceGuideRoot
│  └─ LanceTravelRoot
│     └─ LanceOrientationRoot
│        └─ ImportedLanceModel
├─ LanceTipAnchor
├─ LanceEffectRoot
└─ LanceHelperRoot

其中:

text 复制代码
LanceGuideRoot
→ 确定导轨空间方向

LanceTravelRoot
→ 只负责升降距离

LanceOrientationRoot
→ 修正模型原始方向

LanceTipAnchor
→ 表示枪尖数据和特效位置

51. 推荐的钢包运输结构

text 复制代码
LadleTransportRoot
└─ LadleCarriageRoot
   ├─ LadleLiftRoot
   │  └─ LadleTiltPivot
   │     └─ ImportedLadleModel
   ├─ LadleAnchorRoot
   └─ LadleEffectRoot

这样可以分别控制:

text 复制代码
运输位置
升降高度
倾翻角度
模型方向
标签锚点

52. 结构设计原则

一个节点只承担一个主要变换职责

不推荐:

text 复制代码
同一个节点同时负责:
整体定位
单位缩放
方向修正
设备运动
报警动画

推荐:

text 复制代码
每种变换职责拆成独立节点

运动节点放在模型缩放节点之前

推荐:

text 复制代码
Root
→ MotionPivot
→ OrientationRoot
→ ScaleRoot
→ Model

这样运动逻辑使用统一工程单位,不容易被模型缩放干扰。

控制节点与可见模型分离

控制节点可以长期稳定。

即使以后替换模型文件,只要把新模型挂到同一个控制节点下,设备运动代码就不需要重写。

重要控制节点名称唯一

例如:

text 复制代码
BOF01_ConverterRoot
BOF01_ConverterPivot
BOF01_LanceTravel
BOF01_LanceTipAnchor

缓存节点引用

初始化时查找一次:

javascript 复制代码
const nodes = {
  pivot:
    modelRoot.getObjectByName(
      "ConverterPivot"
    ),
  lance:
    modelRoot.getObjectByName(
      "OxygenLance"
    )
};

运行时直接使用:

javascript 复制代码
nodes.pivot.rotation.z =
  angle;

十三、创建转炉场景树

53. 创建节点

javascript 复制代码
const converterRoot =
  new THREE.Group();

converterRoot.name =
  "BOF01_ConverterRoot";

converterRoot.userData = {
  equipmentId: "BOF-01",
  isEquipmentRoot: true
};

scene.add(converterRoot);

const converterPivot =
  new THREE.Object3D();

converterPivot.name =
  "BOF01_ConverterPivot";

converterPivot.position.set(
  0,
  6,
  0
);

converterRoot.add(
  converterPivot
);

const orientationRoot =
  new THREE.Object3D();

orientationRoot.name =
  "BOF01_OrientationRoot";

converterPivot.add(
  orientationRoot
);

const scaleRoot =
  new THREE.Object3D();

scaleRoot.name =
  "BOF01_ScaleRoot";

scaleRoot.scale.setScalar(
  0.001
);

orientationRoot.add(
  scaleRoot
);

加载模型后:

javascript 复制代码
scaleRoot.add(
  importedModel
);

54. 控制转炉角度

javascript 复制代码
function setConverterAngle(
  angleInDegrees
) {
  converterPivot.rotation.z =
    THREE.MathUtils.degToRad(
      angleInDegrees
    );
}

其他节点自动继承旋转结果。


55. 查找节点并校验

javascript 复制代码
function requireNode(
  root,
  nodeName
) {
  const node =
    root.getObjectByName(
      nodeName
    );

  if (!node) {
    throw new Error(
      `缺少模型节点:${nodeName}`
    );
  }

  return node;
}

使用:

javascript 复制代码
const shell =
  requireNode(
    importedModel,
    "ConverterShell"
  );

这比等到动画执行时才出现空值错误更容易定位问题。


十四、场景树调试工具

56. 打印清晰的层级结构

javascript 复制代码
function logSceneTree(
  root,
  depth = 0
) {
  const indent =
    "  ".repeat(depth);

  const displayName =
    root.name || root.type;

  console.log(
    `${indent}${displayName} [${root.type}]`
  );

  for (
    const child of root.children
  ) {
    logSceneTree(
      child,
      depth + 1
    );
  }
}

调用:

javascript 复制代码
logSceneTree(scene);

输出类似:

text 复制代码
Scene [Scene]
  EquipmentRoot [Group]
    BOF01_ConverterRoot [Group]
      BOF01_ConverterPivot [Object3D]
        BOF01_OrientationRoot [Object3D]
          BOF01_ScaleRoot [Object3D]
            ConverterShell [Mesh]

57. 为控制节点添加坐标轴

由于官方不建议在 traverse() 回调中修改场景树,因此应先收集目标节点,再添加辅助器。

javascript 复制代码
function addAxesToNamedNodes(
  root,
  size = 2
) {
  const targets = [];

  root.traverse((object) => {
    if (
      object.name.includes("Root") ||
      object.name.includes("Pivot")
    ) {
      targets.push(object);
    }
  });

  for (const object of targets) {
    const axes =
      new THREE.AxesHelper(
        size
      );

    axes.material.depthTest =
      false;

    axes.renderOrder = 100;

    object.add(axes);
  }
}

58. 检查重复名称

javascript 复制代码
function findDuplicateNames(
  root
) {
  const nameMap =
    new Map();

  root.traverse((object) => {
    if (!object.name) {
      return;
    }

    const list =
      nameMap.get(object.name) ??
      [];

    list.push(object);

    nameMap.set(
      object.name,
      list
    );
  });

  const duplicates = [];

  for (
    const [name, objects]
    of nameMap
  ) {
    if (objects.length > 1) {
      duplicates.push({
        name,
        count: objects.length,
        objects
      });
    }
  }

  return duplicates;
}

十五、完整示例

59. 示例目标

下面的示例将创建:

text 复制代码
Scene
└─ EquipmentRoot
   └─ ConverterRoot
      ├─ ConverterPivot
      │  └─ ConverterModel
      ├─ LabelAnchor
      └─ HelperRoot

并演示:

  • Object3D
  • Mesh
  • Group
  • add()
  • remove()
  • traverse()
  • getObjectByName()
  • 父子节点变换;
  • 世界位置读取。

60. main.js

javascript 复制代码
import * as THREE from "three";
import "./style.css";

const app =
  document.querySelector("#app");

if (!app) {
  throw new Error(
    "未找到 #app 容器"
  );
}

const scene =
  new THREE.Scene();

scene.background =
  new THREE.Color(0x111827);

const camera =
  new THREE.PerspectiveCamera(
    60,
    window.innerWidth /
      window.innerHeight,
    0.1,
    500
  );

camera.position.set(
  15,
  12,
  18
);

camera.lookAt(
  0,
  4,
  0
);

const renderer =
  new THREE.WebGLRenderer({
    antialias: true
  });

renderer.setPixelRatio(
  Math.min(
    window.devicePixelRatio,
    2
  )
);

renderer.setSize(
  window.innerWidth,
  window.innerHeight
);

app.appendChild(
  renderer.domElement
);

const ambientLight =
  new THREE.AmbientLight(
    0xffffff,
    1.5
  );

scene.add(ambientLight);

const directionalLight =
  new THREE.DirectionalLight(
    0xffffff,
    3
  );

directionalLight.position.set(
  8,
  14,
  10
);

scene.add(directionalLight);

scene.add(
  new THREE.GridHelper(
    30,
    30
  )
);

const equipmentRoot =
  new THREE.Group();

equipmentRoot.name =
  "EquipmentRoot";

scene.add(equipmentRoot);

const converterRoot =
  new THREE.Group();

converterRoot.name =
  "BOF01_ConverterRoot";

converterRoot.userData = {
  equipmentId: "BOF-01",
  isEquipmentRoot: true
};

equipmentRoot.add(
  converterRoot
);

const converterPivot =
  new THREE.Object3D();

converterPivot.name =
  "BOF01_ConverterPivot";

converterPivot.position.set(
  0,
  6,
  0
);

converterRoot.add(
  converterPivot
);

const converterGeometry =
  new THREE.CylinderGeometry(
    2.3,
    2.8,
    4.6,
    32
  );

const converterMaterial =
  new THREE.MeshStandardMaterial({
    color: 0x64748b,
    roughness: 0.65,
    metalness: 0.4
  });

const converterModel =
  new THREE.Mesh(
    converterGeometry,
    converterMaterial
  );

converterModel.name =
  "BOF01_ConverterModel";

converterModel.position.set(
  0,
  -1.7,
  0
);

converterPivot.add(
  converterModel
);

const labelAnchor =
  new THREE.Object3D();

labelAnchor.name =
  "BOF01_LabelAnchor";

labelAnchor.position.set(
  0,
  3,
  0
);

converterModel.add(
  labelAnchor
);

const helperRoot =
  new THREE.Group();

helperRoot.name =
  "BOF01_HelperRoot";

converterRoot.add(
  helperRoot
);

helperRoot.add(
  new THREE.AxesHelper(5)
);

converterPivot.add(
  new THREE.AxesHelper(3)
);

const pivotFromName =
  scene.getObjectByName(
    "BOF01_ConverterPivot"
  );

if (!pivotFromName) {
  throw new Error(
    "缺少转炉旋转中心节点"
  );
}

converterRoot.traverse(
  (object) => {
    console.log(
      object.name ||
      object.type
    );
  }
);

const labelWorldPosition =
  new THREE.Vector3();

const clock =
  new THREE.Clock();

function animate() {
  const elapsed =
    clock.getElapsedTime();

  const angleInDegrees =
    -30 +
    Math.sin(
      elapsed * 0.6
    ) * 45;

  pivotFromName.rotation.z =
    THREE.MathUtils.degToRad(
      angleInDegrees
    );

  labelAnchor.getWorldPosition(
    labelWorldPosition
  );

  renderer.render(
    scene,
    camera
  );
}

renderer.setAnimationLoop(
  animate
);

function handleResize() {
  const width =
    window.innerWidth;

  const height =
    window.innerHeight;

  camera.aspect =
    width / height;

  camera.updateProjectionMatrix();

  renderer.setSize(
    width,
    height
  );
}

window.addEventListener(
  "resize",
  handleResize
);

十六、设备节点注册表

61. 为什么需要节点注册表

只使用:

javascript 复制代码
getObjectByName()

管理全部设备,在大型项目中会出现:

  • 名称冲突;
  • 拼写错误;
  • 每帧重复搜索;
  • 类型不明确;
  • 模型更新后静默失效。

推荐在模型初始化完成时建立节点注册表。


62. 创建转炉节点注册表

javascript 复制代码
function createConverterNodes(
  root
) {
  const converterRoot =
    root.getObjectByName(
      "BOF01_ConverterRoot"
    );

  const converterPivot =
    root.getObjectByName(
      "BOF01_ConverterPivot"
    );

  const converterModel =
    root.getObjectByName(
      "BOF01_ConverterModel"
    );

  const labelAnchor =
    root.getObjectByName(
      "BOF01_LabelAnchor"
    );

  const requiredNodes = {
    converterRoot,
    converterPivot,
    converterModel,
    labelAnchor
  };

  for (
    const [name, node]
    of Object.entries(
      requiredNodes
    )
  ) {
    if (!node) {
      throw new Error(
        `缺少必要节点:${name}`
      );
    }
  }

  return requiredNodes;
}

使用:

javascript 复制代码
const converterNodes =
  createConverterNodes(
    scene
  );

converterNodes
  .converterPivot
  .rotation.z =
    THREE.MathUtils.degToRad(
      -45
    );

十七、常见错误

63. 把 Group 当成纯分类目录

错误理解:

text 复制代码
Group 只用于整理代码

实际情况:

text 复制代码
Group 也是变换节点

修改 Group 的位置、旋转或缩放,会影响所有后代。


64. 同一个对象 add 到两个父节点

错误:

javascript 复制代码
groupA.add(mesh);
groupB.add(mesh);

认为两个组都拥有该 Mesh。

实际结果:

text 复制代码
mesh 会离开 groupA
只保留 groupB 作为父节点

65. 重新挂载后对象突然跳动

原因:

text 复制代码
add() 保留局部变换
但新父节点拥有不同的坐标空间

需要保持世界变换时,可以评估:

javascript 复制代码
newParent.attach(object);

但更推荐提前设计固定层级。


66. remove() 后认为内存已经释放

错误:

javascript 复制代码
scene.remove(model);

然后认为几何体、材质和纹理已经释放。

实际上 remove() 只断开父子关系。

GPU 资源仍可能存在。


67. 在 traverse() 中直接删除节点

不推荐:

javascript 复制代码
root.traverse((object) => {
  if (shouldRemove(object)) {
    object.removeFromParent();
  }
});

应先收集,再统一删除。


68. 每一帧调用 getObjectByName()

不推荐:

javascript 复制代码
function animate() {
  scene
    .getObjectByName(
      "ConverterPivot"
    )
    .rotation.z = angle;
}

推荐初始化时缓存:

javascript 复制代码
const converterPivot =
  scene.getObjectByName(
    "ConverterPivot"
  );

动画中:

javascript 复制代码
converterPivot.rotation.z =
  angle;

69. 节点名称重复

多个节点都叫:

text 复制代码
Cube
Cube
Cube

getObjectByName("Cube") 只返回第一个。

重要节点必须建立命名规范。


70. 直接依赖建模软件自动生成的名称

例如:

text 复制代码
Cube001
Cylinder037
Object_52

这些名称在重新导出模型后可能变化。

正式项目应:

  • 在建模阶段命名关键节点;
  • 在导入时执行节点校验;
  • 用程序控制层包裹原始模型;
  • 为关键节点建立稳定注册表。

71. 父节点非均匀缩放污染机械运动

例如:

javascript 复制代码
parent.scale.set(
  2,
  1,
  0.5
);

子节点的空间和运动方向会受到复杂影响。

机械运动父节点应尽量保持:

javascript 复制代码
scale.set(1, 1, 1);

模型尺寸修正放在更下层专用节点中。


十八、模型层级命名规范建议

72. 推荐命名方式

可以采用:

text 复制代码
设备编号_节点角色

例如:

text 复制代码
BOF01_Root
BOF01_MotionPivot
BOF01_ModelRoot
BOF01_LanceTravel
BOF01_LanceTipAnchor
BOF01_EffectRoot
BOF01_HelperRoot

也可以在每个设备局部根节点下使用短名称:

text 复制代码
BOF01_Root
├─ MotionPivot
├─ ModelRoot
├─ EffectRoot
└─ HelperRoot

然后只在 BOF01_Root 内部查找:

javascript 复制代码
bof01Root.getObjectByName(
  "MotionPivot"
);

73. 名称和 userData 配合

javascript 复制代码
converterRoot.name =
  "BOF01_Root";

converterRoot.userData = {
  equipmentId: "BOF-01",
  equipmentType: "converter",
  nodeRole: "equipment-root"
};

名称便于人工阅读,userData 便于程序识别。


十九、本篇必须掌握的代码

74. 创建空节点

javascript 复制代码
const node =
  new THREE.Object3D();

75. 创建分组

javascript 复制代码
const group =
  new THREE.Group();

76. 创建 Mesh

javascript 复制代码
const mesh =
  new THREE.Mesh(
    geometry,
    material
  );

77. 建立父子关系

javascript 复制代码
parent.add(child);

78. 移除子节点

javascript 复制代码
parent.remove(child);

或者:

javascript 复制代码
child.removeFromParent();

79. 遍历整棵子树

javascript 复制代码
root.traverse((object) => {
  console.log(object.name);
});

80. 按名称查找节点

javascript 复制代码
const object =
  root.getObjectByName(
    "NodeName"
  );

81. 获取世界位置

javascript 复制代码
const worldPosition =
  new THREE.Vector3();

object.getWorldPosition(
  worldPosition
);

二十、本文总结

Three.js 的场景图是一套父子层级结构。

每个节点都代表一个局部空间。

Object3D

是大多数 Three.js 三维对象的基础类。

它提供:

text 复制代码
变换
父子关系
遍历
查找
世界坐标转换

Mesh

是带有 Geometry 和 Material 的可渲染对象,同时也是 Object3D

Group

Object3D 非常接近,主要用于更清晰地表达"一组对象"的语义。

父子节点

子节点的最终状态会受到父节点:

text 复制代码
position
rotation
scale

影响。

add()

用于建立父子关系。

一个对象同一时间最多只有一个父节点。

将对象 add() 到新父节点时,它会离开旧父节点,且世界变换通常会改变。

remove()

用于断开父子关系,但不会自动释放 Geometry、Material 和 Texture。

traverse()

用于处理当前节点及其全部后代。

适合:

text 复制代码
批量设置阴影
查找 Mesh
处理材质
收集节点
输出模型层级

不建议在遍历回调中直接修改场景树。

getObjectByName()

用于递归查找第一个同名节点。

重要控制节点应使用唯一名称,并在初始化阶段缓存引用。

模型层级设计

转炉数字孪生不应直接依赖原始模型的所有层级。

推荐建立稳定的程序控制树:

text 复制代码
设备整体定位
→ 机械运动节点
→ 模型方向修正
→ 模型单位缩放
→ 原始视觉模型

最终可以用一句话概括:

场景树既是对象管理结构,也是变换传播结构;正确的节点层级能够把复杂机械联动转化为简单的局部位置和局部旋转控制。

对于转炉系统:

text 复制代码
ConverterRoot
→ 管理设备整体位置

ConverterPivot
→ 管理耳轴倾动

ModelRoot
→ 管理模型方向和尺寸

EffectRoot
→ 管理火焰和烟气

AnchorRoot
→ 管理标签与测点

理解这部分之后,才能稳定地加载真实设备模型,并对关键节点进行可靠控制。


二十一、下一篇学习内容

下一篇建议继续介绍:

第 6 篇:Geometry、Material 与 Mesh

主要内容可以包括:

  • BufferGeometry
  • 常用内置几何体;
  • 顶点、索引、法线和 UV;
  • Geometry 与 Mesh 的关系;
  • MeshBasicMaterial
  • MeshStandardMaterial
  • 材质颜色、透明度和双面显示;
  • 多材质;
  • Geometry 与 Material 复用;
  • dispose()
  • 使用基础几何体搭建转炉系统占位模型。

参考资料

相关推荐
mONESY3 小时前
React + TypeScript 入门到进阶:从 Props 类型约束到状态管理与副作用最佳实践
javascript
码哥DFS4 小时前
算法练习day1-备战2027届秋招
前端·javascript·数据结构·算法
张元清4 小时前
React useInfiniteScroll Hook:无限滚动轻松实现(2026)
javascript·react.js
别怪我很水4 小时前
Vue element admin 浏览器本地存储 localStorage、useStorage
前端·javascript·vue.js
晓得迷路了5 小时前
栗子前端技术周刊第 140 期 - pnpm、Node 新 API 文档网站、Oxlint...
前端·javascript·node.js
烬羽5 小时前
一个队列,怎么让滑动窗口从 O(nk) 变 O(n)?单调队列彻底搞懂
javascript·数据结构·算法
猫猫不是喵喵.15 小时前
Vue3 Props 属性
前端·javascript·vue.js
jarvisuni19 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
破z晓21 小时前
javascript 导出excel表
开发语言·javascript·excel