《水面与海洋渲染:FFT 频谱、Gerstner 波与菲涅尔》

海洋渲染是实时图形学的经典难题:既有大尺度波动的物理频谱,又有微观表面的反射折射与次表面散射。本文讲解 Gerstner 波的解析构造、Tessendorf FFT 海洋频谱、平面反射与折射的工程实现、Schlick 菲涅尔近似、水体次表面散射与泡沫生成,以及深度、透明度与水下效果的处理,给出从湖面到开放海洋的完整方案。

水是几乎所有 3D 场景都绕不开的元素,也是渲染里最「不老实」的材质:它既像镜子(反射天空与岸边),又像玻璃(折射水下的世界),还自身参与散射(浅水透光、深水发绿)。更麻烦的是,水面不是平面——它随时间起伏,且不同尺度的波(风浪、涌浪、涟漪)遵循不同的物理规律。本文从波浪几何讲到水面着色,串起 https://plumephp.com/graphics-real-time-gi/ 中涉及的反射、散射知识,给出一套可落地的海洋渲染方案。


一、海洋渲染概览

一句话:海洋渲染 = 几何(波浪)+ 着色(反射/折射/散射)+ 效果(泡沫/水下)三部分,难点在尺度跨度极大。

1.1 尺度分解

真实的海洋包含多个尺度的运动,工程上按频率分层处理:

尺度波长物理来源实现手段
涌浪(Swell)100~1000 m远处风暴传播FFT 低频 / 手工方向波
风浪(Wind wave)1~100 m本地风FFT 主频段
涟漪(Ripple)0.01~1 m局部扰动法线贴图 / 细节 FFT
微观< 1 cm表面张力法线贴图 / 高光

1.2 两条技术路线

  • 解析路线(Gerstner):直接写一组正弦波的叠加,位移顶点。简单、可控、易调参,适合湖面、河道、风格化。
  • 频谱路线(FFT):在频域按海洋学频谱(Phillips / JONSWAP)生成高度场,逆 FFT 得到空间域。物理真实、细节丰富,适合开放海洋。
// C++:海洋系统的分层结构
struct FOceanSettings {
    // 大尺度:FFT 频谱
    int   FFTResolution      = 256;    // 256x256 复数网格
    float PatchSize          = 1000.0f; // 每 patch 覆盖米数
    float WindSpeed          = 12.0f;   // m/s
    float WindDirection      = 45.0f;   // 度
    float Amplitude          = 1.0f;
    float Choppiness         = 1.2f;    // 尖化,制造浪尖

    // 小尺度:细节法线
    float DetailNormalScale  = 8.0f;
    float DetailNormalSpeed  = 0.5f;

    // 着色
    float WaterDepth         = 20.0f;   // 用于吸收/散射
    float FoamThreshold      = 0.6f;
};

一句话:小场景用 Gerstner 省事,大海洋用 FFT 才真实,实际项目常常「FFT 大浪 + 法线贴图细浪」混合。


二、Gerstner 波

一句话:Gerstner 波在正弦波基础上对顶点做水平位移,让波峰收窄、波谷拉宽,得到更接近真实的「尖浪」。

2.1 从正弦到 Gerstner

最简单的波是正弦位移 y = A·sin(k·x - ω·t),但它太对称、太「软」。Gerstner 波额外施加水平位移:

x = x0 + Σ (Q_i · A_i · D_i.x · cos(w_i))
y =      Σ (A_i · sin(w_i))
z = z0 + Σ (Q_i · A_i · D_i.y · cos(w_i))
其中 w_i = k_i · dot(D_i, (x0, z0)) - ω_i · t + φ_i

A 振幅、D 波向、k = 2π/λ 波数、ω = √(g·k) 角频率(深水色散关系)、Q 尖化系数(Q = 0 退化为正弦波)。

// GLSL:单个 Gerstner 波对顶点位置与法线的贡献
struct GerstnerWave {
    vec2  direction;   // 归一化波向
    float amplitude;   // A
    float wavelength;  // λ
    float steepness;   // Q, 0..1
    float speed;
};

void gerstnerWave(GerstnerWave w, vec3 pos, float t,
                  inout vec3 displaced, inout vec3 normal) {
    float k = 2.0 * PI / w.wavelength;
    float c = sqrt(9.8 / k);                       // 相速度
    vec2  d = normalize(w.direction);
    float f = k * (dot(d, pos.xz) - c * w.speed * t);
    float a = w.steepness / k;                     // 尖化幅度

    // 水平位移(Gerstner 的核心)
    displaced.x += d.x * a * cos(f);
    displaced.z += d.y * a * cos(f);
    // 垂直位移
    displaced.y += a * sin(f);

    // 解析法线(对位置求偏导)
    float wa = k * a;
    normal.x -= d.x * wa * cos(f);
    normal.z -= d.y * wa * cos(f);
    normal.y -= w.steepness * wa * sin(f);
}

