气象和Cesium在可视化上其实是一对天然搭档。卫星云图、天气雷达、降水拼图这类数据天生带坐标、带时间维度,而Cesium的世界场景正好把经纬度和时间线缝在了一体,做出来的效果放在值班大屏上比二维GIS平面图直观得多。这篇是这个系列的第七篇,我主要把项目里做动态气象可视化的完整思路拆开讲:卫星云图怎么动起来、雷达图怎么扫描、Entity和Primitive在气象场景下该怎么选、帧率怎么保住。内容偏实战,适合已经能上手Cesium、准备把它推向气象展示场景的朋友。
1. 气象数据的三维球面装配:从裸数据到可视化第一步
很多人拿到气象数据的第一反应是直接扔到Cesium里贴图,结果不是图像错位就是颜色不对。这一步其实急不得,先把数据原来的坐标系、时间分辨率和覆盖范围搞清楚,后面所有效果才有地基。
1.1 数据源选型:风云四号、葵花8还是商业云图
我做过几个气象项目,数据源基本逃不出下面这几类:
- 卫星云图:国产的风云四号(FY-4A/4B)L1级产品、日本葵花8号的红外/可见光产品,或者直接接入商业气象云图服务。
- 天气雷达:国内的SWAN雷达拼图,有历史和实时产品,部分近实时接口能到6分钟一个时次;单站基数据则需要自己做反射率因子的网格化。
- 降水估测/格点预报:一般以grib2或netCDF给出,用于叠加风场、降雨量等。
这几类数据有两个共同点:一是都是二维格点,二是都带严格的时间戳。这意味着在Cesium里我们本质上做的是"把时间的二维栅格贴到三维地球表面",而不是简单的图贴上去就完事。
从分辨率角度,我给个实际参考:风云四号可见光通道在星下点分辨率可以到0.5km到1km,红外通道一般是4km;葵花8号全圆盘10分钟一张,可见光0.5km左右;SWAN雷达拼图常见1km格距。展示到Cesium上,0.5km分辨率的云图放大到省一级会很清晰,但如果直接摊到全球视角,其实2km分辨率也看不出来差异,没必要一上来就追求最高分辨率。
1.2 时间重采样与投影预处理:统一到WGS84
我见过最典型的新手翻车现场,是拿到一张HRIT格式云图,转成geoTIFF后没做投影转换,直接按经纬度贴到地球上,结果云带斜着一个弧线偏出去几百公里。根因是原始数据用的是极地投影或其他投影,不是EPSG:4326。
Cesium底图的地形和影像默认都是WGS84,所以所有栅格素材在上球面之前,必须先统一到这个坐标系。用GDAL重投影是最省事的路径:
gdalwarp -t_srs EPSG:4326 -dstalpha -r bilinear FY4A_input.tif FY4A_4326.tif-dstalpha会生成透明背景,这对后面做真彩色云图叠加非常关键。-r bilinear做双线性重采样,云图边界能柔和一些,避免像素锯齿。如果是雷达反射率格点,重采样时我建议保留反射率值的合理性,不要用会拉出异常值的插值方法。
做完投影,下一步是时间重采样。卫星云图10分钟一张,雷达拼图6分钟一张,如果两个图层放在同一个时间轴上播放,时间点对不上,画面里台风中心的位置会错开半个洋面。我惯用的做法是:
- 把每个时次数据的时间戳统一转成UTC,建立
时刻 -> 纹理URL的索引表。 - 前端Cesium的时钟在走,渲染时查表取当前时刻最近的两帧,按时间差做线性权重融合。
- 超过前后阈值就显示灰化或者隐藏,不要生硬地跳过去。
这一步最好在后端预处理就完成一部分,比如把每个时次的云图提前转成PNG或WebP瓦片,前端只做查表和加载,不要边渲染边做投影,否则性能完全跟不上。
2. 卫星云图动态播放:时间轴才是真正的播放器
卫星云图动态化是整个气象可视化的重头戏。一张静态云图没什么稀罕,但当天顶的云带在屏幕上连续流动、台风眼旋涡逐步旋转起来,那种"活过来"的感觉才是沉浸感的来源。Cesium的时间轴机制恰好是干这个的。
2.1 时间序列纹理的挂载方式
把一个矩形区域贴到地球上,最简单的写法是Rectangle+ImageMaterialProperty:
const cloudEntity = viewer.entities.add({ rectangle: { coordinates: Cesium.Rectangle.fromDegrees(70, 0, 140, 55), material: new Cesium.ImageMaterialProperty({ image: cloudFrameUrl, transparent: true }) } });这只能显示一帧。要让云图动起来,我推荐的做法是用CallbackProperty在每一帧回调里根据当前时钟时间动态取纹理:
const cloudEntity = viewer.entities.add({ rectangle: { coordinates: Cesium.Rectangle.fromDegrees(70, 0, 140, 55), material: new Cesium.ImageMaterialProperty({ image: new Cesium.CallbackProperty((time) => { const frame = getCloudFrameByTime(time); return frame ? frame.url : undefined; }, false) }) } });这套机制的好处是你可以随时往里塞数据源,不管是从后端接口拉还是本地数组,只要实现了getCloudFrameByTime的查表逻辑,Cesium时间线一拖,云图就跟着走。CallbackProperty的第二个参数false表示它是动态回调,每一帧都会重新执行,如果写成true(即常量),就不会随时间变化了,这是新手最容易踩的点。
另一个思路是用TimeIntervalCollectionProperty管理多个时刻的图片地址,但实际用下来我个人觉得不如CallbackProperty灵活。因为气象数据的时次经常会有缺帧、延迟、补传,用一个函数查表可以很方便地处理"当前时间没有数据"的降级逻辑,而固定的时间区间集合在数据增删时反而麻烦。
2.2 跨时刻淡入淡出:告别幻灯片
直接切换图片的最大问题是看起来像PPT,值班人员盯着大屏看的时候视觉跳动非常明显。我做了很多轮测试后发现,不一定要做复杂的流场插值,简单的透明度交叉溶解就能把观感提升一大截。
具体方法是同时叠两个矩形:一个显示当前帧,一个显示下一帧,根据当前时刻在两帧之间的位置线性插值两者的透明度:
const total = current.diff(previous, Cesium.TerrainMilliseconds); const progress = total / (next - previous); curEntity.rectangle.material.transparency = 1 - progress; nextEntity.rectangle.material.transparency = progress;这里的关键是两帧时间间隔内的进度比例,progress从0到1,当前帧的透明度从1渐变到0,下一帧从0渐变到1,视觉上就是云图平滑过渡。切换完成后把上一帧实体移除或替换,循环复用两个Entity避免反复创建销毁。
实测下来,10分钟间隔的云图做1到2秒的交叉溶解就够了,时间太长反而会觉得图像发虚;雷达图6分钟间隔的溶解时间建议控制在1秒以内,因为雷达回波轮廓本来就锐利,溶解久了会让强回波中心变得模糊。
2.3 相机跟随与时刻联动
云图如果只是铺在地球上,价值有限。真正的业务场景是值班员要盯着某个台风中心、某条暴雨带反复看。这时候相机交互和时刻绑定就非常重要。
我通常会做一组相机书签,每个书签对应一个关注的天气过程,点击后:
viewer.camera.flyTo({ destination: Cesium.Rectangle.fromDegrees(118, 20, 132, 32), duration: 1.5 });同时把时钟拨到预设的起报时刻:
viewer.clock.currentTime = Cesium.JulianDate.fromIso8601("2025-06-15T00:00:00Z"); viewer.clock.shouldAnimate = true;这样相机飞到目标区域之后,云图自动开始从指定时间播放。还有一个细节:右下角的时钟控件如果默认显示的是 local time,要改成UTC,不然云图时间帧和墙上挂钟对不上,值班的人会直接懵。Cesium默认支持viewer.clockViewModel设置,显示格式我用的是:
viewer.timeline.clockViewModel.systemTimezone = Cesium.Timezone.UTC;虽然这是很小的配置,但真实项目中因为时区对不上导致"看起来很神的云图预报却是昨天的"这种乌龙,真不少见。
3. 雷达图动态渲染:扫描动画与强回波高亮
卫星云图解决的是"大范围天空状态",雷达图解决的是"哪个区域在下雨、下多大"。在Cesium里做雷达可视化,光把回波图贴上去不够——雷达图天生带着"扫描"的属性,做点动态效果会让整个画面活起来。
3.1 雷达回波叠加与透明度分层
雷达反射率产品是一张256色的色带图:小雨是绿色、中雨黄色、大雨橙色、强回波红紫色。贴到Cesium上的重点是背景透明,不然一张完整的矩形图会把底下的地形和地名全部盖掉。
需要让后端在生成图片时就做Alpha通道处理,把无回波的像素透明度设为0。前端叠加时再给整体一个透明度,我一般控制在0.65到0.8之间,既要能看清天气区域的连续形态,又不至于遮挡路网和城市名。
const radarEntity = view.entities.add({ rectangle: { coordinates: Cesium.Rectangle.fromDegrees(110, 18, 125, 35), material: new Cesium.ImageMaterialProperty({ image: radarFrameUrl, transparent: true, alpha: 0.75 }) } });叠加顺序需要留意:雷达图层在Cesium primitives里的渲染顺序依赖zIndex或加载先后。云图在底层、雷达图在上层的组合看起来最自然,因为人对"云层之上看降雨"的空间感知是符合直觉的。
3.2 扫描线与动态光照的简单实现
雷达图的沉浸感很大程度来自"扫描动作"。我自己试过三种方式,从轻到重排个序:
第一种,用一条半透明的扇形扫描线,跟随雷达中心点旋转。实现上可以画一个动态的polyline圆弧,角度随时间变化,代码负担很小:
viewer.entities.add({ polyline: { positions: new Cesium.CallbackProperty(() => { const angle = ((Date.now() / 1000) * 0.6) % (2 * Math.PI); const points = []; for (let i = 0; i <= 120; i++) { const theta = angle + (i / 120) * 0.35; points.push(Cesium.Cartesian3.fromDegrees( lon + 240 * Math.cos(theta) / 111, lat + 240 * Math.sin(theta) / 111 / Math.cos(lat * Math.PI / 180) )); } return points; }, false), width: 2, material: Cesium.Color.GREEN.withAlpha(0.6) } });注意经纬度换算成实际距离时要乘1/Math.cos(lat)的修正,否则纬度越高扫描扇区越歪。
第二种,用粒子系统从雷达中心往外喷射,模拟回波脉冲扩散。这个视觉冲击最强,但移动端和低端工控机上不太扛得住,我一般只在指挥大屏的PC端项目里用。
第三种,用动态光照模拟雷达扫描的亮度变化。Cesium里没有直接的热力光照,但在材质层面可以让整个雷达图层的透明度或色调随扫描角度微微变化,做出"刚扫过的区域更亮"的感觉。这种属于锦上添花,等基础效果稳定后再加不迟。
3.3 强回波区域的圈选与闪烁
雷达图上最需要值班员注意的是红色、紫色强回波中心。这些地方往往对应短时强降水、冰雹、大风。我做了两个层次的强调:
一是自动圈选。对回波强度超过阈值(比如反射率大于45dBZ)的网格簇生成封闭多边形,套一个半透明的红色包络线。这一步如果数据是格点的,直接在格点阈值聚类之后生成边界多边形,再viewer.entities.add一个polygon加outline。
二是闪烁提醒。对当前正在迅速增强的回波区,我会让材质透明度周期性变化:
const flashMaterial = new Cesium.CallbackProperty(() => { const phase = (Date.now() % 1500) / 1500; return Cesium.Color.RED.withAlpha(0.25 + 0.4 * phase); }, false);闪烁频率我控制在1.5秒一个周期,太快的闪烁在值班大屏上会让人焦虑,太慢的又起不到提示作用。
3.4 雷达探测范围圈的绘制
很多人问雷达探测图怎么做,其实就是在站点位置画一个最大探测距离的圆,再叠加几个同心衰减环。最大探测半径单站一般取240km或者460km,按需求取就行:
viewer.entities.add({ ellipse: { position: Cesium.Cartesian3.fromDegrees(lon, lat), semiMajorAxis: 240000, semiMinorAxis: 240000, material: Cesium.Color.fromCssColorString("rgba(0,255,0,0.12)"), outline: true, outlineColor: Cesium.Color.GREEN } });想更精致一点,就叠加几个半径递减的椭圆环,外围透明度低、中心透明度高,再配合站点的三角符号标注。这个小东西在气象辅助决策图层里几乎是标配。
4. Entity还是Primitive:气象图层性能取舍的实战答案
这个话题几乎每个人都会遇到,也是搜索引擎里出现频率最高的问题之一。我先把结论放这:气象可视化不是"绝对别用Entity",而是"数据量分级,量小用Entity,量大换Primitive,最终做成混合架构"。
4.1 Entity的便捷与隐性成本
Entity的好处是API友好,类型丰富,拾取、点击、样式切换都像操作普通对象一样简单。我做原型验证、做站点标注、做路径回放,都会直接用Entity,因为写起来快,调试也直观。
但Entity是有隐形成本的。每个Entity在内部都会生成单独的Primitive,有独立的几何、纹理、状态管理。当你用Entity把几百个雷达回波格点都铺上去,或者把几百个站点标记全挂上去,Cesium每一次渲染都要遍历更新这些实体,帧率就会明显往下掉。我在一个项目里试过直接在球面上用Entity加载3000个回波色块,结果帧率从60直接掉到20左右,操作也跟着卡顿。
4.2 Primitive批量渲染模式
当数据量大到几千上万,就该切换成Primitive。Primitive的本质是你自己管理GeometryInstance列表,Cesium可以一次draw call批量渲染同类几何。
比如把雷达回波格点按颜色分组成多个Primitive:
const instances = []; reflectivityCells.forEach(cell => { instances.push(new Cesium.GeometryInstance({ geometry: new Cesium.RectangleGeometry({ rectangle: Cesium.Rectangle.fromDegrees( cell.west, cell.south, cell.east, cell.north ) }), attributes: { color: Cesium.ColorGeometryAttribute.fromColor(cell.color) } })); }); scene.primitives.add(new Cesium.Primitive({ geometryInstances: instances, appearance: new Cesium.PerInstanceColorAppearance({ flat: true, translucent: true }) }));这一套做下来,三千个回波色块的渲染压力可以压缩到很低的水平。需要说明的是,Primitive方案里如果要动态更新数据,常规做法是销毁重建Geometry实例列表,这在帧率敏感的交互场景下会带来短暂的卡顿,所以我的策略是"静态批量用Primitive,动态频繁变了用Entity或混合方案"。
4.3 混合架构:指挥大屏级气象可视化
实际项目里最稳的架构是分层的:底层的云图、雷达图作为背景图层,用少量的Rectangle或Primitive承载;中间层的格点色块、等值面用Primitive批量渲染;上层交互元素如站点标注、台风路径、点击拾取对象,用Entity。
这个结构的理由也很简单:交互元素需要频繁拾取、选中、高亮,Entity省心;海量格点只需要"显示"和"更新颜色",不参与交互,Primitive高效。各干各的活,性能才不会互相拖累。
我做指挥大屏项目时还有一个习惯:所有静态图层如果能提前合并成一张纹理,就不要用多个图层叠加。比如把风场箭头预渲染到大图上,就不用每帧实时计算几百个箭头方向了。
5. 沉浸式交互与性能调优:从卡顿到丝滑
动态气象可视化的沉浸感需要两个前提:画面流畅,操作跟手。帧率不稳,再漂亮的云图和雷达扫描都会变成一场幻灯片灾难。这一节分享一下我在性能调优和交互细节上的实际操作。
5.1 相机书签与跟踪视角
沉浸式展示不只是"看到地球转一圈",而是要让观看者能围绕关注的天气系统观察。我给每个重要的天气过程预设了相机书签:台风路径跟踪、强对流云团拉升、雷达扫描圈放大。
相机控制这块,Cesium的camera.flyTo非常够用。但如果要跟踪移动目标(比如台风中心),就不能只飞一次,而是要每个渲染帧里更新相机目标点,类似锁定跟踪效果:
viewer.scene.preUpdate.addEventListener((scene, time) => { const target = getCurrentTyphoonPosition(time); viewer.camera.lookAt(target, new Cesium.Cartesian3(0, 0, 80000)); });lookAt会保持相机朝向跟随目标,但相对位置不变,适合做"盯着台风一路走"的效果。切换书签时注意先取消之前的preUpdate监听,不然多个监听叠加在一起,相机会像喝醉了一样乱转。
5.2 帧率监控与问题定位
Cesium自带一个调试开关,打开就能在画面上看到实时帧率:
viewer.scene.debugShowFramesPerSecond = true;我在调优时先明确瓶颈出在哪一层:如果帧率低但GPU占用不高,大概率是CPU侧的Entity更新开销或者纹理解码瓶颈;如果GPU占用快满了,优先怀疑一层叠一层的透明纹理,云图、雷达图、行政区划图叠了好几层,每个像素都要反复混合,开销指数上升。
另外一个容易被忽略的点是maximumScreenSpaceError。Cesium的影像金字塔加载时,屏幕空间误差参数直接决定切到什么层级。默认值大约2,但气象底图本身纹理信息量不大,我经常把它调大一点,减少不必要的瓦片加载:
viewer.scene.globe.maximumScreenSpaceError = 4;调到4之后,云图细节肉眼看不太出来差别,但瓦片请求数量能下降一半以上,帧率提升非常明显。
5.3 请求、渲染、纹理三层优化
逐层优化是我的固定套路,从请求到渲染一层层处理。
请求层:限制并发瓦片请求数,避免后端被瞬间打爆。Cesium默认有请求调度,但气象数据瓦片多,我会把RequestScheduler.throttleRequests打开,并在后端做瓦片缓存。
渲染层:最能立竿见影的是requestRenderMode,打开后Cesium不再每帧自动渲染,只有相机变化、数据变化时才触发绘制:
viewer.scene.requestRenderMode = true; // 手动触发一次 viewer.scene.render();气象可视化场景里,如果云图和雷达图只在时钟推进时变化,打开这个模式能让GPU占用显著下降。但要注意,开启后如果你不用invalidateRender提醒,有些材质动画可能会停住,需要在回调里主动触发渲染。
纹理层:云图不是越清晰越好,特别是作为全球背景时,2km分辨率已经足够。我通常把单张纹理限制在2048x2048以内,雷达图就更小了,1024x1024足矣。格式上能用WebP或者压缩PNG就不要用原始BMP,不然几十帧的气象序列图能把显卡显存直接吃满。
5.4 长期开机的内存泄漏排查
气象大屏往往7x24小时在值班室挂着,内存泄漏问题比普通Web应用严重得多。我踩过几个坑:
一是定时器没清。做扫描动画、闪烁效果时用了setInterval,页面销毁时没清除,时间一长CPU和内存双双爆表。二是实体和Primitive反复创建不销毁。动态更新雷达图时老是entities.add新对象,旧的没entities.remove干净。三是加载过的纹理URL没有释放,Cesium的ImageCache会一直持有引用。
我的做法是给每个动态图层封装一个"清理句柄",更新前先调用清空逻辑,再创建新的。切换天气过程时,统一执行:
layerGroup.removeAll(); viewer.scene.primitives.removeAll();并且在整个应用层用ResizeObserver监听容器尺寸,在页面隐藏时暂停动画时钟,避免后台依然大量消耗GPU。
最后分享一个我实际项目里一直在用的调试技巧:动态气象图层全部上线后,先不要一次把所有效果都打开,而是从底图开始,逐层往上加——先地球底图,再加云图,再加雷达图,再加扫描线和闪烁标记,每加一层就盯一下帧率,心里对每一层大概消耗多少有个数。这样一旦最终大屏卡顿,你能立刻判断是哪一层拖的后腿,而不是把整套代码翻个底朝天。气象可视化这东西,数据对了、时间轴顺了、图层分层清楚了,沉浸感自然就出来了。