概述 水体交互是游戏与实时渲染中极具表现力的视觉效果之一,也是流体模拟领域长期研究的核心课题。从技术实现的角度看,它横跨物理建模 、数值方法 、GPU 并行计算 与渲染管线集成 四个层面,需要在物理准确性与实时性能之间做出精妙的权衡。
技术路线全景 在实时渲染的工程实践中,水体交互主要有以下几条成熟的技术路线:
技术路线
物理精度
GPU 友好度
典型应用场景
粒子系统 Mask 写入
低(纯视觉)
★★★★★
雨滴涟漪、简单脚印
波动方程 (Wave Equation)
中(仅传播)
★★★★★
平静水面扰动、池塘涟漪
浅水方程 (SWE)
高(质量+动量守恒)
★★★★☆
角色涉水、水流冲刷、洪水蔓延
SPH 粒子流体
极高
★★☆☆☆
离线电影、局部小范围特效
Navier-Stokes (全3D)
物理精确
★☆☆☆☆
离线渲染、科学计算
对于需要在移动端或主机平台实时运行的项目,波动方程 和浅水方程 是目前最主流的两种选择,也是本文重点讨论的内容。
核心数据流 无论选用哪种方案,水体交互系统的数据流基本遵循以下管线:
1 2 3 4 5 6 7 8 9 10 11 12 13 交互输入(鼠标/角色/粒子) ↓ 写入 Interaction RT ↓ Compute Shader 物理步进 (Ping-Pong RenderTexture) ↓ Height / State Texture ↙ ↘ 顶点着色器 片元着色器 (顶点位移) (法线重构 / 白沫) ↘ ↙ 最终水面渲染
两种核心方案对比
波动方程只模拟”形状的传递”,而浅水方程模拟的是”物质与能量的真实流动”
特性
2D 波动方程 (Wave Equation)
2D 浅水方程 (Shallow Water Equations)
物理本质
弹性介质中的振动 传播
真实流体的质量与动量守恒
核心变量
仅有水位高度
水位高度 + 水平动量
粒子运动
原地上下起伏(不发生水平位移)
发生真实的水平位移(平流 Advection)
物理现象
涟漪、水波扩散、驻波
水流冲刷、漩涡、激波(水跃现象)、回流
计算性能
极快(仅加减乘运算)
较慢(涉及非线性偏微分、复杂的通量计算)
数值稳定性
容易保持稳定
极易发生数值爆炸,需严格控制时间步长(CFL 条件)
工程复杂度
低,数十行即可实现
较高,需处理干区、动量注入、边界反弹等边界情况
轻量级方案:粒子系统
图形模拟
我们可以使用粒子系统将交互信息渲染(Blit)为 Mask 写入 Height RT,再在 Shader 中采样这张 RT:在顶点阶段 驱动网格顶点位移,在片元阶段 将高度梯度转换为法线图,最终渲染到水面上,形成交互水波效果。这种方案实现简单、性能极佳,是移动端水体交互的首选方案。
🦜
中级方案:波动方程 (Wave Equation)
为了在实时渲染中达到极高的性能,通常采用基于波动方程 的简化流体模型——高度场(Height Field)方法。在这个简化的 2D 流体模型中,水面被视为一张由无数根无形弹簧连接的网格。某点当前时刻的高度 ,由该点自身的历史状态以及其四邻域(上、下、左、右)的高度共同决定。 当某处水面被扰动时,仅是拨动了其中一根弹簧,这种”上下起伏”的趋势会依次传递给相邻弹簧,形成向外扩散的涟漪。
数学表达 :
其中 为水位高度, 为波速, 为高度的拉普拉斯算子(表示该点与周围点的高度差)。
局限性(为什么没有推水效果) :在波动方程中,水分子本身没有水平速度 。这好比体育场里观众做”人浪”——波形绕场一圈,但每个观众始终坐在原位。因此,波动方程无法呈现”水被实体排开”、”河流顺流而下”或”水流绕障碍物产生尾流旋涡”等效果。
适用场景 :雨滴落在平静水面泛起的涟漪、水池表面的轻微扰动动画。
代表的是”波速”(Wave Speed) ,即扰动向四周传播的速率。
若 较大,点击水面后波纹会快速扩散至整个画面;
若 较小,波纹则如同在浓稠液体中缓慢蠕动。
与真实浅水物理的关联
在重力浅水环境中,波速 并非任意常数,而与重力加速度 和水深 直接相关:
这解释了为何海啸在深海区( 极大)传播速度极快(可媲美喷气式飞机),而在靠近岸边水深骤降时,波速 急剧降低——后方海水追上前方,能量堆积,进而形成滔天巨浪。
数值稳定性的约束:CFL 条件
在数值模拟中,波速 是导致数值爆炸 的核心控制指标。根据柯朗-弗里德里希斯-勒维条件(CFL Condition) ,波在一个时间步长 内传播的距离不得超过一个网格宽度 :
这意味着:若 设置过大,则必须等比例缩小 。否则差分计算时波纹将”跨越”相邻像素,导致计算完全失效,画面立刻充满 NaN(黑块或白点)。
离散化求解 使用有限差分法(Finite Difference Method) ,将连续的时间和空间离散化,推导出下一帧像素点 的高度 :
:下一帧 水面高度(Next)
:当前帧 水面高度(Current)
:上一帧 水面高度(Previous)
:由波速、时间步长和网格尺寸共同决定的传播系数
为使波纹随时间自然衰减,还需引入阻尼系数(Damping) ,通常取略小于 1 的值(如 0.99)。
实现 ComputeShader ShallowWater.compute 包含两个 Kernel:UpdateWater 负责推进物理状态,AddDrop 负责响应交互输入(点击滴水)。
ShallowWater.compute 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 #pragma kernel UpdateWater #pragma kernel AddDrop Texture2D<float4> PreviousTex; Texture2D<float4> CurrentTex; RWTexture2D<float4> NextTex;float damping; float waveSpeed; float2 resolution; float2 dropPos; float dropRadius; float dropStrength; [numthreads(8 , 8 , 1 )]void UpdateWater (uint3 id : SV_DispatchThreadID) { if (id.x <= 0 || id.x >= (uint)resolution.x - 1 || id.y <= 0 || id.y >= (uint)resolution.y - 1 ) { NextTex[id.xy] = float4(0 , 0 , 0 , 1 ); return ; } float center = CurrentTex[id.xy].r; float left = CurrentTex[id.xy + int2(-1 , 0 )].r; float right = CurrentTex[id.xy + int2( 1 , 0 )].r; float up = CurrentTex[id.xy + int2( 0 , 1 )].r; float down = CurrentTex[id.xy + int2( 0 ,-1 )].r; float prev = PreviousTex[id.xy].r; float next = 2.0 * center - prev + waveSpeed * (left + right + up + down - 4.0 * center); next *= damping; NextTex[id.xy] = float4(next, next, next, 1.0 ); } [numthreads(8 , 8 , 1 )]void AddDrop (uint3 id : SV_DispatchThreadID) { float dist = distance((float2)id.xy, dropPos); if (dist < dropRadius) { float effect = (1.0 - (dist / dropRadius)) * dropStrength; float currentHeight = CurrentTex[id.xy].r; NextTex[id.xy] = float4(currentHeight + effect, currentHeight + effect, currentHeight + effect, 1.0 ); } else { NextTex[id.xy] = CurrentTex[id.xy]; } }
C# 控制脚本 FluidSimulation.cs 负责创建 RenderTexture、向 Compute Shader 传递参数,并通过 Ping-Pong 纹理交换机制逐帧推进模拟。
FluidSimulation.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 using UnityEngine;public class FluidSimulation : MonoBehaviour { public ComputeShader fluidComputeShader; public Material fluidMaterial; [Header("Simulation Settings" ) ] public int resolution = 512 ; [Range(0.9f, 1.0f) ] public float damping = 0.99f ; [Range(0.0f, 0.5f) ] public float waveSpeed = 0.45f ; [Header("Interaction Settings" ) ] public float dropRadius = 10f ; public float dropStrength = 1f ; private RenderTexture previousTex, currentTex, nextTex; private int updateKernel, addDropKernel; void Start () { previousTex = CreateRenderTexture(); currentTex = CreateRenderTexture(); nextTex = CreateRenderTexture(); if (fluidMaterial != null ) fluidMaterial.mainTexture = currentTex; updateKernel = fluidComputeShader.FindKernel("UpdateWater" ); addDropKernel = fluidComputeShader.FindKernel("AddDrop" ); } void Update () { if (Input.GetMouseButtonDown(0 )) HandleMouseClick(); SimulateFluid(); SwapTextures(); } private void SimulateFluid () { fluidComputeShader.SetFloat("damping" , damping); fluidComputeShader.SetFloat("waveSpeed" , waveSpeed); fluidComputeShader.SetVector("resolution" , new Vector2(resolution, resolution)); fluidComputeShader.SetTexture(updateKernel, "PreviousTex" , previousTex); fluidComputeShader.SetTexture(updateKernel, "CurrentTex" , currentTex); fluidComputeShader.SetTexture(updateKernel, "NextTex" , nextTex); int groups = Mathf.CeilToInt(resolution / 8.0f ); fluidComputeShader.Dispatch(updateKernel, groups, groups, 1 ); } private void HandleMouseClick () { Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); if (!Physics.Raycast(ray, out RaycastHit hit)) return ; Vector2 uv = hit.textureCoord; Vector2 pixelPos = new Vector2(uv.x * resolution, uv.y * resolution); fluidComputeShader.SetVector("dropPos" , pixelPos); fluidComputeShader.SetFloat("dropRadius" , dropRadius); fluidComputeShader.SetFloat("dropStrength" , dropStrength); fluidComputeShader.SetTexture(addDropKernel, "CurrentTex" , currentTex); fluidComputeShader.SetTexture(addDropKernel, "NextTex" , nextTex); int groups = Mathf.CeilToInt(resolution / 8.0f ); fluidComputeShader.Dispatch(addDropKernel, groups, groups, 1 ); Graphics.Blit(nextTex, currentTex); } private void SwapTextures () { RenderTexture temp = previousTex; previousTex = currentTex; currentTex = nextTex; nextTex = temp; if (fluidMaterial != null ) fluidMaterial.mainTexture = currentTex; } private RenderTexture CreateRenderTexture () { var rt = new RenderTexture(resolution, resolution, 0 , RenderTextureFormat.RFloat); rt.enableRandomWrite = true ; rt.Create(); RenderTexture.active = rt; GL.Clear(false , true , Color.black); RenderTexture.active = null ; return rt; } void OnDestroy () { if (previousTex) previousTex.Release(); if (currentTex) currentTex.Release(); if (nextTex) nextTex.Release(); } }
高阶方案:浅水方程 (SWE) 系统架构 浅水方程是纳维-斯托克斯(Navier-Stokes)方程 在深度方向上进行积分平均后的简化形式。它真正将水视为有质量、有惯性、能够流动的连续介质 。
数学表达 (质量守恒 + 动量守恒):
核心优势(平流与动量) :浅水方程引入了速度向量 。当角色踩入水中时,不仅水面高度 发生变化,还会向水体注入真实的水平动量 。水分子携带自身质量与速度从 A 点真实移动至 B 点——这就是平流(Advection) ——从而产生”水被实体推开”乃至障碍物后方尾流旋涡的效果。
适用场景 :角色在积水中奔跑的泥泞感、地形水流冲刷、决堤洪水蔓延、河流模拟。
物理与数学原理 浅水方程通过将三维 Navier-Stokes 方程在深度方向积分平均推导而来,同时考虑了水面高度变化(质量守恒)与水流的 平流运动(动量守恒) 。
守恒形式的偏微分方程组 将流体状态定义为状态向量 ,并分别在 X、Y 方向上计算物理通量 和 :
状态向量 (水深 + 两个方向动量):
X 方向通量 :
Y 方向通量 :
注: 为重力产生的静水压力项 ,是驱动水波向外扩散的核心力。
控制方程:
数值离散:Lax-Friedrichs 格式 直接对上述偏微分方程进行中心差分离散是无条件不稳定的。采用 Lax-Friedrichs 格式 ,用四邻域的平均状态替代中心点状态,引入人工粘性以换取稳定性:
下面要做的就是把这个公式逐项落到 Compute Shader 上。但在写代码之前,先建立这套系统的全局观。
顶层系统设计 生产环境的 SWE 系统横跨 CPU、GPU、Shader 三层 ,每一层各司其职、通过明确的数据接口耦合。先看一张全景图:
CPU 端 (C# Runtime)
[轨道 B: 数据驱动]
[轨道 A: 视觉驱动]
① Trigger 层
WaterTrigger
(维护活跃碰撞体)
② Dispatcher 层
SWEController
(网格对齐、双轨主控)
③ Splat Map 调度层
Ortho Camera
(跟随主相机、剔除静态物)
↓ ComputeBuffer
(点状数据)
↓ Render Target
(图像数据 / Splat RT)
GPU 端 (Compute Shader)
④ ShiftSWE
(网格平移)
⑤ InjectSWE (双轨注入)
(读 Buffer + 读 Splat RT)
⑥ UpdateSWE
(时间步进)
↓ RWTexture2D<float4> (StateOut)
(当前帧 SWE 状态场)
反馈回路 (上一帧 StateOut → ShiftSWE)
Shader 端 (Sea + SWE 融合)
⑦ Vertex / Fragment Shader
Gerstner 位移 + SWE 高度叠加 + 法线倾斜混合
Final Sea Wave Render
三层职责分工
数据接口的两种风格 CPU 与 GPU 之间的数据契约分两种风格,分别对应两种交互特征:
轨道
风格
数据载体
GPU 读取方式
适用对象
轨道 A
视觉驱动
Splat RT (RenderTexture)
纹理采样
体积型物体(船、岩石、角色身体)
轨道 B
数据驱动
StructuredBuffer
数组遍历
点状交互体(浮标、脚步、漂浮物)
两种风格分别覆盖了”任意数量但只需图像近似”与”少量但需要精确数据”的两个极端。除此之外还有最简单的 轨道 C 鼠标单点 作为兼容入口。三轨在 InjectSWE 中加性叠加,互不互斥。
注意架构图中的红色虚线 ——InjectSWE → UpdateSWE 输出的 StateOut 在 PingPong 后会成为下一帧 ShiftSWE 的输入,形成完整的反馈闭环。这意味着每帧都从”上一帧的状态”演进而来,外部交互只是在演进过程中”插一脚”。
下面按”先解算、再注入、最后跟随相机/对接渲染”的顺序展开各个模块。
核心算子 1:UpdateSWE UpdateSWE 是浅水方程的物理推进核心。状态纹理 StateIn 使用 ARGBFloat 格式:R = 水深 ,G = X 方向动量 ,B = Y 方向动量 。
数值常量集中:SWECommon.hlsl 把所有”魔法数字”集中到一个独立头文件,让 Update / Inject / Shift 三个 Kernel 之间永远保持一致:
1 2 3 4 5 6 7 #define SWE_REST_WATER_HEIGHT 0.5 #define SWE_DRY_TOLERANCE 0.01 #define SWE_MAX_VELOCITY 8.0 #define SWE_MAX_MOMENTUM 15.0 #define SWE_MAX_HEIGHT 5.0 #define SWE_REST_STATE float4(SWE_REST_WATER_HEIGHT, 0.0, 0.0, 1.0)
为什么静水基线是 0.5 而非 0? 这给了水面”压下去 0.5”和”涨起来 0.5”双向操作空间。后面会看到,体积型物体注入推力时是直接 state.r -= weight * dt 减量,必须留出负向余量。如果基线在 0,遇到障碍立刻就被 max(DRY_TOLERANCE, ...) 钳住,看不到任何凹陷。
固壁边界与通量函数 为了防止水流出网格边缘,采样边界像素时把坐标 clamp 在纹理范围内,并对撞墙方向的动量取反 模拟弹性反弹:
C 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 Texture2D<float4> StateIn; RWTexture2D<float4> StateOut;float dt, dx, gravity, damping; float2 resolution; float3 GetU (int2 id) { int2 clampedId = clamp(id, int2(0 , 0 ), int2((int )resolution.x - 1 , (int )resolution.y - 1 )); float3 U = StateIn[clampedId].rgb; if (id.x < 0 || id.x >= (int )resolution.x) U.y = -U.y; if (id.y < 0 || id.y >= (int )resolution.y) U.z = -U.z; return U; } float3 GetF (float3 U) { if (U.x < SWE_DRY_TOLERANCE) return float3(0.0 , 0.0 , 0.0 ); float u = clamp(U.y / U.x, -SWE_MAX_VELOCITY, SWE_MAX_VELOCITY); return float3(U.y, U.y * u + 0.5 * gravity * U.x * U.x, U.z * u); } float3 GetG (float3 U) { if (U.x < SWE_DRY_TOLERANCE) return float3(0.0 , 0.0 , 0.0 ); float v = clamp(U.z / U.x, -SWE_MAX_VELOCITY, SWE_MAX_VELOCITY); return float3(U.z, U.y * v, U.z * v + 0.5 * gravity * U.x * U.x); }
GetF / GetG 里两个细节非常关键:
干区除零保护 :当水位 极低时,计算流速 将产生除零或无穷大。U.x < SWE_DRY_TOLERANCE 时直接返回零通量。
速度钳制 :即便水位足够,由于交互注入的动量可能极大, 仍可能超过 CFL 安全速度。clamp(-MAX_VELOCITY, MAX_VELOCITY) 是数值爆炸的最后防线。
主步进逻辑与障碍掩码反弹 C 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 [numthreads(8 , 8 , 1 )]void UpdateSWE (uint3 id : SV_DispatchThreadID) { int2 pos = (int2)id.xy; float3 U_left = GetU(pos + int2(-1 , 0 )); float3 U_right = GetU(pos + int2( 1 , 0 )); float3 U_down = GetU(pos + int2( 0 , -1 )); float3 U_up = GetU(pos + int2( 0 , 1 )); float3 U_avg = 0.25 * (U_left + U_right + U_down + U_up); float3 U_next = U_avg - dt * ( (GetF(U_right) - GetF(U_left)) / (2.0 * dx) + (GetG(U_up) - GetG(U_down)) / (2.0 * dx) ); U_next.y *= damping; U_next.z *= damping; U_next.x = clamp(U_next.x, 0.0 , SWE_MAX_HEIGHT); if (U_next.x < SWE_DRY_TOLERANCE) { U_next.xyz = 0.0 ; } else { float maxMom = U_next.x * SWE_MAX_VELOCITY; U_next.y = clamp(U_next.y, -maxMom, maxMom); U_next.z = clamp(U_next.z, -maxMom, maxMom); } if (_SplatEnabled != 0 ) { float2 splatUV = ((float2)id.xy + 0.5 ) / resolution; float4 splat = _SplatTex.SampleLevel(sampler_SplatTex, splatUV, 0 ); if (splat.a > 0.5 ) { U_next.y *= -_WaveBounciness; U_next.z *= -_WaveBounciness; } } StateOut[id.xy] = float4(U_next, 1.0 ); }
这里出现了 _SplatTex.a > 0.5 —— 它是来自 Splat RT 的”静止障碍掩码”。Splat RT 的具体生成逻辑放在下一节(轨道 A),先按下不表。先记住一个原则:固壁边界(GetU)处理网格四周的硬边,障碍掩码处理网格内部的物体阻挡 ,两者叠加才能构成完整的边界系统。
撞库反弹 vs 固壁反弹的物理直觉 :固壁是”水撞到墙壁”,对法向速度取反、切向速度保留;这里的障碍掩码是 2D 投影没有法向信息,简单把 X、Z 同时反向 + 衰减。waveBounciness = 0.8 可调,太接近 1 会让能量在腔体内无限累积引爆数值。
核心算子 2:InjectSWE 与多源交互场注入 到了”如何让外部物体推开水面”的关键问题。所有交互的本质都是:沿径向给出排开推力 + 沿运动方向叠加水平动量 。这两件事在 Compute Shader 里抽象为同一个工具函数,被三种交互源共用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void ApplyRadialImpulse (inout float4 state, float2 px, float2 center, float radius, float pushForce, float2 momentum) { float2 dir = px - center; float dist = length(dir); if (dist < radius) { float infl = smoothstep(radius, 0.0 , dist); float2 radial = dist > 0.1 ? normalize(dir) : float2(0 , 0 ); state.g += (radial.x * pushForce + momentum.x) * infl; state.b += (radial.y * pushForce + momentum.y) * infl; } }
单纯修改 state.r 会产生”吸水”视觉 Bug——水面凹陷而不是被推开。正确做法是把外部速度转化为径向排开推力 与移动方向动量 叠加注入 通道。
下面分别看三条轨道在 CPU 与 GPU 两侧的具体实现。
轨道 A:Splat RT 路径(体积型物体) 适用场景 :船体、岩石、角色身体等具有真实体积、需要在水底投影出实际”形状”的物体。这类对象单点径向脉冲无法表达——船底凹凸不平、姿态会绕 Y 轴旋转。
解决思路是架一个正交向下的虚拟相机 对着 SWE 网格中心,用替换 Shader(Replacement Shader)把所有 splat 层物体渲染到一张 Splat RT,每个像素编码这块物体的入水深度、速度、与障碍掩码:
通道
含义
编码方式
消费方
R
入水深度 [0,1]
saturate((waterY - worldPos.y) / _MaxDepth)
InjectSWE
G
速度 X [0,1]
(velocity.x / _MaxVel) * 0.5 + 0.5
InjectSWE
B
速度 Z [0,1]
(velocity.z / _MaxVel) * 0.5 + 0.5
InjectSWE
A
静止障碍掩码
1 - moveMask
UpdateSWE
注意 R/G/B 与 A 通道被两个不同的 Kernel 在不同语义上消费 ——这是该设计最巧妙的地方:渲染 Splat RT 的成本只付一次,回报却是两份。
(a) SplatReplacement.shader :替换 Shader 不能 per-object 设置参数,所以速度由挂在物体上的 SplatVelocityProvider 通过 MaterialPropertyBlock 注入”上一帧世界位置”,Shader 自己用差分计算速度。双闸门门控 就在这里:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 float4 frag (Varyings IN) : SV_Target { float dt = max(_SWE_DeltaTime, 1e-4 ); float2 vel = float2(0 , 0 ); if (_PrevWorldPos.w > 0.5 ) vel = (IN.worldPos.xz - _PrevWorldPos.xz) / dt; float speed = length(vel); float moveMask = smoothstep(0.05 , 0.2 , speed); float baseDepth = saturate((_SWE_WaterSurfaceY - IN.worldPos.y) / _SWE_SplatMaxDepth); float finalPushWeight = baseDepth * moveMask; float maxV = max(_SWE_SplatMaxVel, 0.001 ); float2 encVel = saturate(vel / maxV * 0.5 + 0.5 ); return float4(finalPushWeight, encVel.x, encVel.y, saturate(1.0 - moveMask)); }
为什么不直接读 Rigidbody.velocity? Splat 物体可能是动画驱动的(CharacterController、Animator 直接挪 transform),完全没有刚体;位置差分能统一处理所有驱动方式。
(b) SplatVelocityProvider 双闸门 :CPU 侧再加一道闸,过滤掉物理引擎的微小抖动和场景切换的瞬移:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private void LateUpdate () { Vector3 currentPos = transform.position; float moveSqr = (currentPos - _lastPos).sqrMagnitude; bool isValid = moveSqr > minMoveDistSqr && moveSqr < teleportDistSqr; foreach (var r in Renders) { r.GetPropertyBlock(_mpb); _mpb.SetVector(ID_PrevWorldPos, new Vector4(_lastPos.x, _lastPos.y, _lastPos.z, isValid ? 1f : 0f )); r.SetPropertyBlock(_mpb); } _lastPos = currentPos; }
双闸门设计的意义 :物理引擎的 PhysX 即便物体静止也会产生 1e-5 级别的抖动;传送或场景切换时 transform.position 会瞬变数十米。两者都不是”真实运动”,都不应该向水面注入动量,否则会看到水面无故鼓包或瞬间海啸。CPU 端 (min, teleport) 双闸门 + Shader 端 smoothstep(0.05, 0.2) 速度门控构成两道独立的稳定剂 ——前者断绝无效输入,后者平滑有效输入。
(c) GPU 侧采样 :InjectSWE 在每个像素读一次 Splat RT,按通道解码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 if (_SplatEnabled != 0 ) { float2 splatUV = ((float2)id.xy + 0.5 ) / resolution; float4 splat = _SplatTex.SampleLevel(sampler_SplatTex, splatUV, 0 ); if (splat.r > 0.001 ) { float2 splatVel = (splat.gb - 0.5 ) * 2.0 * _SplatMaxVel; float weight = splat.r; state.g += splatVel.x * weight * _SplatMomentumScale; state.b += splatVel.y * weight * _SplatMomentumScale; state.r = max(SWE_DRY_TOLERANCE, state.r - weight * _SplatPushForceScale * dt); } }
轨道 B:ComputeBuffer 路径(点状交互体) 适用场景 :浮标、漂浮道具、玩家脚部、子弹溅起等点状/小范围 交互。这类对象数量少(默认上限 64),不需要走完整的渲染管线,直接通过 StructuredBuffer 上传更划算。
GPU 侧的扩展非常自然——把 ApplyRadialImpulse 套在一个固定上限的 for 循环里:
1 2 3 4 5 6 7 8 9 10 11 12 StructuredBuffer<WaterInteractorData> _Interactors;int _InteractorCount;if (_InteractorCount > 0 ) { for (int i = 0 ; i < _InteractorCount; ++i) { WaterInteractorData d = _Interactors[i]; ApplyRadialImpulse(state, px, d.pixelPos, d.radius, d.pushForce, d.momentum); } }
真正的工程量在 CPU 侧——如何零 GC 地维护”当前位于水域内的物体集合”,并把它们打包成 GPU 可读的结构体数组。三层组件分工:
1 2 3 4 5 6 7 8 9 WaterTrigger ── OnTriggerEnter/Exit ──► HashSet + List 维护活跃集合 │ (零 GC 去重 + 无 GC 遍历) │ Snapshot(buffer) ▼ WaterInteractor[] ── Pack(snapPos, dx, res) ──► WaterInteractorData[] │ │ ComputeBuffer.SetData ▼ StructuredBuffer<WaterInteractorData>
(a) WaterInteractor :挂在每个交互物体上,暴露 radius / pushForce / maxVelocity 三个艺术参数,并通过位置差分自动算出速度:
CSHARP 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public class WaterInteractor : MonoBehaviour { public float radius = 1.0f ; public float pushForce = 2.0f ; public float maxVelocity = 10f ; public Vector3 Velocity { get ; private set ; } private Vector3 _lastPos; private void LateUpdate () { float dt = Mathf.Max(Time.deltaTime, 1e-4 f); Vector3 raw = (transform.position - _lastPos) / dt; Velocity = Vector3.ClampMagnitude(raw, maxVelocity); _lastPos = transform.position; } public WaterInteractorData Pack (Vector2 snapPosWorldXZ, float dx, int resolution ) { Vector3 wp = transform.position; float px = (wp.x - snapPosWorldXZ.x) / dx + resolution * 0.5f ; float py = (wp.z - snapPosWorldXZ.y) / dx + resolution * 0.5f ; return new WaterInteractorData { pixelPos = new Vector2(px, py), radius = radius / dx, pushForce= pushForce, momentum = new Vector2(Velocity.x, Velocity.z), _pad = Vector2.zero }; } }
注意 radius / dx 而非 radius * dx:world-space 半径 1m 对应 1 / dx 个像素。这是高频踩坑点。
(b) WaterInteractorData——32 B 对齐陷阱 :CPU 与 GPU 双端必须严格内存对齐——结构体净内容 24 B 不是 16 的倍数,必须填充到 32 B(GPU StructuredBuffer 默认按 16 字节对齐),否则 GPU 会按 16 字节步长跨过去导致内容错位。这是任何 CPU/GPU 数据契约最经典的坑。
1 2 3 4 5 6 7 8 9 10 11 [StructLayout(LayoutKind.Sequential) ]public struct WaterInteractorData { public Vector2 pixelPos; public float radius; public float pushForce; public Vector2 momentum; public Vector2 _pad; public const int Stride = 32 ; }
1 2 3 4 5 6 7 8 9 struct WaterInteractorData { float2 pixelPos; float radius; float pushForce; float2 momentum; float2 _pad; };
(c) WaterTrigger——零 GC Snapshot :水面上方一个跟随网格中心的 isTrigger Collider,通过 OnTriggerEnter/Exit 维护”哪些 WaterInteractor 当前在水里”:
CSHARP 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 public class WaterTrigger : MonoBehaviour { private readonly HashSet<WaterInteractor> _active = new HashSet<WaterInteractor>(); private readonly List<WaterInteractor> _activeList = new List<WaterInteractor>(64 ); private void OnTriggerEnter (Collider other ) { if (other.TryGetComponent<WaterInteractor>(out var w)) if (_active.Add(w)) _activeList.Add(w); } private void OnTriggerExit (Collider other ) { if (other.TryGetComponent<WaterInteractor>(out var w)) if (_active.Remove(w)) _activeList.Remove(w); } public int Snapshot (WaterInteractor[] buffer ) { int n = 0 , max = buffer.Length; for (int i = _activeList.Count - 1 ; i >= 0 ; i--) { var w = _activeList[i]; if (w == null ) { _activeList.RemoveAt(i); continue ; } if (!w.isActiveAndEnabled) continue ; if (n < max) buffer[n++] = w; else break ; } return n; } }
为什么并行维护 HashSet + List? HashSet 是 O(1) 去重,但其 foreach 在某些 IL2CPP 版本会装箱产生 GC;List 提供无 GC 的反向遍历能力。两者并行是性能与正确性的平衡——这种”同一份数据用两种容器维护”的写法在生产引擎里相当常见。
(d) Controller 端打包上传 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 private void SetComputeBufferInjectionParams () { int n = 0 ; if (enableComputeBufferTrack && waterTrigger != null && _interactorBuffer != null ) { n = waterTrigger.Snapshot(_snapshotBuf); for (int i = 0 ; i < n; i++) _interactorData[i] = _snapshotBuf[i].Pack(_currSnapPos, dx, resolution); if (n > 0 ) _interactorBuffer.SetData(_interactorData, 0 , 0 , n); } sweShader.SetBuffer(_injectKernel, ID_Interactors, _interactorBuffer); sweShader.SetInt (ID_InteractorCount, n); }
轨道 C:鼠标单点交互 最简单的兼容入口——鼠标按住拖动水面。本质上就是”只有一个 entry 的 ComputeBuffer 路径”,但因为只有一个,连结构体都省了,直接 SetVector 上传:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 private void SetMouseInjectionParams () { bool active = false ; if (enableMouseInteraction && Input.GetMouseButton(0 ) && _isDragging) { if (RaycastWaterPlane(out Vector3 hit)) { Vector3 vel = Vector3.ClampMagnitude( (hit - _lastMouseHitWorld) / Mathf.Max(Time.deltaTime, 0.01f ), 10f ); Vector2 pixelPos = WorldToPixelXZ(hit); sweShader.SetVector(ID_MousePixelPos, pixelPos); sweShader.SetFloat (ID_MouseRadius, mouseInteractionRadius); sweShader.SetFloat (ID_MousePushForce, mouseVolumePushForce); sweShader.SetVector(ID_MouseMomentum, new Vector4(vel.x * mouseMomentumMultiplier, vel.z * mouseMomentumMultiplier, 0 , 0 )); active = true ; _lastMouseHitWorld = hit; } } sweShader.SetInt(ID_MouseEnabled, active ? 1 : 0 ); }
GPU 侧只需要在 InjectSWE Kernel 里调用一次 ApplyRadialImpulse:
1 2 if (_MouseEnabled != 0 ) ApplyRadialImpulse(state, px, _MousePixelPos, _MouseRadius, _MousePushForce, _MouseMomentum);
三轨合一:InjectSWE 单 Kernel 三条轨道最终在同一个 InjectSWE Kernel 里加性叠加 ,互不互斥——鼠标拖拽 + 角色奔跑 + 鸭子游动可以同时影响同一片水面:
C 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 [numthreads(8 , 8 , 1 )]void InjectSWE (uint3 id : SV_DispatchThreadID) { float4 state = StateIn[id.xy]; float2 px = (float2)id.xy; if (_SplatEnabled != 0 ) { } if (_InteractorCount > 0 ) { for (int i = 0 ; i < _InteractorCount; ++i) { WaterInteractorData d = _Interactors[i]; ApplyRadialImpulse(state, px, d.pixelPos, d.radius, d.pushForce, d.momentum); } } if (_MouseEnabled != 0 ) ApplyRadialImpulse(state, px, _MousePixelPos, _MouseRadius, _MousePushForce, _MouseMomentum); state.g = clamp(state.g, -SWE_MAX_MOMENTUM, SWE_MAX_MOMENTUM); state.b = clamp(state.b, -SWE_MAX_MOMENTUM, SWE_MAX_MOMENTUM); state.r = clamp(state.r, 0.0 , SWE_MAX_HEIGHT); StateOut[id.xy] = state; }
把三种输入收敛到一个 Kernel 而不是三个独立 Kernel 的好处是:一次 RT 读写就完成所有注入,避免多次 Ping-Pong;钳制逻辑也只需写一次。
大世界支持:Snap-to-Grid 与 ShiftSWE 到目前为止,物理网格的位置一直是固定的。但生产场景里相机会在大世界中无限移动,固定网格意味着角色一旦走出区域水就再也不动了。解决方案是让网格”跟着相机走”,但又必须保持物理网格的离散一致性——这就是 Snap-to-Grid 。
每帧把相机的 XZ 坐标按 dx 向下取整,得到 _currSnapPos;与上一帧 _lastSnapPos 求差,转换为整数格偏移 shift;若 shift != 0,调度专门的 ShiftSWE Kernel 把 RT 整体偏移 shift 像素,新进入边界的格子填静水 :
1 2 3 4 5 6 7 8 9 10 11 12 [numthreads(8 , 8 , 1 )]void ShiftSWE (uint3 id : SV_DispatchThreadID) { int2 dest = (int2)id.xy; int2 src = dest + _ShiftCells; bool inBounds = (src.x >= 0 && src.x < (int )resolution.x && src.y >= 0 && src.y < (int )resolution.y); StateOut[dest] = inBounds ? StateIn[src] : SWE_REST_STATE; }
CSHARP 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 private Vector2 SnapXZ (Vector3 pos ) { float sx = Mathf.Floor(pos.x / dx) * dx; float sz = Mathf.Floor(pos.z / dx) * dx; return new Vector2(sx, sz); }private void Update () { UpdateCameraFollow(); Vector2Int shift = _firstFrame ? Vector2Int.zero : Vector2Int.RoundToInt((_currSnapPos - _lastSnapPos) / dx); if (shift != Vector2Int.zero) { if (Mathf.Abs(shift.x) >= resolution / 2 || Mathf.Abs(shift.y) >= resolution / 2 ) ClearStateRTs(); else { sweShader.SetInts(ID_ShiftCells, shift.x, shift.y); DispatchKernel(_shiftKernel); } } _lastSnapPos = _currSnapPos; }
注意 _currSnapPos 不仅 ShiftSWE 用,所有把世界坐标转 SWE 像素坐标的地方都要用它 ——WaterInteractor.Pack 里出现的 snapPosWorldXZ 参数就是从这里来的。如果交互源用旧的 snap 坐标,水面看起来会出现”轨道 B 的脚步在水面上滑动一格”的错位。
数据出口:BuildSWENormal 与全局广播 物理推进结果要让水面 Shader 用得起来,还差最后两步——法线预计算 与解耦广播 。
法线场预计算 BuildSWENormal 水面 Shader 每个 fragment 都要计算邻域差分得到法线,每帧每像素 4 次纹理采样开销不小。把这部分预先在 Compute Shader 里算好 到一张独立的 RGHalf 法线 RT,水面 Shader 单次采样即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 [numthreads(8 , 8 , 1 )]void BuildSWENormal (uint3 id : SV_DispatchThreadID) { int2 pos = (int2)id.xy; int2 maxId = int2((int )resolution.x - 1 , (int )resolution.y - 1 ); float hL = StateIn[clamp(pos + int2(-1 , 0 ), int2(0 ,0 ), maxId)].r; float hR = StateIn[clamp(pos + int2( 1 , 0 ), int2(0 ,0 ), maxId)].r; float hD = StateIn[clamp(pos + int2( 0 , -1 ), int2(0 ,0 ), maxId)].r; float hU = StateIn[clamp(pos + int2( 0 , 1 ), int2(0 ,0 ), maxId)].r; float dHdX = (hR - hL) * 0.5 * _SWENormalScale; float dHdZ = (hU - hD) * 0.5 * _SWENormalScale; float3 n = normalize(float3(-dHdX, 1.0 , -dHdZ)); SWENormalOut[id.xy] = float2(n.x, n.z); }
为什么只存 X、Z? 法线归一化后 可由 fragment 重建,显存直接砍半 。这个技巧只有在法线 Y > 0 时才能用(水面法线大致朝上,恒成立)。
调度时机也很关键:必须在 UpdateSWE 之后执行,并且 Ping-Pong 已经交换;这样 BuildSWENormal 读到的 StateIn 就是最新的高度场,法线与高度同步无滞后。
Shader.SetGlobal 解耦广播 控制器不持有水面材质引用、水面 Shader 不持有控制器引用——二者通过 Shader.SetGlobalTexture 这层”全局总线”耦合:
1 2 3 4 5 6 7 8 9 10 11 12 13 private void BroadcastToSeaShader () { Shader.SetGlobalTexture(ID_SWE_StateTex, LatestStateRT); Shader.SetGlobalTexture(ID_SWE_NormalTex, _normalRT); Shader.SetGlobalVector (ID_SWECenterWorldPos, new Vector4(_currSnapPos.x, waterSurfaceY, _currSnapPos.y, 0 )); Shader.SetGlobalFloat (ID_SWESimulationSize, SimulationSize); Shader.SetGlobalFloat (ID_SWEHeightMultiplier, heightMultiplier); Shader.SetGlobalFloat (ID_SWENormalStrength, normalStrength); Shader.SetGlobalFloat (ID_SWEWaveSharpness, waveSharpness); Shader.SetGlobalVector (ID_SWEFoamParams, new Vector4(foamSpeedThreshold, foamIntensity, 0 , 0 )); }
水面 Shader 端只需声明对应的全局变量即可(具体在水面 Shader 里如何采样和混合,详见配套笔记《水体渲染》)。这种解耦的好处是:场景里有多片水域共用一个控制器、或者动态加载/卸载水面时,无需手动维护 Material 引用关系。
主控调度管线 (Main Loop) 把上面所有模块串起来,SWEController.Update() 的主循环长这样:
CSHARP 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 private void Update () { UpdateCameraFollow(); Vector2Int shift = ComputeShift(); if (shift != Vector2Int.zero) DispatchKernel(_shiftKernel); if (enableSplatMap) UpdateSplatCamera(); sweShader.SetFloat(ID_dt, dt); SetMouseInjectionParams(); SetComputeBufferInjectionParams(); SetSplatInjectionParams(); DispatchKernel(_injectKernel); sweShader.SetFloat(ID_WaveBounciness, waveBounciness); DispatchKernel(_updateKernel); DispatchNormalKernel(); BroadcastToSeaShader(); }
主循环按”准备数据 → 注入 → 解算 → 出口”四段式推进,对应了系统架构图自上而下的三层。第 9 步的”障碍反弹”和第 7 步的”Splat RT 注入”都依赖 _SplatTex,但用法不同——InjectSWE 用 R/G/B 通道(动量 + 排水),UpdateSWE 用 A 通道(静止障碍掩码)。同一张纹理被两个 Kernel 在不同语义上消费,是这套设计最巧妙的地方。
总结 若只需表现水面受扰动后的形态传播 (如雨天涟漪系统),波动方程是性价比最高的选择——实现简单、性能极佳、数值稳定。若需要表现物体与水体的真实力学交互 (如角色涉水推开水面、船体尾流、地形洪水蔓延),则浅水方程能提供不可替代的物理感,代价是更高的工程复杂度与更严格的数值稳定性管理。
两者并不互斥:在实际工程中,也可将波动方程的高频细节涟漪与浅水方程的低频宏观流动进行多层叠加 ,在视觉丰富度与计算成本之间取得平衡。
而要把浅水方程从”简单 Demo”推到”生产可用”,关键不在物理本身,而在于工程架构的层层叠加:
数值常量集中 (SWECommon.hlsl)保证多 Kernel 间的一致性;
固壁边界 + 障碍掩码反弹 联合构成完整的边界系统;
ApplyRadialImpulse 抽象 + 三轨加性叠加 让鼠标 / 点状物体 / 体积物体共用同一套数学;
零 GC 的 Trigger 维护 + 严格对齐的 32B 数据契约 解决了多对象交互的 GC 与内存陷阱;
CPU 端 (min, teleport) 双闸门 + GPU 端 smoothstep 速度门控 是物理仿真稳定性的两道生命线;
Snap-to-Grid + ShiftSWE 让网格随相机移动而保持物理一致性;
预计算法线 RT + Y 重建 把水面 Shader 的法线开销砍到一次采样;
Shader.SetGlobal* 全局广播 让水面 Shader 完全不感知控制器的存在。
这套架构在保留物理可读性的同时,能在中等画面(512² 网格、64 个交互体、若干 Splat 物体)下稳定 60fps 运行,且能与已有的 Gerstner 海洋 Shader 无缝叠加(参见配套笔记水体渲染 )。