ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WebGIS动态洪水模拟:Cesium与Heatmap.js协同实现,不写GLSL

WebGIS动态洪水模拟:Cesium与Heatmap.js协同实现,不写GLSL 做WebGIS这么多年我最深的体会是很多看起来必须写GLSL才能搞定的可视化需求其实换条技术路线也能做得又快又好看。就拿动态洪水模拟来说如果你不想一头扎进着色器的深水区完全可以用Cesium 1.120配合Heatmap.js 2.0.5做一套够用的热力洪水动态方案而且效果好、代码量小、维护成本低。这篇文章我会把这套方案的完整思路、核心源码、坐标映射细节、动态更新机制以及我在实际项目里踩过的坑全部给你捋一遍。适合那些需要在Web端快速实现洪水风险可视化、又不想死磕Shader的同学参考。1. 为什么动态洪水模拟不一定非要写GLSL1.1 GLSL方案的“门槛”到底在哪很多同学接到“动态洪水模拟”需求第一反应就是去查Cesium如何自定义Shader。这很正常网上大量教程都在讲怎么用Fabric、怎么定义Material、怎么在片元着色器里搞噪声和顶点扰动。但说句实在话GLSL这条路的学习曲线非常陡。用Cesium做水效果常规套路有两种一种是自定义Primitive使用AppearanceShaderMaterial然后自己管理顶点缓冲区和索引缓冲区另一种是使用PostProcessStage做后期处理通过输入的颜色纹理和深度纹理来合成水面效果。无论哪种你都要同时处理好几件事顶点着色器里的坐标变换、片元着色器里的颜色与透明度计算、uniform变量在每一帧怎么更新、渲染状态里的深度测试和混合模式还有Cesium的primitive更新机制。对不熟悉WebGL底层的人来说光是让一个三角形带纹理显示正确就能折腾大半天。我自己也试过写带波纹的水面Shader最后代码看起来是挺唬人的但一旦遇到“水位随时间变化”的需求就意味着每次水位变化都要重新编译、更新uniform调试起来极其痛苦。而且Cesium的Shader功能虽然强大但API相对底层网上能直接抄的完整示例并不多。当然不是说GLSL不好而是对大多数做业务系统的团队来说用高门槛技术换来的效果提升不一定值得。如果只是做保护区撤离路线展示、淹没范围趋势预览、洪水风险热区分析那用热力图完全够用了。1.2 Heatmap.js方案的价值与适用边界Heatmap.js 2.0.5是一个很老牌、很稳定的热力图库核心思路就是把每个数据点变成一个带径向渐变的半透明圆多个点叠加之后密度越高的地方颜色越深。它本身不依赖任何框架直接操作canvas用起来非常简单。把这套逻辑放到Cesium里思路就变成不在地球表面画一堆密密麻麻的Shaded圆而是先在一个隐藏的canvas上用Heatmap.js把洪水数据渲染成一张热力图图片再把这张canvas作为材质贴到一个Cesium的Rectangle上。数据在变canvas就重新绘制Rectangle上的贴图也跟着更新动态洪水效果就出来了。用这个方案你可以完全绕开顶点着色器和片元着色器绕开纹理坐标计算绕开Cesium的Primitive自定义。整套代码只需要理解三个核心点Heatmap.js怎么用、经纬度怎么映射成canvas像素、canvas怎么作为材质贴到Cesium实体上。从这个角度说它几乎是“不会写GLSL人群”的救星。但也要清醒认识到它的边界。Heatmap.js做的是二维热力密度表达不是真正的水体物理模拟所以没有真实的波浪、水流方向、水体交互这些东西。它适合做宏观层面的淹没范围、风险等级和时间演变展示。如果甲方非要看到阳光下的波光粼粼、水面倒影那该学的GLSL还是得学。可如果只是业务预警大屏和WebGIS辅助决策这套方案完全能打。2. 动态洪水热力模拟的核心设计思路2.1 用Heatmap.js避开的“着色器深水区”Heatmap.js 2.0.5提供了一组很简洁的API创建实例、灌数据、刷新配置基本上三行代码就能出图。const heatmap h337.create({ container: document.getElementById(heatmap-container), radius: 20, maxOpacity: 0.8, minOpacity: 0.1, blur: 0.75, gradient: { 0.0: rgba(0, 180, 255, 0.2), 0.5: rgba(0, 120, 255, 0.6), 1.0: rgba(255, 0, 0, 1) } });创建完之后调用setData就能往里面灌数据点。每个点包含坐标和权重值。heatmap.setData({ max: 100, data: [ { x: 100, y: 200, value: 80 }, { x: 150, y: 180, value: 50 } ] });它的内部实现其实不复杂每个点都会被渲染成一个径向渐变的圆所有圆的alpha值叠加在一起再用预置的gradient把叠加后的灰度映射成颜色。这也是为什么热力图天然适合表达“哪里风险高、哪里风险低”。在Cesium里我们并不直接把这个canvas挂到页面上而是把它当做一个纹理源。这里有个非常重要的点canvas不用显示在普通网页布局中display: none即可因为我们是把它作为材质用不需要用户直接看到这张原始图。当然如果你调试时想确认热力图长什么样临时在页面上放一个可视化的canvas副本也行。2.2 经纬度坐标如何映射到画布像素Heatmap.js的坐标系默认就是画布像素坐标所以我们得把经纬度换算成canvas上的x、y。最常见的做法就是等比例线性映射。假设我们关心的洪水模拟范围是一个矩形用四个角定义西经west、南纬south、东经east、北纬north。画布宽度是width像素高度是height像素那么任意点(lng, lat)的像素坐标就是x (lng - west) / (east - west) * width y (north - lat) / (north - south) * height注意y方向因为canvas的y轴向下而纬度向北递增所以要用north - lat把纬度倒过来。这个公式看起来很简单但有几个坑。第一个坑是宽高比如果画布的宽高比和经纬度矩形的宽高比不一致热力图贴到Cesium的Rectangle上之后就会发生拉伸变形。所以创建canvas容器时最好先算一下经纬度矩形在目标纬度下的“实际宽高比”再决定画布尺寸。第二个坑是高纬度拉伸。在地球球面上经度1度代表的实际距离随纬度变化纬度越高距离越短。如果你直接把经纬度当平面坐标线性映射在低纬度可能看不出来在高纬度就会明显变形。这就是很多人遇到“Cesium里贴图位置飘了”的常见原因之一。要修正的话可以在映射x时乘一个余弦修正系数即按目标中心纬度midLat进行修正x (lng - west) / (east - west) * width * Math.cos(midLat * Math.PI / 180)这种做法本质上是把经纬度近似成等距圆柱投影来处理。对于小范围的洪水模拟场景这个修正已经足够用了。如果模拟范围跨好几百公里或者靠近极地那就建议直接用Web墨卡托投影坐标做映射而不是裸的经纬度线性映射。这也是我在处理3857坐标数据时养成的习惯坐标系的问题前置算清楚能省一堆后面Debug的功夫。实际项目中我会写一个专门的映射函数把坐标转换逻辑集中管理后面数据源无论是WGS84还是高德/百度坐标都先转成统一经纬度再进这个函数。function latLngToPixel(lat, lng, bounds, canvasWidth, canvasHeight) { const west bounds.west; const east bounds.east; const north bounds.north; const south bounds.south; const midLat (north south) / 2; const x ((lng - west) / (east - west)) * canvasWidth * Math.cos(midLat * Math.PI / 180); const y ((north - lat) / (north - south)) * canvasHeight; return { x, y }; }2.3 时间序列数据怎么组织成“动态”动态模拟的“动态”二字本质就是多帧数据按时间播放。我一般把数据组织成一个数组数组里每个元素就是某一时刻的完整热力帧const frames [ { time: 0, points: [ { lat: 30.2, lng: 120.1, value: 20 }, { lat: 30.3, lng: 120.2, value: 45 } ] }, { time: 1, points: [ { lat: 30.2, lng: 120.1, value: 40 }, { lat: 30.3, lng: 120.2, value: 80 } ] } ];播放驱动方式有两种。一种是直接用setInterval或者requestAnimationFrame定时切换帧比较直白。另一种是接入Cesium的Clock用场景里的时钟驱动这样能和Cesium的时间轴控件联动播放、暂停、倍速都能统一处理。我倾向于使用Cesium的Clock因为做洪水预警这类业务时甲方的需求往往不只是“看个动画”还要能“拖时间轴看某个时刻的水位”用Cesium原生的时钟体系更顺手。viewer.clock.onTick.addEventListener(function(clock) { const currentTime clock.currentTime; const elapsed Cesium.JulianDate.secondsDifference(currentTime, startTime); const frameIndex Math.floor(elapsed / frameDuration) % frames.length; updateHeatmapFrame(frameIndex); });这样整套动态逻辑就清晰了数据是时间序列帧时钟是推进器每到一个时间点就取对应帧的数据重绘heatmap再更新Cesium材质。3. Cesium 1.120集成实操与源码解析3.1 环境准备与基础场景搭建先确认版本我用的是Cesium 1.120Heatmap.js 2.0.5。Cesium 1.120这个版本API已经比较稳定Rectangle、ImageMaterialProperty这些基础模块用起来很顺手。页面里引入两个库就可以开始。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title动态洪水热力模拟/title style html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } #heatmapContainer { display: none; } /style /head body div idcesiumContainer/div div idheatmapContainer/div script srchttps://cesium.com/downloads/cesiumjs/releases/1.120/Build/Cesium/Cesium.js/script link hrefhttps://cesium.com/downloads/cesiumjs/releases/1.120/Build/Cesium/Widgets/widgets.css relstylesheet script srchttps://unpkg.com/heatmap.js2.0.5/build/heatmap.min.js/script /body /html创建Viewer的时候我会设置requestRenderMode: true这样Cesium不会每帧都去渲染而是等场景有变化才重新绘制能省不少CPU。1.120版本支持这个参数动态更新洪水图时我们手动触发重新渲染就行。const viewer new Cesium.Viewer(cesiumContainer, { animation: true, timeline: true, baseLayerPicker: false, requestRenderMode: true, terrainProvider: new Cesium.EllipsoidTerrainProvider() });如果手头有高精度地形数据也可以换成在线地形服务这样洪水淹没图能贴合地形起伏效果更真实。3.2 核心源码Heatmap与Cesium对接我直接给一份可运行的完整核心源码模拟了100个随机点的洪水热力数据在时间轴上的变化。先定义模拟范围。这里我选了一个中等大小的矩形范围涵盖大约几十公里范围适合热力图展示。const bounds { west: 120.0, east: 120.5, south: 30.0, north: 30.5 }; const canvasWidth 512; const canvasHeight 512;然后创建Heatmap实例。容器是隐藏的但这不影响Heatmap正常绘制。const heatmapContainer document.getElementById(heatmapContainer); heatmapContainer.style.width canvasWidth px; heatmapContainer.style.height canvasHeight px; const heatmap h337.create({ container: heatmapContainer, radius: 20, maxOpacity: 0.8, minOpacity: 0.1, blur: 0.8, gradient: { 0.0: rgba(0, 180, 255, 0.1), 0.4: rgba(0, 120, 255, 0.5), 0.7: rgba(255, 165, 0, 0.8), 1.0: rgba(220, 20, 60, 1) } });生成模拟数据。这里的点是随机分布的实际项目中会换成水动力模型输出或者水文站点插值结果。function generateFrame(timeStep) { const points []; for (let i 0; i 100; i) { const lat bounds.south Math.random() * (bounds.north - bounds.south); const lng bounds.west Math.random() * (bounds.east - bounds.west); const value 20 60 * Math.abs(Math.sin(timeStep i * 0.3)); points.push({ lat: lat, lng: lng, value: value }); } return points; }这里把heatmap数据点从经纬度换算成像素坐标再调用setData。function buildHeatmapData(points) { const data points.map(pt { const pixel latLngToPixel(pt.lat, pt.lng, bounds, canvasWidth, canvasHeight); return { x: Math.round(pixel.x), y: Math.round(pixel.y), value: pt.value }; }); const values data.map(d d.value); return { max: Math.max(...values), data: data }; } function updateHeatmap(points) { const heatmapData buildHeatmapData(points); heatmap.setData(heatmapData); }创建Cesium的Rectangle实体把heatmap的canvas作为材质贴上去。关键点是transparent: true否则canvas透明区域会被当成黑色。const rectangleEntity viewer.entities.add({ rectangle: { coordinates: Cesium.Rectangle.fromDegrees( bounds.west, bounds.south, bounds.east, bounds.north ), material: new Cesium.ImageMaterialProperty({ image: heatmapContainer.querySelector(canvas), transparent: true }), height: 2 } }); viewer.zoomTo(rectangleEntity);这里的height: 2是我故意加的一个很小的高程目的就是让热力贴图和地形稍微拉开一点距离避免因为深度冲突导致贴图闪烁。实际使用时可以根据地形起伏调整。3.3 动态更新的关键坑位纹理刷新如果你直接把上面的代码跑起来然后调用updateHeatmap改数据很可能会发现canvas里的热力图明明变了Cesium场景里却还是老画面。这个坑我当年踩了好几个小时。原因是Cesium的ImageMaterialProperty不会自动感知canvas内容的更新。你传给它一个canvas对象它第一次会把这个canvas的内容上传成GPU纹理之后如果不整一个“材质变脏”的信号它在每一帧渲染时就直接用旧的纹理不会重新读取canvas的最新内容。解决办法有好几种。最省事的是每次更新数据后重新创建一个ImageMaterialProperty并重新赋给实体function refreshMaterial() { const canvas heatmapContainer.querySelector(canvas); rectangleEntity.rectangle.material new Cesium.ImageMaterialProperty({ image: canvas, transparent: true }); }因为每次赋新材质对象Cesium会认为这是一个全新的材质从而重新上传纹理。这个操作简单粗暴实测在低帧率动态播放场景下完全够用。另一个办法是每次把canvas转成dataURL再把URL赋给材质。这样Cesium会按新图片URL去加载纹理但canvas.toDataURL()有CPU开销而且如果数据更新频率高会频繁触发图片解码拖慢性能不建议在动态模拟场景里用。还有一些同学会尝试直接修改rectangleEntity.rectangle.material.image但这个改动在很多Cesium版本里并不会触发纹理更新容易给人“明明改了啊怎么没反应”的错觉。所以我最终的线上代码就用了重建材质对象的方案简单可靠逻辑清晰。3.4 参数调优半径、透明度、渐变与画布尺寸Heatmap.js的参数直接决定了画面效果我在不同项目里调过很多次总结了一套比较实用的参考值。radius是最直观的参数它决定热力点的扩散半径单位是像素。画布512像素宽时半径取15到25比较合适。半径太小点与点之间没有融合看起来像马赛克半径太大整个区域糊成一片看不清风险中心。blur控制边缘的柔和程度0到1取值越大扩散越模糊。做洪水这种面状风险表达时我习惯取0.75到0.85让热力边缘有自然的过渡感而不是生硬的一团。maxOpacity和minOpacity控制叠加后的透明度范围。maxOpacity太高容易把高风险区域的细节盖掉我一般控制在0.7到0.9minOpacity不能为0否则边缘区域完全看不见一般取0.05到0.15。gradient是我们表达水位语义的关键。做洪水模拟我建议不要用默认的蓝绿渐变而是映射成“浅蓝→深蓝→橙→红”这种能体现从低风险到高风险的颜色梯度视觉冲击力更强。画布尺寸也是权衡点。512x512是性价比最高的选择热力细节和渲染性能平衡。如果区域范围很大想看清更多细节可以开到1024但点数量和canvas尺寸上来后重绘和纹理上传的开销也会增加需要结合第二节的更新方式一起考虑。4. 从“热力图”到“洪水淹没图”的进阶处理4.1 让热力值具备水位语义做可视化最忌讳的就是颜色和业务含义脱节。如果直接把热力值当成洪水风险甲方看了会问“这个颜色到底代表多深的水”。所以我们要给热力值赋予明确的语义。我的做法是把value定义成淹没深度单位米然后根据防洪预警等级分段映射到颜色和透明度。const floodLevels [ { max: 0.5, color: rgba(0, 180, 255, 0.2) }, { max: 1.0, color: rgba(0, 120, 255, 0.5) }, { max: 2.0, color: rgba(255, 165, 0, 0.8) }, { max: 3.0, color: rgba(220, 20, 60, 0.9) } ];然后在生成Heatmap数据时把真实的淹没深度映射到0到100的权重值。比如3米水深对应权重1000.5米水深对应权重20。这样heatmap图上颜色的深浅就和水深直接挂钩了看图的业务人员能立刻读懂。最好再在页面上加一个动态图例把渐变条和对应的水深范围标出来。图例可以用一个简单的canvas绘制也可以直接叠一个CSS渐变条实现成本很低但专业感提升明显。4.2 结合地形高程与局部网格插值纯热力图有个先天问题它只表达平面分布不知道地形高低。山区里同一个热力值可能在低洼地和山脊上完全不同的物理意义。要让洪水模拟更接近真实应该让高海拔区域“自动”不淹没。实现思路不复杂对一个区域内均匀取一组采样点每个点既有地形高程也有设计水位。用“水位高程”减去“地形高程”如果差值大于0说明这个点会被淹差值就是淹没深度如果差值小于等于0则不会淹。这里需要插值算法因为采样点是离散的而我们想让热力图覆盖整个矩形区域。工程上常用反距离加权插值思想很简单未知点的值由周围已知点按距离加权平均得到距离越近权重越大。function idwInterpolate(lat, lng, knownPoints, power 2) { let numerator 0; let denominator 0; for (const pt of knownPoints) { const dist Math.sqrt((pt.lat - lat) ** 2 (pt.lng - lng) ** 2); if (dist 0) return pt.value; const weight 1 / Math.pow(dist, power); numerator weight * pt.value; denominator weight; } return numerator / denominator; }然后用这个函数对每个热力图采样点进行赋值。预设网格越密插值结果越平滑。一般按200米间隔取一个网格点100个点的范围用IDW插值计算量很小前端完全扛得住。4.3 边界判定与淹没范围提取有了水深值的栅格场之后我们可以顺手做两个很有用的延展功能。一是淹没范围边界提取。把水深大于阈值的点看成“淹没区”通过简单算法提取边界多边形这个多边形可以叠加到Cesium场景里形成闭合的淹没范围线。比较经典的算法是Marching Squares跑一遍能得到等值线代码量不大。二是和建筑物单体化数据相结合。如果你加载了3D Tiles的楼层建筑数据就可以根据每个建筑物的底面位置去查它所在点的淹没深度判断这栋楼是否受淹、受淹水深多少。这个功能在应急指挥系统里非常受用相当于从宏观的热力展示落到微观的资产风险评估。门槛是这些数据通常需要业务系统提供或者通过后端服务计算好前端只负责查询展示。但只要热力图的数据组织规范接起来并不难。5. 常见问题与性能优化实录5.1 高频踩坑速查表我把实际项目中遇到的高频问题整理成了一张表方便你直接对照排查。现象原因解决办法场景里出现黑色方块ImageMaterialProperty没有开transparent在材质里加transparent: true热力图位置对不上经纬度映射公式写错或没有做余弦修正检查latLngToPixel函数热力图发生拉伸变形画布宽高比和地理范围宽高比不一致按经纬度跨度比例设置画布尺寸数据更新了但画面不变Cesium未感知canvas内容变化每次更新后重建ImageMaterialProperty高纬度区域错位明显直接用经纬度线性映射加入cos(midLat)修正或转墨卡托坐标贴图闪烁、边缘发虚贴图与地形深度冲突给Rectangle设置明显的高度比如2米页面卡顿严重canvas尺寸过大、点位过多、requestRenderMode未开启降到512画布抽稀点位开启按需渲染这张表里的每一条都是我真正遇到过的尤其是纹理刷新和黑色方块这两个问题初学者大概率会踩。5.2 性能优化从每秒1帧到每秒20帧如果你照着上面的代码一跑发现动态更新很卡不要急着骂Heatmap.js多半是参数和渲染模式没调好。第一打开Cesium的按需渲染模式。在创建Viewer时设置requestRenderMode: true配合scene.requestRender()手动触发渲染帧能避免Cesium每帧都做无用渲染。我们的洪水热力图更新频率本身不高没必要让场景以60fps空转。第二控制heatmap画布尺寸。512x512是一个黄金分辨率。画布越大Heatmap重绘时间越长Cesium纹理上传时间也越长。如果你对清晰度要求高可以把画布设为1024x1024但点位超过1000个之后重绘耗时就会明显上涨。第三控制点位数量。热力图并不是点越多越好看点位密集到一定程度后视觉差异很小但计算量线性增长。我一般把每帧的点位控制在200到500个然后用网格抽稀的方式把原始数据减少效果几乎没差别。第四避免用toDataURL做动态更新。有些文章推荐这种做法因为改image的URL一定会触发重新加载。但实际上toDataURL需要同步遍历canvas每个像素生成PNG再进行Base64编码CPU消耗非常大。我实测512画布下一次toDataURL大约增加几十毫秒开销如果把更新频率提到10fps以上会占用大量CPU时间。重建材质对象的方案开销远小于toDataURL。参数调好后我本地的实测表现是512画布、300个数据点、每秒更新5帧整体CPU占用非常低Cesium场景还能自由旋转视角交互完全不受影响。5.3 数据量大时的分级与抽稀策略如果你的洪水数据不是前端模拟而是从后端下载了数千个网格点的完整数据那就要考虑分级和抽稀了。空间上可以先按网格把点聚合比如地图放大到市级尺度时只显示主要风险点位。地图缩小到省级尺度时甚至可以直接用后端预渲染的瓦片热力图。时间上模拟时长如果是24小时时间间隔5分钟就有288帧。如果每帧几千个点浏览器内存和计算压力会非常大。实际项目中我会按时间抽帧比如展示时每15分钟取一帧播放时再通过简单线性插值补充中间过渡帧这样数据量能砍掉三分之二视觉连续性也不会差太多。这一步如果还想做得更专业可以引入Web Worker在后台做数据插值和heatmap绘制主线程只负责拿到canvas后更新到Cesium图层渲染性能还能再上一个台阶。不过对于大部分中后台业务系统前端直接处理几百个点性能已经足够了不用一上来就上重型架构。最后再分享一个我在实操中的体会我最初做动态洪水模拟时也纠结过要不要硬啃GLSL总觉得不用Shader就不够“高级”。但等我把Heatmap.js这套方案真正上线后甲方反馈却出奇地好因为业务方关心的是“哪里淹了、淹多深、趋势怎么变”而不是“你这个水面波纹是不是用了正弦噪声”。技术永远要服务于业务表达能快速交付、稳定运行、易于维护的方案就是好方案。如果你后续真的需要水面的法线扰动、反射折射、粒子溅射等电影级效果那就再单独做Shader版甚至用Three.js做局部水体渲染再叠加到Cesium里也不迟。先把热力图方案跑通既稳住了项目节奏也给自己留出了学习和升级的时间这个路子我认为很值得一试。
返回列表