之前接了一个数字孪生流域的项目需求一句话“在Cesium里让水真正流起来。”注意是“流起来”不是贴一张带法线波动的静态水面纹理。甲方要看到洪水演进、污染扩散、水位变化不是海面波浪那种纯视觉抖动。我一开始想到Three.js里现成的Ocean、Water库但问题在于整个场景是跑在Cesium里的地形、建筑、管线、影像切片全是Cesium的东西硬塞一个Three.js渲染器进去地面和地下两张皮坐标对齐都能让人疯掉。最后定下来用浅水方程做一套完全跑在Cesium管线内的流体模拟网格开到384×384加上交互扰动和实时渲染帧率能稳定在50fps以上。这篇文章就把整个方案从头到尾拆一遍包括方程原理、GPU实现思路、Cesium集成层次、以及我踩过的一堆坑。这里适合两类人看一是想在Cesium里做水动态效果但不知道从哪下手的二是已经试过把流体库搬进Cesium但被坐标、帧率、RenderError折腾到怀疑人生的。我尽量不堆公式把数值格式和渲染流程讲成人话但关键参数和踩坑点会精确到具体数值。1. 为什么我在Cesium里选择浅水方程1.1 三条流体模拟路线的取舍做流体模拟业界大致有三条路线粒子类方法SPH/FLIP、欧拉网格法N-S方程或浅水方程、纯视觉经验模型正弦波叠加、Gerstner波。我在Cesium这个特定场景里逐一把它们毙掉了。SPH和FLIP看起来很美能模拟水花飞溅、液体飞沫但粒子系统的致命问题是数量。一个像样的效果至少需要几十万粒子每个粒子要更新位置、速度、邻居搜索这在WebGL里要么用Compute Shader硬算要么每帧把粒子数据回读CPU。Cesium本身渲染压力已经很大一个粒子系统跑在Cesium旁边CPU和GPU双向压力帧率会很难看。更麻烦的是Cesium的场景是数字孪生级别的水面上还有建筑物、船只、标绘粒子和这些对象的空间遮挡、碰撞完全没法处理。Gerstner波这类纯视觉模型做海面波浪效果倒是很合适但它本质是三角函数叠加根本不含物理量。你没法告诉它“这里降雨了30毫米、那里的水要涨半米”它也没有“水位”概念只能做表面动画。做流域项目需要的是有深度场、有速度场、可受外力驱动的物理模拟所以Gerstner波方案在视觉层只能当辅助。浅水方程恰好踩在正确的位置。它是三维N-S方程在“水平尺度远大于垂直尺度”假设下的简化二维平面上每个网格点只需要维护三个量水深h和水平速度u、v。计算量小、物理量明确、天然适合纹理存储。Cesium里的场景恰好是宏观尺度几十米到几十公里的水面浅水方程在物理上和计算成本上都合适。1.2 浅水方程适用范围与物理假设浅水方程通俗理解就是“忽略水的垂直方向加速度”的流体模型。它假设水体的水平运动尺度远大于水深水的内部压强只有静水压力这样一来三维问题就被压成了二维问题。对于流域洪水、河道演进、水库泄洪这类场景这个假设是天然成立的——你要看的是水面在水平方向怎么涨、怎么流不是浪花内部的三维旋转。技术上这件事还有个隐藏优势浅水方程只需要在二维网格上求解网格数据可以编码到RGBA纹理里。RGBA纹理天然对应四个浮点分量比如RGB存速度u/v和水深hA通道存底高程b或标记格子。这样GPU只需要做纹理采样和像素置算不需要任何复杂的粒子数据结构Cesium的渲染管线能无缝接住。1.3 Cesium渲染管线的特殊约束为什么不能直接搬一个WebGL流体库进Cesium因为Cesium有着自己封闭的渲染管理逻辑。它的Entity、Primitive、材质系统都是围绕地球场景设计的一个普通的WebGL项目可以自己创建FBO、自己管理DrawCall但在Cesium里直接新建一个WebGL上下文是行不通的——浏览器允许的WebGL上下文数量有限而且Cesium自己已经占用了一个再开一个会导致资源冲突和性能崩坏。正确的做法是寄生在Cesium的渲染循环里复用Cesium的Context和FrameBuffer机制。也就是说流体模拟的“每帧更新”和“绘制”必须做进Cesium的Primitive/CustomRenderer流程里要么自己写一个CustomPrimitive插入scene.primitives要么把计算放在Cesium的scene.postRender回调中执行。我最终采用的是自定义Primitive 双FBO ping-pong更新 顶点置换 Fragment着色渲染的架构这在后面第3部分会详细拆解。另外还有一个容易被忽略的约束Cesium场景中所有物体用经纬度或ECEF坐标表示流体模拟是局部平面空间所以坐标基准必须自己把控。我的做法是在某个局部原点建立东北天ENU局部坐标系把模拟网格的UV坐标映射到经纬度范围再通过Cesium的Transforms生成本地坐标系到世界坐标系的矩阵。2. 浅水方程原理与数值格式2.1 方程让人看得懂的解释浅水方程的标准形式是∂h/∂t ∂(hu)/∂x ∂(hv)/∂y 0 ∂(hu)/∂t ∂(hu² gh²/2)/∂x ∂(huv)/∂y -gh ∂b/∂x ∂(hv)/∂t ∂(huv)/∂x ∂(hv² gh²/2)/∂y -gh ∂b/∂y别被这一堆偏导吓住。第一条是连续性方程通俗讲就是“水往低处流总量守恒”一个网格里的水深变化等于从左边流进来、右边流出去的差。第二第三条是动量方程讲的是“水流为什么会加速”因为水面倾斜产生压强差水流就会往低处推同时底部地形起伏也影响流动方向。这里有个很妙的地方方程里的h²项来自静水压强积分。水深越深底部受到的压力就越大所以深的区域天然会把水推向浅的区域。这让浅水方程自带“扩散与平衡”能力能够自然模拟溃坝、涌波、水位回落等真实水动力学现象。如果只做纯水面流动、不做复杂湍流浅水方程足够。我在这套模拟里还额外加了一个被动物理量通道专门用来做污染扩散、水温场、能量波前这类标量场的输运这个后面扩展部分再展开。2.2 MacCormack预测-校正格式浅水方程在GPU上的常见离散格式有Lax-Wendroff、MacCormack、以及Journal等。我实际测试下来MacCormack最顺手原因很简单它只需要两步——预测步加校正步整个逻辑可以在两次纹理采样中完成不需要引入稀疏矩阵求解。MacCormack的核心思路是先利用前向差分预估一个中间状态再用后向差分做一次校正两次结果取平均。具体到GPU上对每个texel在预测步计算右侧通量// 伪代码示意实际GLSL实现见第3部分 predict_h h - dt * (d(h*u)/dx d(h*v)/dy)校正步再反方向差分一次最后加权平均。这样做的好处是二阶精度对波的传播和涌波的捕捉比普通一阶格式准得多而且天然有耗散机制不容易数值爆炸。不过在GPU上用MacCormack有个坑前向差分和后向差分各采样一次纹理总共要采样相邻4个像素以上的值再加上边界条件判断和底地形采样一个fragment的采样量大约在16次到20次之间。在384×384的网格上每帧更新Pass大约要进行15万次像素着色计算这个量级在GPU上完全能接受但如果分辨率推到1024×1024帧率就会直接掉到20fps以下。2.3 CFL稳定条件和参考参数数值模拟最忌讳不问稳定条件就乱设时间步长。浅水方程的特征波速近似为c √(g·h) |u|CFL条件要求dt ≤ CFL_max · dx / max(c)其中CFL_max通常取0.3到0.5。我实际使用中取0.4更稳妥。如果dx是10米水深3米g取9.81那么波速c约等于5.4m/s加上流速2m/s约7.4m/s。此时时间步长dt大约0.54秒。不要小看这个数字它决定了模拟的帧率下限——如果你的模拟区域是5公里×5公里网格384×384意味着dx约13米CFL允许的dt大约是0.7秒但为了视觉流畅我一般每帧只推进1到2个物理步而不是把dt拉满。这里必须强调时间步长过大是数值发散的第一大原因。画面出现粉红色噪点、水面像沸腾一样跳动的九成是CFL条件被打破。排查时不要急着调网格尺寸先算一下当前网格下允许的最大dt再回推每帧可以做几步。我在项目中生成了一张常用参数速查表网格分辨率模拟区域dx约建议每帧物理步数实测帧率256×2565km19.5m2步60fps384×3845km13m1步50fps512×5123km5.8m1步38fps1024×10243km2.9m0.5步即隔帧更新22fps这里面有个有意思的策略高频视觉更新和物理更新可以解耦。水面顶点的置换和颜色渲染每帧都做但浅水方程计算可以隔帧甚至每三帧推进一次。视觉流畅度不降物理依然稳定。这个“模拟速度倍率”我用一个uniform参数控制运行时甚至可以调低网格分辨率而不影响渲染管线。2.4 边界条件和底地形处理边界条件是个实操坑。我一开始图省事所有边界都用零梯度结果水波碰到边界就反弹、叠加整个水面变得像一碗被搅动的浓汤。后来改成组合策略模拟域四周设置开放边界出流边界让波能流走模拟域内部有障碍物比如桥梁、堤坝的网格标记为固体墙速度强制归零。对于底地形浅水方程里有一项 -gh ∂b/∂x其中b是底部高程。我在另一个纹理通道里存了一张底高程图从高程栅格重采样并归一化到纹理的A通道模拟时把有效水深定义为h_water h - b。当有效水深小于阈值该网格就标记为陆地速度清零。这样河床、洼地、岛屿都可以做进去比单纯把水面抬起来真实得多。边界条件的技术实现也不复杂在Shader更新Pass开头判断纹理坐标是否贴边如果是边界则不参与通量交换而是做一下局部线性外推内部干湿边界则用流速归零和高度限幅两种手段同时防穿帮。这个逻辑用纹理坐标判断比用专用mask纹理更高效少一次纹理采样。但要注意纹理坐标UV的0到1之外不能直接sample否则会返回undefined所以我在Cesium调用侧做了clamp到边缘的选项Shader内部再决定是否忽略。3. Cesium里的落地实现3.1 架构双FBO ping-pong与纹理通道在Cesium里做流体更新最关键的决策是谁在什么时候更新纹理。如果你天真地写一个viewer.clock.onTick.addEventListener在里面调用WebGL的texImage2D往纹理里写数据十有八九会碰到Cesium的渲染上下文切换冲突。我采用的架构是自定义Primitive postRender回调。具体流程是创建两个Cesium的RenderTarget或者直接用FramebufferTexture尺寸等于模拟网格分辨率作为浅水方程的双缓冲。A buffer存当前时刻的h、u、vB buffer存下一时刻的结果。在每一帧渲染结束后主动执行一次“更新Pass”读A写入B然后交换A和B的引用。这个Pass是一个屏幕空间四边形渲染片段着色器里跑MacCormack格式的离散方程。模拟结束后把更新后的高度纹理绑定到水面的Primitive材质中供下一帧的渲染采样。用双缓冲的好处不仅是读写分离更重要的是避免在Cesium渲染水面的同时去写同一张纹理造成同一纹理读写依赖这在WebGL里是严重的性能拖累甚至可能被驱动降级。Cesium的RenderTarget管理是这样的你自己创建了Framebuffer就要自己负责释放。如果不释放每次切换场景或销毁viewer时GPU资源会泄漏。我在销毁逻辑里显式调用renderTarget.destroy()在项目内测时发现这条路是通的。如果你图省事不销毁长时间运行之后会看到内存一路飙升。3.2 网格几何与顶点置换流体模拟的高度场算出来了接下来要把它呈现在三维地球上。水面本身我不用Entity的Rectangle因为Entity的Rectangle是规则平面没法做顶点置换。我自建了一个细分平面Primitive沿着经纬度范围生成一个矩形网格横向纵向各N等分顶点总数等于模拟网格分辨率精确对应纹理像素。这里有个描述“精确对应”的关键点在第row行第col列的顶点正好对应高度纹理第row行第col列的像素。这样在顶点着色器里我可以用顶点的attribute-uv直接采样高度纹理得到该点水深再结合局部高程基准和比例尺把水深换算成真实海拔然后更新顶点位置的height分量。你可能会问既然水面是平铺在地球上的为什么不直接用高度纹理来扰动Cesium terrain的高度因为Cesium地形是球面瓦片结构顶点密度不可控你不可能让它在UI要求的高分辨率区域保持网格密度。所以我做了一个“自包含”的水面平面它不跟Cesium地形贴合只跟自己的基准高程对齐水陆交界处靠河流边界建模来处理。示意图如下不是架构图是思维导引模拟网格(纹理) --- 水面几何(顶点) --- Cesium世界坐标 h,u,v数据 displacement Transforms.eastNorthUp在实际项目中河流被堤坝约束在固定范围水面平面的范围正好覆盖河道和淹没区即使跟旁边地形有几十厘米的误差远看不会穿帮。如果你需要水面精确落在Cesium地形上可以在CPU侧把Cesium地形采样一次生成底高程纹理b然后把hb写到顶点这样水面就绕地形走了——代价是水位低于地形的地方需要额外置零别漏。3.3 关键Shader代码与过程我把两段最核心的GLSL列出来。第一段是浅水方程更新Pass的核心计算我用半分辨率精度的float纹理存数据每个texel的RGBA通道分别为u、v、h和一个自定义标量如污染物浓度或温度。为了方便解释下面代码简化了只保留h/u/v。// 更新Pass片段着色器核心逻辑MacCormack形式简化为单步 uniform sampler2D u_stateTex; // 当前状态纹理RGBA存u,v,h,scalar uniform float u_dx; uniform float u_dt; uniform float u_gravity; uniform vec2 u_texelSize; const float friction 0.002; void main() { vec2 uv gl_FragCoord.xy * u_texelSize; vec4 state texture2D(u_stateTex, uv); // 当前点 float u state.r; float v state.g; float h state.b; float b texture2D(u_stateTex, uv vec2(0.0, u_texelSize.y * 1.0)).a; // 底高程示例 // 采样相邻像素求梯度中心差分 float hL texture2D(u_stateTex, uv - vec2(u_texelSize.x, 0.0)).b; float hR texture2D(u_stateTex, uv vec2(u_texelSize.x, 0.0)).b; float hD texture2D(u_stateTex, uv - vec2(0.0, u_texelSize.y)).b; float hU texture2D(u_stateTex, uv vec2(0.0, u_texelSize.y)).b; // 计算水深梯度 float dhdx (hR - hL) / (2.0 * u_dx); float dhdy (hU - hD) / (2.0 * u_dx); // 计算速度梯度 float uL texture2D(u_stateTex, uv - vec2(u_texelSize.x, 0.0)).r; float uR texture2D(u_stateTex, uv vec2(u_texelSize.x, 0.0)).r; float uD texture2D(u_stateTex, uv - vec2(0.0, u_texelSize.y)).r; float uU texture2D(u_stateTex, uv vec2(0.0, u_texelSize.y)).r; float dudx (uR - uL) / (2.0 * u_dx); float dudy (uU - uD) / (2.0 * u_dx); float vL texture2D(u_stateTex, uv - vec2(u_texelSize.x, 0.0)).g; float vR texture2D(u_stateTex, uv vec2(u_texelSize.x, 0.0)).g; float vD texture2D(u_stateTex, uv - vec2(0.0, u_texelSize.y)).g; float vU texture2D(u_stateTex, uv vec2(0.0, u_texelSize.y)).g; float dvdx (vR - vL) / (2.0 * u_dx); float dvdy (vU - vD) / (2.0 * u_dx); // 只有水深为正的区域才做完整计算干网格直接置零 bool wet h 0.001; if (!wet) { gl_FragColor vec4(0.0, 0.0, 0.0, state.a); return; } // 连续性方程 float h_new h - u_dt * h * (dudx dvdy) - u_dt * (u * dhdx v * dhdy); // 动量方程 float u_new u - u_dt * (u * dudx v * dudy) - u_dt * u_gravity * dhdx - friction * u; float v_new v - u_dt * (u * dvdx v * dvdy) - u_dt * u_gravity * dhdy - friction * v; // 人为阻尼把速度微量衰减到0防止长期震荡 u_new * (1.0 - friction); v_new * (1.0 - friction); // 限定最小值避免负水深导致NaN h_new max(h_new, 0.0); gl_FragColor vec4(u_new, v_new, h_new, state.a); }这段代码是单步的简单格式实际项目里我用的是三次亚迭代一次预测、校正、再加人工耗散项。人工耗散的核心是把中间值采样周围四个像素做拉普拉斯项laplacian (neighborSum - 4*center)/dx²乘以一个很小的系数如0.02加到速度场上。这能把高频噪声压下去代价是抹掉一点细节但对抗数值发散非常有用。第二段是水面渲染Pass的顶点着色器片段采样高度纹理做顶点置换并计算法线// 水面渲染顶点着色器关键逻辑 attribute vec2 a_uv; // 矩形网格顶点的纹理坐标 uniform sampler2D u_heightTex; uniform float u_heightScale; // 模拟高度到真实海拔的缩放系数 uniform float u_baseHeight; // 模拟基底海拔 void main() { float h texture2D(u_heightTex, a_uv).b; float dx u_heightScale * 0.5; // 根据模拟横向尺寸换算的梯度步长 // 邻近高度采样用于法线 float hL texture2D(u_heightTex, a_uv - vec2(1.0/384.0, 0.0)).b; float hR texture2D(u_heightTex, a_uv vec2(1.0/384.0, 0.0)).b; float hD texture2D(u_heightTex, a_uv - vec2(0.0, 1.0/384.0)).b; float hU texture2D(u_heightTex, a_uv vec2(0.0, 1.0/384.0)).b; float dzdx (hR - hL) * u_heightScale; float dzdy (hU - hD) * u_heightScale; // Cesium里顶点位置局部坐标放在x/y平面z朝上根据项目实际调整 vec3 pos vec3(a_uv.x, h * u_heightScale u_baseHeight, a_uv.y); vec3 normal normalize(vec3(-dzdx, 1.0, -dzdy)); // 转换为ECEF并套用模型矩阵... }这段代码在Cesium里还要套一层变换。我建了局部坐标系矩阵在Cesium里每个Primitive都有modelMatrix把局部坐标系的东北天坐标转到ECEF。如果你的水面是整个地球范围内的比如全球海平面那就不要用这个方法直接用Cesium的billboard或者globe上的shader去处理但那种场景也很少见。3.4 交互扰动从经纬度到UV坐标让水面能跟用户互动是这套方案最出彩的地方。实现原理很简单把鼠标点击的经纬度通过Cesium的Ellipsoid.cartographicToCartesian转到局部坐标系再减去水面原点的东北天坐标得到相对于局部原点的x/y偏移然后除以模拟区域尺寸得到UV坐标。在目标UV处往径向邻居像素里加一个高斯形状的高度脉冲就形成了水滴撞击水面的一圈圈涟漪。注意交互不想走CPU回读方式而是直接把扰动写入更新纹理所以更新的代码要放在Cesium的render循环里而不是中间某次事件里。事件只负责记录“在哪个UV位置、以什么强度扰动”下一帧更新Pass读取这个uniform把扰动注入到方程右侧源项。源项在方程里就是加到h_new和如果需要推力u_new上。源项注入还有一个细节如果扰动太尖锐会造成局部数值振荡最好用平滑的高斯核半径至少5个网格单元。我试过半径3个单元立刻在点击处出现粉色NaN那是一次深刻的教训。4. 性能调优与显示效果4.1 网格分辨率与实时帧率的平衡做这套模拟最直接的性能瓶颈是更新Pass的像素着色复杂度而非水面渲染的顶点数。因为更新Pass是逐texel跑通量计算每个texel至少要采样8到12次纹理纹理采样带宽是GPU的核心消耗。我实测过的四档分辨率性能表现前面参数表里给过了这里补充一个重要心得不要在GPU上盲目追求高分辨率。在数字孪生大屏上水面只是画面的一部分周围建筑、遥感影像、标注都要抢GPU资源。我最终选择384×384不只是因为性能还因为当水面呈现在4K大屏上时一个模拟texel对应屏幕像素接近1:1再往上加视觉收益已经递减。如果模拟区域很大比如整个蓄滞洪区一上来就是20公里×20公里那你千万别试着把分辨率推到1024以上。合理做法是分块模拟沿河道切成多个3000米见方的模拟块块与块之间通过边界通量交换。虽然实现复杂但每块384×384整体性能可控。Cesium的世界坐标里块与块的位置天然对齐接缝可以用法线贴图平滑掉。4.2 帧率监控与渲染瓶颈定位很多读者问我在Cesium里怎么量化帧率。其实Cesium自带两个调试开关一个是viewer.scene.debugShowFramesPerSecond会在屏幕上叠帧率另一个是viewer.scene.debugShowGlobeDepth可以看深度缓冲。但这两个只告诉你“慢”的结果没告诉你“为什么慢”。我的做法是逐Pass计时在postRender回调里用performance.now()包裹更新Pass打印出耗时。如果更新Pass超过8ms就说明像素着色器太贵了要么降低物理更新频率要么减少采样次数。如果更新Pass只有1ms但总帧率还是低那么瓶颈在Cesium主渲染——建筑遮挡剔除、实体数量过多、图像瓦片解码都会挤压帧率这时候跟流体方案无关得从场景侧的批次优化入手。还有一个小技巧轮询热词里经常出现“cesium 开启帧率”其实Cesium的调试帧率开关和浏览器的requestAnimationFrame是同步的开启后屏幕左上角会显示FPS和DrawCall数量以及Texture/Mesh的内存占用这个对排查纹理泄露非常有用。如果TextureMemory一直涨不回落八成是我前面提到的RenderTarget没销毁。4.3 视觉增强法线计算和颜色映射物理模拟好了只渲染一层纯色水面太可惜。我做了三层视觉增强第一层是水面法线。虽然浅水方程输出的是水柱高度并不含水面的局部细节波但高度场的梯度天然可以作为水面法线方向。把dhdx和dhdy映射到法线再叠一层微小的噪声纹理法线扰动水面就有波光粼粼的质感。注意法线要与光源比如Cesium的太阳位置配合才有真实高光。第二层是颜色映射。水深、流速、标量浓度都可以做颜色映射表。我常用蓝绿色渐变表示水深暖色到冷色表示温度红色预警表示污染超标。推荐用Cesium的createMaterial直接在材质里写一个颜色映射函数把模拟标量值映射到渐变色。第三层是透明度与岸边融合。在大尺度数字孪生里水流到干湿交界处半透明水面跟不透水地表的接缝非常明显。我的做法是让水面plans的透明度随着有效水深线性过渡水深小于指定阈值就渐隐到全透明这样岸边看起来是水慢慢漫上去再消失而不是一刀切。4.4 大幅面场景的分块与LOD思路如果你的场景太大分块是绕不开的。一个可行的简化方案是远处块用粗网格比如128×128近处块细网格512×512块与块之间用“环带数据交换”——每帧把邻居块边界上的h/u/v拷贝到当前块的边界纹理中。这个拷贝在Cesium里得在CPU侧做不能只在GPU侧采样因为不同块是不同的RenderTargetShader无法直接互相采样。我觉得这个方案对大多数项目来说过重了。更务实的做法是视距分级。当相机距离水面中心超过5公里时直接用静态水面纹理加流动UV动画不做物理更新当相机拉近到2到5公里开128×128低精度更新只有进入2公里以内才切换到384×384完整模拟。这个LOD逻辑可以放在每帧判断里相机远离时自动降低更新Pass频率甚至跳过效果不穿帮性能也稳。5. 常见问题与排查记录5.1 RenderError高发场景热词里出现cesium viewer.scene.rendererror说明很多人被Cesium的渲染异常卡住。我在这套流体模拟里也踩过几次最常见的是两种第一种是Framebuffer状态不完整。当你拿了Cesium的Framebuffer但没有调用bind或者绑定时尺寸和纹理尺寸不一致Cesium在下一帧开始渲染时会抛出RenderError。这个错误的经典特征是场景黑屏控制台报“ERROR: Framebuffer incomplete attachment / videocore: Unsupported framebuffer format”之类的提示。解决办法很朴素更新Pass绑定FBO之前检查一下FBO是否isComplete()不完整就重新创建。另一个容易踩的点是渲染循环中修改了viewer.scene.globe.show或切换地形导致Cesium重新初始化渲染状态FBO状态被销毁但你的引用还是旧的再绑定就报错。所以每次RenderError回调里第一件事是重建FBO而不是反复调requestRender()。Cesium本身提供scene.renderError事件可以直接监听拿到error信息。但我建议把这个事件当成断点而不是日志不要在生产环境里让它裸奔。5.2 数值爆炸与水波发散模拟跑着跑着水面出现条纹状噪点接着变成荧光粉——典型数值发散。排查顺序我总结成三问时间步长满足CFL吗这是第一问。用前面给的公式算一遍dt超了就降。边界是否是开放边界如果边界是反射的波形会实现在封闭矿泉壶里一直叠加迟早爆。开放边界这句是使用零梯度假设让波走出去。有没有加人工耗散即使前两点都满足在干湿交界、源项注入点附近还是可能局部振荡。加0.02到0.05的人工粘性系数可以完美压住。如果你三者都检查过还爆那就是MacCormack预测-校正格式的步数没匹配好——你每帧推进了太多物理步每步之间的数据没有重新归一化。我建议物理更新Pass里用uniformu_maxSteps限制每帧步数超过上限就丢弃宁可模拟变慢不能数值爆炸。5.3 坐标映射错位水面模拟区域跟真实地理错开半个网格这个问题非常恼人。最常见的原因是网格UV是从0到1的连续值而顶点坐标的经纬度范围也是从minLon到maxLon但两者的起始点没有对齐。我踩过这个坑后来索性把模拟原点放在网格的左下角而不是网格中心这样UV的(0,0)正好对应经纬度范围的西南角跟采样纹理的坐标系统一。还有一个隐藏错位Cesium的局部坐标系是东北天ENUx轴指向东、y轴指向北、z轴指向天空而你的模拟网格可能是按列行组织的。如果施舍到模型矩阵时直接按列作为x、行作为y水面会整体旋转90度。解决办法是创建模型矩阵时把局部坐标系的y轴映射到网格的行方向或者反过来自己心里要有数写完就调一次Leak定位别靠肉眼。5.4 多次渲染导致实体模糊或纹素采样问题“cesium 图标不清晰”“cesium 如何高清”这类问题在社区很频繁。流体模拟里也有类似现象水面贴图在远景时如果mipmap没开会严重闪烁近景时如果没有关闭mipmap会变模糊。Cesium的纹理默认有自己的采样设置我从自己的FBO创建纹理时设置Sampler为CLAMP_TO_EDGE并关闭mipmap。另外一个容易被忽略的是抗锯齿和分辨率缩放。如果你在Cesium里开启了viewer.resolutionScale大于1整个画布被放大渲染水面纹理的采样密度看起来会变高这时如果模拟网格分辨率没跟上水波会呈像素块感。所以“高清”问题可能不是纹理清晰度而是流场细节不够。要么提高模拟分辨率要么把水纹噪声叠一层或者把分辨率缩放的控制跟LOD逻辑联动拉近时才提升网格细分。6. 扩展玩法与实践建议6.1 接入真实洪水数据驱动浅水方程的好处是天然能接入真实边界条件。比如把水文站的流量过程线折算成源项注入到河道上游下游设成开放边界就能模拟真实洪水演进。这里需要额外做的是把你自己的降雨量或流量折成h_new rainfall * dt并且把河道边界高程固化成底地形。我做这个扩展时碰到一个问题Cesium的影像瓦片和地形是异步加载的而模拟网格坐标必须在下发前确定。处理方式是先从Cesium地形采样比较粗糙的底高程固化到模拟纹理中然后让地形瓦片加载完后再进行一次精细重采样更新底高程纹理。注意重采样的帧要重建FBO否则纹理尺寸不一致会黑屏。6.2 将流体方程扩展为污染物和能量辐射场浅水方程天然自带两个量水深和速度。想做一个“污染浓度扩散场”只需要增加一个标量纹理通道每帧在更新Pass里多采样一次按对流-扩散方程更新它。这种情况下污染源是一块红色羽流它被水流推着走同时按湍流扩散系数向周围扩散。我在一个项目里用它模拟了化工厂泄漏后污染物沿河道漂移的时空过程效果比单纯的粒子动画好得多——粒子只是一堆点浓度场是一张连续的、符合流场逻辑的彩带。同样的思路你甚至可以把雷达波束覆盖范围或卫星视锥做成“一张能量场”让它随时间扩散、被地形遮挡衰减。Cesium里的雷达探测图通常是用Mesh画扇形但你要是接上这个流体场就能做出动态扫描的雷达能量波前。热词里“cesium雷达”“cesium卫星视锥效果”挺多说明这个方向有刚需。我在这里只提一个思路不做展开但技术上是同构的——把一个受地形衰减的波方程或者扩散方程灌进去就把静态几何变成动态传播场。6.3 桌面端集成时的一个提醒热词里还有“qt5.12调用cesium”。如果你在Qt WebEngine里嵌CesiumWebGL的上下文一定要确保只保留一个实例不要把Cesium重复实例化。流体模拟的FBO在这种环境里尤其脆弱因为Qt WebEngine的GPU合成机制和Chromium略有差异窗口尺寸变化时FBO不一定自动重建。解决办法是在窗口resize事件里监听Cesium的canvas尺寸变化然后resize模拟FBO——不要直接更新RenderTarget尺寸而是销毁重建简单粗暴但可靠。另外桌面端的显卡驱动特别是双显卡笔记本容易在更新Pass时把模拟计算的Pass放到集成显卡上跑导致帧率极低。目前没有好的WebGL层控制办法但你可以用viewer.scene.context.halfFloatingPointTextureSupported这种能力检测来打折方案避免那些不支持的机器直接黑屏。最后再分享一个我在摩擦了大量头发后得到的感悟在Cesium里做流体不要把它当成一个独立的“流体插件”而是当成Cesium场景里的一个特殊图层。它的数据基础是网格纹理玩法是跟地形、影像、矢量标注叠加联动最终目的是让数字孪生里的“水”变得可计算、可交互、可预测。只要把握住“纹理即数据、pass即更新、Primitive即显示”这三个原则后面无论是做长江流域洪水模拟还是做一个大屏上的动态河流你都能快速搭出自己的版本。我个人现在最想做的下一步是把这套模拟从“水面高度”扩展成“水下三维速度场”也就是用两三个不同深度的浅水层叠在一起形成伪三维流场。如果你也在折腾类似的事一定记得先跑通一个256×256的封闭小池塘把参数和边界玩明白再上真实地理数据。真不是劝你保守是那个粉红色NaN我实在不想看你再经历一遍。