CesiumJS动态水面实现:从法线贴图到着色器编程的完整指南

发布时间:2026/8/12 15:04:25
CesiumJS动态水面实现:从法线贴图到着色器编程的完整指南 1. 从静态到动态为什么我们需要流动的水面在三维地理信息可视化领域CesiumJS 以其强大的三维地球渲染能力成为了众多开发者构建数字孪生、智慧城市和虚拟仿真应用的首选引擎。然而一个长期存在的挑战是如何让场景中的水体“活”起来默认的 Cesium 水体无论是Cesium.Water材质还是简单的蓝色平面往往呈现一种静态、呆板的观感缺乏真实水体应有的动态波纹、光影流动和表面细节。这种静态感在模拟河流、湖泊、海洋甚至人工水渠时会极大地削弱场景的真实感和沉浸感。最近在社区和搜索引擎中“Cesium 动态流动水面”成为了一个高频热词这背后反映的正是开发者们对提升场景逼真度的迫切需求。无论是模拟一条蜿蜒的河流还是展现港口码头的海浪亦或是为智慧水利项目创建动态的水流态势一个能够实时流动、反射环境光、具备波纹细节的水面是项目从“能用”到“好用”甚至“惊艳”的关键一步。实现动态流动水面其核心价值远不止于视觉美化。在防洪防汛模拟中动态的水流方向可以直观展示洪水演进路径在船舶航行仿真中水面的波纹与浪花能增强运动感知在环境监测应用中不同流速、颜色的水体动态变化可以更有效地传达污染扩散等信息。因此掌握这项技术不仅仅是学习一个“特效”更是解锁一系列高级三维地理应用场景的钥匙。2. 核心原理拆解动态水面的“魔法”是如何炼成的在开始写代码之前我们必须理解 Cesium 中实现动态水面效果的底层逻辑。这并非某种单一的“黑科技”而是多种图形学技术的巧妙结合。理解了这些你才能灵活调整参数甚至创造属于自己的水面风格。2.1 基石Cesium 的材质系统与 Primitive APICesium 渲染一切非地形、非模型的几何体如水面、广告牌、多边形主要依赖于Primitive和GeometryInstance。对于自定义水面我们通常使用Primitive配合GeometryInstance来创建一个覆盖特定区域的平面几何体。这个几何体的外观则由Material材质来定义。材质本质上是一段运行在 GPU 上的着色器程序Shader。Cesium 提供了一些内置材质如ColorMaterialProperty,ImageMaterialProperty但对于复杂效果我们需要使用Material类的fabric属性来自定义着色器。fabric是一个强大的 JSON 对象它允许我们定义材质所需的纹理Texture、统一变量Uniforms以及最重要的——着色器源码Source。动态效果的关键就在于这些uniforms统一变量。它们是 CPU 端JavaScript传递给 GPU 端Shader的变量在单次绘制调用中保持不变但可以在不同帧之间更新。通过每帧修改这些uniforms例如让一个代表时间的变量持续增加我们就能在着色器中驱动纹理坐标的偏移、颜色的变化从而产生“动态”的视觉感受。2.2 灵魂技法法线贴图与 UV 动画这是实现水面波纹细节和流动感的核心技术。法线贴图Normal Map一张特殊的纹理它不存储颜色信息而是存储每个像素点的法线向量X, Y, Z。法线决定了光线照射到该像素时的反射方向从而模拟出凹凸不平的细节。对于水面我们使用一张包含细小波纹图案的法线贴图。即使水面几何体本身是一个绝对平坦的平面贴上法线贴图后在光照下就会呈现出丰富的波纹细节计算开销却远低于使用真实几何体去建模这些波纹。UV 动画纹理坐标U, V决定了纹理图片如何映射到几何体表面。UV动画就是让这些坐标随着时间变化。例如我们让法线贴图的 U 坐标每帧增加一个很小的值那么贴图就会沿着水平方向“滑动”映射到水面上的波纹也就看起来在朝一个方向流动。通过组合多张法线贴图以不同速度、不同方向进行 UV 动画可以创造出非常复杂、自然的水面流动效果避免出现单调的重复图案。2.3 点睛之笔镜面反射、菲涅尔效应与颜色混合一个逼真的水面还需要以下效果镜面反射Specular水面像镜子一样反射环境光在 Cesium 中通常是天空盒skyBox和场景中的其他物体。在着色器中我们通过计算视线向量与法线向量的反射向量来采样天空盒纹理模拟反射。动态的法线来自法线贴图会让反射的图像产生扭曲这正是水波荡漾的感觉。菲涅尔效应Fresnel Effect这是一个物理现象观察者视线与水面法线的夹角越小即平视水面反射越强夹角越大即俯视水面折射或透见水底越强。在着色器中我们通过计算dot(viewVector, normal)来模拟这一效应并用它来混合反射颜色和水的底色Base Color使得水面在边缘处反射强烈中心区域则能透出一些水体的颜色如蓝绿色。颜色混合最终的水面颜色通常是多层效果的混合水底色 反射色 * 菲涅尔系数 高光色。我们还可以加入一张深度图或根据相机高度来混合岸边浅水区的颜色如浅绿色、泥沙色让水体与岸边的过渡更加自然。3. 实战构建一步步创建你的动态水面材质理论已经足够现在让我们动手从零开始构建一个适用于河流或湖泊的动态水面材质。我们将创建一个Cesium.Material对象并将其应用到一个PolygonGeometry上。3.1 第一步准备核心纹理——法线贴图你需要一张高质量的法线贴图。可以在一些免费的纹理网站如 textures.com, polyhaven.com搜索 “water normal map”。通常我们会准备两张不同尺度和细节的法线贴图例如一张大波纹一张小涟漪混合使用以增加细节层次。假设我们将这两张图下载并放置在项目的assets/textures/目录下命名为water_normal_01.jpg和water_normal_02.jpg。在 Cesium 中我们需要创建纹理对象。// 假设 viewer 已创建 var viewer new Cesium.Viewer(cesiumContainer); // 创建纹理统一变量 var normalMap1 new Cesium.TextureUniform({ url: ./assets/textures/water_normal_01.jpg }); var normalMap2 new Cesium.TextureUniform({ url: ./assets/textures/water_normal_02.jpg });注意纹理图片的尺寸最好是2的幂次方如 256x256, 512x512并且确保图片本身没有透明通道除非你需要否则可能会引发意外的混合。对于法线贴图通常使用 PNG 或 JPEG 格式即可。3.2 第二步编写自定义材质的 Fabric 配置这是最核心的一步。我们将创建一个名为DynamicWaterMaterial的自定义材质。// 定义动态水面材质的 Fabric 配置 var dynamicWaterFabric { type: DynamicWater, uniforms: { // 两张法线贴图 normalMap1: normalMap1, normalMap2: normalMap2, // 水流速度与方向 (vec2: [xSpeed, ySpeed]) flowDirection1: new Cesium.Cartesian2(0.1, 0.0), // 第一层法线流动方向 flowDirection2: new Cesium.Cartesian2(0.03, 0.02), // 第二层法线流动方向速度慢一些方向略有不同 // 时间变量由Cesium自动每帧更新 time: 0.0, // 基础水色 (RGBA) baseColor: new Cesium.Color(0.0, 0.3, 0.6, 0.8), // 反射强度系数 reflectionStrength: 0.5, // 菲涅尔效应强度系数 fresnelPower: 3.0, // 波纹凹凸强度 normalScale: new Cesium.Cartesian2(0.5, 0.5) }, source: // 着色器源码 (GLSL ES 1.0即Cesium使用的版本) czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); // 获取纹理坐标和法线 vec2 st materialInput.st; // 模型本身的UV坐标 vec3 normal materialInput.normalEC; // 眼坐标下的几何法线 // 计算随时间变化的UV偏移 vec2 flow1 st - u_flowDirection1 * u_time; vec2 flow2 st - u_flowDirection2 * u_time; // 采样两张法线贴图并将颜色值从[0,1]映射到法线向量的[-1,1]范围 vec3 normalTex1 texture2D(u_normalMap1, flow1).rgb * 2.0 - 1.0; vec3 normalTex2 texture2D(u_normalMap2, flow2).rgb * 2.0 - 1.0; // 混合两张法线贴图并应用强度缩放 vec3 blendedNormal normalize(normalTex1 normalTex2); blendedNormal.xy * u_normalScale; blendedNormal normalize(blendedNormal); // 将切线空间法线转换到眼坐标系简化处理假设水面是平的使用几何法线作为上向量 // 这里是一个简化版本。更精确的做法需要传入切线和副切线向量。 vec3 newNormalEC normalize(materialInput.normalEC blendedNormal.x * czm_tangent blendedNormal.y * czm_bitangent); // 计算视线向量从表面点到相机 vec3 positionToEyeEC -materialInput.positionToEyeEC; vec3 viewVector normalize(positionToEyeEC); // 计算反射向量并采样天空盒环境贴图模拟反射 vec3 reflectionVector reflect(-viewVector, newNormalEC); vec4 reflectionColor czm_sampleCube(czm_environmentMap, reflectionVector); // 计算菲涅尔系数 float fresnel pow(1.0 - max(dot(viewVector, newNormalEC), 0.0), u_fresnelPower); fresnel clamp(fresnel, 0.0, 1.0); // 混合颜色基础色 反射色 * (菲涅尔系数 * 反射强度) vec4 waterBaseColor vec4(u_baseColor.rgb, u_baseColor.a); vec4 finalColor mix(waterBaseColor, reflectionColor, fresnel * u_reflectionStrength); // 可以在此处添加基于深度或高度的颜色渐变模拟岸边浅水效果 // 例如float depthFactor clamp(materialInput.height / 10.0, 0.0, 1.0); // finalColor.rgb mix(vec3(0.2, 0.5, 0.2), finalColor.rgb, depthFactor); material.diffuse finalColor.rgb; material.alpha finalColor.a; // 使用扰动后的法线影响光照计算 material.normal newNormalEC; return material; } };代码关键点解析Uniforms我们定义了控制水面外观的所有“旋钮”。flowDirection控制流动方向和速度time是驱动动画的核心。双法线混合使用两张法线贴图并以不同速度流动能有效打破重复感产生更自然的湍流效果。法线变换blendedNormal.xy * u_normalScale;这行代码用于控制波纹的凹凸强度。值越大波纹看起来越陡峭、剧烈。反射采样czm_sampleCube(czm_environmentMap, ...)是 Cesium 提供的函数用于采样当前场景的天空盒环境贴图这是实现环境反射的关键。菲涅尔计算pow(1.0 - max(dot(...), 0.0), u_fresnelPower)是经典的菲涅尔近似公式。fresnelPower值越大反射与透射的过渡区域越窄。3.3 第三步创建材质、几何体并添加到场景现在我们将材质实例化并将其应用到一个代表水面的多边形上。// 1. 注册并创建自定义材质 Cesium.Material._materialCache.addMaterial(DynamicWater, { fabric: dynamicWaterFabric }); var waterMaterial new Cesium.Material({ fabric: { type: DynamicWater, uniforms: { normalMap1: ./assets/textures/water_normal_01.jpg, normalMap2: ./assets/textures/water_normal_02.jpg, flowDirection1: new Cesium.Cartesian2(0.08, 0.0), // 主要沿X轴流动 flowDirection2: new Cesium.Cartesian2(0.02, 0.01), baseColor: new Cesium.Color(0.1, 0.4, 0.7, 0.85), reflectionStrength: 0.6, fresnelPower: 4.0, normalScale: new Cesium.Cartesian2(0.8, 0.8) } } }); // 2. 定义一个多边形区域例如一条河流 var riverPositions Cesium.Cartesian3.fromDegreesArray([ 116.3, 39.9, 116.4, 39.92, 116.5, 39.91, 116.6, 39.93, 116.7, 39.9 ]); // 3. 创建多边形几何体实例 var riverGeometry new Cesium.PolygonGeometry({ polygonHierarchy: new Cesium.PolygonHierarchy(riverPositions), height: 0, // 水面高度 extrudedHeight: -10, // 如果需要水体侧面如河岸可以设置挤压高度。纯水面通常设为undefined。 vertexFormat: Cesium.VertexFormat.POSITION_AND_NORMAL // 必须包含法线 }); // 4. 创建图元并应用材质 var riverPrimitive new Cesium.Primitive({ geometryInstances: new Cesium.GeometryInstance({ geometry: riverGeometry, id: myRiver // 可选ID便于后续拾取或管理 }), appearance: new Cesium.MaterialAppearance({ material: waterMaterial, translucent: true, // 水面通常是半透明的 closed: true // 几何体是否闭合 }), asynchronous: false // 同步加载简单示例用 }); // 5. 添加到场景 viewer.scene.primitives.add(riverPrimitive); // 6. 关键一步更新time uniform以实现动画 viewer.scene.preRender.addEventListener(function(scene, time) { var waterMaterial riverPrimitive.appearance.material; if (waterMaterial waterMaterial.uniforms) { // 获取自场景开始以来的秒数乘以一个系数控制全局速度 waterMaterial.uniforms.time Cesium.getTimestamp() * 0.001 * 0.05; // 0.05为速度系数 } });实操心得与避坑指南vertexFormat至关重要创建PolygonGeometry时必须指定vertexFormat: Cesium.VertexFormat.POSITION_AND_NORMAL。因为我们的着色器需要法线信息来计算光照和反射。如果只传POSITION法线相关计算会出错水面可能变黑或显示异常。时间更新循环动画的灵魂在于preRender事件监听器中不断更新timeuniform。Cesium.getTimestamp() * 0.001将毫秒转换为秒。乘以一个系数如0.05可以方便地调节整个水面动画的快慢。性能考量每帧更新 uniform 并重绘水面对性能有影响。如果场景中有多个独立的水体可以考虑将它们合并到同一个Primitive的多个GeometryInstance中共享材质和着色器减少绘制调用。深度测试与排序水面是半透明的需要正确排序才能与其他透明物体如云、粒子正确混合。确保translucent: true并且 Cesium 会自动处理大部分排序问题。但在复杂场景中如果出现水面闪烁或遮挡错误可能需要调整depthTest相关属性。4. 进阶优化与效果调参让你的水面更真实基础流动效果实现后我们可以通过调整参数和增加一些技巧来大幅提升真实感。4.1 流动方向与速度的艺术flowDirection不是随便设置的。对于河流你应该根据河流的走向来设定主要流动方向。可以通过计算多边形中心点的流向或者使用一个统一的全局方向。// 示例根据多边形坐标估算一个平均流向简化 function calculateFlowDirection(positions) { var totalVec new Cesium.Cartesian3(); for (var i 0; i positions.length - 1; i) { var vec Cesium.Cartesian3.subtract(positions[i1], positions[i], new Cesium.Cartesian3()); Cesium.Cartesian3.add(totalVec, vec, totalVec); } Cesium.Cartesian3.normalize(totalVec, totalVec); // 将3D流向转换为2D纹理流动方向忽略高度差 return new Cesium.Cartesian2(totalVec.x, totalVec.y); } // 应用估算的流向 waterMaterial.uniforms.flowDirection1 calculateFlowDirection(riverPositions); waterMaterial.uniforms.flowDirection2 Cesium.Cartesian2.multiplyByScalar(calculateFlowDirection(riverPositions), 0.3, new Cesium.Cartesian2()); // 第二层速度更慢速度系数第一层法线flowDirection1代表中大型波纹速度可以快一些如向量模长0.1。第二层法线flowDirection2代表细小涟漪速度应慢一些如0.03方向也可以略有偏差模拟风或复杂水流的影响。4.2 实现岸边浅水区与深度渐变真实的水体岸边颜色更浅可能透出水底泥沙。我们可以通过顶点高度或额外的一张深度图来实现。方法一基于顶点高度简单在着色器中materialInput.height或materialInput.positionWC.z可以获取世界坐标下的高度。我们可以假设一个水面基准高度然后计算当前点与水面的相对深度。// 在着色器source中uniforms里添加 u_waterSurfaceHeight 和 u_shallowColor float depth u_waterSurfaceHeight - materialInput.positionWC.z; // 简化计算假设水面平整 depth clamp(depth, 0.0, 10.0); // 限制深度范围 float depthFactor depth / 10.0; // 归一化 vec3 shallowColor u_shallowColor.rgb; vec3 deepColor finalColor.rgb; finalColor.rgb mix(shallowColor, deepColor, depthFactor);方法二使用深度纹理更精确但复杂Cesium 1.xx 版本后支持深度纹理。你可以渲染一张代表水面到水底地形的深度图然后在着色器中采样根据深度值混合颜色。这需要用到czm_depth相关函数和帧缓冲区纹理FBO实现复杂度较高但效果最好能完美贴合复杂地形岸线。4.3 添加高光与泡沫效果高光Specular Highlight在计算反射后可以额外计算一个基于光线方向和高光强度的镜面高光点让水面在特定角度下“波光粼粼”。// 假设主要光源方向如太阳光方向可以从Cesium内置变量czm_sunDirectionEC获取 vec3 lightDir normalize(czm_sunDirectionEC); vec3 halfVector normalize(lightDir viewVector); float specular pow(max(dot(newNormalEC, halfVector), 0.0), 64.0); // 64是高光指数 vec3 specularColor specular * vec3(1.0, 1.0, 0.9) * 0.5; // 淡黄色高光 finalColor.rgb specularColor;泡沫纹理可以准备一张泡沫噪声图根据水流速度flowDirection的大小或水面坡度法线变化率来动态混合到最终颜色上模拟急流、浪花或岸边的泡沫。这通常需要额外的纹理采样和混合计算。4.4 性能调优LOD与实例化当水面覆盖区域极大如海洋时全精度渲染每个像素的复杂着色器是昂贵的。细节层次LOD可以为水面创建多个几何体层级。当相机远离时使用更粗糙的多边形和更简单的着色器例如只保留一层法线动画和基础颜色。这可以通过创建多个不同精度的Primitive并在scene.preRender中根据相机距离切换来实现。实例化Instancing如果你的场景中有大量形状相同但位置不同的水面如多个规则的水池务必使用GeometryInstance的instances属性进行实例化渲染这能极大减少 GPU 的绘制调用开销。5. 常见问题排查与调试技巧即使按照步骤操作你也可能会遇到水面不显示、全黑、闪烁或不流动的问题。以下是常见的排查清单。5.1 水面完全不显示或全黑检查控制台错误打开浏览器开发者工具F12查看 Console 是否有 WebGL 编译错误或纹理加载失败提示。着色器语法错误是最常见的原因。验证几何体位置和高度确保你的多边形坐标正确且height设置合理。如果水面被地形或其它物体压在下面自然看不到。可以暂时关闭地形viewer.terrainProvider new Cesium.EllipsoidTerrainProvider();进行测试。检查材质透明度确认appearance的translucent属性为true。如果为false半透明的baseColor.alpha可能无效。简化着色器测试将复杂的着色器源码替换为一个最简单的版本只输出固定颜色如material.diffuse vec3(0.0, 0.0, 1.0);。如果能显示蓝色说明几何体和基本材质流程没问题问题出在着色器逻辑上。再逐步添加法线、反射等计算定位出错步骤。检查法线贴图路径和格式确保纹理URL正确且服务器允许跨域CORS。纹理加载失败会导致采样结果为黑色或白色影响最终颜色。可以在uniforms中暂时将法线贴图替换为一个纯色纹理进行测试。5.2 水面有颜色但不流动确认timeuniform 在更新在preRender事件监听器中打印waterMaterial.uniforms.time的值看它是否每帧都在递增。检查flowDirection值是否过小如果flowDirection的 x 和 y 值太小如 0.001乘以time后纹理坐标偏移量可能在一段时间内都小于一个像素肉眼难以察觉。尝试将其增大到 0.05 或 0.1 看看。验证 UV 动画计算在着色器中可以暂时将最终颜色输出为flow1.x或fract(flow1.x)取小数部分如果能看到规律的颜色条纹说明 UV 在变化问题可能出在法线贴图采样或法线变换上。5.3 水面闪烁Z-fighting或与地形穿插深度冲突Z-fighting当水面几何体与地形表面高度完全相同时GPU 深度测试会出现精度错误导致交替显示。解决方法是将水面高度略微抬高一点点如height: 0.01。地形穿插如果地形崎岖平坦的水面多边形会与地形交叉。这时需要更复杂的“水覆盖”算法或者使用ClippingPlane裁剪掉穿帮部分。对于简单场景确保水面高度高于地形最高点即可。调整渲染顺序尝试设置图元的depthTestEnabled和depthFail属性。有时关闭深度写入但开启深度测试可以解决一些透明物体的排序问题但这需要根据具体场景试验。5.4 反射失真或缺失环境贴图问题Cesium 的czm_environmentMap默认使用天空盒。确保你的场景设置了天空盒viewer.scene.skyBox或者开启了基于物理的大气渲染viewer.scene.globe.enableLighting true否则反射的内容可能是空的。法线计算错误反射向量严重依赖于法线。如果法线贴图采样或切线空间转换出错法线方向错误会导致反射图像扭曲到奇怪的方向。检查法线贴图是否是“切线空间”法线贴图并确认czm_tangent和czm_bitangent是否在顶点数据中存在这依赖于VertexFormat。简化调试将反射计算部分注释掉直接输出法线颜色material.diffuse blendedNormal * 0.5 0.5;观察法线贴图是否正确显示为彩色凹凸图案。我个人在多个项目中实践这套流程后发现最大的挑战往往不是着色器本身而是与 Cesium 庞大场景的集成与性能平衡。从一张静态的蓝色多边形到一个能随河道方向流动、在阳光下波光粼粼、在岸边颜色渐变的智能水面每一步调整都让场景的叙事能力更强。记住参数没有绝对的最优值最好的效果是在你的具体场景中通过反复微调flowDirection、normalScale、fresnelPower这些“旋钮”来达到的。不妨多花些时间观察不同时间Cesium 的日照角度和不同视角下你水面的表现那才是检验效果的唯一标准。