第 9 节:光照与阴影系统——从光源到 Shadow Map
光照是 3D 渲染的"灵魂"——没有光,就没有立体感。Three.js 的光照系统是多光源、实时阴影、可配置的复杂工程,但它内部有清晰的抽象。
🏗️ Light 类体系
Light (基类)
├── AmbientLight - 环境光(无方向)
├── DirectionalLight - 平行光(太阳光)
├── PointLight - 点光源(灯泡)
├── SpotLight - 聚光灯(手电筒)
├── HemisphereLight - 半球光(天空+地面)
└── RectAreaLight - 面光源
每种 Light 都有独立的 Uniform 数组结构:
| Light | 关键 Uniform |
|---|---|
| AmbientLight | ambientLightColor[N] |
| DirectionalLight | directionalLights[N].direction / color |
| PointLight | pointLights[N].position / color / distance / decay |
| SpotLight | spotLights[N].position / direction / color / angle / penumbra |
📦 WebGLLights.setup():光源数据打包
Three.js 把场景中所有光源打包成 TypedArray 数组传给 GPU:
// 简化源码:src/renderers/webgl/WebGLLights.js
setup(lights, useShadowMap) {
let r = 0, g = 0, b = 0;
let directionalLength = 0, pointLength = 0, spotLength = 0;
for (const light of lights) {
if (light.isDirectionalLight) directionalLength++;
if (light.isPointLight) pointLength++;
if (light.isSpotLight) spotLength++;
}
// 预分配 array
const directionalColors = new Float32Array(directionalLength * 3);
const directionalDirections = new Float32Array(directionalLength * 3);
// ...
// 填充数据
let did = 0, pid = 0, sid = 0;
for (const light of lights) {
const color = light.color;
if (light.isDirectionalLight) {
directionalColors[did * 3 + 0] = color.r;
directionalColors[did * 3 + 1] = color.g;
directionalColors[did * 3 + 2] = color.b;
// ...
}
}
return {
directional: { colors: directionalColors, directions: directionalDirections, length: directionalLength },
point: { ... },
spot: { ... }
};
}
关键点:用结构化数组(SoA)而不是对象数组,符合 GPU 内存连续访问模式。
🌑 Shadow Map 渲染管线
阴影的本质是"从光源视角看,物体到光源的距离图"。
1. Shadow Map 渲染流程
┌─ 第一遍:深度 Pass(从光源视角)
│ - 替换场景中所有物体的 material 为 MeshDepthMaterial
│ - 相机替换为 OrthographicCamera(平行光)或 PerspectiveCamera(点光源)
│ - 渲染结果写入 depthTexture
│
└─ 第二遍:主 Pass(从相机视角)
- 正常渲染场景
- 在片元着色器中:
1. 计算当前片元到光源的距离
2. 采样 depthTexture,拿到光源视角下最近的深度
3. 比较两者 → 判断是否在阴影中
2. WebGLShadowMap.render() 内部
// 简化源码
render(lights, scene, camera) {
for (const light of lights) {
if (light.castShadow) {
// 1. 更新光源的阴影相机矩阵
light.shadow.update(light);
// 2. 临时替换材质为深度材质
this.renderObject(scene, camera, light.shadow.camera, light);
// 3. 把深度图作为 uniform 传给主着色器
}
}
}
renderObject(object, camera, shadowCamera, light) {
// 遍历场景图,把每个 castShadow=true 的对象的 material 临时替换为 MeshDepthMaterial
object.traverse((child) => {
if (child.castShadow) {
const originalMaterial = child.material;
child.material = this.depthMaterial; // 临时材质
// 渲染到 light.shadow.map (RenderTarget)
this.renderer.render(child, shadowCamera);
child.material = originalMaterial; // 还原
}
});
}
🎨 阴影算法对比
| 算法 | 原理 | 效果 | 性能 |
|---|---|---|---|
| Basic Shadow Map | 直接比较深度 | 锯齿严重 | 极快 |
| PCF | 周围多次采样求平均 | 边缘柔和 | 较快 |
| PCF Soft (PCSS) | 模拟半影,随距离变化 | 真实感强 | 较慢 |
| VSM | 用方差存储深度,Chebyshev 不等式 | 边缘柔和平滑 | 中等 |
| CSM (Cascaded Shadow Map) | 多个深度图覆盖不同距离 | 超大场景精确 | 慢(占用多张贴图) |
Three.js 的设置
// PCF Soft(推荐)
light.shadow.mapSize.set(2048, 2048);
light.shadow.radius = 5; // 模糊半径
light.shadow.blurSamples = 25; // 采样数
// VSM
renderer.shadowMap.type = THREE.VSMShadowMap;
// CSM
import { CSMShadowMap } from 'three/addons/csm/CSMShadowMap.js';
🏔️ CSM:级联阴影
问题:单一 Shadow Map 覆盖整个场景时,近距离精度高、远距离精度低。
CSM 方案:把视锥体沿 Z 轴切成 N 段,每段单独生成 Shadow Map。
┌─────────────────────────────────┐
│ 远段(精度低,分辨率中等) │
│ ┌───────────────────────────┐ │
│ │ 中段(精度中,分辨率中) │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ 近段(精度高,1024+)│ │ │
│ │ │ │ │ │
│ │ │ 相机 │ │ │
│ │ └─────────────────────┘ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
Three.js 的实现:CSMShadowMap(addons 模块)需要手动配置每个级联的分割距离和分辨率。
🎯 Mini 实现
class MiniDirectionalLight {
constructor(color = [1, 1, 1], direction = [0, -1, 0]) {
this.color = color;
this.direction = direction;
this.castShadow = true;
this.shadowMap = null;
}
// 第一遍:生成深度图
renderShadowMap(scene) {
// 1. 创建深度 RenderTarget
this.shadowMap = new MiniRenderTarget(1024, 1024);
// 2. 准备光源视角相机
const lightCam = createLightCamera(this.direction);
// 3. 用深度材质渲染所有 castShadow 物体
scene.traverse((obj) => {
if (obj.castShadow) {
obj.material = this.depthMaterial;
}
});
renderScene(scene, lightCam, this.shadowMap);
}
// 第二遍:主渲染
setupShaderUniforms(uniforms) {
uniforms.uShadowMap = this.shadowMap.depthTexture;
uniforms.uLightDirection = this.direction;
}
}
🆚 OGL 对比
OGL 不内置光照系统——这是它与 Three.js 最大的设计分歧。
理由:
- OGL 假设开发者会自己写完整 Shader(光照代码直接 inline 到 GLSL 中)
- 强耦合的光照系统会增加引擎复杂度
- 适合自定义光照模型(如卡通着色、双向反射分布函数 BRDF 研究)
Three.js 内置光照是因为大多数用户需要"开箱即用"的 PBR 渲染。
🎓 深度思考
为什么 Shadow Map 会有"自阴影痤疮(Shadow Acne)"?
因为同一片元在不同精度下的深度比较存在误差。解决方案:深度偏移(Depth Bias)——在比较时给当前深度加一个微小偏移。点光源的 Shadow Map 为什么是 CubeMap?
点光源向 6 个方向发光,需要 6 张 2D 贴图(立方体贴图)覆盖整个球面。阴影性能与精度的矛盾怎么权衡?
- 静态场景:High-Res 阴影 + PCF Soft
- 动态场景:低分辨率 + VSM / ESM(Exponential Shadow Map)
- 远景:直接禁用阴影或用 SSAO 模拟