第 5 篇:Object3D、Group 与场景树
本文是"使用 Three.js 构建转炉数字孪生系统"系列的第 5 篇。
本篇将系统介绍
Object3D、Mesh、Group、父子节点、场景树遍历和模型层级设计。
在前一篇中,我们学习了:
text
position
rotation
scale
局部坐标
世界坐标
但这些属性并不是孤立存在的。
Three.js 会把场景中的对象组织成父子层级,并让父节点的变换继续影响子节点。
例如:
text
转炉整体位置发生变化
→ 炉体、耳轴、炉口和标签一起移动
耳轴旋转节点发生旋转
→ 转炉炉体围绕耳轴倾动
氧枪系统整体移动
→ 导轨、枪体和枪头一起移动
钢包车移动
→ 钢包、吊耳和状态标签一起移动
Three.js 官方手册将场景图称为 Three.js 的核心之一。场景图中的每个节点都代表一个局部空间,子节点在父节点的局部空间中定义自己的位置、旋转和缩放。
因此,场景树不仅是一种代码分类方式,更是一套三维变换系统。
一、本文要解决的问题
完成本文后,应当能够回答:
Object3D是什么;- 为什么
Mesh、Group、Camera 和 Light 都能拥有position; Mesh与Group有什么区别;- 父节点如何影响子节点;
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 会先把 mesh 从 groupA 移除,再加入 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();- 使用基础几何体搭建转炉系统占位模型。