Loading
S7ARRY_SPACE
0%
INITIALIZING
Album Cover
焚蝶
铁痕电台
0:00
Xiaohongshu
从零开始的SRP学习(四)
DOC_ID // 37afd5ONLINE

从零开始的SRP学习(四)

2026-6-10
UPDATED: 2026-6-14
学习笔记
8364 CHARS
#UNITY#SRP#图形学
S7ARRY
type
Post
status
Published
date
Jun 10, 2026
slug
srp04
summary
Catlike Coding Custom SRP 第四章 Directional Shadows 学习笔记,按 Shadow Map 渲染、Shadow Sampling、CSM、Shadow Quality、Transparency 五部分整理方向光阴影的数据流、级联阴影、PCF、bias、fade/blend、投射和接收阴影控制。
tags
Unity
SRP
图形学
category
学习笔记
icon
password

Directional Shadows

这篇是 Catlike Coding Custom SRP 系列第四章 Directional Shadows 的学习笔记。前三章里,我们已经把基础 SRP、Draw Call、Unlit/Lit Shader、多方向光和 BRDF 接起来了。到了这一章,重点就是渲染方向光阴影。
这一章比前几章长很多,因为阴影不是一个单独的 shader 效果,而是一整条数据链路:CPU 要先从光的视角渲染 shadow map,GPU 在正常 Lit Pass 里再采样这张图,后面还要处理 cascade、bias、PCF、过渡、投影和接收阴影开关。
可以先把整章理解成五件事:
  • 第一部分 Rendering Shadows:从方向光视角渲染场景,把投影物深度写进 _DirectionalShadowAtlas
  • 第二部分 Sampling Shadows:在 Lit Shader 里采样 shadow atlas,把阴影结果乘进方向光的 attenuation
  • 第三部分 Cascaded Shadow Maps:把相机视锥按距离切成多级 cascade,解决近处阴影精度不够的问题。
  • 第四部分 Shadow Quality:用 bias、normal bias、shadow pancaking、PCF、fade 和 cascade blend 改善阴影质量。
  • 第五部分 Transparency:让材质可以控制是否投射阴影、是否接收阴影,以及透明材质如何进入 ShadowCaster Pass。
这篇最重要的主线是:

1. Rendering Shadows:Shadow Map 渲染

阴影的基础思路是:先从光的视角看场景,记录每个位置上离光最近的表面深度;之后正常从相机视角渲染时,再判断当前片元在光的视角下是不是比这张深度图里记录的位置更远。如果更远,说明它前面有东西挡住了光。
所以第一部分不是直接让画面变暗,而是先生成一张 _DirectionalShadowAtlas。这张图里存的是深度,不是最终阴影颜色。

Shadow Settings:给阴影加配置

先建一个 ShadowSettings,把阴影相关参数从管线资源里暴露出来:
maxDistance 控制阴影最多计算到多远。方向光理论上影响整个场景,但 shadow map 分辨率是有限的,如果范围太大,近处阴影就会变糊。
atlasSize 控制方向光 shadow atlas 的大小。后面多个方向光和多个 cascade 不是每个都单独申请一张 RT,而是共享同一张 atlas,再按 tile 分区域写入。
然后把这个设置放进 CustomRenderPipelineAsset,再一路传到 CustomRenderPipelineCameraRenderer.RenderLighting.SetupShadows.Setup。这和前面章节里传 batching 开关、光照数据的思路一样:管线资源负责暴露配置,运行时管线负责使用配置。

Passing Along Settings:让 Cull 知道阴影距离

相机剔除时,需要把最大阴影距离交给 Unity:
这表示阴影投射物只需要考虑这个距离以内的对象。太远的东西即使在相机远裁剪面内,也不一定需要进入方向光阴影图。
这里要注意:shadowDistance 影响的是 shadow caster 的 culling,不是相机能看多远。相机能看到远处物体,但远处物体可以没有实时阴影。

Shadows Class:把阴影逻辑拆出来

第三章已经有了 Lighting 类,负责从 CullingResults.visibleLights 里收集方向光,并把光照数组传给 shader。到了阴影这里,逻辑会复杂很多:
所以教程新建了 Shadows 类,让 Lighting 持有它:
Shadows.Setup 保存三个核心对象:
context 用来执行 CommandBuffer 和提交 shadow draw,cullingResults 用来查询可见灯和投影物,settings 用来决定 shadow distance、atlas size 等参数。

