Banner

水体渲染

概述

真实感水体的渲染是一个跨越几何学与光学的复合系统,而卡通化的水体则是在真实感水体基础进行简化与风格化处理。一个完整的水面体材质,通常需要解决以下六大核心模块:

  1. 波形动力学(微观流动、宏观波浪、地形交互) 波纹
  2. 深度与颜色吸收(浅水、深水、水下吸收)深度颜色变化
  3. 物理反射与折射(SSR、平面反射、屏幕空间扭曲) 反射折射
  4. 次表面散射 (SSS)(波峰透光感)
  5. 焦散 (Caustics)(水下光斑折射) 阳光照射下的焦散
  6. 白沫系统 (Foam)(波峰碎浪、近岸冲刷、法线体积感) 浪花

本文按以下三大模块组织:

模块 关注层 涵盖内容 系统耦合点
第一部分:几何与波形动力学 顶点位移 Flowmap / Gerstner / FFT Wave Pinch (接入 SWE)
第二部分:光学与材质表现 片元光照 深度、反射、折射、SSS、焦散
第三部分:表层细节与系统整合 表层修饰 + 跨系统 泡沫、RNM、全局通信 SWE 流速白沫 / 渲染-物理闭环

每个模块独立成章,模块之间通过明确标注的”系统耦合”小节连接,避免理论分散。

水体渲染技术 思维导图


第一部分:几何与波形动力学

水面顶点的最终位置 = 程序化波形位移 + 物理交互位移。本部分按”微观细节 → 常规海洋 → 宏观汪洋”三个尺度,分别介绍 Flowmap、Gerstner、FFT 三种典型方案,并在 Gerstner 这一节末尾通过”系统耦合”小节衔接到浅水方程的物理位移层。

微观细节:法线扰动与流动贴图 (Flowmap)

最细的水面表层,由静态/动态法线贴图扰动而成。流动贴图(Flowmap)则是表现水面微观流动感的核心技术——普通的 UV 动画在表现水流时会显得极其死板,因为整张纹理都在同一个方向匀速移动;而 Flowmap 用一张额外的”流向贴图”对每个像素指定独立的流动方向,让水面上的每一处都有自己的流速与方向。

工程上的两个关键技术点:

  • 双重相位采样 (Dual-Phase Sampling):为了消除 UV 随时间偏移产生的严重拉伸,通常在 Shader 中采样两组相位差为 0.5 的 UV 坐标,并利用正弦函数 saturate(abs(sin(frac(time) * PI))) 作为权重进行无缝混合。
  • 坡度自适应:结合世界法线的 Y 分量,可实现水流在陡峭地形处自动加速的物理特性。

Water.hlsl 里的 FlowmapUV 同时实现了上面两点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void FlowmapUV(float _UV_DirMode, float2 _TexTiling, float _GlobalTiling, float2 _TexSpeed, 
float _RefreshSpeed, float2 _SlopeInfluence, float _Time, FlowmapUVData IN,
out float2 UV_1, out float2 UV_2, out float FlowLerp)
{
// 坡度自适应: 法线 Y 越小(地形越陡)→ 流速越大
float2 totalSpeed = _TexSpeed + ((1.0 - saturate(abs(IN.WorldSpaceNormal.y))) * _SlopeInfluence);
float2 flowVec = totalSpeed * _TexTiling * IN.uv3.xy;
float2 flowDir = _UV_DirMode ? flowVec : flowVec.yx;

float timeOffset = _Time * _RefreshSpeed;
float2 baseUV = IN.uv0.xy * _TexTiling * rcp(_GlobalTiling);

// 相位差 0.5 的双 UV 与 sin 包络混合,消除拉伸感
UV_1 = baseUV + flowDir * frac(timeOffset);
UV_2 = baseUV + flowDir * frac(timeOffset - 0.5);
FlowLerp = saturate(abs(sin(frac(timeOffset) * PI_CONST + 4.712389))); // 1.5π
}

Flowmap 解决了”微观流动感”,但它只能在 fragment 阶段制造法线扰动,无法真正改变水面几何形状。要让水面”鼓起来”形成真正的浪涌,需要 vertex 阶段的几何位移——这就是下一节 Gerstner Waves 的工作。

常规海洋:基于 Gerstner Waves 的线性叠加

线性波形叠加方法的主要思路是累加不同的线性波形函数以构造波浪表面。可以将其理解为波动现象在深水中引起水颗粒运动的一种解析解。

水颗粒运动的图示 https://wikischool.org/divided_light

波浪中的任何点都沿圆形轨迹移动,靠近表面的半径较大,而在水中更深的半径呈指数减小。突出显示了两个橙色点,可以发现他们的运动轨迹都是圆形。

业界主流的波形函数主要分为正弦波(Sinusoids Wave)和 Gerstner 波(Gerstner Wave) 两种。正弦波作为比较早期的方案,特点是平滑、圆润,但波峰过于对称、缺乏物理真实感,目前在水体渲染领域已经很少直接使用,业界往往青睐于使用它的进化版 Gerstner 波。

正弦波与格斯特纳波

核心原理与数学表达