2.2 参数与稳定性

多个 Gerstner 波叠加时,若总 Σ Q_i · k_i · A_i > 1,水平位移会自交(浪尖打结),产生伪影。约束:

Σ (Q_i / (k_i · A_i)) ≤ 1   // 常见约束写法之一

2.3 与 FFT 的分工

维度GerstnerFFT
实现难度低中高
物理真实度近似高(真实频谱)
可控性强(逐波调参)弱(靠频谱参数)
顶点开销每顶点多波求和采样位移图
适用湖、河、卡通水开放海洋、写实

三、FFT 海洋频谱

一句话:Tessendorf 方案在频域用 Phillips 频谱生成海面高度,逆 FFT 得到随时间演化的位移图,是电影级与 3A 海洋的标准做法。

3.1 流程

  1. 生成频域初始谱 h̃₀(k)(Phillips 或 JONSWAP)。
  2. 每帧按色散关系 ω(k) = √(g·k) 演化相位:h̃(k,t) = h̃₀(k)·e^{iωt} + h̃₀*(-k)·e^{-iωt}。
  3. 逆 FFT 得到高度场 h(x,t),同时得到水平位移(Choppy)。
  4. 生成法线、泡沫掩码。
Phillips 频谱(简化):
  P(k) = A · exp(-1/(k·L)²) / k⁴ · |k̂ · ŵ|²
  L = V²/g             // 最大波长,V 风速
  高斯随机数 × √P(k)   → 初始复振幅

3.2 演化与逆变换

// GLSL:在 compute shader 中演化频谱并准备逆 FFT
// h0(k) 与 h0(-k) 由 CPU/初始化阶段生成
vec2 htilde(vec2 k, float t, vec2 h0k, vec2 h0MinusK) {
    float omega = sqrt(9.81 * length(k));
    float cosw = cos(omega * t);
    float sinw = sin(omega * t);
    // 共轭项旋转
    vec2 a = vec2(h0k.x * cosw - h0k.y * sinw, h0k.x * sinw + h0k.y * cosw);
    vec2 b = vec2(h0MinusK.x * cosw + h0MinusK.y * sinw,
                 -h0MinusK.x * sinw + h0MinusK.y * cosw);
    return a + b;
}

3.3 位移与泡沫

在高度场基础上,用 Choppy 参数沿梯度方向偏移顶点,制造尖浪;泡沫由折叠检测(Jacobian 为负)生成——水平位移把顶点「挤」到一起的地方就是浪尖白沫。

// HLSL:从高度场生成位移与泡沫
float3 BuildDisplacement(float2 uv, float choppiness, out float foam) {
    float2 grad = GetHeightGradient(uv);            // ∂h/∂x, ∂h/∂z
    float3 disp = float3(-grad.x * choppiness, GetHeight(uv), -grad.y * choppiness);

    // Jacobian 折叠 → 泡沫
    float2 dDx = GetDisplacementDx(uv);
    float2 dDz = GetDisplacementDz(uv);
    float jacobian = (1.0 + dDx.x) * (1.0 + dDz.y) - dDx.y * dDz.x;
    foam = saturate(1.0 - jacobian * 4.0);          // 折叠越强泡沫越多
    return disp;
}
参数典型值作用
风速 V5~20 m/s决定频谱峰值与浪高
Patch 尺寸250~2000 m决定大浪尺度
Choppiness0.8~1.5浪尖锐度
FFT 分辨率128~512细节上限

一句话:FFT 海洋的「真实感」来自物理频谱 + 色散演化,泡沫则免费地从位移折叠里冒出来。


四、平面反射与折射

一句话:水面是「半反半折」的界面,工程上常用平面反射(镜像相机)与屏幕空间折射(SSR/扭曲)近似。

4.1 平面反射(Planar Reflection)

对平静水面,把相机沿水面对称镜像,渲染一张反射纹理,再按水面法线扰动采样:

// C++:镜像相机矩阵
glm::mat4 BuildReflectionMatrix(const glm::vec4& plane) {
    // plane = (n.x, n.y, n.z, d),水面 y = h
    glm::mat4 R(1.0f);
    R[0][0] = 1 - 2 * plane.x * plane.x;
    R[0][1] = -2 * plane.x * plane.y;
    // ... 其余元素按平面反射公式填充
    R[3][1] = -2 * plane.w * plane.y;
    return R;
}

4.2 屏幕空间反射(SSR)

大水面用平面反射太贵(额外一次场景渲染),可用 SSR 只反射屏幕内可见的物体。缺点是屏幕外信息缺失,需要 fallback 到环境贴图/天空盒。

方案质量成本局限
平面反射高高(重渲场景)仅平面
SSR中中屏幕外缺失
立方体贴图低低无动态
光线追踪最高高需 RT 硬件

