首页 / 资讯中心 / 文章详情

URP自定义Shader阴影淡出:解决Shadow Distance硬切边界问题

URP自定义Shader阴影淡出:解决Shadow Distance硬切边界问题 ★ FEATURED ARTICLE
自定义 Shader 接入 URP 阴影绝大多数教程都会教你怎样拿 shadowCoord、怎样在 Fragment 里做 PCF 采样但很少有人提醒你当物体跑出 Shadow Distance 之外阴影会在那个距离上像刀切一样突然断掉。很早之前我调试某个跨平台系统的地表材质时就因为这个硬边界在地平线附近拉出一条肉眼可见的分界线折腾了好几天。后来翻 URP 的 Shadows.hlsl真正看懂UnityComputeShadowFadeDistance和UnityComputeShadowFade这两个函数之后那条分界线才被老老实实地按下去。这两个函数是 URP 给手写 Shader 准备的距离渐变工具名字很长做的事情其实很单一一个负责汇报“当前像素处在淡出区间的哪个位置”一个负责把淡出结果和阴影采样结果合并。把它们接到自己的光照计算链路里阴影在远离相机时就不是瞬间消失而是先变浅、再变软、最后融入环境光。这篇文章主要写给正在写 URP 自定义光照 Shader、被 Shadow Distance 裁剪线困扰的技术美术和图形程序也会顺带讲清楚它们背后那套距离归一化的数学逻辑以及我在实际项目里踩过的一些坑。1. 为什么要给阴影做渐变衰减1.1 Shadow Distance 裁剪线为什么难看Unity 渲染实时阴影时不会把场景里所有物体的阴影都画出来而是以相机为中心在 Shadow Distance 范围内生成阴影贴图。超过这个范围物体就不再被投影也没有阴影采样的意义。这个机制本身没问题省掉了大量无效绘制但它的边界处理非常生硬靠近 Shadow Distance 的物体上一帧还带着完整阴影再往后挪一点点阴影突然就没了。这种“硬切断”在户外场景里特别刺眼。比如一大片地面近处有建筑和植被的阴影远处地面却干干净净明暗交界直接横切过去。玩家把相机一拉远那条裁剪线就像贴图接缝一样暴露出来非常破坏画面连续性。更麻烦的是动态物体在边界附近来回移动时阴影会一帧出现一帧消失肉眼看起来就是在“闪”。这不是阴影贴图分辨率不够也不是 PCF 采样半径太小纯粹是距离边界造成的阶跃。想要解决它逻辑上很简单在距离边界之前把阴影强度做一个连续衰减让阴影先慢慢变淡等物体跨过边界时已经淡到看不见硬切也就无从谈起。这就是 Unity 里“阴影渐变衰减”或者说“Shadow Fade”要做的事。URP 内部的主光源阴影流程已经内置了这一步但如果你在写完全手动的光照 Shader绕过了 URP 的 Lit 模板就必须自己把这两个内置函数用起来。1.2 渐变衰减的本质是把阶跃换成斜坡按信号处理的思路看原生的 Shadow Distance 边界是一个阶跃函数距离小于阈值时阴影权重为 1距离大于阈值时权重直接跳为 0。渐变衰减要做的就是把这个阶跃替换成一个斜坡在某个距离开始权重以线性或者平滑曲线下降直到边界附近降为 0。UnityComputeShadowFadeDistance和UnityComputeShadowFade就是为这个斜坡服务的。前者把相机到像素的距离映射成一个 0 到 1 的进度值后者把这个进度值应用到阴影采样结果上。理解成“淡出进度 阴影值 最终阴影表现”会更直观。如果你以后要在引擎之外写类似的渲染代码也可以借鉴同一套思路先归一化距离再控权重最后和原始结果混合。这套逻辑并不依赖 Unity只是 Unity 给了你现成的名字和实现。2. 两个内置函数的职责拆解2.1 UnityComputeShadowFadeDistance算距离进度按我目前看到的 URP 常见实现这个函数大致可以还原成下面的逻辑real UnityComputeShadowFadeDistance(real3 positionWS, real receiveShadowStrength) { real distToCamera distance(_WorldSpaceCameraPos.xyz, positionWS); real fadeProgress saturate(distToCamera / _ShadowFadeDistance); return fadeProgress * receiveShadowStrength; }它的输入有两个positionWS是当前片段的世界坐标receiveShadowStrength是材质接收阴影的强度值通常在 Shader 的 Properties 里暴露成[0, 1]的 Float。函数内部先算相机到片段的直线距离再除以一个全局衰减距离参数_ShadowFadeDistance最后用saturate把结果限制在 0 到 1 之间。这个返回值可以理解成“淡出进度”0 表示还没开始淡出1 表示已经完全进入淡出区。receiveShadowStrength在这里主要起缩放作用让阴影强度偏弱的材质不会在较近距离就开始大面积丢阴影而是跟着材质本身的性质一起衰减。需要注意这里的_ShadowFadeDistance并不是改 Shader 里的某个变量就能生效的孤立参数它由渲染管线在阴影设置阶段统一赋值你可能在 Shader 代码里看不到它的声明直接使用即可。2.2 UnityComputeShadowFade合并阴影采样值拿到淡出进度之后就要和阴影采样结果的语义对齐。URP 里常见的阴影采样函数返回的是光照衰减也就是 1 表示完全照亮、0 表示完全被遮挡。在这个前提下UnityComputeShadowFade的参考实现可以理解成这样real UnityComputeShadowFade(real fadeDistance, real shadowFade) { return saturate(shadowFade fadeDistance); }这个写法初看有点反直觉为什么是加法而不是乘法其实在淡出进度为 0 时它原样保留阴影采样值当淡出进度变大时它相当于把采样值往 1 的方向推也就是把阴影逐渐“擦掉”。因为fadeDistance和shadowFade都限制在 0 到 1saturate保证结果不会超过 1也不会低于 0。虽然这不是严格的线性插值但视觉上足够自然而且代码非常轻量。如果你想追求更可控的过渡曲线也可以自己改成real result lerp(shadowFade, 1.0, fadeDistance);两种写法的差异主要在阴影半影区域saturate(shadowFade fadeDistance)会让半影部分更早向亮侧偏移看起来过渡带宽一点lerp的过渡更温和。具体选哪个建议以你项目所用 Unity 版本内置源码为准同时结合美术对软阴影过渡的需求来定。2.3 两者配合的数学关系把两个函数串起来看一条完整链路是real fadeProgress UnityComputeShadowFadeDistance(positionWS, _ReceiveShadowStrength); real finalShadow UnityComputeShadowFade(fadeProgress, shadowAtten);当相机距离接近 0fadeProgress接近 0finalShadow约等于原生阴影采样值当距离达到衰减距离附近fadeProgress接近 1finalShadow被推到接近 1也就是没有阴影。中间状态则形成一条随距离下降的阴影强度曲线。如果你在调试时把这个中间值直接输出成颜色会看到近处偏黑、远处偏白的渐变带这正是你想要的那个“斜坡”。这个分解方式的妙处在于两个函数各自只做一件简单的事组合起来却能适配很多场景。比如你不仅可以在主光源阴影上用还可以在额外光源、屏幕空间阴影甚至自定义遮挡物上套同一套淡出逻辑只要保证传入的世界坐标和阴影采样结果语义一致。3. 在 URP 自定义 Shader 里的完整接入3.1 准备工作Include 与输入结构要在手写 URP Shader 里使用这两个函数先要保证正确的 Include 路径。不同 Unity 版本可能略有差异但常见做法是包含#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlslCore.hlsl负责基础的结构定义和坐标变换Shadows.hlsl里才有UnityComputeShadowFadeDistance、UnityComputeShadowFade以及阴影坐标变换相关函数。如果你的项目用的是带自定义关键字的老版本管线可能还需要额外引入Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl。输入结构里需要有世界坐标。比较稳妥的做法是在顶点着色器里用TransformObjectToWorld把模型坐标转成世界坐标struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct Varyings { float4 positionCS : SV_POSITION; float3 positionWS : TEXCOORD0; float3 normalWS : TEXCOORD1; }; Varyings vert(Attributes input) { Varyings output; VertexPositionInputs positionInputs GetVertexPositionInputs(input.positionOS.xyz); output.positionCS positionInputs.positionCS; output.positionWS positionInputs.positionWS; output.normalWS TransformObjectToWorldNormal(input.normalOS); return output; }这里用GetVertexPositionInputs一次性拿到裁剪空间坐标和世界空间坐标比自己手动调两个函数省事也避免坐标不一致的问题。3.2 采样阴影后叠加淡出进入 Fragment 阶段后流程是这样的先根据世界坐标算阴影坐标再采样主光源阴影贴图最后调用两个淡出函数。这里直接给一段可以在 Forward 光照里跑的简化代码half4 frag(Varyings input) : SV_Target { float3 positionWS input.positionWS; float3 normalWS normalize(input.normalWS); #if defined(_MAIN_LIGHT_SHADOWS) || defined(_MAIN_LIGHT_SHADOWS_CASCADE) float4 shadowCoord TransformWorldToShadowCoord(positionWS); half shadowAtten MainLightRealtimeShadow(shadowCoord); #else half shadowAtten 1.0h; #endif // 阴影渐变衰减解决 Shadow Distance 边缘硬切 #if defined(_MAIN_LIGHT_SHADOWS) || defined(_MAIN_LIGHT_SHADOWS_CASCADE) real fadeProgress UnityComputeShadowFadeDistance(positionWS, _ReceiveShadowStrength); shadowAtten UnityComputeShadowFade(fadeProgress, shadowAtten); #endif Light mainLight GetMainLight(); float3 radiance mainLight.color * mainLight.shadowAttenuation; // 示意实际按你的光照模型来 half3 diffuse saturate(dot(normalWS, mainLight.direction)); half3 color radiance * (shadowAtten * diffuse) SampleAmbient(...); return half4(color, 1.0); }这段代码里的_ReceiveShadowStrength需要在 Properties 里声明_ReceiveShadowStrength(Receive Shadow Strength, Range(0, 1)) 1.0如果你的 Shader 没有定义_MAIN_LIGHT_SHADOWS相关关键字条件编译会直接走到shadowAtten 1.0淡出函数也不会被调用这样在不需要阴影的材质上不会有额外开销。3.3 一套更完整的参考实现很多情况下你不只要处理主光源还要面对额外光源、球谐光照、甚至屏幕空间阴影。这里给一套更完整的参考实现按我的项目习惯会把淡出部分单独抽成一个函数方便在多个 Pass 之间复用half ApplyMainLightShadowFade(half shadowAtten, float3 positionWS) { #if defined(_MAIN_LIGHT_SHADOWS) || defined(_MAIN_LIGHT_SHADOWS_CASCADE) real fadeProgress UnityComputeShadowFadeDistance(positionWS, _ReceiveShadowStrength); return UnityComputeShadowFade(fadeProgress, shadowAtten); #else return shadowAtten; #endif }然后光照计算里就可以直接写half shadowAtten ApplyMainLightShadowFade(shadowAtten, input.positionWS);把两个函数包在独立函数里还有一个好处以后想换淡出曲线、想加入屏空间距离修正、想按地表材质类别微调衰减范围都只需要改这一个地方不会污染光照主体代码。这个习惯我强推给所有写 URP 手写 Shader 的人。3.4 参数与开关控制UnityComputeShadowFadeDistance内部读取的是管线全局参数但并不是说你不能自己控制它。实际项目里我一般会在材质面板上暴露一个淡出距离倍率乘到世界坐标距离上间接影响淡出进度_FadeDistanceScale(Fade Distance Scale, Range(0.5, 2.0)) 1.0然后在 Shader 内乘到距离计算前real distToCamera distance(_WorldSpaceCameraPos.xyz, positionWS) * _FadeDistanceScale;不过要注意这个做法绕过的是内置函数的原始参数只在你自己抽出的包装函数里有效。管线全局的_ShadowFadeDistance仍然由 Shadow Settings 控制。如果美术同学希望完全通过材质控制最快的方式是让包装函数不调用内置函数自己用材质参数做一次距离归一化。这种自由度正是手写 Shader 最舒服的地方。4. 常见问题与排查技巧实录4.1 淡出进度恒为 1阴影直接消失如果你把UnityComputeShadowFadeDistance的输出当成颜色调试发现整个屏幕上都是接近 1 的白色先检查两件事一是_ShadowFadeDistance是否被管线设置成了非常小的值导致距离一除就超出 0 到 1二是receiveShadowStrength是否被设成了 1而距离本身又很远。排查时可以直接在 Fragment 里用固定值代替传入参数比如:real testFade UnityComputeShadowFadeDistance(positionWS, 1.0);如果测试结果仍然异常定位方向就是_ShadowFadeDistance的来源。去看当前 URP Asset 的阴影设置确认 Shadow Distance 是否覆盖了淡出距离。很多版本里_ShadowFadeDistance和 Shadow Distance 是联动的距离设得越短淡出区间也越短视觉上就会感觉阴影“很快就没了”。4.2 阴影在很近距离就开始变淡这种情况通常不是函数用错了而是坐标漂移。一个常见原因是顶点着色器里传下来的世界坐标没有经过正确的线性插值或者使用了不同坐标空间。比如你在顶点阶段用TransformObjectToWorld但 Fragment 阶段却拿到的是一份来自法线贴图重建的坐标两者数值对不上距离自然偏大。另一个常见坑是半透明物体或自定义 Pass 里忘了处理多 Pass 合批导致 positionWS 被多个阶段重复计算。建议先把调试颜色输出为 world space 的原始坐标检查是否平滑连续如果发现台阶状跳变优先排查合批和坐标变换。4.3 与阴影偏移的互相干扰阴影采样本身带了一堆 BiasDepth Bias、Normal Bias、View Bias。淡出只改最终阴影权重不会修正阴影痤疮或者漏光。很多时候你在边界看到的不只是硬切还有渗漏和黑点那其实是 Bias 设置和淡出叠加在一起的效果。把淡出拉长并不能掩盖足够强的痤疮反而会让痤疮区域先变灰再变干净看起来像污渍。如果出现这种问题先调 Bias再考虑淡出曲线顺序不要反。4.4 调试可视化技巧这段经验可能是整篇文章里最实用的部分。我在调试淡出效果时几乎不使用完整的 PBR 光照而是把中间量直接映射成颜色输出。最常用的几个调试输出调试内容Fragment 返回方式预期效果世界坐标是否正常return float4(positionWS, 1)亮面随相机移动平滑变化无跳变淡出进度return float4(fadeProgress.xxx, 1)近处接近 0远处接近 1形成连续斜坡阴影采样原始值return float4(shadowAtten.xxx, 1)被遮挡区域接近 0亮部接近 1最终淡出结果return float4(finalShadow.xxx, 1)远处阴影被擦除近处阴影保留把四个结果并排比较基本一眼就能看出问题出在坐标、采样还是淡出阶段。建议把这个调试开关做成 Shader 里的 Keyword 或者材质 Float方便在编辑器里快速切换不用每次改代码重编译。4.5 一阵子用得好好的换低端机型就异常移动端的精度问题经常在这个阶段暴露出来。_WorldSpaceCameraPos在某些 API 下精度表现不一致远距离像素在低精度浮点上会出现抖动导致淡出进度闪烁。遇到这种情况不要硬扛把淡出距离调整逻辑放到半精度可接受的范围或者在关键计算位置显式使用float而不是half。这个取舍有点反直觉因为移动端优化通常建议多用half但距离相关计算一旦丢精度视觉代价远大于那一点带宽节省。我在某个模拟项目X的移动版本里就吃过这个亏桌面上淡出曲线非常平滑到低端机上一跑草丛阴影边缘开始轻微闪烁后来定位到是half精度下距离计算的尾数误差。改成float后问题消失代价是可以忽略的。5. 我在实际项目里沉淀的经验如果你只是想在默认的 URP Lit 材质上解决问题其实不需要碰这两个函数管线已经处理好了。真正需要它的是那些写自定义 Shader 的场景比如要地面和角色用不同阴影接收规则、要剥离高光后单独做阴影通道、要在地表 Shader 里把阴影淡出和别的噪声混合。我踩过几轮坑之后有几点经验想特别记录下来。第一尽量把淡出逻辑放在一个独立函数里不要让两个内置函数散落在光照代码各处。原因很简单阴影淡出在项目开发中一定会被美术反复提需求今天要求线性过渡明天要求曲线过渡后天又想把淡出距离和天气系统挂钩。散落的代码改起来很容易漏集中管理才能快速响应调整。第二UnityComputeShadowFadeDistance里那个receiveShadowStrength参数不要在材质上直接绑到阴影强度语义上就直接用。它和阴影强度是两个维度一个是材质本身接收阴影时的整体强度一个是淡出进度的缩放系数。我习惯为它单独暴露一个属性或者直接传固定值1.0然后在后续光照里用另一个参数控制阴影强度。把两件事分开美术同学调整起来才不会互相打架。第三如果项目里用了屏幕空间阴影不要默认淡出逻辑会自动覆盖它。URP 的屏幕空间阴影有自己的权重计算路径手写 Shader 里直接采样屏幕空间阴影贴图时往往需要自己再补一层距离淡出否则屏幕空间阴影和常规阴影在接近边界时可能会出现两层视觉重叠。具体做法通常是把屏幕空间阴影采样值也丢进UnityComputeShadowFade里做一次合并。最后再分享一个小技巧。调试淡出参数时不要只盯着灰度看把场景相机人为拉到很远的距离观察地平线附近的阴影过渡。因为最扎眼的不是粒子特效里的软阴影而是远景地面上一条笔直的亮暗分界线。你只要把那一条线处理得看不出痕迹整个场景的阴影品质至少能提高一个档次。这也是我后来在每个项目里都会先做的一个必查项。
阅读完成 · 觉得有帮助?
咨询建站