Gerstner 波(Gerstner Wave) 也常被称为 Trochoidal Wave,在流体动力学中,其为周期表面重力波(periodic surface gravity waves)的欧拉方程的精确解,由 Gerstner 在 1802 年初次发现,并在 1863 年由 Ranine 独立重新发现。在 1986 年由 Fournier 等人引入水体渲染领域。

Unity下实现的基于Gerstner波的水体

Gerstner Waves 的关键在于:水面上的粒子不仅仅是在上下运动,而是在做圆周运动。

  • 顶点在向波峰移动的同时,在水平方向上也会向波峰靠拢。
  • 物理特征: 波峰变得更加尖锐(Sharp crests),波谷变得更加宽阔平坦(Flat troughs),这更符合真实海洋的物理特性。

对于一个初始位置为 的顶点,其偏移后的位置 计算如下:

其中 是频率与时间的组合 相位

关键参数

  • 振幅 (Amplitude, ):波浪的高度。
  • 方向 (Direction, ):波浪传播的二维向量。
  • 波长 (Wavelength, ):两个波峰之间的距离。
  • 陡度 (Steepness, ):控制波峰的尖锐程度。通常 ,其中 是波的数量。如果 过大,会导致模型顶点自相交(Self-intersection)。
  • 相位/速度 (Phase/Speed, ):波随时间移动的速度。

优点:艺术控制力强(通过 控制海面”愤怒”程度)、计算开销低(纯 Vertex Shader)、易于叠加。
缺点:当 总和超过 1 时会发生顶点自相交;尖锐波峰依赖足够的网格细分。

GerstnerWave基础版
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
float3 GerstnerWave(float3 position, float steepness, float wavelength, float speed, float direction, inout float3 tangent, inout float3 binormal)
{
direction = direction * 2 - 1;
float2 d = normalize(float2(cos(3.14 * direction), sin(3.14 * direction)));
float k = 2 * 3.14 / wavelength;
float f = k * (dot(d, position.xz) - speed * _Time.y);
float a = steepness / k;

tangent += float3(
-d.x * d.x * (steepness * sin(f)),
d.x * (steepness * cos(f)),
-d.x * d.y * (steepness * sin(f))
);

binormal += float3(
-d.x * d.y * (steepness * sin(f)),
d.y * (steepness * cos(f)),
-d.y * d.y * (steepness * sin(f))
);

return float3(
d.x * (a * cos(f)),
a * sin(f),
d.y * (a * cos(f))
);
}

void GerstnerWaves_float(float3 position, float steepness, float wavelength, float speed, float4 directions, out float3 Offset, out float3 normal)
{
Offset = 0;
float3 tangent = float3(1, 0, 0);
float3 binormal = float3(0, 0, 1);

Offset += GerstnerWave(position, steepness, wavelength, speed, directions.x, tangent, binormal);
Offset += GerstnerWave(position, steepness, wavelength, speed, directions.y, tangent, binormal);
Offset += GerstnerWave(position, steepness, wavelength, speed, directions.z, tangent, binormal);
Offset += GerstnerWave(position, steepness, wavelength, speed, directions.w, tangent, binormal);

normal = normalize(cross(binormal, tangent));
}

工程实战:5 层叠加架构

在 AAA 项目中,单一 Gerstner 远不能满足需求,通常会叠加多组波形成层次。Sea_v1 选择了 4 组标准波 + 1 组极坐标波 的组合:

层级 函数 特征 视觉作用
极地波 GerstnerPolar 相位用 length(safeDir) 而非 dot(D, P) 制造非线性的暗流、漩涡感
微波 1 Gerstner 高频小振幅,方向 +30° 表面细碎波纹
微波 2 Gerstner 高频小振幅,方向 −30° 与微波 1 交叉,避免方向单一
巨浪 1 Gerstner 低频大振幅,方向 +60° 远海宏观起伏
巨浪 2 Gerstner 低频大振幅,方向 −60° 与巨浪 1 交叉

极坐标波的关键差异(来自 Water.hlsl):

1
2
3
// 标准 Gerstner: phase = dot(normDir, vertex.xz) * k - sqrt(g·k) * t
// 极坐标 Gerstner: phase = k * (-length(safeDir) - w * t)
float phase = k * (-length(safeDir) - w * _time);

dot 换成 length 后,相位等高线从”平行直线”变成”同心圆”,水面波形不再有明显方向性,呈现旋涡式涌动。这是制造非线性混沌感最廉价的技巧之一。

每一层都有独立的”开始衰减距岸距离”和”完全压平距岸距离”,形成了从远海巨浪 → 近岸微波 → 沙滩平镜的自然过渡。最后整体再叠加一个全局衰减系数:

1
2
3
4
5
// SeaVertexDescription.hlsl
float shoreMask_v = saturate(1.0 - (trueWaterDepth / max(_ShoreFlattenDistance, 0.01)));
float waveAttenuation = lerp(1.0, 0.1, shoreMask_v); // 越靠岸越平 (保留 10% 防死板)
totalDisplacement *= waveAttenuation;
normalDirectionOffset *= waveAttenuation;