4.3 折射

折射用屏幕空间 UV 偏移近似:按水面法线扰动采样「水下场景」的颜色缓冲(Scene Color),偏移量随深度与法线变化:

// GLSL:屏幕空间折射
vec2 ScreenSpaceRefraction(vec2 screenUV, vec3 normal, float depth) {
    // 扰动强度随深度衰减,浅水折射强
    float distortion = RefractionStrength / (depth + 1.0);
    vec2 offset = normal.xz * distortion;
    return texture(sceneColor, screenUV + offset).rgb;
}

一句话:反射用镜像相机或 SSR,折射用屏幕空间 UV 偏移——两者都靠菲涅尔系数按角度混合。


五、菲涅尔与水体着色

一句话:菲涅尔决定「掠射角更反射、正视更透射」,是水面视觉的灵魂,Schlick 近似一行搞定。

5.1 Schlick 菲涅尔

精确的菲涅尔方程需要求解斯涅尔定律,实时渲染用 Schlick 近似:

F(θ) = F₀ + (1 - F₀) · (1 - cosθ)⁵

水的 F₀ ≈ 0.02(折射率 n ≈ 1.33),所以正视水面几乎全透、掠射几乎全反。

// GLSL:Schlick 菲涅尔
vec3 FresnelSchlick(float cosTheta, vec3 F0) {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

vec3 ShadeWater(vec3 N, vec3 V, vec3 L, vec3 reflection, vec3 refraction) {
    float cosTheta = max(dot(N, V), 0.0);
    vec3 F = FresnelSchlick(cosTheta, vec3(0.02));

    // 漫反射水体色 + 高光
    vec3 diffuse = waterAlbedo * (1.0 - F);
    vec3 specular = reflection * F;
    vec3 transmitted = refraction * (1.0 - F) * waterTransmittance;

    vec3 H = normalize(L + V);
    float NdotH = max(dot(N, H), 0.0);
    vec3 sunSpec = sunColor * pow(NdotH, 512.0) * specularStrength;

    return diffuse + specular + transmitted + sunSpec;
}

5.2 水体的 BSDF 组成

分量物理含义近似
反射界面反射菲涅尔 × 环境/SSR
折射透射进水菲涅尔 × 屏幕空间折射
吸收水分子吸收Beer-Lambert,深度相关
散射悬浮颗粒次表面散射近似
高光太阳镜面GGX / Blinn-Phong

5.3 高光与细节法线

太阳在水面的高光需要窄而亮的高光瓣(GGX 粗糙度很低),并叠加多尺度法线(大浪 + 细法线),否则高光会显得过于规则。细节法线可参考 https://plumephp.com/graphics-texture-memory/ 中关于法线压缩与流式加载的讨论。


六、次表面散射与泡沫

一句话:水的「通透感」来自次表面散射——光进入水体后从另一侧逸出,形成浅水透亮、深水幽绿的效果。

6.1 水体吸收

用 Beer-Lambert 按深度衰减,不同波长吸收率不同,这正是水呈青绿色的原因:

// GLSL:按深度吸收,得到水体颜色
vec3 WaterAbsorption(float depth, vec3 waterColor, vec3 extinction) {
    // 视深度(视线穿过的水柱长度)
    float viewDepth = depth / max(dot(N, V), 0.1);
    return waterColor * exp(-extinction * viewDepth);
}
波长吸收系数(相对)穿透深度
红 620 nm高浅(先被吸收)
绿 530 nm中中
蓝 470 nm低深

一句话:红光最先被水吸收,所以深水发青蓝、浅水才透出沙底的红黄色。

6.2 次表面散射近似

水面背光时(太阳在水体另一侧),浪尖会透出光。可用「厚度 × 视线-光线对齐」近似:

// GLSL:简化的水体次表面散射
vec3 WaterSSS(vec3 N, vec3 V, vec3 L, float thickness) {
    vec3 H = normalize(L + N * distortionStrength);
    float VdotH = pow(saturate(dot(V, -H)), power) * scale;
    float backLight = VdotH * saturate(dot(V, L));
    return sssColor * backLight * thickness;
}

6.3 泡沫

泡沫来自 FFT 的折叠掩码(第三节),但还要叠加:

  • 岸边泡沫:靠深度缓冲,浅水区生成。
  • 尾迹泡沫:物体划过水面时写入的额外掩码。
  • 时间衰减:泡沫应逐渐消散,用一张历史缓冲做模糊与衰减。
// GLSL:岸边泡沫(基于深度差)
float ShoreFoam(float sceneDepth, float waterDepth, float threshold) {
    float depthDiff = sceneDepth - waterDepth;   // 越浅越小
    float foam = 1.0 - smoothstep(0.0, threshold, depthDiff);
    return foam * noiseFoam;                     // 叠加噪声打碎
}
泡沫类型来源生成方式
浪尖泡沫波折叠FFT Jacobian
岸边泡沫浅水深度差
尾迹泡沫物体运动历史缓冲 + 时间衰减

七、深度、透明度与水下效果

一句话:水面必须与场景深度交互——折射要读深度、软边缘要读深度、水下雾也要读深度。

7.1 深度采样

水面着色需要两张深度:sceneDepth(不透明几何)与 waterDepth(水面本身),差值决定「水柱厚度」与软边缘。

// GLSL:水面软边缘,避免硬切
float SoftEdge(float sceneDepth, float waterDepth, float fadeDistance) {
    float diff = sceneDepth - waterDepth;
    return saturate(diff / fadeDistance);
}

7.2 水下渲染

相机入水后需要切换:

  • 水下面罩(Underwater Fog):按深度做指数雾,颜色偏青绿。
  • 色差与扭曲:轻微屏幕扭曲模拟水的折射。
  • 焦散(Caustics):水底光斑,用投影贴图或程序噪声。
  • 水下雾:color = mix(color, fogColor, 1 - exp(-depth * density)),密度越大水下越浑浊。

7.3 排序与半透明

水面通常是半透明物体,必须在不透明几何之后渲染,且自身不写深度(或写但用深度预通道)。若水面之上还有粒子/雾,排序更复杂,可参考 https://plumephp.com/graphics-post-processing/ 中的透明合成策略。

效果依赖数据实现
折射Scene Color + 深度UV 偏移采样
软边缘两张深度深度差淡出
水下雾深度指数雾
焦散光照 / 噪声投影或程序化

八、性能与工程实践

一句话:海洋的预算主要花在 FFT(GPU 计算)与反射(额外场景渲染)上,优化核心是分辨率分级与 LOD。

8.1 性能清单

优化收益说明
FFT 分辨率分级4×~16×远处 patch 用低分辨率
多 patch 拼接覆盖大区域相机跟随重定位
反射降分辨率4×1/2 或 1/4
细节法线 LOD2×~4×远处不采样细法线
异步计算隐藏延迟Compute 与渲染重叠
屏幕空间近似省一次渲染SSR 替代平面反射

8.2 多 patch 与无限海洋

开放海洋用「相机跟随的 patch 网格」:中心一块高分辨率,外围若干低分辨率 patch,随时间滚动重定位。关键是patch 之间无缝——相邻 patch 边界处采样同一套频谱,保证连续。

// C++:patch 网格,中心高精度
struct FOceanPatch {
    float CenterX, CenterZ;
    float Size;          // 覆盖范围
    int   FFTRes;        // 该 patch 的 FFT 分辨率(LOD)
};
// 相机移动超过阈值时,把最远的 patch 移到前方(环形缓冲)

8.3 常见坑

  • 波峰自交:Gerstner 尖化系数过大,需按第二节的约束限制。
  • 高光闪烁:细节法线频率过高,需 mipmap 与时间滤波。
  • 反射错位:镜像相机的裁剪面与近平面设置不当。
  • 泡沫拖影 / 接缝可见:历史缓冲未正确重投影,或 patch 边界频谱不一致。

一句话:海洋渲染的工程难点不在单个效果,而在「多尺度、多 patch、多通道」的协同与一致性。


总结

水面与海洋渲染是一条从物理到工程的完整链路,本文的核心结论:

  1. 几何:Gerstner 波用水平位移制造尖浪,适合可控场景;FFT 频谱按海洋学模型生成高度场,适合写实大海洋。
  2. 反射折射:菲涅尔按角度混合反射与透射,反射用镜像相机或 SSR,折射用屏幕空间 UV 偏移。
  3. 着色:Schlick 菲涅尔 + 水体吸收 + 次表面散射近似 + 泡沫,共同构成水的视觉。
  4. 深度交互:折射、软边缘、水下雾、焦散都依赖深度缓冲,水面必须参与正确的渲染顺序。
  5. 工程:FFT 分级、多 patch 滚动、反射降分辨率与异步计算是性能关键。

水的魅力在于「物理正确」与「艺术夸张」之间的平衡——完全物理正确的水未必好看,完全风格化的水又缺乏说服力。理解这套底层原理后,你可以按项目需求在两端之间自由调节。建议继续阅读 https://plumephp.com/graphics-gpu-driven-rendering-lod/ 了解大规模水面网格的 GPU 驱动方案,以及水面在延迟管线中的半透明处理。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机图形学」更多文章

  1. 《物理模拟与渲染集成:固定步长、插值与确定性同步》
  2. 《色彩管理与 HDR 管线:线性工作流、ACES 色调映射与宽色域》
  3. 《可见性剔除系统:视锥、BVH、Hi-Z 与 GPU Driven 剔除》