Cesium洪水淹没分析:从网格采样到动态水面渲染 简介面向河流仿真与三维GIS开发者的Cesium淹没分析资源包围绕动态纹理水体生成与地形淹没效果展开。通过着色器融合海洋参数、噪声参数及扩散反射计算水纹并结合地形高度信息控制水体透明度实现水位升降过程同时利用法线贴图、屏幕空间反射增强水面波动与倒影的真实感。资源共390个文件约13.91MB主体为112个js脚本和Cesium相关wasm、json、css等涵盖核心模块与运行依赖另有149个png、55个jpg等多类纹理与界面素材以及html入口示例便于直接运行与二次开发。对掌握WebGL着色器编程、自定义水材质以及Cesium与地形交互的开发者可参考完整的水纹理生成、淹没逻辑与动态效果集成为一体的HTML示例缩短从零实现该类功能的调试成本。目前已有1442人学习下载。 做Cesium相关项目这几年我接到的可视化需求里出现频率特别高的一个就是淹没分析。尤其是防汛预警、水库调度、洪水推演这类三维GIS场景用户往往不满足于“给一个标着数字的水位表”而是要看到水位上涨之后哪些区域被淹、淹到什么程度、水是怎么漫过去的。这篇文章就围绕Cesium淹没分析里的一个具体玩法——动态纹理水体把方案选型、材质细节、实操流程和踩坑记录都梳理一遍。如果你正在做WebGIS三维仿真或者刚接手一个需要模拟水位变化的项目这篇可以直接当参考文档用。这类需求本质上是个双重问题一是空间判断怎么准确算出水位上涨后的淹没范围二是视觉表达怎么让水面看起来像真的水而不是一块半透明的蓝色布片。两个问题都解决才算一个能交付的淹没分析功能。1. 淹没分析怎么做方案选型与整体设计先明确淹没分析的底层逻辑。在数字高程模型DEM里每个位置都有一个地表高程值。给定一个水位高度 H凡是地表高程低于 H 的格点理论上都属于被淹没区域。把这些格点连成一片再叠加水面渲染就构成了淹没分析的基础效果。听起来不复杂但真正在Cesium里落地方案之间差别很大。我这里把常见的三种做法拉出来对比一下也说说我为什么最终选了“网格采样 动态纹理水体”这条路线。1.1 三种主流实现方案对比从“贴地皮”到“算等值线”第一个方案最简单直接在场景里放一个半透明平面然后动态抬高它的高度。这种做法的实现成本极低几行代码就能跑起来但问题也很致命平面是平的山体会直接从水面里“穿”出来淹没边界跟地形完全对不上。它只能算“示意效果”做不了真正的淹没范围分析。第二个方案是事先计算好淹没区域的多边形边界然后用PolygonHierarchy配合拉伸几何体ExtrudedGeometry生成一个带高度的水体块。这个方案的好处是视觉上比较立体侧面能看到水深的切面。但坏处也很明显一旦水位变化整个边界都要重新计算几何体也要重建在连续动画过程中很容易出现卡顿。第三个方案就是我这篇要讲的重点——对地形做规则网格采样用等值线算法提取当前水位对应的淹没边界然后在边界内铺一个带有动态纹理的材质水面。水位变化时只需要重新计算等值线水面本身用Material驱动纹理流动边界精确且视觉自然。方案实现成本边界精度动态水位更新视觉真实感适用场景半透明平面抬升极低差不贴合地形容易直接该高度差快速演示、概念验证拉伸几何体水体中等取决于边界算法困难需重建几何中等中小范围、单次水位展示网格采样 等值线 动态纹理较高高贴合地形容易只更新边界好防汛推演、水库调度、大范围洪水分析1.2 为什么选择“网格采样 动态纹理水体”路线我选择第三种方案核心原因是它把“计算”和“渲染”解耦了。计算端只关心一个数据流当前水位 → 网格高程判断 → 淹没边界多边形。渲染端只关心一件事我拿到一个多边形怎么在里面画出真实的水。两者互不干扰水位怎么变、水面怎么动都可以独立调优。另外Cesium 的Material机制非常成熟我们不需要去写自定义的Primitive只要在 fabric 里定义一个材质把它挂到 polygon 上就能生效。水面纹理可以用 Canvas 程序化生成不依赖美术出图对于纯前端项目来说省了很多资源协调成本。这个方案在视觉和性能之间找到了一个很好的平衡点也是我最终推荐它的原因。提示如果你只是做一个“示意性”的淹没效果不需要边界精度那直接用半透明平面抬升就够了。但凡是涉及到“哪些房子被淹、哪些道路中断”这类空间判断建议一步到位走网格采样方案不然后期返工成本更高。2. 动态纹理水体的核心细节从生成贴图到调材质方案定了以后最花时间的其实是水面效果。很多新手第一次做动态水面直接找一张水波图片当地图贴上去结果出来的效果要么像是一块蠕动的大果冻要么纹理拉伸得完全看不出是水。这里我把水纹理生成、材质 shader、边缘融合三个环节拆开讲。2.1 用 Canvas 程序化生成水面纹理水纹理不一定要用真实拍摄的图片用 Canvas 程序化生成反而更方便因为可以精确控制尺寸、频率和颜色通道。我的做法是生成一张 256x256 或 512x512 的灰度噪波图叠加几层不同频率的正弦波制造出类似水面波纹的明暗变化。function createWaterTexture() { const canvas document.createElement(canvas); canvas.width canvas.height 256; const ctx canvas.getContext(2d); // 绘制基础暗色背景 ctx.fillStyle #1a3a4a; ctx.fillRect(0, 0, canvas.width, canvas.height); // 叠加多层波纹不同频率和透明度 for (let i 0; i 60; i) { const x Math.random() * canvas.width; const y Math.random() * canvas.height; const radius 20 Math.random() * 80; const gradient ctx.createRadialGradient(x, y, 0, x, y, radius); const alpha 0.05 Math.random() * 0.15; gradient.addColorStop(0, rgba(255, 255, 255, ${alpha})); gradient.addColorStop(1, rgba(0, 0, 0, 0)); ctx.fillStyle gradient; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); } return canvas; }这段代码生成的纹理在 shader 里做 UV 偏移后会产生类似水波流动的效果。多层径向渐变的好处是波纹方向不单一看起来更自然。如果用真实水纹照片反而容易出现明显的平铺重复感。2.2 Fabric 材质与 Shader 动态效果纹理只是基础真正的动态效果要靠材质 shader 来实现。Cesium 的Material类型本质上就是一组 shader 代码加 uniforms我们可以在 GLSL 里对之前生成的纹理做 UV 偏移、叠加甚至采样两张纹理做交叉混合。const waterMaterial new Cesium.Material({ fabric: { type: WaterMaterial, uniforms: { waterImage: waterCanvas, // 上面生成的 Canvas time: 0, repeat: 8 }, source: czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); vec2 st materialInput.st * repeat; // 两层波纹速度和方向都不同避免呆板 float wave1 texture2D(waterImage, st vec2(time * 0.05, time * 0.02)).r; float wave2 texture2D(waterImage, st * 1.5 - vec2(time * 0.04, time * 0.03)).g; float wave wave1 * 0.6 wave2 * 0.4; // 水色深蓝到亮蓝渐变随波峰变化 material.diffuse mix(vec3(0.02, 0.15, 0.25), vec3(0.1, 0.45, 0.5), wave); material.alpha 0.75 0.25 * wave; return material; } } });代码核心在两个点一是time这个 uniform 会逐帧更新驱动st坐标产生位移二是两层波纹采样方向相反、速度不同叠加起来就会有一种不规则的流动感。如果你愿意再进一步还可以在 shader 里接入 Cesium 内置的czm_sunDirection让水面高光方向跟随场景光照变化这就是动态光照水面的思路比单纯纹理滚动又真实一截。在更新逻辑里每一帧给 time 累加一个增量就行viewer.scene.postRender.addEventListener(() { waterMaterial.uniforms.time 0.016; });2.3 边缘融合与地表接缝的处理水面边界生硬是另一个容易翻车的地方。如果淹没区域多边形跟地形精确贴合理论上不会有缝隙但实际渲染时因为浮点精度、地形网格精度等原因边缘容易出现闪线或悬空感。我常用的处理手段是两个第一把水体高度微量抬高。给定水位 H渲染时让水面高度设置为 H 0.5 至 H 2 米。这不会影响分析结果的准确性但能有效避免水面和地形在共面处出现 z-fighting 闪烁。第二在材质上做边缘透明度过渡。如果你用的是 Cesium polygon可以在边界内部留出一圈过渡带通过构造带渐变 alpha 的纹理来模拟“水边逐渐变浅”的效果。更简单一点的做法是把 alpha 整体控制在 0.75 到 0.85 之间不要调成全透明这样边缘即使有细微误差也不容易被肉眼察觉。注意不要为了让水面更透明而把 alpha 调得很低。实测下来透明度低于 0.5 时水面和地形的叠加会让颜色变得很“脏”边界也不清楚反而影响淹没范围的判读。3. 从零实现淹没分析完整实操流程方案和材质都明确了接下来就是实际操作。这一节我按照“地形采样 → 等值线提取 → 动态更新”三个步骤来走每一步都给出可以直接用的代码和参数建议。3.1 地形采样与网格点高程获取第一步是在分析区域内做规则网格采样。假设你的分析范围是某个矩形区域经纬度范围是 [minLon, minLat] 到 [maxLon, maxLat]采样间距 step 根据精度需求决定。50米间距适合中小范围市政级分析100到200米间距适合流域级大场景。const positions []; const step 0.001; // 约 100 米按经纬度换算 for (let lon minLon; lon maxLon; lon step) { for (let lat minLat; lat maxLat; lat step) { positions.push(Cesium.Cartographic.fromDegrees(lon, lat)); } } const terrainProvider viewer.terrainProvider; const updated await Cesium.sampleTerrainMostDetailed(terrainProvider, positions);sampleTerrainMostDetailed是 Cesium 提供的异步采样接口它会对每个点去地形服务里取最高精度的高程值。如果你用的是离线地形切片只要terrainProvider指向对了采样逻辑完全一样差别只在数据源本身。采样完成后把每个网格点的经纬度和高程存下来后续所有淹没判断都基于这份缓存数据不需要重复请求地形服务。3.2 水位线提取Marching Squares 思路与简化实现有了网格高程数据给定当前水位 H就需要找出“哪些网格点低于 H”。但只用低于 H 的点直接画多边形边界会呈锯齿状很丑。更专业的做法是用 Marching Squares 等值线算法。思路是这样的把每个网格单元看作一个四边形四个角点的高程各自与水位 H 比较高于 H 记为 1低于或等于 H 记为 0。四角共 16 种状态每种状态决定了等值线在这个单元内如何穿过。再根据角点高程与 H 的差值做线性插值就能得到等值线与网格边的交点坐标。把相邻单元的交点连起来就是一个平滑的淹没边界多边形。// 伪代码判断单个网格单元的等值线段 function marchingSquare(height00, height10, height11, height01, waterLevel) { const index (height00 waterLevel ? 1 : 0) | (height10 waterLevel ? 2 : 0) | (height11 waterLevel ? 4 : 0) | (height01 waterLevel ? 8 : 0); // 根据 index 查表得到交点所在的边 // 对每条被穿过的边做线性插值得到交点坐标 return segments; // 返回该单元内的线段端点 }真正实现时不需要自己从零写很多开源库可以直接用。但理解原理很重要边界点不是网格点本身而是插值出来的结果所以即使你的网格采样间距是 100 米提取出的边界也能保持平滑。3.3 动态水位驱动与渲染更新边界提取出来后将边界点转换成经纬度数组创建或更新一个 polygon entity。这里的关键点是水位是连续变化的但如果每一帧都重新提取边界性能很容易崩。我的做法是“双频率更新”——边界几何每隔几百毫秒更新一次材质纹理的 time 则每帧更新。// 淹没实体创建 const waterEntity viewer.entities.add({ polygon: { hierarchy: new Cesium.CallbackProperty(() { return new Cesium.PolygonHierarchy(currentBoundaryPositions); }, false), material: waterMaterial, height: currentWaterLevel 1 // 抬高 1 米防闪烁 } }); // 水位动画 function updateWaterLevel(t) { const eased easeInOutCubic(t); currentWaterLevel startLevel (endLevel - startLevel) * eased; currentBoundaryPositions computeBoundary(currentWaterLevel); }CallbackProperty是 Cesium 里专门用来做动态属性的工具它会在每帧渲染时调用回调函数去获取最新值。但我们要注意回调里最好不要做太重的计算所以我把边界提取逻辑放到了computeBoundary里并控制它不每帧执行。水位缓动用easeInOutCubic可以让水位上涨先慢后快再慢视觉上更接近真实涨水过程。4. 踩坑实录4 类高频问题与排查方法这个功能我前后做了不止一次每次都能遇到新问题。下面这四类是最常见的几乎每个做淹没分析的人都会碰见我分别说一下现象和解决办法。4.1 水面与地形交界的闪烁、穿模现象水面和地形在交界处出现明显的闪烁斑点或者水面边缘悬浮在地形上方看起来很假。排查思路先确认水面高度是否和地形发生了 z-fighting。Cesium 的 3D Tiles 地形和 polygon 在共面时深度冲突很难完全避免。解决办法就是我前面提到的把 polygon 的 height 设置为水位 0.5 到 2 米。另一个容易被忽略的地方是如果你给 polygon 设置了heightReference而不是固定的height水面高度会跟随地形走这在淹没分析里是错误的行为务必改成固定height。4.2 水纹拉伸、重复度过低/过高现象水面一出来纹理被拉成几块巨大的色斑或者密密麻麻全是细纹看着眼睛疼。原因材质 uniform 里的repeat参数没有随着水面面积自适应调整。一个 500 米宽的淹没区和一个 5 公里宽的淹没区用同一个repeat值结果必然一个太密一个太稀。我的经验公式是const extentWidth maxLon - minLon; const extentHeight maxLat - minLat; const repeat Math.max(4, Math.round(Math.max(extentWidth, extentHeight) / 1000));以 1000 米为基准单位动态计算重复次数然后取一个不小于 4 的下限避免水面完全没有纹理变化。4.3 大范围场景下的性能卡顿现象分析区域一大比如整个县域几十公里范围网格点数可能到几万甚至十几万每次水位变化都重新提取边界帧率直线下降。解决办法是分级采样。先用较大的间距比如 500 米做粗采样算出一个粗略的淹没边界然后只在这个边界附近的带状区域内做加密采样比如 50 米间距重新计算精确边界。这样做的原理是远离水边界的区域永远是全淹或全不淹加密采样对结果没有影响只有边界附近的高程细节才真正决定边界形态。4.4 水面看起来不够“活”动态感不足现象水面虽然纹理在动但整体感觉像是一块滚动的大石头没有水流的层次感。通常原因有三个一是纹理层次不够只有一层波纹二是 UV 偏移方向太单一三是颜色变化幅度太小。对应的解决方法是用两层或多层纹理交叉叠加、偏移方向相反、速度不同同时在 shader 里把波峰波谷对应的颜色差拉大让亮部和暗部对比更明显。还有一个取巧的技巧是把 time 的更新频率做一个微小的正弦扰动让水流速度本身也有快慢变化waterMaterial.uniforms.time 0.016 * (0.8 0.4 * Math.sin(performance.now() / 1000));这个细节很多人不会注意到但加了之后水面的“呼吸感”会明显增强。提示如果后面想进一步提升效果可以把水面的粒子效果、浪花飞溅等放到 Three.js 里做Cesium 和 Three.js 可以共享同一个 gl 上下文水面主体用 Cesium 材质粒子特效用 Three.js各取所长。但那是更高阶的玩法了先把这篇文章里的基础做好再拓展也不迟。我自己的经验是不要太早陷入写 shader 的细节里先把“网格采样 → 边界提取 → polygon 更新”这条主链路跑通确保水位变化时边界位置准确然后再回来打磨水面效果。因为一旦边界逻辑有问题水面再漂亮也没意义。每做完一个环节就在真实地形数据上验证一遍这样最后组合起来时才不会手忙脚乱。这套流程我已经在好几个项目里复用过了按这个顺序来做效率最高。本文还有配套的精品资源点击获取