岸边法线也要单独抹平,否则只压平高度但保留法线扰动会出现”看起来像玻璃但反射极乱”的诡异质感:

1
2
// SeaSurfaceDescription.hlsl
blendedWaterNormal = lerp(blendedWaterNormal, float3(0, 0, 1), shoreMask * 0.85);

系统耦合:接入 SWE 的物理位移层 (Wave Pinch)

🔗 此处是渲染层与物理层的第一个耦合点:5 层 Gerstner 是先验的、无法响应外部物体推动的程序化波形;要表达”角色涉水推开水面”这种真实交互,必须再叠加一层来自浅水方程的位移。该位移由 SWE 控制器每帧通过 Shader.SetGlobalTexture 广播为 _SWE_StateTex_SWE_NormalTex 两张全局纹理(见配套笔记《水体交互》)。

生产实现把 SWE 高度场作为第 6 个波层注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SeaVertexDescription.hlsl 节选
{
float2 sweUV_v = (wsPos.xz - _SWECenterWorldPos.xz) / _SWESimulationSize + 0.5;

// 边界遮罩 bm: 在 SWE 网格边缘 8% 范围内做 smoothstep 平滑过渡
// 否则水面会在网格边缘"陡然出现波纹",看起来像玻璃罩
float bm_v = smoothstep(0.0, 0.08, sweUV_v.x) * smoothstep(1.0, 0.92, sweUV_v.x)
* smoothstep(0.0, 0.08, sweUV_v.y) * smoothstep(1.0, 0.92, sweUV_v.y);

if (bm_v > 0.001)
{
float h = SAMPLE_TEXTURE2D_LOD(_SWE_StateTex, sampler_SWE_StateTex, sweUV_v, 0).r;
float2 normXZ = SAMPLE_TEXTURE2D_LOD(_SWE_NormalTex, sampler_SWE_NormalTex, sweUV_v, 0).rg;

// ❶ 高度叠加 (减去静水基线 0.5 得到相对偏移)
float sweHeight = (h - 0.5) * _SWEHeightMultiplier * bm_v * waveAttenuation;
totalDisplacement.y += clamp(sweHeight, -_SWEHeightClamp, _SWEHeightClamp);

// ❷ Wave Pinch: 沿法线方向收缩顶点 → 模拟 Gerstner 波峰尖锐化
float horizontalPinch = _SWEWaveSharpness * abs(sweHeight) * 0.5 * bm_v;
totalDisplacement.xz -= normXZ * horizontalPinch;
}
}

两个值得展开的设计要点:

(a) 边界遮罩 bm:在 SWE 网格的物理边界 8% 范围内做 smoothstep 渐变,避免水面在边缘”突然出现波纹”。视觉上 SWE 的影响范围就该是无限大的,不能让玩家看到一个明显的”圆形/方形涟漪边界”。

(b) Wave Pinch(波峰挤压)—— 这是本节最关键的耦合技巧:SWE 解出来的本来是”软糖一样圆滑”的高度场,没有 Gerstner 的尖锐波峰特征。但只要在顶点位移阶段沿着 SWE 法线的反方向做水平拉扯,就能把圆滑波形挤压成尖锐波形——这正是把 Gerstner 摆线运动数学反推回来用在 SWE 上。读者可以对比上面 Gerstner Wave 的水平偏移项 ,两者思路完全一致。

_SWEHeightMultiplier 默认极小(0.05)。SWE 应作为法线主导而非位移主导。如果 heightMultiplier 设大,SWE 会和 Gerstner 高度叠加导致顶点错乱、波峰穿插。

宏观汪洋:基于 FFT 的频域生成方法

🏞️
知乎
快速傅里叶变换(FFT)超详解

当我们需要表现无尽的汪洋大海时,Gerstner 会因为波浪数量受限而显出重复感。如果说在 Gerstner Waves 中,我们是在”微观”层面手动混合几个特定的波浪,那么 海洋 FFT 则是从”宏观”统计学层面生成整片汪洋大海的解决方案。

核心思想:从统计学到频域,再到空间域

真实世界的海洋是由无数个不同方向、不同频率的波浪叠加而成的,直接在顶点着色器里叠加成百上千个正弦波是不现实的。FFT 海洋的绝妙之处在于它转变了思路:

  • 统计学模型(Phillips Spectrum):物理学家通过对真实海洋的观测,总结出了基于风速风向的能量分布公式(通常是 Phillips 频谱或 Tessendorf 模型)。
  • 频域生成(Frequency Domain):在频域纹理中生成这些海浪的振幅和相位,每一个像素代表的是一种特定频率和方向的波浪能量,而不是实际的高度。
  • 逆快速傅里叶变换(IFFT):核心算法。将频域数据通过 IFFT 转换回空间域(Spatial Domain),结果就是一张可以随时间无缝循环的高度图位移图

标准渲染管线

