☰
HDRP自定义Shader获取阴影:Custom Pass烘焙RenderTexture全攻略
2026/10/4 1:11:16 网站建设 项目流程

有段时间我在HDRP里做一套地形交互效果,具体来说是想让草地贴花根据阴影方向变暗,同时还能让水面出现阴影遮罩。项目一开始用的是Unity内置渲染管线,写起来很顺手,GetShadowAttenuation一调就完事。后来切到HDRP,再想在自定义Shader里拿一份阴影信息时,我直接被卡了整整两天——不是没有api,而是HDRP的阴影体系跟内置管线完全是两套逻辑,官方Shader里封得严严实实,正常路径下根本不让你“单独把阴影值拿出来用”。

最后把我救回来的是Custom Pass。我用一个全屏Custom Pass在指定时机把主光阴影信息烤进一张RenderTexture,再在任意Shader里采样这张RT,问题就彻底解决了。这篇文章就是把当时做的整套方案复盘一遍,包括Custom Pass怎么配、Shader怎么写、Cascade偏移为什么要那么算、以及我排过的几个让人抓狂的坑。如果你也在HDRP里遇到类似需求——比如给透明物体做阴影接收、做贴花投影、做雪地脚印的暗部叠加,希望这篇能帮你少走弯路。

1. 先把问题说清楚:HDRP里为什么“取阴影”不是一行代码的事

1.1 内置管线和HDRP的阴影获取差异

在Unity内置渲染管线下,只要你写过Surface Shader或者手写Pass,基本都绕不开SHADOW_COORDS和TRANSFER_SHADOW这一套宏。这套东西帮你干了几乎所有事:在顶点阶段把世界坐标变换到阴影贴图空间,在片元阶段做一次深度比较,最终输出一个0到1之间的shadow值。你甚至可以不关心阴影贴图长什么样、里面存的什么格式,因为引擎全都封装好了。

但是HDRP里这套逻辑彻底变了。HDRP的Lit Shader走的是物理光照管线的完整链路,阴影不是简单的一个二值遮罩,而是包含了级联阴影(Cascade Shadow)、屏幕空间阴影(Screen Space Shadow)、接触阴影(Contact Shadow)等多个层级的汇总结果。每个光源的阴影贡献要通过一系列复杂的函数计算,例如EvaluateShadow、GetMainLightShadowmap等,这些函数依赖大量的全局Shader变量——包括阴影图集的缩放偏移、Cascade球体参数、阴影质量设置等。官方Lit Shader里你能改,但你要是想写一个自己的Shader,把这些逻辑单独抽出来用,门槛一下就上来了。

我自己第一次尝试直接在自定义Shader里“抄”HDRP的阴影代码时,发现拷过来的代码有一堆依赖:纹理声明、采样器、各种_CascadeShadowSplitSpheres、_ShadowmapAtlas之类的变量,稍微漏一个就编译报错。而且不同HDRP版本这些变量名还有变化,网上抄来的老代码在12.x下根本跑不起来。

1.2 Custom Pass在这套体系里的位置

Custom Pass是HDRP提供的一套渲染注入机制,可以理解成在HDRP渲染流程的指定节点上,插入你自己的一段渲染逻辑。HDRP内置了完整的渲染帧结构,从深度预通道、不透明物体、天空、透明物体到后处理,每一段之间都有明确的事件点。Custom Pass可以挂在某个事件点前后,执行自己的渲染命令。

按照执行位置,Custom Pass分为三类:Before Rendering、After Opaque Depth And Normal、Before Transparent、Before Post Process、After Post Process等。而按照渲染方式,又分Full Screen Pass、Object Pass和DrawRenderers Pass等。我这次用的是Full Screen Custom Pass,它可以看作是一段对全屏像素执行的Shader计算,很适合做“把某个屏幕空间信息提取出来另存一份”这种工作。

HDRP为什么要提供Custom Pass?因为真实项目里总有些需求是PBR光照模型覆盖不了的,比如体积光边缘描边、传送门扭曲、卡通渲染的描边、地图遮挡高亮等。HDRP把这些“旁路”需求交给了Custom Pass,你可以在不修改官方Shader的情况下,灵活地插入自己的渲染步骤。而要获取阴影,Custom Pass同样是关键手段——它帮助你在特定时机、把HDRP内置阴影系统已经生成好的阴影信息“抄一份”出来,供后续自定义Shader使用。