Lights with Shadows:记录需要投阴影的方向光

不是所有方向光都需要渲染 shadow map。ReserveDirectionalShadows 会筛选并登记:
这一段判断条件很重要:
  • light.shadows != LightShadows.None:灯光本身开启了阴影。
  • light.shadowStrength > 0f:阴影强度不是 0。
  • GetShadowCasterBounds:Unity 确认这盏灯范围内确实有投影物。
  • shadowedDirLightCount < maxShadowedDirectionalLightCount:没有超过当前管线支持的最大阴影方向光数量。
这里记录的是 visibleLightIndex。它是 Unity cullingResults.visibleLights 里的下标,后面的 ShadowDrawingSettingsComputeDirectionalShadowMatricesAndCullingPrimitives 都要用这个 index。

Rendering:创建 Directional Shadow Atlas

真正渲染阴影从 Shadows.Render 开始:
如果没有任何方向光需要阴影,也会创建一张 1x1 的 dummy shadow map。这样 shader 里采样 _DirectionalShadowAtlas 时,总是有合法资源绑定。
如果有阴影方向光,就申请真正的 atlas:
这里用的是 RenderTextureFormat.Shadowmap,不是普通颜色 RT。它的用途是存深度。
接着切换当前 render target:
这一步很关键。切换 render target 后,后面的 shadow draw 就不再画到屏幕或相机颜色缓冲,而是写进 _DirectionalShadowAtlas

Directional Light Shadows:从方向光视角画深度

方向光没有真实位置,只有方向。Unity 提供了这个 API 帮我们算方向光阴影需要的 view/projection:
它会根据方向光和当前相机裁剪结果,计算这次 shadow map 渲染需要的 viewMatrixprojMatrixsplitData
然后设置绘制 shadow caster 时用的矩阵:
context.DrawShadows 会按照 ShadowDrawingSettings 找出需要绘制的投影物,然后使用它们 shader 里的 ShadowCaster Pass。
这一步和前几章的 context.DrawRenderers 有点像,但它不是画 CustomLit Pass,也不是画颜色,而是专门画 shadow caster 深度。

Shadow Caster Pass:只写深度,不写颜色

为了让物体能进入 shadow map,Lit Shader 需要增加一个 ShadowCaster Pass:
LightMode = "ShadowCaster" 是 Unity shadow draw 要找的 pass 名。ColorMask 0 表示不写颜色。
因为前面已经调用过 SetViewProjectionMatrices(viewMatrix, projMatrix),所以 ShadowCasterPassVertex 里的 TransformWorldToHClip 用到的 VP 矩阵就是方向光的 VP,而不是相机的 VP。
片元阶段还要处理 alpha clipping:
这一步是为了让树叶、栅栏这类 alpha clip 物体的阴影形状也正确。如果 ShadowCaster Pass 里不 clip,透明被裁掉的部分仍然会写进 shadow map,投出一整块实心阴影。

Multiple Lights:多个方向光共享 Atlas

第一版只支持一盏阴影方向光。后面教程扩展到最多四盏:
多个方向光不能都画满整张 atlas,否则后画的会覆盖前画的。所以要根据阴影方向光数量切 tile:
一盏灯用整张图,二到四盏灯切成 2x2。每盏灯渲染前设置 viewport:
这样每盏方向光只会写进自己的 tile。Frame Debugger 或 RenderDoc 里看到一张 atlas 被分成几块,就是这个逻辑的结果。
第一部分梳理:Shadow Map 绘制数据流
第一部分的目标是生成 _DirectionalShadowAtlas。这张图记录的是方向光视角下最近表面的深度,不是最终画面的阴影颜色。
可以记住这条线:
这一步结束后,画面还不会因为阴影变暗。它只是准备了第二部分需要读取的数据。

2. Sampling Shadows:在 Lit Shader 里读取 Shadow Map

第一部分只是写出了 _DirectionalShadowAtlas。如果 Lit Shader 不去采样它,最终画面还是没有阴影。
第二部分要做的事情是:正常从相机视角渲染物体时,把当前片元的世界坐标转换到 shadow atlas 的坐标里,和 shadow map 里记录的深度比较。如果当前点比 shadow map 记录的位置更远,就说明它被挡光。