FFT 海洋的实现高度依赖 GPU Compute Shader:

  1. 初始化阶段:根据初始的风速、风向参数,生成初始的频域频谱 (参数改变时才需要重新计算)。
  2. 每帧更新阶段
    • 根据物理色散关系(Dispersion Relation)推进频谱时间演进,生成当前帧的
    • 使用蝶形算法(Butterfly Algorithm)或 Cooley-Tukey 算法对 执行 IFFT;
    • 输出垂直高度图、水平位移图(用于制造尖锐波峰)、法线/折叠图(用于生成白沫 Foam)。
  3. 渲染阶段:在 Vertex Shader 中采样位移图做顶点偏移;在 Fragment Shader 中采样法线图做 PBR 光照计算;利用折叠图(Jacobian 行列式)混合白沫材质。

落地到项目中时,通常采用多级联 FFT(Cascaded FFT),即生成几张不同分辨率、覆盖不同物理范围的位移图(一张捕捉低频巨浪,一张捕捉高频涟漪),然后将它们叠加采样——与级联阴影(CSM)的思路一致。

FFT 与 Gerstner Waves 的对比评估

特性 海洋 FFT Gerstner Waves
真实度 极高。基于真实海洋统计学,拥有无尽的细节和极其自然的混沌感。 较高。受限于波的数量,容易看出重复的模式。
艺术控制力 较弱。主要通过调整”风速”、”风向”等宏观参数来影响整体,难以精确控制单个波浪的位置。 极强。可以精确控制每一个波的大小、方向、甚至速度。
性能开销 较高。每帧需要大量的 Compute Shader 计算(即使有贴图复用和 LOD 策略)。 极低。纯 Vertex Shader 顶点位移,极其轻量。
适用场景 3A 级写实航海游戏、深海/远洋环境、电影级渲染。 卡通渲染水面、近岸/河流、性能敏感的移动端项目。
物理交互兼容性 难以叠加 SWE 等物理交互(位移图本身已是统计意义的合成结果) 易于叠加 SWE(如本文 Wave Pinch 方案)

至此,几何与波形动力学三个尺度已经覆盖完毕。接下来进入光学层。


第二部分:光学与材质表现

水面顶点位置定下来之后,每个 fragment 还要解决”光是怎么和水交互的”。这一部分按光路顺序展开:进入水体的光被吸收形成深浅渐变 → 部分被反射回观察者 → 部分折射进入水下 → 在水中散射呈现透光感 → 在水底汇聚形成焦散。

水体吸收:深度与色彩渐变 (Beer-Lambert Law)

水体之所以呈现蓝色/绿色,是因为水分子对不同波长的光吸收率不同(红光最先被吸收)。

  • 比尔-朗伯定律 (Beer-Lambert Law):光强随穿透深度呈指数衰减,即
  • 实现方案:通过 SampleSceneDepth 获取屏幕深度,减去当前顶点深度,计算出视线在水下的”穿透距离”。根据此距离,在浅水色 (Shallow Color)深水色 (Deep Color) 之间进行平滑插值或指数过渡。

水的深度采样方法

反射系统:菲涅尔、平面反射与双重衰减

菲涅尔效应与 BRDF

水面具有极强的菲涅尔现象:垂直看清澈见底,平视时则像一面镜子。在工程中,高光不仅受菲涅尔控制,还可以加入双重几何环境衰减(Dual-Shield Attenuation)

  1. 距离抑制:防止近距离看水时被强高光亮瞎。
  2. 深度抑制:极浅的水(如沙滩水洼)无法形成完美全反射,高光被强行切断。

屏幕空间反射 (SSR) 与平面反射

BoatAttack/…/PlanarReflections.cs

平面反射的核心思路是架设一台镜像相机:以水面为对称面把主相机翻转过去,渲染到一张 RT,再让水面 Shader 把这张 RT 当作 reflection probe 采样。三个关键点:

  1. CalculateReflectionMatrix 构造关于平面的反射矩阵
  2. CalculateObliqueMatrix 构造斜投影矩阵,使近裁剪面与水面对齐——这样水面以下的物体被自动裁剪掉,无需手写 clip
  3. 把质量等级(LOD bias / Shadow / Fog)临时降低,反射 RT 不需要主相机的全部精度

SSR 则不依赖额外相机,直接在屏幕空间做光线步进。性能更好但只能反射屏幕内可见的物体——通常作为平面反射的补充。

折射系统:抓屏扭曲与防穿帮伪影修复

  • 抓屏扭曲:采样摄像机的不透明纹理 (Camera Opaque Texture),结合水面法线偏移 UV,模拟折射扭曲。
  • 致命伪影:如果直接扭曲 UV,会导致水面边缘吸附并扭曲水面以上的物体(如玩家的腿)。

折射伪影固定折射

修复算法 —— 深度回退:在扭曲后重新采样一次深度。如果发现扭曲后的 UV 采样到了水面前方的物体(深度差小于 0),则强行回退到未扭曲的原始 UV。

修复伪影

