47 · CSM 阴影同步与 csm_DepthAlpha 的版本陷阱

返回系列目录

同一个物体至少可能经历两种绘制:主相机把它绘制成可见颜色;光源视角把它绘制成遮挡深度。点光源还需要记录相对光源的距离。主材质上的 CSM 不会自动把你的业务公式复制给其他通道。

本章依据 CSM 6.4.0 + Three.js r185。这组版本的 csm_DepthAlpha 默认映射存在兼容问题,因此本章先解释实际源码,再给出不依赖该映射的完整溶解案例。

1. 三份材质需要同步什么

通道 对象属性 需要一致的数据
主相机可见表面 mesh.material 几何变形、裁切条件、材质光照
方向光、聚光灯阴影 mesh.customDepthMaterial 相同的变形和裁切
点光源阴影 mesh.customDistanceMaterial 相同的变形和裁切,保留距离计算

只修改 csm_Position 时,主通道几何会变形,阴影仍可能保持原状;只在主片元丢弃时,可见模型已经溶解,地上的阴影仍然完整。CSM 的输出变量只修改当前材质的着色器。

对于普通不透明 Mesh,关键是让三个通道使用相同的位置函数、同一时间状态以及同一覆盖判定。法线修正主要服务主通道光照,阴影材质通常不需要这段光照计算。不要把只允许 Standard 的 csm_Emissive 复制进 Depth 或 Distance 源码,即使包在 #ifdef 中也可能触发字符串级材质检查。

2. csm_DepthAlpha 实际替换了什么

名字容易让人理解成“给深度通道设置透明度”,但应直接核对版本映射。6.4.0 默认初值为 1,默认 patchMap 有三条:

寻找的输出语句 实际替换及问题
gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity ); alpha 改成 opacity * 1.0 - csm_DepthAlpha,不是 opacity * (1.0 - csm_DepthAlpha),也没有 discard
gl_FragColor = packDepthToRGBA( fragCoordZ ); 加入 < 1.0 时 discard,但输出误用了 packDepthToRGBA( dist )
gl_FragColor = packDepthToRGBA( dist ); 加入 < 1.0 时 discard,保留距离打包

第一条在 BasicDepthPacking 下只改颜色输出的 alpha。片元还活着时,深度写入并不会因为 alpha 小而自动消失。甚至 opacity = 1、csm_DepthAlpha = 1 会算出 alpha 0,不能把它当作稳定的透明阴影接口。

第二条在 RGBA 深度打包分支会引用 Depth shader 没有声明的 dist,导致编译错误。默认替换表又可能再次匹配新插入的 dist 行,但不会解决未定义变量。

第三条对应旧式距离打包;r185 的 distance shader 输出已经是:

gl_FragColor = vec4( dist, 0.0, 0.0, 1.0 );

所以默认锚点不存在,替换静默失效。r185 还存在其他深度打包分支,默认映射也没有完整覆盖。具体阴影配置会选择不同路径,不能把“某次没有编译错误”理解为所有阴影路径都有效。

这些是当前安装版本的实现问题,不是所有未来 CSM 版本的永久规则。升级后应重新核对本地 vanilla 实现、Three.js depth/distance ShaderLib 和阴影渲染器。

3. 稳定方案:在自定义 main 中共享 discard

溶解属于覆盖判定:保留的片元继续进入内置深度/距离输出,消失的片元直接 discard。这样不必改深度打包格式,也不需要让普通阴影贴图模拟半透明透射。

完整案例:examples/csm-shadow-dissolve.js。按 README 的运行方法将入口脚本指向该文件。

案例同时创建方向光和点光源,以覆盖 customDepthMaterial 与 customDistanceMaterial。核心结构如下:

const uniforms = { uDemoThreshold: { value: 0.5 } };
const vertexShader = `
  varying vec3 vDemoLocal;
  void main() {
    vDemoLocal = position;
  }
`;
const shadowFragment = `
  uniform float uDemoThreshold;
  varying vec3 vDemoLocal;
  void main() {
    float field = 0.5 + 0.5 * sin(vDemoLocal.x * 5.0) *
      sin(vDemoLocal.y * 6.0) * sin(vDemoLocal.z * 4.0);
    if (field < uDemoThreshold) discard;
  }
`;

