最难渲染的自然现象之一
实时渲染系列
水是游戏中最难渲染的自然现象之一。它同时涉及几何变形、光学效应和流体动力学。但拆开来看,每一步都不复杂。
一、水面渲染的核心要素
一个令人信服的水面需要这几个视觉特征:
| 特征 | 实现手段 |
|---|---|
| 波浪起伏 | 顶点位移(Gerstner 波 / FFT) |
| 表面细节 | 法线贴图扰动 |
| 反射 | 平面反射 / SSR / 环境贴图 |
| 折射 | 屏幕空间折射 + 深度着色 |
| 反射与折射的比例 | 菲涅尔效应 |
| 水下雾化 | 基于深度的颜色衰减 |
| 泡沫/浪花 | 基于曲率或深度的白色叠加 |
| 焦散 | 投影纹理 / 光线追踪 |
二、波形生成
方法一:正弦波叠加(最简单)
// 多个正弦波叠加floatwaveHeight(vec2 pos,floattime){floath=0.0;h+=0.5*sin(pos.x*0.3+time*1.2);h+=0.3*sin(pos.y*0.5+time*0.8);h+=0.1*sin((pos.x+pos.y)*1.0+time*2.0);returnh;}问题:正弦波的波峰和波谷一样圆润,不像真实海浪(波峰尖、波谷平)。
方法二:Gerstner 波(游戏主流)
Gerstner 波不仅让顶点上下移动,还让顶点水平移动,产生真实的尖峰效果:
// Gerstner 波:顶点同时水平和垂直位移vec3gerstnerWave(vec2 pos,floattime,vec2 dir,floatamplitude,floatfrequency,floatsteepness){floatphase=frequency*dot(dir,pos)+time;floatQ=steepness/(frequency*amplitude);// 控制尖锐度vec3 offset;offset.x=Q*amplitude*dir.x*cos(phase);offset.z=Q*amplitude*dir.y*cos(phase);offset.y=amplitude*sin(phase);returnoffset;}// 叠加多个不同方向、频率的 Gerstner 波vec3 totalOffset=vec3(0);totalOffset+=gerstnerWave(pos,t,vec2(1,0),0.5,0.8,0.6);totalOffset+=gerstnerWave(pos,t,vec2(0.7,0.7),0.3,1.2,0.5);totalOffset+=gerstnerWave(pos,t,vec2(-0.3,0.9),0.2,2.0,0.4);方法三:FFT 海洋(3A 级)
基于 Tessendorf 的论文,用海洋频谱(Phillips Spectrum)在频域生成波浪,再通过 FFT 变换到空间域。
Phillips 频谱 → 随机相位 → IFFT → 高度场 + 法线 + 位移
优点:物理正确,视觉效果极佳。缺点:计算量大,通常在 Compute Shader 中实现。
三、法线贴图:小尺度细节
大波浪用顶点位移,小波纹用法线贴图。两张法线贴图以不同速度和方向滚动,叠加产生复杂的水面细节:
// 双层法线贴图滚动vec2 uv1=worldPos.xz*0.1+time*vec2(0.02,0.01);vec2 uv2=worldPos.xz*0.05+time*vec2(-0.01,0.02);vec3 n1=texture(normalMap1,uv1).xyz*2.0-1.0;vec3 n2=texture(normalMap2,uv2).xyz*2.0-1.0;// 混合两层法线(UDN blending)vec3 N=normalize(vec3(n1.xy+n2.xy,n1.z*n2.z));进阶:Flow Map 可以控制水流方向,让河流沿着河道流动而不是均匀滚动。
四、菲涅尔效应:反射与折射的比例
这是水面看起来真实的最关键因素。
物理规律:
正对水面看(垂直入射)→ 主要看到水下(折射为主)
斜着看水面(掠射角)→ 主要看到反射
// Schlick 近似菲涅尔floatfresnel(vec3 V,vec3 N){floatF0=0.02;// 水的基础反射率floatcosTheta=max(dot(V,N),0.0);returnF0+(1.0-F0)*pow(1.0-cosTheta,5.0);}// 最终颜色 = 反射和折射的混合floatF=fresnel(viewDir,normal);vec3 color=mix(refractionColor,reflectionColor,F);很多"看起来不对"的水面,问题就出在没有菲涅尔。加上菲涅尔,效果立刻提升一个档次。
五、反射
方法一:平面反射(最准确)
把相机关于水面做镜像,再渲染一遍场景,得到反射贴图。
镜像相机 → 裁剪水面以下 → 渲染到 FBO → 作为反射贴图采样
缺点:要多渲染一遍场景,开销翻倍。
方法二:屏幕空间反射(SSR)
在屏幕空间做光线步进,沿反射方向搜索已有像素。
优点:不需要额外渲染。缺点:只能反射屏幕上可见的东西。
方法三:环境贴图(最便宜)
用 Cubemap 提供远处的反射。适合天空和远景,近处物体反射不准确。
实际游戏通常混合使用:近处用 SSR,远处用环境贴图,特殊场景用平面反射。
六、折射与水下效果
// 屏幕空间折射vec2 screenUV=gl_FragCoord.xy/screenSize;vec2 distortion=normal.xz*refractionStrength;vec3 refrColor=texture(sceneColorTex,screenUV+distortion).rgb;// 基于深度的水下雾化floatwaterDepth=texture(depthTex,screenUV).r-gl_FragCoord.z;vec3 deepColor=vec3(0.0,0.05,0.1);// 深水颜色floatdepthFactor=1.0-exp(-waterDepth*0.5);refrColor=mix(refrColor,deepColor,depthFactor);效果:水浅的地方能看到水底,水深的地方逐渐变成深蓝/深绿色。
七、泡沫与浪花
泡沫通常出现在:
- 波峰处(Gerstner 波的 Jacobian 行列式 < 0 时)
- 水面与物体交界处(基于深度差)
- 岸边(基于水深)
// 基于深度的岸边泡沫floatshoreDepth=texture(depthTex,screenUV).r-fragDepth;floatfoam=1.0-smoothstep(0.0,foamWidth,shoreDepth);foam*=texture(foamNoise,uv*4.0+time*0.1).r;// 叠加到最终颜色color=mix(color,vec3(1.0),foam*0.8);八、完整的水面着色器框架
// water.frag - 完整水面片段着色器框架voidmain(){// 1. 法线:从法线贴图获取扰动后的法线vec3 N=getWaterNormal(worldPos,time);// 2. 菲涅尔:决定反射/折射比例floatF=fresnelSchlick(viewDir,N);// 3. 反射vec3 reflDir=reflect(-viewDir,N);vec3 reflection=sampleReflection(reflDir,screenUV,N);// 4. 折射 + 水下雾化vec3 refraction=sampleRefraction(screenUV,N,waterDepth);// 5. 混合vec3 color=mix(refraction,reflection,F);// 6. 高光color+=sunSpecular(N,viewDir,lightDir);// 7. 泡沫color=applyFoam(color,worldPos,waterDepth,time);fragColor=vec4(color,1.0);}九、性能优化
| 技术 | 说明 |
|---|---|
| LOD | 远处水面降低网格密度和法线细节 |
| 半分辨率反射 | 反射贴图用一半分辨率渲染 |
| Tessellation | GPU 动态细分,近处密远处疏 |
| Compute Shader | FFT 海洋在 CS 中计算,不占顶点管线 |
| 时间复用 | 反射/折射隔帧更新 |
十、不同游戏的水面方案
| 级别 | 方案 | 适用场景 |
|---|---|---|
| 低端 | 法线贴图 + 菲涅尔 + 环境贴图 | 手游、独立游戏 |
| 中端 | Gerstner 波 + SSR + 深度雾 | 主流 PC/主机游戏 |
| 高端 | FFT 海洋 + 平面反射 + 体积光 + 焦散 | 3A 大作 |
水面渲染没有银弹。核心是理解每个视觉特征背后的物理原理,然后根据性能预算选择合适的近似方案。菲涅尔 + 法线扰动是性价比最高的组合。