简介:本资源是一套基于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提取平滑边界,再用水深场做实例化热力网格。边界线负责表达“范围”,热力图负责表达“风险”,两者结合起来,既专业又直观。做这类功能时别太贪心追求极致精度,把交互响应速度放在第一位,用户满意度反而更高。
本文还有配套的精品资源,点击获取