【three.js教程】场景图:用“父子关系“让月亮自动绕地球转

Three.js 场景图:用"父子关系"让月亮自动绕地球转

原文出处:Three.js Manual -- Scene Graph

本文基于 three.js 官方手册"场景图"章节整理,用通俗的方式带你理解 three.js 里最容易被忽视、却最强大的概念------场景图(Scene Graph)。


写在前面

前面我们学会了搭场景、放物体、设材质。但有个底层概念一直没细说,而它才是 three.js 真正的基础------场景图(Scene Graph)

如果你一直把每个物体都直接 scene.add() 到场景里,各自算各自的坐标,一旦物体多了、还要互相联动(比如坦克履带跟着车体走、炮塔跟着坦克转),数学会复杂到爆炸。这一篇就用"太阳-地球-月亮"这个经典例子,把场景图的思路讲透。理解了它,你才真正算会用 three.js。


一、场景图到底是什么?

一句话:场景图就是一棵节点树,每个节点代表一个"局部空间"。子节点跟着父节点一起移动、旋转、缩放,而且子节点用的是"相对于父节点"的坐标。

听起来抽象?用太阳系来理解最直观:

  • 太阳在原点
  • 地球绕太阳转
  • 月亮绕地球转

从月亮的视角看,它只管绕地球的"局部空间"转圈,完全不用关心地球在绕太阳怎么跑。但站在太阳视角,月亮的轨迹其实是一条超级复杂的螺旋线(spirograph)。

这就像你走在地球上,根本不用管地球在自转、在绕太阳公转------你只管在地球的"局部空间"里走路、开车。three.js 的场景图,就是把这种嵌套关系用代码表达出来。


二、从只有太阳开始

先搭一个最低配的太阳系:只有一个太阳,放在原点。

js 复制代码
// 所有要旋转的物体都丢这个数组里
const objects = [];

// 所有球共用同一个低面数几何体(方便看清旋转)
const radius = 1;
const widthSegments = 6;
const heightSegments = 6;
const sphereGeometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);

const sunMaterial = new THREE.MeshPhongMaterial({ emissive: 0xffff00 }); // 自发光黄色
const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
sunMesh.scale.set(5, 5, 5);  // 太阳放大 5 倍
scene.add(sunMesh);
objects.push(sunMesh);

几个小细节:

  • 用了超低面数球体(赤道才 6 段),这样旋转时肉眼能看清它在转。
  • emissive 设成黄色,意思是"即使没有光照到,也显示这个颜色"。光会再叠加在它上面。这样太阳看起来像自己在发光。
  • 所有物体共用一个 sphereGeometry,通过 scale 区分大小,省内存。

再加一个点光源放在中心(太阳位置),让其他物体能被照亮:

js 复制代码
{
  const color = 0xffffff;
  const intensity = 500;
  const light = new THREE.PointLight(color, intensity);
  scene.add(light);
}

相机放在正上方往下看最直观。注意这里要告诉相机"哪个方向算上"------因为是正俯视,得把 Z 轴设成 up:

js 复制代码
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.set(0, 50, 0);
camera.up.set(0, 0, 1);   // 正下方俯视,Z 轴朝上
camera.lookAt(0, 0, 0);

渲染循环里,让数组里所有物体绕 Y 轴转:

js 复制代码
objects.forEach((obj) => {
  obj.rotation.y = time;
});

三、加地球

加地球很简单:蓝材质、放太阳左边 10 个单位。

js 复制代码
const earthMaterial = new THREE.MeshPhongMaterial({ color: 0x2233ff, emissive: 0x112244 });
const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
earthMesh.position.x = 10;
scene.add(earthMesh);
objects.push(earthMesh);

但问题来了:地球虽然在转,却没有绕着太阳转。它只是原地自转。

要让地球绕太阳转,直觉是让地球成为太阳的子节点:

js 复制代码
-sunMesh.add(earthMesh);   // 取代 scene.add(earthMesh)

结果却翻车了------地球变得和太阳一样大,还飘得老远,相机得从 50 拉到 150 才能看见。

原因很关键sunMesh.scale.set(5, 5, 5) 让太阳的"局部空间"放大了 5 倍。地球一旦成为它的子节点,所有属性都会被乘 5------包括大小(5×)和距离(10×5=50)。这就是为什么地球又大又远。


四、用空节点(Object3D)修复缩放问题

解决办法:引入一个没有形状、只代表局部空间的空节点 Object3D,让太阳和地球都成为它的子节点。

js 复制代码
const solarSystem = new THREE.Object3D();   // 空节点,代表太阳系中心
scene.add(solarSystem);
objects.push(solarSystem);

const sunMesh = new THREE.Mesh(sphereGeometry, sunMaterial);
sunMesh.scale.set(5, 5, 5);
solarSystem.add(sunMesh);   // 太阳挂到 solarSystem 下
objects.push(sunMesh);

const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
earthMesh.position.x = 10;
solarSystem.add(earthMesh); // 地球也挂到 solarSystem 下
objects.push(earthMesh);

Object3DMesh 一样都是场景图节点,但它没有材质也没有几何体 ,纯粹用来组织层级关系。现在太阳的 5× 缩放只影响自己,地球不再被牵连。地球绕着 solarSystem 转,大小也正常了。


五、继续加月亮

月球要绕地球转,那就再套一层。关键是地球的位置偏移交给一个叫 earthOrbit 的空节点来承担:

js 复制代码
const earthOrbit = new THREE.Object3D();
earthOrbit.position.x = 10;
solarSystem.add(earthOrbit);
objects.push(earthOrbit);

const earthMesh = new THREE.Mesh(sphereGeometry, earthMaterial);
earthOrbit.add(earthMesh);  // 地球挂到 earthOrbit 下,不再自己设 position.x
objects.push(earthMesh);

