three.js CSM 级联阴影贴图(Cascade Shadow Maps)完整指南:从分档策略到逐帧更新机制
在真实感 3D 渲染中,单张方向光阴影贴图很难同时兼顾近处的细节精度和远处的覆盖范围——这正是级联阴影贴图(Cascade Shadow Maps,CSM)要解决的问题。本篇基于 three.js 仓库中的 docs/pages/CSM.html.md API 文档,结合 examples/jsm/csm/CSM.js 源码实现,完整讲解 CSM 插件的构造参数、视锥拆分模式、逐帧更新机制与着色器注入原理。读完本文,你将能够在一个 WebGL 场景中正确配置并驱动一套多级联阴影系统,并理解 update()、updateFrustums()、setupMaterial() 各自的作用边界。
定位与适用范围
CSM 是一个 addon(插件),位于 examples/jsm/csm/ 目录下,需要显式导入:
import { CSM } from 'three/addons/csm/CSM.js';
根据官方文档,该模块只能与 WebGLRenderer 搭配使用;当使用 WebGPURenderer 时应改用 examples/jsm/csm/CSMShadowNode.js(对应文档 docs/pages/CSMShadowNode.html.md)。CSM 与 CSMShadowNode 共享同一套 cascades、maxFar、mode、lightMargin 等参数语义和默认值,二者是面向不同渲染管线的平行实现。
一个 CSM 实例的核心结构(从 CSM.js 构造函数可以看到):
- 一组
DirectionalLight:每条 cascade 一个方向光,光强、阴影贴图尺寸、near/far、bias 均由构造参数统一设置; - 一个主视锥
mainFrustum和一组级联视锥frustums:由场景相机的投影矩阵反推得到; - 一套被增强过的内置材质着色器:通过替换
ShaderChunk实现(详见“着色器注入机制”一节)。
构造参数(CSM~Data)完整说明
new CSM( data ) 接收一个 data 对象。下表汇总了文档与源码 CSM.js#L39-L191 中的全部字段、类型与默认值:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
camera |
Camera |
必填 | 场景相机,决定视锥拆分基础 |
parent |
Object3D |
必填 | 父对象,通常是 scene,方向光及其 target 会被添加到这里 |
cascades |
number |
3 |
级联数量 |
maxFar |
number |
100000 |
阴影覆盖的最大远平面,实际取 min(camera.far, maxFar) |
mode |
'practical' | 'uniform' | 'logarithmic' | 'custom' |
'practical' |
视锥拆分模式 |
customSplitsCallback |
function |
— | mode='custom' 时的自定义拆分回调,签名为 (cascades, near, far, breaks),直接向 breaks 数组 push 值 |
shadowMapSize |
number |
2048 |
每条 cascade 的阴影贴图分辨率(正方形) |
shadowBias |
number |
0.000001 |
阴影偏移,透传到每条光的 light.shadow.bias |
lightDirection |
Vector3 |
new Vector3(1, -1, 1).normalize() |
光照方向向量 |
lightIntensity |
number |
3 |
每条方向光的强度 |
lightNear |
number |
1 |
阴影相机 near |
lightFar |
number |
2000 |
阴影相机 far(注:原文档 Data 定义中该字段误写为第二个 lightNear,源码 typedef 同样有此笔误,实际对应 lightFar) |
lightMargin |
number |
200 |
光照边界余量,用于防止阴影在级联边界出现空洞 |
最小可用构造示例:
const csm = new CSM( {
maxFar: 1000,
cascades: 4,
mode: 'practical',
parent: scene,
shadowMapSize: 1024,
lightDirection: new THREE.Vector3( -1, -1, -1 ).normalize(),
camera: camera
} );
构造函数会依次执行 _createLights()(创建并添加方向光)、updateFrustums()(计算拆分并初始化视锥)和 _injectInclude()(替换着色器代码块)。因此创建 CSM 实例后立即拥有可工作的阴影灯光,只需在渲染循环中调用 update()。
视锥拆分模式(mode)与 breaks
breaks 是一个位于 [0,1] 区间的数字数组,定义主视锥沿深度方向如何被切分。它由 mode 决定,计算逻辑在 CSM.js 的 _getBreaks() 中:
uniform(均匀拆分):breaks按(near + (far - near) * i / amount) / far均匀取值。近处阴影贴图利用率偏低,远处精度较好。logarithmic(对数拆分):按near * (far / near) ** (i / amount)对数分布,近处分辨率最高。practical(实用拆分,默认):对每个断点在对数值与均匀值之间做线性插值lerp(uniform, log, 0.5),lambda固定为0.5,在两种极端之间折中,是官方推荐的默认值。custom(自定义):调用你提供的customSplitsCallback(cascades, near, far, breaks),由你自己 push 断点值。若未定义该回调,源码会console.error报错。
得到的断点数组随后交给 CSMFrustum.split():它在近、远平面之间按 alpha = (break * far - near) / (far - near) 做线性插值,把主视锥切成 cascades 个逐级嵌套的小视锥(近 cascade 的近平面即相机近平面,远 cascade 的远平面即主视锥远平面)。
maxFar 的取值策略值得注意:拆分计算时远平面取 Math.min( camera.far, this.maxFar )(见 CSM.js#L296)。而 CSMFrustum.setFromProjectionMatrix() 会先把远平面顶点还原到世界空间,再用 Math.min( maxFar / absZ, 1.0 ) 缩放回缩,从而支持 maxFar 大于 camera.far 的“超远阴影”场景;对于正交相机走的是 v.z *= Math.min(...) 的分支。
逐帧更新:update() 做了什么
update() 必须在动画循环中、renderer.render() 之前调用。从 CSM.js#L364-L408 的源码看,它每帧为每条 cascade 完成三件事:
- 视锥变换到光空间:构造
_lightOrientationMatrix(由lightDirection与上向量lookAt得到),把相机世界矩阵变换成_cameraToLightMatrix,再通过frustum.toSpace()把该级联视锥的 8 个顶点变换到光空间; - 求光空间包围盒并确定光位置:对变换后的 8 个顶点求
Box3,取中心点,并把z推到_bbox.max.z + lightMargin——这就是lightMargin的含义,它为阴影相机留出深度余量; - texel 对齐消除抖动:
_center.x = Math.floor( _center.x / texelWidth ) * texelWidth;
_center.y = Math.floor( _center.y / texelHeight ) * texelHeight;
光源位置被“向下取整”到阴影贴图像素网格上(texelWidth = (right - left) / shadowMapSize)。这样相机移动时光源只按整数 texel 步进,避免了亚像素级的位置漂移造成的阴影抖动(shadow flickering),这是 CSM 实现中最关键的稳定性技巧之一。
每个 cascade 的阴影相机视口大小则由 _updateShadowBounds() 根据该级联视锥在光空间下的最长对角线确定,并在 fade = true 时按 0.25 * linearDepth^2 额外外扩边界,为级联交叉淡入淡出预留余量。
生命周期管理:updateFrustums、setupMaterial、remove、dispose
这四个 API 的职责边界是正确使用 CSM 的关键:
updateFrustums():每当相机参数(位置、fov、near/far、正交/透视切换)或 CSM 设置(maxFar、mode、fade等)改变时必须调用。源码 CSM.js#L527-L534 显示它串行执行_getBreaks() → _initCascades() → _updateShadowBounds() → _updateUniforms()。注意它不是每帧调用的——每帧调用的是update()。官方示例 examples/webgl_shadowmap_csm.html 中所有 GUI 参数变更(切换正交相机、调整 far、切换 mode 等)的onChange回调里,统一都调用了csm.updateFrustums()。setupMaterial( material ):所有需要受 CSM 影响的材质必须调用此方法。从 CSM.js#L427-L458 看,它会给材质写入defines.USE_CSM = 1和defines.CSM_CASCADES = cascades(可选CSM_FADE),并通过onBeforeCompile注入三个 uniform:CSM_cascades(每级联一个vec2,即相邻 break 组成的区间)、cameraNear、shadowFar。remove():当你要把 CSM 从场景移除时调用,它会逐条把lights和对应target从parent中移除。dispose():释放 GPU 资源。源码 CSM.js#L554-L577 中它遍历shadersMap,删除材质上的onBeforeCompile、USE_CSM、CSM_CASCADES、CSM_FADEdefines 与三个 CSM uniform,并置material.needsUpdate = true,最终清空 Map。不 dispose 就销毁实例会导致材质残留 CSM 宏定义、着色器编译出错。
标准使用流程可归纳为:
// 1. 创建并配置
const csm = new CSM( { camera, parent: scene, cascades: 3, maxFar: 5000 } );
// 2. 让材质参与 CSM
csm.setupMaterial( floorMaterial );
csm.setupMaterial( cubeMaterial );
// 3. 相机或 CSM 参数变化时
csm.updateFrustums();
// 4. 每帧动画循环
function animate() {
csm.update(); // 必须在 render 之前
renderer.render( scene, camera );
}
// 5. 卸载时
csm.remove(); // 从场景移除灯光
csm.dispose(); // 释放材质着色器状态
着色器注入机制:CSM 如何“接管”方向光阴影采样
CSM 与 three.js 内置材质的协作方式是全局替换 ShaderChunk。构造函数中的 _injectInclude() 会执行:
ShaderChunk.lights_fragment_begin = CSMShader.lights_fragment_begin;
ShaderChunk.lights_pars_begin = CSMShader.lights_pars_begin;
替换后的片段(CSMShader.js)在 #if defined( USE_CSM ) && defined( CSM_CASCADES ) 分支里按深度选择 cascade:
- 片元先算出
linearDepth = vViewPosition.z / (shadowFar - cameraNear),即归一化线性深度; - 若未开启 fade:仅当
linearDepth落在CSM_cascades[i]这个[break_{i-1}, break_i)区间内,才对该方向光的阴影贴图采样getShadow(...),即每个片元只查一条 cascade 的阴影图,天然避免了重复计算; - 若开启
fade:以区间中心为轴计算margin = 0.25 * pow(closestEdge, 2.0),用ratio = clamp(dist / margin, 0.0, 1.0)在相邻两级的阴影结果间做mix()混合,消除级联边界处可见的亮度断层; - 未设置
USE_CSM的材质仍走文件尾部保留的标准方向光循环,因此替换ShaderChunk是全局操作,但只有调用过setupMaterial()的材质才会进入 CSM 分支。
对应的 uniform 声明在 lights_pars_begin 中追加:
uniform vec2 CSM_cascades[CSM_CASCADES];
uniform float cameraNear;
uniform float shadowFar;
这也解释了为什么 setupMaterial() 需要给材质打 USE_CSM / CSM_CASCADES 宏——着色器分支完全由这些宏和 uniform 驱动。
调试可视化:CSMHelper
仓库提供 examples/jsm/csm/CSMHelper.js 用于把级联结构画出来:
import { CSMHelper } from 'three/addons/csm/CSMHelper.js';
const csmHelper = new CSMHelper( csm );
csmHelper.visible = false;
scene.add( csmHelper );
它继承自 Group,有三个显示开关:displayFrustum(主视锥线框)、displayPlanes(级联分割平面)、displayShadowBounds(各级联的阴影相机边界)。修改任一开关后需调用 updateVisibility() 刷新可见性;每帧动画循环中调用 csmHelper.update() 可让线框跟随相机移动(官方示例用 autoUpdateHelper 参数控制这一点)。CSMHelper 同时兼容 CSM 与 CSMShadowNode 两种实例(构造参数类型为 CSM|CSMShadowNode)。
实战参考:官方示例 webgl_shadowmap_csm.html
完整可运行的参考实现在 examples/webgl_shadowmap_csm.html,要点摘录:
- 通过 import map 把
three与three/addons/指向本地构建(../build/three.module.js与./jsm/); - 场景包含地面 + 80 个不同高度的方块,全部材质调用
csm.setupMaterial(); renderer.shadowMap.type = THREE.PCFShadowMap搭配 PCF 柔化阴影;- GUI 面板动态演示了各类参数的变更方式:切正交相机时
csm.camera = orthoCamera后调csm.updateFrustums();调整maxFar/mode/fade同理;而lightNear/lightFar则需手动同步到每条csm.lights[i].shadow.camera并调用updateProjectionMatrix()——因为这两个值只在_createLights()时写入一次,运行时修改不会自动生效; - 动画循环中顺序为
camera.updateMatrixWorld() → csm.update() → controls.update() → renderer.render(...)。
一个容易踩的坑:示例中 csm.lights 的阴影相机 near/far 是构造时一次性写入的,若运行时想改,应遍历 csm.lights 手动更新(正如 GUI 回调所做的那样),而不是修改 csm.lightNear 属性本身。
与 WebGPU 渲染器的关系
若你的项目使用 WebGPURenderer,应改用 examples/jsm/csm/CSMShadowNode.js:new CSMShadowNode( light, data ) 以一个你自己的 DirectionalLight 为入口,data 支持 cascades(默认 3)、maxFar(默认 100000)、mode(默认 'practical')、customSplitsCallback、lightMargin(默认 200)等,语义与 WebGL 版一致;继承链为 EventDispatcher → Node → ShadowBaseNode,API 文档见 docs/pages/CSMShadowNode.html.md。两者的差异主要在渲染管线集成方式:WebGL 版通过全局替换 ShaderChunk 增强内置材质,WebGPU 版则以节点(Node)形式接入 TSL 光照图。
小结
CSM 插件把级联阴影的所有工程细节封装进了一个类:mode 决定断点分布,updateFrustums() 负责结构性重算,update() 负责每帧的光空间包围盒计算与 texel 对齐,setupMaterial() 决定哪些材质进入 CSM 着色分支,remove()/dispose() 负责干净卸载。配套源码集中在 examples/jsm/csm/ 目录(CSM.js、CSMFrustum.js、CSMShader.js、CSMHelper.js、CSMShadowNode.js),配合官方示例 examples/webgl_shadowmap_csm.html 可以边读边调试;需要精确核对 API 默认值时,以 docs/pages/CSM.html.md 与 examples/jsm/csm/CSM.js 中的 JSDoc 注释为准。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
