上次写了篇 用 Cesium 撸了一个森林火情监控大屏,反馈比预想的好,然后我就想到了「火点那圈扩散」这个下个也是必须要的。
说实话那篇里的火场椭圆比较简单,更多是半透明盖一层。后来项目里预警类需求变多了------异常点、雷达覆盖、毒气扩散、应急集合------都要一种能看懂严重程度、又别太吵的波纹点。正好单独拆成一个组件,这篇就把涟漪扩散点(RipplePoint)讲清楚。
还是老规矩:一个 HTML 就能跑,讲思路、讲参数、讲坑。代码会贴,但不会一上来就甩两屏 shader。
先看看实际效果

这篇解决什么问题
大屏上「某个位置出事了」,最常见的两种画法:
- 一个闪烁的 Billbaord / 图标
- 一圈一圈往外扩的波纹
图标只能说明「这里有东西」,波纹更能传达「影响范围 + 时间节奏」。但 Cesium 没有开箱即用的「多圈涟漪材质」,自己用 CallbackProperty 每帧改椭圆半径也能做,点一多就虚。
我这边走的是:椭圆几何固定半径 + 自定义 Material,动画全在 GLSL 里跑。JS 几乎不用每帧插手。
效果上准备了几套差异比较大的预设:火情预警、雷达扫描、毒雾扩散、应急集结、生态监测。颜色、半径、圈数、快慢都不一样,方便对照着调。
导入掘金前记得自己截几张效果图塞进来,比纯文字管用。建议至少:对比模式全景一张、单点特写一张、属性面板一张。
思路先讲清楚
整条链路其实就四步:
- 注册一个自定义
Material(fabric + GLSL) - 用
EllipseGeometry铺一个圆盘,半径 =maxRadius - 圆盘上贴
MaterialAppearance,动画靠 shader 里的czm_frameNumber - 颜色、圈数、周期这些走 uniforms,能热更新;半径、高度变了再重建几何
为什么不让 JS 每帧改 semiMajorAxis?
因为半径一变就要重建 / 更新几何,几十个点一起闪的时候很浪费。圆盘大小不变,只在纹理空间里画「第几圈波纹走到哪」,便宜很多。
注意一点:波纹「看起来的最大范围」就是椭圆的 maxRadius 。shader 里 dist 是归一化到 0~1 的,1 就对应圆盘边缘。半径调小,扩散圈再漂亮也出不去那个圆。
Viewer 还是要先阉割一波
和大屏那篇一样,默认控件全关。底图我这篇用的是 ArcGIS 卫星瓦片,演示用不用 Ion Token,省事:
javascript
const viewer = new Cesium.Viewer('cesiumContainer', {
animation: false,
timeline: false,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
fullscreenButton: false,
infoBox: false,
selectionIndicator: false,
imageryProvider: false,
})
viewer.imageryLayers.removeAll()
viewer.imageryLayers.addImageryProvider(
new Cesium.UrlTemplateImageryProvider({
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
maximumLevel: 19,
}),
)
viewer.scene.globe.depthTestAgainstTerrain = false
viewer.scene.fog.enabled = false
depthTestAgainstTerrain 建议先关着。圆盘高度不高的时候,开着地形深度测试,波纹容易被山体「吃掉」一块,排查起来很烦。
有动画就别开 requestRenderMode,否则波纹不刷帧,看着像死图。
自定义材质:注册一次就行
Cesium 扩展材质的官方姿势是往 _materialCache 里塞一份 fabric:
javascript
Cesium.Material.RippleType = 'Ripple'
Cesium.Material._materialCache.addMaterial(Cesium.Material.RippleType, {
fabric: {
type: Cesium.Material.RippleType,
uniforms: {
color: new Cesium.Color(1.0, 0.2, 0.3, 0.9),
duration: 3.0,
ringCount: 3.0,
ringWidth: 0.15,
centerSize: 0.06,
},
source: RIPPLE_GLSL, // 下面讲
},
translucent() {
return true
},
})
注意三点:
translucent一定要返回true,不然半透明环会花- uniforms 里的数字类型用
float心态对待,ringCount也建议写成3.0这种 - 注册要在
Material.fromType('Ripple')之前,全局做一次就够
后面创建实例:
javascript
const material = Cesium.Material.fromType('Ripple', {
color: Cesium.Color.fromCssColorString('#ff2d55').withAlpha(0.95),
duration: 3.2,
ringCount: 3,
ringWidth: 0.12,
centerSize: 0.08,
})
Shader 在干什么(先人话,再代码)
椭圆的 UV 中心大概在 (0.5, 0.5)。先算当前片元离中心的归一化距离 dist(0 在中心,1 在边缘)。
时间用 Cesium 内置的 czm_frameNumber,大约按 60fps 换算成秒,再除以 duration,得到周期进度。多圈的相位错开:fract(time - i / ringCount),这样几圈不会重叠成一根粗环。
每一圈:
ringRadius随时间从 0 走到 1- 片元到环的距离越近越亮
ringWidth控制环有多「胖」- 越往外 alpha 越低,看起来像在消散
中心再叠一个小亮点,免得扩散时中间空成黑洞。最外圈 dist > 1 直接透掉,保证圆盘外边不脏。
核心片段大概是这样:
glsl
vec2 st = materialInput.st;
float dist = distance(st, vec2(0.5, 0.5)) * 2.0;
float time = float(czm_frameNumber) / 60.0 / max(duration, 0.0001);
float totalAlpha = 0.0;
for (int i = 0; i < 8; i++) {
if (float(i) >= ringCount) break;
float phase = fract(time - float(i) / max(ringCount, 1.0));
float ringRadius = phase;
float distToRing = abs(dist - ringRadius);
float width = ringWidth * (1.0 - phase * 0.3);
float ringAlpha = 1.0 - smoothstep(0.0, width, distToRing);
ringAlpha *= (1.0 - phase * 0.7);
totalAlpha = max(totalAlpha, ringAlpha);
}
float centerAlpha = smoothstep(max(centerSize, 0.001), 0.0, dist);
totalAlpha = max(totalAlpha, centerAlpha);
if (dist > 1.0) {
material.alpha = 0.0;
return material;
}
material.emission = color.rgb * (1.0 + totalAlpha);
material.alpha = color.a * clamp(totalAlpha * 1.4, 0.0, 1.0);
有个细节:GLSL 里 for 循环上限我写死到 8。Cesium 材质这套对动态循环不太友好,ringCount 用 if break 截断更稳。所以组件层我也把圈数限制在 1~8,再多没必要,看起来也乱。
发光感主要靠 emission,别只改 diffuse。大屏暗底图下,emission 才会「亮得出来」。
参数怎么调:这份表比看代码管用
我调效果基本就围着这几个转:
| 参数 | 含义 | 我对参数的体感 |
|---|---|---|
color / alpha |
颜色和透明度 | 预警偏红、雷达偏青;alpha 太低卫星图上会脏,太高又糊成实心饼 |
maxRadius |
圆盘真实半径(米) | 城市区块 500~1500 比较常见;太大一屏只能看一个点 |
duration |
一圈扩散周期(秒) | 别太快。1 秒内扩完会很躁,预警类我更常放 3~5 秒 |
ringCount |
同时存在的圈数 | 3 比较稳;雷达可以 5~6 细环;毒气我反而用 2 圈宽环 |
ringWidth |
单环相对宽度(0~1) | 0.08 细、0.2 粗。细环多圈像扫描,粗环少圈像扩散云 |
centerSize |
中心亮点大小 | 太小像没中心,太大抢戏;0.04~0.1 里找 |
height |
圆盘离地高度(米) | 稍微抬一点能减少和地表 z-fighting;贴地场景再另说 |
同一套几何,光改参数就能换气质。我 demo 里几套预设大致是:
- 火情预警:红、半径 900、3 圈、周期 3.2s
- 雷达扫描:青、大半径、5 圈细环、更慢
- 毒雾扩散:紫、2 圈宽环、很慢,像在蔓延
- 应急集结:黄、更快一点,提醒「马上看这里」
- 生态监测:绿、多圈细密、最慢,偏「监测中」而不是「出事了」
建议你自己做组件时也准备 3~5 个预设,别只丢给产品一个默认红圈------他们很难想象参数空间。
圆盘怎么挂到场景上
几何用 EllipseGeometry,外观用 MaterialAppearance:
javascript
const primitive = new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: new Cesium.EllipseGeometry({
center: Cesium.Cartesian3.fromDegrees(lng, lat),
semiMajorAxis: maxRadius,
semiMinorAxis: maxRadius,
height: height,
vertexFormat: Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
}),
}),
appearance: new Cesium.MaterialAppearance({
material,
flat: true,
faceForward: true,
translucent: true,
}),
asynchronous: false,
allowPicking: false,
})
viewer.scene.primitives.add(primitive)
几个注意点:
vertexFormat要对上 。材质要采样st,别忘了用TEXTURED那套,否则 UV 不对波纹会怪。flat: true对这种纯发光 overlay 更干净,少受光照影响。- 属性分两类更新
- 热更新:
color/duration/ringCount/ringWidth/centerSize→ 改material.uniforms - 要重建:
maxRadius/height/ 坐标 → 删 Primitive 再新建
我刚开始所有属性都重建,滑条拖一下就闪一下,体验很差。
- 热更新:
- 演示里为了省事
asynchronous: false。上生产、一次加很多点,可以改回异步,注意创建完成前不要对 primitive 乱改。
坑 1:时间别自己 setInterval 去推
我第一版曾经想过 JS 每帧改一个 time uniform。能跑,但有两个麻烦:
- 你得挂
scene.preRender,组件销毁还要记得摘掉 - 多实例共享材质时,处理不好会抢同一个 time
后来直接用 czm_frameNumber,材质实例各自算进度,暂停就更简单了------
javascript
// 暂停:把周期拉到天文数字,相位几乎不动
material.uniforms.duration = paused ? 1e12 : realDuration
这不是最优雅的 pause API,但实现成本极低,大屏 demo 够用。真要精确暂停,再加一个 pauseTime uniform 也行。
坑 2:相机别从太空「刮」过来,也别飞完再硬掰
这个坑我调 demo 的时候踩得挺闷。
初始化 如果直接 flyToBoundingSphere,默认相机往往在地球外很远,一下冲到武汉边上,地球会被「刮」一圈,很晕。初始化更适合:
javascript
viewer.camera.viewBoundingSphere(sphere, offset)
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY)
瞬间落位;用户切换预设再用 flyToBoundingSphere,并加个缓动:
javascript
viewer.camera.flyToBoundingSphere(sphere, {
duration: 1.8,
offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-52), range),
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
})
另外,页面右侧如果有属性面板,包围球飞完波纹会出现在「整页中心」,结果被面板挡住一半,看着像没居中。我第一版飞完又用 moveRight 纠一次,飞到位再抖一下,更诡异。
后来改成:偏移写进飞行目标------注视点略往东偏一点,让真实波纹落在左侧可视区中心,飞行过程一次到位,没有回抖。
大屏项目同样适用:有侧栏就别假设「屏幕中心 = 地图中心」。
坑 3:扩散速度快了真的会「假」
参数里我刻意把整体周期放慢了。早期为了「显眼」把 duration 放到 1 秒内,录 GIF 确实炸裂,真挂到值班大屏上就很吵,像报警灯坏了。
经验大概是:
- 强提醒(应急):2~3 秒
- 普通预警:3~5 秒
- 监测 / 扫描感:5~8 秒
圈数和速度要匹配。圈多还更快,屏幕上会糊成一片光,细节全丢。
结构还是单 HTML
跟火情那篇一样,CDN + 一个文件。方便的地方在于:给业务看效果时不用起工程,改参数刷新就有反馈。以后进 Vue / React,材质注册和 createRipple 搬进 onMounted 即可,API 不变。
目录心智很简单:
text
demo.html
├─ 样式:顶栏预设 + 右侧参数面板
├─ GLSL:Ripple 材质
├─ PRESETS:几套可对比参数
└─ 创建 / 更新 / 相机
完整文件里大概 700 行,一半是面板 UI。真正跟 Cesium 相关的核心并不长。
性能上我怎么看
单点、五点这种演示量,Primitive + 自定义材质毫无压力。真正要小心的是:
| 点 | 建议 |
|---|---|
| 点数上去 | 能合并到少量 Primitive 更好;别每个点一个 Entity 椭圆 |
| 动画 | 放 shader,别 JS 每帧改几何 |
| 重建 | 只在半径 / 高度 / 坐标变化时重建 |
| 拾取 | 不需要点击就 allowPicking: false |
| 贴地 | 高版本 Cesium 上 GroundPrimitive + Ellipse + MaterialAppearance 有兼容坑,我更倾向抬一点高度,或者项目里单独验证 |
百级点位以上建议自己压一下。材质本身不贵,怕的是反复创建销毁和开拾取。
怎么跑
- 浏览器打开
demo.html(要联网,Cesium CDN) - 顶部切换预设看风格差异
- 右侧拖参数,感受每个值对观感的影响
这篇 demo 底图没走 Ion,一般不用 Token。若你改成 Ion 影像,记得 Token 别提交到公开仓库。
写在最后
火情大屏那篇是「一整桌菜」,这篇是「单独一道」------涟漪点看着简单,想又稳又好看,关键还是:
- 动画放自定义材质,别硬每帧改椭圆半径
maxRadius决定真实范围,shader 只负责 0~1 的相对扩散- 参数要成套调:颜色、圈数、环宽、周期一起决定气质
- 相机初始化别刮地球,有侧栏就把偏移做进目标点
- 扩散宁慢勿快,大屏不是 GIF 擂台
如果你上篇是冲着炫技和大屏完整度来的,这篇更适合直接拆进组件库。后面有空可能再写光墙 / 轨迹拖尾,继续单组件细拆。
有踩坑欢迎评论区聊聊,别闷头调参数调到半夜------我就是这么过来的。
标签: Cesium 可视化 JavaScript GLSL 大屏
