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

波动方程将水面抽象为一张由无数根无形弹簧连接的网格。当某处水面被扰动时,仅是拨动了其中一根弹簧,这种"上下起伏"的趋势会依次传递给相邻弹簧,形成向外扩散的涟漪。

数学表达

局限性(为什么没有推水效果):在波动方程中,水分子本身没有水平速度。这好比体育场里观众做”人浪”——波形绕场一圈,但每个观众始终坐在原位。因此,波动方程无法呈现”水被实体排开”、”河流顺流而下”或”水流绕障碍物产生尾流旋涡”等效果。

适用场景:雨滴落在平静水面泛起的涟漪、水池表面的轻微扰动动画。


高度场流体原理

为了在实时渲染中达到极高的性能,通常采用基于波动方程的简化流体模型——高度场(Height Field)方法。

1. 核心物理模型

在这个简化的 2D 流体模型中,水面被视为一张由弹簧连接的高度网格。某点当前时刻的高度 ,由该点自身的历史状态以及其四邻域(上、下、左、右)的高度共同决定。

根据二维波动方程:

其中 为水位高度, 为波速, 为高度的拉普拉斯算子(表示该点与周围点的高度差)。

代表的是”波速”(Wave Speed),即扰动向四周传播的速率。

  • 较大,点击水面后波纹会快速扩散至整个画面;
  • 较小,波纹则如同在浓稠液体中缓慢蠕动。

与真实浅水物理的关联

在重力浅水环境中,波速 并非任意常数,而与重力加速度 水深 直接相关:

这解释了为何海啸在深海区( 极大)传播速度极快(可媲美喷气式飞机),而在靠近岸边水深骤降时,波速 急剧降低——后方海水追上前方,能量堆积,进而形成滔天巨浪。

数值稳定性的约束:CFL 条件

在数值模拟中,波速 是导致数值爆炸的核心控制指标。根据柯朗-弗里德里希斯-勒维条件(CFL Condition),波在一个时间步长 内传播的距离不得超过一个网格宽度

这意味着: 设置过大,则必须等比例缩小 。否则差分计算时波纹将”跨越”相邻像素,导致计算完全失效,画面立刻充满 NaN(黑块或白点)。

2. 离散化求解

使用有限差分法(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
65
66
// 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
99
100
101
102
103
104
105
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);

// 立即将含水滴的 Next 同步为 Current,确保 UpdateWater 使用最新状态
Graphics.Blit(nextTex, currentTex);
}

private void SwapTextures()
{
// Ping-Pong:Previous ← Current ← Next ← (旧 Previous 复用)
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();
}
}

浅水方程 Shallow Water Equations

浅水方程是纳维-斯托克斯(Navier-Stokes)方程在深度方向上进行积分平均后的简化形式。它真正将水视为有质量、有惯性、能够流动的连续介质

数学表达(质量守恒 + 动量守恒):

核心优势(平流与动量):浅水方程引入了速度向量 。当角色踩入水中时,不仅水面高度 发生变化,还会向水体注入真实的水平动量 。水分子携带自身质量与速度从 A 点真实移动至 B 点——这就是平流(Advection)——从而产生”水被实体推开”乃至障碍物后方尾流旋涡的效果。

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


物理与数学原理

浅水方程通过将三维 Navier-Stokes 方程在深度方向积分平均推导而来,同时考虑了水面高度变化(质量守恒)与水流的平流运动(动量守恒)

1. 守恒形式的偏微分方程组

将流体状态定义为状态向量 ,并分别在 X、Y 方向上计算物理通量

状态向量 (水深 + 两个方向动量):

X 方向通量

Y 方向通量

注: 为重力产生的静水压力项,是驱动水波向外扩散的核心力。

控制方程:

2. 数值离散:Lax-Friedrichs 格式

直接对上述偏微分方程进行中心差分离散是无条件不稳定的。采用 Lax-Friedrichs 格式,用四邻域的平均状态替代中心点状态,引入人工粘性以换取稳定性:


