Skip to main content

第 9 节:光照与阴影系统——从光源到 Shadow Map

光照是 3D 渲染的"灵魂"——没有光,就没有立体感。Three.js 的光照系统是多光源、实时阴影、可配置的复杂工程,但它内部有清晰的抽象。


🏗️ Light 类体系

Light (基类)
├── AmbientLight - 环境光(无方向)
├── DirectionalLight - 平行光(太阳光)
├── PointLight - 点光源(灯泡)
├── SpotLight - 聚光灯(手电筒)
├── HemisphereLight - 半球光(天空+地面)
└── RectAreaLight - 面光源

每种 Light 都有独立的 Uniform 数组结构

Light关键 Uniform
AmbientLightambientLightColor[N]
DirectionalLightdirectionalLights[N].direction / color
PointLightpointLights[N].position / color / distance / decay
SpotLightspotLights[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 渲染


🎓 深度思考

  1. 为什么 Shadow Map 会有"自阴影痤疮(Shadow Acne)"?
    因为同一片元在不同精度下的深度比较存在误差。解决方案:深度偏移(Depth Bias)——在比较时给当前深度加一个微小偏移。

  2. 点光源的 Shadow Map 为什么是 CubeMap?
    点光源向 6 个方向发光,需要 6 张 2D 贴图(立方体贴图)覆盖整个球面。

  3. 阴影性能与精度的矛盾怎么权衡?

    • 静态场景:High-Res 阴影 + PCF Soft
    • 动态场景:低分辨率 + VSM / ESM(Exponential Shadow Map)
    • 远景:直接禁用阴影或用 SSAO 模拟

📚 延伸阅读