Skip to main content

第 8 节:Camera 与投影系统——从透视矩阵到 Frustum Culling

Camera 在 Three.js 中既是"观察者"(决定看到什么),也是"投影机"(决定 3D 如何压成 2D)。它涉及三大核心:View 矩阵、Projection 矩阵、视锥体剔除。


🏗️ Camera 的三把剑

Camera
├── matrixWorld: 相机在世界中的位姿
├── matrixWorldInverse: View 矩阵 = matrixWorld 的逆
├── projectionMatrix: 投影矩阵(透视/正交)
├── projectionMatrixInverse: 投影矩阵的逆
├── fov / aspect / near / far
├── layers: Layers (32 位掩码)
└── view: 启用 frustumCulling 的子对象列表

1. View 矩阵

View 矩阵本质上是"世界坐标 → 相机坐标"的变换,等价于"把世界平移旋转到相机视角下"。

// 简化源码
updateMatrixWorld(force) {
super.updateMatrixWorld(force);
this.matrixWorldInverse.copy(this.matrixWorld).invert();
}

为什么取逆? 因为相机的"位姿"矩阵描述的是"相机在世界中怎么摆放",而我们需要的是"反过来把世界变换到相机视角"——所以取逆。

2. 投影矩阵

透视投影

// 简化源码:PerspectiveCamera.updateProjectionMatrix()
updateProjectionMatrix() {
const near = this.near;
const top = near * Math.tan(DEG2RAD * 0.5 * this.fov) / this.zoom;
const height = 2 * top;
const width = this.aspect * height;
const left = -0.5 * width;

this.projectionMatrix.makePerspective(left, left + width, top, top - height, near, this.far);
}

fov 是垂直视场角,aspect 是宽高比。tan(fov/2) * near 给出 near 平面半高。

正交投影

// 简化源码:OrthographicCamera
updateProjectionMatrix() {
this.projectionMatrix.makeOrthographic(
this.left, this.right, this.top, this.bottom, this.near, this.far
);
}

正交投影没有"近大远小",所有物体大小一致——适合 2D UI、工程制图。


🔍 Frustum Culling:6 个平面的魔法

如果一个物体在相机视锥体之外,根本不需要绘制——直接跳过,省下 GPU 时间。

1. 视锥体的 6 个平面

// 简化源码:src/math/Frustum.js
class Frustum {
constructor(p0, p1, p2, p3, p4, p5) {
this.planes = [p0, p1, p2, p3, p4, p5]; // 6 个 Plane
}

setFromProjectionMatrix(m) {
const planes = this.planes;
const me = m.elements;
// 6 个平面的法向量和常数项,从 projectionMatrix * viewMatrix 推导
// ...
}
}

这 6 个平面分别对应:near、far、left、right、top、bottom。

2. 视锥体与包围盒/包围球求交

// 简化源码
intersectsObject(object) {
if (object.boundingSphere === undefined) {
// 自动计算 boundingSphere
if (object.geometry.boundingSphere === null) {
object.geometry.computeBoundingSphere();
}
object.boundingSphere = object.geometry.boundingSphere.clone();
object.boundingSphere.applyMatrix4(object.matrixWorld);
}
return this.intersectsSphere(object.boundingSphere);
}

intersectsSphere(sphere) {
const planes = this.planes;
const center = sphere.center;
const negRadius = -sphere.radius;

for (let i = 0; i < 6; i++) {
const distance = planes[i].distanceToPoint(center);
if (distance < negRadius) return false; // 完全在某平面外侧
}
return true; // 至少与所有平面相交或在内部
}
算法精髓

「点到平面的距离 < -radius」 就说明球完全在平面外侧。如果任何一个平面判定为完全外侧,则整个球(和物体)在视锥体之外,可以剔除。

3. 包围球 vs 包围盒

类型精度计算开销适用
包围球 (BoundingSphere)较低极低简单几何体、远距离物体
包围盒 (BoundingBox)较高较低复杂不规则几何体

Three.js 默认用包围球——因为它只需要 1 次距离计算就能判定相交,比 AABB 8 个顶点全算一遍快 8 倍


🖼️ Viewport 与 Scissor:多视口渲染

Viewport 决定"渲染到画布的哪个矩形区域":

// 简化源码
setViewport(x, y, width, height) {
if (currentViewport[0] !== x || ...) {
gl.viewport(x, y, width, height);
}
}

// 切到屏幕左半边
renderer.setViewport(0, 0, width / 2, height);
camera.aspect = (width / 2) / height; // 注意:也要更新相机的宽高比!
camera.updateProjectionMatrix();
renderer.render(scene, camera);

// 切到屏幕右半边
renderer.setViewport(width / 2, 0, width / 2, height);
camera.aspect = (width / 2) / height;
camera.updateProjectionMatrix();
renderer.render(scene, camera);

Scissor 类似,但只影响光栅化输出区域,不影响投影——适合做小地图、HUD。


🎯 Mini 实现

class MiniCamera {
constructor(fov = 75, aspect = 1, near = 0.1, far = 1000) {
this.fov = fov;
this.aspect = aspect;
this.near = near;
this.far = far;
this.position = [0, 0, 5];
this.viewMatrix = identity();
this.projMatrix = identity();
}

update() {
// View 矩阵:相机位置的逆
this.viewMatrix = invert(this.position);
// Projection 矩阵
const f = 1 / Math.tan((this.fov / 2) * Math.PI / 180);
this.projMatrix = [
f / this.aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (this.far + this.near) / (this.near - this.far), (2 * this.far * this.near) / (this.near - this.far),
0, 0, -1, 0
];
}

// 简化 Frustum 剔除
isInFrustum(boundingSphere) {
// 简单测试:检查 z 距离
const dz = boundingSphere.center[2] - this.position[2];
if (dz > this.far + boundingSphere.radius) return false;
if (dz < this.near - boundingSphere.radius) return false;
return true;
}
}

🆚 OGL 对比

OGL 的 Camera 直接继承 Transform,矩阵更新与场景图融为一体:

// OGL: src/camera/Camera.js
class Camera extends Transform {
constructor(gl, { fov = 45, aspect = 1, near = 0.1, far = 100 } = {}) {
super();
this.fov = fov;
// ... 自动维护 viewMatrix / projectionMatrix
}
get projectionMatrix() {
// 自动按 fov / aspect / near / far 计算
}
get viewMatrix() {
return this.inverseMatrix; // 直接用 Transform 维护的逆矩阵
}
}

设计取舍:OGL 让 Camera 拥有自己的位姿(继承 Transform),并自动计算 viewMatrix。Three.js 让 Camera 继承 Object3D 但默认不放在场景图里——更传统但更明确。


🎓 深度思考

  1. 为什么 Frustum Culling 不完全消除 overdraw?
    它只剔除"完全在视锥外的物体"。如果物体部分在视锥内,仍会被整物体渲染。真正消除 overdraw 要靠遮挡剔除(Occlusion Culling)——但 WebGL 没有原生支持,需要用 Hi-Z、Portal 等算法。

  2. 透视矩阵的 w 分量为什么是 -z
    这样透视除法之后,近处 z 值更大,远处 z 值更小,符合 NDC 约定(z 越小越远)。

  3. 为什么投影矩阵的 near 不能太小?
    透视除法后深度精度近似于 $1/z$。如果 near=0.001,在 1 米内就吃掉了大部分深度精度,远处就会闪烁(Z-Fighting)。


📚 延伸阅读