1. 一个面向GIS开发者而非图形工程师的工具选择决定
1.1 洪水模拟真正的技术难点在哪里
接到这个需求的时候,甲方那边给的要求其实很直接:在一个Cesium三维场景里,把某一段流域的洪水淹没过程做成动态效果,要求能看出水位从低到高、淹没范围从小变大的趋势,最好还能让业务人员直接拖拽时间轴回放。当时团队里没有人专职做图形学,GLSL这种着色器语言对大部分人来说属于"能看懂一点但写不出一行"的状态。如果按照传统思路去搞自定义Shader,光是调试一个水面的法线扰动和淹没边界过渡就得耗掉大半工期。
但把这个问题拆开看,你会发现洪水模拟真正要解决的其实不是"水怎么画得逼真",而是三件事:第一,怎么把离散的水位监测点变成连续的淹没范围;第二,怎么让淹没范围随时间变化,形成动画;第三,怎么在Cesium的三维球面上把结果直观地叠加出来。这三件事里,前两件是数据处理和插值问题,第三件是渲染叠加问题。渲染叠加又未必非得走底层的像素着色器路线——Cesium本身提供了非常成熟的影像图层机制,只要能生成一张带透明度的图片,它就能帮你贴到地球表面。
1.2 Heatmap.js 2.0.5的工作机制为何天然贴合这个需求
Heatmap.js的核心原理并不复杂:它维护一个离屏Canvas画布,你往里不断添加带权重的数据点,它通过径向渐变把每个点渲染成一个"光斑",然后利用canvas的全局合成模式(globalCompositeOperation)把多个点的光斑叠加起来,形成一张连续的、颜色由冷到暖过渡的热力图。这个过程本质上就是一种非常朴素的二维插值可视化,只不过它没有走GPU着色器,而是跑在Canvas 2D API上。
这个机制和洪水淹没模拟的需求出奇地契合。你把每个水位监测点看成一个热力点,点的数值权重对应水位高程,点的半径对应淹没扩散半径,那么热力图自然就能把离散点变成一片连续区域。更重要的是,Heatmap.js提供了一套setData接口,你可以随时替换整个数据集并重新渲染,渲染效率足够支撑每秒十几次的刷新,做动态动画完全够用。相比之下,如果去写GLSL,你得自己处理采样、渐变、边界过渡、坐标变换,工作量完全不是一个量级。
1.3 什么情况下你才真的需要GLSL
必须诚实地说,Heatmap.js这套方案不是万能的。如果需求变成了"水面要有流动波纹""洪水边缘要跟地形精确贴合""淹没范围要根据真实地形DEM逐像素计算",那Heatmap.js就完全不够看了。前者是纯可视化,后者是物理模拟与地形分析,哪怕你用GLSL也解决不了洪水演进计算本身,那是水文模型的事情,再说究竟,Heatmap.js在这里绕开的就是"绘制层"这个环节。
所以我当时对团队定的调子是:底层水文计算交给专业的模型去算,或者用简化的高程判断逻辑来生成数据;可视化层就用Heatmap.js快速搭建,先把业务验证跑通。等到某一天真的需要像素级水面效果,再把渲染层替换成自定义Shader也不迟。这个决策让整个项目提前了一周半交付,后续在真实水文数据接入时,发现数据刷新逻辑完全不需要改动,只要改热力点的生成规则就行。
2. 版本组合与依赖清单:Cesium 1.120 + Heatmap.js 2.0.5 的搭配依据
2.1 为什么把版本精确锁定到这两个
Cesium的版本迭代非常快,每个版本都在调整API和渲染行为。选1.120这个版本稍微有点讲究。1.120对ImageryProvider的处理仍然保留了传统的回调式接口,同时支持ESM模块导入,这就让我可以在不升级整个构建生态的前提下,跨过Heatmap.js的canvas画布直接交给Cesium去加载。在1.121及之后,Cesium把很多资源加载逻辑迁移到了更严格的异步模型,部分旧的ImageryProvider写法会出现初始化时序问题,排查起来比较麻烦。因为是给项目做长期维护,锁定一个验证过的稳定版本比追新更重要。
Heatmap.js这边,2.0.5是2.x系列里比较收敛的一个版本。2.0.x相比1.x最大的变化是加入了register插件机制,比如你要扩展一个图例组件就可以不走hack路线。2.0.5的构建产物是UMD格式,既能在浏览器里直接<script>引入,也能被webpack和vite正常解析,这给前端工程整合省了不少事。再往后的版本,3.x虽然性能更好一点,但它修改了数据结构的内部接口,老项目中很多基于2.0.x写的数据操作函数迁移成本有点高。对这个需求而言,2.0.5的API已经绰绰有余。
2.2 引入Heatmap.js到Cesium工程中的两种方式
第一种是直接把heatmap.js当作普通脚本引入,然后在代码里拿全局的heatmap工厂函数。这种方式最直接,对Vue或React项目也不需要额外的封装,缺点是类型提示基本为零,团队协作时容易把参数写错。
第二种是用ESM的import方式引入。2.0.5的package.json里module字段指向的是ES5语法的build/heatmap.esm.js,你可以这样写:
import h337 from 'heatmap.js';注意,这里的h337是heatmap官方约定的命名空间,它内部用了一个叫h337的闭包变量,导入后必须用这个名字或者显式改名,否则容易跟后续代码产生名字冲突风险。我个人习惯是import h337 from 'heatmap.js'之后立刻包一层,比如:
const HeatmapFactory = h337;这样后续所有地方都引用HeatmapFactory,就算换版本也不至于全局改一处。
2.3 画布与三维场景的层级关系
Cesium的场景本质上是一个WebGL画布,而Heatmap.js也是一个HTMLCanvasElement,这两个画布天然是平行的。要让热力图显示在三维地球上,思路不是把热力canvas覆盖在Cesium canvas上面,那样会遮挡场景操作。正确做法是把热力canvas作为数据源,交给Cesium的ImageryProvider去读取,让Cesium把它作为一层纹理贴到地球表面。这就是整个方案最核心的架构支点:Heatmap.js做数据计算和栅格化,Cesium负责纹理投影到3D表面。
明白这一点之后,后面所有的实现都围绕一个模式展开:更新Heatmap数据得到新的canvas内容,然后让Cesium刷新对应影像图层。至于刷新方式,最省事的是用一个自定义ImageryProvider,把requestImage这个回调直接设计成返回当前heatmap canvas的内容。
3. 将地理坐标"翻译"成Heatmap的像素坐标
3.1 从经纬度到容器像素的映射关系
Heatmap.js不认识经纬度,它只知道canvas画布里横向x、纵向y的坐标。所以第一步永远是坐标转换。我最初踩过的坑是直接拿Cesium的SceneTransforms.wgs84ToWindowCoordinates来做转换,这方法在相机固定的情况下能工作,但只要相机一旋转缩放,热力图就会完全错位——因为它转换出来的是屏幕坐标,不是贴图坐标。贴图坐标必须是稳定的、跟地图表面绑定的坐标。
正确做法是用经纬度范围到像素坐标的线性映射。先确定你要可视化的地理范围,比如东经118.5到119.5、北纬31.0到32.0,然后跟heatmap canvas的宽度高度对应起来:
const bounds = { west: 118.5, east: 119.5, south: 31.0, north: 32.0 }; const width = 800; const height = 600; const lonToX = (lon) => ((lon - bounds.west) / (bounds.east - bounds.west)) * width; const latToY = (lat) => ((bounds.north - lat) / (bounds.north - bounds.south)) * height;注意纬度方向要反过来,因为canvas的y轴向下增长,而纬度是向上增长的。这个细节如果写反了,整个热力图层会上下颠倒,在真实项目里排查这种问题特别费时间,因为视野一放大缩小,你根本看不出规律来。
3.2 经纬度范围与画布大小之间的取舍
画布大小并不是越精细越好。Heatmap.js的每个数据点渲染会生成一个径向渐变圆,如果画布尺寸太大,而监测点数量又有限,热力图边缘会出现明显锯齿,而且帧率下降很快。我当时选800×600是因为这个分辨率在常见的全球视野下刚好能覆盖市域流域范围,放大细节时再把范围收缩重新生成。另一个更聪明的做法是动态计算画布宽高,跟当前相机的视口范围做关联,范围缩小时重建一张更精细的canvas,范围扩大时退回到低分辨率模式,这样既能保持清晰度又不至于浪费性能。
3.3 完整坐标转换封装函数
为了不让数据层跟坐标转换逻辑混在一起,我封装了一个工具函数,这里贴出来供参考:
function createHeatmapPoint(lon, lat, value, bounds, width, height) { const x = ((lon - bounds.west) / (bounds.east - bounds.west)) * width; const y = ((bounds.north - lat) / (bounds.north - bounds.south)) * height; return { x, y, value }; }如果你后续还要处理一些跨越180度经线的特殊区域,记得对经度做归一化处理,否则误差会直接以像素为单位放大。真实项目中,像洪水监测点这种数据量级,一次生成几百个点完全没有压力。
4. 从静态热力到动态洪水:核心实现全过程
4.1 数据模型设计:水位、范围、时间戳
动态洪水模拟的底层数据不能只给一个点集,必须带时间维度。我是这样组织数据的:
const floodData = [ { time: '2024-06-17T08:00:00Z', points: [ { lon: 118.62, lat: 31.25, waterLevel: 8.2 }, { lon: 118.71, lat: 31.33, waterLevel: 7.9 } ] }, { time: '2024-06-17T09:00:00Z', points: [ { lon: 118.62, lat: 31.25, waterLevel: 9.1 }, { lon: 118.71, lat: 31.33, waterLevel: 8.6 } ] } ];每个时间切片里,点的坐标是固定的,变化的是waterLevel字段。把水位值直接作为Heatmap.js点的value输入,水位越高,热力点中心越红。为了让淹没范围随时间扩大,我还在生成点的时候加了两个派生变量:一个是radius,它根据水位值变化,模拟水流扩散半径;另一个是每个点的透明度,让中心区域颜色更实,外围更虚化。Heatmap.js的setData支持max字段,这个max最好设成所有时间切片里水位的最大值,否则动画过程中颜色会跳变,整体色调不稳定。
4.2 自定义ImageryProvider:让Cesium读取Heatmap画布
有了数据模型,下一个核心就是把heatmap的canvas给Cesium。自定义一个ImageryProvider的骨架大概是这样的:
class HeatmapImageryProvider { constructor(heatmapInstance, bounds, options = {}) { this._heatmap = heatmapInstance; this._bounds = bounds; this._tilingScheme = new Cesium.GeographicTilingScheme({ numberOfLevelZeroTilesX: 1, numberOfLevelZeroTilesY: 1 }); this._canvas = heatmapInstance.canvas; this._ready = true; this.rectangle = Cesium.Rectangle.fromDegrees( bounds.west, bounds.south, bounds.east, bounds.north ); this._tileWidth = heatmapInstance.canvas.width; this._tileHeight = heatmapInstance.canvas.height; } get tileWidth() { return this._tileWidth; } get tileHeight() { return this._tileHeight; } get ready() { return this._ready; } get rectangle() { return this._rectangle; } requestImage(x, y, level, request) { return this._canvas; } }这里最关键的是GeographicTilingScheme的设置。如果你不显式设置,Cesium默认可能用WebMercatorTilingScheme,也就是3857投影。而你的热力图是按照简单的经纬度线性映射生成的,两者一混就会出现"图层贴不到地球上"的问题,表现为热力图上所有点位整体偏移几百米甚至更远。
requestImage直接返回canvas对象就行,Cesium会把它转成纹理贴到对应的瓦片上。因为瓦片覆盖区域就是热力图的bounds,所以每个瓦片请求到的都是同一张canvas,Cesium内部会自动做纹理拉伸和采样。
4.3 注册图层到Cesium场景
把Provider注册进场景有两种方式。一种是走传统图层:
const heatmapLayer = viewer.imageryLayers.addImageryProvider(heatmapProvider);另一种是包裹一层SingleTileImageryProvider,不过既然我们已经自定义了Provider,直接添加到imageryLayers是最顺的。
这里要留意添加顺序。如果热力图下面还有地形影像,比如地形做了抬高,热力图会跟着地形起伏,这其实是好事,因为洪水覆盖在真实地形上看起来更真实。但如果你的地形数据精度不够,起伏会导致热力图边缘出现锯齿,这时候可以直接把热力图放在地形之上,也就是不参与terrainProvider的运算,Cesium允许通过给ImageryLayer设置splitDirection等方法做混合渲染。更简单的办法是关掉地形:
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();关掉之后热力图会平滑贴在球面上,视觉上干净很多,虽然少了地形起伏的真实感,但对业务演示来说反而更聚焦。
4.4 用时间序列驱动洪水蔓延动画
动态效果的核心是一个时间序列循环。我用了requestAnimationFrame做驱动,但每次更新之前先做时间插值,从当前帧时间戳里算出应该显示哪一组数据。
let currentIndex = 0; let progress = 0; function updateFlood() { const currentData = floodData[currentIndex]; const nextData = floodData[currentIndex + 1] || floodData[currentIndex]; const points = currentData.points.map((p, idx) => { const nextP = nextData.points[idx]; const interpolatedLevel = p.waterLevel + (nextP.waterLevel - p.waterLevel) * progress; return { x: lonToX(p.lon), y: latToY(p.lat), value: interpolatedLevel, radius: 20 + interpolatedLevel * 2 }; }); heatmapInstance.setData({ max: 12.0, data: points }); progress += 0.02; if (progress >= 1) { progress = 0; currentIndex = (currentIndex + 1) % (floodData.length - 1); } heatmapImageryProvider.updated = false; // 标记刷新 requestAnimationFrame(updateFlood); }progress每次加0.02的意思是每一帧推进2%,完整一段过渡大概需要50帧,也就是不到1秒钟。这个速度在演示时刚好,不快不慢。如果你想让洪水蔓延更急,把增量调到0.05,想要平缓就调成0.01。更新完数据后,问题来了:Cesium自带的ImageryLayer默认不会主动重新请求同一个瓦片,因为它觉得纹理没变。这里必须手动触发刷新。最简单的办法是调用imageryLayer._textureChanged事件,或者干脆移除图层再加回去,但那样会闪烁。我采用的办法是在Provider里增加一个version字段,每次更新后自增,然后Cesium检测到纹理变化就自动重新渲染。
更稳健的做法是直接调用:
imageLayer.refreshImplementation();但不同Cesium版本这个内部方法名不一样,1.120里我测试下来最稳妥的触发方式还是给请求的url兜底做缓存击穿:每次更新时给requestImage返回一个全新的canvas对象,而不是复用旧的那张。这样Cesium拿到的引用不同,纹理更新逻辑自然就会重新走一遍。
4.5 分辨率与视觉质量调节
Heatmap.js处理出来的热力图默认是平滑渐变的,但它在边缘部分容易发灰,主要是因为多个热力点叠加时,离中心太远的地方alpha值逐渐衰减到接近0。为了让淹没范围边界更清晰,可以手动在渲染完heatmap canvas之后做一次像素级处理:把alpha值低于某个阈值的像素直接置为透明,高于阈值但处于过渡带的部分做增强对比。
function enhanceHeatmapCanvas(canvas, threshold = 30) { const ctx = canvas.getContext('2d'); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; for (let i = 0; i < data.length; i += 4) { const alpha = data[i + 3]; if (alpha < threshold) { data[i + 3] = 0; } else if (alpha < threshold * 2) { data[i + 3] = alpha * 2; } } ctx.putImageData(imageData, 0, 0); }这个后处理在数据量小的时候一帧内就搞定了,不会影响动态性能。处理之后洪水的轮廓瞬间变得锐利,业务人员看到的第一反应通常是"哎,这个范围很清楚"。
5. 实测中的常见问题与完整排查链路
5.1 热力图层与地球表面"漂移"的原因
这是我第一次集成时遇到的问题:热力图整体贴到了地球上,但位置明显偏了,不同缩放级别下偏移量还不一样。排查过程分了四步。
第一步,确认热力图bounds与Cesium Rectangle一致。我打印了Provider里的rectangle属性,跟Cesium相机范围内对比,发现本身没错。
第二步,怀疑底图底图叠加层级顺序有问题。因为我用的底图是WebMercator投影像片源,而热力图用的是GeographicTilingScheme,两套投影混在一起,影像服务在叠加时把热力图强制按3857来采样了,导致位置偏移。这也是大部分GIS场景中"图层飘了"的根源。
第三步,验证这个推理的方法是将底图临时切到EllipsoidTerrainProvider纯球面模式,发现热力图与经纬网完美贴合。这就实锤了投影不一致的问题。
第四步,解决办法有两种。要么把heatmap的生成范围统一处理成WebMercator投影下的坐标直接映射,要么就是干脆用GeographicTilingScheme作为统一坐标系,并把底图选择成EGM或者ArcGIS World Geodetic这种天然支持经纬度的服务。考虑到我的实际数据源是经纬坐标,用后一种方案最快。替换底图服务之后,漂移问题彻底消失。
5.2 更新频繁导致渲染卡顿
动态动画跑起来后,帧率一度降到15帧左右,看起来非常不流畅。用Chrome Performance面板看了一下,发现每次setData之后整个canvas重绘开销并不算大,真正耗时的是requestAnimationFrame里频繁调用Cesium.requestRender和纹理上传。Cesium的默认渲染循环是懒渲染,也就是说没有变化才不会重绘。可我每次更新canvas都是新建一个对象,Cesium就会认为图层内容发生变化,于是触发全屏重绘和纹理绑定,开销成倍增加。
优化是引入了脏标记和局部更新机制。核心想法是:heatmap canvas只在需要的时候重新绘制,如果数据没有变化就复用上一步的canvas对象,省的重新传纹理。配合Cesium的scene.requestRender()在真正变化的那一帧才调用,其他帧不触发额外的渲染请求。
这个优化做完后,帧率稳定在50帧左右。另外,Heatmap.js内部也有一个value上限问题:如果max设太小,高水位数据点会直接溢出色标范围,整个图偏白;设太大,所有点颜色都很淡。这个值最好从数据集中动态计算,确保洪峰水位对应最深的红色。
5.3 热力图边缘出现"方框"色块
还有一种情况是heatmap canvas透明区域被Cesium的纹理采样处理成了黑色或白色。原因是Canvas默认透明区域在某些浏览器下会产生预乘alpha问题,尤其是通过ImageryProvider上传时,如果premultipliedAlpha属性没匹配,GPU采样出来的结果就会有暗色底色。
解决方案是在生成heatmap画布时,先给它填充一层全透明背景:
ctx.clearRect(0, 0, canvas.width, canvas.height);然后在自定义ImageryProvider里设置:
this._canvas.preferredFilter = 'LINEAR';或者更简单粗暴一点,上传前经过一次enhanceHeatmapCanvas处理,把所有alpha低于阈值的像素直接透明化,既清了底色又加强了边缘,一举两得。
5.4 heatmap画布内容无法更新的经典现象
一开始我用heatmapInstance.addData增量添加点位来做动画,发现动画跑起来以后,原来已经添加的点会残留,新加的点覆盖上去之后,颜色是叠加的,越到后期整块区域越红,最后直接糊成一片。这个状态的敏感性在于,addData本身是个累加操作,不适合直接做时间序列更新。
正确的做法是每次都使用setData全量替换:
heatmapInstance.setData({ max: currentMax, data: generatedPoints });setData会清空画布再重绘,所以动画之间不会有拖影。可能有的同学会说全量重绘慢,但我实测下来,500个点以内的全量重绘耗时基本在10ms以下,对于30帧的动画完全够用。这也是Heatmap.js 2.0.5针对2D Canvas优化后的实际表现,写上这句是想给大家吃个定心丸:只要逻辑写对,性能根本不是瓶颈。
6. 这套方案能走多远?精度边界与延伸方向
6.1 精度边界与适用场景
Heatmap.js本质上只是在做可视化插值,不是水力模型计算。它的颜色分布只体现了点的权重高低,不代表真实水深。如果你要拿它来做防洪预警的决策依据,肯定不行。真正的洪水淹没范围要结合DEM高程、降雨量、径流系数、河流断面数据去算,这部分可以交给水文模型生成结果,然后把结果离散成监测点,再丢给Heatmap.js展示。
所以这套方案的正确定位是:在水文计算完成之后,做快速、低成本的时空过程可视化。适合的场景包括防汛预案推演、应急演练展示、科普教育系统、水利工程汇报演示。在这些场景里,用户看的是趋势和形态,不要求厘米级精度。
6.2 延伸方向一:与地形高程结合
如果你不满足于平面热力图,可以给Cesium加载一个地形服务,然后让淹没图层的渲染范围完全贴合地表起伏。具体做法是把热力图渲染的canvas作为透明贴图,叠到一个由高程数据生成的corridor或polygon实体上。结合Cesium的地形clipping,就能实现"水顺着地形往下流"的效果。但实测下来单靠Heatmap.js的纹理很难做出自然的水位边际线,建议此时改用多边形多边形压地的方法,热度图只做辅助配色参考。
6.3 延伸方向二:叠加粒子系统增强洪水泥沙表现
有一点是Heatmap.js做不到的:水往低处流的流动感。如果想让效果更生动,可以在热力图上叠加一套Cesium的粒子系统,模拟水流的位移方向。这不是必需的,但对汇报演示很加分。粒子系统的数据可以复用Heatmap.js点位生成的矢量字段,比如水位差决定的流向向量。这部分逻辑独立,不影响已有热力图渲染。
6.4 延伸方向三:图例与业务联动
业务部门看洪水图,不只要看颜色分布,还要能读出水位值。我后来给项目加了一个自定义图例面板,左边的渐变条跟heatmap的渐变同步,右侧滑动实时显示当前鼠标位置的插值水位。实现原理是把所有监测点的value和经纬度保存下来,鼠标移动时通过逆映射找到对应heatmap像素位置处的热力值。因为是canvas像素读取,所以效率和精度都让人满意。
6.5 从Freelancer项目到平台化架构的思考
通过这次项目,我的切身体会是:工具选型里,性能当然重要,但团队的实际维护能力和业务验证速度往往更应该排在前面。GLSL固然强大,但你要为一个不会写着色器的团队维护一套Shader代码,后期的成本会让人崩溃。Heatmap.js这种"偏老"但成熟方案,反而能在业务迭代为主的项目里发挥极大价值。如果你也面临类似需求,优先确认三点:数据是否以离散点为主、目标平台是否是浏览器、是否需要频繁变更视觉效果。三个条件都满足的情况下,Heatmap.js绝对是不输GLSL的答案。