Water.hlsl 里的 ColorBelowWater_float 是这套修复的生产实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void ColorBelowWater_float(
float4 _screenPos, float2 _uvOffset, Bindings_ColorBelowWater_float IN,
out float2 uvFixed_1, out float depthDiff_2)
{
float2 baseUV = _screenPos.xy / _screenPos.w;
float2 offsetUV = (_screenPos.xy + _uvOffset) / _screenPos.w;

// 像素中心对齐,避免亚像素采样产生闪烁
float2 screenSize = float2(_ScreenParams.x, _ScreenParams.y);
float2 pixelSize = float2(1.0 / _ScreenParams.x, 1.0 / _ScreenParams.y);
float2 alignedOffsetUV = (floor(offsetUV * screenSize) + 0.5) * abs(pixelSize);
float2 alignedBaseUV = (floor(baseUV * screenSize) + 0.5) * abs(pixelSize);

// 关键修复: 用扭曲后 UV 检查深度差
float rawDepthDiff;
Unity_SceneDepthDifference_Raw_float(rawDepthDiff, float4(alignedOffsetUV, 0.0, 1.0), IN.WorldSpacePosition);

// 如果扭曲后 UV 命中了水面前方物体 (depthDiff < 0) → 回退到未扭曲 UV
float2 finalUV = rawDepthDiff >= 0.0 ? alignedOffsetUV : alignedBaseUV;
Unity_SceneDepthDifference_Linear01_float(depthDiff_2, float4(finalUV, 0.0, 1.0), IN.WorldSpacePosition);
uvFixed_1 = finalUV;
}

透光感:次表面散射 (SSS) 的厚度伪造

次表面散射是赋予海浪”果冻般”通透感的灵魂,通常出现在波峰和逆光处。

  • 半兰伯特包裹光照 (Wrapped Lighting):避免昂贵的体积积分,直接对主光源和法线的点积进行空间映射:
  • 厚度伪造:提取 Gerstner 波浪计算出的高度(波浪越高,浪尖越薄),生成动态厚度遮罩。
1
2
3
4
5
6
// Water.hlsl - URPWaterTranslucency_float (生产实现节选)
float thicknessPower = pow(abs(dot(mainDir, wsNormal) * 0.5 + 0.5), _Thickness);
float selfShadowFactor = _SelfShadowReduction * saturate(smoothstep(-_SelfShadowSmooth, 1.0, dot(_MainLightDir, IN.WorldSpaceNormal)));
// 波峰特殊增益 (波越高 → 透光越强)
float maxTransFactor = max(thicknessPower + selfShadowFactor, _WavesMasks * _WavesMasksPower);
float3 translucencyLight = (_GlobalPower * maxTransFactor) * normalize(mainCol * clamp(shadowAtten + _ShadowReduction, 0.001, 1.0));

水下焦散:三轴投影与双层极小值 (Min) 混合

阳光穿过波浪表面后在水底汇聚形成的光斑。两个关键技术:

  • 三轴投影 (Triplanar Mapping):由于水底地形崎岖,普通的 2D 投影会产生拉伸。利用绝对世界坐标和法线权重,在 X、Y、Z 三个平面分别投影焦散贴图。
  • 双层极小值混合 (Dual-Layer Min Blending):采用两张速度和缩放不同的焦散图,利用 min(tex1, tex2) 进行混合。这能最大程度保留光斑交叉时的锐利边缘,形成高度真实的水下光网。
1
2
3
4
5
6
7
8
9
10
11
12
13
// Water.hlsl - Caustic_float (生产实现节选)
float3 offset1 = float3(animTime * 0.76, 0.0, 0.0);
float3 offset2 = float3(animTime * -1.07, animTime * -1.07, animTime * -1.07);

// 三轴投影权重 = |normal|^hardness 然后归一化
float3 weights = SafePositivePow_float(abs(_WorldNorm), safeHardness);
weights /= dot(weights, float3(1.0, 1.0, 1.0));

float4 tri1 = SAMPLE(uv1.zy) * weights.x + SAMPLE(uv1.xz) * weights.y + SAMPLE(uv1.xy) * weights.z;
float4 tri2 = SAMPLE(uv2.zy) * weights.x + SAMPLE(uv2.xz) * weights.y + SAMPLE(uv2.xy) * weights.z;

// 关键: min 混合保留锐利边缘
OutVector = saturate(min(tri1, tri2) * _Strength * saturate(distAtten * shadowAtten));

不能用 +lerp 混合两层焦散,相加会让光斑变成”糊成一片”的亮斑,失去真实焦散的网状结构。min 才能保留两层光斑边缘交叠时的尖锐细节。

至此光学六大子模块完成。下一部分回到水面表层,看泡沫与法线如何把这些底层信号”包装”成最终的视觉质感,并最终把渲染层和物理交互层串成闭环。


第三部分:表层细节与系统整合

水面渲染的最后一公里,是表层的泡沫与法线细节,以及与外部物理系统的通信。本部分包含三个小节:泡沫系统(含 SWE 流速白沫这一耦合点)、RNM 法线重定向技术、最终的渲染-物理全局通信解耦架构。

动态泡沫系统 (Dynamic Foam)

泡沫并非简单的贴图覆盖,在高级水体中,它被分为多套独立的生态、并拥有自己的物理体积。在 FFT 海洋中白沫通常通过计算雅可比行列式(Jacobian Determinant,反映水面的折叠程度)来生成;在基于 Gerstner 的体系中,则通过高度与深度双重驱动;当水面接入物理交互后,还会再加一层由 SWE 流速驱动的动态白沫。三层互不干扰,分别由几何、屏幕空间、流体物理三种独立信号驱动。

