场景图到底是什么?一句话说清楚
场景图 = 你 3D 世界里的「家族族谱」。
你在 Three.js 里创建的每一个物体------立方体、球体、灯光、相机------它们不是孤立存在的,而是像家族成员一样,有爸爸、有儿子、有孙子,形成一个树状的层级结构。
这个结构,就叫场景图。
想象一下你在玩乐高:
- 你先搭了一个车身(父节点)
- 然后在车身上装了 4 个轮子(子节点)
- 每个轮子上又装了轮毂装饰(孙节点)
当你拿起整个车身移动时,轮子和轮毂会自动跟着动。你不需要一个一个去移动它们。
这就是场景图的核心逻辑:父节点动,子节点自动跟着动。
为什么需要场景图?
假设你要做一个太阳系模型:
- 太阳在中心
- 地球绕着太阳转
- 月球绕着地球转
如果没有场景图,你得这么写:
javascript
// 每一帧都要手动计算位置
function animate() {
// 地球绕太阳转
earth.position.x = Math.cos(time) * 10;
earth.position.z = Math.sin(time) * 10;
// 月球绕地球转,还要加上地球的位置
moon.position.x = earth.position.x + Math.cos(time * 2) * 2;
moon.position.z = earth.position.z + Math.sin(time * 2) * 2;
// 如果再加个火星、木星、土星...
// 你的代码会变成一坨屎
}
有了场景图,你只需要:
javascript
// 把月球设为地球的子节点
earth.add(moon);
// 把地球设为太阳的子节点
sun.add(earth);
// 每一帧只需要旋转父节点
function animate() {
sun.rotation.y += 0.01; // 太阳自转
earth.rotation.y += 0.02; // 地球自转,月球自动跟着转
}
场景图让你从「手动计算每个物体的绝对位置」,变成「只管理父子关系,让系统自动计算」。
场景图的三大核心规则
规则 1:每个物体都有自己的「局部坐标系」
这是最容易搞混的地方。
在 Three.js 里,每个物体的 position、rotation、scale 都是相对于它的父节点的,不是相对于整个世界的。
举个例子:
javascript
const car = new THREE.Group(); // 汽车
const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial); // 轮子
wheel.position.x = 2; // 轮子在汽车坐标系里,向右偏移 2 个单位
car.add(wheel);
car.position.x = 10; // 汽车在世界坐标系里,向右移动 10 个单位
此时,轮子在世界坐标系里的实际位置是 10 + 2 = 12。
但你在代码里看到的 wheel.position.x 还是 2,因为它记录的是相对于父节点(汽车)的位置。
这就像你在高铁上走动:
- 你相对于车厢的位置是「第 5 排座位」(局部坐标)
- 但你相对于地球的位置,是「第 5 排座位 + 高铁的位置」(世界坐标)
规则 2:父节点的变换会「传递」给所有子节点
这是场景图最强大的地方。
当你旋转、缩放、移动一个父节点时,它的所有子节点、孙节点、曾孙节点......都会跟着变。
javascript
const robot = new THREE.Group();
const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
const leftArm = new THREE.Mesh(armGeometry, armMaterial);
const rightArm = new THREE.Mesh(armGeometry, armMaterial);
robot.add(body);
body.add(leftArm);
body.add(rightArm);
// 旋转机器人,整个机器人(包括身体和手臂)都会转
robot.rotation.y = Math.PI / 4;
// 旋转身体,手臂会跟着转,但机器人的腿不会动
body.rotation.x = Math.PI / 6;
这就像你转身:
- 你的头、手、脚都会跟着转(子节点跟随父节点)
- 但你手上拿的手机屏幕方向不会变(子节点保持自己的局部旋转)
规则 3:Scene 是所有节点的「根节点」
在 Three.js 里,Scene 就是那个最顶层的「祖宗节点」。
所有你想渲染出来的东西,都必须直接或间接地添加到 Scene 里。
javascript
const scene = new THREE.Scene();
// 方式 1:直接添加到场景
scene.add(cube);
// 方式 2:添加到某个组,再把组添加到场景
const group = new THREE.Group();
group.add(cube);
scene.add(group);
Scene 就像一个舞台:
- 只有站在舞台上的演员(或演员团队)才能被观众(相机)看到
- 你在后台准备的道具(没 add 到 scene 的物体),观众看不见
···
真实场景:用场景图管理一辆汽车
假设你要做一个可交互的汽车模型:
- 汽车可以前进、后退、转弯
- 4 个轮子要跟着车身动
- 轮子转弯时要旋转
- 车门可以单独打开
没有场景图的噩梦写法:
javascript
// 每次移动汽车,你要手动更新 5 个物体的位置
function moveCar(distance) {
carBody.position.z += distance;
wheel1.position.z += distance;
wheel2.position.z += distance;
wheel3.position.z += distance;
wheel4.position.z += distance;
door.position.z += distance;
}
// 转弯时,你要手动计算每个轮子的新位置
function turnCar(angle) {
// 这里要写一堆三角函数...
// 而且很容易算错
}
用场景图的优雅写法:
javascript
// 1. 创建层级结构
const car = new THREE.Group(); // 汽车根节点
const body = new THREE.Mesh(bodyGeometry, bodyMaterial); // 车身
const door = new THREE.Mesh(doorGeometry, doorMaterial); // 车门
const wheel1 = new THREE.Mesh(wheelGeometry, wheelMaterial);
const wheel2 = new THREE.Mesh(wheelGeometry, wheelMaterial);
const wheel3 = new THREE.Mesh(wheelGeometry, wheelMaterial);
const wheel4 = new THREE.Mesh(wheelGeometry, wheelMaterial);
// 2. 建立父子关系
car.add(body);
body.add(door); // 车门是车身的子节点
body.add(wheel1);
body.add(wheel2);
body.add(wheel3);
body.add(wheel4);
scene.add(car); // 整辆车添加到场景
// 3. 设置轮子的局部位置(相对于车身)
wheel1.position.set(-1, -0.5, 1.5); // 左前轮
wheel2.position.set(1, -0.5, 1.5); // 右前轮
wheel3.position.set(-1, -0.5, -1.5); // 左后轮
wheel4.position.set(1, -0.5, -1.5); // 右后轮
// 4. 移动汽车,只需要操作根节点
function moveCar(distance) {
car.position.z += distance; // 一行代码,整辆车都动了
}
// 5. 转弯,也只需要操作根节点
function turnCar(angle) {
car.rotation.y += angle; // 一行代码,整辆车都转了
}
// 6. 打开车门,只操作车门节点
function openDoor() {
door.rotation.y = Math.PI / 3; // 车门绕自己的轴旋转
}
// 7. 轮子转动,只操作轮子节点
function rotateWheels(speed) {
wheel1.rotation.x += speed;
wheel2.rotation.x += speed;
wheel3.rotation.x += speed;
wheel4.rotation.x += speed;
}
场景图让你的代码从「管理 100 个物体的绝对位置」,变成「管理 10 个父子关系」。
代码量少了 90%,bug 也少了 90%。
···
进阶技巧:Group 是你最好的朋友
Three.js 提供了一个专门用来组织场景图的工具:THREE.Group()。
它就是一个「空节点」,自己不渲染任何东西,但可以作为其他物体的容器。
什么时候用 Group?
-
逻辑分组:把相关的物体放在一起
javascriptconst furniture = new THREE.Group(); furniture.add(table); furniture.add(chair); furniture.add(lamp); // 一次性移动所有家具 furniture.position.x = 5; -
动画控制:需要整体旋转或移动时
javascriptconst solarSystem = new THREE.Group(); solarSystem.add(sun); solarSystem.add(earth); solarSystem.add(mars); // 整个太阳系旋转 solarSystem.rotation.y += 0.01; -
坐标系转换:需要改变物体的旋转中心时
javascript// 默认情况下,物体绕自己的中心旋转 // 如果你想让它绕另一个点旋转,可以用 Group const pivot = new THREE.Group(); pivot.add(cube); cube.position.x = 5; // 立方体偏离 pivot 中心 pivot.rotation.y += 0.01; // 立方体绕 pivot 中心旋转(公转) cube.rotation.y += 0.02; // 立方体绕自己中心旋转(自转)
Group 就像乐高的底板:
- 你可以在底板上搭建复杂的结构
- 然后拿起整个底板移动,所有东西都跟着动
- 底板本身不占空间,只是一个「组织工具」
···
常见坑点:为什么我的物体位置不对?
坑点 1:忘记父节点的变换会累积
javascript
const parent = new THREE.Group();
parent.scale.set(2, 2, 2); // 父节点放大 2 倍
const child = new THREE.Mesh(geometry, material);
child.scale.set(2, 2, 2); // 子节点也放大 2 倍
parent.add(child);
// 结果:子节点实际被放大了 2 × 2 = 4 倍!
解决方法:
- 要么只在父节点设置缩放
- 要么在子节点用
1 / parent.scale.x来抵消
坑点 2:直接修改 world position 不生效
javascript
const child = new THREE.Mesh(geometry, material);
parent.add(child);
// ❌ 错误:直接设置世界坐标不会生效
child.position.set(10, 0, 0); // 这是局部坐标!
// ✅ 正确:如果要设置世界坐标,需要先转换
const worldPos = new THREE.Vector3(10, 0, 0);
child.parent.worldToLocal(worldPos);
child.position.copy(worldPos);
坑点 3:移除节点时忘记清理引用
javascript
// ❌ 错误:只从场景移除,但父子关系还在
scene.remove(child);
// ✅ 正确:从父节点移除
parent.remove(child);
// ✅ 更好:彻底清理
parent.remove(child);
child.geometry.dispose();
child.material.dispose();
核心代码与完整示例: my-three-app
总结
如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货
