Cesium+WebGPU降雨汇流模拟:从DEM到动态三维可视化
2026/9/1 23:37:44 网站建设 项目流程

如果你正在做一个三维 GIS 项目,客户突然提出一个要求:“能不能让降雨从山坡流下来,然后在沟谷里汇成洪水?”最直观的想法,是在 Cesium 里叠加一段粒子特效。但真正动手后你会发现,粒子特效只是视觉层,要让雨滴沿着地形坡向流动、自然汇入沟谷,背后其实是水文模型里的“汇流分析”。

这篇文章要讲的,就是用 Cesium + WebGPU 在浏览器里实现降雨汇流模拟的一套最小可运行方案。先说一个明确判断:这个需求的难点,不在于 WebGPU 语法有没有背熟,而在于你如何设计“高程数据 → 流向计算 → 粒子状态 → Cesium 渲染”这一整条数据流。读完这篇文章,你可以跑通一个 256×256 网格上的降雨汇流演示,并知道如何把它扩展到真实 DEM 数据上。

Cesium 负责地球场景、相机控制和三维渲染;WebGPU 负责最核心的“粒子沿坡向运动”计算。两者各司其职,比强行在 Cesium 的 WebGL 管线里塞一套自定义模拟要稳妥得多。接下来我会从原理、架构、代码、验证和排错五个角度,把这套方案完整讲清楚。

1. 这篇文章真正要解决的问题

先说痛点。传统 GIS 里的降雨汇流模拟,通常依赖 ArcGIS 的 Hydrology 工具集或专业水文模型。流程大概是:准备 DEM 数据 → 填洼 → 计算流向 → 计算汇流累积量 → 出专题图。这套流程成熟,但结果是静态的。你做出来的是一张“哪些栅格汇流量大”的图,而不是一段“雨水从山脊流向山谷”的动态过程。

如果项目需要三维可视化,比如防汛演练、洪水淹没推演、Web 大屏展示,静态图就不够用了。我们需要的是:用户拖拽视角,看到雨滴落到山坡上,顺着地形坡向往下流,在沟谷里逐渐汇聚成线。这非常直观,也很有说服力。

那为什么选 Cesium + WebGPU,而不是纯 Cesium + WebGL?

WebGL 2 虽然也能做计算,但需要把大量数据编码到纹理里,再用 fragment shader 间接实现,代码晦涩且调试困难。WebGPU 提供了真正的计算着色器,也就是 Compute Shader,可以直接操作 Storage Buffer,处理粒子位置、高程网格这类数据,代码结构清晰得多。

Cesium 的价值则在于成熟的三维场景能力:地球椭球、相机、坐标系、Primitive 渲染、3D Tiles 加载。你不需要自己写三维地球,只需要关注模拟本身。

不过这里有一个容易踩的坑:Cesium 官方虽然在推进 WebGPU 渲染后端,但目前绝大多数生产项目仍然运行在 WebGL 路径上。如果你试图让 Cesium 把整个地球用 WebGPU 渲染出来,再叠加你的模拟,会遇到很多兼容性和稳定性问题。

所以我在这篇文章里采用的混合架构是:

  • Cesium 负责渲染三维地球、相机交互、粒子点的绘制;
  • WebGPU 负责汇流计算,包括高程采样、坡度计算、粒子位置更新;
  • 每帧模拟结束后,把粒子位置从 GPU 读回 CPU,再交给 Cesium 的 PointPrimitive 渲染。

这种方案既利用了 WebGPU 的并行计算能力,又避开了 WebGL 与 WebGPU 纹理互操作难题,是当前浏览器环境下最容易落地的组合。

2. 降雨汇流模拟的核心概念

2.1 汇流在 GIS 里到底指什么

水文分析里的“汇流”,简单说就是:雨水落到地面后,受重力影响,会沿着坡面从高处流向低处。在数字高程模型 DEM 里,每个格子都有一个高度值。如果某个格子的高度比它周围 8 个格子都低,水就会在这里汇集。如果某个格子的一侧特别陡,水就会优先往那个方向流。

最经典的流向计算算法是 D8 算法:把一个中心格子周围的 8 个相邻格子的坡度都算出来,选择坡度最大的方向作为水流方向。然后从每个格子出发,沿着水流方向追踪路径,累加经过该格子的上游格子数量,得到“汇流累积量”。汇流累积量越大的地方,越容易形成河流。

