水体渲染
发布于2025-03-15 · 更新于2026-04-11
#水体
概述 真实感水体的渲染是一个跨越几何学与光学的复合系统,而卡通化的水体则是在真实感水体基础进行简化与风格化处理。一个完整的水体材质,通常需要解决以下六大核心模块:
波形动力学 (微观流动、宏观波浪、地形交互) 波纹
深度与颜色吸收 (浅水、深水、水下吸收)深度颜色变化
物理反射与折射 (SSR、平面反射、屏幕空间扭曲) 反射 ,折射
次表面散射 (SSS) (波峰透光感)
焦散 (Caustics) (水下光斑折射) 阳光照射下的焦散
白沫系统 (Foam) (波峰碎浪、近岸冲刷、法线体积感) 浪花
水体渲染技术 思维导图
几何与动力学:波形的模拟 (Geometry & Dynamics) 法线扰动 流动贴图 (Flowmap) 普通的 UV 动画在表现水流时会显得极其死板,Flowmap 则是表现水面微观流动感 的核心技术。
双重相位采样 (Dual-Phase Sampling) :为了消除 UV 随时间偏移产生的严重拉伸,通常在 Shader 中采样两组相位差为 0.5 的 UV 坐标,并利用正弦函数 saturate(abs(sin(frac(time) * PI))) 作为权重进行无缝混合。
坡度自适应 :结合世界法线的 Y 分量,可实现水流在陡峭地形处自动加速的物理特性。
线性波形叠加方法 线性波形叠加方法的主要思路是累加不同的线性波形函数以构造波浪表面。可以将其理解为波动现象在深水中引起水颗粒运动的一种解析解
波浪中的任何点都沿圆形轨迹移动,靠近表面的半径较大,而在水中更深的半径呈指数减小。突出显示了两个橙色点,可以发现他们的运动轨迹都是圆形。
业界主流的波形函数主要分为正弦波(Sinusoids Wave)和Gerstner波(Gerstner Wave)两种,下面分别进行说明。
Sin/Cos waves 作为比较早期的水面波形模拟方案,正弦波(Sinusoids Wave)的特点是平滑,圆润,适合表达如池塘一样平静的水面。
1981年,Max[Max 1981]首先提出了采用高低振幅的正弦波曲线的序列组合来模拟水面起伏的想法。将水体表面采用高度进行建模,则基于正弦波(Sinusoids Wave)的方法在时间t的每个点(x,z)上计算的高度y = h(x,z,t)的通用公式为:
其中 是波的总数
是第i个波的振幅
是波矢量
是其脉冲值(pulsation)
是自由表面的高度
正弦波(Sinusoids Wave)目前在水体渲染领域已经很少直接使用,业界往往青睐于使用它的进化版Gerstner波。
Gerstner Waves (格斯特纳波) Gerstner 波(Gerstner Wave)也常被称为Trochoidal Wave,在流体动力学中,其为周期表面重力波(periodic surface gravity waves)的欧拉方程的精确解,由Gerstner在1802 年初次发现,并在1863年由Ranine独立重新发现。在1986年由Fournier等人引入水体渲染领域。
Gerstner Waves(格斯特纳波)由于计算量可控,性价比高,在游戏水体渲染领域的应用较为广泛,不少3A游戏了采用Gerstner Wave作为水体渲染的基础实现。我们通常用它来替代简单的正弦波,以获得更真实的波浪形状。
核心原理:摆线运动 (Trochoidal Motion) 传统的正弦波(Sine Wave)只在垂直方向上移动顶点,这导致波峰和波谷看起来过于圆润且对称。 Gerstner Waves 的关键在于:水面上的粒子不仅仅是在上下运动,而是在做圆周运动。
这会导致顶点在向波峰移动的同时,在水平方向上也会向波峰靠拢。
物理特征: 波峰变得更加尖锐(Sharp crests),波谷变得更加宽阔平坦(Flat troughs) ,这更符合真实海洋的物理特性。
2. 数学表达 对于一个初始位置为 的顶点,其偏移后的位置 计算如下:
其中 是频率与时间的组合 相位 : 。
关键参数 :
振幅 (Amplitude, ) :波浪的高度。
方向 (Direction, ) :波浪传播的二维向量。
波长 (Wavelength, ) :两个波峰之间的距离。
陡度 (Steepness, ) :控制波峰的尖锐程度。通常 ,其中 是波的数量。如果 过大,会导致模型顶点自相交(Self-intersection)。
相位/速度 (Phase/Speed, ) :波随时间移动的速度。
3. 优缺点分析
优点:
艺术控制力强 :通过调整 值,我们可以轻松控制海面的“愤怒”程度。
计算开销低 :可以直接在 Vertex Shader 中完成,不需要复杂的物理仿真。
易于叠加 :我们可以通过叠加 4 到 8 个不同参数的波,利用菲涅尔反射(Fresnel)和法线贴图混合,制造出非常复杂的海洋效果。
缺点:
自相交风险 :当 的总和超过 1 时,波浪会发生严重的叠层和自相交。
依赖顶点密度 :由于位移发生在顶点阶段,需要模型有足够的细分(Tessellation)才能表现出尖锐的波峰。
4. 工程建议 在 Unity 或 Unreal 的 Shader 实现中,我们通常建议:
打包参数 :将多个波的 存入 Constant Buffer 或 StructuredBuffer。
法线计算 :不要依赖物理法线,而是直接在 Shader 中计算 Gerstner Wave 的偏导数(Partial Derivatives),从而得到精确的切线和法线,这样能获得完美的各向异性高光。
距离衰减 :对于远处的水面,我们可以逐渐减小位移的强度或降低波浪数量,以减少锯齿和性能损耗。
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 41 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 项目中,通常会叠加 4组标准波 + 1组极坐标波 :
1. **远海巨浪**:两组大尺度、低频率的波浪,决定宏观起伏。
2. **近岸碎浪**:两组高频小浪,受水深驱动产生“**底部摩擦减速**”和“**拍岸冲刷变形**”。
3. **极波 (Polar Wave)**:放弃点积(Dot)而采用向量长度(Length)计算相位,制造非线性的漩涡或暗流涌动感。
宏观海洋:统计模型方法——FFT (快速傅里叶变换) :::color1快速傅里叶变换(FFT)超详解
:::
当我们需要表现无尽的汪洋大海时,Gerstner 会因为波浪数量受限而显出重复感。如果说在Gerstner Waves中,我们是在“微观”层面手动混合几个特定的波浪,那么**海洋FFT(Fast Fourier Transform,快速傅里叶变换)技术 [FFT 是基于统计学(如 Phillips 频谱)在频域生成海浪,再通过 IFFT 逆变换回空间域生成位移图 ] 则是我们在追求极致写实度时,从“宏观”统计学层面 生成整片汪洋大海 [表现深海、远洋或者高度真实的水体互动 ] **的解决方案。
优势 :极度真实,细节无穷。
代价 :依赖 Compute Shader,计算开销大。通常采用多级联 FFT (Cascaded FFT) ,在不同网格 LOD 下采样不同分辨率的位移图。
1. 核心思想:从统计学到频域,再到空间域 真实世界的海洋极其复杂,是由无数个不同方向、不同频率的波浪叠加而成的。直接在顶点着色器里叠加成百上千个正弦波是不现实的。 FFT海洋的绝妙之处在于它转变了思路:
统计学模型(Phillips Spectrum): 物理学家通过对真实海洋的观测,总结出了基于风速 和风向 的能量分布公式(通常是Phillips频谱或Tessendorf模型)。
频域生成(Frequency Domain): 我们首先在频域(一维或二维的纹理/Buffer)中生成这些海浪的振幅和相 位。在这里,每一个像素代表的是一种特定频率和方向的波浪能量,而不是实际的高度。
逆快速傅里叶变换(IFFT): 这是核心算法。我们将频域数据通过IFFT算法转换回空间域(Spatial Domain) 。转换的结果就是一张张可以随时间无缝循环的**高度图(Height Map)和 位移图(Displacement Map)**。
2. 标准的渲染管线 (以Unity / HLSL为例) 在我们日常的渲染架构中,FFT海洋的实现通常高度依赖 GPU Compute Shader,因为IFFT涉及大量的并行计算:
初始化阶段 (Compute Shader):
根据初始的风速、风向参数,生成初始的频域频谱 。这通常只需要在参数改变时计算一次。
每帧更新阶段 (Compute Shader):
时间演进: 根据物理色散关系(Dispersion Relation),推进频谱随时间的变化,生成当前帧的频域数据 。
执行IFFT: 使用蝶形算法(Butterfly Algorithm)或 Cooley-Tukey 算法对 执行逆傅里叶变换。
输出纹理: 最终烘焙出三张图(或合并打包):垂直高度图(Y位移)、水平位移图(X/Z的斩波偏移,用于制造尖锐的波峰)、法线/折叠图(用于生成白沫 Foam)。
渲染阶段 (Vertex/Fragment Shader):
在 Vertex Shader 中采样位移图,对高细分的网格进行顶点偏移。
在 Fragment Shader 中采样法线图进行PBR光照计算,利用折叠图(Jacobian行列式)来混合白沫材质。
3. 项目实战中的优化考量 在我们将FFT真正落地到项目中时,最大的挑战往往是性能和细节的平衡。我们通常会采用多级联FFT(Cascaded FFT) ,即生成几张不同分辨率、覆盖不同物理范围的位移图(比如:一张捕捉低频巨浪,一张捕捉高频涟漪),然后将它们叠加采样,这与我们在处理级联阴影(CSM)时的思路非常相似。
在您当前规划的渲染管线中,如果您要引入大规模水面,您会倾向于将性能预算投入到更复杂的 Compute Shader FFT 计算中,还是倾向于使用基于顶点着色器的方法(如Gerstner)并结合高品质的屏幕空间反射(SSR/SSGI)来提升整体质感呢?
FFT 与 Gerstner Waves 的对比评估
特性
海洋 FFT
Gerstner Waves
真实度
极高 。基于真实海洋统计学,拥有无尽的细节和极其自然的混沌感。
较高。受限于波的数量,容易看出重复的模式。
艺术控制力
较弱 。主要通过调整“风速”、“风向”等宏观参数来影响整体,难以精确控制单个波浪的位置。
极强 。可以精确控制每一个波的大小、方向、甚至速度。
性能开销
较高 。每帧需要大量的 Compute Shader 计算(即使有贴图复用和LOD策略)。
极低 。纯 Vertex Shader 顶点位移,极其轻量。
适用场景
3A级写实航海游戏、深海/远洋环境、电影级渲染。
卡通渲染水面、近岸/河流、性能敏感的移动端项目。
基于相机距离的曲面细分
TODO
光学与质感:水体色彩与物理 (Optics & Physics) 深度与颜色吸收 (Color & Depth) 水体之所以呈现蓝色/绿色,是因为水分子对不同波长的光吸收率不同(红光最先被吸收)。
比尔-朗伯定律 (Beer-Lambert Law) :光强随穿透深度呈指数衰减,即 。
实现方案 : 通过 SampleSceneDepth 获取屏幕深度,减去当前顶点深度,计算出视线在水下的“穿透距离”。 根据此距离,在浅水色 (Shallow Color) 和 深水色 (Deep Color) 之间进行平滑插值或指数过渡。
此处为语雀卡片,点击链接查看
水体反射 菲涅尔效应与 BRDF 水面具有极强的菲涅尔现象:垂直看清澈见底,平视时则像一面镜子。在工程中,高光不仅受菲涅尔控制,还可以加入了双重几何环境衰减(Dual-Shield Attenuation) :
距离抑制 :防止近距离看水时被强高光亮瞎。
深度抑制 :极浅的水(如沙滩水洼)无法形成完美全反射,高光被强行切断。
SSR 平面反射 BoatAttack/…/PlanarReflections.cs
PlanarReflections.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 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 using System;using UnityEngine.Experimental.Rendering;using UnityEngine.Serialization;using Unity.Mathematics;namespace UnityEngine.Rendering.Universal { [ExecuteAlways ] public class PlanarReflections : MonoBehaviour { [Serializable ] public enum ResolutionMulltiplier { Full, Half, Third, Quarter } [Serializable ] public class PlanarReflectionSettings { public ResolutionMulltiplier m_ResolutionMultiplier = ResolutionMulltiplier.Third; public float m_ClipPlaneOffset = 0.07f ; public LayerMask m_ReflectLayers = -1 ; public bool m_Shadows; } [SerializeField ] public PlanarReflectionSettings m_settings = new PlanarReflectionSettings(); public GameObject target; [FormerlySerializedAs("camOffset" ) ] public float m_planeOffset; private static Camera _reflectionCamera; private RenderTexture _reflectionTexture; private readonly int _planarReflectionTextureId = Shader.PropertyToID("_PlanarReflectionTexture" ); private int2 _oldReflectionTextureSize; public static event Action<ScriptableRenderContext, Camera> BeginPlanarReflections; private void OnEnable () { RenderPipelineManager.beginCameraRendering += ExecutePlanarReflections; } private void OnDisable () { Cleanup(); } private void OnDestroy () { Cleanup(); } private void Cleanup () { RenderPipelineManager.beginCameraRendering -= ExecutePlanarReflections; if (_reflectionCamera) { _reflectionCamera.targetTexture = null ; SafeDestroy(_reflectionCamera.gameObject); } if (_reflectionTexture) { RenderTexture.ReleaseTemporary(_reflectionTexture); } } private static void SafeDestroy (Object obj ) { if (Application.isEditor) { DestroyImmediate(obj); } else { Destroy(obj); } } private void UpdateCamera (Camera src, Camera dest ) { if (dest == null ) return ; dest.CopyFrom(src); dest.useOcclusionCulling = false ; if (dest.gameObject.TryGetComponent(out UniversalAdditionalCameraData camData)) { camData.renderShadows = m_settings.m_Shadows; } } private void UpdateReflectionCamera (Camera realCamera ) { if (_reflectionCamera == null ) _reflectionCamera = CreateMirrorObjects(); Vector3 pos = Vector3.zero; Vector3 normal = Vector3.up; if (target != null ) { pos = target.transform.position + Vector3.up * m_planeOffset; normal = target.transform.up; } UpdateCamera(realCamera, _reflectionCamera); var d = -Vector3.Dot(normal, pos) - m_settings.m_ClipPlaneOffset; var reflectionPlane = new Vector4(normal.x, normal.y, normal.z, d); var reflection = Matrix4x4.identity; reflection *= Matrix4x4.Scale(new Vector3(1 , -1 , 1 )); CalculateReflectionMatrix(ref reflection, reflectionPlane); var oldPosition = realCamera.transform.position - new Vector3(0 , pos.y * 2 , 0 ); var newPosition = ReflectPosition(oldPosition); _reflectionCamera.transform.forward = Vector3.Scale(realCamera.transform.forward, new Vector3(1 , -1 , 1 )); _reflectionCamera.worldToCameraMatrix = realCamera.worldToCameraMatrix * reflection; var clipPlane = CameraSpacePlane(_reflectionCamera, pos - Vector3.up * 0.1f , normal, 1.0f ); var projection = realCamera.CalculateObliqueMatrix(clipPlane); _reflectionCamera.projectionMatrix = projection; _reflectionCamera.cullingMask = m_settings.m_ReflectLayers; _reflectionCamera.transform.position = newPosition; } private static void CalculateReflectionMatrix (ref Matrix4x4 reflectionMat, Vector4 plane ) { reflectionMat.m00 = (1F - 2F * plane[0 ] * plane[0 ]); reflectionMat.m01 = (-2F * plane[0 ] * plane[1 ]); reflectionMat.m02 = (-2F * plane[0 ] * plane[2 ]); reflectionMat.m03 = (-2F * plane[3 ] * plane[0 ]); reflectionMat.m10 = (-2F * plane[1 ] * plane[0 ]); reflectionMat.m11 = (1F - 2F * plane[1 ] * plane[1 ]); reflectionMat.m12 = (-2F * plane[1 ] * plane[2 ]); reflectionMat.m13 = (-2F * plane[3 ] * plane[1 ]); reflectionMat.m20 = (-2F * plane[2 ] * plane[0 ]); reflectionMat.m21 = (-2F * plane[2 ] * plane[1 ]); reflectionMat.m22 = (1F - 2F * plane[2 ] * plane[2 ]); reflectionMat.m23 = (-2F * plane[3 ] * plane[2 ]); reflectionMat.m30 = 0F ; reflectionMat.m31 = 0F ; reflectionMat.m32 = 0F ; reflectionMat.m33 = 1F ; } private static Vector3 ReflectPosition (Vector3 pos ) { var newPos = new Vector3(pos.x, -pos.y, pos.z); return newPos; } private float GetScaleValue () { switch (m_settings.m_ResolutionMultiplier) { case ResolutionMulltiplier.Full: return 1f ; case ResolutionMulltiplier.Half: return 0.5f ; case ResolutionMulltiplier.Third: return 0.33f ; case ResolutionMulltiplier.Quarter: return 0.25f ; default : return 0.5f ; } } private static bool Int2Compare (int2 a, int2 b ) { return a.x == b.x && a.y == b.y; } private Vector4 CameraSpacePlane (Camera cam, Vector3 pos, Vector3 normal, float sideSign ) { var offsetPos = pos + normal * m_settings.m_ClipPlaneOffset; var m = cam.worldToCameraMatrix; var cameraPosition = m.MultiplyPoint(offsetPos); var cameraNormal = m.MultiplyVector(normal).normalized * sideSign; return new Vector4(cameraNormal.x, cameraNormal.y, cameraNormal.z, -Vector3.Dot(cameraPosition, cameraNormal)); } private Camera CreateMirrorObjects () { var go = new GameObject("Planar Reflections" ,typeof (Camera)); var cameraData = go.AddComponent(typeof (UniversalAdditionalCameraData)) as UniversalAdditionalCameraData; cameraData.requiresColorOption = CameraOverrideOption.Off; cameraData.requiresDepthOption = CameraOverrideOption.Off; cameraData.SetRenderer(1 ); var t = transform; var reflectionCamera = go.GetComponent<Camera>(); reflectionCamera.transform.SetPositionAndRotation(t.position, t.rotation); reflectionCamera.depth = -10 ; reflectionCamera.enabled = false ; go.hideFlags = HideFlags.HideAndDontSave; return reflectionCamera; } private void PlanarReflectionTexture (Camera cam ) { if (_reflectionTexture == null ) { var res = ReflectionResolution(cam, UniversalRenderPipeline.asset.renderScale); bool useHdr10 = RenderingUtils.SupportsRenderTextureFormat(RenderTextureFormat.RGB111110Float); RenderTextureFormat hdrFormat = useHdr10 ? RenderTextureFormat.RGB111110Float : RenderTextureFormat.DefaultHDR; _reflectionTexture = RenderTexture.GetTemporary(res.x, res.y, 16 , GraphicsFormatUtility.GetGraphicsFormat(hdrFormat, true )); } _reflectionCamera.targetTexture = _reflectionTexture; } private int2 ReflectionResolution (Camera cam, float scale ) { var x = (int )(cam.pixelWidth * scale * GetScaleValue()); var y = (int )(cam.pixelHeight * scale * GetScaleValue()); return new int2(x, y); } private void ExecutePlanarReflections (ScriptableRenderContext context, Camera camera ) { if (camera.cameraType == CameraType.Reflection || camera.cameraType == CameraType.Preview) return ; UpdateReflectionCamera(camera); PlanarReflectionTexture(camera); var data = new PlanarReflectionSettingData(); data.Set(); Shader.EnableKeyword("_PLANAR_REFLECTION_CAMERA" ); BeginPlanarReflections?.Invoke(context, _reflectionCamera); UniversalRenderPipeline.RenderSingleCamera(context, _reflectionCamera); data.Restore(); Shader.SetGlobalTexture(_planarReflectionTextureId, _reflectionTexture); Shader.DisableKeyword("_PLANAR_REFLECTION_CAMERA" ); } class PlanarReflectionSettingData { private readonly bool _fog; private readonly int _maxLod; private readonly float _lodBias; public PlanarReflectionSettingData () { _fog = RenderSettings.fog; _maxLod = QualitySettings.maximumLODLevel; _lodBias = QualitySettings.lodBias; } public void Set () { GL.invertCulling = true ; RenderSettings.fog = false ; QualitySettings.maximumLODLevel = 1 ; QualitySettings.lodBias = _lodBias * 0.5f ; } public void Restore () { GL.invertCulling = false ; RenderSettings.fog = _fog; QualitySettings.maximumLODLevel = _maxLod; QualitySettings.lodBias = _lodBias; } } } }
物理折射与防穿帮 (Refraction & Artifact Fix)
抓屏扭曲 :采样摄像机的不透明纹理 (Camera Opaque Texture),结合水面法线偏移 UV,模拟折射扭曲。
致命伪影与修复 :如果直接扭曲 UV,会导致水面边缘吸附并扭曲水面以上的物体(如玩家的腿)。修复方案 :在扭曲后重新采样一次深度 (rawDepthDiff)。如果发现扭曲后的 UV 采样到了水面前方的物体(深度差小于 0),则强行回退到未扭曲的原始 UV。
Shader Graph**Scene Color 节点 **抓屏
在 Universal Render Pipeline (通用渲染管道 ) 中,Scene Color (场景颜色) 节点返回 Camera Opaque Texture (摄像机不透明纹理 ) 的值。有关此功能的更多文档,请参阅通用渲染管线 。此纹理的内容仅适用于 Transparent 对象。将 Graph Inspector 的 Graph Settings选项卡上 的 Surface Type 下拉列表设置为 Transparent ,以从此节点接收正确的值。
1 2 3 4 void Unity_SceneColor_float (float4 UV, out float3 Out ) { Out = SHADERGRAPH_SAMPLE_SCENE_COLOR(UV); }
修复折射伪影 基础的折射是存在缺陷的,当物体从水中伸出时,有时会看到折射效果出现在不应该出现的地方。
解决此问题的一种方法是执行深度检查,判断我们应该使用扭曲的 UV 还是未扭曲的 UVS。Scene Position 子图包含用于计算世界空间场景位置的节点。
实施此修复后,效果看起来要好得多,并且仅显示在您可以实际看到水中的对象的位置。
次表面散射 (Subsurface Scattering - SSS) 次表面散射是赋予海浪“果冻般”通透感的灵魂,通常出现在波峰和逆光处。
半兰伯特包裹光照 (Wrapped Lighting) : 避免昂贵的体积积分,直接对主光源和法线的点积进行空间映射:
厚度伪造 :提取 Gerstner 波浪计算出的高度(波浪越高,浪尖越薄),生成动态厚度遮罩。
水下焦散 (Caustics) 阳光穿过波浪表面后在水底汇聚形成的光斑。
三轴投影 (Triplanar Mapping) :由于水底地形崎岖,普通的 2D 投影会产生拉伸。利用绝对世界坐标和法线权重,在 X、Y、Z 三个平面分别投影焦散贴图。
双层极小值混合 (Dual-Layer Min Blending) :采用两张速度和缩放不同的焦散图,利用 min(tex1, tex2) 进行混合。这能最大程度保留光斑交叉时的锐利边缘,形成高度真实的水下光网。
细节衍生:动态泡沫系统 (Dynamic Foam) 泡沫并非简单地贴图覆盖,在高级水体中,它被分为两套独立的生态,并拥有自己的物理体积。在 FFT 海洋中,白沫 通常通过计算雅可比行列式(Jacobian Determinant,反映水面的折叠程度) 来生成。在基于 Gerstner 的体系中,则通过高度与深度双重驱动. 。
双轨生成逻辑
浪尖白沫 (Sea Foam) :主要由几何高度 驱动。提取波浪的位移 Y 值,并在波峰破裂处配合 Flowmap 产生涌动感。
近岸泡沫 (Shore Foam) :主要由水深和噪声 驱动。读取深度,在浅水区结合柏林噪声 (Perlin Noise) 形成边缘的 不规则的冲刷拖尾和堆积。
法线重定向 (Reoriented Normal Mapping) 普通的泡沫仅仅是颜色的 Alpha 混合,看上去就像贴在水面上的一张扁平的纸。真实水沫是有体积的。
采用 Reoriented Normal Mapping 技术,将泡沫自身的独立法线,正确地“贴合”并扰动底层的巨大波浪法线:
(其中 为底层水面法线修正量, 为泡沫细节法线修正量)。这使得泡沫在阳光下能产生独立的高光和粗糙度(Smoothness),视觉上极具物理质感。
1 2 3 4 5 6 7 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; }
意义 :这一步让泡沫完美融入了 PBR 光照管线。泡沫不仅有了漫反射,还能阻挡水面的高光(将 Smoothness 降为粗糙),并在阳光下产生符合泡沫自身微观结构的立体高光点,彻底拉开了与廉价水体的质量差距。
水体交互 水体交互
浮力与物理交互 (Buoyancy & Interaction) 渲染出水面后,还需要让场景中的物体(如船只、木箱)产生正确的浮力响应。
CPU回读 (Readback) :如果使用 FFT 或 Compute Shader 计算海浪,通常需要在 CPU 端异步回读(Async GPU Readback)高度图,或者在 CPU 端同步运行一套轻量级的 Gerstner 数学模型,计算出各个浮力采样点的高度,进而施加物理力(Rigidbody Forces)。
交互式尾迹 (Interactive Wakes) :船只航行产生的尾迹通常通过在正交摄像机下绘制粒子(Render Texture),或者在 Compute Shader 中解算 2D 浅水波动方程(Shallow Water Equations),再将结果作为全局扰动法线叠加回水面 Shader 中。