首页 / Three.js 入门教程 / 场景组织与层级:用 Group 管理你的世界

Three.js 入门教程

场景组织与层级:用 Group 管理你的世界

本教程共 40 篇 · 第 20 篇 · 更新于 2026-08-14 · 约 8 分钟阅读

Three.jsGroupObject3D场景图层级坐标

本节目标:掌握 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 是文件夹,变换沿父链传递。写代码前先想清楚层级,能省下大量坐标计算。至此,场景的骨架(变换)、皮肤(材质)、光线(灯光)和组织(层级)都齐了,下一部分开始加交互。