注意:Custom Pass并不是一个轻量功能,它会在每帧渲染中插入一次GPU工作量。如果只是少数几个物体需要阴影信息,建议把Custom Pass的RenderTarget分辨率调低,或者考虑直接用Object Pass只处理部分物体,避免全屏开销。

2. 方案选型:四条路我都摸过,最终选了Custom Pass

2.1 四条路线横向对比

我这里把HDRP里获取阴影的方式整理成了四种,各自适用场景差异很大。

第一种,直接在自定义Shader里调用HDRP的高层阴影接口,尝试和Lit Shader走同一条路径。这个方案理论上是最高保真的,因为阴影结果和官方效果完全一致。问题在于接口的内部依赖链很长,你需要在Shader里正确声明一堆全局纹理和变量,代码量非常大,还容易因为版本差异翻车。

第二种,通过屏幕空间阴影纹理(SSS, Screen Space Shadow)获取。HDRP在开启Screen Space Shadows后,会在某个阶段生成一张全屏的阴影遮罩纹理,主光阴影信息就在里面。如果只是想在当前屏幕范围内获取主光阴影,直接采样这张纹理就够了,代码量极小。缺点也很明显:它只覆盖屏幕内可见像素,而且依赖HDRP的Screen Space Shadows开关,如果项目里没开启这个特性就用不了。

第三种,手动采样主光的Shadow Map,自己计算Cascade偏移。这种方式可以把任意世界坐标转换到阴影图集UV,精度和灵活性最高,而且不局限于屏幕空间,比如可以采样场景中某个具体位置的阴影。但需要理解HDRP的Cascade阴影图集布局和偏移公式,代码量居中,一旦写对了复用性极强。

第四种,用Custom Pass + RenderTexture把阴影信息烘焙出来,再传给后续Shader使用。这实际上不是第四种独立方案,而是承载第二、第三种方案的容器。我最终的正式选择就是第三种思路放在Custom Pass里跑。

做个表格来对比:

方案代码量精度适用场景坑点
高层阴影接口大与Lit完全一致需要完全一致阴影效果的复杂Shader依赖太深,版本差异明显
采样SSS纹理极小屏幕空间精度屏幕特效、全屏混合、后处理需要开启SSS,且无法获取屏幕外阴影
手动采样Shadow Map中最高,可任意取点贴花、草地交互、阴影查询Cascade偏移一旦写错就会错位
Custom Pass烘焙RT小到中取决于选用的内部算法多Shader共享阴影信息需要处理RT时序和全局变量传递

2.2 为什么最终决定走Custom Pass

我先试的是第一种方案,结果在拼接HDRP阴影函数时栽了跟头。当时从Lit.hlsl一路追到Shadows.hlsl,发现中间绕了七八层函数,再加上各种宏定义控制,代码复制过来根本没法直接用。后来我把目标降低——我不需要逐像素完整复刻Lit的阴影链路,只需要拿到主光的阴影衰减值,用于自己做颜色混合。

那就只剩一个思路:在某个合适的时机,把HDRP已经计算好的阴影信息抓取出来。到了这一步,选择Custom Pass就成了自然而然的事情。Custom Pass的好处在于:它本身就在HDRP渲染流程内执行,所有阴影相关的纹理和变量在该阶段都是可用的,不存在“这个纹理还没生成”的时序问题。你只需要在Pass内部正确地引用它们,然后把结果写到你自己的RenderTexture里,之后别的Shader就能通过全局纹理访问到这份数据。

最终我选定的方案是:Full Screen Custom Pass + 手动采样_MainLightShadowmapTexture。这个组合的好处是精度高、不依赖屏幕空间阴影开关,而且阴影信息以RenderTexture的形式全局共享,项目里多个Shader都可以拿来用。如果你的需求比较简单,只在全屏后处理里用一次阴影,也可以直接用第二个方案(采样SSS纹理),代码能省掉一半。我这里会把两种方式都写出来,你按项目场景挑。

2.3 版本与工具:Unity 2021.3 + HDRP 12.x

