Banner

水体交互

概述

水体交互是游戏与实时渲染中极具表现力的视觉效果之一,也是流体模拟领域长期研究的核心课题。从技术实现的角度看,它横跨物理建模数值方法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)

alt text

为了在实时渲染中达到极高的性能,通常采用基于波动方程的简化流体模型——高度场(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
// ShallowWater.compute
#pragma kernel UpdateWater
#pragma kernel AddDrop

// 三张纹理分别对应上一帧、当前帧、下一帧的水面状态
Texture2D<float4> PreviousTex;
Texture2D<float4> CurrentTex;
RWTexture2D<float4> NextTex;

// 物理参数
float damping; // 阻尼(如 0.99)
float waveSpeed; // 扩散速度(对应公式中的 alpha,建议 < 0.5)
float2 resolution; // 纹理分辨率

// 交互参数
float2 dropPos; // 水滴位置(像素坐标)
float dropRadius; // 水滴半径
float dropStrength; // 水滴强度(高度扰动量)

// ---------------------------------------------------------
// Kernel 1: 核心流体模拟步进
// ---------------------------------------------------------
[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);
}

// ---------------------------------------------------------
// Kernel 2: 添加交互扰动(水滴/点击)
// ---------------------------------------------------------
[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; // 超过 0.5 将违反 CFL 条件

[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)——从而产生”水被实体推开”乃至障碍物后方尾流旋涡的效果。

适用场景:角色在积水中奔跑的泥泞感、地形水流冲刷、决堤洪水蔓延、河流模拟。

alt text

物理与数学原理

浅水方程通过将三维 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 端(C# Runtime)—— 准备数据

    • WaterTrigger:通过 isTrigger Collider 维护当前活跃的交互体集合
    • SWEController:主调度器,管理网格 Snap-to-Grid 对齐、双轨绑定与全局广播
    • 正交相机:跟随 SWE 网格中心向下俯拍,把 splat 层物体的位置/速度信息渲染成 Splat RT
  • GPU 端(Compute Shader)—— 解算流体

    • ShiftSWE:网格随相机平移时整数格偏移状态纹理
    • InjectSWE:把双轨数据(ComputeBuffer + Splat RT)加性注入到 通道
    • UpdateSWE:Lax-Friedrichs 时间步进 + 障碍反弹
    • 此外还有一个 BuildSWENormal Kernel 在每帧最后预计算法线 RT 输出给 Shader——架构图聚焦于解算主路径,BuildSWENormal 与上述三个 Kernel 共同构成完整的 4-Kernel 流水线。
  • Shader 端(URP Sea Shader)—— 消费状态:通过 Shader.SetGlobal* 订阅状态纹理与法线纹理,做顶点位移与法线混合。

数据接口的两种风格

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
// SWECommon.hlsl
#define SWE_REST_WATER_HEIGHT 0.5 // State.r 静水基线
#define SWE_DRY_TOLERANCE 0.01 // 干区阈值(防 0 / 防 NaN)
#define SWE_MAX_VELOCITY 8.0 // 单格速度上限(CFL 下限)
#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
// SWE.compute
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; // X 方向动量反弹
if (id.y < 0 || id.y >= (int)resolution.y) U.z = -U.z; // Y 方向动量反弹
return U;
}

// X 方向物理通量 F(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);
}

// Y 方向物理通量 G(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));

// Lax-Friedrichs: 四邻域均值 - dt * 通量散度
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)
{
// 类似台球撞库 + 摩擦能量损耗
// 乘以 _WaveBounciness (默认 0.8) 防止水波在船体夹角处无限震荡
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
// 共享给鼠标、ComputeBuffer、SplatRT 等所有交互源
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
// SplatReplacement.shader 片段着色器节选
float4 frag(Varyings IN) : SV_Target
{
float dt = max(_SWE_DeltaTime, 1e-4);
float2 vel = float2(0, 0);
if (_PrevWorldPos.w > 0.5) // W 通道作为有效性掩码
vel = (IN.worldPos.xz - _PrevWorldPos.xz) / dt;

// 速度门控: 过滤微小抖动 (< 0.05 静止) 平滑过渡到 0.2 全推力
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; // 静止物体推力为 0

float maxV = max(_SWE_SplatMaxVel, 0.001);
float2 encVel = saturate(vel / maxV * 0.5 + 0.5);

// A 通道 (1 - moveMask) = "静止障碍"掩码 → 给 UpdateSWE 反弹用
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);
// W 通道 = 有效性掩码; W=0 时 Shader 端 vel 直接归 0
_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
// SWE.compute - InjectSWE 节选
if (_SplatEnabled != 0)
{
float2 splatUV = ((float2)id.xy + 0.5) / resolution;
float4 splat = _SplatTex.SampleLevel(sampler_SplatTex, splatUV, 0);

if (splat.r > 0.001)
{
// GB 通道 [0,1] → 世界速度 [-MaxVel, MaxVel]
float2 splatVel = (splat.gb - 0.5) * 2.0 * _SplatMaxVel;
float weight = splat.r; // 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
// SWE.compute - InjectSWE 节选
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()
{
// LateUpdate 确保所有 Update 内的 Transform 修改和 Physics 解算都已完成
float dt = Mathf.Max(Time.deltaTime, 1e-4f);
Vector3 raw = (transform.position - _lastPos) / dt;
Velocity = Vector3.ClampMagnitude(raw, maxVelocity);
_lastPos = transform.position;
}

/// <summary>零 GC 打包成 GPU 友好的结构体</summary>
public WaterInteractorData Pack(Vector2 snapPosWorldXZ, float dx, int resolution)
{
Vector3 wp = transform.position;
// 世界坐标 → SWE 像素坐标
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
// CPU 端
[StructLayout(LayoutKind.Sequential)]
public struct WaterInteractorData
{
public Vector2 pixelPos; // 8 B
public float radius; // 4 B
public float pushForce; // 4 B
public Vector2 momentum; // 8 B
public Vector2 _pad; // 8 B 对齐填充到 32 B
public const int Stride = 32;
}
1
2
3
4
5
6
7
8
9
// HLSL 端必须严格对齐
struct WaterInteractorData
{
float2 pixelPos; // 8 B
float radius; // 4 B
float pushForce; // 4 B
float2 momentum; // 8 B
float2 _pad; // 8 B
};

(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); // HashSet 去重 + List 遍历
}

private void OnTriggerExit(Collider other)
{
if (other.TryGetComponent<WaterInteractor>(out var w))
if (_active.Remove(w)) _activeList.Remove(w);
}

/// <summary>零 GC 把活跃物体写入预分配缓冲,顺手清理 null 引用</summary>
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);
}
// 即使为 0 也要绑定 Buffer (HLSL StructuredBuffer 不能为空)
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
// SWEController.cs
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;

// ── 轨道 A: SplatRT 体积型物体 ──
if (_SplatEnabled != 0) { /* ... 如上 ... */ }

// ── 轨道 B: ComputeBuffer 点状交互体 ──
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);
}
}