浪尖与近岸的程序化生成

前两层属于纯程序化白沫:

  • 浪尖白沫 (Sea Foam):主要由 Gerstner 几何高度驱动。提取波浪的位移 Y 值,并在波峰破裂处配合 Flowmap 产生涌动感。
  • 近岸泡沫 (Shore Foam):主要由水深和噪声驱动。读取深度,在浅水区结合柏林噪声 (Perlin Noise) 形成边缘的不规则的冲刷拖尾和堆积。

这两层只依赖水面 Shader 自身能拿到的信息(高度场、深度图、Flowmap),不需要任何外部系统接入。

系统耦合:基于 SWE 流速的动态白沫

🔗 此处是渲染层与物理层的第二个耦合点:泡沫的第三层来自 SWE 状态纹理的 GB 通道,也就是水平动量 。当玩家奔跑、船体冲过、鸭子游过时,对应位置的动量长度会瞬时变大,自动触发白沫生成——这是程序化海面无法给予的”实际被推动”反馈。

SWE 流速白沫的核心计算非常简洁——动量长度过阈值就出白沫,再用泡沫贴图作 mask 修饰碎泡形态:

1
2
3
4
5
6
7
8
9
10
11
12
13
// SeaSurfaceDescription.hlsl 节选(SWE 流速白沫部分)
float2 momentum = SAMPLE_TEXTURE2D(_SWE_StateTex, sampler_SWE_StateTex, pinchedUV).gb;
float speed = length(momentum);
float threshold = max(_SWEFoamParams.x, 0.01);
float sweFoamWeight = smoothstep(threshold, threshold + 2.0, speed) * _SWEFoamParams.y * bm;

// 用泡沫贴图的 R 通道再过滤一道 → 自然碎泡形态
float foamMask = SAMPLE_TEXTURE2D(_Sea_Foam_Texture, SamplerState_Linear_Repeat_Aniso8, pinchedUV).r;
sweFoamWeight *= foamMask;

// 波峰加成: 法线越斜的地方泡沫越多 (波峰尖端容易碎裂)
float peakFoamBonus = length(rawNormXZ) * _SWEWaveSharpness * 0.5 * sweFoamWeight;
sweFoamWeight = saturate(sweFoamWeight + peakFoamBonus);

pinchedUV 来自下一节 RNM 部分的二次采样——SWE 白沫与 SWE 法线注入在生产代码里其实共享同一个 [branch] 块以节省采样开销,这里为说明清晰拆开展示。

三层白沫最终通过加法叠加成 fragment 输出的 alpha,并送入下一节 RNM 系统获得”立体感”。

质感提升:法线重定向 (RNM) 技术

普通的泡沫仅仅是颜色的 Alpha 混合,看上去就像贴在水面上的一张扁平的纸。真实水沫是有体积的——这需要 Reoriented Normal Mapping,把泡沫自身的独立法线贴合并扰动底层的巨大波浪法线:

(其中 为底层水面法线修正量, 为泡沫细节法线修正量)。

1
2
3
4
5
6
7
// 基于 Reoriented Normal Mapping 的法线混合
float3 BlendNormalsReoriented(float3 baseNormal, float3 detailNormal)
{
float3 t = baseNormal + float3(0.0, 0.0, 1.0);
float3 u = detailNormal * float3(-1.0, -1.0, 1.0);
return (t / t.z) * dot(t, u) - u;
}

应用 1:泡沫的物理体积感

RNM 最初的用途——把泡沫的独立法线混入水面法线栈。这一步让泡沫完美融入了 PBR 光照管线:泡沫不仅有了漫反射,还能阻挡水面的高光(将 Smoothness 降为粗糙),并在阳光下产生符合泡沫自身微观结构的立体高光点,彻底拉开了与廉价水体的质量差距。

应用 2:SWE 法线注入与 Pinched UV 二次采样

