Unity水体渲染实战:从Gerstner波到屏幕空间反射的完整实现

📅 2026/7/14 1:45:15
Unity水体渲染实战:从Gerstner波到屏幕空间反射的完整实现
1. 项目概述从零开始构建一个真实可信的Unity水体在游戏开发中水体渲染一直是一个既迷人又充满挑战的领域。无论是宁静的湖泊、潺潺的溪流还是汹涌的海洋一个高质量的水体效果能极大地提升场景的氛围感和沉浸感。然而很多开发者初次接触Shader时面对水体渲染中复杂的物理现象——如折射、反射、波浪、焦散等——往往会感到无从下手。这篇文章我将从一个从业者的角度分享如何从零开始在Unity中实现一套兼顾效果与性能的水体渲染方案。我不会只停留在理论层面而是会结合我实际项目中的经验拆解每一个核心环节的实现细节、参数调优的“手感”以及那些文档里不会写的“坑”和应对技巧。我们的目标不是复现一个物理绝对精确的海洋模拟而是打造一个在游戏实时渲染环境下视觉上令人信服、性能上可以接受的水体。我们将从最基础的透明与流动效果开始逐步叠加反射、折射、波浪、颜色变化、泡沫等特性最终形成一个完整的Shader。整个过程我会使用URP管线进行讲解但核心思路同样适用于Built-in管线。无论你是Shader新手还是希望优化现有水体效果的开发者这篇文章都能为你提供一条清晰的路径和可直接参考的代码。2. 核心思路与方案选型平衡艺术效果与实时性能在动手写代码之前我们必须先想清楚我们要做一个什么样的水是手游里的小池塘还是PC端3A大作里的广阔海洋不同的目标决定了完全不同的技术路径和性能预算。2.1 效果层级划分明确你的需求根据项目需求我们可以将水体效果大致分为三个层级基础层级移动端/风格化核心是“看起来像水”。实现透明、基础的法线贴图流动、简单的菲涅尔反射和屏幕空间折射。这个层级性能开销极低一个简单的片元着色器加上几张纹理就能实现适合手机游戏或风格化场景。标准层级主流PC/主机在基础层级上增加动态波浪如Gerstner波、平面反射或屏幕空间反射、基于深度的颜色衰减、简单的边缘泡沫。这是目前大多数单机或网游采用的标准方案在效果和性能间取得了很好的平衡。高级层级影视级/特定需求追求物理正确性。可能包含完整的FFT快速傅里叶变换波浪模拟、精确的次表面散射SSS、实时焦散、与物体的动态交互如船体划开的波浪、角色涉水涟漪。这个层级的性能开销巨大通常需要结合Compute Shader、GPU粒子等高级特性仅在特定场景或高端硬件上使用。对于本教程我们将聚焦于“标准层级”这是最具普适性和学习价值的。我们会实现一个视觉效果丰富、代码结构清晰、且能在主流硬件上流畅运行的水体Shader。2.2 关键技术方案选型确定了目标层级后我们需要为每个视觉特征选择具体的实现方案。选型的核心原则是用性价比最高的方法实现最显著的视觉提升。透明与混合毫无疑问使用Alpha混合Blend SrcAlpha OneMinusSrcAlpha。这是实现透明效果的标准方式。需要注意的是透明物体的渲染顺序问题我们后面会详细讨论如何规避。波浪与流动基础流动使用两张法线贴图以不同的速度和方向进行UV动画然后混合。这是模拟水面细节波纹最高效的方法。主体波浪采用Gerstner波。相比简单的正弦波Gerstner波能产生更尖锐的波峰和更宽阔的波谷视觉效果更接近真实的海浪且计算量仍在可接受范围内。我们将它在顶点着色器中实现以驱动网格顶点的位移。反射放弃消耗巨大的实时平面反射需要额外渲染一次场景。我们将采用屏幕空间反射SSR或反射探针Reflection Probe的混合方案。在URP中我们可以利用屏幕空间数据尝试进行光线步进SSR如果失败则回退到采样预先烘焙好的反射探针立方体贴图。这样既能获得动态的反射细节又保证了性能。折射使用屏幕空间折射。在URP中我们可以采样_CameraOpaqueTexture即不透明物体的渲染结果然后用法线对采样UV进行扰动来模拟水下物体的扭曲效果。这是实时渲染中实现折射最主流和高效的方法。颜色与深度基于视线穿过水体的深度通过深度图差值计算使用指数衰减函数来混合水的浅水区颜色和深水区颜色。同时利用菲涅尔效应混合远处水面的颜色通常更受天空盒影响。泡沫分为浪尖泡沫和岸边泡沫。浪尖泡沫基于波浪的高度Y值生成。当顶点高度超过某个阈值时混合一张泡沫纹理。可以通过噪声图来打破泡沫形状的规则性。岸边泡沫基于水面与地形的相交深度生成。在浅水区通过深度值生成遮罩再结合噪声图和UV动画模拟泡沫在岸边聚集的效果。焦散作为高级效果的补充我们将实现一个简化版的焦散。在水底地形上通过投影的方式采样一张焦散纹理并使其随着水面法线扰动而“流动”。这是一种视觉欺骗Screen-Space Decal但效果足够以假乱真。实操心得方案不是越高级越好在早期原型阶段我常常陷入“技术完美主义”的陷阱试图实现所有看到过的炫酷效果。结果就是Shader极其复杂运行效率低下且难以调试。后来我学乖了先实现核心的80%效果透明、流动、基础反射折射确保它跑起来且帧率达标。剩下的20%如精细的泡沫、焦散作为可选项根据项目性能预算动态添加或降级。记住玩家不会拿着显微镜看你的水但一定会对卡顿印象深刻。3. 构建Shader框架与基础属性让我们开始动手。首先在Unity中创建一个Unlit Shader Graph或者直接编写HLSL代码。我更喜欢后者因为它能给我更精细的控制和更清晰的理解。这里我将以HLSL代码为例进行讲解。首先我们定义Shader的基本属性和结构。Shader “Custom/StarterWater” { Properties { // 基础颜色 _ShallowColor (“Shallow Color”, Color) (0.0, 0.5, 0.8, 0.8) _DeepColor (“Deep Color”, Color) (0.0, 0.1, 0.3, 1.0) _DepthFactor (“Depth Factor”, Range(0, 5)) 1.0 // 法线与流动 _NormalMapA (“Normal Map A”, 2D) “bump” {} _NormalMapB (“Normal Map B”, 2D) “bump” {} _NormalTiling (“Normal Tiling”, Float) 1.0 _NormalSpeedA (“Normal Speed A”, Float) 0.05 _NormalSpeedB (“Normal Speed B”, Float) 0.1 _NormalStrength (“Normal Strength”, Range(0, 2)) 0.5 // 反射 _ReflectionStrength (“Reflection Strength”, Range(0, 1)) 0.5 _Smoothness (“Smoothness”, Range(0, 1)) 0.9 _ReflectionDistortion (“Reflection Distortion”, Range(0, 0.1)) 0.02 // 折射 _RefractionStrength (“Refraction Strength”, Range(0, 1)) 0.8 _RefractionDistortion (“Refraction Distortion”, Range(0, 0.1)) 0.05 // 菲涅尔 _FresnelPower (“Fresnel Power”, Range(0.1, 10)) 5.0 _FresnelColor (“Fresnel (Far) Color”, Color) (0.8, 0.9, 1.0, 1.0) // 波浪 (Gerstner) _WaveAmplitude (“Wave Amplitude”, Float) 0.3 _WaveLength (“Wave Length”, Float) 5.0 _WaveSpeed (“Wave Speed”, Float) 1.0 _WaveDirection (“Wave Direction (XY)”, Vector) (1, 0, 0, 0) // 泡沫 _FoamTexture (“Foam Texture”, 2D) “white” {} _FoamTiling (“Foam Tiling”, Float) 5.0 _FoamSpeed (“Foam Speed”, Float) 0.2 _FoamThreshold (“Foam Height Threshold”, Range(0, 2)) 0.8 _FoamDepthThreshold (“Foam Depth Threshold”, Range(0, 1)) 0.3 } SubShader { Tags { “RenderType” “Transparent” “Queue” “Transparent” “RenderPipeline” “UniversalPipeline” } // 第一个Pass渲染不透明部分如水底、背面的深度用于后续折射计算 Pass { Name “DepthPrePass” Tags { “LightMode” “DepthOnly” } ColorMask 0 // 不写入颜色只写深度 ZWrite On Cull Off // … 深度写入Shader代码 … } // 第二个Pass主渲染Pass Pass { Name “ForwardLit” Tags { “LightMode” “UniversalForward” } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off // 透明物体通常关闭深度写入但需要小心排序问题 Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE #pragma multi_compile _ _ADDITIONAL_LIGHTS #pragma multi_compile_fog // 包含URP核心库 #include “Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl” #include “Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl” #include “Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl” #include “Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareOpaqueTexture.hlsl” // 定义属性变量 // … (将Properties中的变量在此用CBUFFER声明) … struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; float2 texcoord : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float3 positionWS : TEXCOORD0; float3 normalWS : TEXCOORD1; float4 tangentWS : TEXCOORD2; float2 uv : TEXCOORD3; float4 screenPos : TEXCOORD4; float fogFactor : TEXCOORD5; }; // 顶点着色器和片元着色器函数将在这里实现 // … (后续填充) … ENDHLSL } } }这个框架定义了我们需要用到的所有属性并设置了两个Pass。第一个DepthPrePass是一个小技巧用于解决透明物体渲染的经典难题——深度排序。因为我们的水是透明的如果直接关闭深度写入ZWrite Off后渲染的水可能会错误地覆盖在前面本该被遮挡的物体上。这个预渲染的深度Pass先写入水的深度确保不透明物体能正确遮挡水然后在主Pass中再进行透明的颜色混合。4. 核心模块实现波浪、法线与光照4.1 实现Gerstner波顶点动画波浪是水体的灵魂。我们首先在顶点着色器中实现Gerstner波它会让水面网格产生真实的起伏。// Gerstner波函数 float3 GerstnerWave(float3 position, float amplitude, float wavelength, float speed, float2 direction) { float k 2 * PI / wavelength; // 波数 float f k * (dot(direction, position.xz) - speed * _Time.y); float a amplitude / (k * 1); // 振幅衰减因子1代表陡峭度可调 // 计算顶点偏移 float3 result; result.x direction.x * a * cos(f); result.y a * sin(f); // 高度偏移 result.z direction.y * a * cos(f); return result; } Varyings vert(Attributes input) { Varyings output; // 将顶点坐标转换到世界空间 VertexPositionInputs vertexInput GetVertexPositionInputs(input.positionOS.xyz); float3 positionWS vertexInput.positionWS; // 应用Gerstner波 // 可以叠加多个波以获得更复杂的效果 float3 waveOffset float3(0, 0, 0); waveOffset GerstnerWave(positionWS, _WaveAmplitude, _WaveLength, _WaveSpeed, normalize(_WaveDirection.xy)); // 可以在这里添加第二个、第三个波方向、波长、振幅不同 // waveOffset GerstnerWave(positionWS, _WaveAmplitude * 0.7, _WaveLength * 1.5, _WaveSpeed * 0.8, normalize(float2(0.7, 0.3))); positionWS waveOffset; // 计算新的顶点位置世界空间-裁剪空间 output.positionWS positionWS; output.positionCS TransformWorldToHClip(positionWS); // 计算新的法线近似值对于Gerstner波需要更精确的法线计算这里简化 // 在实际项目中最好在顶点或片元着色器中基于波函数导数重新计算法线 VertexNormalInputs normalInput GetVertexNormalInputs(input.normalOS, input.tangentOS); output.normalWS normalInput.normalWS; output.tangentWS float4(normalInput.tangentWS, input.tangentOS.w); output.uv input.texcoord * _NormalTiling; output.screenPos ComputeScreenPos(output.positionCS); output.fogFactor ComputeFogFactor(output.positionCS.z); return output; }注意事项法线计算的坑上面的代码中我直接使用了模型原始的法线转换到世界空间。这对于平缓的波浪勉强可用但对于尖锐的Gerstner波这会导致光照错误高光会“飘”在错误的位置。正确的做法是基于波函数的偏导数在片元着色器中实时计算精确的世界空间法线。公式为normal normalize(cross(dFdx(positionWS), dFdy(positionWS)))。 但这依赖于屏幕空间导数有时会有性能考量。另一种折中方案是在顶点着色器中对相邻顶点采样波函数近似计算面法线并传递给片元着色器。为了教程清晰我们先使用简化版但请记住这是需要优化的点。4.2 混合动态法线贴图接下来在片元着色器中我们混合两张法线贴图来模拟水面的细节流动。// UV动画函数 float2 Panner(float2 uv, float2 direction, float speed, float time) { return uv direction * speed * time; } // 采样并混合法线 float3 SampleAndBlendNormals(float2 uv, float strength) { // 采样第一张法线图朝一个方向移动 float2 uvA Panner(uv, float2(0.8, 0.6), _NormalSpeedA, _Time.y); float3 normalTS_A UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMapA, sampler_NormalMapA, uvA), strength); // 采样第二张法线图朝另一个方向移动 float2 uvB Panner(uv, float2(-0.6, 0.8), _NormalSpeedB, _Time.y * 0.7); // 速度略有不同 float3 normalTS_B UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMapB, sampler_NormalMapB, uvB), strength); // 混合两张法线图 float3 blendedNormalTS normalize(float3(normalTS_A.xy normalTS_B.xy, normalTS_A.z * normalTS_B.z)); return blendedNormalTS; }4.3 计算基础光照与菲涅尔有了法线我们就可以计算光照了。对于水体菲涅尔效应至关重要它决定了我们能看到多少反射水面和多少折射水下。// 菲涅尔近似计算 (Schlick‘s approximation) float FresnelEffect(float3 normal, float3 viewDir, float power) { float cosTheta saturate(dot(normal, viewDir)); return pow(1.0 - cosTheta, power); } half4 frag(Varyings input) : SV_Target { // 1. 准备基础数据 float3 positionWS input.positionWS; float3 viewDirWS GetWorldSpaceNormalizeViewDir(positionWS); float2 screenUV input.screenPos.xy / input.screenPos.w; // 2. 采样并计算世界空间法线 float3 normalTS SampleAndBlendNormals(input.uv, _NormalStrength); // 将切线空间法线转换到世界空间 float3 bitangentWS cross(input.normalWS, input.tangentWS.xyz) * input.tangentWS.w; float3x3 TBN float3x3(input.tangentWS.xyz, bitangentWS, input.normalWS); float3 normalWS mul(normalTS, TBN); // 注意这里是mul(normalTS, TBN)因为法线是从切线空间变换到世界空间 // 3. 计算菲涅尔因子 float fresnel FresnelEffect(normalWS, viewDirWS, _FresnelPower); // 加强边缘反射 fresnel saturate(_ReflectionStrength (1.0 - _ReflectionStrength) * fresnel); // 4. 基础光照计算简化版仅主光源 Light mainLight GetMainLight(); float3 lightColor mainLight.color * mainLight.distanceAttenuation * mainLight.shadowAttenuation; float NdotL saturate(dot(normalWS, mainLight.direction)); float3 diffuse lightColor * NdotL; // 高光 (Blinn-Phong 简化) float3 halfDir normalize(viewDirWS mainLight.direction); float NdotH saturate(dot(normalWS, halfDir)); float specular pow(NdotH, _Smoothness * 128.0) * _Smoothness; float3 specularColor lightColor * specular; // 5. 基础颜色先简单给一个颜色后续会与折射反射混合 float3 baseColor lerp(_ShallowColor.rgb, _DeepColor.rgb, 0.5); float3 finalColor (baseColor * diffuse specularColor); // 应用雾效 finalColor MixFog(finalColor, input.fogFactor); return half4(finalColor, _ShallowColor.a); }至此我们已经有了一个会波动、有光照和菲涅尔效应的基础水面。但它还缺少最关键的灵魂——反射和折射。接下来我们将实现这两个核心效果。5. 实现屏幕空间反射与折射5.1 屏幕空间折射水下扭曲折射的原理是由于光线在水面发生弯曲我们看到的水下物体会发生偏移。在实时渲染中我们通过扰动采样屏幕纹理的UV坐标来模拟这种偏移。half4 frag(Varyings input) : SV_Target { // … 接上文代码 … // 6. 屏幕空间折射 // 获取当前像素的屏幕深度 float rawDepth SampleSceneDepth(screenUV); float sceneDepth LinearEyeDepth(rawDepth, _ZBufferParams); float surfaceDepth LinearEyeDepth(input.positionCS.z, _ZBufferParams); float waterDepth sceneDepth - surfaceDepth; // 计算水深 // 用法线扰动屏幕UV模拟折射 float2 refractionOffset normalWS.xy * _RefractionDistortion * saturate(waterDepth); // 深度越大扭曲越强 float2 refractedUV screenUV refractionOffset; // 采样水下场景颜色_CameraOpaqueTexture是URP提供的已渲染的不透明纹理 float3 underwaterColor SAMPLE_TEXTURE2D(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, refractedUV).rgb; // 7. 基于深度混合水的颜色和水下颜色 // 指数衰减模拟光在水中的吸收 float depthAttenuation exp(-_DepthFactor * waterDepth); float3 depthBasedColor lerp(_DeepColor.rgb, _ShallowColor.rgb, depthAttenuation); // 将水的颜色与水下场景颜色混合 float3 refractedFinalColor lerp(underwaterColor, depthBasedColor, _RefractionStrength); // … 后续与反射混合 … }这里的关键是_CameraOpaqueTexture它包含了所有不透明物体渲染后的结果。我们用水的法线去扰动采样它的UV就得到了“看起来像是透过扭曲的水面看到”的颜色。waterDepth用于控制扭曲强度避免在非常浅的水域产生不自然的强烈扭曲。5.2 屏幕空间反射SSR与反射探针回退实现高质量的实时反射比较复杂。一个健壮的方案是尝试进行屏幕空间光线步进SSR如果失败例如反射点不在屏幕内则回退到采样天空盒或反射探针。// 简化版的屏幕空间光线步进反射概念性代码实际需要更复杂的步进和命中检测 float3 CalculateReflection(float3 positionWS, float3 normalWS, float3 viewDirWS) { // 计算反射向量 float3 reflectionVector reflect(-viewDirWS, normalWS); // 方法1简单采样环境贴图反射探针/天空盒 float3 envReflection GlossyEnvironmentReflection(reflectionVector, positionWS, _Smoothness, 1.0); // 方法2尝试屏幕空间反射SSR - 这里是一个极度简化的示意 // 实际SSR需要从当前像素出发沿反射方向在深度图中进行步进搜索判断是否与场景几何相交。 // 如果相交则采样相交点屏幕坐标的颜色。 // 这是一个性能开销较大的操作通常需要单独一个Pass或Compute Shader。 // float3 ssrColor ScreenSpaceReflection(positionWS, reflectionVector); // 伪代码 // 为了性能和教程简洁我们暂时使用方法1并在法线上添加一些扰动来模拟水波对倒影的扭曲 float2 reflectionDistortion normalWS.xz * _ReflectionDistortion; // 我们可以对envReflection进行基于法线的模糊或扰动采样来模拟水波这里略过。 return envReflection; } half4 frag(Varyings input) : SV_Target { // … 接上文代码 … // 8. 计算反射颜色 float3 reflectionColor CalculateReflection(positionWS, normalWS, viewDirWS); // 9. 使用菲涅尔因子混合折射和反射颜色 float3 waterSurfaceColor lerp(refractedFinalColor, reflectionColor, fresnel); // 10. 叠加基础光照和颜色 finalColor waterSurfaceColor * diffuse specularColor; // 注意这里的环境反射已经包含了部分光照信息与直接光叠加时需要权衡避免过亮。 // 更物理的做法是将反射作为镜面反射项的一部分纳入BRDF计算。 // … 应用雾效并输出 … }实操心得反射的“作弊”艺术完全精确的屏幕空间反射SSR在移动端或复杂场景下开销很大。在实际项目中我经常使用一些“作弊”手段平面反射简化如果水面是平坦的如池塘可以只渲染一次场景到RenderTexture然后镜像投影到水面上。这只对完全平静的水面有效。双层反射用一张低分辨率的立方体贴图反射探针提供基础的天空和远景反射再用法线扰动一张屏幕空间的模糊副本来模拟近处动态物体的扭曲倒影。虽然不物理但视觉上足够好性能也低。菲涅尔主导大幅提高菲涅尔效应。这样在视角接近水面时折射主导反射的瑕疵不易被察觉在视角俯视时反射主导水面主要反射天空对动态物体倒影要求不高。关键是把性能预算花在玩家最易察觉的地方。6. 泡沫与焦散效果的实现6.1 浪尖泡沫与岸边泡沫泡沫是让水体“活”起来的关键细节。// 浪尖泡沫基于波浪高度 float CalculateWaveFoam(float3 positionWS, float3 normalWS) { // 简单使用世界空间Y坐标波浪高度作为泡沫因子 float foamHeightFactor saturate((positionWS.y - _FoamThreshold) / 0.5); // 假设泡沫在阈值之上0.5单位内出现 // 采样泡沫纹理并加上UV动画 float2 foamUV positionWS.xz / _FoamTiling float2(_Time.y * _FoamSpeed, 0); float foamTex SAMPLE_TEXTURE2D(_FoamTexture, sampler_FoamTexture, foamUV).r; // 结合高度和纹理并用法线在Y方向上的分量来影响泡沫强度波峰泡沫多 float foam foamHeightFactor * foamTex * saturate(normalWS.y 0.5); return foam; } // 岸边泡沫基于水深 float CalculateShoreFoam(float waterDepth) { // 在水深小于阈值的地方生成泡沫 float shoreMask 1.0 - smoothstep(0.0, _FoamDepthThreshold, waterDepth); // 添加一些噪声和动画让边缘更自然 float2 shoreUV input.positionWS.xz / 10.0 float2(_Time.y * 0.1, _Time.y * 0.08); float shoreNoise SAMPLE_TEXTURE2D(_FoamTexture, sampler_FoamTexture, shoreUV).g; // 使用另一个通道 shoreMask * shoreNoise; // 使用正弦函数创造泡沫线的“脉冲”效果模拟波浪拍岸 shoreMask * (sin(_Time.y * 3.0 positionWS.x * 2.0) * 0.5 0.5); return shoreMask; } half4 frag(Varyings input) : SV_Target { // … 接上文计算 waterDepth, normalWS, positionWS … // 11. 计算泡沫 float waveFoam CalculateWaveFoam(positionWS, normalWS); float shoreFoam CalculateShoreFoam(waterDepth); float totalFoam saturate(waveFoam shoreFoam); // 12. 将泡沫颜色混合到最终颜色 float3 foamColor float3(1.0, 1.0, 1.0); // 白色泡沫 finalColor lerp(finalColor, foamColor, totalFoam); // … 最终输出 … }6.2 简化版焦散效果真实的焦散计算极其昂贵。我们采用一个取巧的办法将一张焦散纹理投影到水底。// 焦散效果 float3 CalculateCaustics(float3 positionWS, float3 normalWS) { // 将世界空间XZ坐标作为UV模拟焦散投影在水底 float2 causticsUV positionWS.xz / 5.0; // 缩放 // 添加基于时间和法线的UV偏移模拟焦散随水波流动 causticsUV normalWS.xz * 0.1 float2(_Time.y * 0.05, _Time.y * 0.03); // 采样焦散纹理通常是一张黑底白纹的图 float caustics SAMPLE_TEXTURE2D(_CausticsTexture, sampler_CausticsTexture, causticsUV).r; // 焦散强度应随水深减小浅水处明显 float depthAttenuation saturate(waterDepth / 5.0); // 假设5米内有效 caustics * depthAttenuation; // 焦散颜色通常是亮白色或带点蓝色 return caustics * float3(1.2, 1.3, 1.0); // 略微偏蓝 } half4 frag(Varyings input) : SV_Target { // … 接上文代码 … // 13. 计算焦散 float3 causticsColor CalculateCaustics(positionWS, normalWS); // 14. 将焦散光斑加到最终颜色上通常用加法混合 finalColor causticsColor; // … 最终输出 … }这个焦散效果是“画”在水底地形上的而不是真正由光线聚焦产生。但它足够快并且在有阳光和清晰水底的场景中能极大地增强真实感。7. 性能优化与常见问题排查一个好看的水体Shader也必须是一个高效的水体Shader。以下是几个关键的优化点和问题解决方法。7.1 性能优化要点纹理与采样压缩格式法线贴图使用BC5/DXT5nm格式其他贴图使用合适的压缩格式如ASTC。纹理尺寸根据水面在屏幕中的大小选择合适的纹理尺寸。512x512或1024x1024的法线贴图通常足够。采样次数合并采样。例如泡沫的噪声和纹理可以打包到同一张贴图的RGBA不同通道中。计算复杂度波浪计算Gerstner波计算在顶点着色器进行但叠加的波不宜过多2-4个为宜。对于超大水域考虑使用贴图如Flow Map或Compute Shader来模拟波浪。循环与分支片元着色器中避免循环和复杂分支。我们的实现中所有计算都是线性的适合GPU并行。精度在移动端将不必要的float改为half甚至fixed。渲染状态Overdraw透明水体是Overdraw大户。确保水面网格面数合理并使用层次细节LOD远距离使用简化版Shader或甚至用面片代替。Shader变体使用#pragma multi_compile或shader_feature来生成不同功能组合的变体。例如为低端设备编译一个不带焦散和高级泡沫的简化版本。7.2 常见问题与解决方案速查表问题现象可能原因解决方案水面闪烁Z-fighting水面与地形或其他透明物体深度值过于接近。1. 使用Offset指令让水面稍微偏离相机。2. 确保渲染顺序正确地形-水-其他透明物体。3. 使用我们提到的DepthPrePass。折射边缘有硬边采样_CameraOpaqueTexture时UV偏移到了屏幕外或深度不连续的区域。对refractedUV进行钳制refractedUV clamp(refractedUV, 0.001, 0.999)。或者采样多次进行模糊混合。反射内容错乱或拉伸反射向量计算错误或采样环境贴图/屏幕空间时坐标转换有误。1. 检查法线、视角向量是否已归一化。2. 在简单场景中调试先反射一个纯色天空盒确认基础反射正确。泡沫看起来不自然、像贴纸泡沫纹理平铺重复感太强或没有与水面运动结合。1. 使用多张不同缩放和速度的噪声图进行混合。2. 让泡沫UV的移动方向与法线或波浪方向有关联。3. 对泡沫纹理本身做随机旋转。在移动设备上帧率骤降Shader计算过于复杂或Overdraw严重。1. 使用Unity的Frame Debugger和Profiler定位瓶颈。2. 创建简化版的Shader变体动态切换。3. 减少波浪数量降低法线贴图分辨率关闭焦散。从特定角度看不到水水的Alpha值可能过低或深度测试/混合设置冲突。检查RenderQueue确保水在透明队列中。检查ZWrite和ZTest设置。有时需要将ZTest设置为LEqual并关闭ZWrite。水与水下物体交界处有接缝深度计算精度问题或折射UV偏移在交界处采样到了错误像素。1. 在交界处使用smoothstep进行柔化过渡。2. 引入一个基于深度差的“边缘淡化”因子在交界处减弱折射效果。7.3 调试技巧可视化中间变量在开发过程中将中间步骤的结果可视化是调试Shader最快的方法。你可以临时修改片元着色器的返回语句来查看法线、深度、菲涅尔因子、泡沫遮罩等任何你想看的数据。// 调试显示世界空间法线 return half4(normalWS * 0.5 0.5, 1.0); // 调试显示水深 return half4(waterDepth.xxx / 10.0, 1.0); // 假设10米为白色 // 调试显示菲涅尔因子 return half4(fresnel.xxx, 1.0);通过这种方式你可以清晰地看到每个模块是否按预期工作以及参数调整带来的直观变化。8. 从Shader到完整水体材质与场景设置写完Shader只是第一步要让它在场景中看起来正确材质和场景设置同样重要。材质设置创建一个材质使用我们刚写好的Shader。耐心调整每一个参数。建议的调整顺序是先调波浪_WaveAmplitude,_WaveLength让水面动起来再调法线强度_NormalStrength和速度让表面有细节然后调颜色和深度因子_ShallowColor,_DeepColor,_DepthFactor确定水的基调接着调反射和菲涅尔_ReflectionStrength,_FresnelPower让水面有光泽和倒影最后微调泡沫和焦散参数增加细节。网格使用一个足够细分至少100x100顶点的平面网格作为水体。对于大海等广阔水域需要使用曲面细分Tessellation或动态LOD网格在近处提供高细节在远处简化。反射探针在场景中放置一个或多个反射探针Reflection Probe并确保其覆盖水体区域。将其类型设置为“Realtime”或“Baked”这样我们的Shader才能采样到正确的环境反射。对于动态场景可能需要每帧更新或使用混合探针。后期处理水体可以和后期处理效果完美结合。颜色分级轻微的整体色调调整能让水的颜色更符合场景氛围。泛光Bloom开启泛光可以让水面的高光和反射的亮部区域产生光晕更加梦幻。屏幕空间环境光遮蔽SSAO虽然水是透明的但SSAO能增强水底地形与水面的接触感让水体更“稳”地坐在场景中。最后别忘了在不同的光照条件正午、黄昏、夜晚和天气条件晴天、阴天、下雨下测试你的水体。一个健壮的水体Shader应该能在各种环境下都保持视觉上的协调与美感。这需要反复的迭代和微调但当你看到自己创造的水面在虚拟世界中波光粼粼时这一切都是值得的。