1. 项目概述从ShaderToy到Unity的水波涟漪如果你在Unity里做过水面效果大概率用过法线贴图加菲涅尔反射或者用顶点动画模拟波浪。这些方法效果不错但总感觉少了点“灵性”——那种真实的、动态的、能与场景交互的涟漪扩散感。今天要聊的这个“Unity-Wave-Propagation-Water-Ripple”项目就完美地填补了这个空缺。它不是一个简单的贴图动画而是一个基于波动传播物理模型的实时水波模拟器。简单来说它把水面看作一个二维网格每个网格点记录着当前的水位高度然后通过一套数学规则让一个点的“起伏”像真实水波一样向四周扩散、衰减、叠加。这个项目的核心价值在于它将一个在ShaderToy上广受好评的创意原型原作者tomkh的“Wave Propagation Effect”完整地移植并适配到了Unity的渲染管线中让你能直接在游戏或交互应用里实现点击、拖拽甚至物体落入水面时生成无比逼真的动态涟漪。这个效果特别适合用在需要高动态、强交互的水面场景。比如你的2D游戏里有一滩积水角色踩上去会泛起涟漪或者在一个艺术装置或数据可视化项目中用鼠标划过屏幕就能“拨动”出一片水波。它不依赖于复杂的三维网格变形核心计算都在屏幕空间的纹理Render Texture里完成性能开销相对可控在移动端经过优化也能有不错的表现。无论你是想为你的独立游戏增添一抹生动的细节还是单纯对图形学与物理模拟的结合感兴趣这个项目都是一个绝佳的学习范本和实用工具。接下来我会带你彻底拆解它的实现原理、Unity工程的具体搭建步骤、Shader代码的每一处细节并分享我在实际应用和性能调优中踩过的坑和总结的技巧。2. 核心原理二维波动方程的离散化实现要理解这个水波效果我们得先抛开Unity和Shader回到最基础的物理模型。理想水面的波动可以用一个简化版的二维波动方程来描述。不过别担心我们不需要解复杂的微分方程而是用一种更直观、更适合计算机模拟的“细胞自动机”思路来理解。2.1 网格与状态水面的数字化想象一下我们把整个水面在屏幕上就是一块矩形区域划分成无数个细小格子组成一个二维网格。每个格子我们称之为一个“细胞”Cell。在这个模拟中每个细胞只关心一个核心状态当前时刻的水面高度或者说位移。我们可以用一个二维数组height[t][x][y]来记录其中t代表时间帧x和y是网格坐标。但实时渲染中我们无法保存所有历史帧的数据。聪明的做法是只保留当前帧和上一帧的高度图。我们定义两张纹理Render TextureCurrentBuffer: 存储当前帧所有网格点的高度值。PreviousBuffer: 存储上一帧所有网格点的高度值。 高度值通常用像素的R通道一个0到1的浮点数来存储0代表静止水平面正负值代表起伏。2.2 传播与计算下一帧高度如何产生波动传播的核心算法可以用下面这个简化的离散公式来理解它计算的是网格中某个点(x, y)在下一帧的新高度newHeightnewHeight[x][y] ( (currentHeight[x1][y] currentHeight[x-1][y] currentHeight[x][y1] currentHeight[x][y-1]) / 2.0 - previousHeight[x][y] ) * damping;我们来拆解这个公式的每一部分邻居平均(currentHeight[x1][y] currentHeight[x-1][y] currentHeight[x][y1] currentHeight[x][y-1]) / 2.0这部分计算了当前点上下左右四个直接邻居在当前帧的平均高度。为什么是除以2原公式是(sum_of_4_neighbors) / 2这其实是一个简化的拉普拉斯算子离散形式它描述了“周围邻居对我的拉扯作用”。如果周围邻居都比我高这个平均值就会把我“拉”高反之则拉低。这是波动传播的驱动力。减去上一帧自身高度- previousHeight[x][y]这是模拟“惯性”。新的高度变化不仅取决于当前周围环境还和自身上一刻的状态有关。减去上一帧的高度结合邻居平均共同决定了运动的加速度二阶导数的离散形式。这是波动方程的核心。乘以阻尼系数* dampingdamping是一个略小于1的常数比如0.99。每一帧计算后都乘以这个系数波的能量就会逐渐衰减高度值慢慢回归到0静止水面。没有阻尼波就会永远震荡下去。计算流程的“双缓冲”技巧 在代码中我们不会真的去修改CurrentBuffer。标准的做法是输入PreviousBuffer(作为上一帧高度prev),CurrentBuffer(作为当前帧高度curr)。输出一张新的NextBuffer(作为下一帧高度new)。然后在下一轮计算开始前进行缓冲区轮换PreviousBuffer CurrentBuffer,CurrentBuffer NextBuffer。 这个过程就像动画制作中的翻页通过两张或三张纹理交替作为输入和输出实现状态的连续迭代更新。注意这个简化公式省略了波速等参数。在实际的ShaderToy原版和本Unity项目中计算会更精细一些可能包含对角线邻居的贡献或者调整了归一化系数但核心思想完全一致用当前帧邻居的状态和自身历史状态迭代计算出下一帧的状态。2.3 渲染与视觉化从高度到波纹计算得到的高度图CurrentBuffer只是一张灰度图数值在正负之间变化。如何把它变成我们看到的蓝色涟漪法线生成这是关键一步。我们通过对高度图进行屏幕空间差分来近似计算法线。简单说就是检查一个像素点与其右边邻居、上边邻居的高度差。float dX height[x1][y] - height[x][y];// X方向的变化率坡度float dY height[x][y1] - height[x][y];// Y方向的变化率坡度这两个差值构成了一个向量(dX, dY, 1)将其归一化后就得到了该点的近似法线向量(nX, nY, nZ)。这个法线图是后续光照计算的基础。光照与颜色有了法线图就可以应用经典的光照模型。漫反射用法线与光源方向比如一个固定的顶光点乘决定基础明暗。镜面反射/菲涅尔效应通常还会混合一些基于视角的效应让水面在边缘处更透明或反射更强。在这个项目中为了简洁和性能可能采用一个固定的颜色渐变如从深蓝到浅蓝再到白色来映射高度值并结合法线扰动模拟出波光粼粼的感觉。交互输入如何用鼠标或触摸产生涟漪很简单在交互发生的瞬间向CurrentBuffer中对应的网格坐标“注入”一个高度值比如一个脉冲一个圆形区域内的点设置为一个正值。在下一帧的计算中这个突起就会按照上述波动公式开始向四周传播和扩散形成涟漪。3. Unity工程搭建与核心组件解析理解了原理我们来看如何在Unity中具体实现。这个GitHub项目提供了完整的代码和Shader但为了让你能彻底掌握并能自行修改我们来一步步拆解。3.1 项目结构与资源准备首先你需要一个Unity项目建议使用较新版本如2021.3 LTS或2022.3 LTS对URP/HDRP和ShaderGraph支持更好。核心文件通常包括C#脚本负责管理渲染纹理、组织渲染流程、处理交互输入。Compute Shader 或 多个Pass的Shader执行核心的高度场迭代计算。原项目可能使用Fragment Shader在多张RenderTexture之间绘制来实现现代做法更倾向于用Compute Shader并行效率更高。显示用Shader用于将最终的高度图/法线图渲染到屏幕上的某个平面Quad或整个相机视图。第一步创建渲染纹理Render Texture我们需要至少两张Render Texture作为高度场缓冲区。在Asset文件夹中右键创建或通过代码动态创建。// 示例代码创建用于计算的高度场纹理 RenderTexture CreateHeightFieldRT(int width, int height) { RenderTexture rt new RenderTexture(width, height, 0, RenderTextureFormat.RFloat); // 只需一个浮点通道存储高度 rt.enableRandomWrite true; // 允许Compute Shader写入 rt.wrapMode TextureWrapMode.Clamp; // 边缘模式Clamp防止波纹溢出屏幕 rt.filterMode FilterMode.Point; // 计算阶段通常用点过滤避免插值引入误差 rt.Create(); return rt; }这里的关键参数是RenderTextureFormat.RFloat因为高度值需要浮点精度。enableRandomWrite true对于使用Compute Shader至关重要。3.2 核心计算脚本C#的工作流创建一个名为WavePropagationController的MonoBehaviour脚本它将是整个系统的总指挥。脚本的核心职责初始化缓冲区在Start()或Awake()中创建两张或三张Render Texture (_bufferA,_bufferB)并可能用一张纯色纹理代表平静水面初始化它们。设置计算材质/Compute Shader加载计算用的Shader并为其设置正确的纹理参数和全局变量如阻尼系数_Damping、波速_WaveSpeed等。每帧更新计算在Update()中处理输入鼠标/触摸将交互位置和强度写入当前高度缓冲区这通常需要另一个单独的Compute Shader Pass或Blit操作。执行核心的波动传播计算。这通常是一个Graphics.Blit调用使用一个特定的Material或者一个ComputeShader.Dispatch调用。这个操作读取_bufferA(作为当前帧) 和_bufferB(作为上一帧)然后输出到一张临时纹理或直接轮换后的_bufferA。执行缓冲区交换Swap(_bufferA, _bufferB)。渲染结果在OnRenderImage后处理方式或者用一个单独的摄像机渲染到一个Quad上的方式将最终的高度图或经过着色后的结果显示出来。一个典型的双缓冲更新循环伪代码void UpdateWaveSimulation() { // 1. 处理交互将输入写入到 _currentBuffer ProcessInteraction(_currentBuffer); // 2. 执行波动计算以 _currentBuffer 和 _previousBuffer 为输入输出到 _nextBuffer _simulationMaterial.SetTexture(_CurrentTex, _currentBuffer); _simulationMaterial.SetTexture(_PreviousTex, _previousBuffer); _simulationMaterial.SetFloat(_Damping, damping); Graphics.Blit(_currentBuffer, _nextBuffer, _simulationMaterial); // 使用特定的Shader Pass进行计算 // 3. 缓冲区轮换 Swap(ref _currentBuffer, ref _previousBuffer); // _previousBuffer 变成旧的_currentBuffer // _nextBuffer 现在变成了新的 _currentBuffer但我们需要把它的内容“移交” // 更常见的做法是直接让 _nextBuffer 和 _currentBuffer 交换引用避免大量数据拷贝 Swap(ref _currentBuffer, ref _nextBuffer); }3.3 计算着色器Compute Shader深度解析对于性能要求高的场景Compute Shader是首选。我们来看一个简化版的核心计算内核。// WaveSimulation.compute #pragma kernel CSWavePropagate RWTexture2Dfloat _CurrentHeight; // 当前帧高度图 (可读写) Texture2Dfloat _PreviousHeight; // 上一帧高度图 (只读) float _Damping; float _WaveSpeed; // 控制波传播速度的系数 uint _Width; uint _Height; [numthreads(8, 8, 1)] void CSWavePropagate (uint3 id : SV_DispatchThreadID) { uint x id.x; uint y id.y; if (x _Width || y _Height) return; // 获取当前点及邻居的高度注意边界处理 float center _CurrentHeight[uint2(x, y)]; // 使用分支或预取方式安全地获取邻居值处理边界边界点邻居可能取自身或0 float left (x 0) ? _CurrentHeight[uint2(x-1, y)] : center; float right (x _Width-1) ? _CurrentHeight[uint2(x1, y)] : center; float top (y 0) ? _CurrentHeight[uint2(x, y-1)] : center; float bottom (y _Height-1) ? _CurrentHeight[uint2(x, y1)] : center; // 获取上一帧自身高度 float prevCenter _PreviousHeight[uint2(x, y)]; // 核心波动公式更完整版包含波速系数 // 公式: newHeight ( (sum(neighbors) - 4*center) * waveSpeedFactor 2*center - prevCenter ) * damping // 其中 (sum(neighbors) - 4*center) 是拉普拉斯算子的标准5点离散形式 float neighborSum left right top bottom; float laplacian neighborSum - 4.0 * center; float newHeight (laplacian * _WaveSpeed 2.0 * center - prevCenter) * _Damping; // 将结果写入一个临时数组或直接交换缓冲区策略中的输出纹理 // 这里假设 _CurrentHeight 就是我们要写入的“下一帧”缓冲区在双缓冲交换策略下 // 实际上为了避免读写冲突我们应有专门的输出纹理。这里为简化概念。 _CurrentHeight[uint2(x, y)] newHeight; }实操心得在Compute Shader中边界处理if (x 0)会带来分支可能影响GPU并行效率。一种优化技巧是将纹理的Wrap Mode设置为Clamp然后在采样时使用SampleLevel或直接通过Load读取让硬件自动处理边界。或者你可以将计算区域缩小一圈边界永远保持为0静止这样内部像素的计算就无需边界判断。3.4 渲染与着色器Display Shader计算得到的高度图需要被渲染出来。我们创建一个Unlit Shader或者Surface Shader来负责视觉化。// 片段着色器核心部分 (简化版) fixed4 frag (v2f i) : SV_Target { // 采样高度图 float height tex2D(_HeightMap, i.uv).r; // 通过差分计算法线 (在片段着色器中或提前用Compute Shader生成法线图) float2 texelSize _MainTex_TexelSize.xy; float hLeft tex2D(_HeightMap, i.uv - float2(texelSize.x, 0)).r; float hRight tex2D(_HeightMap, i.uv float2(texelSize.x, 0)).r; float hTop tex2D(_HeightMap, i.uv - float2(0, texelSize.y)).r; float hBottom tex2D(_HeightMap, i.uv float2(0, texelSize.y)).r; float3 normal; normal.x (hRight - hLeft) * 0.5; // X方向坡度 normal.y (hBottom - hTop) * 0.5; // Y方向坡度 normal.z 1.0; // Z分量 normal normalize(normal); // 基础颜色 法线光照 float3 lightDir normalize(float3(0.5, 1.0, 0.2)); // 简单定向光 float diffuse max(0, dot(normal, lightDir)); // 根据高度和光照混合颜色 float3 waterColorDeep float3(0.0, 0.1, 0.3); float3 waterColorShallow float3(0.2, 0.5, 0.8); float3 foamColor float3(1.0, 1.0, 1.0); // 高度越高波峰颜色越浅甚至出现白色泡沫 float heightFactor saturate(abs(height) * _FoamIntensity); float3 baseColor lerp(waterColorDeep, waterColorShallow, diffuse); float3 finalColor lerp(baseColor, foamColor, heightFactor); // 添加简单的镜面高光 float3 viewDir normalize(_WorldSpaceCameraPos - i.worldPos); float3 halfDir normalize(lightDir viewDir); float spec pow(max(0, dot(normal, halfDir)), _Gloss); finalColor spec * _SpecularColor; return float4(finalColor, 1.0); }这个着色器做了几件事从高度差算法线、用法线计算漫反射光照、根据高度值混合深浅水色和泡沫色、添加了简单的镜面高光。你可以根据需要调整颜色、光照模型甚至加入折射、反射等更复杂的效果。4. 完整实现步骤与参数调优现在我们把所有部分组装起来形成一个可运行的Unity场景。4.1 场景搭建步骤创建渲染目标在场景中创建一个Quad或Plane它将作为水面的视觉载体。将其缩放至适合屏幕或场景的大小。创建材质创建一个新材质使用上文编写的“显示用Shader”。将材质赋给Quad。挂载控制脚本创建一个空GameObject命名为“WaveSimulationManager”将WavePropagationController脚本挂载上去。脚本配置将Quad的材质拖拽到脚本的Display Material字段。在脚本中指定计算用的Compute Shader或Material。设置渲染纹理的分辨率如512x512。分辨率越高波纹越精细但性能消耗越大。调整参数Damping阻尼0.98-0.999、WaveSpeed波速0.5-2.0、Force交互力度。交互设置在控制脚本的Update方法中检测鼠标点击或触摸。将屏幕坐标转换为纹理UV坐标然后在计算着色器或通过一个单独的“注入”Pass向当前高度纹理的对应位置添加一个圆形区域的高度值。4.2 关键参数详解与调优指南参数调优是让效果逼真的关键。以下是我的经验值范围和建议参数名典型范围作用调优技巧Damping (阻尼)0.985 - 0.998控制波纹衰减速度。值越接近1波纹持续越久传播越远值越小波纹消失越快。想要平静水面的小涟漪用0.99左右想要类似粘稠液体的效果用0.98想要悠长的水波用0.995以上。WaveSpeed (波速)0.8 - 1.5控制波纹向外扩散的速度。物理上关联于水深。值越大波传播越快。通常设置在1.0附近比较自然。调得过高2可能使模拟不稳定数值爆炸。Force/Amplitude (力度/振幅)0.05 - 0.3鼠标点击或交互时注入的高度值大小。决定涟漪的初始强度。太大的值会导致水面剧烈震荡像爆炸而不是滴水。从0.1开始尝试。Radius (作用半径)0.01 - 0.05 (UV空间)交互时影响的范围大小。决定涟漪的初始大小。点按产生小圆波拖拽可产生线状波。可以做成根据鼠标移动速度动态变化。Texture Resolution (纹理分辨率)256x256 - 1024x1024高度图的分辨率。分辨率直接影响波纹的细腻程度和性能。移动端建议256或512PC端可尝试1024。注意RenderTexture的创建和采样开销。Time Step (模拟步进)Fixed to Frame Time通常每帧执行一次计算。波动方程对时间步长敏感。如果游戏帧率波动大可以考虑使用固定的时间步长Fixed Timestep进行模拟与渲染帧率解耦避免波纹速度时快时慢。性能优化技巧降低分辨率这是最有效的优化手段。很多时候512x512的视觉效果已经足够好但性能比1024x1024好很多。使用Compute Shader相比用Fragment Shader通过Blit进行全屏绘制Compute Shader的线程组并行计算效率更高尤其是对于这种每个像素独立计算的任务。降低更新频率如果不是极度需要可以每两帧更新一次模拟if (Time.frameCount % 2 0)视觉上几乎无差异但计算量减半。分块更新如果水面很大但只有局部有交互可以只更新以交互点为中心的一个矩形区域而不是整个纹理。但这实现起来更复杂。使用半精度浮点数如果目标平台支持如某些移动GPU在Render Texture格式和Compute Shader中使用半精度(half)可以提升性能。5. 常见问题、排查与效果增强在实际使用中你肯定会遇到各种问题。这里记录了一些典型情况及其解决方法。5.1 模拟不稳定数值爆炸现象波纹不仅不衰减反而幅度越来越大最终所有像素值变成NaN或极大值画面出现破碎的色块。原因这是数值模拟的经典问题。波动公式中的系数组合特别是波速_WaveSpeed和阻尼_Damping不满足数值稳定性条件。当_WaveSpeed过大时每次迭代增加的能量超过了阻尼衰减的能量。解决方案降低_WaveSpeed这是最直接的方法。尝试将其降到1.0以下比如0.8。增加_Damping让其更接近1但不要等于或超过1。从0.99提高到0.995。检查边界条件确保你的边界处理是正确的。如果边界反射能量例如使用重复Wrap模式也可能导致能量累积。确保边界是“吸收”或“固定”的Clamp to Edge或固定为0。使用更稳定的积分方法原始的Verlet积分用到了当前帧和上一帧有时不够稳定。可以尝试引入微小的“速度阻尼”项或者使用半隐式积分方法但这会大幅增加计算复杂度。5.2 波纹有“方格”感或锯齿现象扩散的波纹不是光滑的圆形而是有明显的像素方格感。原因计算纹理分辨率太低且计算着色器中使用的是点采样Point Filter。点采样在计算时是必要的可以防止插值污染数据但在最后显示时如果直接将低分辨率的高度图拉伸到全屏就会产生块状感。解决方案提高计算分辨率这是根本解决方法但耗费性能。在显示阶段使用双线性或三线性过滤确保最终显示水面的材质其_HeightMap纹理的采样器使用Linear过滤。在Shader中声明sampler2D _HeightMap;默认就是线性过滤。关键点计算用的纹理RenderTexture其filterMode应设为Point但传递给显示Shader时Unity会使用显示Shader中定义的采样器设置进行采样。后处理模糊对最终生成的法线图或颜色图进行一次轻微的高斯模糊可以平滑锯齿让波纹看起来更柔和。这需要额外的Blit操作。5.3 交互延迟或位置不准现象鼠标点击后波纹出现的位置和鼠标位置有偏移或者有延迟感。原因坐标转换错误鼠标屏幕坐标到纹理UV坐标的转换出错。需要考虑到视口、渲染纹理比例等因素。注入时机交互输入的处理帧和波纹计算帧不同步。解决方案精确坐标转换Vector3 mousePos Input.mousePosition; Ray ray Camera.main.ScreenPointToRay(mousePos); RaycastHit hit; if (Physics.Raycast(ray, out hit)) // 假设水面有一个Collider { Vector2 uv hit.textureCoord; // 这是在水面模型UV空间中的坐标 // 如果水面模型UV是简单拉伸那么uv可以直接用。 // 否则需要根据模型的UV映射来调整。 // 然后将uv传入Compute Shader。 }如果水面是全屏效果则更简单Vector2 uv new Vector2(mousePos.x / Screen.width, mousePos.y / Screen.height);确保每帧注入在控制脚本的Update中检测输入并立即在同一帧的模拟计算之前将交互数据写入高度缓冲区。确保执行顺序是处理输入 - 执行波动计算 - 交换缓冲区 - 渲染。5.4 效果增强思路基础波纹跑通后你可以尝试以下增强效果让它更出彩多源扰动不止是鼠标可以让场景中的物体如雨滴、角色脚部、行驶的船持续对水面产生扰动。为每个物体定义一个影响区域和力度每帧向高度图叠加。风场引入一个全局或局部的风场向量在波动计算中给每个点一个持续的方向性偏移可以模拟出风吹湖面的效果。障碍物在高度图中定义一些区域为“陆地”或“障碍物”这些点的高度永远为0且不参与计算。这样波纹传播到边缘就会“反弹”或消失可以模拟水池边界。与Stylized Water 2等资产结合你可以将这个动态高度图/法线图作为第三方水面着色器如Stylized Water 2的额外法线输入。将计算得到的法线图与资产原有的法线图混合能在漂亮的静态水面上叠加动态涟漪效果极佳。渲染优化将计算和渲染分离到不同的摄像机层。用一个低分辨率摄像机专门计算水波结果存储为RenderTexture。主摄像机渲染场景时采样这张纹理来影响水面的法线和高度。这样可以灵活控制计算开销和渲染质量。这个“Unity-Wave-Propagation-Water-Ripple”项目是一个经典的图形学编程案例它巧妙地将物理模拟、并行计算和实时渲染结合在一起。我最初实现它时被数值爆炸问题困扰了很久最后发现是波速参数设置得太激进。后来在移动端项目中使用时又将分辨率从1024降到了512并通过每两帧更新一次模拟成功将帧率稳定在60fps。它的扩展性很强你可以把它当作一个基础的“物理场模拟器”思路同样可以用于模拟热扩散、草地摆动甚至人群密度图。希望这篇超详细的拆解能帮你不仅实现效果更能理解其每一行代码背后的意义从而创造出属于你自己的独特水面交互。
Unity实时水波模拟:基于波动传播模型的交互式涟漪实现
发布时间:2026/7/8 17:37:24
1. 项目概述从ShaderToy到Unity的水波涟漪如果你在Unity里做过水面效果大概率用过法线贴图加菲涅尔反射或者用顶点动画模拟波浪。这些方法效果不错但总感觉少了点“灵性”——那种真实的、动态的、能与场景交互的涟漪扩散感。今天要聊的这个“Unity-Wave-Propagation-Water-Ripple”项目就完美地填补了这个空缺。它不是一个简单的贴图动画而是一个基于波动传播物理模型的实时水波模拟器。简单来说它把水面看作一个二维网格每个网格点记录着当前的水位高度然后通过一套数学规则让一个点的“起伏”像真实水波一样向四周扩散、衰减、叠加。这个项目的核心价值在于它将一个在ShaderToy上广受好评的创意原型原作者tomkh的“Wave Propagation Effect”完整地移植并适配到了Unity的渲染管线中让你能直接在游戏或交互应用里实现点击、拖拽甚至物体落入水面时生成无比逼真的动态涟漪。这个效果特别适合用在需要高动态、强交互的水面场景。比如你的2D游戏里有一滩积水角色踩上去会泛起涟漪或者在一个艺术装置或数据可视化项目中用鼠标划过屏幕就能“拨动”出一片水波。它不依赖于复杂的三维网格变形核心计算都在屏幕空间的纹理Render Texture里完成性能开销相对可控在移动端经过优化也能有不错的表现。无论你是想为你的独立游戏增添一抹生动的细节还是单纯对图形学与物理模拟的结合感兴趣这个项目都是一个绝佳的学习范本和实用工具。接下来我会带你彻底拆解它的实现原理、Unity工程的具体搭建步骤、Shader代码的每一处细节并分享我在实际应用和性能调优中踩过的坑和总结的技巧。2. 核心原理二维波动方程的离散化实现要理解这个水波效果我们得先抛开Unity和Shader回到最基础的物理模型。理想水面的波动可以用一个简化版的二维波动方程来描述。不过别担心我们不需要解复杂的微分方程而是用一种更直观、更适合计算机模拟的“细胞自动机”思路来理解。2.1 网格与状态水面的数字化想象一下我们把整个水面在屏幕上就是一块矩形区域划分成无数个细小格子组成一个二维网格。每个格子我们称之为一个“细胞”Cell。在这个模拟中每个细胞只关心一个核心状态当前时刻的水面高度或者说位移。我们可以用一个二维数组height[t][x][y]来记录其中t代表时间帧x和y是网格坐标。但实时渲染中我们无法保存所有历史帧的数据。聪明的做法是只保留当前帧和上一帧的高度图。我们定义两张纹理Render TextureCurrentBuffer: 存储当前帧所有网格点的高度值。PreviousBuffer: 存储上一帧所有网格点的高度值。 高度值通常用像素的R通道一个0到1的浮点数来存储0代表静止水平面正负值代表起伏。2.2 传播与计算下一帧高度如何产生波动传播的核心算法可以用下面这个简化的离散公式来理解它计算的是网格中某个点(x, y)在下一帧的新高度newHeightnewHeight[x][y] ( (currentHeight[x1][y] currentHeight[x-1][y] currentHeight[x][y1] currentHeight[x][y-1]) / 2.0 - previousHeight[x][y] ) * damping;我们来拆解这个公式的每一部分邻居平均(currentHeight[x1][y] currentHeight[x-1][y] currentHeight[x][y1] currentHeight[x][y-1]) / 2.0这部分计算了当前点上下左右四个直接邻居在当前帧的平均高度。为什么是除以2原公式是(sum_of_4_neighbors) / 2这其实是一个简化的拉普拉斯算子离散形式它描述了“周围邻居对我的拉扯作用”。如果周围邻居都比我高这个平均值就会把我“拉”高反之则拉低。这是波动传播的驱动力。减去上一帧自身高度- previousHeight[x][y]这是模拟“惯性”。新的高度变化不仅取决于当前周围环境还和自身上一刻的状态有关。减去上一帧的高度结合邻居平均共同决定了运动的加速度二阶导数的离散形式。这是波动方程的核心。乘以阻尼系数* dampingdamping是一个略小于1的常数比如0.99。每一帧计算后都乘以这个系数波的能量就会逐渐衰减高度值慢慢回归到0静止水面。没有阻尼波就会永远震荡下去。计算流程的“双缓冲”技巧 在代码中我们不会真的去修改CurrentBuffer。标准的做法是输入PreviousBuffer(作为上一帧高度prev),CurrentBuffer(作为当前帧高度curr)。输出一张新的NextBuffer(作为下一帧高度new)。然后在下一轮计算开始前进行缓冲区轮换PreviousBuffer CurrentBuffer,CurrentBuffer NextBuffer。 这个过程就像动画制作中的翻页通过两张或三张纹理交替作为输入和输出实现状态的连续迭代更新。注意这个简化公式省略了波速等参数。在实际的ShaderToy原版和本Unity项目中计算会更精细一些可能包含对角线邻居的贡献或者调整了归一化系数但核心思想完全一致用当前帧邻居的状态和自身历史状态迭代计算出下一帧的状态。2.3 渲染与视觉化从高度到波纹计算得到的高度图CurrentBuffer只是一张灰度图数值在正负之间变化。如何把它变成我们看到的蓝色涟漪法线生成这是关键一步。我们通过对高度图进行屏幕空间差分来近似计算法线。简单说就是检查一个像素点与其右边邻居、上边邻居的高度差。float dX height[x1][y] - height[x][y];// X方向的变化率坡度float dY height[x][y1] - height[x][y];// Y方向的变化率坡度这两个差值构成了一个向量(dX, dY, 1)将其归一化后就得到了该点的近似法线向量(nX, nY, nZ)。这个法线图是后续光照计算的基础。光照与颜色有了法线图就可以应用经典的光照模型。漫反射用法线与光源方向比如一个固定的顶光点乘决定基础明暗。镜面反射/菲涅尔效应通常还会混合一些基于视角的效应让水面在边缘处更透明或反射更强。在这个项目中为了简洁和性能可能采用一个固定的颜色渐变如从深蓝到浅蓝再到白色来映射高度值并结合法线扰动模拟出波光粼粼的感觉。交互输入如何用鼠标或触摸产生涟漪很简单在交互发生的瞬间向CurrentBuffer中对应的网格坐标“注入”一个高度值比如一个脉冲一个圆形区域内的点设置为一个正值。在下一帧的计算中这个突起就会按照上述波动公式开始向四周传播和扩散形成涟漪。3. Unity工程搭建与核心组件解析理解了原理我们来看如何在Unity中具体实现。这个GitHub项目提供了完整的代码和Shader但为了让你能彻底掌握并能自行修改我们来一步步拆解。3.1 项目结构与资源准备首先你需要一个Unity项目建议使用较新版本如2021.3 LTS或2022.3 LTS对URP/HDRP和ShaderGraph支持更好。核心文件通常包括C#脚本负责管理渲染纹理、组织渲染流程、处理交互输入。Compute Shader 或 多个Pass的Shader执行核心的高度场迭代计算。原项目可能使用Fragment Shader在多张RenderTexture之间绘制来实现现代做法更倾向于用Compute Shader并行效率更高。显示用Shader用于将最终的高度图/法线图渲染到屏幕上的某个平面Quad或整个相机视图。第一步创建渲染纹理Render Texture我们需要至少两张Render Texture作为高度场缓冲区。在Asset文件夹中右键创建或通过代码动态创建。// 示例代码创建用于计算的高度场纹理 RenderTexture CreateHeightFieldRT(int width, int height) { RenderTexture rt new RenderTexture(width, height, 0, RenderTextureFormat.RFloat); // 只需一个浮点通道存储高度 rt.enableRandomWrite true; // 允许Compute Shader写入 rt.wrapMode TextureWrapMode.Clamp; // 边缘模式Clamp防止波纹溢出屏幕 rt.filterMode FilterMode.Point; // 计算阶段通常用点过滤避免插值引入误差 rt.Create(); return rt; }这里的关键参数是RenderTextureFormat.RFloat因为高度值需要浮点精度。enableRandomWrite true对于使用Compute Shader至关重要。3.2 核心计算脚本C#的工作流创建一个名为WavePropagationController的MonoBehaviour脚本它将是整个系统的总指挥。脚本的核心职责初始化缓冲区在Start()或Awake()中创建两张或三张Render Texture (_bufferA,_bufferB)并可能用一张纯色纹理代表平静水面初始化它们。设置计算材质/Compute Shader加载计算用的Shader并为其设置正确的纹理参数和全局变量如阻尼系数_Damping、波速_WaveSpeed等。每帧更新计算在Update()中处理输入鼠标/触摸将交互位置和强度写入当前高度缓冲区这通常需要另一个单独的Compute Shader Pass或Blit操作。执行核心的波动传播计算。这通常是一个Graphics.Blit调用使用一个特定的Material或者一个ComputeShader.Dispatch调用。这个操作读取_bufferA(作为当前帧) 和_bufferB(作为上一帧)然后输出到一张临时纹理或直接轮换后的_bufferA。执行缓冲区交换Swap(_bufferA, _bufferB)。渲染结果在OnRenderImage后处理方式或者用一个单独的摄像机渲染到一个Quad上的方式将最终的高度图或经过着色后的结果显示出来。一个典型的双缓冲更新循环伪代码void UpdateWaveSimulation() { // 1. 处理交互将输入写入到 _currentBuffer ProcessInteraction(_currentBuffer); // 2. 执行波动计算以 _currentBuffer 和 _previousBuffer 为输入输出到 _nextBuffer _simulationMaterial.SetTexture(_CurrentTex, _currentBuffer); _simulationMaterial.SetTexture(_PreviousTex, _previousBuffer); _simulationMaterial.SetFloat(_Damping, damping); Graphics.Blit(_currentBuffer, _nextBuffer, _simulationMaterial); // 使用特定的Shader Pass进行计算 // 3. 缓冲区轮换 Swap(ref _currentBuffer, ref _previousBuffer); // _previousBuffer 变成旧的_currentBuffer // _nextBuffer 现在变成了新的 _currentBuffer但我们需要把它的内容“移交” // 更常见的做法是直接让 _nextBuffer 和 _currentBuffer 交换引用避免大量数据拷贝 Swap(ref _currentBuffer, ref _nextBuffer); }3.3 计算着色器Compute Shader深度解析对于性能要求高的场景Compute Shader是首选。我们来看一个简化版的核心计算内核。// WaveSimulation.compute #pragma kernel CSWavePropagate RWTexture2Dfloat _CurrentHeight; // 当前帧高度图 (可读写) Texture2Dfloat _PreviousHeight; // 上一帧高度图 (只读) float _Damping; float _WaveSpeed; // 控制波传播速度的系数 uint _Width; uint _Height; [numthreads(8, 8, 1)] void CSWavePropagate (uint3 id : SV_DispatchThreadID) { uint x id.x; uint y id.y; if (x _Width || y _Height) return; // 获取当前点及邻居的高度注意边界处理 float center _CurrentHeight[uint2(x, y)]; // 使用分支或预取方式安全地获取邻居值处理边界边界点邻居可能取自身或0 float left (x 0) ? _CurrentHeight[uint2(x-1, y)] : center; float right (x _Width-1) ? _CurrentHeight[uint2(x1, y)] : center; float top (y 0) ? _CurrentHeight[uint2(x, y-1)] : center; float bottom (y _Height-1) ? _CurrentHeight[uint2(x, y1)] : center; // 获取上一帧自身高度 float prevCenter _PreviousHeight[uint2(x, y)]; // 核心波动公式更完整版包含波速系数 // 公式: newHeight ( (sum(neighbors) - 4*center) * waveSpeedFactor 2*center - prevCenter ) * damping // 其中 (sum(neighbors) - 4*center) 是拉普拉斯算子的标准5点离散形式 float neighborSum left right top bottom; float laplacian neighborSum - 4.0 * center; float newHeight (laplacian * _WaveSpeed 2.0 * center - prevCenter) * _Damping; // 将结果写入一个临时数组或直接交换缓冲区策略中的输出纹理 // 这里假设 _CurrentHeight 就是我们要写入的“下一帧”缓冲区在双缓冲交换策略下 // 实际上为了避免读写冲突我们应有专门的输出纹理。这里为简化概念。 _CurrentHeight[uint2(x, y)] newHeight; }实操心得在Compute Shader中边界处理if (x 0)会带来分支可能影响GPU并行效率。一种优化技巧是将纹理的Wrap Mode设置为Clamp然后在采样时使用SampleLevel或直接通过Load读取让硬件自动处理边界。或者你可以将计算区域缩小一圈边界永远保持为0静止这样内部像素的计算就无需边界判断。3.4 渲染与着色器Display Shader计算得到的高度图需要被渲染出来。我们创建一个Unlit Shader或者Surface Shader来负责视觉化。// 片段着色器核心部分 (简化版) fixed4 frag (v2f i) : SV_Target { // 采样高度图 float height tex2D(_HeightMap, i.uv).r; // 通过差分计算法线 (在片段着色器中或提前用Compute Shader生成法线图) float2 texelSize _MainTex_TexelSize.xy; float hLeft tex2D(_HeightMap, i.uv - float2(texelSize.x, 0)).r; float hRight tex2D(_HeightMap, i.uv float2(texelSize.x, 0)).r; float hTop tex2D(_HeightMap, i.uv - float2(0, texelSize.y)).r; float hBottom tex2D(_HeightMap, i.uv float2(0, texelSize.y)).r; float3 normal; normal.x (hRight - hLeft) * 0.5; // X方向坡度 normal.y (hBottom - hTop) * 0.5; // Y方向坡度 normal.z 1.0; // Z分量 normal normalize(normal); // 基础颜色 法线光照 float3 lightDir normalize(float3(0.5, 1.0, 0.2)); // 简单定向光 float diffuse max(0, dot(normal, lightDir)); // 根据高度和光照混合颜色 float3 waterColorDeep float3(0.0, 0.1, 0.3); float3 waterColorShallow float3(0.2, 0.5, 0.8); float3 foamColor float3(1.0, 1.0, 1.0); // 高度越高波峰颜色越浅甚至出现白色泡沫 float heightFactor saturate(abs(height) * _FoamIntensity); float3 baseColor lerp(waterColorDeep, waterColorShallow, diffuse); float3 finalColor lerp(baseColor, foamColor, heightFactor); // 添加简单的镜面高光 float3 viewDir normalize(_WorldSpaceCameraPos - i.worldPos); float3 halfDir normalize(lightDir viewDir); float spec pow(max(0, dot(normal, halfDir)), _Gloss); finalColor spec * _SpecularColor; return float4(finalColor, 1.0); }这个着色器做了几件事从高度差算法线、用法线计算漫反射光照、根据高度值混合深浅水色和泡沫色、添加了简单的镜面高光。你可以根据需要调整颜色、光照模型甚至加入折射、反射等更复杂的效果。4. 完整实现步骤与参数调优现在我们把所有部分组装起来形成一个可运行的Unity场景。4.1 场景搭建步骤创建渲染目标在场景中创建一个Quad或Plane它将作为水面的视觉载体。将其缩放至适合屏幕或场景的大小。创建材质创建一个新材质使用上文编写的“显示用Shader”。将材质赋给Quad。挂载控制脚本创建一个空GameObject命名为“WaveSimulationManager”将WavePropagationController脚本挂载上去。脚本配置将Quad的材质拖拽到脚本的Display Material字段。在脚本中指定计算用的Compute Shader或Material。设置渲染纹理的分辨率如512x512。分辨率越高波纹越精细但性能消耗越大。调整参数Damping阻尼0.98-0.999、WaveSpeed波速0.5-2.0、Force交互力度。交互设置在控制脚本的Update方法中检测鼠标点击或触摸。将屏幕坐标转换为纹理UV坐标然后在计算着色器或通过一个单独的“注入”Pass向当前高度纹理的对应位置添加一个圆形区域的高度值。4.2 关键参数详解与调优指南参数调优是让效果逼真的关键。以下是我的经验值范围和建议参数名典型范围作用调优技巧Damping (阻尼)0.985 - 0.998控制波纹衰减速度。值越接近1波纹持续越久传播越远值越小波纹消失越快。想要平静水面的小涟漪用0.99左右想要类似粘稠液体的效果用0.98想要悠长的水波用0.995以上。WaveSpeed (波速)0.8 - 1.5控制波纹向外扩散的速度。物理上关联于水深。值越大波传播越快。通常设置在1.0附近比较自然。调得过高2可能使模拟不稳定数值爆炸。Force/Amplitude (力度/振幅)0.05 - 0.3鼠标点击或交互时注入的高度值大小。决定涟漪的初始强度。太大的值会导致水面剧烈震荡像爆炸而不是滴水。从0.1开始尝试。Radius (作用半径)0.01 - 0.05 (UV空间)交互时影响的范围大小。决定涟漪的初始大小。点按产生小圆波拖拽可产生线状波。可以做成根据鼠标移动速度动态变化。Texture Resolution (纹理分辨率)256x256 - 1024x1024高度图的分辨率。分辨率直接影响波纹的细腻程度和性能。移动端建议256或512PC端可尝试1024。注意RenderTexture的创建和采样开销。Time Step (模拟步进)Fixed to Frame Time通常每帧执行一次计算。波动方程对时间步长敏感。如果游戏帧率波动大可以考虑使用固定的时间步长Fixed Timestep进行模拟与渲染帧率解耦避免波纹速度时快时慢。性能优化技巧降低分辨率这是最有效的优化手段。很多时候512x512的视觉效果已经足够好但性能比1024x1024好很多。使用Compute Shader相比用Fragment Shader通过Blit进行全屏绘制Compute Shader的线程组并行计算效率更高尤其是对于这种每个像素独立计算的任务。降低更新频率如果不是极度需要可以每两帧更新一次模拟if (Time.frameCount % 2 0)视觉上几乎无差异但计算量减半。分块更新如果水面很大但只有局部有交互可以只更新以交互点为中心的一个矩形区域而不是整个纹理。但这实现起来更复杂。使用半精度浮点数如果目标平台支持如某些移动GPU在Render Texture格式和Compute Shader中使用半精度(half)可以提升性能。5. 常见问题、排查与效果增强在实际使用中你肯定会遇到各种问题。这里记录了一些典型情况及其解决方法。5.1 模拟不稳定数值爆炸现象波纹不仅不衰减反而幅度越来越大最终所有像素值变成NaN或极大值画面出现破碎的色块。原因这是数值模拟的经典问题。波动公式中的系数组合特别是波速_WaveSpeed和阻尼_Damping不满足数值稳定性条件。当_WaveSpeed过大时每次迭代增加的能量超过了阻尼衰减的能量。解决方案降低_WaveSpeed这是最直接的方法。尝试将其降到1.0以下比如0.8。增加_Damping让其更接近1但不要等于或超过1。从0.99提高到0.995。检查边界条件确保你的边界处理是正确的。如果边界反射能量例如使用重复Wrap模式也可能导致能量累积。确保边界是“吸收”或“固定”的Clamp to Edge或固定为0。使用更稳定的积分方法原始的Verlet积分用到了当前帧和上一帧有时不够稳定。可以尝试引入微小的“速度阻尼”项或者使用半隐式积分方法但这会大幅增加计算复杂度。5.2 波纹有“方格”感或锯齿现象扩散的波纹不是光滑的圆形而是有明显的像素方格感。原因计算纹理分辨率太低且计算着色器中使用的是点采样Point Filter。点采样在计算时是必要的可以防止插值污染数据但在最后显示时如果直接将低分辨率的高度图拉伸到全屏就会产生块状感。解决方案提高计算分辨率这是根本解决方法但耗费性能。在显示阶段使用双线性或三线性过滤确保最终显示水面的材质其_HeightMap纹理的采样器使用Linear过滤。在Shader中声明sampler2D _HeightMap;默认就是线性过滤。关键点计算用的纹理RenderTexture其filterMode应设为Point但传递给显示Shader时Unity会使用显示Shader中定义的采样器设置进行采样。后处理模糊对最终生成的法线图或颜色图进行一次轻微的高斯模糊可以平滑锯齿让波纹看起来更柔和。这需要额外的Blit操作。5.3 交互延迟或位置不准现象鼠标点击后波纹出现的位置和鼠标位置有偏移或者有延迟感。原因坐标转换错误鼠标屏幕坐标到纹理UV坐标的转换出错。需要考虑到视口、渲染纹理比例等因素。注入时机交互输入的处理帧和波纹计算帧不同步。解决方案精确坐标转换Vector3 mousePos Input.mousePosition; Ray ray Camera.main.ScreenPointToRay(mousePos); RaycastHit hit; if (Physics.Raycast(ray, out hit)) // 假设水面有一个Collider { Vector2 uv hit.textureCoord; // 这是在水面模型UV空间中的坐标 // 如果水面模型UV是简单拉伸那么uv可以直接用。 // 否则需要根据模型的UV映射来调整。 // 然后将uv传入Compute Shader。 }如果水面是全屏效果则更简单Vector2 uv new Vector2(mousePos.x / Screen.width, mousePos.y / Screen.height);确保每帧注入在控制脚本的Update中检测输入并立即在同一帧的模拟计算之前将交互数据写入高度缓冲区。确保执行顺序是处理输入 - 执行波动计算 - 交换缓冲区 - 渲染。5.4 效果增强思路基础波纹跑通后你可以尝试以下增强效果让它更出彩多源扰动不止是鼠标可以让场景中的物体如雨滴、角色脚部、行驶的船持续对水面产生扰动。为每个物体定义一个影响区域和力度每帧向高度图叠加。风场引入一个全局或局部的风场向量在波动计算中给每个点一个持续的方向性偏移可以模拟出风吹湖面的效果。障碍物在高度图中定义一些区域为“陆地”或“障碍物”这些点的高度永远为0且不参与计算。这样波纹传播到边缘就会“反弹”或消失可以模拟水池边界。与Stylized Water 2等资产结合你可以将这个动态高度图/法线图作为第三方水面着色器如Stylized Water 2的额外法线输入。将计算得到的法线图与资产原有的法线图混合能在漂亮的静态水面上叠加动态涟漪效果极佳。渲染优化将计算和渲染分离到不同的摄像机层。用一个低分辨率摄像机专门计算水波结果存储为RenderTexture。主摄像机渲染场景时采样这张纹理来影响水面的法线和高度。这样可以灵活控制计算开销和渲染质量。这个“Unity-Wave-Propagation-Water-Ripple”项目是一个经典的图形学编程案例它巧妙地将物理模拟、并行计算和实时渲染结合在一起。我最初实现它时被数值爆炸问题困扰了很久最后发现是波速参数设置得太激进。后来在移动端项目中使用时又将分辨率从1024降到了512并通过每两帧更新一次模拟成功将帧率稳定在60fps。它的扩展性很强你可以把它当作一个基础的“物理场模拟器”思路同样可以用于模拟热扩散、草地摆动甚至人群密度图。希望这篇超详细的拆解能帮你不仅实现效果更能理解其每一行代码背后的意义从而创造出属于你自己的独特水面交互。