用 Cesium 撸了一个森林火情监控大屏,弧线、粒子、发光效果都齐了

最近接了个可视化 demo 的活,要在地图上展示火源点和周边资源(取水点、消防站、监测云台、机场)的关联关系。产品给的参考图是那种大屏风格------卫星底图、彩色弧线连到火点、线上有光点流动、弧线还会呼吸闪烁。

一开始想的是 ECharts GL 或者 Mapbox,但客户明确要 3D 地球视角,能转、能俯冲那种。最后选了 Cesium,一个 HTML 文件就能跑,方便给甲方预览。

本文记录整个 demo 的实现思路,以及我踩过的两个坑:弧线拱太高初始相机把北侧标注裁出屏幕


最终效果长什么样

核心视觉就四块:

  1. 火源点 + 半透明扩散椭圆
  2. 各资源站点图标 + 文字标注
  3. 站点到火点的抛物线弧线(底色 + 发光层)
  4. 弧线上** Canvas 2D 粒子**流动

UI 层是普通的 HTML/CSS 叠在 Cesium 上面,右侧态势面板数据是 mock 的,隔几秒随机跳一下,营造"实时"的感觉。


项目结构:就一个 HTML

没有 Webpack,没有 Vue,CDN 引入 Cesium 1.114,<script> 里写完逻辑,浏览器直接打开。

这样做的好处是:改完刷新就能看 ,跟甲方对需求的时候特别省事。后面如果要进 Vue/React 项目,把 <script> 里的逻辑拆到组件里就行,Cesium API 本身不变。

唯一前置条件:去 Cesium Ion 注册拿 Token,替换代码里的 CESIUM_ION_TOKEN


第一步:先把 Viewer 搭起来

Cesium 默认 UI 一大堆,大屏用不上,全关掉:

javascript 复制代码
viewer = new Cesium.Viewer('cesiumContainer', {
  baseLayer: Cesium.ImageryLayer.fromProviderAsync(
    Cesium.IonImageryProvider.fromAssetId(3), // 卫星影像
  ),
  terrainProvider: new Cesium.EllipsoidTerrainProvider(),
  animation: false,
  timeline: false,
  baseLayerPicker: false,
  geocoder: false,
  homeButton: false,
  sceneModePicker: false,
  navigationHelpButton: false,
  fullscreenButton: false,
  infoBox: false,
  selectionIndicator: false,
  requestRenderMode: false, // 有动画,不能开按需渲染
})

另外关了几个吃性能但大屏不需要的效果:

javascript 复制代码
scene.globe.showGroundAtmosphere = false
scene.skyAtmosphere.show = false
scene.fog.enabled = false
scene.globe.enableLighting = false
scene.globe.depthTestAgainstTerrain = false // 标注不要被地形挡住
scene.backgroundColor = Cesium.Color.fromCssColorString('#0a0d12')

depthTestAgainstTerrain = false 这个我一开始没关,结果山多的地方 label 时隐时现,排查了半天。


弧线怎么做:别用大地线,自己插值

Cesium 画线默认会走 ArcType.GEODESIC(贴地球表面的大圆弧)。我们要的是拱起来的抛物线 ,所以必须设 arcType: Cesium.ArcType.NONE,然后自己算每个点的经纬度和高度。

核心就一行:

javascript 复制代码
const h = arcHeight * Math.sin(Math.PI * t) // t 从 0 到 1,两端高度为 0,中间最高

完整函数:

javascript 复制代码
function makeArcPositions(lon1, lat1, lon2, lat2, arcHeight, nPoints = 48) {
  const out = new Array(nPoints + 1)
  for (let i = 0; i <= nPoints; i++) {
    const t = i / nPoints
    const lon = lon1 + (lon2 - lon1) * t
    const lat = lat1 + (lat2 - lat1) * t
    const h = arcHeight * Math.sin(Math.PI * t)
    out[i] = Cesium.Cartesian3.fromDegrees(lon, lat, h)
  }
  return out
}

48 个点够平滑了,加到 100 肉眼几乎看不出区别,还浪费顶点。

坑 1:弧高别写死,不然能拱到天上

我第一版给每条线写死了 arcH: 58000 这种值(单位米)。水平距离才五六公里,弧顶五六十公里------打开页面弧线直接戳出屏幕,跟发射导弹似的。

后来改成按两点距离动态算

javascript 复制代码
function calcArcHeight(lon1, lat1, lon2, lat2) {
  Cesium.Cartesian3.fromDegrees(lon1, lat1, 0, undefined, _arcScratchA)
  Cesium.Cartesian3.fromDegrees(lon2, lat2, 0, undefined, _arcScratchB)
  const dist = Cesium.Cartesian3.distance(_arcScratchA, _arcScratchB)
  return Math.min(dist * 0.12, 4500) // 距离的 12%,上限 4.5km
}

