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写进Cesium:PostProcessStage完整实现
原理讲完,下面是可以直接复制的完整实现。我尽量把代码贴全,你照着搭一个HTML文件就能看到效果。
3.1 HTML结构与Slider控件
先搭页面结构。Cesium容器需要全屏,滑块控件要浮在Canvas上方。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Cesium 多云天气效果</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[type="range"] { vertical-align: middle; width: 180px; } #cloudValue { color: #1677ff; font-weight: 600; margin-left: 6px; } </style> </head> <body> <div id="cesiumContainer"> <div id="cloudControl"> <label for="cloudSlider">云层浓度</label> <input type="range" id="cloudSlider" min="0" max="100" value="40" /> <span id="cloudValue">40%</span> </div> </div> <script src="https://cesium.com/downloads/cesiumjs/releases/1.95/Build/Cesium/Cesium.js"></script> <link href="https://cesium.com/downloads/cesiumjs/releases/1.95/Build/Cesium/Widgets/widgets.css" rel="stylesheet"> <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> </html>Cesium的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并绑定uniform:
const 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.0,FBM几乎永远达不到这个值,所以天空无云。当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做云层,那个云层会被当成三维几何体,还要考虑和地球椭球的遮挡关系、视线裁剪,复杂度会成倍增加。这也是我建议把天气类需求统一往后处理这条路推的原因。