Read OSS

数学基础、光照与相机系统

中级

前置知识

  • 第 1-2 篇文章
  • 基本线性代数知识(向量、矩阵、点积/叉积)
  • 理解坐标空间(局部空间、世界空间、观察空间、裁剪空间)

数学基础、光照与相机系统

Three.js 内置了一套完整的线性代数库、一套精细的颜色管理系统、带投影矩阵计算的相机层次结构,以及可同时与传统渲染器和基于节点的渲染器集成的光照系统。这些子系统是整个框架的基石——数学基础类几乎贯穿每一个源文件,而理解相机和光源的工作原理,也是排查渲染问题的必备前提。本文逐一剖析这些模块,并梳理它们之间的关联。

数学库:就地修改的 API 设计

数学类——Vector2Vector3Vector4Matrix3Matrix4QuaternionEulerColor——遵循一套统一的 API 设计哲学:每个操作都直接修改当前实例并返回 this。这种设计既支持链式调用,又避免了因临时对象分配带来的垃圾回收压力。

Vector3 约有 1,263 行代码,清晰地展示了这一模式:

// 就地链式调用——零额外分配
direction.copy( target ).sub( origin ).normalize();

// 等价于:
direction.copy( target );
direction.sub( origin );
direction.normalize();

相比之下,如果使用不可变 API,每次操作都会返回一个新向量,每次调用就要分配三个 Vector3 实例。对于以 60fps 运行、包含数千个对象的渲染器来说,GC 压力将变得不可忽视。

如果确实需要不可变语义,可以使用 clone() 创建副本,或者用 copy(source) 覆盖当前实例:

const original = new Vector3( 1, 2, 3 );
const copy = original.clone();      // New Vector3(1,2,3)
const other = new Vector3();
other.copy( original );              // other is now (1,2,3)
classDiagram
    class Vector3 {
        +x: number
        +y: number
        +z: number
        +set(x, y, z): this
        +add(v): this
        +sub(v): this
        +multiplyScalar(s): this
        +normalize(): this
        +dot(v): number
        +cross(v): this
        +clone(): Vector3
        +copy(v): this
    }

    class Matrix4 {
        +elements: Float32Array[16]
        +compose(pos, quat, scale): this
        +decompose(pos, quat, scale): this
        +multiply(m): this
        +invert(): this
        +makePerspective(): this
    }

    class Quaternion {
        +x: number
        +y: number
        +z: number
        +w: number
        +setFromEuler(e): this
        +setFromAxisAngle(axis, angle): this
        +slerp(q, t): this
        +_onChange(callback)
    }

Matrix4 约 1,314 行,是处理变换的核心类。其 compose(position, quaternion, scale) 方法用于构建 TRS(平移-旋转-缩放)矩阵,而 decompose(position, quaternion, scale) 则负责将矩阵分解还原为各分量——正如我们在第 2 篇中看到的,Object3D 的 updateMatrix()applyMatrix4() 都依赖这两个方法。

src/math/MathUtils.js 中的 MathUtils 命名空间提供了一系列工具函数:clamplerpsmoothstepmapLineardegToRadisPowerOfTwo,以及使用第 3 行预计算十六进制查找表实现高效 UUID 生成的 generateUUID 函数。

提示: Three.js 使用列主序矩阵存储(与 WebGL/WebGPU 约定保持一致)。elements[12]elements[13]elements[14] 对应平移分量。如果你需要通过读取 elements 数组来调试变换,务必牢记这一内存布局。

Color 与 ColorManagement

ColorManagement 是 Three.js 用于确保颜色运算在正确色彩空间中进行的系统,其核心概念是工作色彩空间——默认为 LinearSRGBColorSpace

为了得到物理上正确的结果,所有颜色运算(光照、混合、纹理采样)都应在线性空间中进行。ColorManagement 负责管理整个转换流程:

flowchart LR
    Input["sRGB Input<br/>(textures, CSS colors)"] -->|"EOTF (gamma decode)"| Linear["Linear Working Space<br/>(all math here)"]
    Linear -->|"Lighting, blending,<br/>tone mapping"| Linear
    Linear -->|"OETF (gamma encode)"| Output["sRGB Output<br/>(canvas display)"]

