1. 项目概述:为什么半透明水面的阴影是个“老大难”?
在Unity里做水面效果,尤其是那种清澈见底、波光粼粼的半透明水面,几乎是每个3D场景的标配。但当你兴冲冲地调好了折射、反射和高光,把材质球往水面上一拖,一个让人头疼的问题立刻浮现:这水怎么不接收岸边树木和建筑的阴影?或者更糟,水下的物体怎么被水面自己的阴影给“吞”了?这个看似简单的需求——“让水面既能正确投射阴影到水底,又能接收来自外部物体的阴影”——在实际开发中,却是一个需要深入Shader底层才能解决的经典难题。
问题的根源在于Unity默认的渲染管线对透明物体的阴影处理是“简化”甚至“回避”的。标准的不透明物体,阴影计算相对直接;但对于半透明物体,光线如何穿透、阴影如何与背后的颜色混合,涉及复杂的排序和光照计算。Unity的默认Transparent或Alpha Blend着色器,往往在阴影投射(Shadow Casting)和阴影接收(Shadow Receiving)上存在缺陷,要么完全不参与阴影流程,要么产生错误的、不透明的黑色块状阴影,完全破坏了半透明材质应有的通透感。
因此,我们不能依赖现成的Shader,必须手动编写一个定制化的水面Shader。这个Shader的核心目标非常明确:第一,要能正确地将水面本身的形状(包括波浪起伏)作为阴影源,投射到水下的地面或物体上;第二,水面材质本身要能敏感地接收并融合来自场景中其他物体投射的阴影,让岸边的遮挡关系真实地反映在水面颜色上。这不仅仅是加几行代码,而是需要对Unity的阴影映射(Shadow Mapping)技术、深度纹理(Depth Texture)以及自定义光照模型有清晰的理解和实操能力。接下来,我将拆解整个实现过程,从原理到代码,一步步带你搞定这个技术点。
2. 核心原理拆解:阴影如何与半透明表面共舞?
在动手写代码之前,我们必须把背后的光学和图形学原理捋清楚。这能帮助我们在遇到诡异Bug时,知道该从哪个环节排查。
2.1 阴影投射的底层逻辑:深度比较游戏
Unity最常用的实时阴影技术是阴影映射(Shadow Mapping)。其核心流程可以简化为两步:
- 从光源视角渲染:将相机移动到光源位置,看向整个场景,但这次不输出颜色,只记录每个像素距离光源的最近深度值,生成一张“阴影贴图”(Shadow Map)。
- 从相机视角渲染:正常渲染场景时,对于屏幕上的每一个像素点(即一个表面点),我们将其坐标变换回光源的视角空间,计算出该点当前距离光源的深度值(记为
currentDepth)。然后,去采样第一步生成的阴影贴图,获取该点在光源视角下“理论上最近表面的深度”(记为shadowMapDepth)。 - 比较决定明暗:如果
currentDepth > shadowMapDepth + bias(bias是一个很小的偏移量,用于解决深度精度引起的“阴影痤疮”问题),说明当前点被更近的物体遮挡,处于阴影中;反之,则被照亮。
对于我们的半透明水面,在**阴影投射(作为阴影源)**阶段,它需要作为一个“遮挡物”参与第一步。这意味着,我们的Shader必须包含一个ShadowCasterPass。这个Pass的任务不是输出颜色,而是向阴影贴图写入水面的深度信息。关键点在于,写入的深度必须是考虑了水面顶点动画(波浪)后的、正确的深度值,否则投射到水底的阴影形状就是错的。
2.2 阴影接收的混合艺术:当阴影遇见透明度
水面接收阴影发生在第二步的相机视角渲染中。对于不透明物体,接收阴影通常由Unity的Surface Shader或Universal Render Pipeline (URP)的LitShader自动处理。但对于我们自定义的半透明水面,我们需要手动处理阴影因子。
通常,我们会在片元着色器(Fragment Shader)中计算阴影衰减(Shadow Attenuation)。这个值在0到1之间,0表示完全在阴影中,1表示完全被照亮。对于半透明表面,我们不能简单地将阴影色乘以水面色。正确的做法是,将阴影衰减作为一个因子,去影响最终计算出的水面颜色(包括其深度、折射、高光等)。例如,处于阴影下的水面区域,其高光强度应减弱,整体亮度降低,但依然保持半透明特性,能看到水下的内容。这要求我们对光照计算有完全的控制权,因此通常会选择自定义光照模型,而不是依赖标准的Lambert或BlinnPhong。
2.3 半透明与深度缓冲的纠葛
另一个棘手的问题是深度写入(ZWrite)。标准的半透明渲染(Alpha Blend)为了能正确混合多个透明层,通常会关闭深度写入(ZWrite Off)。但这会干扰阴影投射Pass,因为阴影贴图依赖精确的深度值来判定遮挡关系。如果关闭深度写入,水面可能无法正确遮挡它后面的物体,导致水下的物体错误地“浮”到水面阴影之上。因此,我们必须在ShadowCasterPass中重新开启深度写入,并确保其深度计算与主渲染Pass中用于颜色混合的深度计算逻辑一致,否则会产生深度冲突(Z-fighting)的闪烁。
3. Shader框架设计与关键Pass解析
理解了原理,我们就可以搭建Shader的骨架了。一个完整的、支持阴影投射与接收的半透明水面Shader,通常包含以下关键部分:
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 // 法线贴图用于模拟波浪细节 _BumpMap ("Normalmap", 2D) = "bump" {} _BumpScale ("Normal Scale", Float) = 1.0 // 控制波浪的强度和频率 _WaveSpeed ("Wave Speed", Float) = 1.0 _WaveFrequency ("Wave Frequency", Float) = 1.0 _WaveAmplitude ("Wave Amplitude", Float) = 0.1 // 折射失真强度 _Distortion ("Distortion", Range(0, 128)) = 10 // 深度相关参数,用于模拟水深颜色变化 _DepthColor ("Depth Color", Color) = (0,0.5,1,1) _MaxDepth ("Max Depth", Float) = 5 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } LOD 300 // ------------------------------------------------------------------ // Pass 1: 渲染水面颜色(包含阴影接收计算) // ------------------------------------------------------------------ Pass { Name "FORWARD" Tags { "LightMode"="ForwardBase" } // 重要:用于获取主方向光信息 Blend SrcAlpha OneMinusSrcAlpha ZWrite Off // 主透明通道关闭深度写入 Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase nolightmap nodynlightmap nodirlightmap novertexlight // 关键编译指令:启用阴影接收 #pragma multi_compile_shadowscaster #pragma multi_compile_fog // 自定义表面光照函数声明 #pragma surface surf CustomLighting alpha:fade ... ENDCG } // ------------------------------------------------------------------ // Pass 2: 阴影投射Pass (ShadowCaster) // ------------------------------------------------------------------ Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On // 阴影投射必须开启深度写入! ZTest LEqual Cull Back CGPROGRAM #pragma vertex vertShadow #pragma fragment fragShadow #pragma multi_compile_shadowcaster // 确保和主Pass使用相同的顶点变换和波浪计算,保证深度一致性 #pragma multi_compile_instancing ... ENDCG } // 可选:添加其他Pass,如附加光源(ForwardAdd)Pass,用于处理点光源/聚光灯 } FallBack "Transparent/Cutout/VertexLit" // 一个简单的透明回退Shader }关键设计解析:
- 两个独立的Pass:这是核心。
FORWARDPass负责最终的颜色渲染和阴影接收计算。ShadowCasterPass是一个专用通道,仅在渲染阴影贴图时被调用,它只输出深度,不输出颜色。两个Pass必须使用完全相同的顶点变换和波浪位移算法,否则水面渲染的形状和它投射的阴影形状会不匹配,导致阴影“漂移”或错位。 - Tags的重要性:
"LightMode"="ForwardBase"告诉Unity这个Pass用于主方向光的基础渲染。"LightMode"="ShadowCaster"则明确标识这是阴影投射Pass。这些标签是Unity渲染管线正确调度各个Pass的指令。 - 混合与深度状态分离:在主Pass中,我们使用
Blend SrcAlpha OneMinusSrcAlpha实现标准的Alpha混合,并ZWrite Off以避免透明物体间的深度排序问题。在ShadowCaster Pass中,我们ZWrite On,全力确保深度信息被准确写入阴影贴图。 - 编译指令(Pragma):
#pragma multi_compile_shadowscaster和相关的multi_compile指令是为了让Shader兼容不同的渲染路径和光照配置,确保在各种项目设置下阴影功能都能被正确编译和启用。
4. 核心功能实现:波浪、阴影接收与投射
接下来,我们深入每个Pass的内部,看看关键代码如何实现。
4.1 顶点着色器:统一的波浪动画
无论是主Pass还是阴影投射Pass,顶点动画必须保持一致。我们通常在顶点着色器中实现一个简单的正弦波叠加。
// 在CGINCLUDE块中定义,供多个Pass共享 CGINCLUDE #include "UnityCG.cginc" #include "Lighting.cginc" #include "AutoLight.cginc" struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; float2 texcoord : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldPos : TEXCOORD1; float3 worldNormal : TEXCOORD2; float3 worldTangent : TEXCOORD3; float3 worldBinormal : TEXCOORD4; // 阴影坐标,用于阴影接收采样 SHADOW_COORDS(5) UNITY_FOG_COORDS(6) }; // 波浪计算函数 float3 GerstnerWave(float4 wave, float3 p, inout float3 tangent, inout float3 binormal) { float steepness = wave.z; float wavelength = wave.w; float k = 2 * UNITY_PI / wavelength; float c = sqrt(9.8 / k); float2 d = normalize(wave.xy); float f = k * (dot(d, p.xz) - c * _Time.y * _WaveSpeed); float a = steepness / k; tangent += float3( -d.x * d.x * (steepness * sin(f)), d.x * (steepness * cos(f)), -d.x * d.y * (steepness * sin(f)) ); binormal += float3( -d.x * d.y * (steepness * sin(f)), d.y * (steepness * cos(f)), -d.y * d.y * (steepness * sin(f)) ); return float3( d.x * (a * cos(f)), a * sin(f), d.y * (a * cos(f)) ); } v2f vert (appdata v) { v2f o; // 波浪位移计算 float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; float3 tangent = float3(1, 0, 0); float3 binormal = float3(0, 0, 1); float3 waveOffset = 0; // 可以叠加多个不同方向和频率的波 waveOffset += GerstnerWave(float4(1, 0, _WaveAmplitude, 10), worldPos, tangent, binormal); waveOffset += GerstnerWave(float4(0, 1, _WaveAmplitude * 0.8, 15), worldPos, tangent, binormal); v.vertex.xyz += mul(unity_WorldToObject, float4(waveOffset, 0)).xyz; // 重新计算法线(简化版,可通过切线/副切线叉积得到更精确的法线) v.normal = normalize(cross(binormal, tangent)); o.pos = UnityObjectToClipPos(v.vertex); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; o.worldNormal = UnityObjectToWorldNormal(v.normal); o.worldTangent = UnityObjectToWorldDir(v.tangent.xyz); o.worldBinormal = cross(o.worldNormal, o.worldTangent) * v.tangent.w; o.uv = v.texcoord; // 初始化阴影坐标 TRANSFER_SHADOW(o); UNITY_TRANSFER_FOG(o, o.pos); return o; } ENDCG注意事项与心得:
- 波浪一致性:
GerstnerWave函数计算出的位移waveOffset,必须同时应用于主Pass的vert函数和阴影投射Pass的vertShadow函数。我通常会把顶点变换和波浪计算部分提取到一个单独的函数里,确保两者调用完全相同的代码。 - 法线重计算:顶点位移后,必须重新计算法线向量。否则,光照计算会基于原始平面法线,导致光照错误。这里展示的是通过修改后的切线和副切线叉积来估算,对于复杂波浪,可以在顶点着色器中计算精确的导数,或者依赖法线贴图来补充细节。
- TRANSFER_SHADOW宏:这是Unity CG库提供的便捷宏,它在顶点着色器中计算并将阴影采样所需的坐标从顶点传递到片元。它是实现阴影接收的关键一步。
4.2 片元着色器:集成阴影接收的光照计算
在主Pass的片元着色器中,我们需要计算自定义光照,并将阴影因子融入其中。
// 自定义光照函数 half4 LightingCustomLighting (SurfaceOutput s, half3 viewDir, UnityGI gi) { // 基础光照计算 half3 lightDir = gi.light.dir; half3 normal = s.Normal; // 漫反射 (Lambert) half NdotL = max(0, dot(normal, lightDir)); half3 diffuse = s.Albedo * NdotL * _LightColor0.rgb; // 高光 (Blinn-Phong) half3 halfDir = normalize(lightDir + viewDir); half NdotH = max(0, dot(normal, halfDir)); half specularPower = exp2(s.Gloss * 10 + 1); half3 specular = _LightColor0.rgb * pow(NdotH, specularPower) * s.Specular; // 关键步骤:应用阴影衰减 half shadow = SHADOW_ATTENUATION(i); // 从输入结构i中获取阴影因子 diffuse *= shadow; specular *= shadow; // 环境光+自发光 half3 ambient = ShadeSH9(half4(normal, 1)) * s.Albedo; half3 emission = s.Emission; half4 c; c.rgb = (ambient + diffuse + specular + emission); c.a = s.Alpha; return c; } void surf (Input IN, inout SurfaceOutput o) { // 采样基础纹理和法线贴图 half4 mainTex = tex2D(_MainTex, IN.uv_MainTex); half3 normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap)); normal.xy *= _BumpScale; normal.z = sqrt(1.0 - saturate(dot(normal.xy, normal.xy))); o.Albedo = mainTex.rgb * _Color.rgb; o.Normal = normalize(mul(float3x3(IN.worldTangent, IN.worldBinormal, IN.worldNormal), normal)); o.Specular = _Metallic; o.Gloss = _Glossiness; o.Alpha = mainTex.a * _Color.a; // 基于深度的颜色混合(模拟水深) float sceneDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(IN.screenPos))); float surfaceDepth = sceneDepth - IN.screenPos.w; float depthFactor = saturate(surfaceDepth / _MaxDepth); o.Albedo = lerp(o.Albedo, _DepthColor.rgb, depthFactor); }核心要点:
SHADOW_ATTENUATION宏:这是Unity提供的另一个关键宏。在片元着色器中,它根据之前TRANSFER_SHADOW准备的坐标,对阴影贴图进行采样和深度比较,最终返回一个[0, 1]的阴影衰减值。我们将这个值直接乘到漫反射和高光分量上。注意:环境光通常不受实时阴影影响,所以不乘以shadow。- 自定义光照函数:通过
#pragma surface surf CustomLighting,我们指定了使用自定义的LightingCustomLighting函数。这给了我们完全的控制权,可以在光照计算的最后一步融入阴影因子,实现物理上正确的结果——阴影区域只有直接光照被遮挡,环境光依然存在。 - 深度颜色模拟:
surf函数中演示了如何利用深度纹理(_CameraDepthTexture)模拟水深效果。这不是阴影功能必须的,但能极大增强水面的真实感。计算水面像素与水下场景的深度差,并用这个差值去混合水面基础色和深水区颜色。
4.3 阴影投射Pass的实现
阴影投射Pass的代码相对固定,但必须确保其顶点变换与主Pass完全一致。
// 专门用于ShadowCaster Pass的顶点输入输出结构 struct v2fShadow { V2F_SHADOW_CASTER; // 如果你需要波浪动画,这里也需要传递UV等必要信息,但通常ShadowCaster Pass只关心位置 float2 uv : TEXCOORD1; }; v2fShadow vertShadow(appdata_base v) { v2fShadow o; // !!!重复主Pass中的波浪位移计算 !!! float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; float3 tangent = float3(1, 0, 0); float3 binormal = float3(0, 0, 1); float3 waveOffset = 0; waveOffset += GerstnerWave(float4(1, 0, _WaveAmplitude, 10), worldPos, tangent, binormal); waveOffset += GerstnerWave(float4(0, 1, _WaveAmplitude * 0.8, 15), worldPos, tangent, binormal); v.vertex.xyz += mul(unity_WorldToObject, float4(waveOffset, 0)).xyz; // !!!结束重复 !!! TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) o.uv = v.texcoord; return o; } float4 fragShadow(v2fShadow i) : SV_Target { // 对于简单的阴影投射,片元着色器通常只需返回一个值 // 但如果你使用了透明度测试(Alpha Test),则需要在这里采样纹理并clip // half4 texColor = tex2D(_MainTex, i.uv); // clip(texColor.a - _Cutoff); SHADOW_CASTER_FRAGMENT(i) }避坑指南:
- 深度一致性是生命线:
vertShadow函数中波浪位移的计算,必须与vert函数一字不差。任何细微差别都会导致渲染出的水面网格和用于阴影计算的网格不同,结果就是阴影“飘”在水面之上或之下。我强烈建议将波浪计算代码封装成一个函数,在两个顶点着色器中调用同一个函数。 TRANSFER_SHADOW_CASTER_NORMALOFFSET宏:这个宏负责计算阴影投射所需的顶点位置和法线偏移(用于解决阴影痤疮)。它比简单的顶点变换更健壮。- Alpha Test支持:如果你的水面有镂空部分(比如海草纹理),需要做透明度测试(Alpha Test),那么必须在
fragShadow中使用clip函数。否则,镂空部分也会投射出实心阴影。本例是半透明混合,所以不需要。
5. 实战调试与性能优化策略
Shader写完了,丢进Unity,效果不对怎么办?以下是几个常见的排查方向和优化建议。
5.1 常见问题与排查清单
| 问题现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 水面完全不投射阴影 | 1. Shader中没有ShadowCasterPass。2. ShadowCasterPass的编译指令或Tags错误。3. 水面GameObject的 Cast Shadows属性未开启。 | 1. 检查Shader代码,确认有LightMode="ShadowCaster"的Pass。2. 在Frame Debugger中查看渲染阴影贴图时,该水面是否被绘制。 3. 在Inspector中确认 Mesh Renderer组件的Cast Shadows为On。 |
| 投射的阴影形状是平的,没有波浪 | ShadowCasterPass的顶点着色器没有应用波浪动画,或者动画计算与主Pass不一致。 | 1. 对比vert和vertShadow函数中的顶点变换代码。2. 可以暂时将 ShadowCasterPass的输出颜色改为固定色(如红色),在场景视图中查看其渲染的形状是否正确。 |
| 水面不接收阴影 | 1. 主Pass的LightMode不是ForwardBase。2. 缺少 #pragma multi_compile_shadowscaster或SHADOW_COORDS/TRANSFER_SHADOW宏。3. 自定义光照函数中没有使用 SHADOW_ATTENUATION。4. 阴影距离或分辨率设置太低,阴影在远处消失。 | 1. 检查Pass的Tags。 2. 确保使用了正确的宏,并且输入结构 v2f中定义了SHADOW_COORDS。3. 在自定义光照函数中,确认 shadow因子被乘入了直接光照部分。4. 在 Quality Settings或灯光组件中调整阴影相关参数。 |
| 阴影边缘闪烁(Z-fighting) | 水面主Pass(ZWrite Off)和阴影投射Pass(ZWrite On)计算出的深度值有微小差异。 | 1. 确保两个Pass的顶点变换绝对一致。 2. 可以尝试在 ShadowCasterPass中稍微增加一个深度偏移(offset),但需谨慎,可能造成阴影分离。 |
| 半透明阴影看起来太黑或不自然 | 阴影衰减因子被错误地应用到了所有光照分量(包括环境光)。 | 检查自定义光照函数,确保shadow只乘在了diffuse和specular(直接光照)上,而ambient(环境光)保持不变。 |
5.2 性能优化要点
一个功能完整的Shader可能会比较耗费资源,尤其是在移动平台。以下是一些优化思路:
- 简化波浪计算:在
ShadowCasterPass中,可以考虑使用计算量更少的波浪公式,或者降低波浪频率。因为阴影贴图分辨率通常较低,细微的波浪形状损失在阴影中不易察觉,但能节省大量顶点计算。 - 使用Shader LOD:在SubShader中定义
LOD值,并为低配设备编写一个简化版的SubShader(例如,去掉法线贴图、使用更少波浪、简化光照模型)。 - 谨慎使用深度纹理:
surf函数中采样_CameraDepthTexture来模拟水深是一个屏幕空间后处理效果,开销较大。如果性能吃紧,可以考虑用顶点色或一个简单的高度混合来近似,或者通过脚本控制其开关。 - 批处理考虑:复杂的顶点动画可能会破坏动态合批。如果场景中有大量相同的水面网格,确保它们使用相同的材质,并考虑是否值得为了合批而将波浪动画转移到顶点纹理或Compute Shader中计算。
6. 完整Shader代码与使用指南
将以上所有部分整合,就得到了一个完整的、支持阴影投射与接收的半透明水面Shader。由于代码较长,我将关键部分整合后提供一个核心框架,你可以在其中填充自己的波浪、颜色和光照细节。
使用这个Shader时,你需要:
- 在Unity中创建一个新的材质球,选择此Shader。
- 配置好法线贴图、颜色等属性。
- 将材质赋给一个平面或自定义的水面网格。
- 确保场景中有至少一个开启
Cast Shadows的Directional Light。 - 在相机的渲染设置中,可能需要开启
Depth Texture模式,以便_CameraDepthTexture可用(用于水深效果)。
最后一点个人体会:实现半透明物体的阴影,是深入理解Unity渲染管线的一个绝佳练习。它强迫你去思考渲染的每一个阶段:深度缓冲、阴影贴图、光照计算、混合模式。最开始可能会被各种宏和Pass搞得头晕,但一旦打通,你对Shader的掌控力会上升一个层次。这个方案不仅适用于水面,任何需要半透明且参与阴影交互的材质,比如彩色玻璃、火焰外焰、魔法特效等,都可以借鉴这个思路。在实际项目中,你可能还需要根据URP或HDRP的管线特性调整部分宏和函数,但核心的“双Pass+一致顶点变换+自定义光照集成阴影”的架构是相通的。多调试,多使用Frame Debugger工具观察渲染流程,问题总会迎刃而解。