水是几乎所有 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 的分工
| 维度 | Gerstner | FFT |
|---|---|---|
| 实现难度 | 低 | 中高 |
| 物理真实度 | 近似 | 高(真实频谱) |
| 可控性 | 强(逐波调参) | 弱(靠频谱参数) |
| 顶点开销 | 每顶点多波求和 | 采样位移图 |
| 适用 | 湖、河、卡通水 | 开放海洋、写实 |
三、FFT 海洋频谱
一句话:Tessendorf 方案在频域用 Phillips 频谱生成海面高度,逆 FFT 得到随时间演化的位移图,是电影级与 3A 海洋的标准做法。
3.1 流程
- 生成频域初始谱
h̃₀(k)(Phillips 或 JONSWAP)。 - 每帧按色散关系
ω(k) = √(g·k)演化相位:h̃(k,t) = h̃₀(k)·e^{iωt} + h̃₀*(-k)·e^{-iωt}。 - 逆 FFT 得到高度场
h(x,t),同时得到水平位移(Choppy)。 - 生成法线、泡沫掩码。
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;
}
| 参数 | 典型值 | 作用 |
|---|---|---|
| 风速 V | 5~20 m/s | 决定频谱峰值与浪高 |
| Patch 尺寸 | 250~2000 m | 决定大浪尺度 |
| Choppiness | 0.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 |
| 细节法线 LOD | 2×~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、多通道」的协同与一致性。
总结
水面与海洋渲染是一条从物理到工程的完整链路,本文的核心结论:
- 几何:Gerstner 波用水平位移制造尖浪,适合可控场景;FFT 频谱按海洋学模型生成高度场,适合写实大海洋。
- 反射折射:菲涅尔按角度混合反射与透射,反射用镜像相机或 SSR,折射用屏幕空间 UV 偏移。
- 着色:Schlick 菲涅尔 + 水体吸收 + 次表面散射近似 + 泡沫,共同构成水的视觉。
- 深度交互:折射、软边缘、水下雾、焦散都依赖深度缓冲,水面必须参与正确的渲染顺序。
- 工程:FFT 分级、多 patch 滚动、反射降分辨率与异步计算是性能关键。
水的魅力在于「物理正确」与「艺术夸张」之间的平衡——完全物理正确的水未必好看,完全风格化的水又缺乏说服力。理解这套底层原理后,你可以按项目需求在两端之间自由调节。建议继续阅读 https://plumephp.com/graphics-gpu-driven-rendering-lod/ 了解大规模水面网格的 GPU 驱动方案,以及水面在延迟管线中的半透明处理。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。