1. 从静态到动态为什么水闸放水效果是三维GIS的“硬骨头”在三维GIS和数字孪生领域展示一个水闸模型已经不是什么难事。Cesium作为Web端三维地球的标杆加载倾斜摄影、BIM模型、甚至带动画的glTF都有成熟的方案。但当我们想模拟“水闸放水”这个动态过程时挑战才真正开始。这不仅仅是让一个模型动起来而是要在一个三维地理环境中逼真地模拟出水流从静止到奔涌、水面从平静到湍急的复杂物理现象。很多项目在做到这一步时往往会陷入两个极端要么用一段预渲染的视频贴在地面上效果僵硬且无法交互要么尝试用粒子系统模拟结果要么性能爆炸要么效果像一团烟雾完全没有水体的质感和流动感。水体的模拟尤其是带有明确流向和流速感的表面流动一直是实时三维渲染中的经典难题。它涉及到水面网格的动态变形、纹理的流动与混合、以及如何与三维地形无缝融合。我最近在一个智慧水利项目中就深度折腾了Cesium的水体效果实现。目标很明确在一个真实地理坐标的水闸处实现闸门开启后下游河道水面逐渐上升、水流加速、并形成可见表面流速矢量的效果。经过一系列方案选型、代码实现和性能调优最终找到了一套兼顾效果与性能的可行路径。这篇文章我就把这套从零到一的实现思路、核心代码以及踩过的坑毫无保留地分享出来。无论你是做数字孪生、智慧水务还是任何需要动态水体效果的Cesium项目相信都能从中获得直接的参考。2. 核心思路拆解不依赖粒子系统的“流体”模拟一开始我和大家一样首先想到的是Cesium的粒子系统ParticleSystem。但很快我就放弃了。原因有三第一粒子系统模拟大规模水面流动需要海量粒子性能开销无法承受第二粒子很难表现水面的连续性和反光质感第三粒子与地形的碰撞检测复杂难以实现水流沿地形坡向自然流动的效果。因此我们必须转换思路。核心目标不是模拟每一滴水而是模拟水面的“视觉表现”。这引导我们走向基于着色器Shader的解决方案。Cesium的材质系统Material和后期处理PostProcessStage为我们提供了强大的自定义能力。我的实现方案主要基于以下三个层的叠加2.1 动态水位与地形裁剪基底这是效果的基础。我们需要一个随着时间“长高”的水面并且这个水面要能精确地“淹没”下游河道地形。这里不能简单地用一个无限延伸的平面而必须是一个与真实地形高程相关的动态水平面。2.2 法线扰动与流动纹理表面细节静态的水面看起来像塑料。我们需要通过动态的法线贴图来模拟水波的起伏、涟漪和反光。更重要的是要让这些波纹沿着特定的方向水流方向流动从而产生强烈的流向感。2.3 流速矢量与泡沫轨迹效果增强这是体现“流速”的关键。我们需要在水面上绘制出代表流速大小和方向的动态图案比如拉丝状的条纹、聚集在障碍物后的泡沫等这些视觉元素能极大地增强水流的动感。整个技术栈将围绕Cesium的CustomShader、Material以及ClassificationPrimitive等API展开。下面我们就进入具体的实现环节。3. 构建动态水域基底ClassificationPrimitive与自定义着色器首先我们需要创建一块能够随时间和空间变化的水域。这里我选择了ClassificationPrimitive而不是RectangleGeometry或PolygonGeometry。因为ClassificationPrimitive有一个至关重要的特性它可以将自定义的几何体比如我们的水面分类渲染到地形或3D Tiles之上实现完美的“淹没”效果水体会自动在地形凹陷处显示在高处被裁剪。3.1 创建水面几何体我们首先需要定义水面的覆盖范围。通常这是一个由水闸下游河道边界坐标构成的多边形。// 假设 waterAreaPositions 是定义水域范围的经纬度坐标数组 const waterAreaPositions Cesium.Cartesian3.fromDegreesArray([ 120.1, 30.0, 120.2, 30.0, 120.2, 29.9, 120.1, 29.9, // ... 更多点以闭合多边形 ]); // 创建多边形几何体并挤压成一个很薄的体块用于分类渲染 const waterPolygon new Cesium.PolygonGeometry({ polygonHierarchy: new Cesium.PolygonHierarchy(waterAreaPositions), extrudedHeight: 0, // 不挤压高度我们只需要表面 height: 0, // 初始高度后续在着色器中动态控制 vertexFormat: Cesium.VertexFormat.POSITION_AND_NORMAL // 需要法线信息 });3.2 使用ClassificationPrimitive并应用CustomShader关键步骤是将这个几何体以分类模式添加到场景中并附上我们的自定义着色器。着色器将负责计算动态水位。const waterSurfacePrimitive new Cesium.ClassificationPrimitive({ geometryInstances: new Cesium.GeometryInstance({ geometry: waterPolygon, id: dynamicWaterSurface }), appearance: new Cesium.MaterialAppearance({ material: new Cesium.Material({ fabric: { // 这里先留空我们主要靠CustomShader type: Water } }), translucent: true, closed: false }), // 关键声明为分类类型与地形交互 classificationType: Cesium.ClassificationType.TERRAIN }); // 添加自定义着色器 const customShader new Cesium.CustomShader({ // 定义着色器需要的统一变量Uniforms uniforms: { u_time: { type: Cesium.UniformType.FLOAT, value: 0.0 }, u_baseHeight: { type: Cesium.UniformType.FLOAT, value: 5.0 // 水闸下游河床基础高程米 }, u_waterLevel: { type: Cesium.UniformType.FLOAT, value: 5.0 // 初始水位米等于基础高程即没水 }, u_flowDirection: { type: Cesium.UniformType.VEC2, value: new Cesium.Cartesian2(1.0, 0.0) // 水流方向向量东 } }, // 顶点着色器动态计算每个顶点的高度水位 vertexShaderText: void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) { // 获取模型坐标下的原始高度相对于u_baseHeight float baseVertexHeight vsInput.attributes.positionMC.z; // 模拟放水过程水位从u_baseHeight开始上升 // 这里用一个简单的时间函数模拟实际项目可根据闸门开度等数据驱动 float timeFactor clamp(u_time * 0.1, 0.0, 1.0); // u_time从0开始递增 float targetWaterLevel u_baseHeight 3.0; // 目标水位上升3米 float currentWaterLevel mix(u_baseHeight, targetWaterLevel, timeFactor); // 关键将当前水位赋值给u_waterLevel供片元着色器使用 // 注意这里需要将uniform的值写回但Cesium CustomShader中uniform是只读的。 // 因此我们需要通过varying变量将水位信息传递给片元着色器。 // 更常见的做法是将水位计算放在CPU端每帧更新uniform的value。 // 此处为演示逻辑实际更新在JavaScript中完成见后文。 } , // 片元着色器计算颜色和透明度 fragmentShaderText: void fragmentMain(FragmentInput fsInput, inout czm_modelMaterialOutput materialOutput) { // 获取世界坐标下的片段高度 float fragmentHeight fsInput.attributes.positionWC.z; // 如果片段高度低于当前水位则渲染为水 if (fragmentHeight u_waterLevel) { // 基础水色深蓝色 vec3 waterColor vec3(0.0, 0.2, 0.5); // 根据水深调整透明度越深越不透明 float depth u_waterLevel - fragmentHeight; float alpha clamp(depth * 0.5, 0.3, 0.8); materialOutput.color vec4(waterColor, alpha); } else { // 高于水位部分完全透明不渲染 discard; } } }); // 将自定义着色器附加到图元 waterSurfacePrimitive.customShader customShader; viewer.scene.primitives.add(waterSurfacePrimitive);3.3 动态更新水位与时间上面的着色器逻辑中水位的动态变化需要由JavaScript端每帧驱动。我们需要一个更新循环。let startTime Cesium.JulianDate.now(); const waterLevelUniform customShader.uniforms.u_waterLevel; const timeUniform customShader.uniforms.u_time; viewer.scene.preUpdate.addEventListener(function(scene, time) { const elapsedSeconds Cesium.JulianDate.secondsDifference(time, startTime); // 更新着色器中的时间 timeUniform.value elapsedSeconds; // 根据时间计算当前水位示例放水持续30秒 const fillDuration 30.0; const progress Cesium.Math.clamp(elapsedSeconds / fillDuration, 0.0, 1.0); const baseHeight 5.0; const targetHeight 8.0; const currentWaterLevel baseHeight (targetHeight - baseHeight) * progress; // 更新水位uniform waterLevelUniform.value currentWaterLevel; });通过以上步骤我们得到了一个能随时间推移从河床底部逐渐上涨并完美贴合地形起伏的动态水域基底。但这只是一个蓝色半透明的平面缺乏水的质感和流动感。接下来我们为其注入灵魂——动态波纹。4. 模拟水面流动多层法线贴图与流向控制要让水面“流动”起来核心是让水面的法线图影响反光和凹凸感沿着水流方向移动。单一层的纹理移动会显得很假我们采用多层、不同速度、不同尺度的法线图叠加模拟出自然水流的复杂波光。4.1 准备流动纹理你需要准备至少两张建议四张无缝平铺的法线贴图。这些贴图可以是程序化生成的噪声图也可以是拍摄的真实水波法线图。将它们放入项目的素材目录。4.2 在CustomShader中实现多层纹理流动我们修改之前的fragmentShaderText加入复杂的纹理采样和混合逻辑。fragmentShaderText: // 声明纹理采样器 uniform sampler2D u_normalMap1; uniform sampler2D u_normalMap2; uniform vec2 u_flowDirection; // 归一化的流向向量 uniform float u_speed; // 流速系数 void fragmentMain(FragmentInput fsInput, inout czm_modelMaterialOutput materialOutput) { float fragmentHeight fsInput.attributes.positionWC.z; if (fragmentHeight u_waterLevel) { discard; } // 计算深度用于颜色和透明度 float depth u_waterLevel - fragmentHeight; vec3 baseWaterColor vec3(0.0, 0.25, 0.5); float alpha clamp(depth * 0.4, 0.4, 0.85); // **关键计算基于世界坐标的UV** // 使用世界坐标的X和Y来生成UV确保纹理固定在地球表面而不是随着相机移动 vec2 worldUV vec2(fsInput.attributes.positionWC.x, fsInput.attributes.positionWC.y); // 缩放系数控制纹理密度 float scale 0.0005; // 这个值需要根据你的场景尺度调整 vec2 uv worldUV * scale; // **模拟流动让UV随时间沿流向偏移** float time u_time; vec2 flowDir normalize(u_flowDirection); // 使用不同的速度和缩放制造层次感 vec2 uvLayer1 uv flowDir * time * u_speed * 0.8; vec2 uvLayer2 uv * 1.5 flowDir * time * u_speed * 1.2; vec2 uvLayer3 uv * 0.7 - flowDir * time * u_speed * 0.5; // 反向一层增加混乱度 // 采样法线贴图这里假设法线图是RGB编码的法线向量 vec3 normal1 texture(u_normalMap1, uvLayer1).rgb * 2.0 - 1.0; vec3 normal2 texture(u_normalMap2, uvLayer2).rgb * 2.0 - 1.0; // 可以采样更多层... // 混合多层法线 vec3 blendedNormal normalize(normal1 * 0.6 normal2 * 0.4); // **计算基于法线的镜面反射高光简化版** // 获取视线向量从片段到相机 vec3 viewVector normalize(czm_cameraPositionWC - fsInput.attributes.positionWC); // 假设主要光源方向这里简化使用太阳光方向 vec3 lightDirection normalize(vec3(1.0, 1.0, 1.0)); // 计算反射向量 vec3 reflectVector reflect(-lightDirection, blendedNormal); float specular pow(max(dot(viewVector, reflectVector), 0.0), 32.0); // **计算基于法线的漫反射** float diffuse max(dot(blendedNormal, lightDirection), 0.2); // 0.2是环境光 // 组合最终颜色基础色 漫反射影响 高光 vec3 finalColor baseWaterColor * diffuse vec3(0.8) * specular * 0.5; // 根据深度调整颜色越深颜色越暗 finalColor * clamp(depth * 0.3, 0.7, 1.0); materialOutput.color vec4(finalColor, alpha); } 4.3 在JavaScript中配置纹理和Uniform我们需要将法线贴图加载为Uniform并设置流速等参数。// 加载法线贴图 const normalMap1 new Cesium.TextureUniform({ url: ./assets/water_normal1.jpg }); const normalMap2 new Cesium.TextureUniform({ url: ./assets/water_normal2.jpg }); // 更新CustomShader的uniforms定义 customShader.uniforms.u_normalMap1 { type: Cesium.UniformType.SAMPLER_2D, value: normalMap1 }; customShader.uniforms.u_normalMap2 { type: Cesium.UniformType.SAMPLER_2D, value: normalMap2 }; customShader.uniforms.u_speed { type: Cesium.UniformType.FLOAT, value: 0.05 // 流速系数调整此值改变波纹移动快慢 }; customShader.uniforms.u_flowDirection { type: Cesium.UniformType.VEC2, value: new Cesium.Cartesian2(0.8, 0.6) // 示例流向为东北方向 };注意UV计算的坑。最初我直接使用模型坐标或纹理坐标结果水流纹理会随着相机旋转而“滑动”非常不真实。必须使用世界坐标positionWC的X和Y分量来生成UV这样才能让水流纹理“粘”在地球表面上模拟真实水流相对于地面的运动。至此我们已经有了一个具有动态波纹、反光并能沿指定方向流动的逼真水面。但还缺少最后一步直观显示流速和流向。5. 绘制流速矢量后期处理与动态线绘制为了直观展示水面流速我尝试了两种方案各有优劣。5.1 方案一基于后期处理的流速条纹此方案在屏幕空间进行性能好效果统一但缺乏三维深度感。原理是添加一个全屏后期处理阶段识别出水体区域通过深度或颜色然后在水面区域叠加一层沿着流向、具有速度感的条纹纹理。const velocityStripesShader uniform sampler2D colorTexture; uniform sampler2D depthTexture; uniform float u_time; uniform vec2 u_flowDir; uniform float u_stripeDensity; in vec2 v_textureCoordinates; out vec4 fragColor; void main() { vec4 color texture(colorTexture, v_textureCoordinates); float depth czm_readDepth(depthTexture, v_textureCoordinates); // 简单判断如果像素是半透明蓝色我们的水则添加条纹 bool isWater color.a 0.3 color.b color.r color.b color.g * 1.2; if (isWater) { // 计算屏幕空间下的流动UV vec2 screenPos v_textureCoordinates; // 让条纹沿着流向移动 float flowOffset dot(screenPos, u_flowDir) * u_stripeDensity u_time * 0.5; float stripe sin(flowOffset * 20.0) * 0.5 0.5; // 生成正弦波条纹 stripe pow(stripe, 3.0); // 锐化条纹 // 将条纹混合到原颜色上白色条纹 vec3 stripeColor mix(color.rgb, vec3(1.0), stripe * 0.3); fragColor vec4(stripeColor, color.a); } else { fragColor color; } } ; const velocityStripesStage new Cesium.PostProcessStage({ fragmentShader: velocityStripesShader, uniforms: { u_flowDir: new Cesium.Cartesian2(0.8, 0.6), u_stripeDensity: 50.0 } }); viewer.scene.postProcessStages.add(velocityStripesStage);5.2 方案二基于PolylineGlow的动态流线此方案在三维空间绘制有深度感可随水面起伏但性能消耗随流线数量增加而增大且需要解决流线与水面贴合的问题。思路是在水面上方沿着水流方向生成一系列动态的、发光的线段PolylineGlowMaterialProperty并让这些线段的位置随着水位上涨而更新。// 假设我们有一系列定义流线路径的点这些点的高程需要根据水位动态更新 const flowLinePositions [/* ... 一系列Cartesian3点 ... */]; const flowLineEntity viewer.entities.add({ polyline: { positions: new Cesium.CallbackProperty(function(time, result) { // 动态计算每个点的高度地形采样高度 当前水位 const updatedPositions []; for (let i 0; i flowLinePositions.length; i) { const pos flowLinePositions[i]; // 获取该点的地形高度这是一个异步操作简化处理 // 实际项目中需要预先采样或使用地形Provider同步获取 const terrainHeight sampledTerrainHeightArray[i]; const waterSurfaceHeight terrainHeight currentWaterLevel; // currentWaterLevel来自之前的计算 const updatedPos Cesium.Cartesian3.fromRadians( Cesium.Cartographic.fromCartesian(pos).longitude, Cesium.Cartographic.fromCartesian(pos).latitude, waterSurfaceHeight ); updatedPositions.push(updatedPos); } return updatedPositions; }, false), // 设置为true可每帧回调性能开销大谨慎使用 width: 8.0, material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.2, color: Cesium.Color.CYAN.withAlpha(0.7) }) } }); // 为了让流线有“流动”动画可以动态改变其材质属性比如颜色脉冲 let glowIntensity 0.5; viewer.scene.preUpdate.addEventListener(function() { glowIntensity 0.3 Math.sin(Date.now() * 0.002) * 0.2; flowLineEntity.polyline.material.color Cesium.Color.CYAN.withAlpha(glowIntensity); });实操心得方案一后期处理适用于全局性的、风格化的流速展示性能最优。方案二三维流线适用于需要精确定位、且水流路径固定的场景但必须处理好流线高程与动态水面的同步否则会出现流线浮在空中或沉入水下的穿帮现象。在我的项目中我最终选择了方案一因为它更稳定且与复杂地形的兼容性更好。6. 性能优化与实战避坑指南将上述所有效果组合后在低端设备或大范围水域上性能压力会显现。以下是几个关键的优化点和踩坑记录。6.1 着色器精度与指令数优化Cesium的CustomShader运行在移动端和桌面端必须考虑兼容性。避免分支片元着色器中的if-else分支在某些GPU上性能很差。我最初用if (fragmentHeight u_waterLevel)来做裁剪后来改为使用clamp和mix函数来平滑处理边缘虽然牺牲了一点精确度但性能提升显著。减少纹理采样每多采样一次纹理开销都很大。我最初用了四层法线贴图后来发现两层精心调整的贴图叠加效果已经足够性能却好了很多。简化计算将一些可以在CPU端预先计算好的值如混合权重、最终流向向量作为uniform传入而不是在着色器内实时计算。6.2 水域网格简化用于ClassificationPrimitive的水面多边形几何体其顶点数量直接影响性能。不要用过高精度的多边形河道边界通常由GIS数据提供可能包含成千上万个点。在添加到Cesium前必须使用道格拉斯-普克算法或其他简化算法对多边形进行简化在视觉可接受的范围内大幅减少顶点数。对于长河道考虑分块如果水域范围极长可以将其分割为多个较小的ClassificationPrimitive。这样不仅有利于性能还可以对不同河段设置不同的流速、水位参数模拟更真实的水文情况。6.3 动态更新的频率控制我们的水位u_waterLevel和流速u_speeduniform每帧都在更新。如果数据源如物联网传感器的更新频率很低如每秒1次那么完全没必要每帧都更新着色器uniform。可以设置一个脏检查标志只有当数据真正变化时再更新减少不必要的GPU状态切换。6.4 与3D Tiles模型的交互如果水闸本身是一个精细的3D Tiles模型BIM水流需要与闸门、桥墩等物体交互。淹没效果ClassificationPrimitive的classificationType设置为Cesium.ClassificationType.BOTH可以让水面同时分类到地形和3D Tiles上实现水流淹没建筑底部、绕过桥墩的效果。碰撞与绕流要实现水流遇到障碍物自动分流的效果单纯靠着色器很难。我们的做法是在障碍物周围预先定义好“流场”向量图一个RGBA纹理R和G通道存储流向向量在着色器中采样这个流场图来动态改变u_flowDirection从而实现局部的流向变化。这需要美术和程序的紧密配合。6.5 最大的坑深度冲突与Z-Fighting当水面几何体与地形表面高度完全相同时会产生严重的闪烁Z-Fighting。我们的水面是一个极薄的体块必须确保其高度略高于地形。解决方案在着色器中不要直接用fragmentHeight u_waterLevel判断而是用一个微小的偏移量Bias。float depthBias 0.05; // 5厘米偏移 if (fragmentHeight u_waterLevel - depthBias) { // 渲染水体核心部分 alpha 0.8; } else if (fragmentHeight u_waterLevel) { // 渲染水面与地形的交界处边缘使用更低的透明度混合 float edgeFactor (u_waterLevel - fragmentHeight) / depthBias; alpha 0.3 * edgeFactor; } else { discard; }这样处理既避免了闪烁又让水岸线有一个柔和的过渡看起来更自然。7. 效果集成与参数调试从“能用”到“逼真”把所有代码模块组装起来后距离逼真的效果还差最后一步细致的参数调试。这个过程没有捷径需要反复在场景中观察和调整。7.1 建立一个直观的调试面板强烈建议使用dat.GUI或Cesium自带的调试工具将关键参数暴露出来实时调整u_baseHeight(基础河床高程)u_waterLevel(当前水位)u_flowDirection.x/y(流向)u_speed(波纹速度)u_normalMap的缩放系数scale法线贴图的混合权重水体的基础色、透明度曲线高光强度、反射强度通过实时滑块调整这些参数你能快速理解每个参数对最终视觉效果的影响并找到最适合你场景的那组“魔法数字”。7.2 分阶段验证效果不要试图一次性调出完美效果。应该分阶段验证静态淹没先关闭所有流动效果只保留水位变化确保水域能正确覆盖地形。基础流动开启一层法线贴图流动调整流向和速度观察整体趋势是否正确。细节丰富加入第二层、第三层法线贴图调整它们的缩放和速度制造出大波套小波的层次感。特效叠加最后开启流速条纹或流线调整其密度和亮度使其成为点睛之笔而不是喧宾夺主。7.3 参考真实视频调试时在旁边播放一段真实的水闸放水视频进行对比是极其有效的方法。关注真实水流的几个特点水流中心流速快波纹细密靠近岸边流速慢常有回流和漩涡水面反光不是均匀的而是随着波浪破碎不断变化。尝试用你的参数去逼近这些观察到的现象。经过这样一套从原理分析、技术选型、代码实现到优化调试的完整流程最终在Cesium中呈现出的水闸放水效果就不再是一个简单的动画贴图而是一个与地理环境深度融合、拥有物理质感和动态交互可能的真实水流模拟。它能够响应数据驱动为智慧水利、应急演练、工程展示等应用场景提供强大的可视化支撑。