公司动态
从零实现交互式水面Shader:波纹、反射、折射与动态涟漪全解析
在实际游戏开发和实时图形渲染中水面效果是衡量场景真实感与沉浸感的关键指标。无论是开放世界游戏中的湖泊海洋还是角色扮演游戏中的溪流池塘一个高质量的水面Shader不仅能模拟水的物理外观如波纹、反射和折射更能通过动态交互如角色踏入、物体掉落极大地提升玩家的代入感。然而从简单的纹理动画到复杂的物理交互水面渲染的实现涉及从顶点变换、片元计算到后处理管线等多个环节对开发者的图形学基础和引擎工具链掌握程度提出了较高要求。本文将以一个实战视角系统拆解构建一个从基础到进阶的交互式水面Shader的全过程。我们将从最核心的波纹动画原理讲起逐步引入法线贴图、深度计算、反射与折射、菲涅尔效应等关键技术最终实现角色与水面交互产生的动态涟漪。整个过程将结合主流引擎如Unity、Unreal Engine或Three.js的着色器语言的核心概念进行讲解确保读者不仅能理解算法更能将其转化为可运行的代码。无论你是刚接触Shader编程的图形学新手还是希望优化现有水面效果的中级开发者都能通过本文获得一套清晰、可复现的实现路径与排错思路。1. 理解水面渲染的核心从静态平面到动态波纹在开始编写代码之前必须厘清水面Shader要模拟的几个核心视觉特征。一个逼真的水面不是简单的蓝色平面而是光线、几何与物理共同作用的结果。1.1 视觉特征分解我们看到了什么当我们观察真实水面时大脑处理的是多种视觉信号的叠加颜色与透明度水的颜色由水体本身如藻类、矿物质和底部材质共同决定并且随着深度增加透明度会发生变化。表面起伏风、水流或外力作用会产生波浪和涟漪表现为水面法线的连续变化这是产生高光和扭曲的基础。反射水面像一面不平整的镜子会反射天空、周围环境如树木、山体。折射光线进入水中会发生弯曲使我们能看到水底扭曲的景物。高光阳光或点光源在水面波峰处形成明亮的镜面反射光斑Specular Highlight。焦散与次表面散射更高级的效果涉及光线在水体内部的复杂散射本文作为进阶方向提及。在实时渲染中我们无法完全模拟物理光学而是通过一系列“技巧”即Shader算法来近似这些现象。其中动态波纹是几乎所有水面效果的起点它驱动了法线变化进而影响了反射、折射和高光的计算。1.2 波纹的数学描述正弦波与法线贴图最简单的波纹可以用正弦或余弦函数来模拟。在Shader中我们通常在顶点着色器或片元着色器中对顶点的y坐标高度进行周期性的偏移。一个基础的正弦波高度计算如下// 在片元着色器中计算当前像素的波浪高度 float waveHeight _Amplitude * sin(_Frequency * (worldPos.x * _Direction.x worldPos.z * _Direction.y) _Time.y * _Speed);_Amplitude波幅控制波浪的高度。_Frequency频率控制波浪的密集程度。_Direction二维向量控制波浪传播的方向。_Time.y着色器内置变量代表自场景加载以来的时间秒用于驱动动画。_Speed波速。然而仅靠程序化正弦波细节不够丰富且性能消耗随复杂度增加而剧增。工业级方案普遍采用法线贴图Normal Map。我们使用一张或两张记录着微小波纹法线信息的纹理通过滚动UV坐标来模拟水面细节的流动。法线贴图提供了高频细节而程序化波浪则可以提供低频的主体运动两者结合效果更佳。1.3 渲染管线中的位置Shader如何工作水面Shader通常是一个表面着色器Surface Shader或顶点/片元着色器Vertex/Fragment Shader。它挂载在一个覆盖水域的网格模型通常是一个平面上。其工作流程可以简化为顶点阶段可选。进行大幅度的顶点位移如大的涌浪计算新的顶点位置和法线。片元阶段核心。对每个像素片元进行计算。采样法线贴图结合时间偏移UV得到细节法线。可能叠加程序化波浪计算最终的世界空间法线。使用此法线结合视角方向、光源方向计算反射向量、折射向量、漫反射与高光。采样反射贴图如屏幕空间反射SSR或反射探头和折射贴图通常是抓取的屏幕纹理。应用菲涅尔方程混合反射和折射颜色。根据深度水面到水底的距离调整颜色和透明度。输出将最终计算的颜色、透明度等写入渲染目标。理解了这些基础我们就可以着手搭建开发环境并开始实现。2. 环境准备与项目基础配置我们将以Unity引擎的URPUniversal Render Pipeline为例进行演示因为URP是现代Unity项目的主流选择其Shader编写逻辑与Built-in管线有差异但概念相通也便于迁移到其他类似框架的理解。Unreal Engine的材质蓝图或Three.js的GLSL逻辑在核心算法上是一致的。2.1 引擎与渲染管线设置首先确保你有一个可用的开发环境。安装Unity Hub与Unity Editor从Unity官网下载Unity Hub并通过它安装一个较新的LTS版本如2022.3 LTS。在安装时务必包含Windows/Mono/IL2CPP Build Support以及Universal Windows Platform Build Support根据你的目标平台选择。创建新项目在Unity Hub中创建新项目模板选择Universal Render Pipeline (URP)。这个模板会自动为你配置好URP的核心资产。验证URP配置在项目窗口中找到Assets/Settings文件夹下的UniversalRP-HighQuality或类似名称的URP Asset文件。选中它在检查器中确保渲染管线设置正确。这是我们Shader能够正确编译和显示的基础。2.2 创建水面材质与Shader创建Shader文件在项目窗口中右键点击选择Create - Shader - Universal Render Pipeline - Lit Shader。将其命名为WaterShader。选择Lit Shader作为起点因为它已经包含了URP标准的光照模型框架我们可以在其基础上修改。创建材质球右键点击选择Create - Material命名为WaterMaterial。关联材质与Shader将WaterMaterial的Shader属性从默认的“Universal Render Pipeline/Lit”拖拽选择为我们刚创建的WaterShader。创建测试水面在场景中创建一个PlaneGameObject - 3D Object - Plane将其Scale调整为10, 1, 10以得到一个较大的水面。将WaterMaterial拖拽到该Plane上。2.3 准备关键纹理资产我们需要一张高质量的法线贴图来模拟水面细节。你可以在一些免费的纹理网站如纹理天堂、CC0 Textures搜索“Water Normal”或“Water Ripples”。导入法线贴图将下载的PNG或TGA格式的法线贴图导入Unity的Assets/Textures文件夹。确保纹理的导入设置中Texture Type设置为Normal map这会通知Unity正确解释纹理数据。可选准备深度纹理URP默认不生成深度纹理。为了后续计算水深我们需要手动开启。选中之前提到的URP Asset文件在检查器中找到Rendering部分勾选Depth Texture选项。至此基础环境搭建完成。接下来我们将进入Shader代码的核心部分。3. 实现基础水面法线、反射与折射打开WaterShader文件我们将从URP Lit Shader的模板开始改造。请注意以下代码是示意性的关键片段完整Shader需要处理更多细节。3.1 定义Shader属性与变量在Shader文件的Properties块和CGPROGRAM内部的变量声明部分我们需要暴露一些参数供材质球调节。// Properties 块 - 在材质球面板显示 Properties { [Header(Base Settings)] _BaseColor(Base Color, Color) (0.1, 0.3, 0.6, 1.0) _DepthColor(Depth Color, Color) (0.0, 0.1, 0.2, 1.0) _MaxDepth(Max Depth, Float) 5.0 [Header(Normal Map)] _NormalMap(Normal Map, 2D) bump {} _NormalScale(Normal Scale, Range(0, 2)) 1.0 _NormalSpeed(Normal Speed, Vector) (0.05, 0.05, 0, 0) [Header(Reflection)] _ReflectionStrength(Reflection Strength, Range(0, 1)) 0.8 _ReflectionDistortion(Reflection Distortion, Range(0, 0.1)) 0.02 [Header(Refraction)] _RefractionStrength(Refraction Strength, Range(0, 1)) 0.5 _RefractionDistortion(Refraction Distortion, Range(0, 0.1)) 0.01 [Header(Fresnel)] _FresnelPower(Fresnel Power, Range(0.1, 10)) 5.0 _FresnelBias(Fresnel Bias, Range(0, 1)) 0.04 }在CGPROGRAM中需要用对应的变量来声明这些属性。3.2 计算动态法线法线是后续所有光学计算的基础。我们通过滚动两张法线贴图或同一张贴图以不同速度和方向并混合来模拟复杂的动态波纹。// 在片元着色器函数中 void SurfaceFunction(Input IN, inout SurfaceData surfaceData) { // 计算两个不同速度的UV偏移 float2 uv1 IN.uv_NormalMap _Time.y * _NormalSpeed.xy * 0.1; float2 uv2 IN.uv_NormalMap _Time.y * _NormalSpeed.zw * 0.13; // 采样法线贴图从切线空间转换到世界空间 float3 normal1 UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uv1), _NormalScale); float3 normal2 UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, uv2), _NormalScale); // 混合两个法线简单相加并归一化可以得到更自然的变化 float3 tangentNormal normalize(float3(normal1.xy normal2.xy, normal1.z * normal2.z)); // 将切线空间法线转换到世界空间 float3 worldNormal TransformTangentToWorld(tangentNormal, IN.worldTangent, IN.worldBitangent, IN.worldNormal); surfaceData.normalTS tangentNormal; // 存储切线空间法线供后续扭曲使用 surfaceData.normalWS worldNormal; // 存储世界空间法线供光照计算使用 }这里的关键是使用_Time.y驱动UV偏移以及UnpackNormalScale函数正确处理法线贴图的压缩格式。混合两个不同速度的法线可以打破重复感。3.3 实现屏幕空间反射与折射在URP中我们可以通过SampleSceneColor函数获取当前渲染的屏幕图像通过偏移UV坐标来模拟反射和折射。// 获取当前片元的屏幕空间位置 float4 screenPos ComputeScreenPos(TransformWorldToHClip(IN.worldPos)); float2 screenUV screenPos.xy / screenPos.w; // 使用世界空间法线计算反射向量 float3 viewDir normalize(_WorldSpaceCameraPos - IN.worldPos); float3 reflectDir reflect(-viewDir, surfaceData.normalWS); // 将反射向量投影到屏幕空间并偏移UV float2 reflectionUV screenUV reflectDir.xz * _ReflectionDistortion; float4 reflectionColor SAMPLE_TEXTURE2D(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, reflectionUV); // 使用切线空间法线直接偏移UV来模拟折射 float2 refractionUV screenUV surfaceData.normalTS.xy * _RefractionDistortion; float4 refractionColor SAMPLE_TEXTURE2D(_CameraOpaqueTexture, sampler_CameraOpaqueTexture, refractionUV);_CameraOpaqueTexture是URP提供的包含场景不透明物体颜色的纹理。反射使用基于视角和法线的物理反射方向而折射则简化为直接用法线扰动UV。3.4 应用菲涅尔效应混合菲涅尔效应描述了物体表面反射率随观察角度变化的规律视线与表面法线夹角越大掠射角反射越强。我们使用Schlick近似公式。// 计算菲涅尔系数 float fresnel _FresnelBias (1.0 - _FresnelBias) * pow(1.0 - saturate(dot(viewDir, surfaceData.normalWS)), _FresnelPower); // 混合反射和折射颜色 float4 waterColor lerp(refractionColor, reflectionColor, fresnel); waterColor.rgb * lerp(_RefractionStrength, _ReflectionStrength, fresnel);lerp函数根据fresnel系数在折射色和反射色之间进行线性插值。视线越平行于水面fresnel值越接近1反射越强。3.5 根据深度调整颜色浅水清澈见底深水颜色浓郁。我们需要利用相机的深度纹理。// 采样深度纹理获取当前像素的深度值非线性 float rawDepth SampleSceneDepth(screenUV); // 将深度值转换为线性深度单位通常为世界空间单位 float linearDepth LinearEyeDepth(rawDepth, _ZBufferParams); // 计算水面到水底或场景中物体的深度差 float waterDepth linearDepth - screenPos.w; // screenPos.w 存储了水面到相机的距离 // 根据深度混合基础色和深度色 float depthFactor saturate(waterDepth / _MaxDepth); float4 depthColor lerp(_BaseColor, _DepthColor, depthFactor); // 最终颜色叠加深度影响 waterColor.rgb lerp(waterColor.rgb, depthColor.rgb, depthFactor * (1.0 - fresnel));SampleSceneDepth是URP中采样深度纹理的函数。深度计算是水面真实感的关键它让近岸处的水体显得透明而深处则呈现深蓝色。完成以上步骤后保存Shader回到Unity编辑器。材质球WaterMaterial上应该会出现我们定义的所有属性。调整参数你应该能看到一个具有动态波纹、反射、折射和深度变化的基础水面。4. 实现交互式涟漪让水面响应外力静态水面缺乏生气。交互式涟漪能让角色踏入水中、雨滴落下或物体投掷时水面产生动态扩散的波纹这是提升沉浸感的“点睛之笔”。4.1 交互数据传递从CPU到GPUShader本身不处理游戏逻辑。交互信息如碰撞位置、力度需要在C#脚本中计算然后通过材质属性MaterialPropertyBlock或全局Shader变量如ComputeBuffer传递给GPU。方案一使用材质属性适合少量、简单的交互点// WaterInteraction.cs using UnityEngine; public class WaterInteraction : MonoBehaviour { public Renderer waterRenderer; // 水面的Renderer组件 public string propertyName _InteractionCenter; public float rippleRadius 2.0f; public float rippleStrength 0.5f; private MaterialPropertyBlock mpb; private Vector4 interactionData; // xy: 位置 z: 强度 w: 半径 void Start() { mpb new MaterialPropertyBlock(); waterRenderer.GetPropertyBlock(mpb); // 初始化一个无效的交互数据如位置在远处 interactionData new Vector4(-1000, -1000, 0, 0); mpb.SetVector(propertyName, interactionData); waterRenderer.SetPropertyBlock(mpb); } void OnTriggerEnter(Collider other) { if (other.CompareTag(Player)) { Vector3 hitPoint other.ClosestPoint(transform.position); // 设置交互中心为碰撞点强度和半径 interactionData new Vector4(hitPoint.x, hitPoint.z, rippleStrength, rippleRadius); mpb.SetVector(propertyName, interactionData); waterRenderer.SetPropertyBlock(mpb); // 可以在这里启动一个协程随时间衰减 rippleStrength } } }方案二使用ComputeBuffer适合大量、持续的交互如雨滴此方案更复杂涉及在C#中创建结构化缓冲区并在Shader中声明相同的结构体进行读取。它性能更好但实现门槛更高。本文为简化采用方案一。4.2 Shader中的涟漪计算在Shader中我们需要根据传入的交互点信息计算其对当前像素法线的影响。通常使用一个衰减函数例如高斯衰减或线性衰减。首先在Shader属性中添加交互参数[Header(Interaction)] _InteractionCenter(Interaction Center (XY) Strength (Z) Radius (W), Vector) (0,0,0,0) _RippleSpeed(Ripple Speed, Float) 1.0然后在片元着色器中在计算基础法线之后叠加涟漪影响// 计算交互涟漪 float4 interaction _InteractionCenter; if (interaction.z 0.001) // 如果存在有效交互 { // 计算当前像素世界XZ位置到交互中心的距离 float2 pixelPosXZ float2(IN.worldPos.x, IN.worldPos.z); float2 centerXZ interaction.xy; float dist distance(pixelPosXZ, centerXZ); float radius interaction.w; if (dist radius) { // 计算衰减因子0到1之间中心为1边缘为0 float falloff 1.0 - smoothstep(0.0, radius, dist); // 计算涟漪高度随时间衰减的正弦波 float rippleHeight interaction.z * falloff * sin(dist * 10.0 - _Time.y * _RippleSpeed); // 将高度变化转换为法线扰动 // 简化处理假设涟漪是圆形法线扰动方向从中心向外 float2 dir normalize(pixelPosXZ - centerXZ); // 这里对 tangentNormal.xy 进行扰动 tangentNormal.xy dir * rippleHeight * 0.1; // 0.1是扰动强度系数 tangentNormal normalize(tangentNormal); } } // 之后使用扰动后的 tangentNormal 继续后续的世界空间转换和计算这段代码的核心思想是当像素位于交互点的影响半径内时根据距离计算一个随时间波动的“高度”值然后将这个高度变化转化为对法线向量在XY平面上的扰动。smoothstep函数用于创建平滑的衰减边缘避免涟漪边界出现硬边。4.3 涟漪的传播与衰减上面的简单示例是瞬时且不传播的。更真实的涟漪应该从中心向外传播并逐渐衰减。这需要更复杂的模拟通常有两种思路多帧状态保持在C#端维护一个交互点列表每帧更新其强度、半径和“年龄”并传递给Shader。Shader遍历所有有效交互点进行叠加。这对CPU有一定开销。使用RenderTexture模拟这是更专业的方法。创建一个低分辨率的RenderTexture作为“高度场”在每一帧用一个单独的Shader或Compute Shader根据物理公式如波动方程更新高度场。然后水面Shader采样这张高度场纹理来获取所有像素的精确高度和法线。这种方法能模拟复杂的波纹干涉和扩散性能也更好但实现复杂。对于入门到中级项目方法一维护一个小的交互点列表通常是够用的。确保在C#脚本中交互点的强度rippleStrength会随时间线性或指数衰减至0并在为0时从列表中移除或传递给Shader一个零强度值。5. 性能优化、调试与常见问题排查一个功能丰富的水面Shader可能会成为性能瓶颈。以下是一些关键的优化策略和问题排查指南。5.1 性能优化策略优化方向具体措施效果与权衡纹理采样1. 合并法线贴图如将R、G通道用于两个不同速度的波纹。2. 使用Mipmap并确保纹理导入设置正确。3. 在移动端考虑使用更低分辨率的法线贴图。减少纹理采样次数和带宽占用。合并贴图可能增加美术制作成本。计算简化1. 使用近似公式代替复杂函数如用dot(N, V)的简单幂运算代替精确菲涅尔。2. 在片元着色器中将可移至顶点着色器的计算如简单的UV滚动上移。3. 对于远处水面使用更简化的Shader变体LOD。降低GPU片元着色器的计算压力。可能牺牲远处细节。渲染状态1. 确保水面材质使用合适的渲染队列如Transparent并设置正确的RenderTypeTag。2. 合理使用深度写入ZWrite和深度测试ZTest。通常透明水面ZWrite Off,ZTest LEqual。3. 避免不必要的Alpha Blending如果不需要半透明使用Opaque。正确管理渲染顺序避免过度绘制和视觉错误。后处理交互如果使用屏幕空间反射SSR确保在URP Asset中仅在需要时开启并设置合理的最大距离和步进次数。SSR开销很大。显著降低高端效果带来的性能消耗。5.2 常见问题与排查路径在实现过程中你几乎一定会遇到各种渲染异常。下表列出了典型问题及其排查思路。问题现象可能原因检查与解决步骤水面全黑或纯色1. Shader编译错误。2. 纹理未正确绑定或采样。3. 光照模型不匹配。1. 查看Console窗口是否有Shader编译错误红色信息。2. 在材质球面板检查纹理是否赋值_NormalMap是否为Normal Map类型。3. 检查Shader中surfaceData结构体的字段如normalWS,albedo是否被正确赋值。法线贴图无效水面无波纹1. 法线贴图导入类型不是Normal Map。2. UV滚动计算错误或_Time未使用。3._NormalScale为0。1. 在Project窗口选中法线贴图在Inspector中确认Texture Type为Normal map。2. 在Shader中输出uv1或tangentNormal到颜色进行调试。3. 调整材质球上的_NormalScale滑块。反射/折射内容错误或扭曲过度1. 屏幕UV计算错误。2. 反射/折射扭曲系数_Distortion过大。3. 未正确获取_CameraOpaqueTexture。1. 输出screenUV或reflectionUV到颜色检查其范围是否在[0,1]。2. 将_ReflectionDistortion和_RefractionDistortion调小。3. 确认URP Asset中Opaque Texture已启用默认是启用的。深度计算错误岸边有硬边或颜色突变1. 深度纹理未启用。2. 线性深度转换公式错误。3._MaxDepth值设置过小。1. 确认URP Asset中Depth Texture已勾选。2. 使用URP提供的LinearEyeDepth或Linear01Depth函数。3. 增大_MaxDepth值观察深度渐变是否平滑。交互涟漪不显示1. C#脚本未成功设置材质属性。2. Shader中属性名与脚本中字符串不匹配。3. 交互点强度或半径太小。1. 在C#脚本中使用Debug.Log打印interactionData确认其值在触发时变化。2. 检查脚本中的propertyName与Shader中_InteractionCenter是否完全一致包括下划线。3. 在Scene视图中用Gizmos绘制交互点位置和半径确认其与水面相交。移动端性能极差1. 片元着色器计算过于复杂。2. 使用了高精度变量如float且未优化。3. 纹理分辨率过高。1. 使用Unity的Frame Debugger或RenderDoc分析GPU耗时。2. 在Shader中使用half或fixed精度替代非必要的float。3. 为移动平台创建简化版的Shader变体关闭或简化反射/折射计算。调试技巧在Shader开发中最有效的调试方法是“可视化调试”。将你怀疑有问题的中间变量如worldNormal,fresnel,depthFactor,screenUV等直接作为最终颜色输出。例如return float4(worldNormal * 0.5 0.5, 1.0);可以将法线可视化。通过观察这些中间结果的颜色可以快速定位计算错误发生在哪个环节。6. 进阶方向与生产环境建议当你掌握了基础水面和简单交互后可以考虑以下方向来提升效果和健壮性。6.1 效果进阶焦散Caustics模拟水底的光斑。通常使用一张焦散纹理根据水面法线扰动其UV并投影到水底物体上。这需要额外的渲染通道或延迟渲染技术。泡沫与浪花在岸边或物体与水剧烈交互处生成白色泡沫。可以通过基于深度和法线变化率的阈值来混合一张泡沫纹理实现。次表面散射Subsurface Scattering模拟光线在水体内部的散射使厚的水体边缘呈现透亮的绿色或蓝色。实现较为复杂通常需要预积分查找表或简化模型。基于物理的渲染PBR使用更精确的微表面模型如GGX来计算高光并引入粗糙度、金属度等参数使水面在不同光照环境下更真实。6.2 工程化与生产建议在将水面Shader投入实际游戏项目时需考虑以下工程实践参数标准化与美术协作将关键参数如主色、波纹尺度、反射强度整理成清晰的命名和合理的默认值范围。为美术同学提供文档或简单的材质实例让他们可以通过调节少量参数如“平静湖泊”、“汹涌海洋”快速得到所需效果而无需理解Shader代码。多平台适配为PC/主机和移动平台准备不同的Shader变体或质量等级。在URP中可以利用shader_feature或multi_compile指令来编译不同版本或在运行时根据设备性能动态切换材质。与场景光照集成确保水面能正确响应场景中的动态光源、环境光探针和反射探针。在URP Lit Shader框架下这通常已经处理好但自定义光照模型时需要手动处理。后处理兼容性考虑水面与后期处理效果如颜色分级、Bloom、运动模糊的交互。透明水面可能会与Bloom效果产生意外的光晕需要测试调整。性能监控在性能分析工具中标记你的水面渲染持续监控其Draw Call数量、三角形数量以及GPU耗时。对于大型开放水域考虑使用分块LODLevel of Detail系统在远处使用更简化的网格和Shader。从波纹动画到交互式涟漪构建一个高质量的水面Shader是一个系统性的工程它串联了数学、物理、图形API和引擎工具链。最好的学习方式不是复制代码而是理解每一步背后的意图并亲手调试每一个参数观察其带来的视觉变化。当你能够自如地控制水面的颜色、波纹的形态、反射的清晰度以及涟漪的扩散时你就真正掌握了这门让虚拟世界“流动”起来的技术。接下来可以尝试将本文的技术点应用到其他引擎或者挑战实现更复杂的流体模拟这将是一条充满挑战但回报丰厚的图形学之路。