Unity半透明水面阴影实现:Shader深度干预与优化策略

发布时间:2026/7/26 12:38:30

Unity半透明水面阴影实现:Shader深度干预与优化策略 1. 项目概述半透明水面的阴影困境在Unity里做水面效果尤其是那种清澈见底、波光粼粼的半透明水面几乎是每个3D项目都会遇到的“必修课”。但当你兴冲冲地调好折射、反射和高光准备把角色或船只放上去时一个令人头疼的问题就出现了影子要么穿帮要么直接消失。水面本身要么不接收来自其他物体的阴影显得浮在空中要么自己投射的阴影漆黑一块完全破坏了半透明的美感。这背后的核心矛盾就在于Unity默认的渲染管线对半透明物体的阴影处理是“束手无策”的。半透明渲染主要依赖两种技术Alpha Test透明度测试和Alpha Blend透明度混合。Alpha Test非常“硬核”它设定一个阈值像素透明度低于这个值就直接丢弃高于则完全保留常用于树叶、铁丝网等有清晰边缘的物体。Alpha Blend则温和许多它会根据当前像素的透明度与背景颜色进行混合计算从而实现真正的半透明效果比如玻璃、水、烟雾。问题恰恰出在这里Unity的阴影映射Shadow Mapping技术在生成阴影贴图时默认无法正确处理这两种半透明方式所传递的深度信息。所以这个项目的目标非常明确我们要亲自动手写一个完整的Unity Shader不仅要让水面看起来是半透明的还要让它能正确地投射出符合其透明度的柔和阴影并且能接收来自场景中其他物体的阴影最终实现视觉上统一、物理上可信的水面光影交互。整个过程会涉及对阴影映射原理的深度干预是Shader编程从“效果实现”到“系统集成”的一次关键跨越。2. 核心原理阴影与半透明的冲突根源要解决问题必须先理解问题是怎么来的。Unity的实时阴影无论是平行光的方向阴影还是点光源/聚光灯的阴影其主流技术都是阴影映射。2.1 阴影映射的简化流程从光源视角渲染把相机移到光源位置看向整个场景但这次不输出颜色只输出每个像素到光源的深度值生成一张“阴影贴图”。从相机视角渲染正常渲染场景。对于屏幕上的每一个像素我们把它变换回光源的视角空间得到它在光源视角下的深度值A。深度比较拿这个深度值A去跟阴影贴图中对应位置存储的深度值B比较。如果 A B说明这个像素在光源和它看到的最近物体深度B之间即处于阴影中。如果 A B说明这个像素能被光源直接照亮。这个流程对不透明物体完美适用因为深度值清晰明确。2.2 半透明如何打破规则当物体是半透明时上述流程在两个环节都出了问题阴影投射Casting ShadowsAlpha Test在生成阴影贴图步骤1时如果像素的alpha低于阈值被丢弃那么该位置就没有深度信息。这会导致本该被遮挡的后方物体因为这里“没有东西”而错误地接收到光照影子出现空洞。例如一个Alpha Test的栅栏其镂空部分在阴影贴图中是空的导致墙上的影子也是镂空的这显然不对。Alpha Blend情况更糟。在生成阴影贴图时GPU通常只写入最前面不透明表面的深度开启深度写入。对于混合表面由于它需要和后面的颜色混合一般会关闭深度写入ZWrite Off导致它根本不会向阴影贴图中写入有效的深度信息。因此Alpha Blend物体默认不投射任何阴影。阴影接收Receiving Shadows这发生在步骤2和3。对于Alpha Test物体它本身有明确的深度可以参与比较但它“接收”到的阴影是二进制的有/无无法体现其透明部分应该让部分阴影透过的效果。对于Alpha Blend物体问题在于阴影计算通常发生在不透明渲染之后。当进行深度比较时半透明水面的深度值已经写入了深度缓冲区。如果它上方一个物体的阴影深度值比水面更靠近光源那么水面这个像素就会被判定为“在阴影中”整个像素变暗。然而理想情况是阴影的暗度应该根据水面的透明度进行衰减。例如只有10%透明度的深水区域阴影应该几乎全黑而90%透明度的浅水区域阴影应该非常淡甚至看不见。标准的阴影接收无法实现这种基于透明度的渐变。理解了这些我们的Shader设计思路就有了方向我们需要自定义阴影投射的生成逻辑并修改阴影接收的计算方式。3. Shader框架设计与关键指令解析我们将编写一个支持两种半透明模式的Unity Shader并分别处理它们的阴影。这里使用Surface Shader作为框架因为它能帮我们处理很多光照交互的底层细节让我们更专注于阴影逻辑的重写。3.1 基础属性与渲染标签Shader Custom/TransparentWaterWithShadows { Properties { _Color (Main Color, Color) (1,1,1,1) _MainTex (Base (RGB) Trans (A), 2D) white {} _Glossiness (Smoothness, Range(0,1)) 0.5 _Metallic (Metallic, Range(0,1)) 0.0 // Alpha Test 阈值 _Cutoff (Alpha Cutoff, Range(0,1)) 0.5 // 波浪法线贴图 _BumpMap (Normalmap, 2D) bump {} _BumpStrength (Normal Strength, Float) 1.0 } SubShader { Tags { QueueTransparent // 渲染队列设为透明 RenderTypeTransparent IgnoreProjectorTrue // 通常忽略投影器但我们需要阴影 } LOD 300关键点在于Tags。“Queue”“Transparent”确保水面在所有不透明物体之后渲染这是实现正确Alpha Blend的前提。“IgnoreProjector”“True”通常是为了避免旧版投影器的干扰但对于现代实时阴影我们需要保留。3.2 渲染路径与多重Pass配置半透明阴影需要多个Pass协同工作。// 第一个Pass渲染不透明部分用于Alpha Test模式下的深度和颜色 Pass { Name FORWARD Tags { LightMode ForwardBase } Blend SrcAlpha OneMinusSrcAlpha ZWrite On // 写入深度用于排序和深度测试 CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase // 编译前向渲染基础变体包含阴影接收 // ... 顶点/片元着色器代码 ENDCG } // 第二个Pass专门用于阴影投射 Pass { Name ShadowCaster Tags { LightMode ShadowCaster } ZWrite On ZTest LEqual CGPROGRAM #pragma vertex vertShadow #pragma fragment fragShadow #pragma multi_compile_shadowcaster // 编译阴影投射器变体 #pragma shader_feature _ALPHATEST_ON _ALPHABLEND_ON // 根据关键词选择模式 // ... 专门的阴影投射着色器代码 ENDCG }这里有两个核心PassForwardBase Pass主Pass负责在相机视角下渲染水面的颜色、光照和接收阴影。#pragma multi_compile_fwdbase指令会生成包含阴影接收计算代码的着色器变体。ShadowCaster Pass这是重中之重。当光源需要生成阴影贴图时Unity会调用这个Pass。我们必须自定义这个Pass根据_ALPHATEST_ON或_ALPHABLEND_ON关键词分别实现两种半透明模式的阴影深度生成逻辑。注意ZWrite On在ShadowCaster Pass中至关重要。即使主Pass为了混合而关闭了深度写入在生成阴影贴图时我们也必须打开它以便写入一个代表该半透明表面对光源深度的值。但这个值具体如何计算就是我们需要 hack 的地方。4. 阴影投射ShadowCasterPass的深度重写这是实现半透明阴影最核心、最技术性的部分。我们需要在ShadowCaster Pass中根据不同的透明度处理方式输出一个“合理”的深度值到阴影贴图中。4.1 Alpha Test模式的阴影投射实现对于Alpha Test思路相对直接在阴影投射的片元着色器中我们也执行同样的Alpha Test。如果片元被丢弃它就不贡献任何深度相当于阴影贴图上有个洞。但这正是问题所在我们并不希望阴影有洞。一个常见的修正方案是在阴影生成时使用一个比视觉渲染时更低的_Cutoff值或者干脆不使用Alpha Test而是输出一个根据Alpha值调整过的深度。更高级和实用的方法是“Alpha to Coverage” 在阴影中的模拟。我们不在片段级别直接丢弃而是通过某种方式让一个像素的阴影深度贡献度与其Alpha值成正比。但由于标准阴影贴图只存储一个深度一种近似做法是修改顶点位置。// 在ShadowCaster Pass的顶点着色器中 v2f vertShadow(appdata_base v) { v2f o; // 将顶点从对象空间变换到裁剪空间 o.pos UnityObjectToClipPos(v.vertex); // 关键技巧根据Alpha值轻微调整顶点在光源视图下的深度 // 获取该片元的Alpha值可以从顶点颜色或UV动画采样得到 float alpha tex2Dlod(_MainTex, float4(v.texcoord.xy, 0, 0)).a; #ifdef _ALPHATEST_ON // 模拟Alpha Test如果Alpha低于阈值我们将顶点“推远”一点点 // 这会导致该片段在深度比较时更容易被判定为“在后面”从而不产生阴影遮挡 // 这是一种Hack效果是让阴影的边缘变得柔和而不是出现硬洞 if (alpha _Cutoff) { // 将裁剪空间坐标的Z分量深度稍微增加使其离相机光源更远 // 注意裁剪空间Z越大离相机越远。这里乘以一个很小的系数。 o.pos.z (1.0 - alpha) * _Cutoff * 0.0005; } #endif // 将纹理坐标传递给片元着色器 o.uv TRANSFORM_TEX(v.texcoord, _MainTex); return o; } fixed4 fragShadow(v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); float alpha col.a; #ifdef _ALPHATEST_ON // 在片元着色器中进行标准的Alpha Test彻底丢弃低于阈值的像素 // 结合顶点着色器的调整边缘像素可能被部分保留产生柔化效果 clip(alpha - _Cutoff); #endif // 对于ShadowCaster Pass片元着色器通常不需要返回颜色但必须执行 return 0; }这段代码展示了一种混合策略顶点着色器先做初步的、基于Alpha的深度偏移片元着色器再做最终的Alpha Test。这能在一定程度上缓解阴影边缘的锯齿和空洞但并非物理精确。它传递出的核心思想是在阴影投射阶段我们需要主动干预深度值而不是完全依赖标准的渲染流程。4.2 Alpha Blend模式的阴影投射实现Alpha Blend的阴影投射是更大的挑战。因为传统上Alpha Blend物体不写入深度所以也不投射阴影。我们的目标是让它写入一个深度但这个深度不能是简单的表面深度否则会投射出完全不透明的、坚硬的阴影。解决方案的核心是一种称为“Alpha-Dithered Shadow”或“Stochastic Transparency Shadow”的近似方法。我们不在一个像素内混合而是利用屏幕空间的抖动Dithering或随机采样让一个像素要么完全投射阴影要么完全不投射但宏观上根据Alpha值形成一种概率性的、看起来半透明的阴影。Unity内置的SHADOW_CASTER_FRAGMENT宏其实为我们做了一些基础工作但它默认不支持透明度。我们需要深入其内部逻辑。一个可行的实践是修改片元着色器在输出深度之前根据Alpha值进行一个随机测试// 在 fragShadow 函数中Alpha Blend 分支 #ifdef _ALPHABLEND_ON float alpha col.a; // 使用屏幕空间位置生成一个简单的伪随机数 float random frac(sin(dot(i.uv, float2(12.9898, 78.233))) * 43758.5453); // 如果随机数大于Alpha则丢弃该片元不贡献阴影深度 // 这意味着Alpha越高越不透明片元被保留的概率越大。 if (random alpha) { discard; } // 如果片元被保留则正常输出写入一个不透明的深度 // 这样从统计上看一片Alpha为0.5的区域大约有50%的像素会写入阴影深度 // 从而在阴影贴图上形成一种“半透明”的噪声图案。在最终的阴影过滤软阴影过程中 // 这些噪声会被模糊掉宏观上就形成了柔和的半透明阴影效果。 #endif实操心得这种抖动方法在阴影分辨率较低或物体离光源较远时可能会产生明显的噪声颗粒。为了缓解可以尝试更复杂的蓝噪声纹理进行抖动或者结合多重采样。此外确保用于随机数的种子如i.uv在每一帧是变化的可以加上时间变量可以避免产生静态的噪声图案。这虽然是一种视觉欺骗但在多数游戏运行速度下是性能与效果之间非常好的平衡。5. 阴影接收Shadow Receiving的透明度适配让水面投射出半透明阴影只是成功了一半。同样重要的是当场景中其他物体如桥墩、船只的阴影投射到水面上时阴影的浓度应该与水面的透明度相结合。5.1 在Surface Shader中访问阴影数据在ForwardBase Pass中Surface Shader通过SurfaceOutputStandard结构体和LightingStandard光照模型函数来处理光照和阴影。阴影衰减值通常通过UNITY_LIGHT_ATTENUATION宏来获取。void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c tex2D(_MainTex, IN.uv_MainTex) * _Color; o.Albedo c.rgb; o.Metallic _Metallic; o.Smoothness _Glossiness; o.Alpha c.a; // 关键将纹理的Alpha传递给输出结构 } float4 LightingStandardTransparent(SurfaceOutputStandard s, float3 viewDir, UnityGI gi) { // 先进行标准的光照计算 float4 stdColor LightingStandard(s, viewDir, gi); // 关键步骤根据表面的透明度s.Alpha来调制阴影衰减(gi.light.atten) // 一种简单的乘法混合透明度越高Alpha越小阴影衰减越弱阴影越淡 float shadowStrength s.Alpha; // 或者使用更复杂的映射如 pow(s.Alpha, 0.5) gi.light.atten lerp(1.0, gi.light.atten, shadowStrength); // 用修改后的光照数据重新计算颜色这里简化表示实际需重新组合 // 更直接的做法是在 surf 函数输出后在最终颜色上混合 stdColor.rgb * gi.light.atten; stdColor.a s.Alpha; // 保持原始透明度 return stdColor; }实际上直接修改gi.light.atten可能不直接生效。更常见的做法是在片元着色器末尾在计算完所有光照包含标准阴影衰减atten之后手动进行二次混合。5.2 在片元着色器中手动混合阴影一个更可控的方法是在ForwardBase Pass的片元着色器最后阶段我们明确地采样阴影贴图使用UNITY_SHADOW_COORDS和SHADOW_ATTENUATION然后将其与表面的Alpha值结合。struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; UNITY_SHADOW_COORDS(1) // 声明阴影坐标插值器TEXCOORD1 // ... 其他数据如法线、视角方向等 }; fixed4 frag (v2f i) : SV_Target { // ... 计算漫反射、高光等颜色 color // 采样阴影衰减1.0表示完全照亮0.0表示完全在阴影中 fixed shadow SHADOW_ATTENUATION(i); // 采样或计算水面自身的透明度 alpha fixed alpha tex2D(_MainTex, i.uv).a * _Color.a; // 核心混合逻辑阴影强度应随着水面透明度增加而减弱 // 方法1线性插值。当alpha0全透明时shadowFactor1无阴影。当alpha1时shadowFactorshadow。 fixed shadowFactor lerp(1.0, shadow, alpha); // 方法2非线性映射可以让半透明区域的阴影衰减更明显 // fixed shadowFactor lerp(1.0, shadow, pow(alpha, 0.7)); color.rgb * shadowFactor; // 应用调制后的阴影 // 如果是Alpha Blend还需要与背景混合 return fixed4(color.rgb, alpha); }这种方法将阴影接收的控制权完全掌握在自己手中。你可以根据艺术需求设计不同的映射曲线线性、指数、自定义曲线来精确控制不同透明度区域下的阴影表现。例如你可以让浅水区高透明度的阴影几乎不可见而深水区低透明度的阴影则接近实体。6. 完整Shader代码整合与参数调试将上述所有部分整合到一个完整的Shader中并暴露关键参数给材质面板进行调试是最终落地的环节。6.1 属性与模式切换我们需要在属性块和CGPROGRAM中定义开关让美术可以在Alpha Test和Alpha Blend模式间切换。Properties { // ... 其他属性 [Enum(AlphaTest,0, AlphaBlend,1)] _BlendMode (Blend Mode, Float) 0 _Cutoff (Alpha Cutoff, Range(0,1)) 0.5 _ShadowAlphaScale (Shadow Alpha Scale, Range(0,2)) 1.0 }_ShadowAlphaScale是一个很有用的调试参数用于全局缩放透明度对阴影的影响强度。大于1会增强阴影即使透明也有较深阴影小于1会减弱阴影。在SubShader顶部根据_BlendMode设置渲染状态和编译指令SubShader { Tags { ... } Blend [_SrcBlend] [_DstBlend] // 通过变量控制混合模式 ZWrite [_ZWrite] CGPROGRAM #pragma surface surf StandardTransparent alpha:fade vertex:vert #pragma shader_feature _ALPHATEST_ON #pragma shader_feature _ALPHABLEND_ON // 根据材质属性动态设置关键字 #pragma multi_compile __ _ALPHATEST_ON _ALPHABLEND_ON // 在C#代码或材质检查器中根据_BlendMode的值来启用对应的关键字6.2 统一的顶点与片元函数编写统一的顶点函数处理顶点动画如波浪和阴影坐标变换。void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); // 简单的正弦波顶点动画 float wave sin(_Time.y * 2 v.vertex.x * 0.1) * 0.05; v.vertex.y wave; // 计算世界空间法线用于光照 float3 worldNormal UnityObjectToWorldNormal(v.normal); // 传递阴影坐标 TRANSFER_SHADOW(o); }在片元函数中整合颜色计算、阴影采样和透明度混合。void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 tex tex2D(_MainTex, IN.uv_MainTex); fixed4 c tex * _Color; o.Albedo c.rgb; o.Metallic _Metallic; o.Smoothness _Glossiness; o.Alpha c.a; #ifdef _ALPHATEST_ON clip(o.Alpha - _Cutoff); #endif } fixed4 fragFinal (v2f i) : SV_Target { // ... 执行surf函数获取表面数据 // ... 计算光照得到初版颜色litColor fixed shadow SHADOW_ATTENUATION(i); float finalAlpha s.Alpha; // 阴影透明度调制 float shadowMod lerp(1.0, shadow, saturate(finalAlpha * _ShadowAlphaScale)); litColor.rgb * shadowMod; // 最终输出 #ifdef _ALPHABLEND_ON return fixed4(litColor.rgb, finalAlpha); #else return fixed4(litColor.rgb, 1.0); #endif }6.3 材质调试技巧分层调试创建两个材质一个使用标准不透明Shader一个使用我们这个透明阴影Shader。将水面模型复制一份分别赋予这两个材质。通过切换显示可以直观对比阴影效果的差异。光源角度平行光的角度对水面阴影效果影响巨大。低角度的光源能拉长阴影更容易观察水面的阴影接收和投射效果。阴影距离与分辨率在Quality Settings中调整阴影距离和Shadow Resolution。对于大面积水面可能需要较高的阴影分辨率来捕捉细节同时注意性能开销。透明度渐变贴图使用一张从中心到边缘渐变的透明度贴图来控制水面的透明度分布例如岸边浅水区更透明。这能更好地测试阴影接收的渐变效果。使用Frame DebuggerUnity的Frame Debugger是神器。逐步查看“Shadow Map”绘制阶段确认你的自定义ShadowCaster Pass是否被调用以及渲染出的阴影贴图是否包含了带有透明度信息的图案。7. 性能考量与进阶优化实现功能只是第一步在项目中实际使用必须考虑性能。7.1 性能开销分析额外的Draw Call自定义的ShadowCaster Pass意味着在渲染阴影贴图时水面物体会被额外绘制一次。对于复杂的顶点动画水面这会增加GPU负担。Shader复杂度片元着色器中的随机数计算、条件判断discard会降低GPU并行效率特别是在移动端。阴影贴图采样在接收阴影时每个像素都需要采样阴影贴图。对于大面积水面这是一笔固定的开销。7.2 针对性优化策略基于距离的简化对于远离相机的水面可以动态切换到一个更简单的、不处理半透明阴影的Shader变体使用#pragma multi_compile_fwdadd配合LOD或脚本控制。因为远处细节看不清复杂的阴影计算得不偿失。减少Discard操作在Alpha Blend的阴影投射中discard指令非常昂贵。可以尝试用alpha to coverage配合多重采样渲染目标MSAA来替代但需要硬件支持且配置复杂。另一种方法是使用clip()但其性能与discard类似。在移动端如果性能吃紧可以考虑为Alpha Blend物体使用一个固定的、较低的透明度来生成阴影即“实体阴影但颜色变淡”这是一种牺牲效果保性能的折中方案。烘焙静态阴影如果场景是静态的可以考虑将主要建筑物、地形等对水面的阴影通过光照烘焙Lightmapping到一张光照贴图中。这样实时阴影只需要处理动态物体如角色、船只可以大幅降低实时阴影的开销和复杂度。使用屏幕空间阴影Screen Space Shadows如果项目使用延迟渲染或支持屏幕空间阴影技术可以研究如何将透明度因素整合到屏幕空间的阴影计算中。这通常比修改阴影贴图生成更高效但实现方式依赖于渲染管线。7.3 平台适配注意事项移动平台OpenGL ESdiscard和clip在移动GPU上性能影响显著应尽量避免或限制使用。随机数函数如frac(sin(...))的精度和性能也需要测试。优先考虑使用贴图采样的伪随机噪声。WebGL需要注意着色器变体数量。#pragma multi_compile会产生多个变体增加构建大小和加载时间。确保只编译必要的变体。SRPURP/HDRP在可编程渲染管线中阴影的生成和接收流程可能与内置管线不同。URP提供了ShadowCaster渲染器特性需要编写对应的HLSL代码并挂载到Renderer Feature上。HDRP则有更复杂的材质和光照系统可能需要使用自定义的Master Stack或Unlit Master Node来实现类似功能。核心原理相通但API和实现路径差异较大。实现一个完美的半透明水面阴影是一个在视觉质量、性能开销和实现复杂度之间不断权衡的过程。从最基本的ShadowCaster Pass重写到精细的阴影接收调制再到跨平台的优化每一步都需要根据项目具体需求进行调整。掌握了这套流程你不仅能解决水面的阴影问题其原理同样适用于玻璃、纱窗、粒子特效等任何需要半透明阴影的场合这无疑是Shader编程能力的一次实质性飞跃。

相关新闻