山海鲸可视化

GIS融合之路(二)CesiumJS和ThreeJS深度缓冲区整合

在这篇文章开始前再次重申一下,山海鲸并没有使用 ThreeJS 引擎。但由于 ThreeJS 引擎使用广泛,下文中直接用 ThreeJS 同 CesiumJS 的整合方案代替山海鲸中 3D 引擎和 CesiumJS 整合,系列传送门:
山海鲸可视化:GIS 融合之路(一)技术选型 CesiumJS/loaders.gl/iTowns

文章开始之前大家可以看下这个视频当中山海鲸中 CesiumJS 与山海鲸深度整合的结果,图片中展示了 Cesium 的地形和山海鲸中的水面的整合,这个过程中就有一个完整的深度缓冲区的整合:
image.png
具体内容可以移步完整的教程查看: GIS 地形编辑-山海鲸可视化视频教程

上一篇文章里简单介绍了山海鲸中城市大师为了整合 GIS 系统所做的技术选型的探索,最终我们决定采用先后绘制的形式在单个 Canvas 上整合山海鲸的 3D 引擎和 CesiumJS。那有同学要问了,如果一个先画,一个后画,后画的不就把先画的覆盖了吗?这里我们就要学到深度缓冲区的概念了。

1. 深度缓冲区的概念

深度缓冲度也称之为 DepthBuffer,是 GPU 为了对光栅化渲染时物体的遮挡关系进行排序用到的概念。概念本身很简单,就是每绘制一个物体的同时,把这个物体在每一个像素点上的深度信息与这个像素点之前的深度信息进行对比,如果这个像素点的深度较小(注意这要看具体深度缓冲的 DepthFunction,一般在 WebGL 上默认是最大的是 Depth 是 1,因此越小越近)则继续渲染像素颜色,否则直接丢弃。

有了深度缓冲区,问题就变得简单了,让 CesiumJS 先画,山海鲸引擎后画。只要保证深度写入和深度测试都是默认打开的,那不就万事大吉了。Done,下班!

等等,好像有问题;等等,好像有不少问题!等等,好像完全不行….
image.png

2. 三个问题

问题可是真不少,咱还得一关一关的过啊:

2.1 CesiumJS 默认用的 LogarithmicDepth,而普通的 3D 引擎默认用的是 LinearDepth

按说这也不是什么大问题,CesiumJS 支持修改 Scene 上的 logarithmicDepthBuffer 改成 linearDepth,Threejs 这类也基本都实现了 LogarithmicDepth,因此不是大问题。不过由于 CesiumJS 一般都是大场景和超大场景,改成 Linear 的话一定会有严重的 Z-Fighting,而 ThreeJS 这类主要是小场景,改成 LogarithmicDepth,又会导致在近景部分 depth 精度不足。当然希望近景和远景同时完美,本身在技术上也是鱼和熊掌的问题,我们暂时不去深入解决这个问题。

2.2 CesiumJS 默认的渲染方式是距离切段后逐段渲染

这条就非常坑了,CesiumJS 默认会将整个相机裁切空间(近平面到远平面之间的空间)分成多段,然后逐段渲染。这样做的好处显而易见,可以进一步拓展 Depth 的利用率,非常适合 CesiumJS 这种知道所有模型的位置且不会有体积超出范围的大模型的情况。然后每次分段绘制结束之后,depth 的信息就会被清除,导致最初规划的深度缓冲度整合的方案完全无法使用(除非关闭这个分段绘制机制),只能采用新的方案。

2.3 CesiumJS 绘制过程无法嵌入

CesiumJS 绘制过程机制及其复杂,想要找到一个合适的时机将 ThreeJS 这类引擎的绘制过程嵌入进去非常困难,而且也没有对应的接口,写起来对 CesiumJS 代码侵入性极强,后续 CesiumJS 升级时很难跟随升级,为将来的可维护性留下很深的隐患。

