最近在做一个三维地理可视化项目需要模拟海洋、河流等动态流体效果尝试了多种方案后发现基于 Cesium 和 WebGL 原生能力实现的流体模拟效果和性能都远超预期。网上关于 Cesium 流体模拟的完整教程不多资料也比较零散。本文将系统拆解如何利用 Cesium 的PrimitiveAPI、DrawCommand以及借鉴 Shadertoy 的思路实现一个高性能的流体模拟效果并提供完整的开源代码和避坑指南。无论你是想为三维地图添加动态水面还是学习 WebGL 在 Cesium 中的高级用法这篇文章都能带你从原理到实战走通全流程。1. 流体模拟与 Cesium 可视化背景与核心概念在三维 GIS 和数字孪生应用中静态的水面贴图往往显得呆板无法真实反映海浪、河流、湖泊的动态美感。流体模拟Fluid Simulation技术正是为了解决这一问题而生。它通过计算机图形学方法模拟液体如水流、海浪、烟雾的物理运动和行为从而在视觉上产生逼真的动态效果。1.1 为什么选择在 Cesium 中实现流体模拟Cesium 是一个用于创建三维地球和地图的 JavaScript 库。它本身提供了丰富的地理空间数据可视化能力但对于高度定制化、需要极致性能的图形特效如复杂的粒子系统、流体动力学模拟其内置的 Entity 和 Primitive 模型有时会显得力不从心。这时直接使用其底层的 WebGL 渲染管线就成为更优选择。性能优势通过Primitive和DrawCommand直接与 WebGL 交互可以绕过 Cesium 高层 API 的一些开销实现更精细的渲染控制和更高的帧率这对于实时模拟计算量大的流体效果至关重要。灵活性你可以完全自定义顶点着色器Vertex Shader和片元着色器Fragment Shader实现从简单的噪声波动到基于物理的 Navier-Stokes 方程求解等各类复杂效果。集成性模拟效果可以直接放置在真实的地理坐标上与 Cesium 的地形、影像、矢量数据无缝融合创造出极具沉浸感的场景。1.2 核心组件Primitive, DrawCommand 与 Shadertoy 思想要实现自定义渲染我们需要理解 Cesium 中的几个核心概念Primitive图元 这是 Cesium 中用于自定义几何体渲染的主要接口。一个Primitive包含了几何Geometry、外观Appearance以及渲染状态RenderState等信息。它负责将你的自定义图形数据组织起来并提交给渲染引擎。DrawCommand绘制命令 这是更底层的渲染指令。每个DrawCommand封装了一次 WebGLdrawElements或drawArrays调用所需的所有信息包括着色器程序ShaderProgram、顶点数组VertexArray、统一变量Uniform等。通过构建和提交DrawCommand你可以获得最大的渲染控制权。在自定义Primitive的update方法中我们通常会创建并返回一个或多个DrawCommand。Shadertoy 思想 Shadertoy 是一个著名的在线社区专注于用片段着色器Fragment Shader创作视觉艺术。其核心思想是将渲染过程抽象为对每一个屏幕像素或纹理像素并行执行的函数。许多惊艳的流体、烟雾、火焰效果都源于此。我们可以借鉴这种“基于像素/纹理坐标计算”的思路在 Cesium 的片元着色器中实现流体的动态纹理生成。简单来说我们的技术路径是创建一个自定义的Primitive在其内部构建一个覆盖特定区域的几何平面作为流体表面然后编写一套 GLSL 着色器程序。在着色器中运用从 Shadertoy 学习到的算法如使用噪声函数、模拟流体运动方程根据时间和位置不断计算新的颜色与高度最终通过DrawCommand驱动 WebGL 绘制出动态的流体表面。2. 环境准备与项目搭建在开始编写代码前我们需要准备好开发环境。2.1 基础环境要求操作系统Windows 10/11, macOS 或 Linux 均可。现代浏览器Chrome, Firefox, Edge 或 Safari 的最新版本确保支持 WebGL 2.0以获得更好的性能和功能支持。你可以在浏览器中输入chrome://gpu或about:support查看 WebGL 支持状态。代码编辑器VS Code, WebStorm 等。Node.js 与 npm用于管理项目依赖和启动本地开发服务器。建议安装 Node.js 16 版本。2.2 创建项目并引入 Cesium我们从一个干净的 HTML 项目开始。初始化项目mkdir cesium-fluid-simulation cd cesium-fluid-simulation npm init -y安装 Cesium 你可以通过 npm 安装也可以直接下载 Cesium 的构建版本。这里使用 npm 方式方便管理。npm install cesium项目结构 创建以下目录和文件。cesium-fluid-simulation/ ├── node_modules/ ├── index.html ├── src/ │ ├── main.js │ └── FluidSimulationPrimitive.js ├── package.json └── (可选) 一个本地服务器配置文件如 vite.config.js 或使用 live-server基础 HTML 文件 (index.html)!DOCTYPE html html langen head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleCesium Fluid Simulation with Primitive DrawCommand/title script src./node_modules/cesium/Build/Cesium/Cesium.js/script link relstylesheet href./node_modules/cesium/Build/Cesium/Widgets/widgets.css style html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } /style /head body div idcesiumContainer/div script typemodule src./src/main.js/script /body /html使用开发服务器 由于 Cesium 需要加载本地模块直接打开file://协议下的 HTML 文件可能会遇到 CORS 问题。建议使用一个简单的开发服务器。使用 Vite(推荐速度快)npm install vite --save-dev创建vite.config.jsimport { defineConfig } from vite; export default defineConfig({ server: { open: true } });在package.json中添加脚本scripts: { dev: vite, build: vite build }运行npm run dev。使用live-servernpx live-server3. 核心原理与着色器设计流体模拟的核心在于着色器Shader。我们将创建一个包含顶点着色器和片元着色器的着色器程序。3.1 着色器程序结构我们的着色器程序需要完成以下任务顶点着色器接收顶点位置将其转换到裁剪空间Clip Space并可能将世界坐标或纹理坐标传递给片元着色器。片元着色器对于流体表面的每一个片段像素根据时间、位置等信息计算其最终颜色和可能的高度偏移用于模拟波浪。3.2 借鉴 Shadertoy 的噪声与波动算法Shadertoy 上大量的流体效果依赖于程序化噪声Procedural Noise来生成看似随机的、自然的波动。一个经典的方法是使用分形布朗运动Fractal Brownian Motion, fBM叠加多个不同频率和振幅的噪声如 Simplex 或 Perlin 噪声。下面是一个在 GLSL 中常用的经典噪声函数Simplex Noise 的简化变体和 fBM 实现示例我们将把它用在片元着色器中// 片段着色器代码片段 (FluidFS.glsl) // 注意这是一个简化示例实际完整的着色器需要与Cesium的Uniform系统结合。 // 伪随机数生成器 float hash(float n) { return fract(sin(n) * 1e4); } float hash(vec2 p) { return fract(1e4 * sin(17.0 * p.x p.y * 0.1) * (0.1 abs(sin(p.y * 13.0 p.x)))); } // 值噪声 float noise(vec2 x) { vec2 i floor(x); vec2 f fract(x); float a hash(i); float b hash(i vec2(1.0, 0.0)); float c hash(i vec2(0.0, 1.0)); float d hash(i vec2(1.0, 1.0)); vec2 u f * f * (3.0 - 2.0 * f); return mix(a, b, u.x) (c - a) * u.y * (1.0 - u.x) (d - b) * u.x * u.y; } // 分形布朗运动 (fBM) float fbm(vec2 p) { float value 0.0; float amplitude 0.5; float frequency 1.0; for (int i 0; i 6; i) { value amplitude * noise(p * frequency); frequency * 2.0; amplitude * 0.5; } return value; } void main() { // 获取当前片元的纹理坐标假设从0到1 vec2 uv v_textureCoordinates; // v_textureCoordinates 来自顶点着色器 // 加入时间变量使噪声动起来 float time czm_frameNumber * 0.01; // czm_frameNumber 是Cesium提供的Uniform // 计算流体高度偏移 vec2 p uv * 5.0; // 缩放噪声 p time * 0.1; // 使噪声随时间平移模拟流动 float height fbm(p); // 根据高度计算颜色例如深蓝到浅蓝再到白色的泡沫 vec3 waterColorDeep vec3(0.0, 0.1, 0.3); vec3 waterColorShallow vec3(0.2, 0.5, 0.8); vec3 foamColor vec3(1.0, 1.0, 1.0); float intensity clamp(height * 2.0, 0.0, 1.0); vec3 color; if (intensity 0.7) { color mix(waterColorDeep, waterColorShallow, intensity / 0.7); } else { color mix(waterColorShallow, foamColor, (intensity - 0.7) / 0.3); } // 输出最终颜色 out_FragColor vec4(color, 0.8); // 带一些透明度 }这个着色器会产生一个基于噪声的动态水面有深浅变化和简单的“泡沫”效果。czm_frameNumber是 Cesium 提供的内置 Uniform代表帧数非常适合用来驱动动画。4. 完整实战创建 FluidSimulationPrimitive现在我们将把着色器集成到 Cesium 中。核心是创建一个自定义的Primitive类。4.1 定义自定义 Primitive 类 (src/FluidSimulationPrimitive.js)这个类负责管理几何体、着色器程序、Uniform 并生成DrawCommand。// src/FluidSimulationPrimitive.js import { Primitive, GeometryInstance, RectangleGeometry, Rectangle, Ellipsoid, VertexFormat, Material, DrawCommand, Pass, RenderState, BlendingState, BlendingFunction, WebGLConstants, ShaderSource, ShaderProgram, UniformState, defined } from cesium; // 顶点着色器源码 const VS attribute vec3 position3DHigh; attribute vec3 position3DLow; attribute vec2 st; varying vec2 v_textureCoordinates; void main() { // 将经纬度坐标转换为裁剪空间坐标 vec3 p czm_translateRelativeToEye(position3DHigh, position3DLow); gl_Position czm_modelViewProjectionRelativeToEye * vec4(p, 1.0); // 传递纹理坐标给片段着色器 v_textureCoordinates st; } ; // 片段着色器源码 (整合了上一节的噪声算法) const FS varying vec2 v_textureCoordinates; uniform float u_time; float hash(float n) { return fract(sin(n) * 1e4); } float hash(vec2 p) { return fract(1e4 * sin(17.0 * p.x p.y * 0.1) * (0.1 abs(sin(p.y * 13.0 p.x)))); } float noise(vec2 x) { vec2 i floor(x); vec2 f fract(x); float a hash(i); float b hash(i vec2(1.0, 0.0)); float c hash(i vec2(0.0, 1.0)); float d hash(i vec2(1.0, 1.0)); vec2 u f * f * (3.0 - 2.0 * f); return mix(a, b, u.x) (c - a) * u.y * (1.0 - u.x) (d - b) * u.x * u.y; } float fbm(vec2 p) { float value 0.0; float amplitude 0.5; float frequency 1.0; for (int i 0; i 6; i) { value amplitude * noise(p * frequency); frequency * 2.0; amplitude * 0.5; } return value; } void main() { vec2 uv v_textureCoordinates; float time u_time; vec2 p uv * 5.0; p time * 0.05; float height fbm(p); vec3 waterColorDeep vec3(0.0, 0.1, 0.3); vec3 waterColorShallow vec3(0.2, 0.5, 0.8); vec3 foamColor vec3(1.0, 1.0, 1.0); float intensity clamp(height * 2.0, 0.0, 1.0); vec3 color; if (intensity 0.7) { color mix(waterColorDeep, waterColorShallow, intensity / 0.7); } else { color mix(waterColorShallow, foamColor, (intensity - 0.7) / 0.3); } gl_FragColor vec4(color, 0.85); } ; class FluidSimulationPrimitive { constructor(options) { options options || {}; this._rectangle options.rectangle || Rectangle.fromDegrees(-100.0, 20.0, -80.0, 40.0); // 默认覆盖一片区域 this._show defined(options.show) ? options.show : true; this._command undefined; this._shaderProgram undefined; this._uniformMap undefined; this._geometryInstance undefined; this._rs undefined; } createCommand(context) { const rectangle this._rectangle; // 1. 创建几何实例 const geometry new RectangleGeometry({ rectangle: rectangle, vertexFormat: VertexFormat.POSITION_AND_ST, // 需要位置和纹理坐标 ellipsoid: Ellipsoid.WGS84, }); this._geometryInstance new GeometryInstance({ geometry: geometry, }); // 2. 创建着色器程序 if (!this._shaderProgram) { this._shaderProgram ShaderProgram.fromCache({ context: context, vertexShaderSource: VS, fragmentShaderSource: FS, attributeLocations: { position3DHigh: 0, position3DLow: 1, st: 2, }, }); } // 3. 创建Uniform Map if (!this._uniformMap) { this._uniformMap { u_time: function () { // 使用Cesium的 JulianDate 获取连续时间或直接用帧数 // return Date.now() * 0.001; // 简单方法 // 更集成的方法使用Cesium的模拟时间 return context.frameState.simulationTime.getSecondsOfDay(); }, // 可以添加更多Uniform如 u_resolution, u_mouse 等用于交互 }; } // 4. 配置渲染状态 (开启混合实现透明效果) if (!this._rs) { this._rs RenderState.fromCache({ blending: BlendingState.ALPHA_BLEND, // 启用Alpha混合 depthTest: { enabled: true, }, depthMask: false, // 允许透过水面看到地形 }); } // 5. 创建并返回DrawCommand this._command new DrawCommand({ pass: Pass.TRANSLUCENT, // 半透明通道渲染 modelMatrix: context.frameState.context.uniformState.model, // 使用单位矩阵 geometryInstances: this._geometryInstance, shaderProgram: this._shaderProgram, uniformMap: this._uniformMap, renderState: this._rs, }); return this._command; } update(frameState) { if (!this._show) { return; } // 在每一帧更新时创建或获取绘制命令 if (!defined(this._command)) { this._command this.createCommand(frameState.context); } // 将命令加入命令列表 frameState.commandList.push(this._command); } // 是否被显示 get show() { return this._show; } set show(value) { this._show value; } // 销毁资源防止内存泄漏 destroy() { if (defined(this._shaderProgram)) { this._shaderProgram.destroy(); } this._command undefined; this._shaderProgram undefined; this._uniformMap undefined; this._geometryInstance undefined; this._rs undefined; return undefined; } } export default FluidSimulationPrimitive;4.2 在主程序中集成并运行 (src/main.js)// src/main.js import * as Cesium from cesium; import FluidSimulationPrimitive from ./FluidSimulationPrimitive.js; // 1. 初始化Cesium Viewer Cesium.Ion.defaultAccessToken 你的Ion Token; // 如果你使用Cesium Ion的资产需要Token。对于本地开发可以注释掉并使用默认底图。 const viewer new Cesium.Viewer(cesiumContainer, { terrainProvider: Cesium.createWorldTerrain(), // 使用世界地形 baseLayerPicker: false, animation: false, // 关闭时间轴动画控件 timeline: false, fullscreenButton: false, }); // 2. 设置初始视角 viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(-90.0, 30.0, 500000.0), // 经度纬度高度米 }); // 3. 创建并添加我们的流体模拟图元 const fluidPrimitive new FluidSimulationPrimitive({ rectangle: Cesium.Rectangle.fromDegrees(-95.0, 25.0, -85.0, 35.0), // 覆盖墨西哥湾北部一片区域 show: true, }); // 注意Primitive 需要添加到 scene.primitives 中而不是 viewer.entities viewer.scene.primitives.add(fluidPrimitive); // 4. (可选) 添加一个开关控制 const handler new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction(function (movement) { fluidPrimitive.show !fluidPrimitive.show; console.log(Fluid simulation visibility toggled:, fluidPrimitive.show); }, Cesium.ScreenSpaceEventType.LEFT_DOWN); console.log(Cesium Fluid Simulation Demo Loaded. Click on the globe to toggle the fluid effect.);4.3 运行与验证在项目根目录运行npm run dev(如果使用 Vite) 或npx live-server。浏览器会自动打开。你应该能看到 Cesium 地球并且在指定的经纬度矩形区域本例中是墨西哥湾附近覆盖了一片动态的、波光粼粼的“水面”。点击地球可以在控制台看到日志并且水面效果会显示/隐藏。效果说明此时你看到的是一个基于程序化噪声的简单流体模拟。水面有动态的波纹颜色随“波高”变化并且是半透明的可以看到下面的地形。这已经是一个可用的基础效果。5. 进阶优化与效果增强基础效果实现了但可能还不够真实或有趣。我们可以从以下几个方向进行优化5.1 使用纹理进行更复杂的模拟上述方法是完全基于像素计算的Procedural。另一种更强大、能模拟更复杂流体动力学的方法是基于物理的模拟PBF或稳定流体Stable Fluids这通常在 GPU 上通过渲染到纹理Render-to-Texture来实现。核心思路创建两个或多个WebGLTexture作为模拟状态如速度场、密度场。编写一个着色器计算着色器在 WebGL 中可用片段着色器模拟根据物理方程如 Navier-Stokes 方程更新这些纹理。在渲染流体表面的着色器中读取这些“状态纹理”来获取当前点的速度和密度从而决定颜色和位移。这需要更复杂的帧缓冲区Framebuffer管理。在 Cesium 中你可以通过Context对象创建纹理和帧缓冲区并在update方法中组织多步渲染。5.2 添加交互如鼠标扰动让用户能与流体互动会大大增加趣味性。思路是在uniformMap中添加一个u_mouse变量传递鼠标在归一化设备坐标NDC或世界坐标下的位置。在片段着色器中计算当前片段与鼠标位置的距离。根据距离在噪声计算中增加一个外力项模拟鼠标“推开”或“吸引”流体的效果。需要在主程序中监听鼠标移动事件并将坐标转换后传递给 Primitive 的 Uniform。5.3 与 Cesium 地形、实体结合地形适配目前我们的流体是一个平面。在真实场景中水面应该贴合地形。一种方法是使用GroundPrimitive替代Primitive它会自动贴合地形表面。但GroundPrimitive的自定义着色器支持可能有限。另一种方法是在顶点着色器中采样 Cesium 的地形高度图纹理将顶点的高度偏移到地形表面。与其他实体交互你可以让船只实体Entity在流体表面上移动并根据流体高度调整其姿态。这需要从流体着色器中读取高度信息可能需要回读到 CPU或者使用另一套简化的模拟来计算实体位置处的波浪。5.4 性能优化几何体细分RectangleGeometry的细分程度granularity参数会影响顶点数量。对于大面积流体适当降低细分度可以提升性能。着色器优化减少循环次数、使用低精度的浮点数mediump、预计算复杂函数。按需渲染如果流体区域不在视锥体内可以在update方法中跳过命令的创建和提交。使用 WebGL 2.0确保浏览器支持并使用 WebGL 2.0 上下文以获得更多纹理格式和计算能力。6. 常见问题与排查思路在实现过程中你可能会遇到以下问题问题现象可能原因排查与解决思路一片空白没有效果1. Primitive 未添加到scene.primitives。2. 着色器编译或链接错误。3. 几何体范围Rectangle不在当前视图中或设置错误。4. 渲染状态如深度测试、混合配置冲突。1. 检查viewer.scene.primitives.add()是否执行。2. 打开浏览器开发者工具的“控制台”和“WebGL”或“着色器”面板查看是否有编译错误。3. 将矩形范围设置得大一些如fromDegrees(-180, -90, 180, 90)看是否出现。4. 尝试简化渲染状态先禁用深度测试和混合depthTest: {enabled: false},blending: BlendingState.DISABLED。效果不动态静止Uniformu_time没有正确更新或传递。检查uniformMap中u_time函数的返回值是否随时间变化。使用console.log或 Cesium 的Debug模式查看 Uniform 值。确保在update方法中每帧都重新提交了命令或命令的 Uniform 已更新。颜色异常全黑、全白等1. 片元着色器颜色计算错误结果超出 [0,1] 范围。2. 纹理坐标v_textureCoordinates未正确传递或范围不对。1. 在着色器中使用clamp()函数限制颜色值。逐步简化着色器先输出固定颜色如gl_FragColor vec4(1.0, 0.0, 0.0, 1.0);测试。2. 在顶点着色器中确保st属性被正确赋值给v_textureCoordinates。帧率很低1. 着色器计算过于复杂如 fBM 迭代次数太多。2. 几何体顶点数过多。3. 每帧都创建新的着色器程序或命令。1. 减少噪声函数的迭代次数或寻找性能更好的噪声算法。2. 增加RectangleGeometry的granularity值以减少顶点。3. 确保着色器程序和 DrawCommand 在createCommand中只创建一次并缓存复用。与地形或其他模型穿插Z-fighting流体表面与地形表面完全重合深度值精度冲突。1. 在渲染状态中启用polygonOffsetrenderState: { polygonOffset: { enabled: true, factor: 1.0, units: 1.0 } }。2. 或者在顶点着色器中将顶点在视角空间下略微向上偏移一点点。在特定浏览器或设备上不显示浏览器不支持某些 WebGL 扩展或 GLSL 语法。1. 检查浏览器控制台是否有“WebGL not supported”等错误。2. 使用更通用的 GLSL 语法如避免 WebGL 2.0 独有的特性。3. 使用 Cesium 的context.webgl2进行特性检测和降级处理。7. 最佳实践与工程建议将此类高级图形效果集成到生产级 Cesium 项目中时需要考虑以下几点模块化与封装将FluidSimulationPrimitive及其着色器代码封装成一个独立的 ES6 模块或 npm 包。提供清晰的构造函数参数如区域、颜色、速度、分辨率等方便在不同项目中复用和配置。资源管理在destroy()方法中务必销毁创建的 WebGL 资源着色器程序、纹理、缓冲区防止内存泄漏。当 Primitive 被从场景中移除时应调用其destroy方法。优雅降级在初始化时检测 WebGL 支持情况。如果不支持可以静默禁用该特效或回退到使用静态图片或简单的 CesiumMaterial来表现水面保证应用的基本功能可用。性能监控集成 Cesium 的Scene.debugShowFramesPerSecond或使用stats.js来监控帧率。为流体效果提供质量等级设置如低、中、高通过调整着色器复杂度、模拟步长和纹理分辨率来适应不同性能的设备。与 Cesium 生态协调时间系统如果你的模拟需要与 Cesium 的模拟时间同步如在回放模式下应使用context.frameState.simulationTime而非Date.now()。深度测试仔细配置RenderState中的depthTest和depthMask。对于半透明流体通常需要depthTest: true确保被地形遮挡的部分不绘制但depthMask: false允许绘制在流体后面的物体。拾取Picking默认的自定义DrawCommand不支持 Cesium 的实体拾取。如果需要点击流体区域触发事件可能需要实现自定义的拾取逻辑。着色器代码管理对于复杂的着色器不要将 GLSL 代码以字符串形式硬写在 JS 文件里。可以考虑使用构建工具如 Webpack 的raw-loader或 Vite 的?raw导入将.glsl文件作为纯文本导入这样有利于语法高亮和代码分割。通过本文的梳理你应该已经掌握了在 Cesium 中利用Primitive、DrawCommand和自定义着色器实现流体模拟效果的全套流程。从简单的程序化噪声到基于物理的纹理模拟这条路充满了扩展的可能性。下一步你可以尝试集成更真实的流体动力学方程添加风场、障碍物交互或者将效果应用到河流、瀑布等线性水体上创造出独一无二的三维地理可视化体验。