场景图:Object3D、变换系统与几何体-材质契约
前置知识
- ›线性代数基础(向量、矩阵、四元数)
- ›第一篇:架构与代码导航指南
场景图:Object3D、变换系统与几何体-材质契约
每一个 Three.js 应用,本质上都是一棵由 Object3D 节点组成的树。相机观察场景,场景包含网格体,网格体持有几何体和材质,渲染器遍历整棵树并最终输出像素。要想真正用好 Three.js,而不只是照着教程复制粘贴,就必须理解 Object3D 如何管理变换、树结构如何传播矩阵更新,以及几何体-材质契约在 Mesh 中的工作原理。
Object3D:通用场景节点
Object3D 定义在 src/core/Object3D.js,继承自 EventDispatcher,也因此获得了第一篇中介绍的发布/订阅机制。它的构造函数通过自增数字 id 和 uuid 建立唯一标识,初始化父子关系字段(parent、children),并且——最关键的是——创建了变换相关的属性。
变换状态由四个属性共同描述:position(Vector3)、rotation(Euler)、quaternion(Quaternion)和 scale(Vector3)。这四个属性通过 Object.defineProperties 在第 160–226 行完成绑定,它们不可被替换,但内容可以修改。此外还定义了两个矩阵:modelViewMatrix(视图空间变换,由渲染器计算)和 normalMatrix(用于正确变换法线向量)。
Euler 与 Quaternion 的双向同步
这是整个代码库中最精妙的设计之一。Three.js 同时暴露了欧拉角(rotation)和四元数(quaternion)两个属性,并通过 _onChange 回调实现双向同步:
// src/core/Object3D.js, lines 145-158
function onRotationChange() {
quaternion.setFromEuler( rotation, false );
}
function onQuaternionChange() {
rotation.setFromQuaternion( quaternion, undefined, false );
}
rotation._onChange( onRotationChange );
quaternion._onChange( onQuaternionChange );
每次转换时传入的 false 参数,会抑制另一方的变更回调,从而防止无限循环触发。当你设置 object.rotation.y = Math.PI 时,Euler 的 _onChange 被触发,悄悄更新四元数;当你调用 object.quaternion.setFromAxisAngle(axis, angle) 时,四元数的 _onChange 被触发,悄悄更新欧拉角。
sequenceDiagram
participant User
participant Euler as rotation (Euler)
participant Quaternion as quaternion
User->>Euler: rotation.y = π
Euler->>Euler: _onChange fires
Euler->>Quaternion: setFromEuler(rotation, false)
Note right of Quaternion: false = don't fire _onChange
User->>Quaternion: quaternion.setFromAxisAngle(...)
Quaternion->>Quaternion: _onChange fires
Quaternion->>Euler: setFromQuaternion(quat, order, false)
Note right of Euler: false = don't fire _onChange
提示: 如果需要对旋转进行动画处理,建议直接使用四元数。欧拉角存在万向节锁问题,插值时也容易产生意料之外的结果。无论你设置的是哪个属性,Three.js 在内部合成矩阵时始终使用四元数。
变换矩阵管道
Object3D 维护两个矩阵:matrix(局部变换)和 matrixWorld(世界变换)。三个方法负责管理它们的生命周期,理解这三者之间的层级关系至关重要。
updateMatrix() 位于第 1133 行,通过 matrix.compose() 由 position、quaternion 和 scale 合成局部矩阵,随后应用可选的轴心点偏移,并将 matrixWorldNeedsUpdate 置为 true。
updateMatrixWorld(force) 位于第 1165 行,是渲染器调用的核心方法。若 matrixAutoUpdate 为 true,它会先调用 updateMatrix(),再合成世界矩阵:根节点的 matrixWorld 直接复制 matrix,子节点则计算 parent.matrixWorld × matrix。关键在于,它随后会递归处理所有子节点——这就是自上而下的传播机制,确保每个后代节点的世界矩阵都是最新的。
updateWorldMatrix(updateParents, updateChildren) 位于第 1212 行,提供了更精细的控制:你可以只更新某个节点的世界矩阵而不影响整个子树,也可以选择先向上更新祖先节点。这在渲染循环之外需要获取某个对象的世界坐标时非常有用。
flowchart TD
A["position, quaternion, scale"] -->|"compose()"| B["matrix (local)"]
B -->|"parent.matrixWorld × matrix"| C["matrixWorld (world)"]
subgraph "updateMatrixWorld(force)"
D["For each child:"] --> E["child.updateMatrixWorld(force)"]
end
C --> D
style A fill:#e8f4f8
style B fill:#fff3cd
style C fill:#d4edda
父子树操作
第 746 行的 add() 方法在挂载子节点时做了多重安全检查:防止自我嵌套、通过 isObject3D 进行类型验证,以及通过 removeFromParent() 自动从原有父节点中移除。成功后,它会分别向子节点派发 'added' 事件,向父节点派发 'childadded' 事件。
第 798 行的 remove() 方法与之对称,触发相应的 'removed' 和 'childremoved' 事件。两个方法都支持传入多个参数,以便批量操作。
Three.js 提供三种遍历方法,各有不同的迭代策略:
| 方法 | 行为 |
|---|---|
traverse(callback) |
深度优先访问所有后代节点 |
traverseVisible(callback) |
同上,但跳过 visible === false 的节点 |
traverseAncestors(callback) |
沿父链向上遍历至根节点 |
渲染器在做场景投影时内部使用 traverseVisible,Raycaster 在做射线相交检测时使用 traverse。
graph TD
Scene["Scene"] --> Camera["Camera"]
Scene --> Group["Group"]
Scene --> Light["DirectionalLight"]
Group --> MeshA["Mesh A"]
Group --> MeshB["Mesh B"]
MeshA --> ChildMesh["Child Mesh"]
style Scene fill:#d4edda
style Group fill:#e8f4f8
style MeshA fill:#fff3cd
style MeshB fill:#fff3cd
style ChildMesh fill:#fff3cd
BufferGeometry 与 BufferAttribute
BufferGeometry 继承自 EventDispatcher,而非 Object3D——几何体本身在空间中没有位置。它沿用了与 Object3D 相同的 ID + UUID 模式,并以命名 BufferAttribute 对象的形式存储顶点数据。
数据模型是一个属性字典,通过 setAttribute(name, attribute) 写入、getAttribute(name) 读取。标准属性名包括 'position'、'normal'、'uv'、'color' 和 'tangent',同时也支持自定义属性,供 node materials 和自定义 shader 使用。每个 BufferAttribute 封装了一个类型化数组(如 Float32Array)以及 itemSize,用于定义单个顶点条目由多少个元素组成。
BufferGeometry 还管理以下内容:
- Index buffer:可选的
BufferAttribute,用于索引绘制(共享顶点) - Groups:几何体内的子范围,可分别使用不同的材质
- 包围体:
boundingBox(Box3)和boundingSphere(Sphere),按需通过computeBoundingBox()和computeBoundingSphere()计算
注意 BufferGeometry.js 顶部定义的模块级临时对象:_m1、_obj、_offset、_box、_vector——这与第一篇中介绍的对象池模式如出一辙,在 applyMatrix4() 和 computeBoundingSphere() 等方法中同样有所体现。
Three.js 在 src/geometries/ 目录下内置了 22 种程序化几何体类型(BoxGeometry、SphereGeometry、CylinderGeometry 等),它们全部继承自 BufferGeometry,并携带预计算好的顶点数据。
Material 基类与属性驱动的设计理念
Material 是所有表面外观定义的抽象基类。与 BufferGeometry 一样,它继承自 EventDispatcher,并遵循 ID + UUID 模式。
Material 采用纯粹的属性驱动设计:你不需要调用会触发状态变更的 setter 方法,只需直接设置 blending、side、depthTest、transparent、opacity、visible 等属性即可。渲染器在渲染循环中读取这些属性,并据此配置 GPU 状态。唯一的"触发"机制是 needsUpdate——将其设为 true 会强制渲染器重新编译材质的 shader 程序。
基类定义了 30 多个属性,涵盖混合模式、深度测试、模板操作、多边形偏移、alpha 测试阈值等。具体子类如 MeshStandardMaterial 会在此基础上增加 PBR 属性(color、roughness、metalness、贴图等),而新的 NodeMaterial 体系则进一步引入了可编程的 shader 节点。
提示: 仅设置
material.transparent = true并不会让对象变得透明,还需要同时设置material.opacity < 1.0,或提供带 alpha 通道的贴图。transparent标志控制的是排序行为——透明对象会在不透明对象之后,按从后到前的顺序渲染。
Mesh、InstancedMesh 与 BatchedMesh
Mesh 是最基础的可渲染对象:它继承自 Object3D,并增加了 geometry 和 material 两个属性。这种几何体-材质的配对构成了渲染契约——渲染器在遍历场景时遇到 Mesh,就能知道如何绘制它:几何体提供顶点数据,材质提供外观表现和 shader 程序。
classDiagram
class Object3D {
+position: Vector3
+quaternion: Quaternion
+scale: Vector3
+matrixWorld: Matrix4
}
class Mesh {
+geometry: BufferGeometry
+material: Material
+isMesh: boolean
+raycast()
}
class InstancedMesh {
+instanceMatrix: InstancedBufferAttribute
+instanceColor: InstancedBufferAttribute
+count: number
}
class BatchedMesh {
+addGeometry()
+addInstance()
+setMatrixAt()
}
Object3D <|-- Mesh
Mesh <|-- InstancedMesh
Mesh <|-- BatchedMesh
Mesh 还实现了 raycast() 方法,使其与 Raycaster 相交检测系统兼容。该方法先检测包围球(快速排除),再对单个三角面进行射线测试。
InstancedMesh 支持 GPU 硬件实例化:你提供一个几何体和一个材质,再加上一个 instanceMatrix(存储每个实例 4×4 矩阵的缓冲区)和可选的 instanceColor。GPU 会以单次 draw call 绘制所有实例,非常适合用于森林、粒子系统或人群场景。
BatchedMesh 则通过另一种方式减少 draw call:它将多个不同的几何体合并到同一个顶点缓冲区中,并在内部管理实例到几何体的映射关系。与 InstancedMesh(要求所有实例共享同一几何体)相比,BatchedMesh 更灵活,但配置也更复杂。
Scene 与 Raycaster
Scene 出人意料地精简,只有 165 行。它继承自 Object3D,仅新增了少量属性:background(用于天空盒的 Color 或 Texture)、environment(用于 IBL 的纹理)、fog(Fog 或 FogExp2)、overrideMaterial,以及背景和环境的模糊度、强度、旋转控制。Scene 本身不包含任何渲染逻辑,纯粹是一个数据容器。
Raycaster 提供针对场景图的相交检测能力。其核心方法 intersectObjects() 遍历场景图,并调用每个对象的 raycast() 方法——这是一种访问者模式:Raycaster 提供射线,每种对象类型自行实现相交逻辑。Mesh 检测三角面,Sprite 检测平面,Line 检测线段,Points 则对每个点周围的球体进行检测。
flowchart LR
RC["Raycaster"] -->|"intersectObjects()"| Scene["Scene.traverse()"]
Scene --> M1["Mesh.raycast()"]
Scene --> M2["Sprite.raycast()"]
Scene --> M3["Line.raycast()"]
M1 -->|"Hit?"| Results["Sorted Intersections[]"]
M2 -->|"Hit?"| Results
M3 -->|"Hit?"| Results
结果数组按距离排序,每个相交结果包含:distance(距离)、point(世界空间中的命中位置)、face(命中的三角面)、faceIndex(面索引)、object(被命中的 Object3D),以及用于纹理坐标查找的 uv/uv1。
下一步
至此,我们已经梳理了每个 Three.js 应用都会操作的核心数据结构:Object3D 场景树、几何体-材质契约,以及变换如何在层级中传播。下一篇文章将深入探讨渲染器实际绘制这些数据的过程——包括双渲染器架构,从传统的单体式 WebGLRenderer,到为 WebGPU 未来而生的新模块化 Renderer + Backend 系统。