Cesium实时流体模拟:用浅水方程实现洪水淹没与动态水面效果 1. 这个标题到底在解决什么问题刚看到“Cesium 基于浅水方程流体模拟”这个标题的时候我第一反应是这是把玩地图的人和解偏微分方程的人拉到了一张桌上。Cesium 本身不是渲染引擎里的通用 GPGPU 平台它优先服务的是经纬度、地形、影像、三维模型这些空间数据。但浅水方程又是真实感水面、洪水淹没、近岸波浪、污染扩散这类可视化里逃不掉的一环。把两者放一起本质上是在问一件事在 Cesium 这种地理场景里我们能不能用一张可以交互的水面把连续流体的演化过程实时表现出来。这个问题听起来很“炫技”但实际落地的人都知道它背后不是调一个材质接口那么简单而是一条完整的 GPU 计算管线。你需要把水位场、流速场变成纹理在每一帧里做离散化求解再把计算结果送进水面渲染同时还要处理 Cesium 的地形、光照、高程遮挡这些地理底子。做成了你得到的不是一个漂亮的 DEM 水面而是一个会响应地形、会扩散、会波动、能用来做预案推演的动态网格。这套东西适合谁参考我觉得有三类人第一类是做数字孪生、智慧水利、应急仿真项目的人他们想用 Cesium 展示洪水演进或泄洪淹没不需要论文级精度但要“物理上说得通”第二类是喜欢折腾 Cesium 渲染管线的开发者想试试 Entity API、Primitive、RenderTarget 这些环节能组合出什么花活第三类是采集数据后做可视化验证的研究生需要把一个二维浅水模型的结果搬进三维地球里看空间分布。先说清楚能力边界。这里说的“基于浅水方程”是可视化级别的流体模拟不是用于工程计算的精确数值模型。Cesium 的精度、坐标转换、纹理分辨率都会限制模拟的网格规模。你可以用它做趋势展示、方案比选、效果推演但别拿它替代真正的防洪调度计算。把预期定在这个位置后面每一步都不会拧巴。1.1 用浅水方程而不是随便做个水动画差别在哪很多水面效果是拿正弦波叠加做出来的好处是快、稳、好看坏处是它“不认地形、不认边界”。你把一座山放进去波浪还是会从山底下穿过去水不会因为高程变化而汇聚、绕流、漫堤。浅水方程的核心是守恒水的质量不凭空产生动量受重力、底面坡度和压力梯度影响。说得通俗点它就是给水赋予了“常识”——水往低处流障碍物会挡路落差大就冲得快。所以这个标题的真正价值不是“造一个水波”而是“造一个会遵守空间规则的水体”。把它跑在 Cesium 的地理坐标下意味着你能直接跟地形高程、建筑模型、淹没范围这些真实空间数据联动。这个联动才是项目立项时能讲清楚的价值点。1.2 在 Cesium 里做模拟要接受它的三个限制第一Cesium 的渲染循环是它自己的不是给你随便 while(true) 跑物理的。你把模拟逻辑塞进preUpdate或者postRender事件里必须考虑帧率波动对时间步长的影响否则水会时快时慢。第二Cesium 是地理尺度引擎一个矩形区域跨度可能是几十公里而浅水方程在这么夸张的尺度下需要小心“网格步长”和“科里奥利力”这些物理参数普通小水面玩法不能直接抄。第三Cesium 对用户自己创建的 RenderTarget 支持程度有限官方对内部渲染管线的封装不少你需要学会跟scene.context打交道。这三条我后面会逐个展开。先记在心里这不是一个纯前端调参的活它要求你至少理解“模拟状态在哪里存、每个帧怎么更新、最后怎么画出来”。2. 开工前必须想清楚的三件事2.1 选型真想算物理还是只想渲染得像流体动手前先做一次灵魂拷问。如果只追求画面像水用 Cesium 自带的Material写一个基于时间和 UV 的波形函数就够了完全不用碰浅水方程。你要看到水波扰动随地形扩散、遇到障碍物回弹、降雨之后水面抬升那才需要维护一个“状态场”。浅水方程的完整形式包含水位 h 和水平流速 u、v 两套场典型的实现是二分量或者三分量纹理。说明白点你可以把水位场看成一张灰度图黑色是干底白色是水最深流速场是两张带符号的纹理分别记录东西方向和南北方向的流动速度。每一帧GPU 从“上一时刻”的纹理里采样根据周围格点的水位差计算加速度再更新流速和水位写进“下一时刻”的纹理。这个过程叫双缓冲 ping-pong是 GPU 流体模拟最常见的套路。如果你只需要做洪水淹没范围不考虑流场方向可以只求解线性化的浅水方程甚至只解一个波动方程。那更快但会丢失很多“水真的在流”的质感。我的建议是第一次做就把水位和流速都放进去否则后面想加流向箭头、污染扩散时又要推倒重来。2.2 浅水方程到底在算什么我们需要一个不那么数学劝退的直觉。浅水方程假设水的深度远小于水平尺度把三维水体压扁成二维平面上的水深和平均流速。它的连续方程是水位随时间的变化等于流入和流出水量的差动量方程是流速的变化等于水位梯度产生的压力。合在一起就是“坡越陡水越急于流平”这个生活直觉。用公式写出来比较清楚[ \frac{\partial h}{\partial t} \nabla \cdot (h \mathbf u) 0 ][ \frac{\partial \mathbf u}{\partial t} (\mathbf u \cdot \nabla) \mathbf u -g \nabla h ]h 是水深u 是水平流速g 是重力加速度。第二项是非线性项负责洪水那种“水冲过来”的感觉如果只做小幅水面扰动可以把它扔掉变成线性浅水方程数值上会稳很多。在着色器里你不需要真的解矩阵而是用显式差分每个像素从邻近像素读水位差更新自己的速度和高度。这一步可以理解成每个人都在看邻居的行动来决定自己怎么动GPU 里的成百上千个像素一起做这件事天然适合并行。2.3 数据从哪来用一张高度纹理把 Cesium 地形“喂”给方程光有水面方程还不够浅水模拟必须知道底面长什么样。最直接的办法是让 Cesium 帮我们烘焙一张地形高度纹理。用viewer.scene.globe.getHeight(cartographic)在 CPU 上把研究区域内的地形高程取出来写进一张纹理作为模拟的底床高程。模拟时每个格点的实际水深等于当前水位减去底床高程底床高的地方先露出水面底床低的洼地先积水。这一步是把 Cesium 地形和浅水方程打通的关键。很多初版实现都只在一个平面矩形上做水波完全不读地形结果就是水面漂在半空和场景没有任何关系。真正想展示洪水淹没效果一定要把地形高度当成模拟的初始条件。3. 基础环境搭建与底图叠加3.1 初始化 Viewer高清、动态光照、高性能一次配好Cesium 的配置项多很多人上来就new Cesium.Viewer(map)后面高清不清晰、锯齿严重、夜景光照不对全是起步姿势的问题。我习惯这样初始化const viewer new Cesium.Viewer(cesiumContainer, { baseLayerPicker: false, geocoder: false, sceneMode: Cesium.SceneMode.SCENE3D, terrainProvider: await Cesium.createWorldTerrainAsync(), contextOptions: { webgl: { alpha: true, antialias: true, powerPreference: high-performance, }, }, }); viewer.scene.globe.depthTestAgainstTerrain true; viewer.scene.postProcessStages.fxaa.enabled true; viewer.resolutionScale window.devicePixelRatio || 1; viewer.scene.globe.enableLighting true;resolutionScale直接决定画面清不清晰。如果你在 Retina 屏上不设置它Cesium 默认是一个逻辑像素对应一个物理像素字体和图标会发虚。depthTestAgainstTerrain打开后水面、模型、线框会被地形遮挡这对浅水模拟至关重要——否则水会从山体里穿出来。enableLighting是 Cesium 自带日照渲染水面反射时会跟着太阳走后面做动态光照省不少事。3.2 用 Entity API 画一个矩形容器把模拟结果贴上去水面的载体我用矩形。Cesium 里画矩形很简单用 Entity APIconst waterEntity viewer.entities.add({ rectangle: { coordinates: Cesium.Rectangle.fromDegrees(116.0, 31.5, 117.0, 32.0), material: waterMaterial, height: 0, classificationType: Cesium.ClassificationType.TERRAIN, }, });这里需要特别提醒height: 0表示水面贴在地表高程上不是悬浮球面。如果你用模拟算出来的水位作为高度想让它随时间变化不要直接用 Entity 的 height因为 Entity 的 height 是静态的。更灵活的做法是写一个自定义 Primitive在渲染循环里更新水位纹理然后通过自定义 Material 采样。如果你只是想快速验证“矩形能画出来、相机能飞过去”上面的代码够用。但真正上项目我建议把水面几何做成Cesium.RectangleGeometry加Cesium.Primitive几何体只建一次材质每一帧换纹理数据性能会好很多。3.3 把海底地形和模型节点也纳入观察范围浅水模拟只要跑在有地形起伏的区域“海底地形”就不是纯粹的选项而是必需品。地形高度纹理从 Cesium 获取之后你可以做成一张离散化的 DEM分辨率跟模拟网格保持一致。网格分辨率不一定要跟 Cesium 地形精度完全一样通常是模拟网格 256×256 或 512×512然后从地形数据重采样。关于“模型节点”的作用我后面会讲。但在地形这块如果你的模拟区域里有一栋楼、一个堤坝最好把它当成底面高程的一部分写进地形纹理而不是单纯放在场景里当装饰。Cesium 的model.getNode(堤坝)可以拿到模型节点你可以读取它的包围盒把对应区域的高程抬升。这样水遇到堤坝才会真的绕行。4. 核心实现把浅水方程搬进着色器4.1 双缓冲模拟管线状态不丢帧帧更新这是一个很多人绕不明白的地方。Cesium 不能把“上一帧的水位”直接存在某个全局变量里因为 GPU 渲染管线的输入输出边界很严格。方案是准备两张相同尺寸的 RenderTarget一张当“当前状态”一张当“下一状态”。每帧执行一次模拟 shader从当前状态纹理采样算完写到下一状态纹理然后交换两个纹理的引用继续下一帧。它跟 CPU 上交换数组指针是一样的道理只是发生在 GPU 的 framebuffer 里。我写的辅助函数大致长这样function createSimTarget(context, width, height) { const texture context.createTexture2D({ width: width, height: height, pixelFormat: Cesium.PixelFormat.RGBA, pixelDatatype: Cesium.PixelDatatype.FLOAT, sampler: new Cesium.Sampler({ minificationFilter: Cesium.TextureMinificationFilter.LINEAR, magnificationFilter: Cesium.TextureMagnificationFilter.LINEAR, wrapS: Cesium.TextureWrap.CLAMP_TO_EDGE, wrapT: Cesium.TextureWrap.CLAMP_TO_EDGE, }), }); const framebuffer context.createFramebuffer({ colorTextures: [texture], }); return { texture, framebuffer }; }FLOAT 纹理是必要的。水位、流速可能是小数甚至负数用普通的 uint8 纹理一量化就全糊了。如果你的浏览器或显卡不支持浮点纹理可以退一步用半浮点但精度会打折。4.2 离散化与时间步长别让水“爆炸”浅水方程在 GPU 上用有限差分求解最常见的坑是数值不稳定。显式格式的天条是 CFL 条件时间步长不能大于网格步长除以波速否则信息传太快数值直接发散成噪声。简化处理时你可以先固定时间步长等于渲染步长比如每帧 1/60 秒然后缩小网格步长让波速传播控制在合理范围。如果发现水面出现密密麻麻的椒盐噪点大概率是 dt 太大或者阻尼太小。实用技巧是给方程加一个小的速度衰减项相当于水的黏性让能量慢慢耗散画面立刻稳下来。我在项目里常用的参数参数建议值说明网格分辨率256×256研究区域 1°×1° 以内够用时间步长 dt0.02不超过 CFL 条件重力 g9.8物理量纲别随手改基准水深 H2~10 米控制波速影响扩散速度速度阻尼0.999每次更新乘一下防止发散4.3 模拟着色器核心骨架模拟阶段的核心 shader 我拆成两个部分一部分读邻居水位更新流速另一部分根据流速的散度更新水位。写个简化版// 输入: uHeight 当前水位, uVel 当前流速, uBed 底床高程 // 输出: 下一帧水位和流速 float h texture2D(uHeight, v_uv).r; vec2 uvL v_uv - vec2(uTexel.x, 0.0); vec2 uvR v_uv vec2(uTexel.x, 0.0); vec2 uvD v_uv - vec2(0.0, uTexel.y); vec2 uvU v_uv vec2(0.0, uTexel.y); float hL texture2D(uHeight, uvL).r; float hR texture2D(uHeight, uvR).r; float hD texture2D(uHeight, uvD).r; float hU texture2D(uHeight, uvU).r; vec2 vel texture2D(uVel, v_uv).xy; float bed texture2D(uBed, v_uv).r; float waterDepth max(h - bed, 0.0); // 压力梯度驱动力 float gradX (hR - hL) * 0.5 * uMapSize.x; float gradY (hU - hD) * 0.5 * uMapSize.y; vel - uGravity * vec2(gradX, gradY) * uDt; // 连续性方程: 水位变化 水量散度 float divergence (hR - hL) * vel.x (hU - hD) * vel.y; float newH h - uDt * divergence; gl_FragColor vec4(newH, vel * 0.999, 1.0);真实项目里要用迎风差分而不是中心差分中心差分容易在流速大的区域产生振荡。第一次跑通可以用这个简版但要记住它不是数值精度最高的格式。把模拟结果写入纹理后水面渲染阶段直接从水位纹理取 h换算成颜色或者法线。4.4 把模拟步骤接进 Cesium 的渲染循环模拟不能放在普通setInterval里不然会跟 Cesium 的渲染循环不同步。我放在scene.preUpdate事件中执行viewer.scene.preUpdate.addEventListener(function () { stepSimulation(); });stepSimulation里做的事把当前状态纹理绑定到模拟 shader 的 uniform让 GPU 执行一次绘制命令把结果写到备用 framebuffer然后交换两个缓冲。Cesium 的scene.executeCommand可以执行自定义 draw command你需要手动构造一个Cesium.DrawCommand或使用自定义 Primitive。这里我踩过一个坑如果在preUpdate里每帧执行多次模拟帧率会明显下降。更合理的做法是让模拟跟帧率解耦用累计时间控制步数。比如固定物理步长 1/60 秒渲染帧率 30fps 时每帧跑两步渲染帧率 60fps 时每帧跑一步。这样无论机器快慢水的演化速度基本一致。5. 让效果更像“流体”5.1 水面的动态光照与法线扰动拉通模拟之后你可能发现水面是平的像一张涂了颜色的半透明塑料。原因是模拟纹理分辨率通常低于屏幕分辨率直接采样水位得到的高度场不够细腻。这时需要用法线贴图叠加细节常见做法是从水位纹理算梯度得到大尺度法线再叠加一张高频率的波纹法线贴图。Cesium 的动态光照直接使用czm_sunDirectionEC在水面材质里计算 NdotL模拟太阳光方向。卯足了劲往“真实”靠的话还要考虑视线方向和反射方向。在自定义材质里我把流程写成float h texture2D(uHeightMap, materialInput.st).r; vec2 grad vec2(dFdx(h), dFdy(h)); vec3 normal normalize(vec3(-grad * uNormalStrength, 1.0)); float ndl dot(normal, czm_sunDirectionEC); vec3 color mix(deepColor, shallowColor, waterDepthFactor); alpha clamp(waterDepth, 0.0, 1.0);法线强度要控制好太强水会变成碎玻璃太弱又看不出波动。我一般是把uNormalStrength放在 0.5 到 2.0 之间根据相机高度动态调整。5.2 水位配色、透明度与海岸线浅水模拟最直观的输出是淹没范围颜色一定要区分深浅。低水位给浅蓝色高水位给深蓝或者直接红色警报。做洪灾推演时我习惯加一条水位分界线超过警戒水位的区域变红剩下是透明蓝。这个在 shader 里就是两个 mix 操作很简单。透明度要跟地形配合。水深接近 0 的地方应该露出地面而不是像一块玻璃贴在山坡上。做法是让 alpha 正比于水深水深小于阈值直接丢弃或透明。这也会自然形成海岸线和干湿边界视觉上非常真实。5.3 叠加雷达探测图、卫星波束和视锥效果做完整的水面模拟后通常还要在场景里叠加业务元素。这几个热词都很典型雷达探测图、卫星波束、卫星视锥效果。雷达探测图不一定是一个真实的三维锥体。在业务演示里常见的是在地面画一个扇形范围表示雷达扫描区域。用 Cesium Entity API 可以画viewer.entities.add({ polygon: { hierarchy: new Cesium.PolygonHierarchy(positions), material: Cesium.Color.RED.withAlpha(0.3), height: 0, }, });卫星波束和视锥效果就更有意思了。很多 Cesium 开发者第一次做都找不到“锥体” Entity官方里确实没有直接画三维圆锥的方便接口。我建议用PolylineVolumeGraphics先定义从卫星位置到地面的圆环路径再指定不同半径的截面形成一个渐缩的波束。如果你想更精细也可以自定义 Geometry用两个端面加侧面三角网拼一个视锥然后放进 Primitive。视觉效果是卫星位置一条窄线到地面展开成圆形覆盖区很像通信仿真里的波束覆盖。5.4 模型节点控制模型的显示和隐藏业务场景里水面模拟旁边很可能有建筑、船只、雷达站等模型。Cesium 加载 glTF 模型后用model.getNode(nodeName)可以拿到节点。你可以控制节点显示、缩放、位移而不是重新加载模型。const node model.getNode(radar); node.show true; node.scale new Cesium.Matrix3.fromScale(new Cesium.Cartesian3(1.5, 1.5, 1.5));模型节点的作用在于可以把不同部件拆开做状态联动。雷达站模型、波束覆盖、水面淹没范围放在同一个 Cesium 场景里就能做出一个完整的态势演示页面。很多人实现到一半才发现真正加分的不是流体本身而是这些业务要素和流体之间的空间遮挡关系。6. 性能、调错与周边问题6.1 在 Cesium 里开启帧率监控Cesium 没有内置 FPS 显示。你要是开着浏览器的性能面板看开发期还行客户演示的时候总不能开着 DevTools。我一般用scene.postRender自己统计const stats { frames: 0, time: 0, fps: 0 }; viewer.scene.postRender.addEventListener(function () { stats.frames; const now performance.now(); if (now - stats.time 1000) { stats.fps Math.round((stats.frames * 1000) / (now - stats.time)); stats.time now; stats.frames 0; stats.el.innerHTML FPS: stats.fps; } });把stats.el指向一个页面上的隐藏 DOM 或者简单调试面板。监控帧率的作用不是炫耀数字而是要发现模拟卡顿的来源。判断方法是把模拟步骤临时关掉看帧率回不回。如果回得很明显说明模拟 shader 是瓶颈如果帧率没变化问题可能在水面材质采样或者地形绘制。6.2viewer.scene.rendererror与关闭报错窗口Cesium 在 WebGL 出错时会弹出红彤彤的错误浮层调试期很烦人客户看到更尴尬。官方事件可以拦截viewer.scene.renderError.addEventListener(function (scene, error) { if (error error.message) { console.error(renderError:, error.message); } });只要监听了renderErrorCesium 就不会再弹默认报错窗。但我要泼一盆冷水不要为了“看起来没报错”而盲吞错误。着色器编译错误的提示信息通常直接指向 shader 的哪一行拦下来不看等于把排查线索丢了。正确做法是开发期保留弹窗上线前再接管错误事件并在控制台输出。我遇到最多的是浮点纹理在低端设备上创建失败报错信息会是RUNTIME_WEBGL_FRAMEBUFFER_OPERATION或者 shader 编译错误。这种情况不是你代码写错了是显卡不支持。提前准备好半浮点降级方案比在客户现场手忙脚乱好得多。6.3 MVT 格式的数据在 Cesium 里怎么接入MVT 是矢量瓦片格式Cesium 原生对这种格式的支持一直不算友好。官方没有开箱即用的 MVT 图层加载器。我处理 MVT 有三种路子轻量数据直接在服务端把 MVT 解析成 GeoJSON再用Cesium.GeoJsonDataSource加载。数据量不大时最简单。数据量大用第三方库现转。Cesium 社区里有些基于 Web Worker 的 MVT 解包方案把 tile 转成 polygon 或 polyline 后丢给Cesium.Primitive。如果 MVT 本身就是带样式的矢量瓦片也可以考虑用非 Cesium 渲染方案叠加在场景上层但容易跟三维地形脱节。我建议项目早期尽量统一成 GeoJSONCesium 的 Entity API 对 GeoJSON 支持得最好样式也能直接用Cesium.Color。等你把 MVT 接入和浅水模拟同时跑起来你会觉得加载数据的性能瓶颈远大于模拟本身。6.4 Qt 5.12 里调用 Cesium 的几个坑桌面端集成很常见尤其做指挥大屏、水利会商系统。Qt 5.12 用的是 QWebEngineChromium 版本比现代浏览器老对 WebGL 2 和浮点纹理的支持有坑。我实际踩过的问题有这么几个WebGL 默认没开。需要在启动前设置QApplication::setAttribute(Qt::AA_ShareOpenGLContexts); QWebEngineSettings::globalSettings()-setAttribute(QWebEngineSettings::WebGLEnabled, true);部分 Windows 配置下 GPU 能力被 block需要加启动参数--ignore-gpu-blocklist --enable-webgl --use-glswiftshader最后一个参数是强制用软件渲染画面能出来但帧率可能很惨。性能敏感的项目别依赖 swiftshader要确认目标机器显卡可用。Qt 5.12 内置的 Chromium 对 Cesium 版本也很挑剔。新版本 Cesium 对 WebGL2 依赖越来越高在 Qt 5.12 里可能会初始化失败。我建议把 Cesium 版本锁在 1.95 到 1.105 这个区间功能够用兼容性也稳。7. 常见问题速查与心得7.1 水面异常现象速查表我整理了这几年做流体可视化最常碰到的现象和解决方向现象可能原因处理思路水面出现密密麻麻噪点时间步长过大数值发散缩小 dt或增加速度阻尼水不流动像一张贴图没有更新双缓冲状态没写入检查 RenderTarget 交换逻辑水从山体里穿出来没开深度测试或地形高度没参与模拟开启depthTestAgainstTerrain正确烘焙底床高程水面忽明忽暗法线直接用了水位梯度噪声太大对水位做模糊或平滑再看法线帧率只有十几帧模拟纹理分辨率太高降到 256×256或用半浮点纹理矩形水面覆盖全地图coordinates 范围太大检查经纬度范围别用整球坐标模型波浪穿透建筑建筑节点未写入模拟底床读取模型包围盒写入高程纹理图标发虚未设置 resolutionScale 或图片尺寸太小提高 DPR 缩放放大图标纹理尺寸这些坑不是说每个项目都会全踩一遍但你一定会遇到其中三四个。最难查的是第二类“水不流动”因为画面不报错只是效果不动很容易误判是 shader 写错了。排查时在模拟 shader 里加一个固定“降雨”扰动如果水面一点反应都没有问题八成在 RenderTarget 的绑定和交换。7.2 我做了三次之后才理解的经验第一次做这个题我用了一个最简单的正弦波加法线贴图两小时就出效果客户说“挺好看的”。第二次想做成真的浅水方程折腾了三天才明白 Cesium 的地形高度不是拿来就直接能用的要先把研究区域的地形采样成跟模拟网格一致的纹理。第三次再回头做我反而把大部分精力花在了“让模拟结果跟业务数据联动”上比如跟降雨监测点联动跟水位警戒线联动跟雷达探测范围联动。水面本身反而只占了三分之一的工作量。我想提醒后来的人一句话浅水方程在 Cesium 里的实现没有想象中高不可攀但如果你把“能跑通”当成终点那你只是完成了技术验证如果你想让它成为项目里能讲故事的模块还需要把接口、数据、业务参数设计得足够开放。我可以很肯定地跟你讲下一次客户需求的落点一定不是“水真像”而是“水能不能跟实时雨量、地形、淹没范围这些数据一起动起来”。最后分享一个小技巧把整个浅水模拟模块封装成一个独立类对外只暴露setRain(x, y, intensity)、setTimeStep(dt)、getHeightAt(lon, lat)这几个方法。你在 Cesium 里做任何交互都只调这些方法不到一天就能把二维矩形里的流体模拟变成一个可以服务整个数字孪生场景的公共能力。