综合这三个问题,最终决定不再让 CesiumJS 直接绘制到 Canvas 上,而且采用 CesiumJS 提供的 PostProcessStage 接口将整个绘制的 ColorBuffer 和 DepthBuffer 都存入 FrameBuffer 当中,在 ThreeJS 中再将这两个 FrameBuffer 转换为 WebGLRenderTarget。

3. 进行绘制

通过这两种方式就可以拿到 CesiumJS 的绘制结果。将 CesiumJS 的绘制结果转换为两个 Texture 之后,就要在 ThreeJS 端绘制进去。这个过程类似 PostProcess 的过程,但是要先做。这里参考 CesiumJS 中的 ViewportQuad 接口,在 ThreeJS 中创建一个 PlaneGeometry,设置一个 ShaderMaterial,在 VetexShader 中,将四个点对齐到整个视口的四个角上,实现代码非常简单。

1
2
3
void main() {
gl_Position = vec4( position.x, position.y, 1., 1. );
}

在 FragmentShader 当中读入 ColorBuffer 和 DepthBuffer

1
2
uniform sampler2d czmColorSampler;
uniform sampler2d czmDepthSampler;

ColorBuffer 直接写入,非常简单,depth 如何写入呢?

4. 写入 depth

首先我们要明确我们从 CesiumJS 拿到的是什么 Depth,查看 Cesium 源码可以发现,我们拿到的 depth 是 LogarithmicDepth 被映射到 01 之间之后被 pack 在 rgba 四个通道上之后的结果。因此我们首先要对 CesiumJS 的 depth 进行 unpack,并且根据相机的 near 和 far 将 depth 恢复到相机空间的 z 距离。拿到这个距离之后为了方便存储,山海鲸目前的做法是将这个 z 距离再在自己引擎的相机中的 near 和 far 做一次映射,算出线性的 01 的 depth,这样就可以和自己引擎中拿到的 depth 一致了,当然为了方便存储,也 pack 到 rgba 中区。最终得到的结果存入 czmDepthSampler,具体结果如下图所示:
image.png
czmColorSampler
image.png
czmDepthSampler

image.png
最终合成图

5. 用 GPU 自己的深度缓冲区测试

这个结果理论上就可以直接和 ThreeJS 里的相机空间的 depth 进行对比了,但是注意我们这里并不打算认为对比,而是希望用 GPU 自己的深度缓冲区测试,这个怎么做呢?这里就要用到 Shader 的深度写入功能,一般来说 GPU 在拿到 vetexShader 中的 gl_Position 之后会自己把得到的坐标转换到 NDC 空间中,并进一步将 depth 映射到 0~1 之间,再存入 depth buffer。
image.png
WebGL 中转换前和转换后坐标

我们需要在 FragmentShader 中接管这个功能,WebGL 也提供的接口:gl_FragDepth。(但是要特别注意的是,一旦开启 Shader 的缓冲区写入,GPU 的 early-Z 优化就会自动关闭,所有的像素点着色都会进行,因此不是我们这种迫不得已的情况,还是尽量不要用的。)有的这些只是,我们只需要最后将线性空间的 depth 模拟 GPU 的计算过程,转换为 ndc 空间的 depth 写入就可以了。

我们通过以上方式正式将 Cesium 的渲染过程并入了山海鲸引擎的渲染过程当中,当然这中间还要处理很多 gl state 状态的问题,不过不管怎样,最难的一步已经越过去了,剩下的就是对目前的机制进行完善,防止状态冲突问题即可。但是深度整合成功是不是 Cesium 就整合完成了呢,正式成为了山海鲸可视化的一部分?答案显然是否定的,现在的 Cesium 除了遮挡正常了以外,相机还没有同步,一旦移动,就会发现完全对不上位置。

另外除了相机起码还有 3 个比较大问题:1.阴影的整合 2.光照的整合 3.G-buffer 管线数据的同步。别急,我们一步一步来,后面的文章逐个给大家展开这些问题的处理。