我们这篇文章不会实现完整的 D8 算法,但会用一个更简单的“梯度下降法”:在 WebGPU 计算着色器里,用有限差分算出当前粒子所在位置的坡度方向,然后让粒子沿着坡度反方向移动。粒子流到局部低洼处后会停留、堆积,从视觉上就能看到“降雨 → 坡面漫流 → 汇入沟谷”的效果。

2.2 为什么用 WebGPU 而不是纯 Cesium 或 WebGL

Cesium 本身支持粒子系统,但它的粒子系统更多是 Billboard 或点精灵,适合做雨滴打在屏幕上这类特效。如果要让粒子感知地形高程并沿流向运动,你需要自定义更新逻辑。在 WebGL 里,常见的做法是把高程写入纹理,然后在顶点着色器或片元着色器里采样纹理,计算坡度。但这个流程有几个问题:

  • 纹理采样需要把 float 数据编码成 RGBA,来回编解码很麻烦;
  • WebGL 没有通用计算着色器,想并行更新几万甚至几十万个粒子,只能靠顶点着色器里的技巧,性能上限低;
  • 数据从 CPU 传到 GPU、再从 GPU 读回,路径不直观。

WebGPU 的出现改变了很多。它提供了compute pass,你可以直接在 GPU 上运行一个@compute着色器,操作storage buffer。粒子位置、速度、状态都放在 buffer 里,每个线程负责一个粒子,并行度很高。代码读起来也更接近现代图形编程的直觉。

2.3 Cesium 和 WebGPU 怎样协作

这里需要明确一个边界:Cesium 的默认渲染管线是 WebGL,WebGPU 计算着色器是另一条独立的 GPU 管线。两者不能直接共享 WebGL 纹理或 Buffer,但有几种协作方式:

  1. Cesium 渲染,WebGPU 计算,CPU 中转:WebGPU 计算完成后,把结果 buffer 映射到 CPU,再用 Cesium API 更新点/线位置。适合粒子数量不大、教学演示、快速验证。
  2. WebGPU 渲染到纹理,再传给 WebGL 作为纹理:需要 WebGL 与 WebGPU 共享外部纹理,目前浏览器支持有限,且 Cesium 材质系统接入成本高。
  3. 完全用 WebGPU 渲染覆盖层:在 Cesium 的 canvas 上再叠加一个 WebGPU canvas,通过相机同步对齐,但目前 WebGPU 还不支持一些高级的深度测试和 Cesium 场景交互,做起来复杂。

本文采用第一种方式。粒子数控制在 3000 以内,每帧读回一次位置,性能完全可接受。

3. 整体架构设计

在写代码之前,先把架构想清楚。整个模拟可以拆成四层:

职责技术选型
数据层生成或加载 DEM 高程数据虚拟 DEM / geotiff.js
计算层采样高程、计算坡度、更新粒子位置WebGPU Compute Shader
同步层从 GPU 读回粒子位置Buffer.mapAsync
渲染层绘制三维地球和粒子Cesium PointPrimitiveCollection

数据流方向是单向的:

  • 初始化时,把 Float32Array 格式的高程网格写入 WebGPU Storage Buffer;
  • 每一帧,计算着色器读取高程 Buffer 和粒子 Buffer,更新粒子位置;
  • 计算完成后,把粒子 Buffer 拷贝到一个可读的 Buffer;
  • CPU 映射该 Buffer,拿到最新粒子位置;
  • 将经纬度和高度转换为 Cesium 的 Cartesian3 坐标,更新 PointPrimitive 的位置。

这个架构最大的好处是:每一层都能单独替换。比如你想使用真实 DEM,只需要把“生成虚拟 DEM”换成“用 geotiff.js 读取 GeoTIFF 文件”;想把粒子换成轨迹线,只需要在渲染层把 PointPrimitive 换成 PolylineCollection。

4. 环境准备与前置条件

4.1 浏览器要求

WebGPU 在 Chrome 和 Edge 中已经默认开启,从 113 版本开始稳定支持。Safari 和 Firefox 仍在实验阶段或支持不完整。因此请使用最新版 Chrome 或 Edge 打开本示例。

另外,WebGPU 需要安全上下文。localhost和 HTTPS 都可以,普通的http://局域网IP在部分浏览器里可能无法调用navigator.gpu

如果你在浏览器控制台输入:

navigator.gpu

返回undefined,说明当前浏览器或协议不支持 WebGPU,需要先解决这一步,再往下开发。

4.2 工程初始化

本文使用 Vite 作为开发工具,Cesium 使用 npm 包方式引入。

mkdir cesium-webgpu-rainfall cd cesium-webgpu-rainfall npm init -y npm install cesium@latest npm install -D vite@latest

项目结构如下:

