news 2026/9/3 8:38:51

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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要为它单独管理状态、更新渲染队列,几千个还能扛,上万就明显掉帧。

正确做法是使用Primitive+GeometryInstance的实例化渲染。把成千上万个结构相同的小矩形合并成一次绘制调用,每个实例只额外带一个颜色属性,渲染效率提升一个数量级。我实测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里,主线程只负责接收结果和更新渲染。

我的做法是给每个网格单元发一个自增ID,Worker计算完之后把颜色值和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提取平滑边界,再用水深场做实例化热力网格。边界线负责表达“范围”,热力图负责表达“风险”,两者结合起来,既专业又直观。做这类功能时别太贪心追求极致精度,把交互响应速度放在第一位,用户满意度反而更高。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/3 6:44:53

Python机械臂绘图系统:逆解算法与轨迹插补全解析

简介&#xff1a;这套基于Python实现的机械手臂绘图系统源码包&#xff0c;面向机器人爱好者、计算机视觉初学者和相关创意项目开发者。项目融合OpenCV图像处理、Kmeans聚类颜色识别、骨架化操作以及ultraArm P340机械手臂SDK控制&#xff0c;并通过Tkinter搭建图形界面&#x…

作者头像 李华
网站建设 2026/9/1 10:20:30

5款免费开源网络拓扑工具:从手画到自动更新拓扑

5款免费开源网络拓扑工具&#xff1a;从手画到自动更新拓扑 【免费下载链接】awesome-sysadmin A curated list of amazingly awesome open-source sysadmin resources. 项目地址: https://gitcode.com/GitHub_Trending/aw/awesome-sysadmin 网络变更前夜&#xff0c;你…

作者头像 李华
网站建设 2026/9/2 23:44:59

Android相机Overlay叠加层:基于CameraX的自定义View绘制网格与水印

Overlay 在相机应用里并不神秘&#xff0c;它就是这个时代的贴纸、水印、人脸框和增强现实效果的统称。常规做法是把相机预览区当成一个底层画布&#xff0c;在其上叠加另一层透明或半透明画面&#xff0c;形成“相机画面 实时绘制层”的组合视觉效果。很多入门开发者第一次接…

作者头像 李华
网站建设 2026/9/1 10:18:25

MKVToolNix v96.0.0 指南:无损合并与拆分视频的终极工具

在实际处理视频素材时&#xff0c;无论是从网上下载的教程、自己录制的游戏片段&#xff0c;还是需要合并的多个短视频&#xff0c;我们常常会遇到需要将多个视频文件无损合并成一个&#xff0c;或者从一个长视频中精确裁剪出所需片段的需求。对于追求画质无损、操作便捷且跨平…

作者头像 李华
网站建设 2026/9/1 10:18:07

jq 命令行 JSON 处理实战指南:从安装到搞定真实接口数据

jq 命令行 JSON 处理实战指南&#xff1a;从安装到搞定真实接口数据 【免费下载链接】jq Command-line JSON processor 项目地址: https://gitcode.com/GitHub_Trending/jq/jq 处理嵌套 JSON 最痛苦的&#xff0c;从来不是"读不懂"&#xff0c;而是"取不…

作者头像 李华