🔗 RNM 不仅适用于泡沫——任何”在已有法线表面叠加另一层细节法线”的场景都可以用 RNM。前面 Wave Pinch 在顶点阶段已经让 SWE 几何与 Gerstner 几何融为一体;fragment 阶段还需要把 SWE 法线叠加到 Gerstner 法线栈,让交互造成的水波在光照上正确反映。这是 RNM 的另一个典型应用,也是渲染-物理耦合在 fragment 端的延续。

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
// SeaSurfaceDescription.hlsl - SWE 法线 RNM 注入
{
float2 baseSweUV = (wsPos.xz - _SWECenterWorldPos.xz) / _SWESimulationSize + 0.5;
float bm = smoothstep(0.0, 0.08, baseSweUV.x) * smoothstep(1.0, 0.92, baseSweUV.x)
* smoothstep(0.0, 0.08, baseSweUV.y) * smoothstep(1.0, 0.92, baseSweUV.y);

[branch]
if (bm > 0.001 && _SWENormalStrength > 0.001)
{
// ❶ 第一次采样: 取出原始法线方向 (用作"波峰偏移指向")
float2 rawNormXZ = SAMPLE_TEXTURE2D(_SWE_NormalTex, sampler_SWE_NormalTex, baseSweUV).rg;

// ❷ Pinched UV: 沿法线反方向偏移 UV, 与 vertex 阶段 Pinch 配合
float2 pinchedUV = baseSweUV - rawNormXZ * (_SWEWaveSharpness * 0.01) * bm;

// ❸ 第二次采样: 在偏移后的 UV 处采样 → 得到"被尖锐化后"的法线
float2 sweNormXZ = SAMPLE_TEXTURE2D(_SWE_NormalTex, sampler_SWE_NormalTex, pinchedUV).rg;

// ❹ Y 分量重建 (前提: 法线必朝上, saturate 防止微小负数引爆 sqrt)
float ySqr = saturate(1.0 - dot(sweNormXZ, sweNormXZ));
float3 sweNormalWS = float3(sweNormXZ.x, sqrt(ySqr), sweNormXZ.y);

// ❺ 强度加权 + 转切线空间
float w = bm * _SWENormalStrength;
sweNormalWS.xz *= w;
sweNormalWS = normalize(sweNormalWS);

float3 sweNormalTS = normalize(CalculateWaveTangentNormal(
sweNormalWS, IN.WorldSpaceTangent, IN.WorldSpaceBiTangent, wsNormal));

// ❻ RNM 混合到 Gerstner 法线栈 (关键: 不是 lerp 替换, 而是 RNM 叠加)
blendedWaterNormal = BlendNormalsReoriented(blendedWaterNormal, sweNormalTS);
}
}

这段代码同时贯穿了三个关键设计:

(a) Pinched UV 二次采样:注意第 ❶ 与 ❸ 步——采样了同一张纹理两次。第一次取出法线方向把它作为”波峰偏移向量”反向偏移 UV,第二次再到偏移后的位置采样。这与顶点阶段的 Wave Pinch 是同一个原理在 fragment 上的对偶——把”软糖法线场”映射成”Gerstner 风格法线场”的几何手段。

(b) 法线 Y 分量重建:第 ❹ 步把法线 RT 中只存了 X、Z 的法线重建出 Y 分量。saturate 防止极端情况下浮点误差让 1 - dot 变成微小负数引爆 sqrt。这个技巧让法线 RT 从 RGBHalf 降到 RGHalf,显存直接砍半

(c) RNM 而非简单替换:第 ❻ 步用 BlendNormalsReoriented 把 SWE 法线叠加到已有的 Gerstner 法线栈,既保留了 Gerstner 大尺度波形的视觉,又让 SWE 的”小尺度交互细节”贴合在大波之上。如果直接用 lerp(gerstnerN, sweN, w) 会让大波消失,水面在交互区域瞬间变得”死板”——这与”泡沫不替换水面法线、而是叠加”是同一个工程哲学。

[branch] 标注的意义bm > 0.001 在网格外是 0、网格内是 1 的”空间分块”判断,整个 warp 内大概率走同一分支。[branch] 提示编译器生成动态分支而非展开两路计算,能在水面远超 SWE 网格的场景下省下大量片段开销。

系统解耦:水体渲染与物理交互的全局通信

🔗 此处是渲染层与物理层的第三个耦合点——也是收束全文的全局架构视角

前面已经看到 Gerstner 几何、SWE 流速白沫、SWE 法线 RNM 三个具体的耦合落点。但渲染端到底如何”知道”这些 SWE 数据的存在?反过来,物理端如何”知道”水面最新的高度让浮力计算可以进行?这两个问题的答案构成了水体系统的完整闭环。

渲染端:通过 Global 变量无缝接收交互数据

水面 Shader 不持有任何 SWE 控制器引用、不绑定任何特定的状态纹理实例——它声明全局变量,由 SWE 控制器在 Update 末尾通过 Shader.SetGlobal* 广播:

1
2
3
4
5
6
7
8
9
10
11
// SeaInput.hlsl - 渲染端只需声明这些全局变量
float3 _SWECenterWorldPos; // 网格世界中心 (x, waterY, z)
float _SWESimulationSize; // 物理边长 = resolution * dx
float _SWEHeightMultiplier; // 顶点高度叠加强度
float _SWEHeightClamp; // 高度叠加上限 (防破面)
float _SWENormalStrength; // 法线 RNM 混合权重
float _SWEWaveSharpness; // 波峰尖锐度 (Gerstner Pinch)
float4 _SWEFoamParams; // x=Speed Threshold, y=Intensity

TEXTURE2D(_SWE_StateTex); SAMPLER(sampler_SWE_StateTex); // ARGBFloat (R=h, G=hu, B=hv)
TEXTURE2D(_SWE_NormalTex); SAMPLER(sampler_SWE_NormalTex); // RGHalf (世界法线 X, Z)

这种设计的好处是:

  1. 多水面共享:场景里若有多片水域(湖、河、海),共用一个 SWE 控制器即可,所有水面 Shader 自动收到同一份数据。
  2. 零绑定开销:水面动态创建/销毁、材质动态切换都无需调整 Material 引用关系。
  3. 替换自由:调试时可以临时替换为另一个 Mock 控制器(比如手动填充测试纹理),渲染端无感。

