拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Cesium全球动态光照体积云渲染:Ray Marching实战与性能优化

简介一套基于体渲染的Cesium全球体积云实现代码面向需要在大范围三维地球场景中模拟连续云层效果的前端GIS、WebGL开发者。方案结合shadertoy与three.js社区思路使用Perlin噪声与Worley噪声叠加生成云形并通过分形布朗运动增强细节层次云层体数据先由CPU计算后改为GPU渲染到纹理加速生成再以光线步进完成渲染整体不依赖外部噪声纹理实现门槛较低。包内共4个文件含两个JavaScript脚本、一个HTML页面及一个inscode工程配置分别对应体数据生成、云层渲染与页面演示入口压缩包仅9KB结构清晰便于阅读。目前已有436人学习下载适合想快速上手体积云渲染或为Cesium扩展天气效果的中高级开发者。代码虽精简但完整呈现了从噪声生成到光照步进的主干流程可直接运行并修改参数观察云层变化对飞行模拟、智慧城市可视化等真实感要求较高的场景也有直接参考价值。 做Cesium项目的时候一旦涉及“全球”这两个字很多在局部场景里特别成熟的做法就都不好用了。体积云渲染就是这样。我前后试过贴图云、粒子云、Sprite云和Cesium地表材质方案要么从太空看是平的要么转到全球视角就穿帮要么性能直接崩掉。折腾了几个月后最终沉淀下来一套能真正覆盖全球、支持动态光照的Ray Marching体积云方案。这篇文章不聊PPT式的架构图直接把我实测过的实现路径、关键代码、参数选择和踩坑记录写出来。这套方案适合谁如果你正在做Cesium的三维数字孪生项目、全球气象可视化、飞行模拟或者是想给WebGIS场景加一层“电影感”天空效果那么这篇文章可以帮你直接省掉一大轮弯路。接下来我会从渲染原理讲起一步步拆到Cesium集成、性能调优和问题排查。1. 方案选型全球体积云为什么必须走Ray Marching1.1 为什么贴图云、粒子云在全球尺度下都不靠谱Cesium是一个地球级别的GIS平台相机可以无缝从地表拉到卫星轨道。在这样的场景下云不是一个“道具”而是必须符合地球曲率的半透明体积层。贴图云本质是一张带透明通道的平面图片贴在球体上后从侧面看就是一片薄纸完全穿帮粒子云更考验性能全球范围几百米尺度的粒子数量不会是一个小数目还要处理粒子与地球曲率的关系计算量本身就失控Sprite云只能在局部区域摆几个广告牌它们适合做“大气层的一朵点缀”但根本撑不起“全球云图”这种需求。1.2 后处理Stage要比Cesium的材质系统更适合做全球云层这里有一个关键判断云层体积应该“悬浮”在地球上方而不是像地表纹理一样贴在地形上。Cesium的CustomShader、Fabric材质天生是给地形、模型或billboard做表面效果用的要做真实的云层光照和体积遮挡绕来绕去非常别扭。我的选择是PostProcessStage后处理。思路很直接在每一帧渲染完成后拿到的其实是一张包含场景颜色的纹理。我在后处理片元着色器里根据每个像素的屏幕坐标反算出视线方向再用视线方向去和“云层包围壳”求交沿视线做步进采样累积颜色和透明度最后叠加回场景。这样云层天然就是全球的跟相机位置无关也不需要额外的几何网格。很多人在这一步会犹豫Cesium的PostProcessStage是作用于全屏的会不会跟UI文字、billboard冲突其实不会因为后处理拿到的colorTexture就是场景本身我们只是在场景颜色上叠加云层最终结果再与场景自动合成。只要把云层alpha计算正确不会破坏原有的实体渲染。1.3 动态光照云的颜色不是“贴图颜色”是散射和吸收的结果体积云和普通模型的Phong光照完全不是一回事。普通光照算的是一个表面体积云是半透明的体积光在云层内部被散射、吸收还会穿过云体形成阴影。所以体积云的颜色计算实际上是一个“体光照”问题。在Cesium里我直接用Simon1994PlanetaryPositions计算太阳在ECEF下的方向把它作为uniform传到后处理着色器里。这样云层的光影会随着场景时间动态变化早晨和黄昏的云会带出暖色调正午则更白亮。这个动态光照是整个方案里观感提升最大的一项也是我标题里为什么特意强调“动态光照”的原因。2. 全球体积云核心实现包围壳、噪声与密度场2.1 云层包围壳用内外两个椭球限定云的“活动空间”要渲染全球云层我不能在世界里凭空造一个云层实体。更通用的做法是给云层定义一个“包围壳”。在Cesium的ECEF坐标系下我直接用WGS84椭球的两个同心高度面来构造内壳约在海拔1500米外壳约在海拔8000米。相机任意位置我都可以在着色器里用射线与这两个椭球求交得到光线进入云层和离开云层的两个距离值。这一步看起来很数学但对精度影响很大。如果只用球体做包围壳在近地视角下云层底部会和真实地形高度产生肉眼可见的误差尤其是高原和山区。用椭球能直接复用Cesium的Ellipsoid参数把云层约束在一个贴合地球形状的厚度带中。在实际代码里射线与椭圆面的求交可以用解析公式做也可以迭代逼近。我建议解析求交因为迭代在远距离视角下性能和精度都不稳定。Cesium自己也内置了很多椭球计算相关的数学方法可以直接参考。2.2 3D噪声纹理云的“形状DNA”体积云形状的核心是3D噪声。我用了两层噪声低频Perlin噪声决定云的整体聚散形态高频Worley噪声负责给云层边缘增加蓬松细节。光靠单次噪声是不够的世界上的云有明显的“絮状感”所以我会把多个倍频程的噪声叠加起来这个技术叫FBMFractal Brownian Motion。具体实现上我在初始化阶段预生成一张3D纹理比如64×64×64把3到4个倍频程的噪声结果打包进纹理的RGBA通道。运行时只需采样这张3D纹理再用少量ALU做FBM叠加就能获得不错的云形态。这么做的好处是效率高缺点是纹理分辨率有限靠近看会有重复感。所以我建议采样时对坐标乘一个缩放因子让噪声在空间上不产生明显周期。2.3 云密度函数高度衰减、覆盖度与边缘软化有了噪声形状后不是所有噪声值都能成为云。我通过一个密度函数来“雕刻”云朵高度因子在1500米到8000米的区间内云层中部密度最大底部和顶部逐渐衰减。这个衰减曲线我习惯用平滑的梯形或正弦曲线模拟。覆盖度Coverage用一个0到1的参数控制全球云的总体覆盖率。cover越大云连成片越接近0云越稀疏破碎。边缘软化对采样得到的噪声值做一个阈值映射让云层边缘具有渐变的半透明过渡而不是硬边。这个密度函数我调了很多版本最终发现一个经验值Coverage在0.5附近时云层在视觉上既有大片的云区也有零散的碎云对整个地球的观感最自然。过于拉高Coverage后云层容易糊成一大片反而看不出体积感。2.4 代码示例噪声采样与密度合成这里直接给一段在Cesium后处理片元着色器里用到的密度函数GLSL核心逻辑float sampleCloudDensity(vec3 p, float time) { vec2 uv geographicUV(p); // 低频大形 float base texture3D(noiseTex, p * 0.0004 vec3(0.0, 0.0, time * 0.02)).r; // 高频细节 float detail texture3D(noiseTex, p * 0.002 vec3(time * 0.03)).g; // 高度衰减innerAlt和outerAlt需要外部传入 float altFactor smoothstep(innerAlt, midAlt, height) * (1.0 - smoothstep(midAlt, outerAlt, height)); // 覆盖度裁剪 float density max(0.0, base - (1.0 - coverage)); density * detail * altFactor; return max(0.0, density); }实际工程里我会把p的坐标从ECEF转到局部空间再采样主要是为了规避全球坐标数值过大带来浮点精度问题这个在下面的排查章节细讲。3. 与Cesium集成后处理、视线步进与光照合成3.1 创建后处理Stage并注入动态uniformCesium里创建一个自定义后处理效果并不复杂关键是要把“每帧会变化”的uniform注入进去。以相机位置和太阳方向为例const cloudStage viewer.postProcessStages.add( new Cesium.PostProcessStage({ fragmentShader: cloudFragShader, uniforms: { cameraPosition: new Cesium.Cartesian3(), sunDirection: new Cesium.Cartesian3(), cloudCoverage: 0.5, }, }) ); viewer.scene.postProcessStages.add(cloudStage); // 每个渲染帧更新uniform viewer.scene.postUpdate.addEventListener(() { cloudStage.uniforms.cameraPosition viewer.camera.positionWC; cloudStage.uniforms.sunDirection Cesium.Simon1994PlanetaryPositions.computeSunDirectionInWorld( viewer.clock.currentTime, Cesium.Matrix3.IDENTITY ); });注意uniform里不要放Cesium的Matrix4对象直接当vec3用PostProcessStage会自动做类型映射但是为了保险我都是从Cartesian3对象取分量传入避免因为对象引用问题导致更新不生效。3.2 Ray Marching主循环的GLSL实现后处理片元着色器里我首先要反算视线方向。通过varying拿到屏幕UV再配合相机逆视图、逆投影矩阵可以重建出视线方向。在Cesium的PostProcessStage中它会自动注入一些uniform比如czm_inverseProjection和czm_inverseView直接乘就能从NDC坐标得到世界坐标方向。然后执行射线与椭球的求交进入ray marching主循环。下面是伪代码级别的核心流程vec3 rayDir normalize(reconstructWorldDir(uv)); vec2 t intersectEllipsoidShell(cameraPos, rayDir); // 与全球云层壳求交 vec3 acc vec3(0.0); float transmittance 1.0; for (float tCurrent t.x; tCurrent t.y; ) { vec3 pos cameraPos rayDir * tCurrent; float density sampleCloudDensity(pos, time); if (density 0.002) { float light computeLighting(pos, rayDir, sunDir); vec3 color light * density * cloudColor; acc color * transmittance * stepSize; transmittance * exp(-density * stepSize * absorbCoef); } if (transmittance 0.02) break; // 提前终止 tCurrent stepSize; } gl_FragColor vec4(acc, 1.0 - transmittance);这里computeLighting函数计算当前采样点被太阳照亮后的散射光和透射衰减。实际编写中有很多优化空间比如步长不固定、远处步长短近处步长长再比如云层很薄的时候可以直接跳过大步长段。3.3 光照与相位函数让云层有体积感体积云的光照计算我参照了Schneider和Vos的做法在单次散射假设下点p处的亮度可以写成L(p) I_sun * T(p, sunDir) * sigma(p) * phase(angle) 环境光其中T(p, sunDir)代表从该点到太阳方向的透射率sigma是散射系数phase函数用Henyey-Greenstein相位函数来模拟云层的前向散射。云在逆光时边缘会呈现“银边”效果很大程度就是相位函数的功劳。我用一个近似技巧提升效率主步进循环里对每个采样点做一次向太阳方向的光线采样开销太大。我退而求其次用同一个采样点的噪声密度值去估算光照遮蔽再用一个pow函数模拟多重散射的补光。简单说就是float light exp(-densityAlongSun * absorbCoef); light pow(light, 0.6); // 模拟多重散射这一行的效果非常明显实测能显著提升云层的通透感尤其在太阳方向侧。3.4 合成到场景注意与深度、透明件的顺序后处理拿到的是整个场景的colorTexture。如果直接把云叠上去会出现云层被地表物体遮挡的错误。我的做法是读depthTexture把云层的深度和场景深度做比较云在场景后面就不渲染。这个逻辑写起来不复杂但能避免云层穿山体的尴尬。另外有个细节Cesium中后处理Stage输出的颜色是覆盖整个场景的。要把半透明的云层正确混入场景我一般输出RGBA其中Alpha代表云的遮挡度然后在最终合成时手动做一次mixvec3 finalColor mix(sceneColor.rgb, cloudColor, cloudAlpha);如果你的自定义Stage里还包含其他效果比如大气散射、体积光要留意执行顺序。我通常把云层Stage放在大气后、常规后期色调映射之前。4. 性能优化从“能看”到“能跑”4.1 自适应步长与提前终止Ray Marching的步长是最大性能瓶颈。固定步长在4000米厚的云层里会逼死GPU。我采用两层策略第一层根据采样点距离相机的距离调整步长。离相机近的云层视觉权重高用细步长远距离云层用粗步长。第二层当transmittance降到某一个阈值以下直接终止循环。因为此时继续往前走的光线几乎都被挡住了再算下去意义不大。这两招组合起来通常能把循环次数压缩掉四成以上。我的线上版本最大步数控制在48步以内再高就要考虑降级。4.2 低分辨率渲染与上采样后处理Stage的渲染尺寸是可以自定义的。我的做法是单独开一个小尺寸RenderTarget保存云层结果再在合成阶段把低分辨率云纹理上采样混合回场景。云属于低频信息半分辨率下肉眼几乎看不出差异但计算量直接少了四分之三。实测在2K屏上全分辨率步进大约耗时3-4ms半分辨率能压到1.5ms左右。低分辨率渲染时云层边缘会产生锯齿建议在上采样时做一次柔和的高斯模糊或使用bilinear纹理过滤缓解。如果你的团队对画面要求特别高也可以把云单独用动态分辨率策略相机快速运动时降分辨率静态观察时逐步恢复到全分辨率。4.3 深度提前截止与相机高度分级相机在海平面附近时云层被地表挡住的部分其实没必要计算。我利用scene.depthTexture在进入ray marching之前先判断当前像素对应的场景深度如果场景深度小于云层内壳距离那就说明这个像素被地形或建筑遮挡直接discard或者输出原色不做云计算。这个优化在峡谷、城市等场景里收益极其明显。另一个经验是相机高度分级相机在近地面时视线能穿过的云层厚度有限步进深度不需要太大相机在太空时全球云层变成一整片视觉信息步数可以进一步降低。我在uniform里根据相机高度动态调整最大步数和步长。4.4 与动态水面、地形等效果的协同很多项目不止要做体积云还要做高逼真动态水面、全球海洋效果等。有一个容易被忽略的点后处理Stage会消耗场景的颜色和深度纹理如果多个后处理Stage互相叠加GPU带宽会很紧张。我的经验是尽量把同类型的全球视觉特效合并在一个Stage里。比如全局海洋着色、云层、大气可以放到同一个片元着色器里按视线方向分支处理。这样能减少render-to-texture的次数。Cesium的多Effect链固然方便但每个Stage都是一次全屏pass叠加多了性能会非常难看。5. 排查实录几个让我头疼到失眠的Bug5.1 云层在太空视角疯狂闪烁症状把相机拉高到几百公里后云层出现大量闪烁噪点像老电视雪花。原因非常典型我在噪声采样时直接把ECEF世界坐标作为纹理坐标ECEF坐标动辄几百万的量级而GLSL的float在高精度下也只有7位有效小数坐标一膨胀噪声采样就出现了严重的数值抖动。闪烁的本质是浮点精度不够导致采样坐标上下跳动。解决办法是把采样坐标从世界坐标改为“以相机为原点的局部坐标”也就是在进入ray marching前先把pos减去cameraPos再做噪声采样。这样坐标值在几十到几千的范围内精度完全够用。这是一个很小的改动但把闪烁根治了。5.2 云层在南北极区域变形开裂症状云在赤道附近看起来正常一旦转到北极圈附近云层出现明显的拉伸和错位。原因是早期版本图省事直接用经纬度来做云层UV。WGS84的经度在极点处会聚成一个点导致纹理采样在极点附近被无限放大形成类似“万花筒”的畸变。解决思路是放弃经纬度UV改成用归一化的ECEF方向向量去采样3D噪声。三维方向在球面上是均匀的不存在极点奇异性。你只需要对方向向量做坐标变换映射到一个局部坐标系里再采样。这个方案在南北极区域表现稳定就是实现成本稍高一点。5.3 帧率在低端机直接掉到个位数症状RTX 3060级别的显卡上运行流畅换到集成显卡或低端移动端只能两位数徘徊。排查结果是三处叠加全程高分辨率步进、最大步数设置过大、每帧重复计算噪声的FBM倍频程太多。最终我把云层后处理降到半分辨率最大步数从96降到48并把噪声纹理从R16格式改成单通道RGBA打包格式减少纹理采样次数。三管齐下后集成显卡也能稳定30帧左右。记住云层是视觉重点但不是全部场景的主角性能预算一定要卡死。5.4 太阳方向对不齐黄昏效果很假症状云层明明有光照但亮面和暗面的角度和场景里的其他光影完全对不上。后面发现Cesium的Simon1994PlanetaryPositions.computeSunDirectionInWorld返回的是时间相关的太阳方向但我没有把相位函数参数和太阳高度对应起来。解决方式是确保uniform更新时使用同一个时间源并且在着色器里对光照系数做太阳高度的梯度插值。比如太阳低于地平线时云的base color不应该还是白色要混合暗蓝紫色。6. 扩展方向从“云”到“天气系统”6.1 接入真实天气数据体积云方案稳定后可以尝试把真实的气象数据接入云层覆盖率。核心思路是不从Uniform里写死一个全局Coverage而是把气象数据如云量格点解码成纹理映射到全球经纬度。云层的密度函数里用这张纹理替换或调制全局Coverage参数就能实现“哪里下雨哪里多云”的真实全球云图。这里有个坑气象数据纹理通常都是经纬度平面投影直接采样会重复我在5.2节提到的极点问题。建议把气象纹理映射到cube map上再用方向向量采样。这个工作量不大但能彻底规避极点畸变。另外如果项目要求整体离线部署地形和影像瓦片也别忘了本地化体积云本身不依赖任何在线服务这点反而是它的优势。6.2 与three.js共享GL上下文的另一种思路团队里如果有比较强的three.js渲染功底也可以选择在Cesium的GL上下文中挂载一个three.js渲染器用three的体积云插件渲染云层相机再合帧。这个思路对熟悉three的团队可能更快但缺点是麻烦在于相机同步和深度合成容易出现云层不跟地形遮挡的问题。我两种方案都试过最终保留Cesium原生后处理实现因为它更可控依赖更少。6.3 模块化把云层参数做成可配置项我在工程里把云层的所有视觉参数都抽成了一个单独的json配置文件覆盖度、高度区间、步长、太阳光颜色、云层密度等全部可调。项目方改需求时美术同学可以直接在面板里调数不需要动着色器代码。这一步看起来不起眼但实际交付项目中非常关键省掉了我大量“帮忙改个参数”的沟通成本。最后再讲一个实际的小技巧。我用这套方案跑了几个真实项目后最大的收获是调试体积云时先不要在完整场景里调试直接建一个空场景只留体积云和太阳。这样每次改参数肉眼能立刻看到云层形态和光照的变化。等云层形态调满意了再放回真实场景去调遮挡和性能。这样做至少帮你省掉一半的调试时间。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门