数学基础、光照与相机系统
前置知识
- ›第 1-2 篇文章
- ›基本线性代数知识(向量、矩阵、点积/叉积)
- ›理解坐标空间(局部空间、世界空间、观察空间、裁剪空间)
数学基础、光照与相机系统
Three.js 内置了一套完整的线性代数库、一套精细的颜色管理系统、带投影矩阵计算的相机层次结构,以及可同时与传统渲染器和基于节点的渲染器集成的光照系统。这些子系统是整个框架的基石——数学基础类几乎贯穿每一个源文件,而理解相机和光源的工作原理,也是排查渲染问题的必备前提。本文逐一剖析这些模块,并梳理它们之间的关联。
数学库:就地修改的 API 设计
数学类——Vector2、Vector3、Vector4、Matrix3、Matrix4、Quaternion、Euler、Color——遵循一套统一的 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 命名空间提供了一系列工具函数:clamp、lerp、smoothstep、mapLinear、degToRad、isPowerOfTwo,以及使用第 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.enabled 为 true(默认值)时,以十六进制(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(垂直视场角,单位为度)、aspect、near 和 far 参数。其 updateProjectionMatrix() 方法结合这些参数以及 zoom、filmGauge、filmOffset,还有用于分块渲染的可选子视锥体 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 将每种光源类型映射到对应的节点类(例如 PointLight → PointLightNode,SpotLight → SpotLightNode)。
LightsNode 是光照汇聚器。它继承自 Node,输出类型为 'vec3',并维护 totalDiffuseNode 和 totalSpecularNode 两个属性,用于累积所有光源的贡献。在 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 子类(如 DirectionalLightNode、PointLightNode)负责实现各自光源的贡献计算:方向计算、距离衰减、锥角衰减(聚光灯),以及阴影评估。这些全部是 TSL 表达式,因此可以自动编译为 WGSL 或 GLSL。
提示: 如果需要自定义光照衰减或非标准的衰减曲线,可以继承
AnalyticLightNode并重写相关方法。节点系统会将结果无缝组合进光照管线,无需修改其他任何光源的代码。
阴影映射与视锥体剔除
新渲染器中的阴影映射通过 ShadowNode 和 ShadowBaseNode 实现。其原理是从光源视角渲染场景生成阴影贴图,再在主光照 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 系统,以及如何熟悉测试基础设施并向项目贡献代码。