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 API:
const 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° 以内够用 |
| 时间步长 dt | 0.02 | 不超过 CFL 条件 |
| 重力 g | 9.8 | 物理量纲,别随手改 |
| 基准水深 H | 2~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); } });只要监听了renderError,Cesium 就不会再弹默认报错窗。但我要泼一盆冷水:不要为了“看起来没报错”而盲吞错误。着色器编译错误的提示信息通常直接指向 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 渲染方案叠加在场景上层,但容易跟三维地形脱节。
我建议项目早期尽量统一成 GeoJSON,Cesium 的 Entity API 对 GeoJSON 支持得最好,样式也能直接用Cesium.Color。等你把 MVT 接入和浅水模拟同时跑起来,你会觉得加载数据的性能瓶颈远大于模拟本身。
6.4 Qt 5.12 里调用 Cesium 的几个坑
桌面端集成很常见,尤其做指挥大屏、水利会商系统。Qt 5.12 用的是 QWebEngine,Chromium 版本比现代浏览器老,对 WebGL 2 和浮点纹理的支持有坑。我实际踩过的问题有这么几个:
- WebGL 默认没开。需要在启动前设置:
QApplication::setAttribute(Qt::AA_ShareOpenGLContexts); QWebEngineSettings::globalSettings()->setAttribute(QWebEngineSettings::WebGLEnabled, true);- 部分 Windows 配置下 GPU 能力被 block,需要加启动参数:
--ignore-gpu-blocklist --enable-webgl --use-gl=swiftshader最后一个参数是强制用软件渲染,画面能出来但帧率可能很惨。性能敏感的项目别依赖 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 里做任何交互都只调这些方法,不到一天就能把二维矩形里的流体模拟,变成一个可以服务整个数字孪生场景的公共能力。