正式写代码之前,先把版本环境说清楚。我的项目跑在Unity 2021.3 LTS上,HDRP版本是12.x。不同HDRP版本里阴影相关的全局变量名确实有差异,比如在某些版本中_ScreenSpaceShadowMapTexture是存在的,但在另一些版本中可能改成了_ShadowmapAtlas加索引的方式。所以下面代码里凡是涉及HDRP内部纹理的地方,建议你根据自己的版本在FrameDebugger里看一眼实际名字,稍作调整就能跑通。

附带说一句,Shader Graph也可以实现一部分阴影处理,它内置了Sample Shadowmap等节点,但节点级别能拿到的控制力有限。如果你需要精细控制Cascade偏移或者想拿到原始Shadow Map数据,还是手写HLSL更靠谱。我的经验是:Shader Graph适合处理常规的PBR材质效果,但像这种“提取阴影数据并跨Shader复用”的活,手写Shader配合Custom Pass上限更高。

3. 实操过程:从Custom Pass Volume到把阴影写进RenderTexture

3.1 第一步:创建Custom Pass Volume并配置注入点

在HDRP的项目里,你要在场景中创建一个空物体,挂上Custom Pass Volume组件。这一步很关键,因为它决定了Custom Pass脚本在哪个渲染阶段被执行。

组件面板上最重要的两个字段是Mode和Injection Point。Mode选择Global,这样Custom Pass对整个相机生效,而不是限定在一个Volume范围内。Injection Point需要根据你要抓取的阴影信息选择。如果选择After Opaque Depth And Normal,那么此时不透明物体的深度和法线已经写好了,阴影相关纹理也可用;如果选择Before Transparent,则不透明物体和阴影都已经渲染完毕,适合抓取完整的阴影状态。

我当时选的是After Opaque Depth And Normal。这个阶段主光的Shadow Map已经生成,SSS纹理如果开启也会可用,而且不透明物体的深度已经写入,后续做位置重建不会出错。如果你的目标是抓取包含透明物体参与计算后的阴影状态,就要选Before Transparent或更晚的节点。

注意:Custom Pass Volume组件不会自动创建对应的Custom Pass实例,你需要配合代码里的CustomPass子类来挂载Pass。一个Volume上可以挂多个Custom Pass,通过Add Custom Pass按钮添加,并指定一个CustomPass类型的自定义脚本实例。

3.2 第二步:C#侧编写Custom Pass脚本

接下来是核心部分:写一个继承CustomPass的脚本,在Execute方法里把你的全屏Shader渲染到一张RenderTexture上。我直接贴我当时用的脚本,细节都在注释里。