// ── 轨道 C: 鼠标单点交互 ──
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
// SWE.compute - Kernel ShiftSWE
[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
// SWEController.Update() - 平移调度
private Vector2 SnapXZ(Vector3 pos)
{
// 用 Floor 而非 Round: 让"向左走 0.5dx 与向右走 0.5dx"对应不同 snap, 避免阈值附近来回跳
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)
{
// 超远传送: 直接清屏跳过 Shift, 避免反复读越界
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;
// ... 后续 InjectSWE / UpdateSWE / BuildSWENormal
}

注意 _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
// SWE.compute - Kernel BuildSWENormal
[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;

// 切向 T_x = (1, dHdX, 0), T_z = (0, dHdZ, 1) → N = T_x × T_z
float3 n = normalize(float3(-dHdX, 1.0, -dHdZ));

// 仅输出 X / Z (Y 由 fragment 重建为 sqrt(1 - x² - z²))
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
// SWEController.cs
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()
{
// 1. 相机跟随 + Snap 检测
UpdateCameraFollow();

// 2. ShiftSWE 网格平移(如需要)
Vector2Int shift = ComputeShift();
if (shift != Vector2Int.zero) DispatchKernel(_shiftKernel);

// 3. SplatCamera 渲染(轨道 A 数据准备)
if (enableSplatMap) UpdateSplatCamera();

// 4. 设置公共参数(dt / dx / gravity / damping / resolution)
sweShader.SetFloat(ID_dt, dt);
/* ... */

// 5-7. 三轨注入参数
SetMouseInjectionParams(); // 轨道 C
SetComputeBufferInjectionParams(); // 轨道 B 打包
SetSplatInjectionParams(); // 轨道 A 绑定

// 8. InjectSWE 三轨叠加注入
DispatchKernel(_injectKernel);

// 9. UpdateSWE Lax-Friedrichs + 障碍反弹
sweShader.SetFloat(ID_WaveBounciness, waveBounciness);
DispatchKernel(_updateKernel);

// 10. BuildSWENormal 高度场 → 法线 RT
DispatchNormalKernel();

// 11. 全局广播给所有 Sea Shader 实例
BroadcastToSeaShader();
}

主循环按”准备数据 → 注入 → 解算 → 出口”四段式推进,对应了系统架构图自上而下的三层。第 9 步的”障碍反弹”和第 7 步的”Splat RT 注入”都依赖 _SplatTex,但用法不同——InjectSWE 用 R/G/B 通道(动量 + 排水),UpdateSWE 用 A 通道(静止障碍掩码)。同一张纹理被两个 Kernel 在不同语义上消费,是这套设计最巧妙的地方。


总结

若只需表现水面受扰动后的形态传播(如雨天涟漪系统),波动方程是性价比最高的选择——实现简单、性能极佳、数值稳定。若需要表现物体与水体的真实力学交互(如角色涉水推开水面、船体尾流、地形洪水蔓延),则浅水方程能提供不可替代的物理感,代价是更高的工程复杂度与更严格的数值稳定性管理。

两者并不互斥:在实际工程中,也可将波动方程的高频细节涟漪与浅水方程的低频宏观流动进行多层叠加,在视觉丰富度与计算成本之间取得平衡。

而要把浅水方程从”简单 Demo”推到”生产可用”,关键不在物理本身,而在于工程架构的层层叠加:

  1. 数值常量集中SWECommon.hlsl)保证多 Kernel 间的一致性;
  2. 固壁边界 + 障碍掩码反弹联合构成完整的边界系统;
  3. ApplyRadialImpulse 抽象 + 三轨加性叠加让鼠标 / 点状物体 / 体积物体共用同一套数学;
  4. 零 GC 的 Trigger 维护 + 严格对齐的 32B 数据契约解决了多对象交互的 GC 与内存陷阱;
  5. CPU 端 (min, teleport) 双闸门 + GPU 端 smoothstep 速度门控是物理仿真稳定性的两道生命线;
  6. Snap-to-Grid + ShiftSWE让网格随相机移动而保持物理一致性;
  7. 预计算法线 RT + Y 重建把水面 Shader 的法线开销砍到一次采样;
  8. Shader.SetGlobal* 全局广播让水面 Shader 完全不感知控制器的存在。

这套架构在保留物理可读性的同时,能在中等画面(512² 网格、64 个交互体、若干 Splat 物体)下稳定 60fps 运行,且能与已有的 Gerstner 海洋 Shader 无缝叠加(参见配套笔记水体渲染)。