Cesium 涟漪扩散点:自定义材质,参数调对了才好看

上次写了篇 用 Cesium 撸了一个森林火情监控大屏,反馈比预想的好,然后我就想到了「火点那圈扩散」这个下个也是必须要的。

说实话那篇里的火场椭圆比较简单,更多是半透明盖一层。后来项目里预警类需求变多了------异常点、雷达覆盖、毒气扩散、应急集合------都要一种能看懂严重程度、又别太吵的波纹点。正好单独拆成一个组件,这篇就把涟漪扩散点(RipplePoint)讲清楚。

还是老规矩:一个 HTML 就能跑,讲思路、讲参数、讲坑。代码会贴,但不会一上来就甩两屏 shader。

先看看实际效果

这篇解决什么问题

大屏上「某个位置出事了」,最常见的两种画法:

  1. 一个闪烁的 Billbaord / 图标
  2. 一圈一圈往外扩的波纹

图标只能说明「这里有东西」,波纹更能传达「影响范围 + 时间节奏」。但 Cesium 没有开箱即用的「多圈涟漪材质」,自己用 CallbackProperty 每帧改椭圆半径也能做,点一多就虚。

我这边走的是:椭圆几何固定半径 + 自定义 Material,动画全在 GLSL 里跑。JS 几乎不用每帧插手。

效果上准备了几套差异比较大的预设:火情预警、雷达扫描、毒雾扩散、应急集结、生态监测。颜色、半径、圈数、快慢都不一样,方便对照着调。

导入掘金前记得自己截几张效果图塞进来,比纯文字管用。建议至少:对比模式全景一张、单点特写一张、属性面板一张。


思路先讲清楚

整条链路其实就四步:

  1. 注册一个自定义 Material(fabric + GLSL)
  2. EllipseGeometry 铺一个圆盘,半径 = maxRadius
  3. 圆盘上贴 MaterialAppearance,动画靠 shader 里的 czm_frameNumber
  4. 颜色、圈数、周期这些走 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)

几个注意点:

  1. vertexFormat 要对上 。材质要采样 st,别忘了用 TEXTURED 那套,否则 UV 不对波纹会怪。
  2. flat: true 对这种纯发光 overlay 更干净,少受光照影响。
  3. 属性分两类更新
    • 热更新:color / duration / ringCount / ringWidth / centerSize → 改 material.uniforms
    • 要重建:maxRadius / height / 坐标 → 删 Primitive 再新建
      我刚开始所有属性都重建,滑条拖一下就闪一下,体验很差。
  4. 演示里为了省事 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 有兼容坑,我更倾向抬一点高度,或者项目里单独验证

百级点位以上建议自己压一下。材质本身不贵,怕的是反复创建销毁和开拾取。


怎么跑

  1. 浏览器打开 demo.html(要联网,Cesium CDN)
  2. 顶部切换预设看风格差异
  3. 右侧拖参数,感受每个值对观感的影响

这篇 demo 底图没走 Ion,一般不用 Token。若你改成 Ion 影像,记得 Token 别提交到公开仓库。


写在最后

火情大屏那篇是「一整桌菜」,这篇是「单独一道」------涟漪点看着简单,想又稳又好看,关键还是:

  • 动画放自定义材质,别硬每帧改椭圆半径
  • maxRadius 决定真实范围,shader 只负责 0~1 的相对扩散
  • 参数要成套调:颜色、圈数、环宽、周期一起决定气质
  • 相机初始化别刮地球,有侧栏就把偏移做进目标点
  • 扩散宁慢勿快,大屏不是 GIF 擂台

如果你上篇是冲着炫技和大屏完整度来的,这篇更适合直接拆进组件库。后面有空可能再写光墙 / 轨迹拖尾,继续单组件细拆。

有踩坑欢迎评论区聊聊,别闷头调参数调到半夜------我就是这么过来的。


标签: Cesium 可视化 JavaScript GLSL 大屏

相关推荐
ttwuai1 小时前
Go 后台富文本图片上传失败排查:前端限制、接口和存储目录要一起看
前端·golang·状态模式
小雪_Snow1 小时前
JS 数组的遍历 —— 两种方式详解
前端·javascript
禅思院2 小时前
AI对话前端从入门到崩溃:一个长对话引发的五层优化战争【五】
前端·架构·前端框架
zzz_23682 小时前
【Java实习面试算法冲刺】图论
java·算法·面试
kyle~2 小时前
前端---Zustand组件库(轻量快速精细化的状态管理方案)
前端
IT_陈寒2 小时前
为什么我的React状态更新总是不按套路出牌?
前端·人工智能·后端
恋猫de小郭2 小时前
Fluter 共享内存多线程正在落地,IsolateGroupBound 来了
android·前端·flutter
Sam_Deep_Thinking2 小时前
一次线程池线上故障复盘:四层防线如何避免数据丢失
java·面试·程序员
十铭忘2 小时前
Vue3实现Pixso中的钢笔工具3——梳理函数调用
前端·vue.js