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 biasbias是一个很小的偏移量用于解决深度精度引起的“阴影痤疮”问题说明当前点被更近的物体遮挡处于阴影中反之则被照亮。对于我们的半透明水面在**阴影投射作为阴影源**阶段它需要作为一个“遮挡物”参与第一步。这意味着我们的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 { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } LOD 300 // ------------------------------------------------------------------ // Pass 1: 渲染水面颜色包含阴影接收计算 // ------------------------------------------------------------------ Pass { Name FORWARD Tags { LightModeForwardBase } // 重要用于获取主方向光信息 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 { LightModeShadowCaster } ZWrite On // 阴影投射必须开启深度写入 ZTest LEqual Cull Back CGPROGRAM #pragma vertex vertShadow #pragma fragment fragShadow #pragma multi_compile_shadowcaster // 确保和主Pass使用相同的顶点变换和波浪计算保证深度一致性 #pragma multi_compile_instancing ... ENDCG } // 可选添加其他Pass如附加光源ForwardAddPass用于处理点光源/聚光灯 } FallBack Transparent/Cutout/VertexLit // 一个简单的透明回退Shader }关键设计解析两个独立的Pass这是核心。FORWARDPass负责最终的颜色渲染和阴影接收计算。ShadowCasterPass是一个专用通道仅在渲染阴影贴图时被调用它只输出深度不输出颜色。两个Pass必须使用完全相同的顶点变换和波浪位移算法否则水面渲染的形状和它投射的阴影形状会不匹配导致阴影“漂移”或错位。Tags的重要性LightModeForwardBase告诉Unity这个Pass用于主方向光的基础渲染。LightModeShadowCaster则明确标识这是阴影投射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代码确认有LightModeShadowCaster的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水面主PassZWrite Off和阴影投射PassZWrite 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工具观察渲染流程问题总会迎刃而解。