const moonOrbit = new THREE.Object3D();
moonOrbit.position.x = 2;   // 月球离地球 2 个单位
earthOrbit.add(moonOrbit);

const moonMaterial = new THREE.MeshPhongMaterial({ color: 0x888888, emissive: 0x222222 });
const moonMesh = new THREE.Mesh(sphereGeometry, moonMaterial);
moonMesh.scale.set(0.5, 0.5, 0.5);
moonOrbit.add(moonMesh);
objects.push(moonMesh);

现在层级是:solarSystem → earthOrbit → earthMesh → moonOrbit → moonMesh

神奇的地方来了:你完全不用手动算月亮相对于太阳的轨迹,只要把层级摆对、让每个节点转自己的 Y 轴,月亮自然就走出那条复杂的螺旋线。场景图帮你把复杂数学自动化了。


六、可视化辅助工具:AxesHelper 和 GridHelper

调试层级关系时,最好能"看见"每个节点的局部坐标系。three.js 提供了两个帮手:

  • AxesHelper:画三条轴,红=X、绿=Y、蓝=Z
  • GridHelper:在 XZ 平面画一张网格
js 复制代码
const axes = new THREE.AxesHelper();
axes.material.depthTest = false;  // 不被挡住,永远可见
axes.renderOrder = 1;             // 后绘制,盖在球体上面
node.add(axes);

depthTest = false 保证坐标轴即使藏在球体内部也能看见;renderOrder = 1 保证它最后画,不会被球体盖住。

手册里还演示了用 lil-gui 给每个节点做个开关,一键显示/隐藏它的坐标轴和网格。核心是一个 AxisGridHelper 类:

js 复制代码
class AxisGridHelper {
  constructor(node, units = 10) {
    const axes = new THREE.AxesHelper();
    axes.material.depthTest = false;
    axes.renderOrder = 2;
    node.add(axes);

    const grid = new THREE.GridHelper(units, units);
    grid.material.depthTest = false;
    grid.renderOrder = 1;
    node.add(grid);

    this.grid = grid;
    this.axes = axes;
    this.visible = false;
  }
  get visible() {
    return this._visible;
  }
  set visible(v) {
    this._visible = v;
    this.grid.visible = v;
    this.axes.visible = v;
  }
}

用 getter/setter 把"网格可见"和"坐标轴可见"绑在一个 visible 属性上,lil-gui 就能用一个复选框同时控制两个。这是个很实用的小模式。


七、更复杂的场景图长什么样?

场景图能表达的东西远超太阳系。手册举了几个例子:

  • 汽车 :车体移动,轮子跟着动。想让车身和轮子独立弹跳,就再加一个 frame 节点,车体和轮子都挂它下面。
  • 人体:头、手臂、手指、脚趾、下巴......每个关节都是一个节点,层级能深到吓人(光手指就 28 个节点)。
  • 坦克 (手册里最复杂的示例):车体沿样条曲线行驶,炮塔自动用 lookAt 瞄准目标,6 个轮子同步转,还挂了 4 个摄像机在不同位置切换。

坦克示例里有个重要技巧:getWorldPosition() 拿到子节点在"世界坐标"里的真实位置 ,再去 lookAt 它。因为子节点的坐标都是相对父节点的,直接拿 position 会出错,必须用世界坐标。

js 复制代码
const targetPosition = new THREE.Vector3();
targetMesh.getWorldPosition(targetPosition);  // 拿到世界坐标
turretPivot.lookAt(targetPosition);          // 炮塔瞄准目标

回顾一下

场景图是 three.js 的骨架,核心就三点:

  1. 父子关系 = 局部空间套娃:子节点跟着父节点动,坐标用"相对父节点"的。
  2. 善用空节点 Object3D:只为了组织层级、避免被父节点的 scale 牵连。太阳放大 5 倍却不能让地球也变大,靠的就是中间垫一个空节点。
  3. 复杂联动交给层级,别手算:月亮绕地球的螺旋轨迹、坦克炮塔瞄准,都不用算数学,摆好树结构让 three.js 自动处理。

下次做有"联动"效果的东西(车辆、机器人、骨骼、UI 跟随),先别急着算坐标,先想想:这个场景图该怎么搭?


延伸阅读

相关推荐
avi91112 天前
Threejs新版本后glb导出提示.x问题GLTFExporter,替代3dmax
开发语言·前端·javascript·threejs·glb·gltfexporter
答案—answer17 天前
VibeCoding 能做到什么程度?我用它做了一座 3D 数字博物馆
3d·ai编程·threejs·vibecoding
a1117761 个月前
三色软糖坠落玻璃池 THreeJS kimi
前端·人工智能·threejs
一根数据线1 个月前
从建筑可视化到数字孪生:Three.js如何改变3D展示?
3d建模·3d模型轻量化·threejs·3d模型·模型减面·lod·轻装3d
gis分享者3 个月前
学习threejs,实现炫酷的3D编程语言地球可视化效果
可视化·编程语言·threejs·地球·spheregeometry·raycaster·meshbasic
gis分享者3 个月前
学习threejs,实现分形球体交互可视化
交互·threejs·分形·glsl·球体·shadermaterial·planegeometry
gis分享者3 个月前
学习threejs,实现Transformer架构三维模拟
深度学习·transformer·threejs·三维·renderpass·effectcomposer·unrealbloompass
gis分享者4 个月前
学习threejs,把“数据流”做成未来城市光轨
threejs·数据流·shadermaterial·renderpass·unrealbloompass·城市光轨·shaderpass
军军君015 个月前
Three.js基础功能学习十五:智能黑板实现实例二
开发语言·前端·javascript·vue.js·3d·threejs·三维