Read OSS

场景图:Object3D、变换系统与几何体-材质契约

中级

前置知识

  • 线性代数基础(向量、矩阵、四元数)
  • 第一篇:架构与代码导航指南

场景图:Object3D、变换系统与几何体-材质契约

每一个 Three.js 应用,本质上都是一棵由 Object3D 节点组成的树。相机观察场景,场景包含网格体,网格体持有几何体和材质,渲染器遍历整棵树并最终输出像素。要想真正用好 Three.js,而不只是照着教程复制粘贴,就必须理解 Object3D 如何管理变换、树结构如何传播矩阵更新,以及几何体-材质契约在 Mesh 中的工作原理。

Object3D:通用场景节点

Object3D 定义在 src/core/Object3D.js,继承自 EventDispatcher,也因此获得了第一篇中介绍的发布/订阅机制。它的构造函数通过自增数字 iduuid 建立唯一标识,初始化父子关系字段(parentchildren),并且——最关键的是——创建了变换相关的属性。

变换状态由四个属性共同描述: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 行,是渲染器调用的核心方法。若 matrixAutoUpdatetrue,它会先调用 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 方法,只需直接设置 blendingsidedepthTesttransparentopacityvisible 等属性即可。渲染器在渲染循环中读取这些属性,并据此配置 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,并增加了 geometrymaterial 两个属性。这种几何体-材质的配对构成了渲染契约——渲染器在遍历场景时遇到 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 系统。