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);
Object3D 和 Mesh 一样都是场景图节点,但它没有材质也没有几何体 ,纯粹用来组织层级关系。现在太阳的 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、蓝=ZGridHelper:在 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 的骨架,核心就三点:
- 父子关系 = 局部空间套娃:子节点跟着父节点动,坐标用"相对父节点"的。
- 善用空节点
Object3D:只为了组织层级、避免被父节点的 scale 牵连。太阳放大 5 倍却不能让地球也变大,靠的就是中间垫一个空节点。 - 复杂联动交给层级,别手算:月亮绕地球的螺旋轨迹、坦克炮塔瞄准,都不用算数学,摆好树结构让 three.js 自动处理。
下次做有"联动"效果的东西(车辆、机器人、骨骼、UI 跟随),先别急着算坐标,先想想:这个场景图该怎么搭?