using UnityEngine; using UnityEngine.Rendering.HighDefinition; using UnityEngine.Rendering; public class ShadowToTexturePass : CustomPass { [Header("Settings")] public Material shadowMaterial; // 用于全屏采样的Shader材质 public int rtResolution = 1024; // 阴影RT的分辨率,建议和屏幕分辨率呈比例 private RenderTexture m_ShadowRT; private const string k_ShadowRTName = "_MyShadowRT"; protected override void Execute(CustomPassContext ctx) { if (shadowMaterial == null) { Debug.LogWarning("ShadowToTexturePass: shadowMaterial is null, skip."); return; } // 延迟创建RT,避免每次Execute都new if (m_ShadowRT == null) { m_ShadowRT = new RenderTexture(rtResolution, rtResolution, 0, RenderTextureFormat.RFloat); m_ShadowRT.name = "MyShadowRT"; m_ShadowRT.filterMode = FilterMode.Bilinear; } // 关键:把RT注册成全局纹理,这样之后任何Shader都能用 _MyShadowRT 采样 ctx.cmd.SetGlobalTexture(k_ShadowRTName, m_ShadowRT); // 用目标材质把全屏Shader绘制到 m_ShadowRT 上 // 第4个参数0表示subShader pass index CoreUtils.DrawFullScreen(ctx.cmd, shadowMaterial, m_ShadowRT, null, 0); } }

这里有个细节:CoreUtils.DrawFullScreen会设置一个全屏三角形作为几何体,所以你的Shader不需要写MeshRenderer,只需要一个能处理全屏片的Fragment Shader。另外RenderTextureFormat.RFloat用来存阴影值足够,如果你需要后面在Shader里直接把这个值当颜色用,也可以用RGHalf或者ARGB32,但那样会浪费带宽和精度。

然后在Custom Pass Volume组件上添加这个自定义Pass,把shader材质拖进去,分辨率按需调整。我项目里用的rtResolution是屏幕宽度的四分之一左右,这样既保证了采样精度,又不会让GPU带宽爆炸。

3.3 第三步:Shader侧核心——手动采样Shadow Map

这一步是整个方案最关键的环节。Custom Pass里执行的全屏Shader需要把当前像素的世界坐标反算出来,再把它转换到主光阴影图集的采样坐标,最后比较Shadow Map深度得到阴影值。下面是一个能跑的FullScreen Shader的完整代码,我用的HDRP版本是12.x,如果你版本不一样,注意看_MainLightShadowmapTexture等变量名是否一致。

Shader "Custom/ShadowToTexture" { SubShader { Tags { "RenderPipeline" = "HDRP" } Pass { Name "ShadowToTexture" Cull Off ZWrite Off ZTest Always HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl" #include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl" #include "Packages/com.unity.render-pipelines.high-definition/Runtime/Lighting/Shadow/Shadows.hlsl" struct Attributes { float4 positionCS : POSITION; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings Vert(Attributes input) { Varyings output; // FullScreen三角形的裁剪坐标直接透传 output.positionCS = input.positionCS; output.uv = input.positionCS.xy * 0.5 + 0.5; #if UNITY_UV_STARTS_AT_TOP output.uv.y = 1.0 - output.uv.y; #endif return output; } // 声明HDRP主光阴影贴图 TEXTURE2D(_MainLightShadowmapTexture); SAMPLER(sampler_MainLightShadowmapTexture); float4 Frag(Varyings input) : SV_Target { // 从当前像素的uv和深度重建世界坐标 float depth = LoadCameraDepth(input.positionCS.xy); float3 positionWS = ComputeWorldSpacePosition(input.uv, depth, UNITY_MATRIX_I_VP); // 使用HDRP内置函数计算cascade shadow坐标 // 注意:normalWS传0是因为阴影衰减通常不依赖法线 float4 shadowCoord = GetCascadeShadowCoord(positionWS, float3(0, 0, 0)); // 采样Shadow Map float shadow = SAMPLE_TEXTURE2D(_MainLightShadowmapTexture, sampler_MainLightShadowmapTexture, shadowCoord.xy).r; // 视平台差异做校正,shadowCoord.z存储的是偏差后的深度 // 这里用比较简单的近似:shadow值本身已经是0~1衰减 return float4(shadow.xxx, 1.0); } ENDHLSL } } }

这段Shader做了三件事:用屏幕UV和深度重建世界坐标,然后通过GetCascadeShadowCoord得到阴影图集采样坐标,最后用SAMPLE_TEXTURE2D采样主光阴影贴图。如果你手头HDRP版本里没有GetCascadeShadowCoord这个函数,你需要手动实现Cascade偏移,我后面在第4节详细讲。

有些人可能会问:为什么不让Lit Shader直接算好阴影,我复制一份?因为你不是每帧都在写一个和Lit完全一致的Shader,反而想在不同Shader里拿到阴影数据做自己的逻辑。Custom Pass相当于在Lit Shader之外开辟了一条旁路,你用自己的材质、自己的全屏Shader,把阴影信息“提”出来。这种做法虽然多了一步RT绘制,但胜在干净、可控、可复用。

3.4 第三步备选:采样Screen Space Shadow纹理

如果你的项目已经开了HDRP的Screen Space Shadows选项,其实可以省掉手动求Cascade的麻烦,直接采样_ScreenSpaceShadowMapTexture。这个纹理在HDRP内部已经存好了屏幕空间的主光阴影遮罩,你只需要在Custom Pass的Shader里按屏幕UV采样即可。

Shader部分只需要把Frag简化成:

TEXTURE2D(_ScreenSpaceShadowMapTexture); SAMPLER(sampler_ScreenSpaceShadowMapTexture); float4 Frag(Varyings input) : SV_Target { float shadow = SAMPLE_TEXTURE2D(_ScreenSpaceShadowMapTexture, sampler_ScreenSpaceShadowMapTexture, input.uv).r; return float4(shadow.xxx, 1.0); }

这个方案的优点是省掉了世界坐标重建和Cascade偏移计算,代码量直接少一半。缺点也很明显:它只有屏幕空间的信息,无法拿到屏幕外物体的阴影;而且如果你没有开启Screen Space Shadows,这个纹理就不存在,采样结果会是默认值。对于我那种地形贴花和草地的需求,屏幕空间阴影勉强够用,但遇到需要跨物体法线方向做精细混合的情况,Shadow Map方案明显更稳。

版本提示:不同HDRP版本中_ScreenSpaceShadowMapTexture的行为有差异。有的版本它只存主光的阴影,有的版本会把多光源阴影打包到不同通道。建议先用FrameDebugger看一眼实际纹理内容和通道,再决定采r通道还是a通道。

3.5 第四步:在任意Shader里使用这张阴影RT

到了这一步,阴影已经被烤到了_MyShadowRT这张全局纹理里。任何Shader想用,只需要声明全局纹理然后采样即可。比如我要做草地变色,就在草地Shader里这样写:

TEXTURE2D(_MyShadowRT); SAMPLER(sampler_MyShadowRT); float shadowFactor = SAMPLE_TEXTURE2D(_MyShadowRT, sampler_MyShadowRT, uv).r; // 用shadowFactor控制草地的暗部渐变

注意这里的uv要用屏幕坐标转换到0~1范围的uv。如果是世界空间投影的贴花,则需要把世界坐标先换算到屏幕uv,再用Load或Sample访问RT。HDRP中可以通过ComputeScreenPos完成转换,或者直接在顶点着色器里把positionCS.xy / _ScreenSize.xy作为uv传出。

我项目里是让阴影RT同时供三个Shader使用:草地交互Shader用阴影值来压暗草地颜色,水面Shader用阴影值做倒影遮罩,贴花Shader用阴影值决定贴花亮部暗部的权重,全程只用了一个Custom Pass,性能和效果都满意。这正是把阴影数据烘焙到RT的最大价值——一次计算,多处复现。

4. 调优与验证:Cascade偏移原理、调试手段、性能取舍

4.1 Cascade偏移计算的“为什么”

很多人卡在最开始就是不明白GetCascadeShadowCoord里头到底干了什么,用了什么数学。要理解它,先要理解HDRP的影子图集。

HDRP的主光阴影不是一张简单贴图,而是一张包含了多级Cascade的图集。假设开启了4级Cascade,那么Shadow Atlas会被分成4个区域,离相机越近的Cascade区域越大、分辨率越高。每个Cascade都有自己的一套世界坐标到阴影贴图空间的变换矩阵和偏移。所以当你有一个世界坐标,你先要判断这个点落在哪个Cascade范围内,然后用对应的offset和scale去采样正确的图集区域。

HDRP判断某个点落在哪个Cascade时,使用的是包围球体判定。它把每一级Cascade的体积抽象为一个球体,如果世界坐标在球体范围内,说明该点使用了这一级Cascade。_CascadeShadowSplitSpheres存了球心坐标和球半径相关参数,CascadeShadowSplitSphereRadii存了各级球体的半径平方,通过点积和距离比较就能得出cascade索引。

判断好cascade索引后,再用该级Cascade的偏移_CascadeShadowOffset0~3和缩放比例,把世界坐标映射到Shadow Atlas的对应区域。偏移计算的关键是让每个Cascade的采样UV落到图集的正确块里,否则就会出现“阴影贴图被切成好几块,但采样采错块”的错位问题。

所以,如果你要手写Cascade偏移,不能只写UV偏移,还要加上图集区域的scale。参考HDRP源码中的简化逻辑,大致是:

int cascadeIndex = GetCascadeIndex(positionWS); float4 shadowCoord = mul(_MainLightWorldToShadow[cascadeIndex], float4(positionWS, 1.0)); shadowCoord.xy = shadowCoord.xy * _CascadeShadowOffset[cascadeIndex].w + _CascadeShadowOffset[cascadeIndex].xy;

这段写法不是所有版本都通用,但思路是对的。实际项目里我更推荐直接使用HDRP内置的GetCascadeShadowCoord,除非你确实需要自定义Cascade数量或者做非标准阴影变换。手写的好处是你能在Shader里把中间变量抠出来调试,坏处是容易把自己绕晕。

4.2 调试手段:FalseColor、逐级输出、错位排查

搞CG调试最忌讳“打开Game视图瞎调”。我在写这个Custom Pass时用过几个很有效的调试手段,逐个分享。

第一个手段是分通道显示。把Custom Pass的Shader改成输出cascade索引而不是阴影值,比如在Frag里返回float4(cascadeIndex / 3.0, 0, 0, 1),这样你在屏幕上就能看到哪块区域用的是哪级Cascade。如果Cascade没有明显分层,或者分层位置不对,说明你的Cascade判定参数有问题。

第二个手段是直接显示阴影RT的原始值。把Frag里最后的结果原样输出,不经过任何混合、调整。正常情况下你应该看到:被遮挡处是接近0的黑色,亮处是接近1的白色,边缘有柔和的半影过渡。如果你看到整屏都是0.5的灰色或者其他怪异颜色,大概率是你采样的纹理根本不对,或者屏幕UV翻转错误。

第三个手段是错位排查。如果阴影RT中的阴影形状是正确的,但位置偏移了半个屏幕,那基本可以断定是世界坐标重建时出了问题。这时候先把positionWS可视化输出,比如输出positionWS.y的某个范围,看屏幕上的亮度变化是否符合相机视角。如果世界坐标重建正确,再往下查Cascade偏移;如果重建都错了,先去检查深度纹理采样和UNITY_MATRIX_I_VP的使用方式。

经验之谈:很多时候“阴影错位”和“阴影闪烁”的根因都出在RT分辨率上。Custom Pass里如果RT分辨率比屏幕分辨率低很多,采样时会产生明显的锯齿和抖动。这属于精度不够而非算法错误,调整rtResolution即可。

4.3 性能与精度的取舍

Custom Pass本质上是在HDRP渲染流程中插入了一次额外的全屏Draw Call和一次RT写入,代价主要在于三块:RT本身的内存带宽、全屏Shader的计算量、以及后续其他Shader采样这张RT的带宽开销。

第一块,RT分辨率。我用rtResolution设为屏幕分辨率的四分之一,这个值在多数项目里够用,内存带宽也只有全屏的1/16。如果你要拿阴影做水面反射之类的精细效果,建议保持一半分辨率;如果只是做色调混合,四分之一完全够。另外记得把RT的filterMode设成Bilinear,能缓解低分辨率下的锯齿感。

第二块,全屏Shader的计算量。逐像素做世界坐标重建和Cascade偏移计算,在PC上是小意思,但移动端要注意。移动端GPU的ALU吞吐有限,建议把重建逻辑尽量简化,或者在顶点阶段预处理一部分计算。例如可以先做一张低分辨率的ShadowMask,再在目标Shader中用双线性采样插值,减少重复计算。

第三块,全局纹理生命周期的控制。我在脚本里偷懒直接用的RenderTexture私有字段,项目退出时没有手动释放。长线项目一定要在OnDisable或Cleanup里调用m_ShadowRT.Release(),否则编辑器反复Enter Play Mode时很容易出现内存上涨。另外,如果多个相机共用同一个Custom Pass Volume,RT会被多个相机序列覆盖,建议要么用一个RT按需更新,要么根据ctx.camera为每个相机分配独立的RT。

5. 踩坑实录:这些问题我挨个排查过

这里把我在这套方案里踩过的坑整理成一张速查表,每个问题都是我实际遇到过并把原因查明白的。

现象可能原因解法
阴影RT整屏都是纯白色采样了错误纹理,或屏幕UV不对用FrameDebugger确认纹理名称和RT绑定;检查UV翻转
阴影RT整屏都是纯黑色深度重建失败,或Custom Pass注入点太早确认注入点在深度写入之后;在Shader中单独输出positionWS检查
阴影位置整体偏移世界坐标重建错误或Cascade偏移算错先显示positionWS验证重建;再检查GetCascadeShadowCoord
阴影边缘闪烁、锯齿RT分辨率太低或filterMode不是Bilinear提高rtResolution;把filterMode改为Bilinear
Scene视图正常,Game视图不对不同相机的全屏UV约定差异在Shader里做平台判断和相机类型判断,或统一用positionCS.xy计算UV
后处理开启后偏色Custom Pass注入点选在了后处理之后把注入点改到Before Post Process
移动端帧率下降明显全屏Shader计算量过大降低RT分辨率;把自定义Shader的逐像素计算简化
进Play Mode多次后内存上涨RenderTexture未释放在OnDisable中调用m_ShadowRT.Release()

5.1 关于“全屏蓝图”的问题排查

这个现象我可太熟了:代码跑起来一切正常,结果画面呈现出一片统一的蓝色调或者奇怪的渐变颜色。最开始我一直怀疑是Shader写错了,后来才发现问题出在物理相机和后处理。HDRP默认有Tone Mapping和Color Grading,如果你把Custom Pass的输出直接当成屏幕颜色,后处理会把那个灰度值当成普通颜色做映射,偏色就是这么来的。

解决方法是看你的应用场景。如果只是想拿阴影数据给后续Shader做计算,那根本不要把这个结果直接输出到屏幕上,写入RT之后,Camera的最终图像不会受影响,颜色映射问题就无从谈起。如果你确实需要可视化阴影遮罩,请把注入点放在After Post Process阶段,或者关闭该相机的后处理进行预览。

经验之谈:在Custom Pass里建立的RT命名建议加下划线前缀,例如_MyShadowRT,这是Unity全局Shader纹理的命名惯例,能避免和你自己材质的局部属性冲突。

5.2 关于Scene视图和Game视图表现不一致

有一次我发现,Custom Pass在Game视图能看到阴影RT的纹理内容,但切到Scene视图时全屏什么都没有。查了文档才知道,Custom Pass默认只作用于相机的渲染,而Scene视图使用的SceneView相机和Game视图的主相机是两个不同的渲染路径,注入点也可能不一样。部分Custom Pass的配置会对SceneView也生效,但如果你在Custom Pass里用了ctx.camera去做特定相机判断,两个视图的行为就会分道扬镳。

调试时最简单的方法是在Custom Pass Volume组件里勾选Show Debug或者在脚本里用Debug.Log输出ctx.camera.name,确认当前到底跑的哪个相机。如果Scene视图也需要显示,要么让Custom Pass对两个相机都执行,要么临时在组件上把Current Camera Offset设为0,让它以全局方式注入到所有相机。

5.3 关于阴影RT在目标Shader里“时灵时不灵”的问题

还有一种很恼火的状况:你在A材质里采样_MyShadowRT没问题,换到B材质里就变成默认值了。这种问题多半是全局纹理绑定时机不对。SetGlobalTexture在HDRP里绑定的是当前CommandBuffer的全局状态,如果执行Custom Pass的时机太早,目标Shader渲染时该纹理还未被设置,就会取到默认的白纹理或者上次残留的旧纹理。

我的做法是在每次Execute一开始就调用ctx.cmd.SetGlobalTexture,并且把脚本挂在Custom Pass Volume的Global Pass列表里,这样每帧都会重新绑定一次。只要你的Custom Pass执行节点在目标Shader渲染之前,覆盖关系就是严格有序的。如果还有问题,可以在目标Shader的Frag开头先输出一个固定值(比如0.5),确认Shader本身有没有编译问题和采样路径是否通畅。

写在最后:这套方案在我的项目里怎么演进的

回头再看这个需求,其实最关键的一步不是代码怎么写,而是想清楚HDRP的阴影系统是怎么组织的。你要拿阴影,不能把它当做一个孤立的值,而要理解它背后依赖的Shadow Atlas、Cascade层级、屏幕空间纹理等概念。Custom Pass只是一个容器,它解决了“在正确的时机用正确的Shader提取数据”这个工程问题。把这两件事打通之后,HDRP里获取阴影就不再是玄学,而是完全可以掌控的常规操作了。

我后来在这个基础组件上扩展了几个变体:一个变体是把Custom Pass输出叠加到水面的反射色上,模拟大雨天阴影里水面更暗的效果;另一个变体是额外采样一张低分辨率的ShadowMap,做角色脚下的实时阴影交互,用于演出场景里的灯光塑形。每次扩展都发现,只要底层把阴影数据管道搭好,上层创意几乎是想加就加。

如果你在复现过程中遇到HDRP版本差异导致的函数名或纹理名对不上,我的建议是先打开FrameDebugger,在里面搜“shadow”或者“Shadowmap”,看一下当前版本实际创建的纹理叫什么,再对照调整代码。这套排查思路比在网上盲目搜某个报错要高效得多。祝你能顺利跑通,如果这篇文章帮你少折腾两天,那就值得了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询