Shadow Data:方向光要知道自己的阴影信息

第三章里每个方向光只有颜色和方向:
现在阴影会影响光照强度,所以 Light 里要增加 attenuation:
attenuation 表示这盏灯照到当前片元时被削弱了多少。没有阴影时是 1;完全在阴影里时接近 0;加了过滤和淡出后可能是 0 到 1 之间的值。
方向光数组也要多传一组 shadow data:
里面至少要包含两个信息:
  • shadowStrength:灯光面板里的 Shadow Strength。
  • tileIndex:这盏灯对应 atlas 里的第几个 shadow tile。
C# 端的 ReserveDirectionalShadows 因此不只是登记灯,还会返回 shadow data:
Shader 取方向光时,就能把 shadow data 一起带出来。

Shadow Matrices:从世界空间到 Shadow Tile Space

采样 shadow map 不能用屏幕 UV,因为 _DirectionalShadowAtlas 不是从相机视角生成的。它是从方向光视角生成的深度图。
所以 shader 需要一个矩阵,把当前片元的世界坐标变换到 shadow atlas 对应 tile 的坐标里。C# 端维护:
每次渲染某个方向光 shadow tile 后,把矩阵保存下来:
这里不是简单的 projMatrix * viewMatrix,还要通过 ConvertToAtlasMatrix 把坐标从裁剪空间转换到 atlas tile 空间。
这一步大致包含:
还要处理 reversed Z。Unity 在不同平台上深度方向不完全一样,所以代码里会判断 SystemInfo.usesReversedZBuffer
最后通过 command buffer 传给 shader:

Shadows.hlsl:封装 shadow atlas 采样

新增 Shadows.hlsl,把阴影相关资源和函数单独放起来:
这里用的是 TEXTURE2D_SHADOWSAMPLER_CMP,不是普通纹理采样器。Shadow sampler 会把传入的深度和 shadow map 里的深度做比较。
采样方向光阴影大概是:
positionSTS 可以理解成 Shadow Tile Space。它的 xy 是 atlas 里的位置,z 是当前片元在灯光视角下的深度。

Surface Position:片元要带世界坐标

第三章做 BRDF 时,Surface 已经有 normal、color、alpha、metallic、smoothness、viewDirection 等数据。现在阴影采样还需要当前片元的世界位置:
在 Lit pass 的 fragment 里,把顶点阶段传下来的 positionWS 放进去:
这一步和第三章算 view direction 的需求刚好接上。BRDF 需要世界位置来算观察方向,阴影也需要世界位置来投影到 light space。

Directional Shadow Attenuation:计算当前灯的阴影衰减

有了 surface position、shadow matrix 和 light shadow data,就能计算方向光阴影:
这段逻辑是:
  • 如果这盏灯没有阴影,直接返回 1。
  • 如果有阴影,把当前片元世界坐标乘 shadow matrix,得到 shadow tile space。
  • positionSTS 去采样 _DirectionalShadowAtlas
  • shadowStrength 在完全无阴影和采样结果之间插值。
shadowStrength 不是开关,而是控制阴影深浅。比如 shadow sample 是 0,strength 是 0.8,最后 attenuation 大概就是 0.2。

Apply Shadows to Lighting:把 attenuation 乘进入射光

第三章里入射光大概是:
第二部分后变成:
这说明阴影不是单独画一个黑色贴图盖上去,而是改变某一盏灯对当前片元的贡献。
如果有多盏方向光,每盏灯都会各自计算 attenuation。某个点可能被第一盏灯挡住,但仍然被第二盏灯照亮。最终结果还是所有方向光贡献的累加。
第二部分梳理:Shadow Sampling 数据流
第二部分的目标是把第一部分生成的 _DirectionalShadowAtlas 用起来。核心是当前片元的 world position 要通过 shadow matrix 转成 shadow tile space,然后用 shadow compare sampler 查询它是否被挡光。
可以记住这条线:
第一部分是“从光看场景,写深度”;第二部分是“从相机看场景,查这张深度”。两边合起来,方向光阴影才真正成立。

3. Cascaded Shadow Maps:级联阴影

