尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Cesium中实现淹没分析热力图:从地形采样到水深渲染

Cesium中实现淹没分析热力图:从地形采样到水深渲染 简介本资源是一套基于Cesium实现的三维地理空间淹没分析系统面向GIS开发工程师、Web三维可视化开发者及地理信息专业学习者解决城市内涝模拟、防灾预案推演与风险热力可视化等实际工程问题。压缩包共464个文件涵盖104个核心JavaScript逻辑文件含水位动态计算、地形高程修改、热力图叠加渲染等、93个source map调试支持文件、53个JPG/PNG遥感底图与效果截图、30个JSON配置与地理数据、28个CSS样式文件含CesiumWidget、Timeline、InfoBox等官方UI组件定制化样式整体体积9.35MB。已有2634人学习下载资源结构完整包含可直接运行的HTML入口、模块化JS功能分层如Animation、BaseLayerPicker、Cesium3DTilesInspector等、以及适配WebGL性能优化的WASM与纹理资源开箱即用便于二次开发与教学演示。 前阵子帮一家水利设计院做三维展示项目需求听起来很简单给定一个水库特征水位把周边被淹没的区域在Cesium场景里画出来。但我拿到细化需求之后发现对方要的不只是一个蓝色水面而是要把整个淹没区域按水深分层染色——坝前水位最深的地方颜色要重边缘刚被淹的河滩颜色要淡最好一眼就能看出风险等级从高到低的分布。说白了这就是“淹没分析”加“热力图”的组合。我把这套东西在Cesium里完整实现了一遍从地形高程采样、淹没边界提取到水深热力渲染、水位动态联动整个过程踩了不少坑也有不少可以直接复用的经验。这篇文章就把完整思路、关键代码和实际测试中的问题都写出来给正在做类似WebGIS功能的朋友一个参考。1. 项目背景淹没分析为什么不是画一个蓝面就完事1.1 这类需求都从哪来淹没分析在三维GIS里是个高频需求碰到的场景大致有三类水利工程影响评估水库蓄水到某个水位回水区会淹到哪里两岸村庄、道路是否受影响。这类项目最常见也是我这次接到的类型。城市内涝模拟结合暴雨强度、排水能力推算城区积水范围重点看危旧房片区、地下空间入口的淹没风险。防洪预案与蓄滞洪区启用给定河道超标准洪水位判断蓄滞洪区内哪些乡镇需要转移。这些业务的共同点是领导要看的不是一张抽象的淹没范围线而是要在三维地形和影像底图上直观看到“水涨到哪里、淹多深”。Cesium天然适合这个场景——地形、倾斜摄影、影像、3D Tiles建筑模型和分析结果可以叠加在同一个地球场景里比传统二维GIS填色的表现力强太多。1.2 用户真正关心的是水深分布画一个蓝色多边形只能回答“淹不淹”回答不了“淹多深”。但业务决策恰恰依赖深度水深小于0.5米基本是河滩漫水影响较小可能只是临时禁止通行。水深0.5米到1米行人出行困难低洼院落开始进水。水深1米到3米一层房屋进水人车转移困难。水深大于3米甚至5米长时间淹没已经涉及搬迁和重大财产损失。所以淹没分析的热力图本质就是“水深场可视化”。把每个空间位置的水深计算出来再按风险等级映射成不同颜色叠到三维场景里业务人员才能拿着图去对标预案、划定转移范围。1.3 技术路线为什么选Cesium前端实时计算做淹没分析传统做法是在服务端用水文水动力模型算好结果发布成地图服务再加载。比如用HEC-RAS、MIKE等做水动力模拟输出淹没范围shp再切片发布。这个方案精度高但流程长、计算慢最要命的是交互差——用户想拖个水位滑块看不同工况下的淹没情况服务端根本跟不上。Cesium前端实时计算的思路则是利用地形服务提供的高程数据在浏览器端完成水位判断和边界生成。优点非常明显交互实时水位参数一改结果马上刷新适合方案比选。部署简单不用额外维护GIS分析服务。与三维场景天然融合分析结果直接叠加在地形上。代价是精度受地形数据分辨率限制但这个精度做宏观方案比选、应急研判完全够用。我的判断是如果你要的是“分秒级响应、多工况对比”前端方案是首选如果项目要求厘米级成果并出具正式报告那还是老老实实走专业水文模型前端只做展示。2. 核心计算流程从地形高程到淹没边界2.1 两种实现路线怎么选服务端GIS分析和Cesium前端计算不是二选一的对立关系我在实际项目里更多是结合着用。先看这张对比对比项服务端GIS分析Cesium前端实时计算数据精度取决于水文模型和DEM可做高精度取决于地形服务或本地DEM中精度计算速度分钟级到小时级秒级交互体验差动态调参困难好水位滑块实时联动部署成本需要GIS服务器、空间数据库纯前端依赖地形服务适用阶段正式成果报告方案比选、应急推演、汇报演示我在这个项目里采用的是前端为主先在地形服务上采样高程前端算淹没网格再用结果叠加影像做汇报。如果需要精确数据再单独用服务端模型出报告。2.2 区域网格化与高程获取淹没分析的第一步是拿到分析区域的地形高程场。Cesium里最直接的途径是Cesium.sampleTerrainMostDetailed它可以从地形服务批量采样指定经纬度坐标的高程值。我需要把分析区域变成一个规则网格。这里有个关键点经纬度步长和实际距离步长不是简单的等比例关系纬度越高经度方向上的实际距离越短。function generateGrid(west, south, east, north, stepMeters) { const positions []; const earthRadius 6378137; // 纬度方向的弧度步长对应的是固定距离经度方向要乘 cos(lat) 修正 const latStep stepMeters / earthRadius * 180 / Math.PI; for (let lat south; lat north; lat latStep) { const lonStep latStep / Math.max(Math.cos(lat * Math.PI / 180), 0.01); for (let lon west; lon east; lon lonStep) { positions.push(Cesium.Cartographic.fromDegrees(lon, lat)); } } return positions; }拿到网格点之后分批次采样高程。注意sampleTerrainMostDetailed一次性传几千个坐标没问题但一次传十几万个可能导致地形服务压力过大而且浏览器端处理数组也慢。我这里把点集切成了每批1万个。async function sampleElevations(terrainProvider, cartographics) { const results []; const batchSize 10000; for (let i 0; i cartographics.length; i batchSize) { const batch cartographics.slice(i, i batchSize); const updated await Cesium.sampleTerrainMostDetailed(terrainProvider, batch); results.push(...updated); } return results; }采样完成之后每个点就带上了实际地面高度。2.3 淹没判定与深度场计算有了每个网格点的地面高程水位判断就很简单了地面高程小于水位高程的点即为被淹点水深等于水位高程减去地面高程。function computeInundation(sampledPositions, waterLevel) { return sampledPositions.map(pos { const depth waterLevel - pos.height; return { longitude: pos.longitude, latitude: pos.latitude, height: pos.height, depth: depth, isInundated: depth 0 }; }); }运行完之后我得到一个“水深场”——每个采样点都带上了深度值后面画热力图就用这个深度值映射颜色。这一步计算量不大纯数组遍历几万个点毫秒级跑完。2.4 提取淹没边界——Marching Squares要在地图上画出“被淹没区域”的范围线我一开始走了弯路直接把被淹点坐标连起来。结果边界锯齿严重还需要做大量化简。后来改用经典的Marching Squares移动正方形算法直接从规则网格中提取等值线。原理不复杂把每个网格单元单独拿出来四个顶点分别判断是否被淹没这样每个顶点有“被淹/未被淹”两个状态四个顶点共16种组合。每种组合对应边界线穿过单元的方式。下图是算法最基础的几个模式4个顶点全淹边界不穿过这个单元。4个顶点全不淹边界不穿过。1个顶点淹、3个不淹边界在该顶点对角的两个边之间穿过。2个顶点淹、2个不淹边界有两种穿法具体方向要看淹的顶点是相邻还是对角。3个淹、1个不淹边界穿过未淹顶点对角的两条边。实现时核心是对每个网格单元判断状态码再用状态码查表得到边界线段的两个端点位置。所有单元遍历完之后再把离散线段拼接成闭合多边形。function marchingSquares(gridData, waterLevel) { // gridData 是二维数组栅格数据每个值表示该点高程 const contours []; const rows gridData.length; const cols gridData[0].length; for (let i 0; i rows - 1; i) { for (let j 0; j cols - 1; j) { const bl gridData[i][j] waterLevel ? 1 : 0; const br gridData[i][j 1] waterLevel ? 1 : 0; const tr gridData[i 1][j 1] waterLevel ? 1 : 0; const tl gridData[i 1][j] waterLevel ? 1 : 0; const index bl | (br 1) | (tr 2) | (tl 3); if (index 0 || index 15) continue; // 全淹或全不淹 // 根据 index 在预计算的边表中查找线段 const segment EDGE_TABLE[index]; // 线性插值计算线段端点 const p1 interpolateEdge(segment[0], j, i, gridData, waterLevel); const p2 interpolateEdge(segment[1], j, i, gridData, waterLevel); contours.push([p1, p2]); } } return contours; }这里我简化了代码实际项目里EDGE_TABLE是一张16行、每行两个边索引的表配合插值函数把等值点坐标算出来。如果你是第一次实现可以直接用现成的开源实现比如Mapbox的marchingsquares包或者自己把16种情况画个图对照着写。最后把这些边界线段的端点按拓扑关系连接就得到了完整的淹没范围多边形。3. 热力图实现水深场到颜色带的映射3.1 为什么用“离散单元”而不是逐个多边形算完水深场之后最直观的做法是给每个被淹网格点画一个小多边形每个多边形按水深填充颜色。但如果你天真地用Cesium的entity添加了上万个多边形结果就是页面卡到没法操作。原因很简单每个entity都是一个独立对象Cesium要为它单独管理状态、更新渲染队列几千个还能扛上万就明显掉帧。正确做法是使用PrimitiveGeometryInstance的实例化渲染。把成千上万个结构相同的小矩形合并成一次绘制调用每个实例只额外带一个颜色属性渲染效率提升一个数量级。我实测4万个单元用这种方式依然流畅。3.2 五级风险色带设计颜色映射是热力图的核心体验所在。色带设计不是随意的要符合业务直觉冷色代表风险低暖色代表风险高。我的做法是定义五级风险区间分段线性插值function depthToColor(depth, maxDepth) { // 定义五级阈值 const stops [0, 0.5, 1, 3, 5]; const colors [ Cesium.Color.fromCssColorString(#7EC8E3), // 0-0.5m 浅蓝 Cesium.Color.fromCssColorString(#00A0B0), // 0.5-1m 青 Cesium.Color.fromCssColorString(#FFD24D), // 1-3m 黄 Cesium.Color.fromCssColorString(#FF8C42), // 3-5m 橙 Cesium.Color.fromCssColorString(#E03636) // 5m 红 ]; if (depth stops[0]) return colors[0]; for (let i 1; i stops.length; i) { if (depth stops[i]) { const t (depth - stops[i - 1]) / (stops[i] - stops[i - 1]); return Cesium.Color.lerp(colors[i - 1], colors[i], t); } } return colors[colors.length - 1]; }这个色带在实际演示中反馈很好领导一眼就能看出溃坝口附近、河道深槽这些高风险区域。3.3 用Primitive GeometryInstance实现实例化渲染接下来是把每个网格单元转成实例化矩形的核心代码。注意这里用RectangleGeometry而不是PolygonGeometry因为矩形几何体不需要做多边形三角化性能更好也够用。function createHeatmapPrimitive(floodCells, gridStepMeters) { const instances []; for (const cell of floodCells) { // 计算单元格四个角的经纬度 const west cell.longitude - Cesium.Math.toRadians(gridStepMeters / 6378137 / Math.cos(cell.latitude) * 180 / Math.PI); const east cell.longitude Cesium.Math.toRadians(gridStepMeters / 6378137 / Math.cos(cell.latitude) * 180 / Math.PI); const south cell.latitude - Cesium.Math.toRadians(gridStepMeters / 6378137 * 180 / Math.PI); const north cell.latitude Cesium.Math.toRadians(gridStepMeters / 6378137 * 180 / Math.PI); const geometry new Cesium.RectangleGeometry({ rectangle: Cesium.Rectangle.fromDegrees( Cesium.Math.toDegrees(west), Cesium.Math.toDegrees(south), Cesium.Math.toDegrees(east), Cesium.Math.toDegrees(north) ) }); instances.push(new Cesium.GeometryInstance({ geometry: geometry, attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor( depthToColor(cell.depth, 5) ) } })); } return new Cesium.Primitive({ geometryInstances: instances, appearance: new Cesium.PerInstanceColorAppearance({ translucent: true, flat: true }), asynchronous: false }); }这里有个小坑RectangleGeometry创建的是贴合地表的平面直接渲染会和地形产生Z-fighting闪烁。我的处理是给height属性设一个很小的离地高度或者在RectangleGeometry中直接指定height: 0.5相当于整个热力面抬高0.5米。这样既不会闪烁也不影响视觉判断。asynchronous: false这个参数很有用它强制几何体同步创建这样函数返回后立即可用否则热力面会出现延迟加载的闪烁。3.4 动态水位与热力图联动的更新策略业务方必然会提一个要求加个滑块从低水位拖到高水位看淹没范围怎么变化。Cesium里做这个交互不难关键是更新策略不能太粗暴。我试过两种方案方案一每次水位变化都重新采样地形、重新生成Primitive。这个方案实机测试会卡因为地形采样有网络请求即使有缓存几万点的Marching Squares计算也需要时间。方案二预计算多个水位方案切换时直接渲染。也就是把165米、170米、175米、180米等若干个工况提前算好存成一组Primitive拖动滑块时切换显隐。这个方案交互最流畅适合固定方案比选。最后我用了折中方案低于当前水位一定范围内的数据实时算远处的用缓存结果。但如果是DEM数据放在本地、分析区域在几平方公里以内的项目你也可以直接实时重算因为省掉了网络请求时间纯CPU计算四万点也就一两百毫秒。4. 实测中踩过的坑精度、闪烁与性能4.1 采样分辨率太粗锯齿明显太细直接卡死我一开始图省事分析整个流域30公里范围都用了5米步长。结果网格点上百万地形采样请求发了上百批浏览器直接崩溃。后来老老实实按需求缩放方案比选用30米到50米步长重点村庄用10米步长。实测下来30米步长在宏观尺度上边界已经比较平滑10米步长能明显看到地形细节但计算量大了近10倍。建议优先做“人机交互响应快”的默认档位再给一个“高精度计算”按钮让用户按需选择。4.2 地形瓦片LOD精度导致的采样误差这算是我踩过最隐蔽的坑。Cesium.sampleTerrainMostDetailed虽然名义上是“最精细采样”但实际返回结果受地形瓦片加载进度影响。首次采样时如果某些瓦片还没加载到最高LOD采到的就是粗分辨率的高程看起来就是一块块拼接的假地形。我的解决办法分两步提前预热地形进入分析页面时先把分析区域的地形瓦片通过Cesium.createWorldTerrain加载一遍确保最高LOD瓦片进入缓存。采样后校验对几个已知高程的控制点做对比比如水库坝顶高程、河道最低点高程。如果偏差超过5米就要提示用户重新分析。如果项目本身用的是本地DEM数据GeoTIFF等建议直接解析DEM的高程矩阵做采样绕开地形服务的LOD问题精度反而更可控。4.3 网格缝隙与Z-fighting实例化的矩形单元如果高度和地形完全贴合相机视角一拉低能看到地表纹理从缝隙里透出来严重的时候整个热力面都在闪。原因是地形网格和热力网格的三角形不完全重合深度缓冲区精度不够。解决办法是给热力单元一个微小的高程偏移。我在每个RectangleGeometry里加了height: 0.8整层抬高0.8米。对于淹没分析这种宏观场景这点偏移肉眼根本看不出来但闪烁问题就彻底消失了。4.4 大区域计算Web Worker与任务分割如果分析区域确实很大网格点达到几十万主线程计算会让页面出现明显的卡顿用户拖拽地图都不跟手。优化思路是把计算放到Web Worker里主线程只负责接收结果和更新渲染。我的做法是给每个网格单元发一个自增IDWorker计算完之后把颜色值和ID传回主线程主线程批量更新已有Primitive的实例属性。这样即使计算耗时1秒页面也依然流畅只是分析结果稍微晚一点出来。如果你不想上Worker也至少要做到分帧计算——每帧只算一部分网格避免长时间阻塞渲染。4.5 相机大范围俯视时的视觉问题热力图做得越精细单元就越多相机拉远之后整个画面会变成密密麻麻的噪点颜色信息完全丢失。这里我用了距离控制const primitive createHeatmapPrimitive(cells, stepMeters); primitive.distanceDisplayCondition new Cesium.DistanceDisplayCondition( 0, 20000 // 20公里内显示 );拉远时自动隐藏热力图只保留淹没范围边界线。近距离再显示热力图。这样既能看清整体范围又能放大查看水深细节。5. 工程化落地从分析Demo到业务系统5.1 从“固定水位”到“库容-水位曲线驱动”纯分析Demo做到水位滑块联动就已经很唬人了但接到真实业务系统里还有一个关键对接用户输入往往不是水位而是“来水量”或“库容”。比如调度人员说“入库流量5000立方米每秒持续24小时”系统要先通过水库的水位-库容曲线换算成水位再驱动淹没分析。这个对接不复杂但一定要把分析组件做成通用接口class InundationAnalyzer { constructor(options) { this.terrainProvider options.terrainProvider; this.viewer options.viewer; } // 核心接口只需要传入水位和分析范围 async analyze(waterLevel, boundary) { const grid generateGridFromBoundary(boundary, this.gridStep); const sampled await this.sampleElevations(grid); const cells computeInundation(sampled, waterLevel); const contour marchingSquares(sampled, waterLevel); return { cells, contour }; } }业务系统只需要调用analyze(waterLevel, boundary)完全不关心内部是怎么算的。水位换算、降雨产流模型这些逻辑交给业务层。5.2 分析结果导出与图层叠加演示汇报之外业务方要求把分析结果导出成GIS数据归档。我的做法是淹没边界导出GeoJSON边界坐标点是LonLat格式方便在ArcGIS、QGIS里打开。热力网格导出成带depth属性的GeoJSON点集或者直接输出成PNG热力切片。这里提醒一点导出时要注意坐标系。Cesium里默认是WGS84经纬度但国内很多业务系统用的是CGCS2000或地方坐标系导出前要做转换否则数据落到对方的GIS里会偏移几百米。5.3 后续还能怎么扩展做完基本功能之后我发现这个架构扩展性不错后面可以考虑几个方向接入实时监测数据把实时雨量、上游来水接入水位每秒刷新系统自动触发预警和淹没范围推演。用Cesium CustomShader做GPU实时渲染当前是CPU计算网格再渲染如果数据量特别大可以在片元着色器里直接比较地形高度和水位每个像素自算水深和颜色省掉网格化步骤。这个对Cesium版本有要求但对大范围场景非常有效。叠加社会经济数据把淹没网格和房屋建筑、人口数据做叠加分析统计“影响多少户、多少人”这个对防汛决策的价值远大于一个单纯的漂亮热力图。我实际测试下来这套方案最稳定的组合是先用地形服务做快速采样用Marching Squares提取平滑边界再用水深场做实例化热力网格。边界线负责表达“范围”热力图负责表达“风险”两者结合起来既专业又直观。做这类功能时别太贪心追求极致精度把交互响应速度放在第一位用户满意度反而更高。本文还有配套的精品资源点击获取
返回列表