场景组织与层级:用 Group 管理你的世界
本教程共 40 篇 · 第 20 篇 · 更新于 2026-08-14 · 约 8 分钟阅读
本节目标:掌握 Group 和 Object3D 层级树,能组织几十上百个物体的场景,理解局部坐标与世界坐标的区别,会用 traverse、getObjectByName 等工具管理节点。
场景一复杂,平铺直叙地往 scene 里加物体就行不通了。这一章讲 three.js 组织世界的唯一方式:场景图。先看一个概念:场景里每个可见对象,都挂在一棵树上。
场景图:一棵树
从第 4 章开始,我们一直在用 scene.add(物体)。其实 Scene 本身也是一个 Object3D,所有东西都挂在它下面,形成一棵树。这棵树叫场景图(scene graph):
- 每个节点都可以有自己的孩子(children);
- 父节点的变换会传递给所有子孙;
- 移动、旋转、缩放父节点,子节点跟着动。树可以随便多深,嵌套本身不耗性能。渲染时 three.js 会沿着树递归计算每个节点的世界矩阵,这个计算量很小,真正影响性能的是绘制本身。
单个物体看不出好处。当场景里有几十个物体时,没有层级会寸步难行。假设一辆车的 20 个零件全部平铺在场景里:想整车挪个位置,就得挨个改坐标;想一起隐藏,就得写 20 行代码。
Group:纯组织容器
Group 就是为这个场景准备的”透明文件夹”。它没有任何渲染效果,唯一的作用是当父节点:
const car = new THREE.Group();
car.add(body, wheelFL, wheelFR, wheelRL, wheelRR);
scene.add(car);
car.position.set(10, 0, 0); // 整车移动
car.visible = false; // 整车隐藏
子节点仍然可以独立变换:车轮可以自己转,车门可以自己开。真实世界就是这么运作的——汽车移动时轮子跟着走,但轮子同时还在自转。机械臂是另一个经典场景:底座、大臂、小臂、手爪逐层嵌套,转动任何一个关节,它下面的部件跟着转,上面的不受影响。没有层级,这些联动全靠手算矩阵。
父子层级下的坐标系
每个 Object3D 的 position、rotation、scale 都是相对父节点的”局部坐标”。物体在世界的真实位置叫”世界坐标”,由父链上所有变换叠加而成。
const moon = new THREE.Mesh(geo, mat);
moonGroup.add(moon);
moon.position.set(1.5, 0, 0); // 相对月球组的原点
const worldPos = new THREE.Vector3();
moon.getWorldPosition(worldPos); // 换算成世界坐标
getWorldPosition 这类 getWorld* 方法专门用来把局部信息换算成世界信息,动画、碰撞检测里经常用到。
Note一个对象同一时刻只能有一个父节点。把对象 add 到新父节点时,它会自动从旧父节点移除,不会出现”身兼两职”。
场景树的常用操作
Object3D 提供了完整的树操作 API:
group.add(child); // 添加孩子
group.remove(child); // 移除孩子
group.clear(); // 清空所有孩子
group.traverse((obj) => { ... }); // 深度优先遍历自己和所有子孙
scene.getObjectByName('earth'); // 按 name 查找节点
mesh.removeFromParent(); // 从父节点脱离
给节点起名字是好习惯,尤其是加载进来的模型,节点树又深又乱:
earth.name = 'earth';
const found = scene.getObjectByName('earth');
traverse 可以批量操作,比如一次性把所有网格的阴影打开:
group.traverse((obj) => {
if (obj.isMesh) obj.castShadow = true;
});
用户自定义数据可以放在 userData 里,跟着节点走:
car.userData = { type: 'vehicle', owner: 'player1' };
命名建议:用语义化英文单词,比如 playerShip、enemyTank_01。模型加载进来的节点名字常常是 mesh_0、node_1 这类乱码,加载后统一重命名,后面按名字查找才靠谱。
clone:快速复制,但小心共享
Group 和 Mesh 都有 clone() 方法,可以一键复制整个子树。但有个大坑:克隆出来的网格和原件共享同一份几何体和材质。改原件的材质颜色,克隆体也跟着变:
const car2 = car.clone();
car2.position.x = 20; // 位置独立,没问题
car2.material.color.set('red'); // car 也会变红,注意!
想让克隆体独立,就给它的每个网格换一份新材质:
car2.traverse((obj) => {
if (obj.isMesh) obj.material = obj.material.clone();
});
另外,自定义属性(比如自己挂上去的 tick 方法)不会随 clone 复制,需要手动补。
示例:太阳系
把层级用起来最经典的例子是太阳系:太阳在中心,地球绕着太阳公转,月球绕着地球公转,地球自己还在自转。三层嵌套,每层只管自己的旋转:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>20 场景层级:太阳系</title>
<style>
body { margin: 0; overflow: hidden; background: #000; }
#info {
position: fixed; top: 12px; left: 12px; color: #fff;
font: 13px/1.8 sans-serif; background: rgba(0,0,0,.6);
padding: 8px 12px; border-radius: 6px; white-space: pre;
}
</style>
</head>
<body>
<div id="info"></div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a1a);
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 6, 12);
camera.lookAt(0, 0, 0);
const controls = new OrbitControls(camera, renderer.domElement);
// 灯光
scene.add(new THREE.AmbientLight(0xffffff, 0.3));
const dirLight = new THREE.DirectionalLight(0xffffff, 2);
dirLight.position.set(6, 8, 4);
scene.add(dirLight);
// 太阳系根组
const solarSystem = new THREE.Group();
scene.add(solarSystem);
// 太阳:自发光材质,不需要灯光
const sun = new THREE.Mesh(
new THREE.SphereGeometry(1.2, 32, 32),
new THREE.MeshBasicMaterial({ color: 0xffaa33 })
);
sun.name = 'sun';
solarSystem.add(sun);
// 地球组:留在原点,旋转它实现公转
const earthGroup = new THREE.Group();
solarSystem.add(earthGroup);
const earth = new THREE.Mesh(
new THREE.SphereGeometry(0.5, 32, 32),
new THREE.MeshStandardMaterial({ color: 0x4488ff, roughness: 0.6 })
);
earth.name = 'earth';
earth.position.x = 4; // 在组内偏置 4 个单位,公转轨道半径
earthGroup.add(earth);
// 月球组:挂在 earth 下,跟着地球走
const moonGroup = new THREE.Group();
moonGroup.position.x = 1.1;
earth.add(moonGroup);
const moon = new THREE.Mesh(
new THREE.SphereGeometry(0.18, 16, 16),
new THREE.MeshStandardMaterial({ color: 0xbbbbbb, roughness: 1 })
);
moonGroup.add(moon);
const info = document.getElementById('info');
const moonWorldPos = new THREE.Vector3();
function animate() {
requestAnimationFrame(animate);
earthGroup.rotation.y += 0.01; // 地球公转
moonGroup.rotation.y += 0.06; // 月球公转
earth.rotation.y += 0.03; // 地球自转
moon.getWorldPosition(moonWorldPos);
info.textContent =
`月球世界坐标\n` +
`x: ${moonWorldPos.x.toFixed(2)}\n` +
`y: ${moonWorldPos.y.toFixed(2)}\n` +
`z: ${moonWorldPos.z.toFixed(2)}`;
controls.update();
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>
代码里地球的公转就是 earthGroup.rotation.y 在转,月球的位置始终是”地球旁边 1.1 个单位”,完全不用算世界坐标——这就是层级的威力。信息栏里的月球世界坐标由 getWorldPosition 实时算出,你可以看着它画圈。如果不用层级,每帧得先算地球的位置,再算月球相对地球的位置,再手动同步旋转——代码量和出错概率都会翻倍。层级树把”相对运动”变成了”只转自己”。
Tip灯光和相机也能挂进组里。比如把一盏灯挂到”手电筒”模型下,模型转向哪,光就照到哪,比手动同步坐标省心得多。
什么时候该建组
几个常见信号:
- 一组物体要一起动、一起隐藏、一起旋转:建组;
- 物体之间有关节关系(机械臂、车门、轮子):建组,并按关节顺序嵌套;
- 模型加载器返回的节点树已经分好组:别拆,直接用;
- 只是几个互不相干的装饰物:平铺在场景里就行,别为建组而建组。
attach:换父节点保持世界坐标
把物体从一个父节点挪到另一个时,它的局部坐标是相对旧父节点的,直接 add 会”跳一下”。Object3D.attach 会在搬家时保留世界坐标:
newParent.attach(child); // 世界位置、旋转、缩放都不变
“物体从背包换到手上”这类交接场景经常用到。
小结
场景图是 three.js 组织世界的唯一方式:Scene 是根,Group 是文件夹,变换沿父链传递。写代码前先想清楚层级,能省下大量坐标计算。至此,场景的骨架(变换)、皮肤(材质)、光线(灯光)和组织(层级)都齐了,下一部分开始加交互。