大世界海洋与天空统一渲染 2026:FFT、Preetham 与体积雾的耦合取舍
把海、天、雾三者的渲染方程放回同一张边界条件图——按统计海谱 + 大气散射 + 体积云三层堆叠,按远—中—近三段 LOD 视角重写大世界户外渲染的可调度架构,落到 PC、主机与移动端的统一取舍清单。
约 22 分钟阅读6,344 字10 次阅读博主

把海、天、雾三者的渲染方程放回同一张边界条件图——按统计海谱 + 大气散射 + 体积云三层堆叠,按远—中—近三段 LOD 视角重写大世界户外渲染的可调度架构,落到 PC、主机与移动端的统一取舍清单。

当玩家的镜头从地平线拉回近海岸的浅水区,"天空"与"海面"必须在物理上、视觉上、数值稳定性上同时保持连续。任何一段独立优化,都会让另一段出现鬼影、断裂、或色彩断层。本文把海、天、雾三者的渲染方程放回同一张边界条件图,按"远—中—近"三段 LOD 视角重写大世界户外渲染的可调度架构。
大世界户外场景的真实感瓶颈,从不在海面或天空任一方的"单点优化",而在两者交界处的"接缝质量"。一线引擎的踩坑史都印证了这条规律:Unreal 的 Sky Atmosphere 与 Water System 早期版本在大洋面场景出现明显的"水平线色偏带"——大气散射贡献的橙色与海面菲涅尔反射贡献的青色在像素级别上互相打断;Unity 的 HDRP 在引入 Physically Based Sky 后不得不为 Water System 重写一轮采样接口,理由同样是"天空 contribution 必须先到,海面再叠加"。Unity 水系统 2025 LTS、Unreal Water 5.7、Godot 4.4 的水/天空模块,三家源码里都能找到同一个判断分支:if (rendering sky && camera in exterior && ocean mode enabled) → use coupled pipeline。这条分支的存在,本身就说明海天耦合是大世界渲染的"必答题",不是"可选项"。
更具体的工程痛点集中在三点。第一,远距离海平面与天空的交界是一条数学上的退化曲线——当视线接近水平时,大气散射的 Rayleigh + Mie 积分的采样步长必须被非线性加密,否则会产生水平方向的 banding——这是因为 Rayleigh 相位函数在 接近 0 时斜率最陡,固定步长会漏掉高频变化;第二,海面作为高频几何载体,其 FFT 频谱的中高频段与大气散射的低频频段在抗锯齿上互相冲突,TAA 的 history 复用策略必须分别处理——海面走 spatial-temporal variance、天空走 spatial-temporal denoise,两套 jitter 序列;第三,体积雾的消光积分需要把海面作为下边界,但海面本身是动态高度场,雾的"地板"参数不能写死——dynamic floor 不仅影响雾的密度,还影响 Mie 相位函数的采样起点。
第二个容易被忽视的痛点是"运动一致性"——当玩家乘船穿过 1km × 1km 的海面 patch 时,TAA 的 jitter 序列需要在 patch 边界重新 seed,否则会出现"屏幕空间水纹"的伪影;这一约束反过来要求 FFT 海面的 mip 选择按 camera-relative 距离而非 absolute world 距离算,整个 LOD 表都需要"相机锚定"。第三个痛点则是多平台差异:PC 端可以把 32 步 atmospheric scattering 跑得毫无压力,移动端 Mali-G715 上同样 32 步会让 fragment shader 直接超过 8ms 预算,必须靠 SH 拟合或 LUT 降级。这三条——接缝、运动一致性、平台差异——共同决定了"海天雾"不是一个可以拆成三个独立优化项的子系统。
第四个常被低估的痛点是"光照一致性"。海面作为 PBR 材质,其反射率严格依赖入射辐照度,而入射辐照度又来自天空 + 太阳 + 云层反射——这三者在时间维度上必须保持一致。日落时分,Unity HDRP 在 2024 LTS 曾报告过一个 bug:天空的橙红色与海面反射的青蓝色之间出现"色相漂移",根因是太阳入射项用了 analytical 解析式,但海面反射的 cubemap 采样走了 atmospheric LUT,两条路径对太阳高度的 phase 差不一样。修复办法是把太阳 phase 强制归一化为统一查询——这也成为 2025 LTS 重构 Sky-Atmosphere 模块的导火索之一。
把渲染方程按"光源—介质—表面"三层拆开,海天雾场景可以写成下面这个分段积分形式:
其中 是相机位置、 是海面交点或天空盒退距离、 是介质透射率、 是沿路径的散射积分。耦合的难点在于三个 不互相独立:大气 、雾 、海面到相机的海雾 在视线穿过水平线附近时叠加在同一个像素上,但各自的积分步长相差三个数量级。
工程上的取舍边界可以归纳为三条规则。规则一:当视线仰角 时,天空 contribution 主导,雾按 altitude-based 退距离采样即可;规则二:当 时进入"水平带",大气散射必须用 32 步以上的 Rayleigh/Mie march,海面 FFT 频谱必须截断到 4 级以内,否则 TAA 历史会发散;规则三:当 时,海面反射成为唯一通道,但海雾的 exponential height fog 仍按 8 步 ray-marching 计算。
把规则翻译成代码层,三条对应的 shader 分支在 Unreal Water 5.7、HDRP Water、Godot 4.4 Sky 三家实现里都能找到近 1:1 的对应——只是分支的判定阈值不同(Unreal 5.7 用了 ±3°、HDRP 用 ±5°、Godot 用了 ±4°)。这不是巧合,而是物理决定的:视线接近水平时,Rayleigh 相位函数的 项会快速变化,相位采样间距必须按 加密,三角函数告诉我们这个角度窗口大约就是 ±5°。这也是为什么所有主流引擎的水平带都落在 ±3° 到 ±5° 之间,而不是 ±10° 这种更宽松的窗口。
海面渲染的核心是"统计海谱 + 实时 FFT 合成"。统计海谱方面,Pierson–Moskowitz(PM)和 JONSWAP 是两种最常用的能量谱。PM 谱描述完全发展海,给定风速 后可以解析地写出波数—能量曲线:
其中 是峰值波数, 是 Phillips 常数, 是 JONSWAP 引入的峰放大因子(PM 谱里取 1)。JONSWAP 在 PM 基础上乘一个峰值放大项,把"年轻海"的高频能量拉高,更适合游戏场景的动态天气切换。JONSWAP 的峰值放大因子写成 ,其中 , 在低频侧取 0.07、高频侧取 0.09;这个非对称的 是 JONSWAP 比 PM 谱"看起来更尖"的来源。
把谱密度变到顶点位移,需要做逆 FFT。但纯 FFT 海面有两个工程缺陷:一是会出现周期性重复的"块状感",二是波峰缺乏锐度、视觉上偏"软"。现代引擎的解决方案是 FFT 基底 + Gerstner 波叠加:FFT 生成低频大尺度起伏,叠加 4-8 条 Gerstner 波生成高频锐峰。Gerstner 波的位移公式是:
其中 是锐度系数(0=正弦,1=尖峰)、 是振幅、 是水平方向单位向量、 是波向量、 是角频率。当 接近 1 时,波峰会变尖、波谷会变平,这与真实海洋的 capillary–gravity 转换一致。
FFT 海面的 shader 实现通常分两步。第一步:CPU 端用 或 的 FFT 预计算 displacement + slope + foam mask,写到 RGBA16F 纹理的三张变体;第二步:vertex shader 在网格细分时对纹理采样,并用 mip level 对应到网格距离——远距离用高 mip 取低频位移,近距离用低 mip 取高频细节。这种"FFT + LOD mip"的写法,是大世界海面的事实标准。
Gerstner 波叠加的实现细节里,频率选择有一套工程经验:4 条 Gerstner 的波数比建议 1:2.1:4.7:9.4(质数比避免波峰对齐),方向角按 30° 间隔均匀分布,振幅从最低频的 1.0m 衰减到最高频的 0.04m。当场景从"平静"切到"风暴"时,可以让最高频 Gerstner 的振幅从 0.04m 放大到 0.2m、Q 从 0.3 提到 0.8——视觉上会立刻从"平静水面"过渡到"狂风巨浪"。FFT 基底通常只覆盖前 4 级 mip(最大尺度波),剩下 4 级 Gerstner 补全高频,这种"FFT 4 + Gerstner 4"组合在 UE5 Water 和 HDRP Water 都是默认配置。
FFT 海面在采样上有个微妙的坑:当 vertex shader 拉得太远时(>2km),gradient 估算法线会因为 mip 滤波而变钝,导致镜面反射方向漂移。Unreal Water 5.7 的解决方案是把法线单独存在一张 normal map,mip 选择与 displacement 同步,但采样时不插值、直接 fetch 当前 mip 层的中心像素。Unity HDRP Water 走类似思路,但 normal map 是用 finite difference 在 FFT 输出上现算的——这种"现算法线"在 网格上每帧额外 16 万次 fetch,性能敏感。Godot 4.4 直接 precompute 法线到 RG16F,shader 里一次 tap 就拿到——是三个方案里最省带宽的,但代价是内存 ×1.5。
天空部分的核心是 Rayleigh + Mie 散射。Rayleigh 散射描述空气分子的角散射,对短波长(蓝光)偏强,给天空主体颜色;Mie 散射描述气溶胶的角散射,前向散射偏强,给日盘周围的光晕(halo)和曙暮光时的橙红色带。
最经典的实时大气散射实现是 Preetham 1999 的解析模型。它把 Rayleigh 和 Mie 的相位函数拆开后用查找表拟合:
其中 是太阳天顶角、 是视线—太阳夹角、 和 是相位函数。Preetham 模型的优点是单 pass、零 ray-marching、可以在 vertex shader 完成;缺点是没考虑多次散射——当日盘附近或日落时,多散射贡献能达到 30%-40% 的额外亮度,会被简化为"色彩偏暗"或"橙色不够亮"。
多散射的工程化方案有两条路径。第一条是 Bruneton 的 16 阶 spherical harmonics 拟合:把大气散射的 source term 和 inscattering 都投影到 SH 基底上,shader 里做 SH 重建,性能与 Preetham 同量级但视觉上几乎逼近 path tracing 离线结果。Bruneton 的 8 阶 SH 版本在精度上略逊于 16 阶,但 ALU 节省一半,是移动端降级的首选。第二条是 Hillaire 的 ray-marching 16-32 步:从相机沿视线步进,每步累加 Rayleigh + Mie 单散射贡献,最后再做一次"二阶项"的近似累加作为多散射。Bruneton 方案精度更高但 implementation cost 大,Hillaire 方案灵活度高但性能贵 4 倍。
实际工程选型通常取决于平台分级:PC + 主机走 Bruneton SH,移动端走 Preetham + 简化 Mie 相位。Unreal 的 Sky Atmosphere 选 Hillaire 路径但步数降到 6 步作为 fallback,Unity HDRP 的 Physically Based Sky 选 Bruneton SH + 系数可调。三家的差异背后是同一个判断:SH 方案适合"日间常态天空",ray-marching 方案适合"动态天气 + 高湿度场景"。一条经验法则:当游戏场景里雾天/霾天占比 > 20% 时,ray-marching 的多散射视觉收益才会显著超过 SH 拟合的简洁性;否则 Bruneton SH 是更好的工程选择。
Bruneton 的 SH 拟合还藏了一个工程细节:SH 系数必须按太阳高度动态更新——太阳位置变了,inscattering 分布变了,对应的 SH 系数也要重算。CPU 端每帧(或每 1° 太阳高度变化时)重算一次 SH 系数、上传 GPU,shader 里直接做 SH 重建。8 阶 SH 一共 64 个系数(3 色 × 21 系数),上传开销可忽略;16 阶 SH 是 256 个系数,上传开销略大但仍在预算内。Godot 4.4 的 Sky 模块选择了更激进的"online SH 拟合"——shader 内部实时算 SH 系数,CPU 0 开销,但代价是 fragment ALU 多 30%。这条路径在 Mali-G715 上跑不到 60fps,仅适合 PC/主机。
大世界户外场景少不了体积云和高度雾。体积云的工程实现通常分三层:低频形状层(Perlin-Worley noise 生成的密度场)、高频细节层(curl noise 或 VDB)、光照积分层(沿视线方向 64-128 步 march 计算消光 + in-scattering)。把云放到海天耦合场景里,复杂度会上升一档,因为:
工程上推荐的实现顺序:先把 Rayleigh 大气做对(无云场景的"纯天空"),再叠加 Mie 气溶胶(霾、雾),最后叠加体积云。这三层之间的渲染顺序对最终画质影响极大:Mie 必须在大气之后做(被大气衰减),云必须在 Mie 之后做(被气溶胶衰减),但云必须在大气 sky pass 之前采样 in-scattering 入射(因为云是"被照亮的",不是"自发光的")。
体积云的另一个工程细节是"云的密度梯度"。直接用 Perlin noise 做密度场会出现"块状边界",因为 Perlin 的导数不连续。改进方案是 Perlin-Worley 混合噪声:在 Perlin 基础上乘一个 Worley noise 的反转(),让边缘自然衰减。Schneider 的 GPU Pro 6 章详细讨论了这个技巧,实测可以把云的视觉边缘软化 60%-70%。再进一步叠加 curl noise 做高频细节,能让云有"被风吹动"的扰动感,但 curl noise 每像素 +20 ALU,要慎用——只在云边缘 ±10% 像素内叠加 curl,中间仍走 Perlin-Worley。
海天雾耦合的关键,是把"大气 ray-marching"、"海雾 ray-marching"、"海面 ray-marching(反射方向)"三条射线的积分复用同一个步进器。如果三条射线分别 march,shader 指令数会膨胀到 80+ ALU/像素,移动端无法承担。
复用步进器的实现方法是:先从相机出发沿视线 march 大气,遇到海平面相交后切换为海面反射方向继续 march "天 + 云 + 雾"——再计算反射方向后再做一轮 march。整个流程在 shader 里表现为一个 while (t < t_max) 循环,循环内部根据当前位置与海平面的相对关系切换 scattering model:
// simplified — coupled ray-marching shader skeleton
float t = 0.0;
vec3 color = vec3(0.0);
float transmittance = 1.0;
while (t < T_MAX) {
vec3 p = cameraPos + viewDir * t;
if (p.y > SEA_LEVEL) {
// air segment: Rayleigh + Mie
vec2 density = sampleAtmProfile(p);
vec3 inscatter = rayleighPhase(...) * density.x + miePhase(...) * density.y;
vec3 stepTrans = exp(-(density.x + density.y) * stepSize);
color += transmittance * inscatter * stepSize;
transmittance *= stepTrans;
} else {
// below sea level: branch to reflection direction
vec3 reflDir = reflect(viewDir, seaNormal);
// march reflected ray (recurse / restart loop)
return marchReflection(cameraPos, reflDir, ...);
}
t += stepSize;
}
这条循环在 PC 上跑 32 步就能达到 60fps,移动端需要降到 12-16 步 + 提前退距离。Bruneton 的 8 阶 scattering 拟合在这里特别有用,因为它把 Rayleigh + Mie 的 inscattering 拟合到一个 8 阶多项式,shader 里一次 pow 就能拿到。
耦合的另一个易踩坑点是"海平面交线处的奇点"。当视线恰好擦过海平面时,reflect() 算出的反射方向会与视线平行,march 退不出去,整条路径的积分溢出。工程防御是给 SEA_LEVEL 加一个 ±1cm 的 fuzzy band:相机位置在 [SEA_LEVEL - 1cm, SEA_LEVEL + 1cm] 时强制使用 mirror reflection(精确几何反射)而非 fresnel-weighted 反射,避免 fresnel 在掠射角的退化。
大世界渲染永远不是"统一精度",而是"按距离/屏幕空间覆盖率调度"。海天雾场景的三段 LOD 划分推荐如下。
远段(相机距海平面 > 1km):天空走预积分 cubemap,海面走 1 级 FFT(mip=8),雾退距离关掉直接采样 constant color。中段(100m-1km):天空走 Bruneton SH 拟合,海面走 3 级 FFT + 4 条 Gerstner,雾走 16 步 ray-marching。近段(<100m):天空走完整 atmospheric scattering + 4 层云体素,海面走 7 级 FFT + 8 条 Gerstner + foam particle,雾走 32 步 march + 高度场插值。
LOD 切换要避免"硬跳变"。一种成熟做法是把相邻 LOD 的输出做 temporal blend:当前帧 LOD 输出 = mix(prevLod, curLod, transitionFactor),transitionFactor 在距离切换点附近按 0-1 平滑插值,周期 0.5s。这种 temporal blend 与 TAA 的 history 复用天然兼容,因为 TAA 本来就需要 per-pixel 的历史抖动。
LOD bias 的相机速度补偿也很关键:当玩家乘船高速前进时,远段的 1km 阈值会因为相对速度导致 LOD 切换点出现"拉链伪影"——船头看出去还是中段,船尾已经切成远段。修复是在 LOD 选择时引入 camera velocity factor:effectiveDistance = geometricDistance - velocityDotView * 0.5,让高速前进方向上的像素"提前"切到中段。Unity HDRP Water 在 2025 LTS 里加入了这条 bias,Unreal Water 5.7 仍按纯几何距离——是 Unreal 5.7 仍被抱怨"LOD 闪烁"的根因之一。
LOD 切换的另一个隐性成本是着色器变体膨胀:三段 LOD × 三种 scattering 模式 × 四种雾密度配置 = 36 个着色器变体,PSO 预热在 console 端会成为首帧卡顿源。优化方案是 dynamic branch 在 shader 内部做 LOD 选择,让所有变体共用同一份 compiled bytecode,只在运行时按 uniform 切换分支——代价是 fragment ALU 略增(多 5%-8%),但首帧 PSO 编译时间从 80ms 降到 12ms。Epic 在 Unreal Water 5.7 中正是这条路,Unity HDRP 仍走 precompile 多变体路径。
移动端 GPU(ARM Mali、Qualcomm Adreno、PowerVR)几乎全是 tile-based deferred renderer(TBDR)。TBDR 对 fragment shader 的重负载友好(避免 framebuffer read),但对带宽极敏感(system memory 写入是瓶颈)。海天雾三套 ray-marching 同时跑在 fragment shader 上,带宽峰值能冲到 4-8 GB/s,中端手机直接带宽瓶颈。
移动端的工程取舍有四条。第一条:把 Rayleigh + Mie 拟合到 8 阶 SH 而非 ray-marching,省 80% fragment ALU;第二条:海面 displacement 纹理降为 RGBA16F(甚至 RGBA8 加手动解码),bandwidth 减半;第三条:体积云从 64 步降到 24 步,密度场用 3D 纹理切片代替 VDB;第四条:Fresnel 项用 half precision(fp16)计算,精度损失在 1% 以内但带宽节省显著。
Unreal Water 5.7 的 mobile fallback 是这套取舍的典型实现:远段用 sky cubemap、中段用 simplified SH、近段才上完整 ray-marching + cloud volumetrics。Unity HDRP 的 mobile 路径走类似思路,但保留了更高分辨率的 foam particle 系统——因为 Adreno 690 之后的 mobile GPU 实际上能跑得起。Mali-G715 与 Adreno 690 的实测对比数据:Mali-G715 在 SH 拟合 8 阶上能跑到 0.6ms/frame,Adreno 690 同等配置 0.4ms——Adreno 在 SH 重建的 ALU 优化上比 Mali 好约 30%。这一硬件差异直接影响"哪一段 LOD 走 SH"的阈值选择:Mali 平台倾向于更早切到 SH + cubemap,Adreno 平台可以让 ray-marching 覆盖更远距离。
把上面九节落到代码层,建议按这张清单排期。
未来方向集中在三个点:一是 neural atmospheric scattering,用一个小网络拟合 32 步 ray-marching 的结果,性能与 SH 同量级但精度逼近 path tracing(NVIDIA 的 Neural Radiance Cache 在云散射上有早期实验,UE5 的 Lumen 的 cloud 模块也在探索这条路径);二是 virtual shadow map 风格的体积纹理,把云密度场切成 clipmap,远距离直接采样、低距离再 ray-marching,避免传统 volumetric 的全屏 march 浪费;三是 GPU-driven 海面网格,用 Mesh Shader + Draw Indirect 把海面 patch 的 LOD 选择上移到 GPU 端,CPU 不再 per-frame 提交 grid。
海天雾三者的耦合不是单一管线的优化题,而是渲染方程在户外场景里的"系统级落地"。任何把这三者拆开独立优化的尝试,最终都会回到同一张耦合表。当游戏行业进入"大世界 + 真实天气 + 跨平台"的三重约束期,把海、天、雾从"三个子系统"压缩成"一张边界条件图",就是渲染工程师下一阶段的核心功课。
一句话摘要:海、天、雾三者不是三条独立管线,而是同一张渲染方程在"远—中—近"三段 LOD 视角下的耦合采样——本文按大气散射、统计海谱、体积云三层堆叠,给出可调度到移动端的统一架构与九项工程清单。
Conversation
0 条