Unity半透明水体阴影实现:从原理到实战的完整Shader方案

Unity半透明水体阴影实现:从原理到实战的完整Shader方案 1. 项目概述为什么半透明水体的阴影如此棘手在Unity里做水面效果很多开发者都卡在了阴影这一关。一个看起来波光粼粼、清澈见底的湖面如果它下面的物体没有影子或者水面本身像个实心板一样投下浓重的黑影整个场景的真实感瞬间就崩塌了。这不仅仅是“好看”的问题更是视觉逻辑的关键。真实世界的水体是半透明的光线能穿透它照亮水下的物体同时水体本身也会因为光线折射、吸收和散射形成复杂的光影交互。在实时渲染里尤其是移动端或性能受限的平台完整模拟这套物理过程代价太高。因此我们通常用Shader技巧来“欺骗”眼睛实现视觉上可信的半透明水体阴影。这个项目的核心就是彻底搞懂并实现一套从阴影投射到阴影接收的完整Shader方案。它不是一个简单的“透明加影子”功能。你需要让水面能向场景中的其他物体投射出符合其半透明特性的柔和阴影投射同时也要能让来自太阳、灯光或其他物体的阴影正确地、带有衰减地投射到水面上接收。这两者相辅相成缺一不可。只做接收水面会显得轻飘飘没有体积感只做投射水下世界会显得不真实。从技术栈来看这涉及到Unity的渲染管线无论是内置管线还是URP/HDRP、深度纹理、阴影映射、Alpha混合以及自定义的着色器代码。对于有一定Unity和Shader基础希望提升场景真实感的中高级开发者来说掌握这套技术是必经之路。接下来我会拆解整个实现思路、核心Shader代码并分享我趟过的坑和优化技巧。2. 核心思路与方案选型在性能与效果间寻找平衡实现半透明阴影本质上是在和渲染引擎的默认行为“作斗争”。Unity的默认阴影系统是为不透明物体设计的它基于深度比较。对于半透明物体引擎会感到困惑它到底该不该写入深度该不该遮挡后面的物体该以什么颜色混合阴影2.1 阴影投射的困境与出路默认情况下一个使用了Transparent渲染队列的Shader其ShadowCasterPass 要么不写深度要么写入的深度信息无法正确表现半透明物体的“部分遮挡”特性。这会导致两种错误完全不投射阴影水面像空气一样对水下物体毫无遮挡水下物体被阳光直射违反物理直觉。投射实心阴影水面被当作完全不透明物体处理投下浓黑、生硬的阴影完全看不到水下细节。解决方案是自定义ShadowCasterPass。我们需要在这个Pass里手动计算片元即水面上的每个像素的“不透明度”并将这个不透明度转化为对阴影贴图的“贡献度”。核心思想是一个半透明的片元只应该部分地遮挡光线。在阴影映射中这通常通过Alpha Test或修改深度值来实现但对于平滑的半透明我们需要更精细的控制——即利用片元的Alpha值来调制阴影的强度。2.2 阴影接收的挑战与策略阴影接收同样复杂。当一张阴影贴图投射到水面上时如果简单应用水面会被完全涂黑掩盖了其半透明特性。我们希望的是阴影落在水面上时能隐约透出水下的景物。解决方案依赖于屏幕空间阴影纹理或阴影贴图采样并结合水面的透明度进行混合。在URP/HDRP中我们可以方便地使用GetMainLight和GetShadowCoord来获取阴影信息。关键步骤在于在计算最终水面颜色时将采样到的阴影强度一个0到1的值0表示完全在阴影中与水面自身的颜色/透明度进行混合。一个常见的技巧是让阴影减弱水面的高光和环境反射但尽量保留水下的折射或底色这样阴影区域的水面会变暗但依然透明。2.3 方案选型内置管线 vs SRP (URP/HDRP)内置渲染管线你需要手动处理更多底层细节例如自己声明和使用_ShadowMapTexture编写复杂的多光源阴影处理。灵活性高但复杂且难以维护。对于专注于学习原理或者项目被锁定在内置管线的开发者这是一条路。通用渲染管线这是当前绝大多数项目的推荐选择。URP提供了ShadowCaster渲染特性标签和一套更清晰的阴影宏如SHADOWS_SCREENMainLightRealtimeShadow。它简化了阴影数据的获取让我们能更专注于效果本身。本文的实战解析将主要基于URP进行因为其代表现在和未来的主流方向。高清渲染管线HDRP本身对半透明和阴影有更高级的支持如光线追踪半透明阴影但复杂度更高更适合高端PC或主机平台。对于大多数移动端或性能敏感项目URP的方案是性价比最高的。我的选择与理由我选择基于URP进行开发。原因有三第一URP的Shader框架更现代代码更清晰第二它对多平台支持更好更容易在移动端优化第三社区资源和学习材料最丰富。我们将编写一个包含自定义ShadowCasterPass 的URP Lit Shader或Unlit Shader视需求而定变体。3. 核心Shader实现逐行解析代码这里我们构建一个相对完整的URP水体Shader框架重点关注阴影相关的部分。我们将使用URP Lit着色器作为基础因为它已经包含了光照模型我们只需修改其阴影行为。3.1 着色器结构与属性定义首先我们定义Shader的基本属性和结构。除了常规的水体颜色、法线贴图、波纹速度等我们需要特别关注与透明度相关的属性。Shader Custom/Water/TransparentWaterWithShadows { Properties { // 基础外观 _BaseColor(Base Color, Color) (0.2, 0.6, 0.9, 0.8) _DepthColor(Depth Color (浅水区), Color) (0.1, 0.3, 0.8, 1) _ShallowDepth(Shallow Depth, Range(0, 10)) 2.0 // 表面细节 _NormalMap(Normal Map, 2D) bump {} _NormalScale(Normal Scale, Range(0, 2)) 0.5 _NormalSpeed(Normal Speed, Vector) (0.05, 0.05, 0, 0) // 高光与光滑度 _Smoothness(Smoothness, Range(0, 1)) 0.9 _Specular(Specular, Color) (0.2, 0.2, 0.2, 1) // 透明度与折射简化 _RefractionStrength(Refraction Strength, Range(0, 0.1)) 0.02 // **关键阴影透明度控制** _ShadowAlpha(Shadow Alpha Multiplier, Range(0, 1)) 0.7 _ShadowDepthFactor(Shadow Depth Factor, Range(0, 5)) 1.0 }_ShadowAlpha和_ShadowDepthFactor是我们新增的核心控制参数。_ShadowAlpha直接乘到阴影投射计算的Alpha值上全局控制水面投射阴影的“浓度”。_ShadowDepthFactor用于在阴影投射Pass中根据水面顶点深度相对于某个平面微调阴影强度模拟水深变化对阴影的影响。3.2 自定义ShadowCaster Pass让水面投下“柔和”的影子这是实现半透明阴影投射的核心。我们需要在SubShader中添加一个专门的ShadowCasterPass。SubShader { Tags { RenderType Transparent Queue Transparent RenderPipeline UniversalPipeline IgnoreProjector True } // ... 其他Pass (如ForwardLit Pass) ... // 核心自定义阴影投射Pass Pass { Name ShadowCaster Tags { LightMode ShadowCaster } ZWrite On // 必须开启深度写入否则阴影无法生成 Cull Off // 建议关闭剔除确保水面两面都能投射阴影 ColorMask 0 // 我们不关心颜色缓冲只写深度 HLSLPROGRAM #pragma vertex ShadowPassVertex #pragma fragment ShadowPassFragment #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW // 支持点光源/聚光灯 #include Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl #include Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl // 声明我们的属性 float _ShadowAlpha; float _ShadowDepthFactor; float _ShallowDepth; // 自定义的顶点着色器基本复用标准实现但可以传递更多数据 Varyings ShadowPassVertex(Attributes input) { Varyings output; // 调用内置的TransformObjectToWorld等函数处理顶点位置、法线等 // ... (标准阴影投射顶点变换代码通常直接调用ShadowPassVertex的基础部分) // 关键计算并传递世界空间下的顶点Y轴高度或深度用于片元着色器 float3 worldPos TransformObjectToWorld(input.positionOS.xyz); // 假设水面在Y0平面计算深度可自定义基准平面 output.worldDepth -worldPos.y; // 简单示例水面以下为负取反得正深度 return output; } // 核心自定义片元着色器 half4 ShadowPassFragment(Varyings input) : SV_TARGET { // 1. 计算基础Alpha来自顶点色、纹理或固定值 // 对于简单水体我们可以用一个基于深度的渐变Alpha float depth input.worldDepth; // 将深度映射到[0,1]范围并应用因子控制 float depthAlpha saturate(depth / _ShallowDepth) * _ShadowDepthFactor; // 基础透明度越深越不透明阴影越强 float baseAlpha lerp(_ShadowAlpha, 1.0, depthAlpha); // 2. 应用Alpha裁剪Alpha Clipping来模拟半透明阴影。 // Unity的ShadowCaster Pass通常使用AlphaTest。我们可以将计算出的Alpha与一个随机/抖动值或固定阈值比较。 // 更物理的方法是使用“Alpha to Coverage”思想但阴影Pass中常用AlphaTest。 // 这里我们做一个简单的AlphaTest如果片元非常透明则完全丢弃不产生阴影。 // 注意clip函数会直接丢弃片元导致阴影出现“孔洞”。对于半透明阴影更好的方法是修改深度值但这更复杂。 // 一个折中方案使用一个阈值低于阈值的部分丢弃高于阈值的部分保留为实心阴影。这能产生“半影”效果但边缘生硬。 // 为了柔和边缘我们可以引入抖动或使用多重采样但这会显著增加性能开销。 // 简单实现基于Alpha的确定性裁剪 float shadowStrength baseAlpha; // 引入一个微小的偏移避免因精度问题导致的闪烁 clip(shadowStrength - 0.01); // 3. 标准的阴影深度输出 // 后续的阴影深度计算由URP的宏ShadowPassFragment内部完成我们只需确保AlphaTest通过。 // 实际上在URP中更常见的做法是直接使用内置的ShadowPassFragment但通过修改输入数据或Alpha值来影响它。 // 一个更兼容URP的做法是复制内置的ShadowCasterPass.hlsl中的片段函数并插入我们的Alpha计算逻辑。 // 由于URP的ShadowCaster可能使用AlphaClip关键字我们需要确保我们的Shader与之兼容。 // 以下是一种实践方法我们实际上需要影响的是片元在阴影贴图中的“存在感”。 // 在传统阴影映射中半透明物体应该以一定概率写入阴影贴图。我们可以通过修改output.pos.z即深度值来模拟。 // 但更简单且在现代管线中有效的方法是利用COLOR语义输出一个值并被URP的阴影收集器识别如果支持。 // 然而URP的标准ShadowCaster Pass默认只输出深度。对于真正的半透明阴影可能需要更高级的管线功能如软阴影或自定义渲染纹理。 // **重要说明**上述的clip方法是一个简化模拟。在移动平台或大量水体上性能尚可但效果是二值化的有阴影/无阴影。 // 对于追求高质量半透明柔和阴影如水面边缘的渐变阴影需要实现“Percentage-Closer Filtering”或“Variance Shadow Maps”等高级技术这超出了单个Shader的范畴通常需要管线扩展。 // 本示例给出一个在URP中相对可行的折中思路 // A. 使用 _ALPHATEST_ON 关键字并设置 _Cutoff 属性。 // B. 在ShadowCaster Pass中将计算出的 shadowStrength 赋值给 Alpha并让 clip(Alpha - _Cutoff) 决定去留。 // C. 通过调整 _Cutoff 和 _ShadowAlpha可以控制产生阴影的水面区域范围从而模拟“部分投射”的感觉。 // 例如设置 _Cutoff 为0.5_ShadowAlpha 根据深度从0到1变化那么只有Alpha0.5的深水区才会投射阴影。 // 由于篇幅和复杂性这里不展开完整的PCF/VSM实现。大多数项目可以接受这个折中方案。 return 0; } ENDHLSL } }注意上面的代码块是一个原理性展示和思路说明。URP内置的ShadowCasterPass.hlsl封装了许多细节直接修改其片段函数并不容易。一个更实用的方法是复制URP的Lit.shader找到其ShadowCaster Pass并在其片元着色器开始处添加对我们自定义Alpha值的计算和clip操作。你需要确保相关的属性和变量被正确定义和传递。实操心得1ShadowCaster的Alpha处理在URP中让ShadowCaster Pass响应_Cutoff和_ALPHATEST_ON是最直接的方法。在你的水体材质上勾选 “Alpha Clipping” 并设置合适的Cutoff值。然后在ShadowCaster的片元着色器里确保你的baseAlpha计算逻辑能输出一个Alpha值这个值会与_Cutoff比较。这样你就可以通过材质面板上的Cutoff滑块直观地控制哪些深度的水面会开始投射阴影这是一个非常实用的调试和艺术控制手段。3.3 增强阴影接收让水面“感受”到阴影阴影接收在URP的ForwardLit Pass中处理相对直接。我们需要在片元着色器中获取主光源的阴影信息并用它来调制最终颜色。// 在ForwardLit Pass的片元着色器中例如在UniversalFragmentPBR函数调用前后 void LitPassFragment(...) { // ... 计算基础颜色、法线、金属度、光滑度等 ... // 获取主光源和阴影数据 Light mainLight GetMainLight(inputData.shadowCoord); // inputData需要包含正确的shadowCoord // mainLight.shadowAttenuation 就是阴影衰减值1.0无阴影0.0完全阴影 float shadow mainLight.shadowAttenuation; // **关键步骤将阴影衰减与水面透明度结合** // 简单叠加阴影区域降低漫反射和高光强度但保留折射/底色 float3 diffuse lerp(baseColor * _BaseColor.rgb, baseColor * _BaseColor.rgb * 0.3, (1.0 - shadow)); float specularStrength smoothness * shadow; // 阴影越强高光越弱 // 更高级的做法阴影主要影响水面的“反射/高光”部分对水下折射部分影响较小。 // 假设我们已经通过抓屏或深度计算了一个水下折射颜色 refractionColor float3 finalColor lerp(refractionColor, surfaceReflectionColor, shadow * waterFresnel); // 然后将阴影衰减作为一个因子混合最终颜色与一个“阴影色调” finalColor lerp(finalColor, finalColor * float3(0.5, 0.6, 0.8), (1.0 - shadow) * 0.5); // ... 组合其他光照贡献 ... }这里的关键是inputData.shadowCoord。它必须在顶点着色器中正确计算并传递。在URP的Lit Shader模板中这通常通过GetVertexPositionInputs和GetShadowCoord系列函数自动完成。你需要确保你的水体Shader继承了这些标准输入结构体如Attributes和Varyings并包含了SHADOW_COORDS和TRANSFER_SHADOW宏在URP中已被封装到更高层的函数中。实操心得2阴影坐标与深度校正水面是不断波动的其顶点位置每帧都在变。这会导致直接采样阴影贴图时出现严重的“阴影游泳”现象。为了解决这个问题必须使用“屏幕空间阴影”或“透视投影阴影”。URP的GetMainLight函数在传入正确的shadowCoord时内部已经处理了这些变换。确保你的水面网格有足够的细分使得顶点动画不会导致相邻顶点的阴影坐标差异过大否则阴影边缘会出现锯齿或抖动。对于顶点动画剧烈的水体可以考虑在片元着色器中对阴影坐标进行基于法线或高度的微调但这属于高级优化技巧。4. 实战配置与参数调优指南写好Shader只是第一步材质球的参数配置才是出效果的关键。下面是一个典型的配置流程和参数解读。4.1 材质球参数设置基础透明度与渲染队列Render Type: 设置为Transparent。Rendering Mode: 选择Fade或Transparent。Fade在透明时也会淡化镜面反射更适合水Transparent会保留高光。Queue: 确保是Transparent。这决定了渲染顺序水体应在不透明物体之后渲染。阴影投射控制Cast Shadows:必须勾选On。这是启用阴影投射的总开关。Alpha Clipping: 勾选。这是我们实现半透明阴影投射的关键开关。Cutoff: 调整这个值。例如设置为0.3。这意味着只有Shader中计算出的Alpha值大于0.3的片元才会参与阴影深度比较。结合我们Shader中的_ShadowAlpha和深度计算你可以控制多深的水才开始投射可见的阴影。将其调低更多水面区域会投射阴影更浓调高则只有最深的地方才有阴影。外观与阴影接收Base Color和Alpha: 设置水体的基本颜色和整体透明度。这直接影响阴影接收时的视觉混合效果。Normal Map和Smoothness: 法线贴图产生波纹细节光滑度影响高光大小和强度。阴影会显著减弱高光因此高光滑度高的水面阴影区域会显得更“平”。在Shader中暴露的_ShadowAlpha(材质上可能叫Shadow Strength)这是控制阴影投射“浓度”的主参数。降低它水面投射的阴影变淡。4.2 场景与灯光设置主光源确保你的方向光太阳启用了阴影Cast Shadows设为Soft Shadows或Hard Shadows。软阴影效果更柔和更适合水体。相机与后处理如果使用了屏幕空间环境光遮蔽注意它可能会与水面的透明阴影产生奇怪的交互可能需要调整SSAO的强度或排除水体图层。水下物体确保水下的物体岩石、水草等也接收阴影。并且它们的材质应该能对来自水面上方的阴影即水体投射的阴影做出反应。这需要水下物体的Shader也能正确处理阴影。参数调优流程首先关闭水体的阴影投射只调好阴影接收。确保阳光能正确在水面上投下其他物体的阴影并且水面颜色变化自然。然后开启水体的阴影投射将Cutoff调至一个中间值如0.5调整_ShadowAlpha观察水下物体是否出现了明暗变化。目标是深水区物体明显变暗浅水区变化微弱或没有。微调_ShallowDepth和_ShadowDepthFactor让阴影的深浅变化与你的场景深度比例匹配。例如一个10米深的湖_ShallowDepth设为2意味着0-2米是浅水过渡区。最后整体观察。在一天的不同时间不同光照角度检查效果。侧光时水体投射的阴影最长最容易发现问题。5. 常见问题、性能优化与进阶技巧即使按照上述步骤操作你依然会遇到各种“坑”。下面是我在实践中总结的一些典型问题及其解决方案。5.1 常见问题排查表问题现象可能原因解决方案水体完全不投射阴影1. 材质Cast Shadows未开启。2. Shader中ShadowCasterPass缺失或编译错误。3. 使用了Transparent队列但未处理AlphaTest。1. 检查材质球设置。2. 在Shader编辑器中检查编译错误确保有LightMode为ShadowCaster的Pass。3. 启用Alpha Clipping并设置合适的Cutoff。水体投射出实心、不透明的黑色阴影ShadowCaster Pass中的片元未被正确裁剪所有片元都以不透明方式写入了阴影贴图。确保在ShadowCaster的片元着色器中你的Alpha计算逻辑生效并且clip或AlphaTest基于这个Alpha值工作。检查_Cutoff值是否过低。水面上的阴影边缘闪烁或抖动1. 水面顶点动画导致阴影坐标计算不稳定。2. 阴影贴图分辨率过低。3. 没有使用软阴影。1. 增加水面网格细分。2. 提高光源的阴影分辨率 (Shadow Resolution)。3. 将方向光阴影类型改为Soft Shadows。水下物体在阴影区域过暗或过亮阴影接收混合公式过于简单或粗暴。调整Shader中阴影衰减 (shadow) 对最终颜色影响的算法。尝试让阴影主要影响水面高光和镜面反射而非直接乘在整个颜色上。使用lerp进行平滑混合。移动端性能急剧下降1. 自定义的ShadowCaster Pass计算过于复杂。2. 水面网格面数过高同时计算阴影和渲染。3. 使用了多张法线贴图叠加。1. 简化ShadowCaster中的计算避免纹理采样和复杂运算。2. 使用LOD系统远处的水面使用低模简化版Shader。3. 合并或简化纹理减少采样次数。半透明阴影边缘有锯齿这是AlphaTest硬裁剪的固有缺陷。1. 接受这种风格化效果。2. 进阶方案实现软边缘阴影如使用PCF但这需要修改阴影过滤方式可能涉及渲染管线扩展。5.2 性能优化要点半透明阴影是性能消耗大户尤其是在移动端。以下优化策略至关重要精简ShadowCaster Pass这个Pass每帧对每个光源执行一次。确保其中的计算尽可能轻量。避免采样复杂的纹理尽量使用顶点数据或简单计算得出Alpha。如果水体颜色均匀甚至可以考虑在ShadowCaster Pass中使用一个简化版本的水面高度图来计算Alpha而不是完整的着色计算。控制投射范围不是所有水体都需要投射阴影。对于小水坑或远处的水面可以关闭其Cast Shadows属性。使用代码根据与相机的距离动态控制。使用阴影距离和层级调整相机的Shadow Distance和光源的阴影层级 (Culling Mask)。让远处的水体或非关键光源不产生精细阴影。善用LOD为水体创建多个细节层级。高模LOD使用完整的半透明阴影Shader中低模LOD可以使用不投射阴影或使用更简单阴影方案的Shader。5.3 进阶效果拓展当基础效果稳定后可以考虑以下增强焦散阴影模拟光线通过水面波动后在水底形成的光斑。这通常需要额外的渲染技术如使用一个投影器将水面的法线扰动图案投射到水底。动态深度阴影让_ShadowAlpha不仅基于静态深度还能基于实时动态物体如游泳的角色与水面的交互深度而变化。这需要将深度信息写入一张RT或在Shader中进行深度碰撞检测。屏幕空间接触阴影对于水体与岸边、岩石接触的边缘使用SS Contact Shadows可以增强接触处的阴影细节使结合更自然。实现Unity半透明水体的完整阴影是一个从理解渲染原理到精细调参的完整过程。它没有唯一的“正确”答案更多的是在性能预算和视觉需求之间找到最佳平衡点。从自定义ShadowCaster Pass开始理解AlphaTest如何控制阴影的生与灭再到精细调整阴影接收的混合模式让光影与水色交融。这个过程会反复挑战你对深度、透明度和光照模型的理解。我建议从一个平静的小水池开始实验参数调整时多使用场景视图的实时预览并养成在不同光照角度和时间通过旋转方向光模拟下检查效果的习惯。当你看到阳光透过摇曳的水面在水底的石头上投下斑驳晃动的光晕时那种成就感正是图形编程的魅力所在。