Day 102 · 几何与变换 — 用零件组装一台设备
前三天你有了:场景、循环、光照。但只有一个立方体。今天做"组装"——几何体(零件的形状)、三变换(position/rotation/scale)、以及 3D 组装的灵魂 Group(把零件焊成一台可以整体搬动的设备)。做完你会明白:3D 场景的复杂性不是靠一个个孤立的物体堆出来的,而是靠层级组织出来的——这正是数字孪生里"设备-部件-零件"结构的雏形。
目录
一、常用几何体清单:你的零件库
1.1 六种核心几何体
1.2 口诀与用法
// 零件工厂:统一封装"几何 + 材质 = Mesh"(避免散落创建)
import * as THREE from "three";
/**
* 创建工业零件:给定形状与颜色,返回 Mesh
* 几何/材质分开传,便于复用(见第五节)
*/
export function part(
geometry: THREE.BufferGeometry,
material: THREE.Material
): THREE.Mesh {
return new THREE.Mesh(geometry, material);
}
记住:几何体描述形状,材质描述表面,
Mesh = 几何 + 材质。任何复杂设备都是一堆 Mesh 的拼装。
二、三变换:position / rotation / scale
2.1 三个属性的语义(与 2D 对照)
2.2 关键注意点
rotation 用弧度,不是角度:
45° = Math.PI / 4。习惯rotation.y += speed * delta的写法(Day 100 时间纪律)scale 可整体也可单轴:
.scale.set(1, 2, 1)把一个立方体拉成"板"position 是相对父级的——父是 Group 时,这个意义极其关键(见第四节)
// 变换示例:把一个"板"摆到桌面上方
const plate = new THREE.Mesh(new THREE.BoxGeometry(1, 0.1, 2), mat);
plate.position.set(0, 0.5, 0); // 放到 (0,0.5,0)
plate.rotation.y = Math.PI / 4; // 绕 Y 轴转 45°
plate.scale.set(1, 1, 1); // 不缩放(默认就是 1)
三、Group:把零件焊成设备
3.1 为什么需要 Group
直接往 scene.add() 塞一堆 Mesh,你会得到:
想整体平移设备?得一个一个改 position(还要算相对位移)
想让风扇转但机身不动?不好区分"动的"和"不动的"
想整体旋转?噩梦
Group 就是 3D 的"容器/文件夹"——把相关物体打包,对 Group 的变换会作用于其所有子物体:
import * as THREE from "three";
// 组装:设备 = Group(机身 + 风扇 + 指示灯)
const device = new THREE.Group(); // 空容器,位置默认在原点
const body = new THREE.Mesh(new THREE.BoxGeometry(2, 1, 1), bodyMat);
body.position.set(0, 1, 0); // 相对 Group:向上 1 格
const fan = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.4, 0.2, 24), fanMat);
fan.position.set(1.2, 1, 0); // 相对 Group:右 1.2
device.add(body, fan); // 焊进 Group
scene.add(device); // Group 进场景
// ✨ 整体搬动:只改 Group 一个位置,所有零件跟着走
device.position.set(3, 0, 2);
device.rotation.y = Math.PI / 6;
3.2 Group 层级树(数字孪生的结构雏形)
scene
└── device(Group) ← 设备:整体变换
├── body(Mesh:机身) ← 相对设备:静止
├── fan(Mesh:风扇) ← 相对设备:自转(改 fan.rotation)
└── indicator(Mesh:指示灯)
记忆钩子:Group = DOM 里的
<div>。子元素的位置相对父容器;父容器一动,子元素跟着动。3D 的"分层组装"和 HTML 的"嵌套布局"是同一个思想。
四、变换的层级与坐标系
4.1 父级变换 → 子级继承
这是 Group 最核心的机制:子物体的 position/rotation/scale 都是相对父级的。父级自身的变换会被"叠加"到子级头上。
世界坐标 = 父级变换 × 子级变换(一层层套下来)
// 演示:父级旋转,子级跟着转但保持相对姿态
const parent = new THREE.Group();
parent.position.set(3, 0, 2);
const child = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.5, 0.5), mat);
child.position.set(1, 0, 0); // 相对父级:右 1 格
parent.add(child);
scene.add(parent);
parent.rotation.y = Math.PI / 2; // 父转 90°,child 跟着绕父级原点转 90°
4.2 组装时的定位技巧
先在脑子里给设备画坐标:以 Group 原点为"设备中心/地面中心",所有零件用相对坐标摆放。好处:
设备旋转/移动时,零件相对关系不变
风扇这类"独立旋转件"放进自己的子 Group,只转它不转别的(见第六节)
五、性能纪律:几何体与材质的复用
5.1 一个 geometry 可以给多个 Mesh
同样的形状(如 4 根相同的管道)只需建一个 geometry,多个 Mesh 共用——GPU 只需要一份顶点数据:
// ✅ 复用:一份几何体,四个 Mesh
const pipeGeo = new THREE.CylinderGeometry(0.1, 0.1, 1, 12);
const pipes = [0, 1, 2, 3].map((i) => {
const m = new THREE.Mesh(pipeGeo, pipeMat);
m.position.x = (i - 1.5) * 0.6; // 一字排开
return m;
});
// ❌ 反例:每根管道 new 一次 geometry——浪费显存
5.2 材质也可以共享(只要改的是引用不是对象)
const sharedMat = new THREE.MeshStandardMaterial({ color: 0x16233a });
// 多个 Mesh 共用 sharedMat;想单独变色时再 clone
// const other = sharedMat.clone(); other.color.setHex(0xff6b35);
纪律:循环里绝不 new 几何/材质(Day 100 坑 4);初始化时能复用就复用。这直接决定 Day 105 性能验收的 draw call 数。
六、完整示例:组装一台带风扇的设备
把今天所有内容合成一台"可整体搬动、风扇可独立自转"的设备:
// src/core/three/build-fan-device.ts
import * as THREE from "three";
import { threeTheme as T } from "./theme";
/**
* 组装一台带风扇的设备(Group 层级示例)
* 返回 device(整体变换)与 fan 组(风扇独立旋转)
* —— 结构:device > (body, fanGroup > (blades, hub))
*/
export function buildFanDevice(): {
device: THREE.Group; // 整台设备:可整体平移/旋转
fanGroup: THREE.Group; // 风扇组:改 rotation.z 让它转
} {
const device = new THREE.Group();
// 机身:金属质感盒子
const body = new THREE.Mesh(
new THREE.BoxGeometry(2, 1.4, 1),
new THREE.MeshStandardMaterial({ color: T.panel, metalness: 0.7, roughness: 0.4 })
);
body.position.y = 0.7;
device.add(body);
// 底座:扁立方体,用 scale 拉长
const base = new THREE.Mesh(
new THREE.BoxGeometry(1.2, 0.2, 1),
new THREE.MeshStandardMaterial({ color: T.panel, metalness: 0.5, roughness: 0.6 })
);
base.position.y = 0.1;
device.add(base);
// —— 风扇:独立子 Group(只转它,不动机身)——
const fanGroup = new THREE.Group();
fanGroup.position.set(1.3, 1.0, 0); // 风扇在机身右侧
// 扇叶:用立方体拉成"叶片"绕中心转一圈(4 片)
const bladeGeo = new THREE.BoxGeometry(0.18, 0.5, 0.04);
const bladeMat = new THREE.MeshStandardMaterial({ color: T.primary, metalness: 0.6, roughness: 0.3 });
for (let i = 0; i < 4; i++) {
const blade = new THREE.Mesh(bladeGeo, bladeMat);
blade.position.y = 0.3; // 叶片离中心一段距离
blade.rotation.z = (i / 4) * Math.PI * 2; // 均匀分布四片
fanGroup.add(blade);
}
// 轴心:小球盖住中心
const hub = new THREE.Mesh(
new THREE.SphereGeometry(0.08, 12, 12),
new THREE.MeshStandardMaterial({ color: T.panel, metalness: 0.8, roughness: 0.2 })
);
fanGroup.add(hub);
device.add(fanGroup);
return { device, fanGroup };
}
// —— 循环里:只转风扇,不动机身(Day 100 时间纪律)——
// const { device, fanGroup } = buildFanDevice();
// scene.add(device);
// const tick = () => {
// fanGroup.rotation.z += 4 * clock.getDelta(); // 风扇每秒转 4 弧度
// renderer.render(scene, camera);
// };
这就是"独立旋转件放进自己的子 Group"的典型:风扇整体绕着 Group 原点转,而叶片是相对 Group 摆的。想加第 2 台设备?
buildFanDevice()再调一次,.position.set()摆开——函数返回 Group,设备即插即用。
七、对照表第 3 行:手写 WebGL vs Three.js(几何体)
结论:几何体与层级是"手写成本飙升"的第二个分水岭——手写一个立方体就要面对顶点数组,写一台设备要自己管矩阵链。Three.js 把这一切变成"摆零件"。你在 2D 学过的所有"组合/封装"思想,原封不动搬进 3D。
八、常见坑点
坑 1:所有零件都直接加进 scene
症状:想整体移动设备时要逐个改 position,容易乱。修法:用 Group 打包,只改 Group 的变换。
坑 2:忘记零件位置是相对 Group 的
症状:设备整体移动后,零件"原地漂移"或位置错乱。原因:把相对坐标当成世界坐标。修法:先在 Group 内定位,再整体摆 Group。
坑 3:风扇想转,把整个设备转了
症状:机身跟着风扇一起转。原因:旋转加错了对象。修法:独立旋转件放进自己的子 Group。
坑 4:每根管道都 new 几何体
症状:显存飙升、draw call 多。修法:同形状共享一个 geometry(见第五节)。
坑 5:rotation 用角度值
症状:旋转角度不对(如写 45 但转得很少)。原因:rotation 是弧度,45 ≠ 45°。修法:Math.PI / 4 或用 THREE.MathUtils.degToRad(45)。
九、自测挑战
T1 · 三变换实验(40 分钟)
单个立方体,分别调 position/rotation/scale,各截一张图记录变化。重点实验:scale.set(1,2,1) 和 rotation.y = Math.PI/4 的叠加效果。
T2 · 组装风扇设备(70 分钟)
完成第六节的设备:机身 + 底座 + 四叶片风扇(独立自转)。给设备整体 .position.set(3,0,2) 和 .rotation.y,确认设备整体动、风扇照常转。
T3 · 批量复用(30 分钟)
用同一份 geometry 造 6 台相同设备,沿 X 轴排成一排(间距 2)。打开 Performance 面板看 draw call 数量与显存——体会"共享几何体"的价值。
T4 · 结构复盘(20 分钟)
把设备画成层级树(scene → device → body/fanGroup/…),标出每个节点的"相对坐标"。这张树状图就是 Day 105 车间结构的最小原型。
十、总结
一台会转风扇的设备组好了,还能整台搬走。明天解决本周最重要的问题:Three.js 与 Vue 的边界——设备树归谁管,状态放哪。
明日预告:Day 103 Three × Vue 边界——3D 版三问法则:场景图归 Three 管、业务状态归 Pinia 管、哪些数据要 markRaw,以及组件与引擎之间的"桥契约"。