Unity半透明水体阴影实现:从原理到实战的完整Shader方案
2026/8/1 11:16:15 网站建设 项目流程

1. 项目概述:为什么半透明水体的阴影如此棘手?

在Unity里做水面效果,很多开发者都卡在了阴影这一关。一个看起来波光粼粼、清澈见底的湖面,如果它下面的物体没有影子,或者水面本身像个实心板一样投下浓重的黑影,整个场景的真实感瞬间就崩塌了。这不仅仅是“好看”的问题,更是视觉逻辑的关键。真实世界的水体是半透明的,光线能穿透它,照亮水下的物体,同时水体本身也会因为光线折射、吸收和散射,形成复杂的光影交互。在实时渲染里,尤其是移动端或性能受限的平台,完整模拟这套物理过程代价太高。因此,我们通常用Shader技巧来“欺骗”眼睛,实现视觉上可信的半透明水体阴影。

这个项目的核心,就是彻底搞懂并实现一套从阴影投射阴影接收的完整Shader方案。它不是一个简单的“透明加影子”功能。你需要让水面能向场景中的其他物体投射出符合其半透明特性的柔和阴影(投射),同时,也要能让来自太阳、灯光或其他物体的阴影,正确地、带有衰减地投射到水面上(接收)。这两者相辅相成,缺一不可。只做接收,水面会显得轻飘飘没有体积感;只做投射,水下世界会显得不真实。

从技术栈来看,这涉及到Unity的渲染管线(无论是内置管线还是URP/HDRP)、深度纹理、阴影映射、Alpha混合以及自定义的着色器代码。对于有一定Unity和Shader基础,希望提升场景真实感的中高级开发者来说,掌握这套技术是必经之路。接下来,我会拆解整个实现思路、核心Shader代码,并分享我趟过的坑和优化技巧。

2. 核心思路与方案选型:在性能与效果间寻找平衡

实现半透明阴影,本质上是在和渲染引擎的默认行为“作斗争”。Unity的默认阴影系统是为不透明物体设计的,它基于深度比较。对于半透明物体,引擎会感到困惑:它到底该不该写入深度?该不该遮挡后面的物体?该以什么颜色混合阴影?

2.1 阴影投射的困境与出路

默认情况下,一个使用了Transparent渲染队列的Shader,其ShadowCasterPass 要么不写深度,要么写入的深度信息无法正确表现半透明物体的“部分遮挡”特性。这会导致两种错误:

  1. 完全不投射阴影:水面像空气一样,对水下物体毫无遮挡,水下物体被阳光直射,违反物理直觉。
  2. 投射实心阴影:水面被当作完全不透明物体处理,投下浓黑、生硬的阴影,完全看不到水下细节。

解决方案是自定义ShadowCasterPass。我们需要在这个Pass里,手动计算片元(即水面上的每个像素)的“不透明度”,并将这个不透明度转化为对阴影贴图的“贡献度”。核心思想是:一个半透明的片元,只应该部分地遮挡光线。在阴影映射中,这通常通过Alpha Test或修改深度值来实现,但对于平滑的半透明,我们需要更精细的控制——即利用片元的Alpha值来调制阴影的强度。

2.2 阴影接收的挑战与策略

阴影接收同样复杂。当一张阴影贴图投射到水面上时,如果简单应用,水面会被完全涂黑,掩盖了其半透明特性。我们希望的是,阴影落在水面上时,能隐约透出水下的景物。

解决方案依赖于屏幕空间阴影纹理或阴影贴图采样,并结合水面的透明度进行混合。在URP/HDRP中,我们可以方便地使用GetMainLightGetShadowCoord来获取阴影信息。关键步骤在于,在计算最终水面颜色时,将采样到的阴影强度(一个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); // 假设水面在Y=0平面,计算深度(可自定义基准平面) 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变化,那么只有Alpha>0.5的深水区才会投射阴影。 // 由于篇幅和复杂性,这里不展开完整的PCF/VSM实现。大多数项目可以接受这个折中方案。 return 0; } ENDHLSL } }

注意:上面的代码块是一个原理性展示和思路说明。URP内置的ShadowCasterPass.hlsl封装了许多细节,直接修改其片段函数并不容易。一个更实用的方法是,复制URP的Lit.shader,找到其ShadowCaster Pass,并在其片元着色器开始处,添加对我们自定义Alpha值的计算和clip操作。你需要确保相关的属性和变量被正确定义和传递。

实操心得1:ShadowCaster的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模板中,这通常通过GetVertexPositionInputsGetShadowCoord系列函数自动完成。你需要确保你的水体Shader继承了这些标准输入结构体(如AttributesVaryings),并包含了SHADOW_COORDSTRANSFER_SHADOW宏(在URP中已被封装到更高层的函数中)。

实操心得2:阴影坐标与深度校正水面是不断波动的,其顶点位置每帧都在变。这会导致直接采样阴影贴图时出现严重的“阴影游泳”现象。为了解决这个问题,必须使用“屏幕空间阴影”或“透视投影阴影”。URP的GetMainLight函数在传入正确的shadowCoord时,内部已经处理了这些变换。确保你的水面网格有足够的细分,使得顶点动画不会导致相邻顶点的阴影坐标差异过大,否则阴影边缘会出现锯齿或抖动。对于顶点动画剧烈的水体,可以考虑在片元着色器中对阴影坐标进行基于法线或高度的微调,但这属于高级优化技巧。

4. 实战配置与参数调优指南

写好Shader只是第一步,材质球的参数配置才是出效果的关键。下面是一个典型的配置流程和参数解读。