convert() 方法以 CIE XYZ 作为中间色彩空间进行转换,文件顶部(第 5-15 行)预先计算好了 Rec.709(sRGB)原色对应的 3×3 转换矩阵:

const LINEAR_REC709_TO_XYZ = new Matrix3().set(
    0.4123908, 0.3575843, 0.1804808,
    0.2126390, 0.7151687, 0.0721923,
    0.0193308, 0.1191948, 0.9505322
);

ColorManagement.enabledtrue(默认值)时,以十六进制(0xff0000)或 CSS 字符串('red')形式传入的颜色会在使用前自动从 sRGB 转换到线性空间。也就是说,new Color(1, 0, 0) 表示线性红(全强度),而非 sRGB 红。

相机层次结构与投影

相机系统遵循清晰的继承链。Camera 继承自 Object3D,并额外增加了三个矩阵:

  • matrixWorldInverse:即视图矩阵——相机世界变换的逆矩阵。updateMatrixWorld() 在第 112 行被重写,用于自动计算该矩阵。
  • projectionMatrix:由子类设置,用于透视或正交投影。
  • projectionMatrixInverse:投影矩阵的逆矩阵,在屏幕空间到世界空间的反投影中非常有用。

Camera 还重写了 getWorldDirection(),在第 108 行对结果取反,因为按照惯例,相机朝向其 Z 轴方向。

classDiagram
    class Object3D {
        +matrixWorld: Matrix4
    }

    class Camera {
        +matrixWorldInverse: Matrix4
        +projectionMatrix: Matrix4
        +projectionMatrixInverse: Matrix4
        +coordinateSystem: number
    }

    class PerspectiveCamera {
        +fov: number
        +aspect: number
        +near: number
        +far: number
        +updateProjectionMatrix()
    }

    class OrthographicCamera {
        +left: number
        +right: number
        +top: number
        +bottom: number
        +updateProjectionMatrix()
    }

    Object3D <|-- Camera
    Camera <|-- PerspectiveCamera
    Camera <|-- OrthographicCamera

PerspectiveCamera 接受 fov(垂直视场角,单位为度)、aspectnearfar 参数。其 updateProjectionMatrix() 方法结合这些参数以及 zoomfilmGaugefilmOffset,还有用于分块渲染的可选子视锥体 view,共同计算出透视投影矩阵。

Camera 的 updateMatrixWorld() 方法中有一处细节值得注意——第 118 行在计算视图矩阵时会去除缩放,以符合 glTF 规范。如果相机存在非均匀缩放(虽然罕见但可能发生),在计算 matrixWorldInverse 时该缩放会被剥离。

光源类型与层次结构

Light 继承自 Object3D,仅新增了两个属性:color(Color 类型)和 intensity(数值类型),并会派发 'dispose' 事件以便资源清理。各具体光源类型在此基础上添加各自的专有属性:

光源类型 属性 场景图行为
AmbientLight color, intensity 无需位置信息
DirectionalLight color, intensity, target 位置与 target 共同决定方向
PointLight color, intensity, distance, decay 位置决定光源原点
SpotLight color, intensity, distance, angle, penumbra, decay, target 位置 + target + 锥角共同定义
HemisphereLight color, groundColor, intensity 方向由朝向决定
RectAreaLight color, intensity, width, height 位置与朝向共同定义面积
LightProbe sh (SphericalHarmonics3) 辐照度探针

需要方向信息的光源(DirectionalLight、SpotLight)使用 target 属性——一个光源"指向"的 Object3D 对象。这是一个优雅的设计:无需存储方向向量,光照方向直接由光源的世界坐标与 target 的世界坐标推导而来,两者都自然参与场景图的变换系统。

