ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

实时海洋模拟源码解析:从波形生成到水面渲染

实时海洋模拟源码解析:从波形生成到水面渲染 在图形方向做了这么多年我最怕听到的四个字就是“做个海洋”。没做过的人以为是一块蓝布加一点高光做过的人都清楚实时海洋模拟项目能把渲染管线、数学物理、性能优化和美术审美全部压在一起。最近又完整拆了一个海洋模拟项目的源码从波浪生成到水面着色再到和场景交互整个链路非常典型。这篇文章就按我拆代码的顺序把里面最值得看的东西一五一十铺开讲希望给想自己写海洋、或者正被海洋效果折磨的朋友一些可参考的思路。1. 为什么直接做一片“蓝海”会露馅一个海洋场景好不好外行人一眼就能判断但很少有人能说清楚判断依据是什么。最初级的方案就是一张大平面叠一层蓝色半透明材质再加一张法线贴图做扰动。这种效果站在远处还能糊弄人相机一压下去海面和船、岛屿、天空的衔接就全崩了。真正项目的核心目标其实不只是“生成波浪”而是让海面在任何角度看都有可信度——包括浪的尺度、形状、运动方向以及水面和所有静态物体交界处的光影变化。源码解析不是把每个类点一遍名而是要看作者做了哪些取舍。这个项目给我的第一印象就是它的目录结构不按传统MVC分而是按“波形生成”“水面渲染”“碰撞交互”“调试工具”四条线拆。表面看只是代码组织习惯问题实际上映射出海洋模拟的本质它不是一个单模块功能而是一条从世界坐标到顶点着色器再到物理反馈的数据链。如果一开始按“UI模块、数据模块、工具模块”这种结构来组织海洋后期加一个浮力系统或者船体交互必定会改到想哭。把“海洋”当成一个独立业务来设计而不是当成场景里的一张贴图是加分项。具体落地方式就是所有波浪相关状态由海洋管理器持有渲染部分只负责把高度场采样结果呈现出来物理交互部分用同样的高度场函数查询波面位置。三个环节共用一套波形参数避免Shader里一套算法、CPU侧又复制一份不同的结果。这种统一性是后面所有细节能成立的前提。1.1 想清楚“海面”到底是谁算出来的很多人第一次写海洋会把高度计算直接扔在顶点着色器里CPU这边根本不知道当前波面长什么样。这样做渲染是没问题可一旦要做船体浮动、物体落水、岸边潮汐CPU就必须再维护一份完全一致的波浪计算。源码里最关键的一个接口就是GetWaveHeight(const Vector3 worldPos, float time, float height, Vector3 normal)这个函数在CPU和GPU端都有实现两边的参数完全一致。这个接口暴露了一个核心设计思想波形函数不应该是GPU的私有财产。无论是漂浮物、渔船、还是军港里的系泊浮标都应调用同一个函数查高度。水面渲染用GPU求解同一方程的完整版本包含顶点位移和法线扰动交互系统用CPU的简化版本。虽然细节上有误差容差但宏观运动是一致的视觉上和物理上不会出现“船在海面上悬空飞”的情况。1.2 参数集中管理别到处写死数值大量项目的海面丑不是算法不行而是参数管理太随意。浪高0.3、浪长5.0这种Magic Number散落在各个Shader片段和C代码里调起来要开全局搜索。这个项目的做法是维护一份OceanParams结构体涵盖风速、浪向、主波波长、波幅、泡沫阈值、透明度、反射强度等几十个字段所有模块统一引用。我实测下来这样的好处不仅是好调参更重要的是能支持运行时动态修改。比如从平静港湾开向外海浪高随风速实时上涨这套结构只需要把风速赋值给参数体波形模块和渲染模块就能同步变化。如果散落写死动态天气变化就是一场改代码的灾难。2. 波浪从哪里来三维Gerstner波与FFT谱的取舍逻辑面对“波浪怎么生成”这个问题市面上的答案基本分成两派叠加解析波和频谱反演。源码里两套方案都出现了一套作为默认渲染方案一套作为高质量预览方案这让我有点意外。认真看下来它们的取舍逻辑其实非常清晰。解析波方案的核心是Gerstner波。它和普通正弦波最大的区别在于波面顶点不是只在垂直方向上位移而是在水平方向和垂直方向同时做圆周运动因此可以做出波峰尖锐、波谷平坦的真实海面轮廓。代码如下float3 GerstnerWave(float4 wave, float3 p, float t) { float steepness wave.w; float wavelength wave.x; float k 2.0 * PI / wavelength; float c sqrt(9.8 / k); float2 d normalize(wave.yz); float f k * dot(d, p.xz) - c * t; float a steepness / k; return float3( d.x * (a * cos(f)), a * sin(f), d.y * (a * cos(f)) ); }其中steepness控制波峰的尖锐程度实际取值在0.05到0.2之间。超过0.2波峰会开始穿插形成不自然的尖刺。这个项目里默认四个方向各叠加三到四个不同波长和幅度的波加在一起就是非常经典的多方向浪涌效果。为什么是三四层而不是更多因为Gerstner波叠加数量越多计算量线性增长在移动级GPU上撑不住四层以上视觉收益边际递减还容易互相干扰产生周期性的“驻波”。FFT频谱方案则是另一条路线它用Phillips谱或JONSWAP谱反演海面高度场本质上是对海面高度场做快速傅里叶变换从频域生成时空一致的波面。这套方案质量上限高能模拟出带风域、涌浪和capillary波的复杂海面但代价是每帧要做一次二维FFT。项目里的做法是把它放在离屏Pass里通过Compute Shader执行只输出一张高度贴图和一张法线贴图水面主Pass再采样这两张贴图。2.1 两套方案在源码里如何和谐共存两个方案并存会不会导致工程失控源码告诉我们不会。关键在抽象层。作者定义了一个IWaveProvider接口Gerstner实现和FFT实现都实现这个接口对外只暴露高度查询和法线重构。渲染管线里也做了一个开关来切换平时默认走Gerstner按下调试键可以即时切到FFT预览做对比。从使用体验来说Gerstner方案的优势在于“可控”。美术说这片海域需要三米高的浪直接改波幅参数就行指向性非常明确。FFT方案的优势在于“真实”特别是风从开阔海面吹过来时大面积波浪的分布和衰减规律几乎不需要手动调。实际项目里更多人会混用远处用FFT生成立体感强的远景波浪细节近景用Gerstner保持可控的形态和方向感中间通过距离做权重过渡。这个思路值得借鉴。2.2 波形代码最容易埋的雷时间累积如果你直接把全局时间t传给波形函数跑几个小时后会开始出现肉眼可见的波浪抖动。原因是浮点数精度有限大时间值参与三角计算时小数位精度丢失波形的连续性开始破裂。源码的做法是在波形模块里维护一个本地累积时间当时间值超过一定阈值且处于非暂停状态时把时间对一个大周期取模让波形继续从周期性起始点推进。这个细节不写进文档但渲染结果会告诉你它值多少钱。3. 源码核心链路从OceanManager到DrawCall的一次旅程这个项目的调用链很清爽没有过度封装的包袱。每一帧大概经历这么几个步骤先由OceanManager采集当前相机的视锥体范围计算出本帧需要渲染的海面网格范围然后把波形参数传递到渲染系统最后提交网格到GPU在顶点阶段求解波形在像素阶段完成着色。全过程大概依赖两个大系统和若干辅助类。第一步非常关键计算渲染范围。海面是一个理论上是无限的表面但GPU不可能画一个无限平面。代码里默认的主网格覆盖大约4公里见方的范围以相机为中心。相机移动时网格跟着走边缘处永远保持有足够大的海面延伸到地平线附近。这套策略通常叫“跟随相机海平面网格”是移动平台海面最常用的地基方案。3.1 波浪参数是怎么万里迢迢传给Shader的牵涉跨CPU/GPU的数据流最容易踩的就是“今天加了风速Shader那边没收到”。源码的做法是定义了一套共用结构体CPU端和HLSL端各写一次字段顺序、类型严格对齐然后通过常数缓冲区整包提交。执行流程是OceanManager每帧构造OceanParamsBufferWavePropagationSystem把它填充上正确数值交给渲染系统绑定到全局Shader资源。任何模块想要读取浪高、波长、风向统一从这块缓冲区取禁止单独拼参数、单独设全局变量。这条链路看似普通但很多人就是栽在这里负责物理的同事单独给波高函数复制了一份参数表负责渲染的同事又自己定义了一套两边的数值本来就微调过最终表现就是船跟着波走海面却不受影响。统一参数入口是保证整个海洋系统不会内部打架的核心操作。3.2 DrawCall和批次海洋网格为何能如此“省”我们经常在项目里见到海面和几百个物件穿插在一起DrawCall直接飙升到上千。这个项目的海面本身只产生很少的DrawCall原因是海洋网格完全独立于场景透明队列使用自身材质球渲染而且网格顶点数是用网格LOD控制的不会因为LOD层级多而拆出大量批次。比较让人意外的是海面材质没有走标准的半透明混合。远看水体是半透明的靠近后你会发现它主要依靠反射和环境色近处浅水区域才用深度纹理做透明混合。源码里把水面放在不透明队列用深度和雾效模拟水体容积感避开半透明排序的坑。这个决定看起来很反直觉但实际预览效果极其稳定不会有海面和物体来回遮挡闪烁的问题。4. 海面网格和相机让“无限”听起来不吹牛很多海洋模拟截图很好看但玩家一动起来就露馅浪花在相机周围是清晰的跑出几百米后波浪密度肉眼可见地下降甚至能看到网格的折痕。网格策略基本决定了这种瑕疵的上限。这个项目里用的是极坐标网格不是常规矩形网格这算是个辨识度很高的选型。矩形网格最大的问题是相机方向和网格轴对齐时产生的规则走样。极坐标网格以相机为圆心径向和角向分布顶点天然让近处顶点密度高、远处密度低而且网格方向变化伴随着相机旋转不会产生规律的莫尔条纹。配合顶点着色器里的LOD过渡画面稳定度明显更高。4.1 LOD过渡怎么做才不会“跳变”源码里的LOD不是离散切分而是靠“顶点融合权重”做平滑过渡。当相机距离跨过一级LOD边界时内圈网格和高一级LOD网格同时存在顶点最终位置是两套网格的加权混合。权重从0线性渐变到1视觉上只会有非常轻微的几何蠕动不会出现一整块网格突兀切换的爆点。实现时需要注意沿LOD边界保持法线的一致。游戏引擎里我们看到海面网格切换处突然亮一条线往往就是法线没有做同样权重的过渡。这个项目的做法是把法线也存储为两个层级各自的法线然后在顶点着色器里插值混合。简单说“顶点坐标混合了法线也必须一起混合”。4.2 网格拓扑如何配合波形频率网格顶点密度不是越高越好因为Gerstner波高频部分比低频部分更难表现得精准。如果一个波长超过20米的波在顶点间距5米的网格上效果还可以如果海浪波长只有2米5米间距的网格会直接出现“波浪穿过网格却没有顶点响应”的视觉错误。这个项目在生成网格时已经按波长范围做了约束主波波长和网格间距保持一个比例关系保证每个波长内至少有四个顶点参与采样。这条约束是后来排查“波浪怎么不够好看”时最常忽略的原因。5. 水色与光照从“蓝玻璃”到“像水”的距离如果波形解决了运动的可信度那颜色光照解决的就是材质的可信度。一块单纯蓝平面和真实海面的差距主要在光线的吸收和反射分布上。最廉价的方案是漫反射蓝加一个有向高光实际表现就像一块上釉的瓷砖。源码里选了几个非常关键但实现成本不算高的模型组合。水体越深看起来越暗、越偏深蓝是水体对不同波长光的吸收率不同造成的。所以源码中通过深度纹理获取当前像素距离海底的深度近处浅滩透出沙滩或沙地底色远处深海将漫反射颜色压暗并偏向青色。这个效果不需要严格参与多次散射模拟只用一个指数衰减函数就能得到足够可信的结果。5.1 菲涅尔海面永远不可能是一片纯色塑料不管什么角度看近乎水平的掠射角区域反射亮度远高于垂直俯视角度的亮度。源码里用Schlick近似处理菲涅尔用F0参数控制水面特征色。海水的F0非常低约0.02意味着正视时大部分光线透射进水体只有很弱的反射掠射时反射率趋近1能看到完整的天空倒影。没有菲涅尔的水面从山顶俯视所有水面都像一块深色地毯加了菲涅尔后远山方向的水面会突然亮起来光线和空气透视感立刻出现。这可能是整个水面Shader里性价比最高的一行公式。5.2 反射不是只有CubeMap一条路CubeMap做海面反射确实简单代价是场景里的动态物体船、飞鸟、落日云无法实时出现在反射里。源码用屏幕空间反射作为增强先采样主场景的深度缓冲重建世界位置再沿反射方向做raytrace。SSR最怕的问题是反射方向掠射角过大导致采样步长太大产生假跳源码里设置了反射强度随相机距离和像素粗糙度衰减并只对近景启用。实际调的时候建议把SSR只加到水面距离相机20米以内再远就让反射权重平滑归零过渡交给雾效。很多人同时开SSR和CubeMap导致反射叠加层次混乱这个项目的方法是把CubeMap作为远距离兜底反射SSR负责近距离高动态细节两者用反射强度混合没有做加法叠加。5.3 泡沫浪头和浪尾怎么区分泡沫是让水面看起来“活”起来的关键细节。源码的做法是在波峰采样高度当波峰高度超过阈值后泡沫区域逐渐亮白然后随着波面下沉慢慢消散。更有意思的是泡沫的产生还叠加了一个“浪向”判断垂直于浪向的坡面更倾向起泡平行方向不容易起泡这符合自然观察。泡沫最容易做的假象是完全静止的白色波浪花边。真实泡沫应该有延迟扩散、缓慢消失、逐渐透出背景水色的过程。项目里用一张处理后的噪声纹理控制泡沫纹理的透明度让泡沫边缘不规则、内部出现空洞大幅削减了“贴纸感”。6. 踩过的坑从波浪穿透到远海发黑任何海洋项目在开发过程中都会踩到几个典型的坑这个项目也留下了很明显的痕迹。源代码注释里甚至写着“do not delete this line, or the shape will break at noon light”这样比较有个人色彩的提醒。最让人头疼的问题是波浪穿透。船体和柱子明明应该浮在波峰上但穿模现象非常严重。排查半天发现问题出在碰撞体材质浮力系统查询的碰撞体还是BoxCollider原始高度而视觉上的海面高度已经远远低于箱体中心导致船被埋在水里。解决方案很简单碰撞体应该用波高查询函数的高度值加上吃水深度而不是用包围盒中心。这个改动小但影响沉浸感极大。6.1 远海发黑一场深度纹理的“功劳”另一个非常常见的问题是远海莫名发黑。一开始以为是天空反射没有覆盖到后来一行行查Shader才发现是深度纹理采样时用到了近裁剪面附近的深度值导致远处水面被错误判断成极深的水体漫反射颜色压到几乎为0。修复方法是限制深度采样的最大范围超过一定距离后用天光颜色填充同时把“深度决定颜色”的权重做平滑限制避免线性映射。这类问题的排查思路有一个通用模板先看波形网格有没有起伏再看颜色材质模型是否正确最后看数据流深度、参数有没有被污染。如果一个水面远看黑、近看正常问题大概率出在数据流而不是材质本身。6.2 风浪方向与网格的各向同性另外一个隐蔽的坑是风向和网格方向不匹配导致的不自然条纹。当风吹向与网格某条边平行时所有波峰排布会过于完美地沿着网格轴排成直线视觉上能看出一行行的规律。解决方式是把风向和网格方向做一点错位或者给浪向加一个随时间缓慢漂移的偏转角。源码里把这个偏转角也放进了参数列表方便美术按场景氛围自行调整。7. 想快速改造这个项目的话优先替换什么不管你是要把这套海洋模拟移植到自己的引擎里还是准备彻底改造源码里最值得保留的核心资产其实就是两个波浪高度查询接口以及水面着色拓扑。其他部分包括网格生成策略和LOD基本上可以按自己的渲染管线重做。如果暂时不想要实时FFT的复杂度可以从Gerstner版开始。把采样频率从每帧两次降到每帧一次网格LOD层级减少一级性能压力就会小一大截。等基础效果跑通后再考虑把FFT作为高配选项接进来。这是最稳妥的接法默认低配版本性能可控高配版本通过开关切换不会一上来就压垮资源受限的硬件。关于重复的波浪模式Gerstner波叠加周期会让顶层效果在一段时间后重复这是任何纯解析波的宿命。要破解不是靠多加几个正弦波而是让波长比不要取整数关系比如1.0、1.7、2.31、4.57让公倍数成倍拉长。这个项目的默认参数已经这么做了所以视觉重复周期能拉到几十秒以上日常看不出来。如果你打算直接在这个项目基础上做二次开发我个人建议先花半天时间把参数系统的控件面板做出来把所有重要的波高、波长、风浪方向、泡沫阈值暴露到面板上。因为后面所有效果调优都会高度依赖这套面板。没有面板你只能回到源码里改参数再编译迭代效率会降到让人怀疑人生。海洋模拟项目源码真正的价值不是那些漂亮的RenderDoc截图而是一条写清晰了的数据链。你能在源码里看到波形生成、网格拓扑、着色模型、交互反馈这条链路是怎么稳定咬合在一起的这比单独看某个Shader神技有用得多。以后遇到海面问题先问自己“问题出在哪条链上”基本就能少走一半弯路。
返回列表