ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Cesium结合Heatmap.js实现动态洪水模拟可视化

Cesium结合Heatmap.js实现动态洪水模拟可视化 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所以每个瓦片请求到的都是同一张canvasCesium内部会自动做纹理拉伸和采样。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的答案。
返回列表