同样在这篇文章开始前重申一下:山海鲸并没有使用 ThreeJS 引擎!但由于 ThreeJS 引擎使用广泛,下文中直接用 ThreeJS 同 CesiumJS 的整合方案代替山海鲸中 3D 引擎和 CesiumJS 整合。
系列传送门:
山海鲸可视化:GIS 融合之路(一)技术选型 CesiumJS/loaders.gl/iTowns?
山海鲸可视化:GIS 融合之路(二)CesiumJS 和 ThreeJS 深度缓冲区整合
山海鲸可视化:GIS 融合之路(三)CesiumJS 和 ThreeJS 相机同步
到了这一篇文章,直接融合的工作基本结束,剩下的是对光影效果的整合和提升。说到视觉效果,那不得不提大名鼎鼎的虚幻引擎,恰好 Cesium 团队也做了一个Cesium For Unreal的产品,将 Cesium 对地理信息的展示能力结合了 Unreal 的视觉效果,可谓如虎添翼。我们先放一张 CesiumJS 和 CesiumForUnreal 的对比图,我选取了同一个经纬度的地区,图中上半部分时 Cesium 的 Sandcastle 效果图,下半部分是 CesiumForUnreal 的 Samples 中的截图。(这个地区就是 CesiumForUnreal 默认的项目经纬度)
可以看出,Cesium For Unreal 默认项目的表现力显著强于 CesiumJS 的效果。那我们就要问到底是什么让 Cesium For Unreal 的效果更加真实呢,这里就不得不提真实感渲染中大名鼎鼎的大气散射了。
同样的,我们放以下在山海鲸中整合 CesiumJS 的效果(实际上山海鲸中默认还会有体积云,为了同效果对比,我在图中把体积云关掉进行预览)
山海鲸 CesiumJS
可以看出,山海鲸中默认的效果已经非常逼近 Cesium For Unreal 的效果了,而且山海鲸默认自带了体积云效果及非常灵活的体积云设置,同样在 Unreal 中想要达到类似效果需要购买插件或者需要自己用蓝图对体积云进行建模。同时由于山海鲸中整合的是 CesiumJS,因此大家可以用自己熟悉的 JS 语言和 CesiumJS 接口来对山海鲸中 CesiumJS 进行二开,之前的 CesiumJS 项目甚至都不需要改什么代码就可以一键迁移,开发成本和学习难度远远低于 Unreal 的 C++或者蓝图。
好了,广告打完了,下面就要开始正题了。山海鲸到底是通过怎样的改造将 CesiumJS 的效果提升到 Cesium For Unreal 的效果呢?那我们需要先了解一下什么是大气散射以及大气散射的渲染原理。
什么是大气散射呢?大家可以做一个思维实验,首先我们知道光在空气中是沿着直线传播的。我们也知道地球大气层以外就是一望无垠的宇宙空间,那么我们白天看向天空时,按说我们应该看到外太空才对(就像晴朗无云的晚上一样),为什么我们却看到的是蓝色呢?原因很简单,是因为太阳光被我们大气层中的大小粒子散射到了我们眼睛里,就是部分光线拐弯了,这个就是大气散射。同样的,当我们看向远处的山的时候,为什么山越远,看着就越接近天空的颜色(所谓秋水共长天一色)也是因为离视线越远,那么经过的空气粒子越多,那么太阳光经过空气粒子散射到我们眼睛里的颜色占比就越大,这个就是空气透视(AerialPerspective)。
大气散射不同时间不同高度观测效果
这类的文章可以说非常多了,我这里给大家简单归纳一下,毕竟这个系列文章主要着重在整合,而不是渲染层面。技术细节我就不说太多了。
实际上大家用过 ThreeJS 就知道,ThreeJS 的 Examples 里自己就有动态天空,我记得山海鲸第一版动态天空就是从这段代码里移植的,这个基本就是一个典型的 Single Scattering 的天空渲染模型。如果大家想了解技术细节,可以移步这篇文章:Simulating the Colors of the Sky。我记得山海鲸刚开始使用这版天空时,所有同事看着都摇头,表示这天空太灰了,能不能变蓝一些。我表示:你们都不懂物理!现在想来看来还是我不懂物理啊,因为这里缺了 MultiScattering 和 Ozone。
现在工业界领先的 3A 游戏或者顶尖引擎,大多都实现的是MultiScattering。同时由于性能因素,直接进行 RayMarching 实际上是非常浪费的,因此大多都基于 Eric Bruneton 等人在 2008 年提出的 Precomputed Atmospheric Scattering 模型,将大气散射的参数都预计算好成 lut,在后续渲染中在进行查询的方式实现。最终 UE 在 2020 年优化了这个算法,进一步降低了 LUT 的计算难度。技术细节参考这个链接中的文章:https://sebh.github.io/publications/egsr2020.pdf。
好了,原理我们都懂了,下面就看我们能不能过好这一生了。不对,就看我们能不能在 webgl 中实现并且和已经合并渲染的 CesiumJS 进行整合了。由于 UE 不仅仅把自己的渲染原理写成了一篇文章,同时 UE 的代码也算是开源的,因此实际上我们是能看到全部的实现细节的,那我们就看看将其移植到 webgl 中的难度以及解决方案:
实际算法中除了论文中几个 LUT 以外,还有一个 DistantskyLut,这个 LUT 是取了地面海拔 6km 高度的空气散射积分结果。主要用在体积云和高度雾的融合部分,本文内容暂且不涉及。值得一提的是我们在移植过程中也做了一个小优化。在 UE 当中 DistantSkyLut 是每帧渲染的,我们也采用 TransmittanceLut 的方案将太阳角度作为 x 轴一次性把所有结果渲染在一张贴图中,这样只用在场景加载时渲染一次就可以了。
不管是大气散射还是体积云的渲染,在现代客户端引擎中都大量使用 ComputeShader。然而遗憾的是,Webgl 中不支持 ComputeShader(WebGPU 倒是有,但是现在是真不敢用),所以采用 ScreenViewQuad 的方案去代替,虽然略微损失性能,起码在写的时候还算优雅。具体方案我系列文章二中已经有提及,这里就不再赘述了。
webgl 啊 webgl,你可是真的不争气啊,你但凡是采用的 opengles3.1 也比现在好用太多了,可惜木已成舟,咱只能带着镣铐跳舞。为什么我们需要 GeometryShader 呢,因为在 AerialPerpectiveLut 是一张 3d 的 LUT。我们没有 ComputeShader,也没有 GeometryShader,那我们就没办法一次性渲染到一张 3d texture 的所有 slice 当中去。在论文或者 UE 实现中,AerialPerpectiveLut 是一张 32×32×32 的贴图,难道在 Webgl 上要 32 次 drawcall 吗?不忍心啊,这就像苹果好不容易把 iPhone 做薄 1mm,我买回来就装一个 1cm 的壳一样。对不起人家费尽心思优化的算法啊。经过一番搜索发现,webgl 可以通过 multirendertarget 一次性写入多个 slice,结合常见浏览器一次最多写 8 个 pass 的限制,最终实现了 4 次 drawcall 绘制一个 AerialPerpectiveLut。
UE 算法中建议空气透视在不透明物体渲染完后执行一次后处理,然后对于透明物体在 VertexShader 中叠加空气透视,但是由于我们无法在 Shader 中读取到一个像素点上的 Multi Sample 的值,因此直接做后处理就会有一些瑕疵。遗憾的是,这个问题目前我们依然还在考虑解决方案。
解决这些问题之后,就只剩下和 Cesium 整合了,这个很简单,就是在前面说到的 Cesium 渲染结果再次渲染到我们引擎的 QuadMesh 上的时候,再逐像素叠加空气透视效果即可,因为我们已经有了 Cesium 的 Depth Buffer 了。
好了,到这里总算是把 UE 算法中完整的大气散射和空气透视在 webgl 上同 CesiumJS 整合到了一起,而且对 CesiumJS 代码没有任何侵入性修改。下一篇我们看一看对于大气中其他元素的如体积云和指数高度雾的整合。帮人帮到底,送佛送到西。咱一步到位,把 UE 中天空插件和高度雾的效果也一并整合好,甲方爸爸们应该就可以心满意足了。最后我们看一下大气散射在山海鲸中最终的效果视频:
山海鲸可视化空气透视效演示
山海鲸昼夜大气散射变化