ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity半透明材质接收阴影完全指南:从原理到URP与移动端实践

Unity半透明材质接收阴影完全指南:从原理到URP与移动端实践 做项目最怕的不是功能不会写而是那种“明明开关都打开了效果却死活出不来”的诡异问题。前阵子我在一个数字孪生展厅项目里做玻璃展柜美术反馈展柜里的产品明明开了实时阴影地面、墙面、柜体上都能看到影子偏偏半透明玻璃罩上什么都没有。我第一反应是“Receive Shadows是不是没勾上”结果检查了一圈材质参数全对最后才定位到Shader这一层。这个问题在Unity社区里被问过无数次但答案普遍散落在论坛回帖里没有一个把原理、代码和工程实践一次性讲透的。这篇文章我打算从根因讲起覆盖Built-in管线和URP再到移动端和Pico4这类XR设备的阴影取舍把我的踩坑记录完整写出来给做半透明材质、玻璃展柜、全息界面阴影的兄弟们一份能直接照着操作的参考。1. 透明材质为何默认接不住阴影从深度缓冲说到阴影宏1.1 先复现一遍默认Shader下开满所有开关依然没影子新建一个Unity工程选Built-in渲染管线场景里放一个Cube挂上默认的Standard Shader再放一盏平行光并开启Shadow Type为Soft Shadows。地面正常出影子这个大家都知道。然后把Cube的材质换成Legacy Shaders/Transparent/Diffuse或者直接把Standard材质面板上的Rendering Mode从Opaque切到Transparent。问题来了材质下方那个Receive Shadows的勾选框明明还开着Mesh Renderer组件的Cast Shadows和Receive Shadows也都是启用状态但阴影就像被吞掉一样完全消失。很多人在这一步就开始怀疑是不是Unity的Bug或者去调Shadow Distance、调分辨率最后发现毫无意义。实际上这里藏着一个容易被忽略的真相Mesh Renderer上的Receive Shadows只是一个“允许接收”的开关Shader本身如果没写阴影采样相关的代码这个开关并不会让阴影出现。Unity的实时阴影本质上是ShadowMap技术。主灯光先把场景从光源视角渲染成一张深度图之后每个片元在计算光照时把自身坐标变换到光源空间跟这张深度图做比较判断自己是否处在阴影里。这套比较逻辑不是引擎隐式完成的而是通过Shader里的内置宏比如SHADOW_ATTENUATION在Pass代码里手动触发。而老旧的Transparent/Diffuse这类内置半透明Shader它的Pass里只有最基础的纹理采样和颜色输出压根没有包含阴影收集逻辑。换句话说这材质根本不认识阴影贴图自然谈不上接收。1.2 ZWrite Off和混合模式它们是背锅侠不是元凶排查这个问题时论坛里最常见的一个说法是“透明物体要开启ZWrite才行ZWrite Off导致阴影丢失”。这个说法既对又不对需要仔细掰扯。先明确一点ZWrite Off只影响该物体是否写入深度缓冲不影响它去采样ShadowMap。一个半透明物体就算完全不写深度它接收阴影的采样计算依然可以正常执行阴影数据照样能从贴图里读出来。那为什么“关闭ZWrite”经常和“没有阴影”一起出现因为半透明物体普遍使用Blend混合ZWrite Off会导致它不参与遮挡关系镜头一转后面不透明白色墙面先写入深度再往前混合半透明玻璃时玻璃上的暗色阴影很容易被周围高亮环境光盖掉视觉上自然觉得“没影子”。这属于混合结果的问题不是采样逻辑的问题。还有一层更隐蔽的关系是RenderType和ShadowCaster Pass的绑定。Unity在生成ShadowMap阶段会为每个物体寻找它的ShadowCaster Pass这个Pass通常包含顶点位置变换和深度输出但一般不会做半透明混合。半透明物体如果没有显式声明ShadowCaster Pass引擎会尝试从Fallback里拉一个。如果Fallback的Shader带有ShadowCaster它能往ShadowMap里写深度也就是“能投射阴影”但这跟“自己身上是否接收阴影”还是两码事。所以你在排查任何透明物体阴影问题之前脑子里必须先建立一条链路阴影贴图是谁生成的—— 物体的ShadowCaster Pass。谁在消费阴影数据—— 物体主Pass里的阴影采样宏。显示效果如何叠加—— 深度测试、混合模式、绘制顺序。这三层缺一环屏幕上就看不到想要的影子。2. 三种落地做法从材质参数到自定义Shader的渐进式解法2.1 不改代码前先做的验证官方Lit材质在URP下的表现如果你用的是URP项目先别急着写Shader有个相对省事的验证路径找一个官方Lit材质把Surface Type切成Transparent然后在Surface Options里把Receive Shadows打开Cast Shadows选择On。在Unity 2021.3之后的URP版本中官方Lit对Transparent表面已经能比较勉强地做到“透明接收阴影”前提是你的Shadow设置不是保守模式且物体本身没有被后处理遮罩掉。我自己实测过这个方法在URP 12到URP 16都能跑通但效果有局限官方Lit的半透阴影本质上还是把不透明物体的阴影衰减拿过来用无法控制半透明面自身的“阴影浓度”也就是说玻璃罩上投出来的影子是全黑的像贴了张黑纸。而且不同Unity小版本之间Transparent模式下Receive Shadows的开关显示位置还会变你没法保证团队的Unity版本全都一样。它最大的用处是快速验证场景里阴影是否生效帮你在动手写Shader前排除灯光、阴影距离、Mesh组件设置这些外围问题。如果这一步做完发现还是没有影子十有八九是Shadow Distance太短、灯光没开阴影、或者物体超出阴影覆盖范围而不是透明材质本身的问题。2.2 Surface Shader方案alpha:fade addshadow 自定义Lighting函数在Built-in渲染管线里对“半透明材质接收阴影并且阴影浓度可控”这件事最稳的做法是写一个自定义Surface Shader。Surface Shader会把光照和阴影宏自动展开到Pass里比手写Forward Pass省心很多。我有一个实战里跑了两年的模板直接贴出来Shader Custom/GlassWithShadow { Properties { _MainTex (Albedo (RGB), 2D) white {} _Color (Color, Color) (1,1,1,0.5) _ShadowStrength (Shadow Strength, Range(0,1)) 0.8 } SubShader { Tags { QueueTransparent RenderTypeTransparent } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off LOD 200 CGPROGRAM #pragma surface surf GlassShadow alpha:fade addshadow #pragma multi_compile_fwdadd_fullshadows sampler2D _MainTex; fixed4 _Color; float _ShadowStrength; struct Input { float2 uv_MainTex; }; half4 LightingGlassShadow(SurfaceOutput s, half3 lightDir, half3 viewDir, half atten) { half diff max(0, dot(s.Normal, lightDir)); half shadow lerp(1.0, atten, _ShadowStrength); half4 c; c.rgb s.Albedo * _LightColor0.rgb * diff * shadow UNITY_LIGHTMODEL_AMBIENT * s.Albedo; c.a s.Alpha; return c; } void surf (Input IN, inout SurfaceOutput o) { fixed4 c tex2D(_MainTex, IN.uv_MainTex) * _Color; o.Albedo c.rgb; o.Alpha c.a; } ENDCG } Fallback Transparent/Diffuse }这段代码有三个关键点需要展开讲。alpha:fade让Surface Shader生成的Pass包含半透明混合同时保留光照计算里的阴影衰减宏。如果你写的是alpha:blend或者alpha:premul混合方式会变但阴影采样逻辑依然在。addshadow是给Surface Shader添加匹配的ShadowCaster Pass用的它确保这个半透明物体在主Pass裁剪Alpha的地方ShadowCaster也能做同样处理否则Unity会Fallback到Transparent/Diffuse的阴影投射裁剪逻辑跟主Pass对不上容易出现“画面里镂空了阴影却还是完整一块”的问题。自定义的LightingGlassShadow函数里atten已经包含实时阴影衰减和距离衰减不要再调一遍SHADOW_ATTENUATION否则阴影会叠出两层深得没法看。我用_ShadowStrength做了一次混合本质上是把全黑的阴影值0抬升到接近1的区间让半透明物体上的阴影浓度变得可控。这个参数在玻璃展柜场景里特别有用——你通常不希望玻璃罩上有一块跟地面一样黑的影子而是希望它若有若无地映出下面产品的轮廓。2.3 URP下的手动阴影采样写法到了URPSurface Shader虽然还能用但官方更推荐走HLSL的手写Pass路线。URP里获取阴影的方式是TransformWorldToShadowCoord GetMainLight这套组合比Built-in的宏更显式。一个可复现的URP透明阴影Shader核心代码如下Shader Custom/URP/GlassShadow { Properties { _BaseMap (Base Texture, 2D) white {} _BaseColor (Base Color, Color) (1,1,1,0.5) _ShadowStrength (Shadow Strength, Range(0,1)) 0.8 } SubShader { Tags { RenderTypeTransparent QueueTransparent RenderPipelineUniversalPipeline } Pass { Name ForwardLit Tags { LightModeUniversalForward } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile_fragment _ _SHADOWS_SOFT #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; float3 normalWS : TEXCOORD2; float4 shadowCoord : TEXCOORD3; }; TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); CBUFFER_START(UnityPerMaterial) float4 _BaseColor; float4 _BaseMap_ST; float _ShadowStrength; CBUFFER_END Varyings vert(Attributes IN) { Varyings OUT; VertexPositionInputs posInputs GetVertexPositionInputs(IN.positionOS.xyz); OUT.positionCS posInputs.positionCS; OUT.positionWS posInputs.positionWS; VertexNormalInputs normalInputs GetVertexNormalInputs(IN.normalOS); OUT.normalWS normalInputs.normalWS; OUT.uv TRANSFORM_TEX(IN.uv, _BaseMap); OUT.shadowCoord TransformWorldToShadowCoord(posInputs.positionWS); return OUT; } half4 frag(Varyings IN) : SV_Target { half4 albedo SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) * _BaseColor; Light mainLight GetMainLight(IN.shadowCoord); half shadow saturate(mainLight.shadowAttenuation); shadow lerp(1.0, shadow, _ShadowStrength); float3 normalWS normalize(IN.normalWS); half NdotL saturate(dot(normalWS, mainLight.direction)); half3 diffuse mainLight.color * NdotL * shadow; half3 ambient SampleSH(normalWS); half3 finalColor albedo.rgb * (diffuse ambient); return half4(finalColor, albedo.a); } ENDHLSL } } }这段Shader只处理主光源。如果需要点光源和阴影要再加额外的Pass或者用_ADDITIONAL_LIGHTS循环否则场景里一盏以上的灯就会出问题。还有个URP下的实用技巧自定义Shader默认没有ShadowCaster Pass所以它本身不能向场景投射阴影。如果想让它也能投射可以在SubShader里加一行UsePass Universal Render Pipeline/Lit/ShadowCaster复用官方Lit的ShadowCaster。前提是你的属性名跟官方Lit一致_BaseMap、_BaseColor刚好一致这样美术换材质时不需要额外调参数。3. 半透明影子的质感控制强度、软硬、偏移与镂空剪影3.1 投射阴影的浓度真正的“透明阴影”需要自己控制很多项目做到上一步就觉得完事了但一运行就发现新问题玻璃桌面在地面上投出了一块“全黑实体影子”看起来比实木桌子还重。这不符合直觉——透明物体本来应该让大部分光穿过去它投下的影子不该是实心的。这个问题的根源在ShadowCaster Pass。ShadowCaster在渲染阴影贴图的时候只关心深度它不会做半透明混合也不会因为材质Alpha是0.5就把深度值淡化。所以一张玻璃桌和一张木板桌生成的ShadowMap深度上几乎没区别。要让透明物体投出浓度可控的影子工程上有几种做法最简单粗暴把半透明物体的Cast Shadows关掉靠它里面或下面的不透明物体自然生成影子。做玻璃展柜时展品本身的不透明影子落在柜体底板上视觉上比玻璃投一大块黑影子真实得多。如果一定要玻璃本身产生浅影可以在Surface Shader里把Alpha Test的阈值抬到接近1让半透明区域被裁掉不写入ShadowMap保留下来的高不透明区域比如磨砂边框、彩色玻璃图案才显示阴影。更精细的做法是写自定义ShadowCaster Pass在片元阶段用材质Alpha采样结果乘以一个阴影强度系数让半透明区域对深度图产生“灰度级”的影响。但这种方式需要读颜色纹理性能开销比普通深度渲染高移动端一般不建议用。我自己做展柜类项目时习惯把它拆成两个物体来做玻璃罩本体材质保持纯半透明、Cast Shadows设为Off里面的展品保持正常不透明Cast Shadows。地面接收展品的影子玻璃本身只做一个高光反射效果。这样视觉上观众看到的是“展品在玻璃罩里投下了影子”而不是玻璃在投影子整体效果最自然。3.2 Alpha Test镂空阴影从剪影到渐变边缘另一种常见需求是镂空半透明物体比如铁丝网、百叶窗、带孔的金属格栅。用Alpha Test被裁掉的地方不应该投下影子这时就要确保ShadowCaster Pass和主Pass用同一个Alpha阈值。Surface Shader里用alphatest:_Cutoff配合addshadow就能正确生成对应的阴影投射Shader Custom/AlphaTestShadow { Properties { _MainTex (Albedo (RGB), 2D) white {} _Color (Color, Color) (1,1,1,1) _Cutoff (Alpha Cutoff, Range(0,1)) 0.5 } SubShader { Tags { QueueAlphaTest RenderTypeTransparentCutout } LOD 200 CGPROGRAM #pragma surface surf Lambert alphatest:_Cutoff addshadow sampler2D _MainTex; fixed4 _Color; struct Input { float2 uv_MainTex; }; void surf (Input IN, inout SurfaceOutput o) { fixed4 c tex2D(_MainTex, IN.uv_MainTex) * _Color; o.Albedo c.rgb; o.Alpha c.a; } ENDCG } Fallback Transparent/Cutout/Diffuse }这个Shader生成的阴影会跟随主Pass的Alpha裁剪结果铁丝网中间的空洞部分不会在ShadowMap里留下深度投在地面上的影子就是镂空的网状效果。但注意Alpha Test是硬裁剪边缘会出锯齿。如果希望镂空边缘有柔和过渡可以配合ShadowMap的软阴影或者在片元里做边缘抗锯齿处理比如算一个基于UV的dither mask。移动端为了性能我还是建议先用硬裁剪再靠软阴影浑水摸鱼实测观感差距并不明显。3.3 软阴影、偏移与阴影裁剪的实测参数半透明物体上的阴影和地面阴影有个不同点半透明面本身离ShadowMap的采样平面更近更容易出现shadow acne阴影粉刺和peter-panning。遇到这类条纹优先调物体所在光线方向的Bias参数。先分清概念Depth Bias深度偏移让阴影深度比较时给自己多加一点容差防粉刺。Normal Bias沿法线方向偏移采样点防大面积条纹但调太大会让影子看起来“缩水”。我一般在质量设置里的Shadow Projection改为Stable Fit而非Close Fit这样阴影稳定性会好很多。半透明材质上如果出现竖状条纹先把Normal Bias从默认的1调到0.05~0.1再把Depth Bias调到0.02~0.05两个配合着微调。URP的Shadow Settings里还多一个Shadow Offset参数通常Unity项目里建议设到1但半透明物体上我会单独在物体属性里加一个脚本动态调整。软阴影方面半透明材质采样ShadowMap时因为混合叠加容易出现阴影边缘“双层变暗”的问题。解决方案是在URP的URP Asset里选择Medium或High的软阴影质量PCF滤波层数提高以后边缘过度更均匀。移动端的软阴影开销较大Pico4这类设备上如果阴影面积大建议直接用Hard Shadows配合低分辨率ShadowMap观感反而干净。还有个很容易忽略的坑Mesh Renderer的Bounds过小会导致阴影被裁剪掉。如果你动态生成的半透明物体Bounds算错了引擎在渲染阴影时不光会多出多余的物体包围盒还会让阴影距离计算产生偏差。在代码里创建Mesh后记得手动renderer.localBounds new Bounds(Vector3.zero, Vector3.one * 10f);放大包围盒能治好多半“阴影突然消失”的疑难杂症。4. 项目实战中的必踩坑与工程建议从PC到Pico44.1 Receive Shadows失效的排查链路按这个顺序查最省时间这一节我直接给排查清单全部是实测过的顺序。确认当前项目是Built-in还是URP。不同管线下的Shader写法完全不同URP的透明阴影能力依赖URP Asset版本太老的话官方Lit也可能没这个选项。检查Mesh Renderer里的Cast Shadows / Receive Shadows。这个最基础但往往被忽略尤其美术从别处复制材质后容易误关。检查Light组件的Shadow Type。Directional Light默认是No Shadow点光源阴影开销大一些场景会主动关掉如果你只有一个点光源保不齐就没有阴影。检查Shadow Distance。如果场景里物体离相机超过阴影距离影子直接不渲染跟透不透明毫无关系。默认Shadow Distance是50米室内展厅场景够用大场景要手动加大。检查材质Shader是否包含阴影采样宏。这个是最关键的也是透明材质没影子的头号元凶。Surface Shader看有没有alpha:fade / addshadow / fullforwardshadows手写Shader看有没有SHADOW_COORDS、TRANSFER_SHADOW、SHADOW_ATTENUATIONURP看有没有#pragma multi_compile _ _MAIN_LIGHT_SHADOWS以及GetMainLight调用。检查渲染队列和混合模式。Queue如果是Overlay或者后处理级别的绘制顺序会很靠后混合结果可能覆盖阴影。建议透明玻璃统一用QueueTransparent。检查ShadowMap分辨率。如果场景里只有一个玻璃物体但阴影模糊成一团可能是ShadowMap分辨率太低。在Quality Settings里把Shadow Resolution调到High或者Ultra看看是不是瞬间变清晰。我见过最离谱的一个案例阴影一直不显示排查了两天最后发现是相机上挂了一个后处理特效把颜色修正成很亮的HDR阴影被压缩到肉眼看不见。所以说排查的时候尽量先把后处理关掉再测能省下大量时间。4.2 移动端与XR平台的阴影取舍近两年XR项目越来越多不少兄弟是在Pico4这类设备上做Unity开发。移动端和XR的GPU能力有限实时阴影的成本是桌面端的数倍半透明物体接收阴影更是雪上加霜。一次Alpha混合可能就要多采样几次ShadowMap再加上半透明物体overdraw叠加帧率立马崩给你看。针对Pico4和类似设备我在实际项目里的几个惯例场景里最多保留一盏平行光的实时阴影且Shadow Cascade等级设为2级。Cascade等级越高阴影越精细但移动端Shader对Cascade的采样开销通常会翻倍性价比很低。半透明材质默认不考虑实时接收阴影改用烘焙Lightmap或者Light Probe。尤其是展馆内固定结构的物体烘焙出的静态阴影几乎零运行时开销光影质量还比实时阴影稳定。如果确实需要动态物体在半透明面上显示阴影就只在PC端编辑器里开实时阴影调试移动端发布时收紧Shadow Distance到10米左右并关掉Soft Shadows。如果做的是小范围UI类全息界面可以用一张带Alpha的阴影贴图做屏幕空间叠加效果会比真正的ShadowMap柔和而且成本可控。Unity 66000.x版本系列对移动端实时阴影做了一些优化但半透明物体这块依然要靠开发者自己取舍。我的经验是透明阴影问题本质上不是纯粹的技术问题而是美术表现和性能预算之间的博弈。拿到需求先问一句“这个影子真的需要实时吗”比闷头写Shader有用得多。4.3 项目里的资源规范与Shader资产整理这种问题经历多了以后我在团队里定了三条规范现在基本不需要为透明阴影再开bug单。第一自研半透明材质Shader统一放在Assets/Shaders/TransparentShadow/目录下命名带Glass、TransparentCutout这类前缀避免美术误用官方Legacy Shader。项目目录里明明有封装好的Shader库结果美术又从网上拖了个旧的Transparent/Diffuse下来这种事情反复发生过。第二所有半透明材质默认Cast Shadows为Off。美术需要特殊阴影效果时必须走自定义Shader里做Alpha处理而不是直接打开Cast Shadows。目的是防止一张半透明玻璃桌面投出个全黑实心影子。第三在材质命名规范里强制要求标注RenderQueue。比如M_Glass_Exhibit_Transparent看到名字就知道这个材质是透明管线的。如果哪天某层玻璃收不到阴影了打开材质面板第一个检查RenderQueue是不是被后处理Cover了定位问题的时间能缩短一半。再补充一个开发中的小妙招做一个编辑器脚本自动扫描场景里所有RenderQueue为Transparent的材质列出它们的Shader路径和Receive Shadows状态。这样每次场景合入主干前跑一遍就能提前发现谁偷偷用了不支持阴影的Shader。最后再分享一点经验如果你问我“透明物体接收阴影”这个问题最重要的认知是什么我会说Unity的阴影系统里“投射”和“接收”是两条完全独立的链路。投射走ShadowCaster Pass接收走主Pass里的阴影采样宏中间的桥梁是ShadowMap这张深度图。搞清楚谁负责生成、谁负责消费比记住任何一个开关的位置都重要。我在实际项目里现在已经养成了习惯凡是涉及透明材质的渲染问题先不看材质面板直接先看Shader路径再判断它有没有可能接得住阴影。省下来的排查时间比写十段注释都值钱。如果你在项目里也遇到类似问题按照这篇文章的排查链路走一遍大概率能在半小时内定位到根因。最后再提醒一句换Shader之前一定要先备份原材质别问我为什么知道——那是我第一个Unity项目交过的学费。
返回列表