机制设计与工程边界处理

将公式转化为生产代码时,需要妥善处理以下几个工程化挑战:

  1. 交互推水(Momentum Injection):单纯修改高度 会产生”吸水”视觉 Bug——水面凹陷而不是被推开。正确做法是将交互物体(鼠标、角色脚部)的速度转化为径向排开推力移动方向动量,注入 通道。

  2. 数值防爆(Stability Clamp)

    • 干区除零:当水位 极低时,计算流速 将产生除零或无穷大。必须设置 DRY_TOLERANCE,水量低于阈值时直接将该格所有分量归零。
    • 速度钳制:无论是外部注入的角色速度,还是内部计算出的流速,都必须通过 clamp 严格限幅,防止违反 CFL 条件导致全局崩溃。
  3. 固壁边界(Closed Boundary):为防止水流出网格边缘导致总体水量衰减,采样边界像素时需将坐标 clamp 在纹理范围内,并对撞墙方向的动量取反,模拟弹性固壁反弹。


实现

1. Compute Shader

SWE.compute状态纹理 StateIn 使用 ARGBFloat 格式:R = 水深 ,G = X 方向动量 ,B = Y 方向动量

SWE.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
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
99
100
101
102
103
104
105
106
107
108
109
#pragma kernel UpdateSWE
#pragma kernel InteractWater

#define DRY_TOLERANCE 0.01
#define MAX_VELOCITY 8.0

Texture2D<float4> StateIn;
RWTexture2D<float4> StateOut;

float dt;
float dx;
float gravity;
float damping;
float2 resolution;

float2 dropPos;
float dropRadius;
float dropPushForce;
float2 dropMomentum;

