type
Post
status
Published
date
Jun 10, 2026
slug
srp04
summary
学习 Catlike Coding Custom SRP 的 Directional Shadows:第一部分生成方向光 Shadow Atlas,第二部分在 Lit Shader 中采样 Shadow Map 并接入光照衰减。
tags
Unity
SRP
图形学
category
学习笔记
icon
password
Directional Shadows
这篇是 Catlike Coding Custom SRP 系列第四章
Directional Shadows 的学习笔记。前三章里,我们已经把基础 SRP、Draw Call、Unlit/Lit Shader、多方向光和 BRDF 接起来了。到了这一章,重点就是渲染Shadow。这一章前两部分主要做两件事:
- 第一部分
Rendering Shadows:从方向光视角再渲染一次场景,把投影物的深度写进_DirectionalShadowAtlas。
- 第二部分
Sampling Shadows:在正常 Lit Pass 里,用当前片元的世界坐标去采样这张 shadow atlas,把结果乘进方向光的attenuation。
这两部分可以理解成一写一读。第一部分写 shadow map,第二部分读 shadow map。只有两边都完成,画面里才会真正出现方向光阴影。
1. Rendering Shadows:Shadow Map 渲染
阴影的基础思路是:先从光的视角看场景,记录每个位置上离光最近的表面深度;之后正常从相机视角渲染时,再判断当前片元在光的视角下是不是比这张深度图里记录的位置更远。如果更远,说明它前面有东西挡住了光。
详细可以看:
所以第一部分需要生成一张ShadowMap,也就是
_DirectionalShadowAtlas。Shadow Settings:给阴影加配置
先建一个
ShadowSettings:maxDistance 控制阴影最多算到多远。方向光理论上影响整个场景,但 shadow map 分辨率是有限的,如果范围太大,近处阴影就会变糊。atlasSize 控制方向光 shadow atlas 的大小。后面多个方向光不是每盏灯单独申请一张 RT,而是共享同一张 atlas,再按 tile 分区域写入。然后把这个设置放进
CustomRenderPipelineAsset,再一路传到 CustomRenderPipeline 和 CameraRenderer.Render。这和第二章里传 batching 开关、第三章里传光照数据的思路一样:管线资源负责暴露配置,运行时管线负责使用配置。Passing Along Settings:让 Cull 知道阴影距离
相机剔除时,需要把最大阴影距离交给 Unity:
告诉 Unity 的 culling 系统:阴影投射物只需要考虑这个距离以内的对象。
也就是说,
shadowDistance 会影响哪些 renderer 有资格进入 shadow caster 的裁剪结果。太远的东西就算在相机远裁剪面内,也不一定需要进入方向光阴影图。Shadows Class:把阴影逻辑从 Lighting 里拆出来
第三章已经有了
Lighting 类,负责从 CullingResults.visibleLights 里收集方向光,并把光照数组传给 shader。到了阴影这里,逻辑会明显复杂起来:要筛选有阴影的灯、申请 atlas、设置 render target、计算光空间矩阵、调用 DrawShadows、后面还要传 shadow matrix 给 shader。所以新建
Shadows 类,让 Lighting 持有它:Shadows.Setup 保存三个东西:这里的
context 用来执行 CommandBuffer 和提交 shadow draw,cullingResults 用来查询可见灯和投影物,settings 用来决定 shadow distance、atlas size 等参数。这个结构和前面
CameraRenderer、Lighting 的拆分类似:功能开始变复杂时,就把一类状态和命令收束到单独的类里,避免所有逻辑都堆在 CameraRenderer.Render 里。Lights with Shadows:记录需要投阴影的方向光
不是所有方向光都需要渲染 shadow map。用
ReserveDirectionalShadows 筛选并登记:这一段的判断条件很重要:
light.shadows != LightShadows.None:灯光本身开启了阴影。
light.shadowStrength > 0f:阴影强度不是 0。
GetShadowCasterBounds:Unity 确认这盏灯范围内确实有投影物。
shadowedDirLightCount < maxShadowedDirectionalLightCount:没有超过当前管线支持的最大阴影方向光数量。
这里记录的是
visibleLightIndex。它是 Unity cullingResults.visibleLights 里的下标,后面的 ShadowDrawingSettings 和 ComputeDirectionalShadowMatricesAndCullingPrimitives 都要用这个 index。Rendering:创建 Directional Shadow Atlas
真正渲染阴影从
Shadows.Render 开始:如果没有任何方向光需要阴影,也会创建一张
1x1 的 shadow map。这个 dummy texture 是为了保证 shader 里后面采样 _DirectionalShadowAtlas 时,总是有一个合法资源可以绑定。如果有阴影方向光,就申请真正的 atlas:
这里用的是
RenderTextureFormat.Shadowmap,不是普通颜色 RT。它的用途是存深度。接着把当前 render target 切到 shadow atlas:
这一步很关键。切换 render target 之后,后面的 shadow draw 就不再画到屏幕或相机颜色缓冲,而是写进
_DirectionalShadowAtlas。也正因为这样,
Lighting.Setup 要在 CameraRenderer.Setup 之前执行。否则如果先设置了相机 render target,又在 Shadows 里切到 shadow atlas,后面的正常物体可能会继续画到 shadow atlas 里。Directional Light Shadows:从方向光视角画深度
方向光没有真实位置,只有方向。所以不能像点光源那样直接从灯的位置看场景。Unity 提供了:
它会根据方向光和当前相机裁剪结果,计算这次 shadow map 渲染需要的 view matrix、projection matrix 和 split data。
然后设置绘制 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 表示不写颜色。ShadowCasterPassVertex 还是把对象空间位置变换到裁剪空间:因为前面已经调用过
SetViewProjectionMatrices(viewMatrix, projMatrix),所以这里的 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_SHADOW 和 SAMPLER_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 acne:当前阶段能看到问题是正常的
做到第二部分后,画面里通常会出现很多问题,比如条纹、自阴影、锯齿、接触处偏移。这些不是这一节马上解决的内容。
现在的重点是先把主链路跑通:
至于 shadow acne、bias、normal bias、PCF、cascade、fade,都是后面几部分继续补的。
第二部分梳理:Shadow Sampling 数据流
第二部分的目标是把第一部分生成的
_DirectionalShadowAtlas 用起来。核心是当前片元的 world position 要通过 shadow matrix 转成 shadow tile space,然后用 shadow compare sampler 查询它是否被挡光。这里可以记住一条线:
第一部分是“从光看场景,写深度”;第二部分是“从相机看场景,查这张深度”。两边合起来,方向光阴影才真正成立。
总结
这一章前两部分把方向光阴影的主链路接上了。
第一部分
Rendering Shadows 的重点是生成 _DirectionalShadowAtlas。它从 ShadowSettings 开始,把阴影距离交给 culling,然后在 Lighting 遍历可见方向光时登记需要阴影的灯。真正渲染时,Shadows 创建 shadow atlas,切换 render target,计算方向光的 view/projection,设置 viewport,并通过 context.DrawShadows 调用物体的 ShadowCaster Pass 写入深度。第二部分
Sampling Shadows 的重点是让 Lit Shader 读取这张 atlas。CPU 端要把每个 shadow tile 对应的世界到 atlas 矩阵传给 shader,同时给方向光传 shadowStrength 和 tileIndex。Shader 端用当前片元的 world position 乘 _DirectionalShadowMatrices 得到 positionSTS,再采样 _DirectionalShadowAtlas,最终把结果作为 light.attenuation 乘进 IncomingLight。这时阴影效果已经能出现,但质量还很原始。后面还需要继续处理 cascade、shadow distance fade、bias、normal bias、PCF filtering 等问题。当前阶段先不要急着追求阴影好看,重点是把“写 shadow map”和“读 shadow map”这条数据链路理解清楚。

