ARTICLE · 1072091
UE5 浅水模拟源码级全拆解:一个 512*512 网格,怎么装下一整条流动的河
翻 UE5.8 的 Water Advanced 插件源码时,我在 ShallowWaterSubsystem.h 里看到一句 Epic 工程师留下的注释,它一下子点破了这套系统最反直觉的设计:
Think of this as a cursor that in most time locks on the current player pawn.(把它想象成一个游标,大部分时候它锁定在当前玩家的 Pawn 上。)
不是"把整条河都算一遍",而是"只算玩家脚下那一小块"。这跟你以为的流体模拟完全不同。今天就把这套浅水系统从方程到渲染拆到底,顺便告诉你——那条看起来无边无际的河,其实只有 20 米见方是真的在算。
先说清楚一个误会:UE 的浅水模拟(Shallow Water)不等于"液体模拟"。它算的从来不是"水花飞溅、水体翻卷"那种全三维流体,而是浅水方程——一套把流体压成二维高度场的简化模型。理解了这个前提,后面所有的代码才讲得通。
为什么水能被压成一张纸
完整的三维流体要用 Navier-Stokes 方程,每个体素都要算速度、压力、散度,算力爆炸,实时跑不动。浅水方程是它的一个"作弊版":当水的水平尺度远远大于水深时(河、湖、浅滩),垂直方向的速度可以忽略,整个水体退化成一张二维的高度场——每个网格点只存两个量:水面高度 h,以及水平流速 u。
于是三维问题变成了二维问题,方程也从一堆偏微分项砍到只剩两条核心关系:
连续性方程:∂h/∂t + ∇·(h·u) = 0,意思是"水往低处流,高处的水会变少,低处的水会变多",本质是质量守恒。 动量方程:∂u/∂t + (u·∇)u = -g·∇h,意思是"水面有坡度,重力就会把水推着往坡下流"。
这套东西在实时图形学里成熟得很早——SIGGRAPH 2004 就有 GPU 化实现了。UE 的贡献不在于发明方程,而在于把它做成一个"跟着玩家跑、还能被子弹和船撞出水花"的工程系统。
关键的一刀就在源码的 ShallowWaterCommon.h 里,两个参数定义了这个"纸面世界"的精度:
// ShallowWaterCommon.h:13-21USTRUCT(BlueprintType)structFShallowWaterSimParameters{GENERATED_BODY()// 网格覆盖的世界尺寸:2000 厘米 = 20 米见方UPROPERTY(EditAnywhere, Category = "Shallow Water")float WorldGridSize = 2000.f;// 单轴分辨率:512×512 个网格点UPROPERTY(EditAnywhere, Category = "Shallow Water") int32 ResolutionMaxAxis = 512;};看出来了吗?一条河再长,实际参与计算的永远是 20 米 × 20 米、512×512 的一小片。20 米除以 512,每个格子大约 4 厘米——这个精度用来表现脚踩出的涟漪、船尾的尾迹绰绰有余,用来表现海啸就不行了。
谁在真正算?是 Niagara,不是 CPU
拆开 UShallowWaterSubsystem 你会发现一个很有意思的事实:这个挂在 UTickableWorldSubsystem 上的系统,自己一行流体方程都没解。它是个"经纪人",真正的数值计算全在 GPU 上的一个 Niagara 系统里跑。
子系统每帧做的是三件调度的事:
创建两张渲染目标(Render Target)当作 sim 和渲染之间的桥梁,一张存法线、一张存速度; 把这两张 RT 连同网格参数塞进 Niagara 组件; 把这个 Niagara 组件的位置"贴"到玩家投影在水面上的点。
看 InitializeParameters 里的这段,把参数喂给 Niagara 的过程一目了然:
// ShallowWaterSubsystem.cpp:517-525if (ShallowWaterNiagaraSimulation){ ShallowWaterNiagaraSimulation->SetVariableVec2(FName("WorldGridSize"), FVector2D(GetGridSize())); ShallowWaterNiagaraSimulation->SetVariableInt(FName("ResolutionMaxAxis"), GetGridResolution()); ShallowWaterNiagaraSimulation->SetVariableTextureRenderTarget( Settings->NormalRTNiagaraName, NormalRT); ShallowWaterNiagaraSimulation->SetVariableTextureRenderTarget( Settings->VelocityRTNiagaraName, VelocityRT); ShallowWaterNiagaraSimulation->SetVariableBool(FName("UseBakedSim"), false);}注意 NormalRT 的格式——源码里写的是 RTF_RGBA16f,速度那张是 RTF_RG16f,而且都打开了 bCanCreateUAV。半精度浮点(16f)+ UAV(可读写视图),这是 GPU 上跑迭代 sim 的标准配置:既要省带宽,又要让同一张纹理在每个模拟 step 里既能读又能写。法线只存方向,速度只存水平两个分量,所以一个用 RGBA、一个用 RG。
游标:整套设计里最聪明的一招
回到开头那句注释。为什么让网格"锁定玩家"而不是覆盖整张地图?因为浅水 sim 是 O(n²) 的,512×512 已经要算 26 万个网格点,每帧跑好几步迭代。如果整条 500 米长的河都这么算,任何显卡都扛不住。
所以 UpdateGridMovement() 每帧干一件事:找到"最相关的玩家 Pawn",把 sim 网格的中心搬过去。源码里这段求最近水面的循环写得非常克制:
// ShallowWaterSubsystem.cpp:547-605(节选)// #note this query gets the closest water surface in 3D space,// not a 2D topdown projection as we instinctively assumedAPawn* CursorPawn = GetTheMostRelevantPlayerPawn();...for (TWeakObjectPtr<AWaterBody> WeakWaterBody : LastOverlappingWaterBodies_Internal){// 对每个重叠的水体,查离玩家最近的表面点const TValueOrError<FWaterBodyQueryResult, EWaterBodyQueryError> WaterInfo = WaterBody->GetWaterBodyComponent()->TryQueryWaterInfoClosestToWorldLocation( CursorPawnLocation, EWaterBodyQueryFlags::ComputeLocation); ...}const FVector ProjectedLocation =FVector(CursorPawnLocation.X, CursorPawnLocation.Y, 0);ShallowWaterNiagaraSimulation->SetWorldLocation(ProjectedLocation);那句 #note 注释很值得玩味:作者自己都提醒"这个查询找的是 3D 空间里最近的水面,不是我们直觉以为的 2D 俯视投影"。这背后是个真实的坑——玩家站在河岸斜坡上时,最近的水面点可能是斜下方河床而不是正下方水面,直接决定网格吸附得对不对。一段代码的注释里藏着一个工程师踩过的坑,这就是源码级阅读的价值。
游标还有个更聪明的细节:网格不是玩家一动就瞬移,而是"贴过去"的。玩家站在原地不动时,sim 网格也静止,水面波纹才不会被拖拽得发糊;玩家跑起来,网格才跟着挪。这套"按需移动 + 静止时稳定"的策略,是浅水 sim 能上移动端的核心原因之一。
交互:一颗子弹怎么砸出水花
波纹从哪来?所有外部扰动都走同一条路:RegisterImpact。
// ShallowWaterSubsystem.h:155-158UE_API voidRegisterImpact( FVector ImpactPosition, FVector ImpactVelocity, float ImpactRadius);UE_API voidFlushPendingImpacts();UE_API voidWriteImpactToNDC( FVector ImpactPosition, FVector ImpactVelocity, float ImpactRadius);子弹命中水面、角色踩进水里、物体砸落——这些事件先被攒进一个 PendingImpacts 数组(源码里叫 TArray<PendingImpact>),下一帧统一刷给 sim,而不是每命中一次就打断一次 GPU 计算。攒一批、一次冲刷,这是典型的"批处理 + 延迟提交",跟渲染里的命令缓冲是同一个道理。
WriteImpactToNDC 这个名字很关键:NDC 是 Normalized Device Coordinates(归一化设备坐标)。因为 sim 网格是 512×512、跟着玩家跑的,一次扰动必须先换算成"在网格里的相对坐标",Niagara 那边的数据通道才能接到正确的格子上。位置、速度、半径三个量,正好对应一个波纹的"在哪、多猛、多大"。
再看控制台变量,调试和性能调节全在这里:
// ShallowWaterSubsystem.cpp:36-79(节选)r.ShallowWater.Enabled // 是否启用整个子系统r.ShallowWater.FadeOutWait // 没人碰水后,sim 再撑多久等波纹消退(默认 15 秒)r.ShallowWater.CollisionTracker.ImpactTrackerActiveForSeconds // 子弹命中被追踪多久(默认 5 秒)r.ShallowWater.DebugRender // 打开调试可视化r.ShallowWater.UseWaterInfoTexture // 是否用水体信息纹理FadeOutWait = 15 这个默认值很有意思:玩家走出河之后,sim 不会立刻停,而是再跑 15 秒,等最后一圈波纹自己散掉。因为波纹的消退是物理过程,不是按下开关就消失的——立刻关掉 sim,水面会"啪"地冻住。这个 15 秒就是给物理收尾留的缓冲。
三套水方案,一张表说清
UE 内部其实同时存在三套"水"的技术,别搞混:
浅水方程是这里面最"省"的,所以它能做交互;FFT 海洋最"漂亮",但基本是纯渲染、不好互动;全三维流体最"真",但一格都不能少、贵到只能做局部特效。选型时先想清楚:你要的是"会跟着脚反应的水面",还是"会翻卷的瀑布"?答案直接决定用哪套。
上手:不用写一行 C++
这套系统暴露给使用者的接口相当友好。开一个项目,启用三个插件:Water、Water Advanced、Buoyancy(浮力)。然后拖一个 Shallow Water River Actor 进关卡,就能得到一条能互动的河。
真正的门槛不在激活,而在理解那几个参数的含义:
WorldGridSize:网格世界尺寸,默认 20 米。开大一点能覆盖更广,但每格精度下降、算力不变(因为分辨率没变)。ResolutionMaxAxis:单轴分辨率,默认 512。翻倍到 1024,网格点变 4 倍,GPU 压力直接乘 4。项目设置里 Water Advanced 一栏,能看到 sim 用哪个 Niagara 资产、法线/速度 RT 叫什么名字、MPC 材质参数集合的名字(源码里默认叫 FluidSimSize和FluidSimResolution)。
如果你想让水在"玩家脚下"之外也正确显示——比如远处那条河看起来也得像有水——那就需要用到"烘焙 sim"(Baked Sim):把某一帧的 sim 结果存成纹理,让远处水体用这张静态纹理糊过去。源码里 UseBakedSimulationForQueriesAndPhysics() 这个判断,就是决定"物理查询走实时 sim 还是走烘焙数据"的分叉点。烘焙 sim 省的是算力,牺牲的是动态响应,这是所有实时模拟都绕不开的取舍。
延伸:这套思路能推到哪
浅水方程只是"把高维问题压成低维再解"这一招的一个例子。同一招在引擎里反复出现:
FFT 海洋走的是频谱域——不存高度场,存海浪的频谱系数,用快速傅里叶变换反解出波面,能表现几千公里尺度的大洋。 Niagara Fluids 反过来,坚持三维体素,用压力投影一步步解真正的 Navier-Stokes,代价是只能覆盖很小的区域。 ZibraVDB 这类第三方插件用 SPH(光滑粒子流体动力学)+ 稀疏体素,想在"真实"和"便宜"之间再挤出一条路。
它们本质是同一个问题的三种答案:实时流体到底牺牲哪一维?浅水牺牲了"深",FFT 牺牲了"局部细节",Niagara Fluids 牺牲了"尺度"。想通了这一点,你就拿到了看懂所有实时水体技术的地图。
一个它没回答的问题
浅水模拟把"算不完"的问题用"网格跟着玩家跑"解决了,做得干净利落。但它留了一个更大的坑没填:多人联机时,两个玩家相隔 200 米,各自脚下都有一片 sim 网格,这两片网格怎么合并、服务器和客户端怎么同步水面的高度场? 网络复制一套每帧都在变、又高度依赖本地 GPU 时序的模拟数据,是比"算水"本身更棘手的问题。Iris 网络复制系统能不能扛住这种"非确定性模拟状态"的同步——这就是浅水系统丢给下一个工程师的接力棒。
我每周拆一个 UE5 底层系统,从方程讲到能跑的源码,再把代码背后的设计意图讲明白。觉得这期有用,点个"在看",别让这 20 米见方的河白算。还想看哪套水?评论区点名,我优先安排。