0.124500 是我调了几版之后的值,不同场景可以微调。原则就一条:弧高跟水平距离成比例,别搞成固定大数。


弧线分两层:底色 + 发光

底色层:4 条线合并成 1 次 draw call

Primitive + GeometryInstance 批量提交,比每条线单独 entities.add 省:

javascript 复制代码
const instances = STATIONS.map(st => new Cesium.GeometryInstance({
  geometry: new Cesium.PolylineGeometry({
    positions: makeArcPositions(st.lon, st.lat, FIRE.lon, FIRE.lat, st.arcH),
    width: 1.5,
    arcType: Cesium.ArcType.NONE,
  }),
  attributes: {
    color: Cesium.ColorGeometryInstanceAttribute.fromColor(
      Cesium.Color.fromCssColorString(st.color).withAlpha(0.25),
    ),
  },
}))

scene.primitives.add(new Cesium.Primitive({
  geometryInstances: instances,
  appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
  releaseGeometryInstances: true,
  allowPicking: false,
}))

发光层:PolylineGlow + 正弦呼吸

上层用 Entity + PolylineGlowMaterialProperty,颜色 alpha 用 CallbackProperty 驱动正弦波,每条线相位错开 idx * (Math.PI * 0.5),避免齐刷刷闪。

一个小优化:CallbackProperty 里用 performance.now() 算时间,别用 JulianDate 做差值------后者每帧会 new 对象,动画开久了 GC 压力明显。

javascript 复制代码
color: new Cesium.CallbackProperty(() => {
  const t = performance.now() / 1000
  const alpha = 0.25 + 0.65 * (0.5 + 0.5 * Math.sin(t * 1.6 + phase))
  if (Math.abs(alpha - _lastAlpha) > 0.005) {
    _cachedColor = baseColor.withAlpha(alpha)
    _lastAlpha = alpha
  }
  return _cachedColor
}, false),

alpha 变化小于 0.005 就不重新 clone,也是抠细节。


粒子动画:别走 Cesium,自己画 Canvas

弧线上的流动光点,如果用 Cesium 的 PointPrimitiveEntity 做 80 个动画点,每帧改 position,开销不小。

我的做法是:单独盖一层 2D Canvas ,用 scene.cartesianToCanvasCoordinates 把弧线上的世界坐标投影到屏幕,然后在 Canvas 上画圆点。

流程:

  1. 启动时预计算所有弧线的 48 个世界坐标点(静态,不变)
  2. 维护 80 个粒子的对象池,每个粒子有 t(0~1 进度)和 speed
  3. 每帧 t += speed,用 floor(t * 48) 取当前点,投影到屏幕,画带 shadow 的圆
  4. t >= 1 重置,循环播放
javascript 复制代码
const idx = Math.min(Math.floor(p.t * N_POINTS), N_POINTS - 1)
const worldPt = ARC_POINTS[p.si][idx]
const screen = scene.cartesianToCanvasCoordinates(worldPt, _scratchScreen)
if (!screen) continue

pCtx.globalAlpha = Math.sin(Math.PI * p.t) * 0.85
pCtx.shadowColor = p.color
pCtx.shadowBlur = 7
pCtx.fillStyle = p.color
pCtx.beginPath()
pCtx.arc(screen.x, screen.y, p.size, 0, Math.PI * 2)
pCtx.fill()

粒子完全绕开了 Cesium 渲染管线,相机怎么转都能跟住。_scratchScreen 复用一个 Cartesian2,避免每帧 new。


标注图标:Canvas 画 emoji,不用图片资源

站点图标没用 PNG,直接用 Canvas 画圆底 + emoji:

javascript 复制代码
function makeIconDataURL(emoji, bg, size = 52) {
  const key = `${emoji}_${bg}_${size}`
  if (_iconCache[key]) return _iconCache[key] // 缓存,同类型只画一次
  
  const c = document.createElement('canvas')
  // ... 画外圈光晕、实心圆、描边、emoji
  return c.toDataURL()
}

好处:零 HTTP 请求,改颜色只改参数,Billboard 直接 image: makeIconDataURL('💧', '#00d4ff')

Billboard 和 Label 绑在同一个 Entity 上,比分开 add 少一半 draw call。disableDepthTestDistance: Number.POSITIVE_INFINITY 保证标注永远在最前面。

火场范围用 EllipseGeometry + Primitive 画半透明椭圆,比 Entity 静态图形性能好一点。


坑 2:相机别写死坐标

第一版相机写死:

javascript 复制代码
viewer.camera.flyTo({
  destination: Cesium.Cartesian3.fromDegrees(119.82, 29.13, 52000),
  orientation: { pitch: Cesium.Math.toRadians(-45) },
})

弧线高度调低之后,北侧的取水点和机场直接出屏幕了------俯角太陡,视心偏南。