// 带固壁边界条件的采样(超界坐标钳位 + 法向动量取反)
float3 GetU(int2 id)
{
int2 clampedId = clamp(id, int2(0, 0), int2(resolution.x - 1, 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;
}

// X 方向物理通量
float3 GetF(float3 U)
{
if (U.x < DRY_TOLERANCE) return float3(0, 0, 0);
float u = clamp(U.y / U.x, -MAX_VELOCITY, MAX_VELOCITY);
return float3(U.y, U.y * u + 0.5 * gravity * U.x * U.x, U.z * u);
}

// Y 方向物理通量
float3 GetG(float3 U)
{
if (U.x < DRY_TOLERANCE) return float3(0, 0, 0);
float v = clamp(U.z / U.x, -MAX_VELOCITY, MAX_VELOCITY);
return float3(U.z, U.y * v, U.z * v + 0.5 * gravity * U.x * U.x);
}

[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 F_left = GetF(U_left);
float3 F_right = GetF(U_right);
float3 G_down = GetG(U_down);
float3 G_up = GetG(U_up);

float3 U_next = U_avg - dt * (
(F_right - F_left) / (2.0 * dx) +
(G_up - G_down) / (2.0 * dx)
);

U_next.y *= damping;
U_next.z *= damping;
U_next.x = clamp(U_next.x, 0.0, 5.0);

// 干区抽干与动量防爆
if (U_next.x < DRY_TOLERANCE)
{
U_next.xyz = 0.0;
}
else
{
float maxMom = U_next.x * MAX_VELOCITY;
U_next.y = clamp(U_next.y, -maxMom, maxMom);
U_next.z = clamp(U_next.z, -maxMom, maxMom);
}

StateOut[id.xy] = float4(U_next, 1.0);
}

[numthreads(8, 8, 1)]
void InteractWater (uint3 id : SV_DispatchThreadID)
{
float2 dir = (float2)id.xy - dropPos;
float dist = length(dir);
float4 currentState = StateIn[id.xy];

if (dist < dropRadius)
{
float influence = smoothstep(dropRadius, 0.0, dist);
float2 radialDir = dist > 0.1 ? normalize(dir) : float2(0, 0);

// 径向排开推力 + 移动方向动量叠加
float forceX = (radialDir.x * dropPushForce + dropMomentum.x) * influence;
float forceY = (radialDir.y * dropPushForce + dropMomentum.y) * influence;

currentState.g = clamp(currentState.g + forceX, -15.0, 15.0);
currentState.b = clamp(currentState.b + forceY, -15.0, 15.0);
}

StateOut[id.xy] = currentState;
}

2. C# 控制器

SWEController.cs挂载在接收交互的物体(带 MeshCollider 的 Plane)上,负责驱动物理步进与鼠标拖拽交互。

SWEController.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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
using UnityEngine;

public class SWEController : MonoBehaviour
{
public ComputeShader sweShader;
public Material displayMaterial;

[Header("Simulation Settings")]
public int resolution = 512;
public float dx = 1.0f;
public float gravity = 9.8f;
[Range(0.9f, 1.0f)] public float damping = 0.99f;
[Range(0.001f, 0.05f)] public float dt = 0.015f;

[Header("Interaction")]
public float interactionRadius = 12f;
public float volumePushForce = 2.0f;
public float momentumMultiplier = 4.0f;

private RenderTexture stateA, stateB;
private bool useAForIn = true;
private int updateKernel, interactKernel;
private Vector3 lastMousePos;
private bool isDragging = false;

void Start()
{
stateA = CreateRT();
stateB = CreateRT();
updateKernel = sweShader.FindKernel("UpdateSWE");
interactKernel = sweShader.FindKernel("InteractWater");
}

void Update()
{
RenderTexture stateIn = useAForIn ? stateA : stateB;
RenderTexture stateOut = useAForIn ? stateB : stateA;
bool hasInteraction = false;

// --- 交互逻辑 ---
if (Input.GetMouseButtonDown(0))
{
if (GetHitPos(out Vector3 pos)) { lastMousePos = pos; isDragging = true; }
}
else if (Input.GetMouseButton(0) && isDragging)
{
if (GetHitPos(out Vector3 currentPos, out Vector2 pixelPos))
{
Vector3 velocity = Vector3.ClampMagnitude(
(currentPos - lastMousePos) / Mathf.Max(Time.deltaTime, 0.01f), 10f);

sweShader.SetVector("dropPos", pixelPos);
sweShader.SetFloat("dropRadius", interactionRadius);
sweShader.SetFloat("dropPushForce", volumePushForce);
sweShader.SetVector("dropMomentum", new Vector2(velocity.x, velocity.z) * momentumMultiplier);
sweShader.SetTexture(interactKernel, "StateIn", stateIn);
sweShader.SetTexture(interactKernel, "StateOut", stateOut);

int intGroups = Mathf.CeilToInt(resolution / 8.0f);
sweShader.Dispatch(interactKernel, intGroups, intGroups, 1);

hasInteraction = true;
lastMousePos = currentPos;
SwapTextures();
stateIn = useAForIn ? stateA : stateB;
stateOut = useAForIn ? stateB : stateA;
}
}
else if (Input.GetMouseButtonUp(0)) isDragging = false;

// --- 物理步进 ---
if (!hasInteraction)
{
Graphics.Blit(stateIn, stateOut);
SwapTextures();
stateIn = useAForIn ? stateA : stateB;
stateOut = useAForIn ? stateB : stateA;
}

sweShader.SetFloat("dt", dt);
sweShader.SetFloat("dx", dx);
sweShader.SetFloat("gravity", gravity);
sweShader.SetFloat("damping", damping);
sweShader.SetVector("resolution", new Vector2(resolution, resolution));
sweShader.SetTexture(updateKernel, "StateIn", stateIn);
sweShader.SetTexture(updateKernel, "StateOut", stateOut);

int groups = Mathf.CeilToInt(resolution / 8.0f);
sweShader.Dispatch(updateKernel, groups, groups, 1);

if (displayMaterial != null) displayMaterial.mainTexture = stateOut;
SwapTextures();
}

private bool GetHitPos(out Vector3 worldPos, out Vector2 pixelPos)
{
worldPos = Vector3.zero; pixelPos = Vector2.zero;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit))
{
worldPos = hit.point;
pixelPos = new Vector2(hit.textureCoord.x * resolution, hit.textureCoord.y * resolution);
return true;
}
return false;
}
private bool GetHitPos(out Vector3 worldPos) => GetHitPos(out worldPos, out _);
private void SwapTextures() => useAForIn = !useAForIn;

private RenderTexture CreateRT()
{
var rt = new RenderTexture(resolution, resolution, 0, RenderTextureFormat.ARGBFloat);
rt.enableRandomWrite = true;
rt.Create();
RenderTexture.active = rt;
GL.Clear(false, true, new Color(0.5f, 0f, 0f, 1f)); // 初始水深 0.5
RenderTexture.active = null;
return rt;
}
}