做到前两部分后,阴影已经能出现,但很快会遇到一个问题:一张 shadow map 要覆盖相机前方很大范围,近处阴影会明显不够清晰。
方向光 shadow map 通常是正交投影。如果只用一张图覆盖整个阴影距离,那么这张图的像素会被分摊到很大的世界范围里。远处还好,近处人眼很敏感,就会看到锯齿。
CSM 的思路就是把相机视锥按距离切成几段:
每一级 cascade 都生成一张自己的 shadow map,放进 atlas 的一个 tile 里。采样时,shader 根据当前片元所在位置决定用哪一级 cascade。

Cascade Settings:配置级联数量和切分比例

ShadowSettings.Directional 里会加入 cascade 相关配置:
cascadeCount 控制级联数量,最多四级。cascadeRatio1/2/3 控制视锥按阴影距离切分的位置。
比如 cascadeCount = 4,ratio 大概是:
这里 ratio 是相对于 shadow distance 的比例。

Rendering Cascades:每一级都画一张 shadow map

之前每盏方向光只占一个 tile。加入 cascade 后,一盏方向光会占多个 tile:
然后在每盏灯内部再循环 cascade:
这段循环就是“每一级 cascade 生成一张 shadow map”的地方。i 是当前 cascade index。Unity 会根据 cascadeIndexcascadeCountratios 算出这一段视锥对应的方向光矩阵和 shadow caster 范围。

Cascade Culling Spheres:shader 里判断该用哪一级

CPU 画完每一级 cascade 后,还要把每一级 cascade 的范围传给 shader。Unity 给的是 splitData.cullingSphere
Shader 里判断当前世界坐标是否落在某个 sphere 里面:
为了避免 shader 里开方,C# 端会提前把半径平方存到 sphere.w。所以 shader 里比较的是 distanceSqr < sphere.w

Tile Index:方向光起始 tile 加上 cascadeIndex

加入 cascade 后,方向光的 shadow data 里存的不再是“唯一 tile”,而是这盏灯的 tile 起始位置。
Shader 取某盏方向光的阴影数据时:
也就是:
这样同一盏方向光的不同片元,可能会采样 atlas 中不同的 cascade tile。

Shadow Distance Fade:远处阴影逐渐消失

实时阴影不能无限远计算,所以需要 shadow distance。但如果超过距离就突然没有阴影,画面会很明显。
教程加入了 fade:
C# 端会传 _ShadowDistanceFade,shader 端根据当前片元到相机的距离算一个衰减值。最终不是让阴影直接断掉,而是让 shadowData.strength 逐渐变小。
这一步解决的是“阴影最远处突然消失”的问题。
第三部分梳理:CSM 数据流
CSM 的核心是:CPU 为每盏方向光的每一级 cascade 都画一张 shadow map;shader 根据当前片元位置选择 cascade,再采样对应 tile。
可以把它压缩成一句话:
你之前总结的理解是对的:CSM 就是在不同距离使用不同精度的 shadow map,解决单张 shadow map 近处不够细、远处又浪费的问题。

4. Shadow Quality:阴影质量修正

前面三部分解决的是“有没有阴影”和“近处是否清晰”。但实际画面里还会出现很多质量问题:自阴影条纹、阴影漂浮、边缘锯齿、cascade 接缝、远处突然消失等。
第四部分就是围绕这些问题补修正。

Shadow Acne:自阴影问题

Shadow acne 是阴影系统里最常见的问题之一。它的表现是物体表面出现很多错误的暗纹,好像自己把自己遮住了。
原因大概是:shadow map 里的深度精度有限,正常渲染时当前片元算出来的光空间深度,可能因为浮点误差比 shadow map 里记录的深度稍微大一点,于是被误判为在阴影里。
解决方式不是完全消除误差,而是加 bias,让比较时稍微偏一点。

Slope Scale Bias:渲染 shadow map 时的偏移

Unity 的 Light 上有 shadowBias。教程把它当作 slope scale bias 使用:
它发生在渲染 shadow map 的阶段。斜率越大的表面,越容易出现 acne,所以 slope scale bias 会根据斜率调整深度偏移。
这个 bias 太小,acne 还在;太大,阴影会和物体分离,出现 Peter Panning。

Normal Bias:采样 shadow map 时沿法线偏移

