资讯动态

Cesium天气效果之云层实现:屏幕空间后处理与FBM噪声实战

发布时间:2026/9/18 10:22:49 来源:尧图企业网站定制
1. 先把需求盘明白Cesium默认没有“天气预报”我之前接了一个数字孪生项目场景是某个园区的三维倾斜摄影甲方看完第一版直接来了句“这天空怎么回事跟游戏差太远了连朵云都没有。”我打开Cesium默认的天空确实只有一层大气辉光和一个贴图星空万里无云干净得有点不真实。在GIS可视化、数字孪生、智慧城市项目里“天气多云”“阴天”“傍晚霞光”这类需求出现频率很高但Cesium官方API基本没给现成的云层能力。这其实是个很有意思的现状Cesium 的场景体系里SkyBox、SkyAtmosphere、雾、粒子这些效果都做得比较完善唯独没有“云层”这个天气元素。雨雪可以走粒子系统雾有scene.fog但云是一个覆盖在大范围天空上的连续噪声场做起来没那么简单。于是很多人开始搜“Cesium 天气效果”“Cesium 云层实现”能找到的多半是改 SkyBox 贴图或者叠加一张云纹理当广告牌效果都很有限。1.1 三条主流路线为什么我最后选了后处理对“多云效果”这件事我在动手前大概盘了三条技术路线这里直接说结论路线做法优点缺点SkyBox贴图做一张带云的天空盒贴图替换默认背景零代码、性能最好云是静态的相机转动时云被“画”在天球上没有动态感和浓度变化Primitive云壳用半球几何体覆盖在地球上空自定义Material采样云纹理有透视、有遮挡关系适合近景相机在云层下方时会把天空全部挡死需要自己处理深度、坐标、模型矩阵复杂屏幕空间后处理用PostProcessStage在渲染完成后对整帧再做一次片元处理通过噪声生成云并混合到天空实现简单、slider调uniform非常灵活、不影响地球上的模型和地形云在几何上不完全贴合曲面极端低空视角下属于“伪造透视”我最终采用的是第三条路屏幕空间后处理。理由很直接这个需求的核心操作是“用slider调整云层的浓度”这句需求本质上是让某个着色器uniform实时变化。后处理方案的uniform就是写在PostProcessStage上的调起来天然顺手而Primitive云壳方案虽然更符合三维几何逻辑但要让一个覆盖全球的云的浓度动态变化代价要大得多。另外Entity和Primitive这两个Cesium API层级对这个需求都不太友好——Entity是面向业务数据展示的自定义渲染能力有限Primitive虽然能写自定义几何和材质但那套Fabric材质体系的实时uniform绑定远没有后处理直接用uniform变量来得干净。1.2 先理解PostProcessStage怎么“截胡”画面Cesium的PostProcessStage可以理解成一道放在渲染管线末端的滤镜先把当前帧的场景颜色写到colorTexture把深度信息写到depthTexture然后执行你给的fragmentShader输出的颜色就是最终屏幕颜色。这样一来你可以在画面上叠加任何东西而且不会污染Cesium本身的三维场景数据。我做云效果的时候shader里同时读两张纹理一是场景颜色决定云混合到哪二是深度决定哪里是天空、哪里是地球。云只能出现在天空区域不能把园区写字楼的窗户给糊上这是所有天气效果的一个基本底线。2. 屏幕空间云的原理一张深度图和一堆噪声如何变成云这一节我把原理拆开讲。很多教程只贴代码不讲为什么这样写结果你抄完换个相机角度就露馅。我们要避免这种情况。2.1 深度纹理怎么区分“天空”和“地物”Cesium后处理阶段拿到的深度纹理存的是每个像素在NDC坐标系下的深度值。一个约定俗成的经验是深度接近1.0的地方说明这个像素没有命中任何三角面也就是天空、大气层之外的背景深度小于1.0的地方说明这里有地球表面、建筑、3D Tiles或其他几何体。所以我在shader里最先做的事就是float depth texture2D(depthTexture, v_textureCoordinates).r; if (depth 0.999) { gl_FragColor sceneColor; return; }这句相当于一个“天空遮罩”。深度不满足条件的像素直接返回原色不做云效果处理。这样无论你的场景里有多少GIS数据、倾斜摄影模型、白模建筑云都不会盖到它们头上。有个细节要注意Cesium的不同版本里天空盒是否写入深度、深度值精度有过微小差别。如果你在自己环境里发现云把整个地面都糊上了优先把0.999这个阈值往大了调比如0.9999或者反过来调成0.99根据自己的项目实际效果来。2.2 FBM噪声云的轮廓不是贴图是“算”出来的云的形状如果纯靠一张贴图最大的问题是视角一变就露馅。我用的方案是FBM噪声全称Fractal Brownian Motion中文常叫分形布朗运动。概念不复杂就是把多个不同频率、不同振幅的噪声叠加在一起低频噪声提供云的大块轮廓高频噪声补充细节边缘最终看起来就是一团团蓬松、边缘不规则、像棉花糖一样的云。做个类比你在大白纸上先用手掌压出一个大面积颜料痕这是低频再用牙刷蘸颜料弹上去点状的小点这是高频。FBM就是无数个“手掌痕牙刷点”层层叠上去得到既有大形态又有小细节的图案。我在shader里用的基础噪声是hash noise的组合float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); } float noise(vec2 p) { vec2 i floor(p); vec2 f fract(p); vec2 u f * f * (3.0 - 2.0 * f); return mix( mix(hash(i), hash(i vec2(1.0, 0.0)), u.x), mix(hash(i vec2(0.0, 1.0)), hash(i vec2(1.0, 1.0)), u.x), u.y ); }hash函数给每个整数网格点一个随机值noise函数用平滑插值把这些随机点连接成连续过渡的值。然后FBM就是循环叠加float fbm(vec2 p) { float value 0.0; float amplitude 0.5; for (int i 0; i 6; i) { value amplitude * noise(p); p p * 2.0; amplitude * 0.5; } return value; }每叠一层频率翻倍振幅减半。6层叠加下来值域基本稳定在0到1之间这样后续用smoothstep做阈值裁切的时候才可控。性能方面6次噪声叠加在PC上压力不大但移动端建议降到4层视觉损失能接受帧率提升明显。2.3 让云“固定在天上”而不是贴在屏幕上初学者最容易犯的错是直接用屏幕UV当云噪声的采样坐标也就是vec2 p v_textureCoordinates * 8.0这种写法。这么做的结果是云朵完全跟着屏幕走相机一旋转云就像贴纸一样钉在屏幕上整个天空的立体感瞬间垮掉。正确做法是把屏幕坐标反算成世界方向然后用这个方向向量去采样噪声。这样你转动相机时云在世界空间中的位置是固定的看起来才像是远处的天气而不是屏幕上的滤镜。vec3 getWorldDirection(vec2 uv) { vec4 ndc vec4(uv * 2.0 - 1.0, 1.0, 1.0); vec4 world czm_inverseViewProjection * ndc; vec3 worldPos world.xyz / world.w; return normalize(worldPos - czm_viewerPositionWC); }Cesium内置的czm_inverseViewProjection和czm_viewerPositionWC在这里非常好用。前者把NDC坐标还原到世界坐标后者是当前相机位置。两者相减得到一个从相机指向远景的射线方向这个方向就是“天空中的某个方位”。然后用dir去采样FBM噪声云就牢牢固定在世界坐标系里了。再配合一个小技巧把时间变量加进采样坐标云就会缓慢流动而不是死板地贴在天上。这一步其实就是案例里“动态天气”的来源。3. 手把手把Cloud写进CesiumPostProcessStage完整实现原理讲完下面是可以直接复制的完整实现。我尽量把代码贴全你照着搭一个HTML文件就能看到效果。3.1 HTML结构与Slider控件先搭页面结构。Cesium容器需要全屏滑块控件要浮在Canvas上方。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleCesium 多云天气效果/title style html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } #cesiumContainer { position: relative; } #cloudControl { position: absolute; top: 20px; right: 20px; z-index: 1000; background: rgba(255, 255, 255, 0.92); border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px 14px; font: 14px/1.6 PingFang SC, Microsoft YaHei, sans-serif; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); user-select: none; } #cloudControl input[typerange] { vertical-align: middle; width: 180px; } #cloudValue { color: #1677ff; font-weight: 600; margin-left: 6px; } /style /head body div idcesiumContainer div idcloudControl label forcloudSlider云层浓度/label input typerange idcloudSlider min0 max100 value40 / span idcloudValue40%/span /div /div script srchttps://cesium.com/downloads/cesiumjs/releases/1.95/Build/Cesium/Cesium.js/script link hrefhttps://cesium.com/downloads/cesiumjs/releases/1.95/Build/Cesium/Widgets/widgets.css relstylesheet script const viewer new Cesium.Viewer(cesiumContainer, { animation: false, baseLayerPicker: false, fullscreenButton: false, geocoder: false, homeButton: false, infoBox: false, sceneModePicker: false, selectionIndicator: false, timeline: false, navigationHelpButton: false, shouldAnimate: false }); viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(116.391, 39.907, 6000) }); /script /body /htmlCesium的CDN地址可以换成你本地部署的版本正式项目里我建议一定下载离线包避免外网加载不稳定。这里z-index: 1000是为了确保slider不被Cesium的Canvas盖住。如果你把滑块放在Cesium容器外面要额外注意Cesium初始化时会创建自己的绝对定位div层级容易出问题。3.2 完整片元着色器核心着色器代码如下uniform sampler2D colorTexture; uniform sampler2D depthTexture; uniform float u_coverage; uniform float u_time; varying vec2 v_textureCoordinates; float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); } float noise(vec2 p) { vec2 i floor(p); vec2 f fract(p); vec2 u f * f * (3.0 - 2.0 * f); return mix( mix(hash(i), hash(i vec2(1.0, 0.0)), u.x), mix(hash(i vec2(0.0, 1.0)), hash(i vec2(1.0, 1.0)), u.x), u.y ); } float fbm(vec2 p) { float value 0.0; float amplitude 0.5; for (int i 0; i 6; i) { value amplitude * noise(p); p p * 2.0; amplitude * 0.5; } return value; } vec3 getWorldDirection(vec2 uv) { vec4 ndc vec4(uv * 2.0 - 1.0, 1.0, 1.0); vec4 world czm_inverseViewProjection * ndc; vec3 worldPos world.xyz / world.w; return normalize(worldPos - czm_viewerPositionWC); } void main() { vec4 sceneColor texture2D(colorTexture, v_textureCoordinates); float depth texture2D(depthTexture, v_textureCoordinates).r; if (depth 0.999) { gl_FragColor sceneColor; return; } vec3 dir getWorldDirection(v_textureCoordinates); if (dir.y 0.05) { gl_FragColor sceneColor; return; } vec3 sampleCoord dir * 5.0; sampleCoord.x u_time * 0.015; float n1 fbm(sampleCoord.xy sampleCoord.z * 0.7); float n2 fbm(sampleCoord.xy * 2.6 - sampleCoord.z * 1.3 n1 * 1.2); float cloudMask smoothstep(1.0 - u_coverage, 1.0 - u_coverage 0.35, n2); vec3 cloudColor mix( vec3(0.42, 0.50, 0.62), vec3(1.0, 0.99, 0.96), smoothstep(0.0, 0.6, dir.y) ); float alpha cloudMask * 0.9; gl_FragColor mix(sceneColor, vec4(cloudColor, 1.0), alpha); }有几个变量说明一下u_coverage云层浓度范围0到1。这个就是slider要控制的uniform。u_time当前时间单位秒用来让云缓缓流动。dir.y 0.05过滤地平线以下的区域避免在贴近地表的屏幕底部刷出一片怪异的云。sampleCoord dir * 5.0这是云的空间频率缩放。数字越大云朵越细碎越小云块越大。5.0是我在多个场景里试过比较平衡的值你可以根据项目效果微调。n1和n2两次FBM采样不是多余操作。第二次把第一次的结果作为偏移量加到采样坐标里这种“domain warp”技巧能让云边缘出现更自然的丝状、卷曲结构。如果只做一层FBM云边缘会显得比较“肉”过度太圆。3.3 创建后处理并挂到场景上接下来创建PostProcessStage并绑定uniformconst cloudStage new Cesium.PostProcessStage({ fragmentShader: cloudShader, uniforms: { u_coverage: 0.4, u_time: function () { return performance.now() / 1000.0; } } }); viewer.scene.postProcessStages.add(cloudStage);uniforms里的u_time用函数形式返回Cesium会在每一帧调用这个函数把返回值写入着色器这样云才能持续运动。u_coverage我去掉了function因为初始值只需要写一次后面用slider改。如果你用的Cesium版本较老可能要检查一下PostProcessStage是否存在。它从Cesium 1.46就开始提供了绝大多数项目不会遇到问题。3.4 Slider实时更新Uniform滑块交互那段代码也很短const slider document.getElementById(cloudSlider); const valueLabel document.getElementById(cloudValue); slider.addEventListener(input, function () { const value parseFloat(this.value) / 100.0; cloudStage.uniforms.u_coverage value; valueLabel.textContent this.value %; });这里必须用parseFloat把字符串转成浮点数否则slider的20会被当成字符串Cesium在写入uniform时行为会变得不可预期。除以100是把百分比映射到0到1的浓度区间。你可以看到整个实现里slider和云层之间的联动本质上就是“改一个float变量”。这也是我当初坚持用后处理方案的原因——如果走Primitive Fabric材质路线要动态改材质uniform还得通过material.uniforms去访问写法反而更绕而且自定义材质对GLSL结构的限制也更多。4. 浓度调参、性能优化和真正要避开的坑代码能跑起来只是第一步。接下来这些经验才是这节要讲的重点每一个都是我实际调试中踩过或者反复调优过的。4.1 Slider背后的参数到底在控制什么很多人以为u_coverage控制的是云的“透明度”其实不是。它控制的是FBM噪声的裁切阈值。看这行float cloudMask smoothstep(1.0 - u_coverage, 1.0 - u_coverage 0.35, n2);FBM输出的n2主要集中在0到1之间但不是均匀分布是类似山脉剖面那种取值——大部分区域偏低少数区域偏高。smoothstep的作用是低于阈值的区域判为“无云”高于阈值的判为“有云”中间一段做平滑过渡避免出现硬边。当u_coverage是0时阈值下限是1.0FBM几乎永远达不到这个值所以天空无云。当u_coverage是1时阈值下限是0几乎任何噪声位置都会变成云天空会被大片云覆盖。slider从0拖到100实际是在改变“多少比例的噪声区域会被识别成云”。实际操作可以这样理解浓度档位slider值实际天气感建议场景0%晴空默认状态20%-40%少云、天气晴朗但有零星云大部分数字孪生场景40%-60%多云天空有一定厚度本案例默认40%60%-80%阴天感较重影视级氛围、灾情推演100%近乎全阴天特殊氛围需求4.2 让云动起来时间偏移和云海方向云的流动本质是采样坐标随时间偏移。我的代码里写的是sampleCoord.x u_time * 0.015;这意味着云沿着x方向缓慢移动。如果你想做“云自西向东”的天气可以把u_time系数改成自适应的方向向量vec2 windDir normalize(vec2(0.6, 0.2)); sampleCoord.xy u_time * 0.015 * windDir;风速由0.015控制。太小了云几乎不动太大了云会像开了倍速不自然。我项目里一般取0.01到0.02手机上看刚好有“缓缓移动”的呼吸感。但这里有个无限远云的固有限制因为采样坐标基于世界方向云是作为无限远处的球面效果存在的。当你站在地面上抬头看云的位置是正确的但当你有超低空几十米高度大角度俯视时云的透视关系会跟真实世界有差异。如果你的项目大量使用这种视角建议额外叠加一层近景云粒子或者改用Primitive云壳方案弥补远景。4.3 性能后处理全屏开销比想象中大后处理是对整帧颜色做逐像素处理屏幕分辨率越高shader越复杂性能开销越大。我的优化优先级是降低FBM迭代次数。6层改4层肉眼几乎看不出差别但GPU压力明显下降。开textureScale。这是Cesium PostProcessStage自带的能力cloudStage.textureScale 0.5;把后处理纹理分辨率降到一半模糊损失基本可以接受帧率却能拉回来不少。移动端我甚至会设成0.25再配合4层FBM效果比满分辨率6层FBM的卡顿体验好得多。避免在低端设备做太密集的domain warp。我代码里做了两次FBM第二次还依赖第一次的结果这相当于串行计算。移动端可以把第二次的偏移系数从n1 * 1.2降为n1 * 0.6减少噪声TPS。4.4 深度阈值的“玄学”问题前面提到depth 0.999就跳过云效果。实际项目里这个值和很多因素有关Cesium版本、是否开启FXAA、三地Tiles的深度精度、相机远裁剪面设置等。我遇到过一种情况在视口边缘深度值因为浮点精度问题即使背景是天空读出来的值也会低于0.999导致边缘出现一条条“没有云”的缝隙。解决办法是把阈值放宽到0.99或者干脆用1.0 - 1e-5。反过来如果发现云把远处的山体也盖住了说明山体所在像素的深度值也比较接近1.0可以先把相机远裁剪面调小或者用另一个思路比较像素深度和该像素处地球椭球深度只在地物深度较浅时才算天空。这个方案稍微复杂一点但更稳。4.5 后续扩展从“多云”到“天气系统”这个云层效果做出来之后顺手还可以做几件很自然的事把u_coverage联动到“天气预设”按钮比如晴天、少云、多云、阴天四档。把云色向量随太阳方向改变模拟日出、黄昏的霞光。云层浓到一定阈值时同时打开系统粒子雨雪整个天气系统就闭环了。我在实际项目中会把u_coverage同步到一个全局天气状态对象业务层切换天气时直接调用setWeather(cloudy)这类接口内部改的仍是那一个uniform。整个链路很干净后续维护成本低。另外关于Entity和Primitive的选择这次需求虽然叫“天气效果”但本质上属于场景级全局渲染不是业务实体所以完全不应该用Entity。Entity更擅长管理一个“有数据含义的对象”比如一个建筑、一辆车云的归属是“天空”是后处理该管的事。如果非要用Primitive做云层那个云层会被当成三维几何体还要考虑和地球椭球的遮挡关系、视线裁剪复杂度会成倍增加。这也是我建议把天气类需求统一往后处理这条路推的原因。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价