3. 渲染 Shader

SWEWaterSurface.shader赋给 Plane 材质,集成顶点置换、动态法线重构与基于流速的白沫生成。

SWEWaterSurface.shader
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
Shader "Custom/SWEWaterSurface"
{
Properties
{
_MainTex ("Fluid State (ARGBFloat)", 2D) = "black" {}
_Displacement ("Height Multiplier", Float) = 2.0
_NormalStrength ("Normal Strength", Float) = 5.0
_WaterColor ("Water Color", Color) = (0.1, 0.4, 0.6, 0.9)
_FoamColor ("Foam Color", Color) = (1.0, 1.0, 1.0, 1.0)
_FoamThreshold ("Foam Speed Threshold", Float) = 2.5
}
SubShader
{
Tags { "RenderType"="Transparent" "Queue"="Transparent" }
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off

Pass
{
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

struct Attributes {
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct Varyings {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldPos : TEXCOORD1;
};

sampler2D _MainTex;
float4 _MainTex_TexelSize;
float _Displacement;
float _NormalStrength;
float4 _WaterColor;
float4 _FoamColor;
float _FoamThreshold;

Varyings vert (Attributes v)
{
Varyings o;
// R 通道为水深,减去初始水深 0.5 得到相对偏移
float height = tex2Dlod(_MainTex, float4(v.uv, 0, 0)).r - 0.5;
float4 pos = v.vertex;
pos.y += height * _Displacement;
o.pos = TransformObjectToHClip(pos);
o.uv = v.uv;
o.worldPos = mul(unity_ObjectToWorld, pos).xyz;
return o;
}

half4 frag (Varyings i) : SV_Target
{
float3 state = tex2D(_MainTex, i.uv).rgb;
float h = state.r;
float2 momentum = state.gb;
float speed = length(momentum);

// 四邻域差分重构法线
float hL = tex2D(_MainTex, i.uv + float2(-_MainTex_TexelSize.x, 0)).r;
float hR = tex2D(_MainTex, i.uv + float2( _MainTex_TexelSize.x, 0)).r;
float hD = tex2D(_MainTex, i.uv + float2(0, -_MainTex_TexelSize.y)).r;
float hU = tex2D(_MainTex, i.uv + float2(0, _MainTex_TexelSize.y)).r;
float3 normal = normalize(float3((hL - hR) * _NormalStrength, 0.2, (hD - hU) * _NormalStrength));

// Blinn-Phong 光照
Light mainLight = GetMainLight();
float3 lightDir = normalize(mainLight.direction);
float3 viewDir = GetWorldSpaceViewDir(i.worldPos);
float diff = saturate(dot(normal, lightDir));
float spec = pow(saturate(dot(normal, normalize(lightDir + viewDir))), 64.0);

half4 finalColor = _WaterColor;
finalColor.rgb += diff * 0.2 + spec * 0.5;

// 高流速区域混合白沫
float foamFactor = smoothstep(_FoamThreshold, _FoamThreshold + 2.0, speed);
finalColor.rgb = lerp(finalColor.rgb, _FoamColor.rgb, foamFactor * 0.8);

return finalColor;
}
ENDHLSL
}
}
}

总结

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

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