除了 slope scale bias,教程还使用 normal bias。这个偏移发生在采样阶段:
它的意思是:不要拿表面原位置去查 shadow map,而是沿表面法线稍微抬起来一点再查。这样能减少表面自己遮挡自己的概率。
但 normal bias 也不是越大越好。太大会让阴影脱离接触点,尤其在薄物体和接触阴影处很明显。

Shadow Pancaking:避免 caster 被 near plane 裁掉

方向光 shadow map 使用正交投影。某些投影物可能被光空间 near plane 裁掉,导致 shadow map 里缺失一部分阴影。
教程在 ShadowCasterPassVertex 里做 shadow pancaking:
可以粗略理解为:把超出 near plane 的 caster 深度压回 near plane 附近,让它仍然能写进 shadow map。这个处理主要是为方向光阴影服务的,不是普通相机渲染里常见的变换逻辑。

PCF Filtering:让阴影边缘变软

原始 shadow map 采样只比较一个点,所以结果很硬:
PCF 的做法是采样周围多个点,然后求平均。这样边缘会变成 0 到 1 之间的过渡,看起来更软,也能减轻锯齿。
教程加入了几种模式:
并用 shader keyword 控制:
C# 端根据设置开 keyword:
Shader 端通过 ShadowSamplingTent.hlsl 计算 tent filter 的采样位置和权重。PCF 越大,边缘越软,但采样次数也越多,性能更贵。

Filter Size 和 Normal Bias:采样范围变大后的修正

PCF 采样范围变大后,normal bias 也需要跟着变大,否则采样到周围点时仍然容易出现 acne。
教程在 C# 端计算每一级 cascade 的 texel size 和 filter size:
然后 shader 里根据当前 cascade 的 _CascadeData 放大 normal bias。
这里的重点是:normal bias 不应该是一个和 shadow map 分辨率无关的固定世界值。cascade 范围、tile size、PCF filter size 都会影响实际需要的偏移。

Reducing Cascade Culling Spheres:避免采样越界

PCF 会采样当前点周围一圈 texel。如果当前片元刚好在 cascade 边缘,采样范围可能越过当前 cascade 的有效区域,导致采到错误数据。
所以教程会根据 filter size 缩小 cascade culling sphere:
这一步的意思是:不要让片元在太靠近 cascade 边缘时还认为自己属于这个 cascade,给 PCF 留出安全边界。

Cascade Blend:隐藏级联切换接缝

不同 cascade 的 shadow map 精度不同,切换时可能出现明显接缝。教程加入 cascade fade 和 cascade blend。
最基础的 hard 模式就是直接切换,便宜但容易看到接缝。
Soft blend 会在接近 cascade 边缘时,同时采样当前 cascade 和下一级 cascade,然后 lerp:
Dither blend 则用屏幕空间噪声决定当前像素是否提前切到下一级 cascade。它比 soft 少一次采样,但会有颗粒感。
对应设置大概是:
Shader variant:
这里要注意:cascade blend 解决的是 cascade 之间的接缝,不是 shadow distance 最远处的消失。远处消失靠 distance fade,级联之间的过渡靠 cascade fade / blend。
第四部分梳理:阴影质量问题和对应修正
这一部分不是新增一条完全不同的数据流,而是在原来的 shadow map 写入和采样流程上修正各种误差。
这部分可以这样记:
这些修正都不是免费的。bias 太大有 Peter Panning,PCF 太大性能更贵,soft cascade blend 要多采样一次,dither 会引入噪声。所以阴影质量本质上一直是在稳定性、清晰度和性能之间取平衡。

5. Transparency:投射阴影和接收阴影控制

最后一部分处理材质和阴影之间的关系。前面我们已经能让物体投射阴影,也能让 Lit Shader 接收阴影。但实际材质需要更细的控制。
这里要区分两个概念:
一个物体可以投射阴影但不接收阴影,也可以不投射阴影但接收别人的阴影。这两个开关不是一回事。

Clipped Shadows:Alpha Clip 材质投影

