原生 Cesium 提供了一种积云的效果,云的物理特征和渲染性能都还不错,这种方案适合表达小范围相对离散的云朵,但是用来实现全球范围下相对连续、柔和渐变的云层比较困难。本文在体渲染的基础上,参考了开源社区中 shadertoy 和 three.js 关于体积云的一些例子,提出一种在 Cesium 上实现全球体积云效果的方案。
Cesium 上进行体渲染可以参考《Cesium中使用Sampler3D,3D纹理,实现体渲染》。体渲染有相对固定的实现流程,关键在于以三维纹理表达的体数据的构建。由于云没有统一的形状和密度,因此我们在构建体数据时,需要引入噪声来表达这种随机性。本文参考的 shadertoy 效果 用到了perlin 噪声和 worley 噪声的组合,并且在 perlin 噪声和 worley 噪声的基础上都加上了分形布朗运动(Fractal Brownian Motion),将不同振幅(amplitude)和频率(frequency)的多个噪声叠加起来,让噪声有更多细节,使得云层的形态更加自然。
体数据的计算我基本照搬了上面链接中 shadertoy 的代码。计算过程放在 CPU 或者 GPU 上都可以。我最开始是在 CPU 上做的,但是发现实在是太慢了,光计算体数据就要花两三分钟;后来转到 GPU 上,用渲染到纹理的方式,依次把三维纹理每一层(三维纹理可以看成是由一系列二维纹理叠放组成的)的数值绘制到二维纹理上;每绘制一层就把那一层的数据读取(gl.readPixels)到一个 Uint8Array 中,最后合并成一个大的 Uint8Array 用于构建存储体数据的三维纹理,基本流程如下面的代码片段所示。这种方式非常快,几乎感觉不到计算的耗时。这一步创建好的体数据在光线步进(RayMarching)的时候采样。我把计算好的体数据存在了一个 json 文件里,如果有同学想直接拿到一份可用的体积云数据,可以到这里下载。
const slice = 128; // 体数据是一张 128 * 128 * 128的三维纹理const data = new Uint8Array(slice * slice * slice * 4);for (let i = 0; i < slice; ++i) {// 清空上一次绘制的纹理renderClearCommand.execute(viewer.scene.frameState.context, viewer.scene.view.passState);// 离屏渲染三维纹理的一张二维切片数据renderColorCommand.execute(viewer.scene.frameState.context, viewer.scene.view.passState);// 读取一层二维切片的像数值const pixels = viewer.scene.context.readPixels({ framebuffer: renderFbo,x: 0,y: 0,width: slice,height: slice});// 存储像数值到体数据的 Uint8Arraydata.set(pixels, i * slice * slice * 4);}
用GPGPU的方式生成体数据
光线步进(RayMarching)一般不会从相机位置就开始迭代,为了提升性能,都是将射线和体渲染的范围几何体求一个近处的交点、一个远处的交点,缩短步进的距离。我们要渲染全球范围的体积云,云层的最小高度和最大高度分别确定了两个球体,射线需要和这两个球体求交。下面代码参考自《Unity URP RayMarching 体积云》,其中 raySphereDst 函数用于计算射线和球体的相交情况,rayCloudLayerDst 函数计算从相机发出的射线和云层最小高度和最大高度分别确定的两个球体的相交情况,从而得到体积云渲染光线步进的起点和终点。
/*计算射线和球体的相交情况sphereCenter 球体中心sphereRadius 球体半径rayOrigin 步进起点rayDir 步进方向返回值:dstToSphere 射线起点到球体的距离dstInSphere 射线穿过球体的距离*/vec2 raySphereDst(vec3 sphereCenter, float sphereRadius, vec3 rayOrigin, vec3 rayDir){vec3 oc = rayOrigin - sphereCenter;float b = dot(rayDir, oc);float c = dot(oc, oc) - sphereRadius * sphereRadius;float t = b * b - c; // t > 0有两个交点, = 0 相切, < 0 不相交float delta = sqrt(max(t, 0.0));float dstToSphere = max(-b - delta, 0.0);float dstInSphere = max(-b + delta - dstToSphere, 0.0);return vec2(dstToSphere, dstInSphere);}/*计算相机发出的射线与云层范围的相交情况返回值:dstToCloudLayer 到云层的最近距离dstInCloudLayer 在云层中穿过的距离*/vec2 rayCloudLayerDst(vec3 rayOrigin, vec3 rayDir){vec3 sphereCenter = vec3(0.0);vec2 cloudDstMin = raySphereDst(sphereCenter, (minCloudHeight + earthRadius) / (maxCloudHeight + earthRadius), rayOrigin, rayDir);vec2 cloudDstMax = raySphereDst(sphereCenter, 1.0, rayOrigin, rayDir);float cameraHeight = czm_eyeHeight;// 射线到云层的最近距离float dstToCloudLayer = 0.0;// 射线穿过云层的距离float dstInCloudLayer = 0.0;// 在地表上if (cameraHeight <= minCloudHeight){vec3 startPos = rayOrigin + rayDir * cloudDstMin.y;dstToCloudLayer = cloudDstMin.y;dstInCloudLayer = cloudDstMax.y - cloudDstMin.y;return vec2(dstToCloudLayer, dstInCloudLayer);}// 在云层内if (cameraHeight > minCloudHeight && cameraHeight <= maxCloudHeight){dstToCloudLayer = 0.0;dstInCloudLayer = cloudDstMin.y > 0.0 ? cloudDstMin.x: cloudDstMax.y;return vec2(dstToCloudLayer, dstInCloudLayer);}// 在云层外dstToCloudLayer = cloudDstMax.x;dstInCloudLayer = cloudDstMin.y > 0.0 ? cloudDstMin.x - cloudDstMax.x: cloudDstMax.y;return vec2(dstToCloudLayer, dstInCloudLayer);}
计算光线步进的起点和终点
上面的步骤分别是体数据的生成和光线步进起止点的计算,体积云的正式渲染可以参考 three.js 官方给出的体积云示例,这个示例也只适合小场景离散的云朵渲染,结合上面的步骤可以在 Cesium 上拓展为全球体积云效果。该示例的实现比较简单,主要工作量是在通过噪声生成体数据以及片元着色器中相关的着色代码。
本文介绍的全球体积云实现方案主要是把现有的一些开源方案做了组合,它不需要依赖外部的噪声纹理,也不用再从片元向光源步进去计算云层的漫反射光颜色,优势是实现步骤简单明了,在仿真要求不是特别高的场景是够用的。最终可以得到如下图1和2所示的体积云效果。
图1 全球视角下的体积云效果
图2 近地面体积云效果