这里 position 是物体局部位置,varying 将顶点值插值到片元。因为三个通道都从相同几何和模型位置出发,图案不会因为换到光源视角而漂移。若改用屏幕坐标 gl_FragCoord 生成图案,三个相机各有屏幕坐标,覆盖就不再一致。

场函数为:

f(p)=12+12sin⁡(5px)sin⁡(6py)sin⁡(4pz) f(p)=\frac12+\frac12\sin(5p_x)\sin(6p_y)\sin(4p_z)

它的范围在 [0, 1]。阈值低于 0 时全部保留,高于 1 时全部丢弃。频率越高,孔洞越碎,阴影贴图分辨率不足时越容易出现闪烁。此公式是便于观察的周期场,不是高质量噪声。

const depth = new CustomShaderMaterial({
  baseMaterial: THREE.MeshDepthMaterial,
  vertexShader,
  fragmentShader: shadowFragment,
  uniforms,
  depthPacking: THREE.BasicDepthPacking,
  cacheKey: () => 'csm-dissolve-depth-v1'
});
const distance = new CustomShaderMaterial({
  baseMaterial: THREE.MeshDistanceMaterial,
  vertexShader,
  fragmentShader: shadowFragment,
  uniforms,
  cacheKey: () => 'csm-dissolve-distance-v1'
});
mesh.customDepthMaterial = depth;
mesh.customDistanceMaterial = distance;
mesh.castShadow = true;

完整脚本中的主材质也执行同一个 discard,并在阈值边缘写入 csm_Emissive。三份材质共享 { value } 容器,每帧只更新一次阈值;无需设置 needsUpdate。样例保持主材质不透明,因为保留区域就是实心表面,洞口靠 discard 表达。

CSM 自定义 main 在内置主体之前执行。这里不依赖尚未计算的 diffuseColor、normal 或 fragCoordZ,所以适合在这个位置裁切。若覆盖依赖内置 map、alphaMap 等结果,需要自己使用正确 UV 采样,或通过 patchMap 选择更晚的阶段。

4. 若同时有顶点波动

把同一变形顶点源码交给三个材质即可同步位置:

uniform float uDemoTime;
varying vec3 vDemoLocal;
void main() {
  vDemoLocal = position;
  csm_Position.y += 0.12 * sin(position.x * 3.0 + uDemoTime);
}

vDemoLocal = position 表示图案附着在未变形表面;若用变形后的 csm_Position,图案会按变形空间重新计算。两种选择都可以,但三个通道必须一致。对于水面主材质还应修正 csm_Normal;Depth/Distance 不必加入仅用于光照的片元变量。

阴影一致仍不解决 CPU 的 Raycaster、包围盒、视锥剔除:GPU 变形没有修改 BufferGeometry。参阅GPU 变形与 CPU 场景数据。

5. 验证不能只看主画面

  1. 阈值小于 0:完整物体与完整阴影。
  2. 阈值处于中间:孔洞与阴影缺口对应,方向光与点光源都参与检查。
  3. 阈值大于 1:物体与投射阴影全部消失。
  4. 暂时移除 customDepthMaterial,再恢复;对比方向光阴影,确认配套材质确实被使用。
  5. 只开启点光源重复检查,确认 customDistanceMaterial 的路径有效。

如果 renderer.shadowMap.autoUpdate = false,改变阈值后还需触发阴影更新;默认自动更新的例子不需要这一步。边缘失真也可能来自 shadow map 分辨率、bias、normalBias 和光源相机范围,不能把全部问题归因于公式。

运行外壳会收集 Mesh 的 customDepthMaterial、customDistanceMaterial 和灯光阴影资源并释放。复杂 uniform 纹理数组或外部共享资源要由自己的资源管理层处理,避免遗漏或提前释放。

练习(暂不附答案)

  1. 在完整案例中分别关闭方向光和点光源,确认两种阴影通道都正确执行相同溶解。
  2. 加入共享时间控制的顶点波动,并解释使用原始局部坐标和变形后坐标生成场函数的区别。
  3. 根据当前版本源码说明为什么 BasicDepthPacking 中只改变输出 alpha 不能让阴影自动消失。
  4. 将程序场替换成遮罩纹理,列出主通道和阴影通道必须共享的纹理、UV、阈值及采样约定。