第二章做过 alpha clipping,Lit Pass 里可以用 _CLIPPING 丢弃片元。但如果 ShadowCaster Pass 不做同样的裁剪,shadow map 里还是会写入完整几何,导致镂空材质投出实心阴影。
所以 ShadowCaster Pass 里也要采样 base map,并根据 cutoff clip:
教程后面不再直接复用 _CLIPPING,而是给阴影单独做 _SHADOWS_CLIP。这样材质可以正常显示为透明/裁剪,同时单独控制它的投影方式。

Dithered Shadows:透明材质的抖动投影

普通透明材质不适合直接把半透明颜色写进 shadow map,因为 shadow map 存的是深度,不是颜色。教程用 dither 的方式近似透明阴影。
做法是:根据 alpha 和屏幕空间 dither 值决定当前片元是否写入 shadow map:
这样 alpha 越低,越多片元被丢弃,shadow map 里留下的覆盖率越低。视觉上看起来就像半透明物体投出了较浅的阴影。
它不是物理准确的透明阴影,但对这条学习管线来说是一个实用近似。

Shadow Modes:材质控制投影方式

给材质加一个 _Shadows 属性,用来选择投影模式:
大致含义是:
对应 ShaderCaster Pass 里需要 keyword:
如果是 Off,材质的 ShadowCaster Pass 可以通过 Shader GUI 控制 ShadowCaster pass enabled,让它不参与 shadow map 绘制。
这里和前面 CustomShaderGUI 的预设思路一样:材质面板上的一个选项,背后要同步设置 property、keyword 和 pass 状态。

Receive Shadows:控制是否接收阴影

投射阴影是 ShadowCaster Pass 的事。接收阴影是 Lit Pass 的事。
教程最后给 Lit shader 加了一个接收阴影开关:
CustomLit Pass 里增加:
然后在 GetDirectionalShadowAttenuation 开头判断:
return 1.0 表示完全不被阴影削弱。也就是说,这个材质仍然可以被方向光照亮,但不会采样 shadow map。
这个功能常用于一些特殊材质,比如特效、UI、某些透明物体,或者为了风格化效果让某些对象不接收实时阴影。
第五部分梳理:Cast Shadows 和 Receive Shadows
这一部分主要是在材质层面控制阴影行为。投射阴影发生在 ShadowCaster Pass,接收阴影发生在 CustomLit Pass。
这部分要分清楚:
所以如果一个物体“有影子但自己不变暗”,通常是 Cast Shadows 开着、Receive Shadows 关着。反过来,如果一个物体“自己会被别人影子盖住,但不投影到别人身上”,就是 Cast Shadows 关着、Receive Shadows 开着。

总结

这一章的主线是:在自定义 SRP 里完整接入方向光阴影。
第一部分 Rendering Shadows 解决“阴影图从哪里来”。CPU 侧从可见方向光里筛选需要阴影的灯,创建 _DirectionalShadowAtlas,切换 render target,计算方向光视角矩阵,并通过 context.DrawShadows 调用物体的 ShadowCaster Pass,把深度写进 atlas。
第二部分 Sampling Shadows 解决“阴影图怎么用”。CPU 把每个 shadow tile 的 world-to-shadow 矩阵和方向光 shadow data 传给 shader;Lit Pass 用当前片元的 world position 变换到 shadow tile space,采样 shadow atlas,得到 shadow attenuation,并乘进 IncomingLight
第三部分 Cascaded Shadow Maps 解决“近处阴影精度不够”。一张 shadow map 覆盖整个阴影距离会让近处很糊,所以把相机视锥按距离切成多个 cascade,每一级各自生成一张 shadow map。Shader 根据 cascade culling sphere 判断当前片元属于哪一级,再采样对应 tile。
第四部分 Shadow Quality 解决“阴影看起来不稳定”。slope scale biasnormal bias 用来减少 shadow acne,shadow pancaking 避免 caster 被 light near plane 裁掉,PCF 让阴影边缘变软,distance fade 避免远处阴影突然消失,cascade blend 隐藏级联之间的接缝。
第五部分 Transparency 解决“材质怎么参与阴影”。ShadowCaster Pass 决定物体是否投射阴影,以及是正常投影、alpha clip 投影、dither 透明投影还是不投影;_RECEIVE_SHADOWS 决定 Lit Pass 是否采样 shadow map,也就是是否接收阴影。
方向光阴影可以拆成这条总链路:

参考

NAVIGATION // Related Articles
Loading...