物理端:通过 Async GPU Readback 实现浮力

反向通信通常通过 Async GPU Readback 完成。在物理层需要让船只、浮标等刚体感知最新水面高度时,CPU 端发起异步回读请求,避免阻塞 GPU:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 概念性代码示意 (浮力探针的异步回读)
private void RequestBuoyancySample()
{
AsyncGPUReadback.Request(stateRT, 0, request =>
{
if (request.hasError) return;
var heightData = request.GetData<float>(); // R 通道

// 在每个浮力探针的像素位置采样
foreach (var probe in _buoyancyProbes)
{
Vector2 px = probe.WorldToPixelXZ(_currSnapPos, dx, resolution);
float h = SampleBilinear(heightData, px);
float waterY = waterSurfaceY + (h - 0.5f) * heightMultiplier;

// 浮力 = 浸没深度 × 排水体积 × 流体密度 × g
float submersion = Mathf.Max(0, waterY - probe.transform.position.y);
probe.rigidbody.AddForceAtPosition(
Vector3.up * submersion * probe.volume * 1000f * 9.81f,
probe.transform.position);
}
});
}

Async GPU Readback 的特点是”晚一两帧但不卡 GPU”。对浮力这种感官没那么敏感的物理量足够用。如果需要更精确的 CPU 同步采样,可以在 CPU 端并行跑一份轻量化的 Gerstner 数学模型(这种方案在 Unity 的 BoatAttack 项目中有典型实现)。

闭环架构

把渲染端 SetGlobal 接收 + 物理端 Readback 回读结合起来,整个水体系统形成完整闭环:

Sea Shader (GPU) Gerstner + Wave Pinch + SWE Normal + 3-Layer Foam SWEController 4-Kernel Pipeline (Shift/Inject/Update/Normal) Buoyancy / Rigidbody (CPU 物理层) WaterInteractor SplatVelocityProvider Shader.SetGlobal* (本帧) AsyncGPUReadback (隔一帧) ComputeBuffer + Splat RT forces 物理引擎驱动 (玩家输入 / AI)
Fig 1.

水体渲染与物理交互流程图

数据走向解读

  1. 下行 (GPU → CPU):每帧 SWE 控制器算出最新状态 → Shader.SetGlobal* 广播给所有 Sea Shader → 同时通过 Async GPU Readback 把高度场回读给浮力系统;
  2. 上行 (CPU → GPU):玩家输入或 AI 驱动 Rigidbody → 浮力反作用让物体在水面上颠簸 → 物体的位置/速度通过 WaterInteractor (轨道 B) 与 SplatVelocityProvider (轨道 A) 反推回 SWE → 下一帧物理推进;
  3. 闭环:物体推动水面 → 水面反推物体 → 物体推动水面…… 形成稳定的物理-渲染自洽循环。

整个系统的”主动接口”只有 SWE 控制器的 Update() 与刚体的 FixedUpdate() 两处,其它所有组件都是被动响应。这种”主动调度少、被动响应多”的设计是大型系统保持可维护性的核心原则——每个组件只关心自己输入/输出端口的数据契约,不关心数据从哪里来、到哪里去。


总结

一个生产级水面 Shader 不是六大模块的简单堆砌,而是它们之间精巧的耦合关系——折射 UV 必须用扭曲后的深度回退、泡沫法线必须用 RNM 而非 alpha 混合、SWE 物理交互必须做 Wave Pinch 才能与 Gerstner 共存。这些耦合关系决定了一个水体 Shader 的最终质感。本文按模块化分层组织:

第一部分(几何与波形动力学) 解决了”水面长什么样”的问题——Flowmap 提供微观流动法线,5 层 Gerstner 提供中尺度程序化波形,FFT 适用于宏观汪洋。Wave Pinch 作为系统耦合点,将 SWE 物理位移融入 Gerstner 波形栈。

第二部分(光学与材质表现) 解决了”光与水如何交互”的问题——按 Beer-Lambert 吸收、菲涅尔反射、深度回退折射、半兰伯特 SSS、双层 min 焦散五个子模块依次展开。每个子模块都有自己工程级的细节修复(折射的深度回退、焦散的 min 混合是其中最有代表性的两处)。

第三部分(表层细节与系统整合) 解决了”水面如何与世界互动”的问题——三层泡沫(其中SWE 流速白沫是第二个耦合点)+ RNM 法线重定向(SWE 法线 RNM 注入是第三个耦合点)+ 全局通信解耦 三块共同构成闭环。

跨模块的工程判据可以总结成几条朴素原则:

  • 新法线叠加旧法线,永远用 RNM 而非 lerp——保留低频特征
  • 新位移叠加旧位移,永远是相加而非替换——但要做衰减以避免越界
  • 新泡沫叠加旧泡沫,永远是 alpha 合成——但驱动信号要互相正交(几何 / 深度 / 流速)
  • 跨系统的数据交换,永远走 SetGlobal* 与 AsyncGPUReadback——不持有引用、不绑定实例

这些原则没有一条是 Shader 本身的限制——它们都是大型系统模块化设计的思路,只是在水面渲染这个具体的场景里被全部凑齐。