4.1 材质球参数设置

  1. 基础透明度与渲染队列

    • Render Type: 设置为Transparent
    • Rendering Mode: 选择FadeTransparentFade在透明时也会淡化镜面反射,更适合水;Transparent会保留高光。
    • Queue: 确保是Transparent。这决定了渲染顺序,水体应在不透明物体之后渲染。
  2. 阴影投射控制

    • Cast Shadows:必须勾选On。这是启用阴影投射的总开关。
    • Alpha Clipping: 勾选。这是我们实现半透明阴影投射的关键开关。
    • Cutoff: 调整这个值。例如,设置为0.3。这意味着只有Shader中计算出的Alpha值大于0.3的片元才会参与阴影深度比较。结合我们Shader中的_ShadowAlpha和深度计算,你可以控制:多深的水才开始投射可见的阴影。将其调低,更多水面区域会投射阴影(更浓);调高,则只有最深的地方才有阴影。
  3. 外观与阴影接收

    • Base ColorAlpha: 设置水体的基本颜色和整体透明度。这直接影响阴影接收时的视觉混合效果。
    • Normal MapSmoothness: 法线贴图产生波纹细节,光滑度影响高光大小和强度。阴影会显著减弱高光,因此高光滑度高的水面,阴影区域会显得更“平”。
    • 在Shader中暴露的_ShadowAlpha(材质上可能叫Shadow Strength):这是控制阴影投射“浓度”的主参数。降低它,水面投射的阴影变淡。

4.2 场景与灯光设置

  • 主光源:确保你的方向光(太阳)启用了阴影(Cast Shadows设为Soft ShadowsHard Shadows)。软阴影效果更柔和,更适合水体。
  • 相机与后处理:如果使用了屏幕空间环境光遮蔽,注意它可能会与水面的透明阴影产生奇怪的交互,可能需要调整SSAO的强度或排除水体图层。
  • 水下物体:确保水下的物体(岩石、水草等)也接收阴影。并且,它们的材质应该能对来自水面上方的阴影(即水体投射的阴影)做出反应。这需要水下物体的Shader也能正确处理阴影。

参数调优流程

  1. 首先,关闭水体的阴影投射,只调好阴影接收。确保阳光能正确在水面上投下其他物体的阴影,并且水面颜色变化自然。
  2. 然后,开启水体的阴影投射,将Cutoff调至一个中间值(如0.5),调整_ShadowAlpha,观察水下物体是否出现了明暗变化。目标是:深水区物体明显变暗,浅水区变化微弱或没有。
  3. 微调_ShallowDepth_ShadowDepthFactor,让阴影的深浅变化与你的场景深度比例匹配。例如,一个10米深的湖,_ShallowDepth设为2,意味着0-2米是浅水过渡区。
  4. 最后,整体观察。在一天的不同时间(不同光照角度)检查效果。侧光时水体投射的阴影最长,最容易发现问题。

5. 常见问题、性能优化与进阶技巧

即使按照上述步骤操作,你依然会遇到各种“坑”。下面是我在实践中总结的一些典型问题及其解决方案。

5.1 常见问题排查表

问题现象可能原因解决方案
水体完全不投射阴影1. 材质Cast Shadows未开启。
2. Shader中ShadowCasterPass缺失或编译错误。
3. 使用了Transparent队列但未处理AlphaTest。
1. 检查材质球设置。
2. 在Shader编辑器中检查编译错误,确保有LightModeShadowCaster的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 性能优化要点

半透明阴影是性能消耗大户,尤其是在移动端。以下优化策略至关重要:

  1. 精简ShadowCaster Pass:这个Pass每帧对每个光源执行一次。确保其中的计算尽可能轻量。避免采样复杂的纹理,尽量使用顶点数据或简单计算得出Alpha。如果水体颜色均匀,甚至可以考虑在ShadowCaster Pass中使用一个简化版本的水面高度图来计算Alpha,而不是完整的着色计算。
  2. 控制投射范围:不是所有水体都需要投射阴影。对于小水坑或远处的水面,可以关闭其Cast Shadows属性。使用代码根据与相机的距离动态控制。
  3. 使用阴影距离和层级:调整相机的Shadow Distance和光源的阴影层级 (Culling Mask)。让远处的水体或非关键光源不产生精细阴影。
  4. 善用LOD:为水体创建多个细节层级。高模LOD使用完整的半透明阴影Shader,中低模LOD可以使用不投射阴影或使用更简单阴影方案的Shader。

5.3 进阶效果拓展

当基础效果稳定后,可以考虑以下增强:

  • 焦散阴影:模拟光线通过水面波动后在水底形成的光斑。这通常需要额外的渲染技术,如使用一个投影器将水面的法线扰动图案投射到水底。
  • 动态深度阴影:让_ShadowAlpha不仅基于静态深度,还能基于实时动态物体(如游泳的角色)与水面的交互深度而变化。这需要将深度信息写入一张RT或在Shader中进行深度碰撞检测。
  • 屏幕空间接触阴影:对于水体与岸边、岩石接触的边缘,使用SS Contact Shadows可以增强接触处的阴影细节,使结合更自然。

实现Unity半透明水体的完整阴影,是一个从理解渲染原理到精细调参的完整过程。它没有唯一的“正确”答案,更多的是在性能预算和视觉需求之间找到最佳平衡点。从自定义ShadowCaster Pass开始,理解AlphaTest如何控制阴影的生与灭;再到精细调整阴影接收的混合模式,让光影与水色交融。这个过程会反复挑战你对深度、透明度和光照模型的理解。我建议从一个平静的小水池开始实验,参数调整时多使用场景视图的实时预览,并养成在不同光照角度和时间(通过旋转方向光模拟)下检查效果的习惯。当你看到阳光透过摇曳的水面,在水底的石头上投下斑驳晃动的光晕时,那种成就感,正是图形编程的魅力所在。

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

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

立即咨询