cesium-webgpu-rainfall/ ├─ index.html ├─ package.json └─ src/ ├─ main.js ├─ rainfallSim.js └─ waterSim.wgsl

如果你使用 Cesium Ion 的影像服务,需要准备一个 Token。没有 Token 也不影响本示例运行,代码里会把baseLayer设置为false,只使用默认椭球体。

下面是index.html

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Cesium + WebGPU 降雨汇流模拟</title> <style> html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; overflow: hidden; } </style> </head> <body> <div id="cesiumContainer"></div> <script type="module" src="/src/main.js"></script> </body> </html>

5. 核心实现:WebGPU 汇流模拟

5.1 DEM 高度场数据准备

为了让你在没有任何外部数据的情况下也能跑通流程,先用程序化函数生成一个虚拟 DEM。这个 DEM 包含山脊和山谷,高度范围在 0 到 1 之间,方便后面映射到地理高度。

文件路径:src/rainfallSim.js

function buildHeightMap(gridSize) { const data = new Float32Array(gridSize * gridSize); for (let y = 0; y < gridSize; y++) { for (let x = 0; x < gridSize; x++) { const nx = x / gridSize; const ny = y / gridSize; const v = Math.sin(nx * Math.PI * 3) * Math.cos(ny * Math.PI * 2 + 0.5) * 0.5 + Math.sin(ny * Math.PI * 5 + 1.2) * 0.25 + 0.25; data[y * gridSize + x] = Math.min(1.0, Math.max(0.0, v)); } } return data; }

这个函数生成了两块凸起山体,中间自然形成低洼沟谷。粒子在重力作用下会从山脊流向这些沟谷,视觉效果就是“汇流”。

如果你想替换成真实 DEM,只需要保持输出为Float32Array,长度为gridSize * gridSize,并且按行优先存储高度值即可。数据源可以来自geotiff.js解析的 GeoTIFF,也可以是 CesiumsampleTerrainMostDetailed采样结果重采样后的网格。

5.2 WGSL 计算着色器

WebGPU 的计算着色器使用 WGSL 语言。下面这段代码负责:

  • 根据粒子当前坐标采样 DEM 高度;
  • 使用有限差分计算当前点的坡度方向;
  • 让粒子沿坡度反方向移动;
  • 如果粒子流到区域外,重新生成一个新粒子,模拟降雨。

文件路径:src/waterSim.wgsl

struct SimParams { gridSize: f32, particleCount: f32, deltaTime: f32, time: f32, }; @group(0) @binding(0) var<uniform> params: SimParams; @group(0) @binding(1) var<storage, read> heightMap: array<f32>; @group(0) @binding(2) var<storage, read_write> particles: array<vec4<f32>>; fn sampleHeight(xf: f32, yf: f32) -> f32 { let x = clamp(u32(xf), 0u, u32(params.gridSize - 1.0)); let y = clamp(u32(yf), 0u, u32(params.gridSize - 1.0)); return heightMap[y * u32(params.gridSize) + x]; } fn rand(seed: u32, t: f32) -> f32 { let v = sin(f32(seed) * 127.1 + t * 311.7) * 43758.5453; return v - floor(v); } @compute @workgroup_size(64) fn main(@builtin(global_invocation_id) gid: vec3<u32>) { let i = gid.x; if (i >= u32(params.particleCount)) { return; } var p = particles[i]; var x = p.x; var y = p.y; var state = p.w; // 粒子不活跃时,模拟新降雨粒子 if (state <= 0.0) { x = rand(i, params.time) * params.gridSize; y = rand(i + 1024, params.time + 1.0) * params.gridSize; state = 1.0; } let h = sampleHeight(x, y); let gx = sampleHeight(x + 1.0, y) - sampleHeight(x - 1.0, y); let gy = sampleHeight(x, y + 1.0) - sampleHeight(x, y - 1.0); let len = sqrt(gx * gx + gy * gy); // 沿着最陡坡降方向移动,单位步长 0.4 格 if (len > 0.001) { x = x - (gx / len) * 0.4; y = y - (gy / len) * 0.4; } if (x < 0.0 || x > params.gridSize - 1.0 || y < 0.0 || y > params.gridSize - 1.0) { state = 0.0; } particles[i] = vec4<f32>(x, y, h, state); }

这里有几个细节解释一下:

  • sampleHeightclamp保证采样不越界,避免在边界处读空数据。
  • rand是一个基于sin的伪随机函数,用来生成新的降雨粒子位置。
  • 坡度计算采用中心差分(height[x+1] - height[x-1]) / 2,虽然简单

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询