classDiagram
    class Object3D
    class Light {
        +color: Color
        +intensity: number
        +isLight: boolean
    }

    class AmbientLight {
        +isAmbientLight: boolean
    }

    class DirectionalLight {
        +target: Object3D
        +shadow: DirectionalLightShadow
    }

    class PointLight {
        +distance: number
        +decay: number
    }

    class SpotLight {
        +distance: number
        +angle: number
        +penumbra: number
        +decay: number
        +target: Object3D
    }

    Object3D <|-- Light
    Light <|-- AmbientLight
    Light <|-- DirectionalLight
    Light <|-- PointLight
    Light <|-- SpotLight

光源即节点:AnalyticLightNode 与 LightsNode

在新渲染器中,光源不再通过 uniform 数组和 #ifdef 分支直接参与着色计算,而是作为 shader 图中的节点存在。正如我们在第 4 篇中了解到的,StandardNodeLibrary 将每种光源类型映射到对应的节点类(例如 PointLightPointLightNodeSpotLightSpotLightNode)。

LightsNode 是光照汇聚器。它继承自 Node,输出类型为 'vec3',并维护 totalDiffuseNodetotalSpecularNode 两个属性,用于累积所有光源的贡献。在 setup 阶段,它遍历场景中的所有光源,为每个光源创建或获取对应的 AnalyticLightNode 子类实例,由各光源节点分别贡献其漫反射和镜面反射项。

graph TD
    LN["LightsNode"] --> DL["DirectionalLightNode"]
    LN --> PL["PointLightNode"]
    LN --> SL["SpotLightNode"]
    LN --> AL["AmbientLightNode"]

    DL --> Diff["totalDiffuseNode (vec3)"]
    PL --> Diff
    SL --> Diff
    AL --> Diff

    DL --> Spec["totalSpecularNode (vec3)"]
    PL --> Spec
    SL --> Spec

    Diff --> LC["LightingContextNode"]
    Spec --> LC
    LC --> Material["NodeMaterial output"]

每个 AnalyticLightNode 子类(如 DirectionalLightNodePointLightNode)负责实现各自光源的贡献计算:方向计算、距离衰减、锥角衰减(聚光灯),以及阴影评估。这些全部是 TSL 表达式,因此可以自动编译为 WGSL 或 GLSL。

提示: 如果需要自定义光照衰减或非标准的衰减曲线,可以继承 AnalyticLightNode 并重写相关方法。节点系统会将结果无缝组合进光照管线,无需修改其他任何光源的代码。

阴影映射与视锥体剔除

新渲染器中的阴影映射通过 ShadowNodeShadowBaseNode 实现。其原理是从光源视角渲染场景生成阴影贴图,再在主光照 pass 中采样该贴图,以判断哪些片元处于阴影中。不同的过滤策略(basic、PCF、PCF soft、VSM)被实现为独立的 TSL 函数。

视锥体剔除发生在渲染循环中、任何绘制命令下发之前。src/math/Frustum.js 中的 Frustum 类将相机的可视体积表示为六个 Plane 对象。在场景投影阶段,每个对象的包围球都会与这六个平面进行测试:

flowchart TD
    Cam["Camera"] -->|"projection × view"| ProjMat["projScreenMatrix"]
    ProjMat -->|"setFromProjectionMatrix()"| Frust["Frustum (6 planes)"]

    Obj["Object3D"] --> BS["boundingSphere"]
    BS --> Test{"frustum.intersectsObject()"}
    Frust --> Test

    Test -->|"Inside"| Add["Add to RenderList"]
    Test -->|"Outside"| Skip["Skip object"]

正如我们在第 2 篇中看到的,将 Object3D 的 frustumCulled 设为 false 的对象会完全跳过此测试,适用于天空盒等无论相机位于何处都应始终渲染的对象。

数学基础计算变换、相机生成投影矩阵、视锥体剔除对象、光源生成 shader 节点、阴影贴图判断遮挡——这些系统相互配合,共同构成了每一帧画面背后渲染管线的核心骨架。

下一步

在最后一篇文章中,我们将探讨向这套渲染系统输送数据的资产管线:loader 架构与缓存系统、关键的 GLTFLoader 插件、基于新 RenderPipeline 类的后处理、用于用户交互的 controls 系统,以及如何熟悉测试基础设施并向项目贡献代码。