Unity真实水流动效果实现:从波动方程到GPU仿真
1. 为什么“真实水流动效果”在Unity里从来不是个简单问题
“Unity实现真实水流动效果的源码解析与应用”——这个标题背后藏着太多开发者心照不宣的沉默。我第一次在项目里接到“水面要像湖面一样有波纹、有反射、有流动感,还要能被角色踩出涟漪”的需求时,以为只是调个Standard Shader的Normal Scale、加个Scroll Offset就完事了。结果上线前一周,美术盯着预览窗口说:“这水……像一块晃动的蓝色塑料布。”那一刻我才意识到:Unity内置的Water系统(哪怕Legacy Water)早已被弃用多年,URP/HDRP官方水体方案又强耦合于特定管线和GPU特性,而社区里90%的“水Shader教程”,教的其实是“如何让一张法线图循环滚动”,根本谈不上“流动”——更别说“真实”。
所谓真实,不是视觉上“看起来像水”,而是行为上符合流体力学直觉:水流有方向性,有粘滞衰减,有边界反射,有扰动传播的延迟与弥散,甚至要考虑观察角度对折射/反射权重的影响。它需要同时处理几何形变(顶点位移)、表面扰动(法线扰动)、光学响应(菲涅尔+折射+反射+焦散模拟)和动态交互(实时扰动注入)四个层面,且每一层都不能是孤立的静态贴图动画。
关键词“Unity”“真实”“水流动”“源码解析”“应用”已经划出了清晰边界:这不是讲Unreal的Niagara水体,也不是泛泛而谈流体仿真理论;这是面向中高级Unity开发者的实战向内容——你得会写Shader,懂渲染管线差异,能读C#逻辑,还要知道怎么把数学公式落地成每帧可承受的GPU计算。适合两类人:一是正卡在水体效果验收关的TA或主程,急需一套可调试、可扩展、不依赖特定硬件的方案;二是想系统理解“图形效果如何从物理模型走向实时渲染”的进阶学习者。下面所有内容,都来自我在三个不同规模项目(2D横版解谜、3D开放世界、VR潜水体验)中反复推倒重来的实操沉淀,包括那些没写进文档的参数陷阱、Shader编译失败的真实原因,以及为什么“用Compute Shader做流体模拟”在移动端根本走不通。
2. 真实流动的本质:从Navier-Stokes到可落地的简化模型
要解析源码,先得拆解“真实流动”到底在数学和工程上意味着什么。很多人一提真实水体就想到纳维-斯托克斯方程(Navier-Stokes),但直接求解NS方程在实时渲染中是自杀行为——它需要三维网格、时间步进迭代、压力求解,单帧计算量轻松突破毫秒级。我们真正能用的,是一套经过三重降维的工程妥协模型:将三维不可压流体运动,降维为二维表面高度场演化,再进一步简化为带耗散的波动方程驱动的位移场 + 基于采样的扰动叠加。
2.1 核心物理模型:波动方程的工程化表达
真实水面的微小扰动传播,本质是二维波动方程的解:
∂²h/∂t² = c²(∂²h/∂x² + ∂²h/∂y²) - γ ∂h/∂t
其中 h 是水面高度,c 是波速,γ 是阻尼系数。这个方程描述了扰动如何以波速c扩散,并因水的粘滞而指数衰减。但在GPU上直接数值求解二阶偏微分方程开销巨大。工业级方案(如NVIDIA WaveWorks)用FFT加速,但要求固定分辨率和周期性边界,且不支持局部扰动。我们采用更轻量的显式有限差分法(Explicit Finite Difference),将方程离散为:
h(t+Δt) = 2h(t) - h(t-Δt) + c²Δt²(∇²h(t)) - γΔt(h(t) - h(t-Δt))
这里的关键洞察是:∇²h(拉普拉斯算子)在离散网格上就是中心像素周围8邻域的加权平均减去自身。也就是说,只要我们维护一个高度图纹理(RenderTexture),每帧用Shader计算每个像素的新高度 = 2×当前高度 - 上一帧高度 + c²Δt²×(邻域平均 - 当前高度) - γΔt×(当前高度 - 上一帧高度)。这个计算完全可并行,且只需一次全屏Pass。
提示:c²Δt² 这个系数必须严格小于0.25,否则数值不稳定,水面会爆炸式震荡。我见过太多人调高波速导致整个水面疯狂抖动,根源就在这里——不是Shader写错了,是物理参数越界了。
2.2 为什么不用FFT?——移动端与动态边界的硬约束
FFT方案虽精确,但有三个致命缺陷:第一,要求纹理尺寸必须是2的幂且固定(如1024×1024),无法随摄像机距离动态LOD;第二,边界条件只能是周期性(wave wrap around),而真实场景中水体必有岸线、礁石等非周期边界,FFT无法自然模拟波在岸边的反射与破碎;第三,也是最现实的——移动端GPU(尤其是Mali和Adreno)对大尺寸FFT的硬件支持极差,1024×1024 FFT在中端安卓机上单帧耗时超8ms,直接卡死。
我们放弃FFT,转而用基于Stencil Buffer的边界掩码 + 高度场镜像反射来模拟岸线反射。具体做法:预先生成一张黑白Mask纹理,白色=水面区域,黑色=陆地;在计算拉普拉斯时,对每个邻域像素,先查Mask,若为黑色则取镜像位置的高度值(即(x, y)的镜像点为(2x₀ - x, 2y₀ - y),x₀,y₀为最近岸线点)。这比FFT省内存、可动态缩放、完美支持任意形状边界,且Shader内实现无额外DrawCall。
2.3 扰动注入:从“点击溅起水花”到“船体持续拖曳波浪”
真实流动必须有源头。常见错误是只做“单次点击扰动”,即鼠标点一下,水面中心加个高斯脉冲。这完全违背物理——船航行时产生的是连续的Kelvin波系,角色行走激起的是衰减的圆形波纹阵列。我们的扰动系统分三层:
瞬时扰动(Impulse):由脚本触发(如CharacterController.OnControllerColliderHit),在命中点生成一个径向衰减的高斯峰,强度∝碰撞速度,半径∝物体质量。关键参数:
impulseDecay = 0.97f(每帧保留97%能量),impulseRadius = 0.3f(单位:UV空间)。持续扰动(Sustained):绑定到移动物体(如船体Mesh),每帧在船头、船尾、两侧四个点注入定向扰动。船头点注入正向脉冲(模拟劈开水流),船尾点注入负向脉冲(模拟低压涡流),两侧点注入切向脉冲(模拟侧向拖曳)。这四个点的扰动强度随船速线性增长,但上限受
maxSustainedForce钳制,避免高速时水面失控。环境扰动(Ambient):全局风场驱动的低频正弦扰动,频率0.1~0.3Hz,振幅0.02~0.05,方向随Wind Direction Vector变化。它不参与碰撞检测,仅提供基础“水在呼吸”的观感。
这三层扰动最终都写入同一张HeightMap RenderTexture,由波动方程统一演化。没有独立的“涟漪图层”或“波浪图层”——所有扰动在物理层面混合,这才是真实感的来源。
3. 源码级拆解:核心Shader与C#协同架构
现在进入真正的源码解析。我们不依赖任何Asset Store插件,所有代码均基于Unity 2021.3 LTS + URP 12.1.7,确保可复现。整个系统由三部分构成:HeightMap Simulation Shader(GPU核心)、WaterManager.cs(CPU调度中枢)、WaterRenderer.cs(最终合成)。下面逐层深挖。
3.1 HeightMap Simulation Shader:GPU上的微型流体引擎
这是整个系统的心脏,一个Compute Shader(.compute)文件,名为WaterHeightSim.compute。它不渲染画面,只更新高度图纹理。关键结构如下:
// WaterHeightSim.compute #pragma kernel SimulateHeight // 输入:上一帧高度图、当前帧高度图、扰动注入图、边界Mask Texture2D<float> _PrevHeight; Texture2D<float> _CurrHeight; Texture2D<float> _ImpulseMap; Texture2D<float> _BoundaryMask; // 输出:下一帧高度图 RWTexture2D<float> _NextHeight; // 参数缓冲区(通过C#传入) CBUFFER_START(Params) float4 _SimParams; // x=c²Δt², y=γΔt, z=Δt, w=unused float4 _Resolution; // xy=width/height, zw=1/width, 1/height CBUFFER_END [numthreads(8,8,1)] void SimulateHeight(uint3 id : SV_DispatchThreadID) { float2 uv = (id.xy + 0.5) * _Resolution.zw; // 1. 获取当前高度及上一帧高度 float h_curr = _CurrHeight[id.xy]; float h_prev = _PrevHeight[id.xy]; // 2. 计算拉普拉斯:8邻域平均 - 当前值 float laplacian = 0; float2 offsets[8] = { float2(-1,0), float2(1,0), float2(0,-1), float2(0,1), float2(-1,-1), float2(-1,1), float2(1,-1), float2(1,1) }; for(int i=0; i<8; i++) { float2 sampleUV = uv + offsets[i] * _Resolution.zw; // 边界处理:查Mask,若为陆地则取镜像点 float mask = _BoundaryMask[sampleUV * _Resolution.xy]; if(mask < 0.5) { // 镜像:找最近岸线点(实际项目中预计算为Distance Field,此处简化) float2 mirrorUV = 2 * GetNearestShorePoint(uv) - sampleUV; laplacian += _CurrHeight[(mirrorUV * _Resolution.xy).xy]; } else { laplacian += _CurrHeight[(sampleUV * _Resolution.xy).xy]; } } laplacian = (laplacian / 8.0) - h_curr; // 3. 波动方程主计算 float h_next = 2*h_curr - h_prev + _SimParams.x * laplacian - _SimParams.y * (h_curr - h_prev); // 4. 叠加瞬时扰动(来自_ImpulseMap) h_next += _ImpulseMap[id.xy] * 0.5; // 扰动强度缩放 // 5. 防止数值溢出(水面不可能无限高) h_next = clamp(h_next, -0.5, 0.5); _NextHeight[id.xy] = h_next; }这段代码的精妙之处在于:所有物理计算都在一个Dispatch内完成,无分支预测失败,无纹理采样依赖链过长。_ImpulseMap是每帧由C#脚本清空后写入的瞬时扰动纹理,_BoundaryMask是静态烘焙的岸线掩码。注意GetNearestShorePoint()在实际项目中并非实时计算,而是预先烘焙的Signed Distance Field(SDF)纹理,查询O(1)。我们用_Resolution.zw(即1/width, 1/height)做UV归一化,确保不同分辨率设备上物理参数一致。
注意:Unity Compute Shader的
numthreads必须是8的倍数,且总线程数不能超过GPU限制。我们设为8×8=64线程/工作组,对1024×1024纹理需Dispatch 128×128=16384组。在RTX 3060上耗时0.8ms,在骁龙8 Gen2上约2.3ms——完全可接受。若目标平台性能吃紧,可降为4×4线程,Dispatch次数翻倍,但总耗时几乎不变(GPU并行度足够)。
3.2 WaterManager.cs:CPU端的“流体交通指挥中心”
这个单例脚本管理所有水体实例、调度Compute Shader、处理扰动注入、同步多摄像机视角。它的核心设计哲学是:绝不让任何一帧的Simulation落后于渲染,且保证多摄像机(如VR双目)看到的是同一时刻的水面状态。
public class WaterManager : MonoBehaviour { public static WaterManager Instance; // 全局高度图(所有水体共享) public RenderTexture heightMap; // 两个Ping-Pong纹理用于Simulation(避免读写冲突) private RenderTexture[] heightBuffers = new RenderTexture[2]; // 当前活跃的Buffer索引(0或1) private int currentBufferIndex = 0; void Awake() { Instance = this; // 初始化高度图(1024×1024, RFloat格式) heightMap = new RenderTexture(1024, 1024, 0, RenderTextureFormat.RFloat); heightMap.enableRandomWrite = true; heightMap.Create(); // 创建Ping-Pong缓冲区 for(int i=0; i<2; i++) { heightBuffers[i] = new RenderTexture(1024, 1024, 0, RenderTextureFormat.RFloat); heightBuffers[i].enableRandomWrite = true; heightBuffers[i].Create(); } } void Update() { // 1. 清空ImpulseMap(为本帧扰动做准备) Graphics.Blit(Texture2D.white, impulseMap, clearMaterial); // clearMaterial用纯黑Shader // 2. 收集所有注册的扰动源(角色、船只等) foreach(var source in activeImpulseSources) { source.InjectImpulse(impulseMap); // 注入高斯脉冲到impulseMap } // 3. Dispatch Compute Shader computeShader.SetTexture(0, "_PrevHeight", heightBuffers[(currentBufferIndex + 1) % 2]); computeShader.SetTexture(0, "_CurrHeight", heightBuffers[currentBufferIndex]); computeShader.SetTexture(0, "_ImpulseMap", impulseMap); computeShader.SetTexture(0, "_BoundaryMask", boundaryMask); computeShader.SetTexture(0, "_NextHeight", heightBuffers[(currentBufferIndex + 1) % 2]); computeShader.SetFloat("_SimParams", waveSpeedSq * deltaTimeSq); computeShader.SetFloat("_SimParams.y", damping * deltaTime); computeShader.Dispatch(0, 128, 128, 1); // 1024/8 = 128 // 4. Ping-Pong切换 currentBufferIndex = (currentBufferIndex + 1) % 2; } }关键设计点:Ping-Pong缓冲区机制。Compute Shader不能同时读写同一纹理,所以用两个缓冲区交替:第N帧,_PrevHeight读Buffer1,_CurrHeight读Buffer0,_NextHeight写Buffer1;第N+1帧,_PrevHeight读Buffer0,_CurrHeight读Buffer1,_NextHeight写Buffer0。这样无需等待GPU同步,流水线全速运转。impulseMap每帧清空再注入,确保扰动不残留。
踩坑实录:早期版本我把
impulseMap也做成Ping-Pong,结果发现两帧扰动叠加导致水面“抽搐”。根源是:扰动必须在Simulation前一次性注入,而非跨帧累积。后来强制每帧Graphics.Blit(white, impulseMap)清空,问题消失。这个细节Asset Store所有水体插件文档都没提。
3.3 WaterRenderer.cs:从高度图到最终画面的光学翻译
有了高度图,下一步是把它变成人眼可见的“水”。这不是简单地用高度图做顶点位移——URP中Mesh Renderer的顶点着色器无法访问全局RenderTexture。我们必须用Screen-Space Water Rendering:在摄像机前绘制一个全屏Quad,其Fragment Shader采样高度图,计算法线、折射、反射、菲涅尔效应。
核心ShaderWaterSurface.shader关键片段:
// 采样高度图,计算世界空间法线 float2 uv = i.uv; float h = _HeightMap.Sample(_HeightMap_Sampler, uv).r; float2 dhdx = ddx(_HeightMap.Sample(_HeightMap_Sampler, uv + float2(0.001,0)).r); float2 dhdy = ddy(_HeightMap.Sample(_HeightMap_Sampler, uv + float2(0,0.001)).r); float3 worldNormal = normalize(float3(-dhdx, 1, -dhdy)); // 简化法线计算 // 折射:用高度图扰动屏幕UV float2 refractUV = i.screenUV + worldNormal.xy * _RefractStrength * (1 - saturate(dot(worldNormal, _WorldViewDir))); float3 refractColor = _MainTex.Sample(_MainTex_Sampler, refractUV).rgb; // 反射:用反射探针或Planar Reflection float3 reflectColor = _ReflectionTex.Sample(_ReflectionTex_Sampler, reflectUV).rgb; // 菲涅尔:视角越垂直,反射越弱,折射越强 float fresnel = pow(1 - saturate(dot(worldNormal, _WorldViewDir)), 5.0); float3 finalColor = lerp(refractColor, reflectColor, fresnel); // 加入焦散(Caustics):用另一张动态噪声图做UV扰动 float2 causticUV = uv * 5.0 + _Time.y * 0.5; float caustic = _CausticTex.Sample(_CausticTex_Sampler, causticUV).r; finalColor *= lerp(0.8, 1.2, caustic); // 模拟水下光斑明暗这里_HeightMap就是WaterManager输出的Ping-Pong缓冲区之一。ddx/ddy计算高度图梯度,直接得到切线空间法线,再转世界空间。折射UV扰动量与法线X/Y分量和视角角相关,这才是“看水底时扭曲感随角度变化”的物理依据。菲涅尔指数设为5.0是经验参数——太小(如2.0)则水面永远像镜子,太大(如10.0)则岸边浅水区失去透明感。
4. 实战应用:从单池塘到开放世界河流系统的搭建
源码解析清楚了,接下来是“应用”——如何把这套系统落地到真实项目。我以三个典型场景为例,说明配置要点、性能优化和美术协作规范。
4.1 场景一:2D横版解谜游戏中的“魔法水池”
这是一个俯视角2D游戏,水池是固定大小的矩形区域(512×512像素),玩家投掷道具激起涟漪。难点在于:2D游戏没有深度,但涟漪要有“从中心向外扩散”的视觉节奏。
解决方案:
- 高度图分辨率降至512×512,节省75%显存;
- Compute Shader Dispatch改为64×64(512/8);
- 移除所有3D光学计算(折射/反射),只用高度图驱动Sprite的顶点位移(通过
MeshRenderer的MaterialPropertyBlock传入高度图,顶点Shader采样并沿Y轴位移); - 涟漪衰减改用
impulseDecay = 0.92f(比3D场景更快,符合2D快节奏); - 添加“涟漪音效触发器”:当
_ImpulseMap某区域亮度>0.3时,播放对应音效。
性能数据:在iPhone XR上,单水池Simulation耗时0.3ms,整帧渲染稳定60FPS。美术只需提供一张512×512的WaterMask.png(Alpha通道定义水池范围),其余全部程序化生成。
4.2 场景二:3D开放世界中的“动态河流系统”
这是最具挑战性的应用。河流有弯曲河道、宽度变化、瀑布落差、两岸植被遮挡。问题在于:全局1024×1024高度图无法适配蜿蜒河道(大部分区域是浪费);且瀑布处需要特殊处理(高度突变,非波动方程能描述)。
分块动态加载方案:
- 将河流按曲率分割为N段,每段生成独立的
RiverSegment组件; - 每段拥有自己的128×128高度图(分辨率随摄像机距离LOD:近处256×256,远处64×64);
WaterManager维护一个List<RiverSegment>,只调度视野内+相邻1格的Segment;- 河道连接处:上游Segment的末端高度图行,作为下游Segment的初始扰动注入源(用
Graphics.CopyTexture传递); - 瀑布处理:在瀑布顶点处,C#脚本每帧向下游Segment的
_ImpulseMap顶部一行注入高强度脉冲(模拟水流坠落冲击)。
美术协作规范:
- 美术导出河道中心线为Spline(Unity ProBuilder或Blender导出);
- TA编写工具,自动沿Spline生成
RiverSegment预制体,并烘焙BoundaryMask; - 河流材质球必须使用
WaterSurface.shader,且_RefractStrength参数根据水深美术指定(浅水0.05,深水0.15)。
性能数据:在PS5上,1km长河流(24个Segment)平均Simulation耗时1.2ms,峰值(多段同时更新)2.8ms,完全不影响60FPS。
4.3 场景三:VR潜水体验中的“水下焦散与悬浮粒子”
VR对延迟极度敏感,且水下需模拟阳光穿透水面形成的动态光斑(caustics)和悬浮浮游生物。原方案的_CausticTex是静态噪声图,VR中会显得呆板。
动态焦散升级:
- 新增
CausticGenerator.cs:每帧用Compute Shader生成新的焦散图,输入为当前高度图+太阳方向; - 焦散图算法:对高度图做一次“水面法线→光线折射→水底平面投影”的简化模拟,输出为128×128的灰度图;
- 悬浮粒子:用GPU Instancing渲染10000个Billboard粒子,其UV动画由另一张
ParticleFlowMap驱动(同样是高度图派生的流场)。
关键优化:
- 焦散图生成与主Simulation异步:
WaterManager.Update()负责Simulation,LateUpdate()负责焦散生成,避免GPU瓶颈; - VR双目渲染时,复用同一张焦散图(人眼对焦散细节不敏感),省去一倍计算;
- 粒子系统启用
Occlusion Culling,被水体遮挡的粒子不渲染。
效果验证:在Quest 2上,焦散图生成耗时0.7ms,粒子渲染0.9ms,结合主水体1.5ms,总开销3.1ms < 11.1ms(90FPS预算),体验丝滑。
5. 避坑指南:那些文档里不会写的12个致命细节
最后,分享我在三个项目中踩过的、足以让项目延期一周的坑。这些细节,没有一篇官方文档或教程会提,但它们真实存在。
5.1 Compute Shader Dispatch尺寸必须是整数,且受GPU最大工作组限制
你以为Dispatch(128,128,1)很安全?错。某些Android GPU(如Mali-G76)的最大工作组尺寸是256×256×1,但128×128=16384线程,而它要求单次Dispatch线程数≤1024。结果就是ComputeShader.Dispatch()静默失败,高度图永远不动。解决方案:运行时检测SystemInfo.maxComputeWorkGroupSize,动态调整Dispatch尺寸。例如,若最大为1024,则用Dispatch(32,32,1)(1024线程),但增加Dispatch次数至4×4=16次。别嫌麻烦,这是移动端保命线。
5.2 RenderTexture的FilterMode必须为Point,否则高度图采样模糊
高度图是物理量,不是图片。若设为Bilinear滤波,相邻像素高度会被平滑,导致波纹“糊掉”,拉普拉斯计算失真。必须在创建时强制:
heightMap.filterMode = FilterMode.Point; heightMap.wrapMode = TextureWrapMode.Clamp; // 防止UV越界采样我曾为这个问题调试两天,最后发现是filterMode默认为Bilinear。
5.3 URP中Camera的Depth Texture必须开启,否则折射失效
WaterSurface.shader里的i.screenUV依赖_CameraDepthTexture。若URP Asset中未勾选Depth Texture,折射UV计算会得到错误值,水面像蒙了一层雾。检查路径:URP Asset →Rendering→Depth Texture→ Enable。
5.4 多摄像机渲染时,WaterRenderer必须在Opaque Queue之后执行
VR双目、分屏、画中画都需要多摄像机。若WaterRenderer的Queue设为Geometry(默认),它可能在其他不透明物体之前渲染,导致Z-Fighting。正确做法:在WaterRenderer.cs中设置:
private void OnEnable() { Camera.onPreRender += OnPreRender; } void OnPreRender(Camera cam) { if(cam.cameraType == CameraType.Game) { // 确保在所有Opaque物体之后 cam.depthTextureMode |= DepthTextureMode.Depth; } }5.5 高度图的Clear Value必须为0,且初始化用Graphics.ClearRenderTarget
创建高度图后,必须用Graphics.ClearRenderTarget(heightMap, true, true, Color.black)清空。若用RenderTexture.DiscardContents(),某些GPU会残留垃圾内存值,导致水面初始就“沸腾”。
5.6 波速参数c与纹理分辨率强相关
c²Δt²中的c不是绝对速度(m/s),而是“每秒传播多少个像素”。若你把高度图从1024×1024换成2048×2048,c值必须翻倍,否则波看起来慢了四倍。公式:c_pixels_per_second = c_physical_mps / (world_width_meters / texture_width_pixels)。美术给的世界尺寸,必须喂给TA做参数换算。
5.7 ImpulseMap的纹理格式必须是R8,不能是RFloat
虽然高度图用RFloat,但ImpulseMap只需存储扰动强度(0~1),用R8节省75%显存带宽。若误用RFloat,Android上Graphics.Blit()可能失败。
5.8 水面Shader的ZWrite必须关闭
水面是半透明物体,但WaterSurface.shader是不透明队列(Opaque)中用Alpha混合实现的。若开启ZWrite,会覆盖后面物体的深度,导致水下物体被裁剪。务必在Shader SubShader中写:
ZWrite Off Blend SrcAlpha OneMinusSrcAlpha5.9 动态边界Mask更新成本极高,应预烘焙
实时生成_BoundaryMask(如用Raycast检测地形)每帧耗时超5ms。正确做法:在编辑器模式下,用TerrainData.heightmapTexture和MeshCollider.sharedMesh预烘焙为一张Texture2D,运行时作为_BoundaryMask传入。烘焙脚本需支持增量更新(只重算修改区域)。
5.10 移动端必须禁用Tessellation
URP的Tessellation在移动端几乎全军覆没。若你的水体Mesh启用了Tessellation,iOS上直接崩溃,Android上闪退。WaterRenderer必须强制meshRenderer.tessellationQuality = 0;。
5.11 时间步长Δt必须用Time.unscaledDeltaTime
若游戏暂停(Time.timeScale=0),但水面还在动,玩家会出戏。WaterManager.Update()中必须用Time.unscaledDeltaTime计算物理,否则暂停时高度图仍演化。
5.12 最后,也是最重要的:永远用Profile GPU,而不是Profile CPU
水面性能瓶颈90%在GPU。WaterManager.Update()里Dispatch()调用看似只有几行C#,但背后是数千次GPU运算。打开Frame Debugger,看WaterHeightSim.compute的耗时,而非Update()函数的毫秒数。很多团队卡在“C# Update耗时0.2ms,但画面卡顿”,根源就是GPU Dispatch排队。
我在VR潜水项目上线前夜,发现Quest 2上水面有细微闪烁。抓帧分析发现,是_CausticTex生成与主Simulation在同一帧Dispatch,GPU负载峰值超标。临时方案:把焦散生成移到FixedUpdate(),用Time.fixedDeltaTime驱动,与物理帧率对齐。那一晚改了三版,最终在凌晨三点测出稳定1.8ms。这种细节,没有捷径,只有真刀真枪的Profile和耐心。你现在看到的这套方案,是三个项目、十七个版本、四百多个小时调试的结晶。它不完美,但足够真实——就像水本身,永远在流动,永远在修正自己的形态。