改成根据所有标注点算包围球自动取景

javascript 复制代码
function initCamera() {
  const positions = STATIONS.map(st =>
    Cesium.Cartesian3.fromDegrees(st.lon, st.lat),
  )
  positions.push(Cesium.Cartesian3.fromDegrees(FIRE.lon, FIRE.lat))

  const boundingSphere = Cesium.BoundingSphere.fromPoints(positions)
  boundingSphere.radius *= 1.6

  viewer.camera.flyToBoundingSphere(boundingSphere, {
    duration: 2.8,
    offset: new Cesium.HeadingPitchRange(
      Cesium.Math.toRadians(10),
      Cesium.Math.toRadians(-32),
      boundingSphere.radius * 3.5,
    ),
  })
}

俯角从 -45° 调到 -32°,视距按包围球半径算。以后加新站点不用改相机参数,自动框进去。


数据层:一个数组驱动全部

所有站点配置集中在一个 STATIONS 数组,弧线、图标、粒子颜色都从这里读:

javascript 复制代码
const STATIONS = [
  { id: 'water', lon: 119.78, lat: 29.18, label: 'XX取水点', dist: '5.9km', color: '#00d4ff', icon: '💧' },
  { id: 'fire',  lon: 119.76, lat: 29.13, label: 'XX消防站', dist: '6.1km', color: '#00ff88', icon: '🏠' },
  { id: 'monitor', lon: 119.82, lat: 29.07, label: 'XX监测点云台', dist: '3.2km', color: '#ffcc00', icon: '📷' },
  { id: 'airport', lon: 119.88, lat: 29.19, label: 'XX机场', dist: '2.8km', color: '#aa88ff', icon: '✈️' },
]

STATIONS.forEach(st => {
  st.arcH = calcArcHeight(st.lon, st.lat, FIRE.lon, FIRE.lat)
})

接真实接口的时候,把 STATIONS 换成 API 返回的数据,重新跑一遍 buildStaticArcs / buildGlowArcs / buildLabels / initCamera 就行。


性能小结

做法
静态弧线 4 条合并 1 个 Primitive
发光弧线 Entity + 缓存 alpha,减少 clone
粒子 Canvas 2D,对象池,预计算路径
图标 Canvas 生成 + 内存缓存
标注 Billboard + Label 同 Entity
相机 包围球自适应,不写死

这个 demo 标注点个位数,谈不上压力测试。但如果要上百条线,静态层继续合并 Primitive、动画粒子保持 Canvas 方案、Entity 能少则少,方向是对的。


怎么跑

  1. 去 Cesium Ion 注册,复制 Access Token
  2. 替换代码里的 CESIUM_ION_TOKEN
  3. 浏览器直接打开 map.html(要联网,Cesium 资源走 CDN)

Token 别提交到公开仓库,生产环境走环境变量。


后续可以扩展的方向

  • 弧线改为 WebSocket 推送的实时调度路径
  • 点击站点弹详情面板(Cesium 的 ScreenSpaceEventHandler
  • 接真实地形 Cesium.createWorldTerrainAsync(),山区效果更立体
  • 迁到 Vue 3:onMounted 里 init Viewer,onUnmountedviewer.destroy()

写在最后

Cesium 做这种"地图 + 关系线 + 动效"的大屏,上手门槛比 ECharts 高,但 3D 视角确实唬人。几个关键点:

  • 抛物线弧线自己插值,ArcType.NONE
  • 弧高跟距离挂钩,别写死大数字
  • 粒子动画放 Canvas,别硬塞进 Cesium 管线
  • 相机用包围球,别凭感觉写坐标

完整代码在一个 HTML 文件里,700 多行,注释写得比较细,需要的话可以直接拿去改。

如果你也在做类似的可视化,欢迎评论区交流踩坑经验。


标签: Cesium 可视化 JavaScript GIS 大屏

相关推荐
IT_陈寒1 小时前
垃圾回收器选错了,我的Java服务内存炸了
前端·人工智能·后端
月光下的丝瓜2 小时前
Flutter 国内安装指南
前端·flutter
先吃饱再说2 小时前
JavaScript中`this` 的“千层套路”:从默认绑定到箭头函数的五种指向
javascript
玄星啊2 小时前
AI 编程的第 30 天,我怀念古法 Coding 了
前端·ai编程
Jolyne_2 小时前
Angular基础速通
前端·angular.js
foxire2 小时前
基于nodejs实现服务端内核引擎
javascript
锋行天下3 小时前
半秒开!还有谁!!!
前端·vue.js·架构
代码搬运媛4 小时前
git 下中文文件名乱码问题解决
前端
CaffeinePro4 小时前
告别知识点零散!React零基础通关,从环境搭建到Ant Design页面实战
前端·react.js