首页
/ three.js CSM 级联阴影贴图(Cascade Shadow Maps)完整指南:从分档策略到逐帧更新机制

three.js CSM 级联阴影贴图(Cascade Shadow Maps)完整指南:从分档策略到逐帧更新机制

2026-09-06 12:40:31作者:尤辰城Agatha

在真实感 3D 渲染中,单张方向光阴影贴图很难同时兼顾近处的细节精度和远处的覆盖范围——这正是级联阴影贴图(Cascade Shadow Maps,CSM)要解决的问题。本篇基于 three.js 仓库中的 docs/pages/CSM.html.md API 文档,结合 examples/jsm/csm/CSM.js 源码实现,完整讲解 CSM 插件的构造参数、视锥拆分模式、逐帧更新机制与着色器注入原理。读完本文,你将能够在一个 WebGL 场景中正确配置并驱动一套多级联阴影系统,并理解 update()updateFrustums()setupMaterial() 各自的作用边界。

three.js CSM 级联阴影示例渲染效果

定位与适用范围

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)。CSMCSMShadowNode 共享同一套 cascadesmaxFarmodelightMargin 等参数语义和默认值,二者是面向不同渲染管线的平行实现。

一个 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 完成三件事:

  1. 视锥变换到光空间:构造 _lightOrientationMatrix(由 lightDirection 与上向量 lookAt 得到),把相机世界矩阵变换成 _cameraToLightMatrix,再通过 frustum.toSpace() 把该级联视锥的 8 个顶点变换到光空间;
  2. 求光空间包围盒并确定光位置:对变换后的 8 个顶点求 Box3,取中心点,并把 z 推到 _bbox.max.z + lightMargin——这就是 lightMargin 的含义,它为阴影相机留出深度余量;
  3. 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 设置(maxFarmodefade 等)改变时必须调用。源码 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 = 1defines.CSM_CASCADES = cascades(可选 CSM_FADE),并通过 onBeforeCompile 注入三个 uniform:CSM_cascades(每级联一个 vec2,即相邻 break 组成的区间)、cameraNearshadowFar
  • remove():当你要把 CSM 从场景移除时调用,它会逐条把 lights 和对应 targetparent 中移除。
  • dispose():释放 GPU 资源。源码 CSM.js#L554-L577 中它遍历 shaders Map,删除材质上的 onBeforeCompileUSE_CSMCSM_CASCADESCSM_FADE defines 与三个 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 同时兼容 CSMCSMShadowNode 两种实例(构造参数类型为 CSM|CSMShadowNode)。

实战参考:官方示例 webgl_shadowmap_csm.html

完整可运行的参考实现在 examples/webgl_shadowmap_csm.html,要点摘录:

  • 通过 import map 把 threethree/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.jsnew CSMShadowNode( light, data ) 以一个你自己的 DirectionalLight 为入口,data 支持 cascades(默认 3)、maxFar(默认 100000)、mode(默认 'practical')、customSplitsCallbacklightMargin(默认 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.jsCSMFrustum.jsCSMShader.jsCSMHelper.jsCSMShadowNode.js),配合官方示例 examples/webgl_shadowmap_csm.html 可以边读边调试;需要精确核对 API 默认值时,以 docs/pages/CSM.html.mdexamples/jsm/csm/CSM.js 中的 JSDoc 注释为准。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388