整个系列已经来到了九,实际是第十一篇文章。终于的终于,我们要来一起看看水怎么实现的。开始之前当然是先回顾一下之前的内容,系列传送门:
山海鲸可视化:GIS融合之路(一)技术选型CesiumJS/loaders.gl/iTowns?
山海鲸可视化:GIS融合之路(二)CesiumJS和ThreeJS深度缓冲区整合
山海鲸可视化:GIS融合之路(三)CesiumJS和ThreeJS相机同步
山海鲸可视化:GIS融合之路(四)如何用CesiumJS做出Cesium For Unreal的效果
山海鲸可视化:GIS融合之路(五)给CesiumJS加上体积云(Volumetric Cloud)和高度雾(Height Fog)
山海鲸可视化:GIS融合之路(六)-Cesium的雨雪风雷电效果
山海鲸可视化:GIS融合之路(七)-Cesium实现夜空月亮星星渲染
山海鲸可视化:GIS融合之路(八)-如何用Cesium直接加载OSGB文件(不用转换成3dtiles)
两个番外:
山海鲸可视化:GIS融合之路(五)番外-山海鲸的体积云又又又升级了
山海鲸可视化:高斯溅射和GIS融合之路- 将splat文件切片成3dtiles
说到水的渲染,不得不再次分享一下业内大佬的这篇文章,也再次缅怀一下:
毛星云:真实感水体渲染技术总结3516 赞同 · 97 评论文章编辑
由于水利是数字孪生中非常重要的一个领域,因此水面的渲染和大气,云层一样,也是山海鲸的重点攻克的目标。经过了两年的打磨,和与真实项目的磨合,山海鲸目前围绕水的渲染,已经实现了从平面,到波形模拟,到浅水方程式,到粒子的全流程水效的技术实现和快速使用,我们可以看下山海鲸中的水的概览介绍:
那么我们今天就一起来过一下Cesium中如何该如何整合水面以及如何在Cesium上实现洪水模拟。
下面我们沿着上面提到的这篇文章的思路看一下山海鲸中水面的实现:
一、凹凸贴图
我们从简单到复杂逐个来看下,首先考虑到大部分可视化项目运行的电脑性能都不太高,因此我们肯定得支持凹凸纹理贴图。当然这块实在是过于简单,原理上也没什么可说的。唯一值得提一下的就是反射,反射GIS地形或者3DTiles,如果用两次渲染的话,渲染效率就太低了。我们则直接使用SSR(SSR本身实现并不复杂,当然要做的很好是有很多细节工作要做的)。在和GIS结合的过程中,根据我们前两篇文章中提到的,我们是有一个depthBuffer。我们通过把Cesium的depthBuffer和孪生中的depthMap进行结合之后,在SSR步进的过程中直接使用这个结合后的DepthMap,就会自动整合了GIS的画面了。看下反射的效果:
实际基于凹凸贴图的水,只需要将整个水面抬高,就可以一定程度的模拟到洪水的效果,我们可以看下山海鲸这一篇水面结合数据的水体淹没的教程案例:
二、波形模拟(Gerstner和FFT)
对于凹凸贴图来说,我们只需要4个顶点就可以实现一个平面水面。但这样的水面边缘实在太硬了。为了实现更加真实的水面波形,同时也实现更加真实的水面渲染。光一个平面水,显然无法满足我们团队对自己的严格要求。肯定哪个最好用哪个,那我们先来看看工业界水面渲染的最常用海洋模拟算法FFT:
2.1 FFT波形模拟
FFT也是快速(逆)傅里叶变换,虽然这个技术本身和水体没有直接的关联,只是更方便GPU实现快速的频域转时域的算法。但由于基于海洋统计学得到的海洋波形频谱需要实时的转成高度场,因此我们就要用到FFT算法。刚开始我们决定用我们之前的方法移植FFT算法到WebGL上来(就像我们前面移植体积云和大气散射逻辑一样)。然而实际在移植的过程中却遇到了前所未有的困难:这一次WebGL缺失ComputeShader成为了致命问题,因为FFT计算过程中的蝶形运算需要大量使用ComputeShader中的共享存储的功能,也就是把计算结果放在一段所有计算单元都可以访问的存储当中。而使用WebGL普通的shader,用一个像素值作为一个计算单元时是无法在一段运算后共享所有计算单元的存储的。
经过仔细研究,我们得出了两个方向:
方向一:用WebGPU先执行ComputeShader。在将WebGPU的Context下的canvas直接传入WebGL的Context中,期待对应的浏览器底层实现的过程中会是GPU到GPU的模式,以免出现跨Context时需要用CPU当做桥接,从而导致性能大幅下降。这个方案虽然实现起来简单,但对浏览器底层的实现要求很高,经过慎重考虑决定放弃。
方向二:考虑拆分蝶形计算,将每一次蝶形计算作为一个pass。运算结束后利用Transform Feedback技术得到一个结果Buffer,将这个Buffer传回下一个蝶形计算的pass当中。这个方案导致一次fft计算会需要多次pass,而且在实际实现过程中也发现WebGL中的Buffer不能满足最低的FFT大小的要求,只能将Buffer又替换成Texture才最终实现,我们可以简单看一下我们实现的pass代码:
void ButterflyPass(int passIndex, int x, out vec3 resultR, out vec3 resultI) {
ivec2 indices;
vec2 weights;
GetButterflyValues(passIndex, x, indices, weights);
float fftResolution = float(FFT_RESOLUTION);
indices.x = isVerticle == 1.0
? int(position.z * fftResolution * fftResolution +
position.y * fftResolution + float(indices.x))
: int(position.z * fftResolution * fftResolution +
float(indices.x) * fftResolution + position.x);
indices.y = isVerticle == 1.0
? int(position.z * fftResolution * fftResolution +
position.y * fftResolution + float(indices.y))
: int(position.z * fftResolution * fftResolution +
float(indices.y) * fftResolution + position.x);
vec3 inputR1 =
texelFetch(pingPongArrayInTexture0, ivec2(indices.x, 0), 0).rgb;
vec3 inputI1 =
texelFetch(pingPongArrayInTexture1, ivec2(indices.x, 0), 0).rgb;
vec3 inputR2 =
texelFetch(pingPongArrayInTexture0, ivec2(indices.y, 0), 0).rgb;
vec3 inputI2 =
texelFetch(pingPongArrayInTexture1, ivec2(indices.y, 0), 0).rgb;
resultR = (inputR1 + weights.x * inputR2 + weights.y * inputI2);
resultI = (inputI1 - weights.y * inputR2 + weights.x * inputI2);
}
我们看下最终我们实现的效果:
FFT下的海洋效果
最终虽然我们极其Hack的实现了蝶形运算,但代价也十分巨大,于是我们只能做出妥协,每两帧做一次计算。也导致了最终如果FPS不高的情况下,会显得十分的卡顿。于是为了大部分用户能够使用这套新的水体,我们必须再实现一套对GPU性能要求较低的波形模拟,于是我们就转向了Gerstner波形的实现。
2.2 Gerstner波形模拟
Gerstner波形本身只是对Sin波的升级,由于Sin波只考虑水沿着y方向上的移动。而Gerstner波通过Sin波的叠加,制造出海面更加尖一些的浪尖,显得整个水体的波形更加的真实。虽然对比FFT实现的波形会显得稍微不真实且重复度会变高,但是由于优秀的性能,因此是我们推荐大家实际使用中主要选择这个波形方案,我们也来看下Gerstner波的实现效果:
2.3 水体渲染
我们前面部分只是针对水体的波形模拟,我们针对水面的渲染也做了大幅的更新,在基础的PBR材质之上进一步增加了以下几个效果:
效果一:水体的折射和散射效果。为了能很好的呈现折射效果,我们对整体的渲染管线进一步做了调整,在非透明物体和GIS渲染完之后,将整个framebuffer拷贝一次暂存下来,然后再渲染透明物体。后续渲染水的过程中在用这张暂存的framebuffer最为水的折射贴图。剩下的基本参考毛星云提到的细节进就可以实现,
效果二:浪尖白沫。浪尖白沫实际也是标准的算法,只需要在波形模拟结束后计算水面的雅克比矩阵即可得到浪尖白沫的位置。
效果三:岸边白沫。岸边白沫是水打在岸边时产生的白沫,岸边白沫的实现方式也并不复杂,如果希望精细一些,可以从视角顶部向下渲染一张深度图,再对比水面的位置即可计算岸边白沫的区域,我们后面讲到的浅水方程式的模拟水中就使用了这种精细的深度对比。但在波形模拟的这类水中,基于性能的考量,我们并不希望再单独用一个pass去渲染一张顶部深度图。那么我们就直接用基于视角的深度图去近似计算这个区域值即可。
最后我们也一起看下结合后的渲染效果:
三、基于浅水方程式的水体模拟
实现了波形模拟的水之后,基本上只关注水面渲染的场景就可以全部覆盖了,甚至如果客户有了水体模拟的结果也可以以高度图的方式实时的在山海鲸中进行孪生显示。那是不是工作就到此为止了呢?然而我们知道在UE中有一个效果极好的模拟水流的插件,被很多自称自研的套壳类产品整合到他们的产品中,那我们肯定不能就这么算了。不能对别人怎么样,就只能对自己狠一些了。我们也来手搓一个基于浅水方程式的水体模拟把。
浅水方程式本身是一种对水体模拟的思路,本质呢就是毛星云提到的欧拉方法,把水体先简化为网格,然后再计算网格之间的互相作用。当然这里特别提到的浅水(shallow water)可以让我们只考虑水面的网格,这样把水体简化成一个一个的柱体。我们借用一下Games103课程PPT的内容可以更加直观的看到这样的简化:
在每一帧的计算当中,我们只需要计算相邻网格的高度差来得到水流的加速度,通过加速度和前一帧的速度来计算这一帧水流的速度,再通过这一帧水流的速度计算下一帧网格高度的变化。当然最终我们需要去模拟类似洪水的效果,因此在水体模拟过程中需要考虑地形的存在,最简化的浅水方程式并不能满足我们的需求,最终我们根据论文《Real-time Simulation of Large Bodies of Water with Small Scale Details》实现了有地形存在的浅水方程式算法,具体论文链接如下:
在此基础上再进一步结合我们前面的水体渲染效果,最终可以实现真实的水体模拟,我们可以通过下面这个视频来看看在山海鲸中浅水方程水体模拟如果设置来实现洪水或者大坝开闸这类试试的水体模拟需求:
以上就是山海鲸可视化中对于水体渲染的技术总结,当然我们也用到了粒子效果模拟水流飞溅的瀑布等效果,这里没有什么技术难点,更多的是粒子系统的设置和粒子贴图的选择,因此就不做过多赘述了,大家如果有更深的兴趣也可以联系我们的客服了解我们过往项目中结合各种水面实现的水利水务等项目案例。