
做 GIS 可视化的同学应该都有同感在 Cesium 里做一个“下雨”效果并不难粒子系统、贴图动画、GPU 局部雨效果网上有大量现成案例几分钟就能跑起来。可一旦把需求从“让屏幕出现雨丝”升级成“模拟雨落到地面之后水往哪里流、哪里会汇成积水、哪些区域会先被淹没”实现的难度立刻完全不同。这不是换套材质、调几个参数就能解决的问题。降雨只是一个输入条件真正核心的部分是汇流过程——水流从地形高处向低处转移、沿坡面汇入沟谷、在洼地积累。这意味着你需要同时拥有三个东西地形高程、随帧变化的水量场以及一套能对全场数据进行持续更新的计算机制。我最终选择用 Cesium WebGPU 来做这件事。Cesium 负责地球场景、地形、相机和交互WebGPU 负责真正的降雨汇流计算。这篇文章会从技术选型、架构设计、高程数据获取、Compute Shader 实现、结果可视化到常见坑位完整拆解这套方案的实现过程。如果你正在做数字孪生、洪涝推演、水利可视化或者三维大屏里的动态水体这篇文章应该能帮你少走很多弯路。1. 这篇文章要解决的核心问题1.1 降雨模拟和降雨汇流模拟不是一回事普通降雨模拟本质上是一个视觉效果问题。你要做的事情是用粒子系统制造雨丝下落或者用噪声纹理做雨幕动画让用户“看见”雨在下就行。降雨汇流模拟则完全不同它是要在三维地球上回答一个物理问题这些雨水落在地形表面之后下一时刻会移动到哪个网格每个网格的水量是增加还是减少哪些低洼位置会持续积水这意味着它必须具备以下能力拥有真实的地形高程数据不能只在地球表面贴一张平面图。维护一个随时间演化的水量场每一帧都要更新。模拟水流从坡度高的地方向坡度低的地方转移的过程。把模拟结果实时渲染到三维场景中并且能跟着相机一起动。单纯靠 Cesium 的 Material 或者传统 WebGL Shader很难把这件事做得优雅。因为普通 Shader 本质上是无状态的每个片元只知道自己当前位置的信息不知道邻居的水量也不知道上一帧自己积累了多少水。而汇流模拟偏偏是需要邻域读取、状态累积和时间演化的典型任务。1.2 为什么 WebGPU 是当前最适合的选择做并行计算浏览器里可以用的手段其实有三个方案并行能力代码复杂度与 Cesium 集成难度适用场景CPU 遍历计算弱容易卡主线程简单容易但会拖垮渲染网格很小、单次计算WebGL Transform Feedback中能利用 GPU 但语法别扭较高中等老项目迁移WebGPU Compute Shader强计算管线是原生设计中等需要自己搭桥大范围实时模拟本文推荐WebGPU 的 Compute Shader 天生就是为这类“大规模数据并行更新”设计的。它允许我们把水量场、高程场作为纹理资源传入计算管线每个线程处理一个网格单元一次性并行更新几千甚至几十万个网格的水量数据。从材料和信息来看Cesium 官方也在逐步推动 WebGPU 渲染方向把它作为未来高性能场景渲染的基础之一。虽然现阶段 Cesium 和 WebGPU 之间还没有一个“开箱即用”的官方完整集成方案但这不妨碍我们用自己的方式把两者结合起来。我的思路是Cesium 继续负责它擅长的场景渲染WebGPU 在幕后做计算最后把计算结果通过纹理或 Canvas 回传给 Cesium 场景进行显示。1.3 这篇文章的阅读收益读完这篇文章你会得到一条完整的“Cesium WebGPU 降雨汇流模拟”实现链路如何从 Cesium 地形服务中采样高程并转换为 WebGPU 能用的数据格式。如何编写 WGSL Compute Shader实现一个可运行的简化版汇流模型。如何把 WebGPU 的计算结果实时传回 Cesium 场景叠加到三维地形上。会遇到哪些经典问题以及对应排查思路。如果你只是想做“视觉上的雨”这篇文章可能不适合你。但如果你需要的是“雨落到地上之后的物理过程”那这套方案值得认真看。2. 降雨汇流模拟的难点与技术选型2.1 真正难的不是渲染而是状态维护很多人刚开始做这个需求时会尝试用 Cesium 的 CustomShader 直接在地形表面写一个水流效果。这个路线很快会遇到一个难以绕开的障碍地形材质 Shader 没有跨帧状态。每个片元在每一帧执行时只能拿到当前片元相关的位置、法线、UV 等信息。它并不知道这个位置在上一个帧里积了多少水也不知道周边八个方向的水量分布。没有状态就没有办法做基于物理的水量转移。你最多只能做一个“看起来像水流动”的动态纹理而不是真正的汇流结果。所以正确思路是把“降雨汇流”从渲染管线中抽出来放回计算管线。每一次模拟都基于上一帧的水量场来更新当前帧的水量场然后再把更新结果交给渲染层。这就是状态维护。2.2 简化的汇流模型D8 与多流向在 GIS 水文分析里最经典的流向算法是 D8 算法每个网格的水只流向周围 8 个邻居中坡度最低的那个方向。在实时模拟中我会把模型进一步简化每个网格有固定的高程H。每个网格有动态水量W。每一帧先加上降雨量R * dt。然后计算当前网格和 8 个邻居“水面高度”H W的差异。让水量从高水面往低水面转移一部分。最后减去蒸发量或渗透量保证水量不无限增长。这个模型虽然简单但已经能呈现“水往低处流、洼地积水、沟谷汇流”这些基本现象。后续如果想做更精确的产流汇流可以替换成更复杂的物理模型但整体的“状态场 邻域计算 逐帧更新”架构不会变。2.3 为什么不用 CPU 逐网格算如果模拟区域很小比如只有64 x 64个网格你用 CPU 每帧遍历一遍也不会太卡。但一旦区域扩大网格升到512 x 512甚至1024 x 1024每个网格要读取 8 个邻居的数据并做水量转移判断CPU 每帧计算 26 万次左右的判断和写操作即使能跑也会明显挤压渲染线程的资源。更重要的是Cesium 本身对主线程的占用不低瓦片加载、场景遍历、渲染调度都在主线程和渲染进程里。如果你让主线程去逐帧遍历水量场很容易出现视角拖动卡顿、浏览器无响应等情况。把计算压到 GPU 上CPU 只负责输入参数和读取结果这是最合理的分工。3. 整体方案Cesium 负责场景WebGPU 负责计算3.1 架构分层整套方案可以分为四个部分。场景层Cesium Viewer 负责地球、地形、相机和交互 数据层从地形服务采样高程生成模拟网格 模拟层WebGPU Compute Shader 逐帧更新水量场 渲染层把水量场结果回传叠加到 Cesium 场景流程中最重要的原则是“计算”和“渲染”分离。Cesium 需要展示的是计算后的结果而不是自己去计算雨水流向。这样可以保证模拟逻辑足够纯粹也可以让水量模拟这块功能独立复用今天模拟降雨明天模拟污染扩散后天模拟爆炸冲击波计算管线的架构都可以保持一致。3.2 核心数据流整个过程可以概括成五步在指定地理范围内按固定网格间距采样地形高程。把高程数据写入Float32Array再上传到 WebGPU 纹理或 Buffer。WebGPU Compute Shader 每个帧读取高程场和上一帧水量场计算新水量场。把新水量场从 GPU 读回绘制到 Canvas或者直接传给后续渲染管线。Cesium 场景使用 Canvas 或纹理作为材质显示叠加在地形表面。第 4 步有两种常见做法读回 CPU 再画 Canvas简单直观适合功能验证和中小网格。直接把 GPU 纹理交给渲染管线性能最好但工程复杂度高很多。对于第一版 Demo我建议用读回 CPU Canvas 的方式跑通全流程等确认模拟结果和交互都没问题再优化成纹理直传。3.3 你可能会遇到的一个误区有人会问能不能直接在 Cesium 的渲染调用链里嵌入 WebGPU Compute让 Cesium 内部的相机矩阵直接传给 Compute Shader理论上可以但没必要。Cesium 内置渲染器目前还是基于 WebGL/新版本 WebGPU 抽象封装跨上下文调用内部资源容易踩版本敏感问题。更稳妥的做法是把 WebGPU 的模拟结果标定到地理坐标区域然后用 Cesium 的 Entity、Primitive 或者 Rectangle 去叠加显示。这样即使 Cesium 升级你的模拟层也不会受到太大影响。4. 环境准备与工程搭建4.1 运行环境要求在做这个方案之前先确认你的浏览器支持 WebGPU。目前 Chrome、Edge 等主流 Chromium 系浏览器在较新版本中已经默认支持或可通过配置开启。Safari 和 Firefox 的支持情况随版本变化较大项目里必须做特性检测。你可以直接在浏览器控制台执行if (navigator.gpu) { console.log(当前浏览器支持 WebGPU); } else { console.warn(当前浏览器不支持 WebGPU请更换浏览器或升级到最新版本); }也可以在浏览器地址栏输入chrome://gpu查看 WebGPU 相关状态。4.2 初始化前端工程我用 Vite 搭建了一个最基础的 Vanilla 工程方便快速验证思路。你可以根据团队习惯选择 Vue 或 React核心逻辑不变。npm create vitelatest cesium-webgpu-rain -- --template vanilla cd cesium-webgpu-rain npm install npm install cesium安装完成后在开发环境引入 Cesium。这里要注意Cesium 官方推荐使用 ION Token 来加载在线地形和影像服务但国内网络环境下在线服务不一定稳定。更稳妥的方案是本地部署地形切片或使用离线影像避免被在线 Token 限制。如果你的项目允许可以在开发阶段先用 Cesium 官方的示例 Token 验证效果生产环境再切换到自己的地形服务。4.3 项目目录建议cesium-webgpu-rain/ ├── index.html ├── package.json ├── src/ │ ├── main.js // 入口逻辑 │ ├── cesium/ │ │ └── createViewer.js // Cesium 场景初始化 │ ├── terrain/ │ │ └── sampleHeight.js // 地形高程采样 │ ├── webgpu/ │ │ ├── initWebGPU.js // WebGPU 初始化 │ │ └── waterSim.js // 汇流模拟调度 │ └── shaders/ │ └── waterSim.wgsl // WGSL 计算着色器5. 第一步加载地形并获取高程数据5.1 创建一个 Cesium 场景先创建一个 Cesium Viewer。为了性能建议关闭默认的 UI 组件和不必要的特效。// 文件路径src/cesium/createViewer.js import * as Cesium from cesium; export async function createViewer(containerId) { const viewer new Cesium.Viewer(containerId, { animation: false, baseLayerPicker: false, fullscreenButton: false, geocoder: false, homeButton: false, infoBox: false, sceneModePicker: false, selectionIndicator: false, timeline: false, navigationHelpButton: false, // 新版本 Cesium 提供了 Async 版本的地形创建方法 terrainProvider: await Cesium.createWorldTerrainAsync(), }); return viewer; }如果你的 Cesium 版本较旧不提供createWorldTerrainAsync可以使用回调风格viewer.terrainProvider Cesium.createWorldTerrain({ requestVertexNormals: true, requestWaterMask: true, });这里有一个很容易踩的坑降雨汇流模拟对地形精度很敏感。如果你使用的地形分辨率太低山谷和高地会被抹平水流流向会变成一片混乱。实际项目中优先选择高精度的本地地形服务或者支持细节层级的地形切片。5.2 采样地形高程Cesium 的sampleTerrainMostDetailed方法可以根据经纬度坐标批量获取地形高度。我们可以用它来生成一张高程网格图把模拟区域划分成gridSize x gridSize的网格逐点采样高程。// 文件路径src/terrain/sampleHeight.js import * as Cesium from cesium; export async function sampleTerrainGrid(viewer, west, south, east, north, gridSize) { const positions []; for (let row 0; row gridSize; row) { for (let col 0; col gridSize; col) { const lon west (east - west) * (col / (gridSize - 1)); const lat south (north - south) * (row / (gridSize - 1)); positions.push(Cesium.Cartographic.fromDegrees(lon, lat)); } } const sampled await Cesium.sampleTerrainMostDetailed( viewer.terrainProvider, positions ); const heightMap new Float32Array(gridSize * gridSize); for (let i 0; i sampled.length; i) { heightMap[i] sampled[i].height || 0; } return { width: gridSize, height: gridSize, data: heightMap, }; }这段代码的思路是先用经纬度生成一个等间距网格调用 Cesium 地形服务批量查询高度最后输出一个一维Float32Array。Float32Array后面可以直接上传到 WebGPU 的 Buffer 或 Texture 中非常方便。注意sampleTerrainMostDetailed是通过网络请求完成的。如果你的网格达到512 x 512意味着一次要请求 26 万个点这个并发量在地形服务端可能是不可接受的。在实际项目中建议缩小模拟范围或者只在启动时采样一次不要每帧都采样。5.3 高程数据坐标与 WebGPU 的对应关系Cesium 里使用的坐标系是经纬度加高程WebGPU 的 Compute Shader 不认识经纬度。我们需要在 CPU 端做一次简单换算把“经纬度网格”映射到“平面网格索引”。最简单的方式是直接把网格矩阵的row和col当作二维坐标第(row, col)个网格对应高程数组中的row * gridSize col位置。WebGPU 侧通过线程编号GlobalInvocationId.x和GlobalInvocationId.y来定位当前处理的是哪个网格。这样其实不需要把经纬度传给 GPU只需要在最终叠加显示时用同样的网格范围去创建 Cesium Rectangle保证对齐即可。6. 第二步用 WebGPU Compute Shader 模拟降雨汇流6.1 初始化 WebGPU先做 WebGPU 特性检测和基础初始化。// 文件路径src/webgpu/initWebGPU.js export async function initWebGPU() { if (!navigator.gpu) { throw new Error(当前浏览器不支持 WebGPU请更换为支持 WebGPU 的浏览器); } const adapter await navigator.gpu.requestAdapter(); if (!adapter) { throw new Error(无法获取 WebGPU Adapter请检查显卡驱动或浏览器设置); } const device await adapter.requestDevice(); return { adapter, device }; }这里有一个常见问题即使浏览器支持 WebGPU某些设备或远程桌面、虚拟机环境也可能拿不到可用的 Adapter。所以requestAdapter返回null时要给出明确提示而不要直接继续执行导致后续代码全部报错。6.2 编写 WGSL 计算着色器WGSL 是 WebGPU 的着色器语言。下面是一个简化版的降雨汇流计算着色器演示了核心逻辑读取高程和水位、找最低相邻网格、向低处转移水量、加入降雨并扣除蒸发。// 文件路径src/shaders/waterSim.wgsl struct SimParams { mapWidth: f32, mapHeight: f32, rainRate: f32, evaporation: f32, dt: f32, }; group(0) binding(0) var heightTex: texture_2df32; group(0) binding(1) var waterTex: texture_2df32; group(0) binding(2) var outTex: texture_storage_2drgba32float, write; group(0) binding(3) varuniform params: SimParams; compute workgroup_size(8, 8, 1) fn main(builtin(global_invocation_id) gid: vec3u32) { let x i32(gid.x); let y i32(gid.y); let size textureDimensions(heightTex); if (x i32(size.x) || y i32(size.y)) { return; } let h textureLoad(heightTex, vec2i32(x, y), 0).r; var w textureLoad(waterTex, vec2i32(x, y), 0).r; // 寻找水面高度最低的相邻网格 var lowestSurface h w; var flowDir vec2i32(0, 0); for (var dy -1; dy 1; dy dy 1) { for (var dx -1; dx 1; dx dx 1) { if (dx 0 dy 0) { continue; } let nx x dx; let ny y dy; if (nx 0 || ny 0 || nx i32(size.x) || ny i32(size.y)) { continue; } let nh textureLoad(heightTex, vec2i32(nx, ny), 0).r; let nw textureLoad(waterTex, vec2i32(nx, ny), 0).r; let surface nh nw; if (surface lowestSurface) { lowestSurface surface; flowDir vec2i32(dx, dy); } } } // 按简化比例向低处转移水量 let transfer min(w * 0.25, max(0.0, lowestSurface - (h w)) * 0.5); var outWater w - transfer; // 加入降雨扣除蒸发和渗透 outWater outWater params.rainRate * params.dt; outWater outWater - params.evaporation * params.dt; outWater max(outWater, 0.0); // 简化输出R 通道为水量G 通道为水量/10 方便调试 textureStore(outTex, vec2i32(x, y), vec4f32(outWater, outWater / 10.0, 0.0, 1.0)); }这段着色器反映了汇流模拟最核心的简化思路每个线程处理一个网格。计算“地形高度 水量高度”得到当前水面高度。找附近 8 个网格中水面高度最低的位置把一部分水转移过去。降雨让水增加蒸发让水减少。结果写入outTex供下一帧读取。这里必须要提一个关键问题上面是教学简化版本实际运行时会有读写竞争风险。当相邻两个线程都决定向同一个低洼网格转移水量时它们同时写入outTex的同一点结果可能覆盖丢失。在稳定版本中通常需要使用“双缓冲”策略分两个阶段处理第一遍统计要流入的水量第二遍再加上输出避免相邻线程互相干扰。6.3 在 JS 中调度计算管线接下来在 JavaScript 中创建管线、绑定资源并让模拟每帧执行一次。// 文件路径src/webgpu/waterSim.js import { initWebGPU } from ./initWebGPU; export async function createWaterSim(heightMap, gridSize) { const { device } await initWebGPU(); const textureSize { width: gridSize, height: gridSize }; // 高程纹理 const heightTexture device.createTexture({ size: textureSize, format: r32float, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, }); // 将 CPU 端的高程数据写入纹理 device.queue.writeTexture( { texture: heightTexture }, heightMap.data, { bytesPerRow: gridSize * 4 }, textureSize ); // 当前水量纹理和输出纹理双缓冲示意 const waterTexture device.createTexture({ size: textureSize, format: r32float, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, }); const outputTexture device.createTexture({ size: textureSize, format: rgba32float, usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC, }); // 参数 Buffer const paramsBuffer device.createBuffer({ size: 20, usage: GPUTextureUsage.COPY_DST | GPUBufferUsage.UNIFORM, }); const shaderModule device.createShaderModule({ code: waterSimWGSL, }); const pipeline device.createComputePipeline({ layout: auto, compute: { module: shaderModule, entryPoint: main, }, }); // 创建 BindGroup const bindGroup device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [ { binding: 0, resource: heightTexture.createView() }, { binding: 1, resource: waterTexture.createView() }, { binding: 2, resource: outputTexture.createView() }, { binding: 3, resource: { buffer: paramsBuffer } }, ], }); const updateParams (rainRate, evaporation, dt) { const params new Float32Array([gridSize, gridSize, rainRate, evaporation, dt]); device.queue.writeBuffer(paramsBuffer, 0, params.buffer, params.byteOffset, params.byteLength); }; const computeOneFrame () { const encoder device.createCommandEncoder(); const pass encoder.beginComputePass(); pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(Math.ceil(gridSize / 8), Math.ceil(gridSize / 8)); pass.end(); device.queue.submit([encoder.finish()]); }; return { device, outputTexture, updateParams, computeOneFrame, }; }这里使用了r32float和rgba32float格式的纹理。需要注意不同 GPU 设备对纹理格式的支持范围略有差异生产环境可以先调用device.features或相关格式检查接口确认避免在低端设备上出现兼容性问题。6.4 双缓冲的重要提醒上面代码为了演示简单直接使用了outputTexture作为输出。在实际项目中如果水量场需要不断累积必须使用两个纹理交替读写当前帧从waterTexture读取数据把计算结果写入pingTexture下一帧从pingTexture读取写入pongTexture。这样才能避免同一帧内读写同一张纹理产生数据竞争。如果你暂时不想做双缓冲也可以采用“在 WGSL 中通过原子操作累加水量”的方式但代码复杂度会明显提升。先跑通最简单的版本再逐步优化是比较务实的节奏。7. 第三步把模拟结果可视化回 Cesium7.1 从 GPU 读回水量数据模拟计算完成后需要把 GPU 中的水量数据读回 CPU并绘制到 Canvas再交给 Cesium 作为贴图使用。最简单的方式是用copyTextureToBuffer将纹理数据复制到一个 Buffer 中然后映射到 CPU 侧读取。async function readWaterTexture(device, outputTexture, gridSize) { const bytesPerRow Math.ceil((gridSize * 16) / 256) * 256; const bufferSize bytesPerRow * gridSize; const readBuffer device.createBuffer({ size: bufferSize, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, }); const encoder device.createCommandEncoder(); encoder.copyTextureToBuffer( { texture: outputTexture }, { buffer: readBuffer, bytesPerRow }, { width: gridSize, height: gridSize } ); device.queue.submit([encoder.finish()]); await readBuffer.mapAsync(GPUMapMode.READ); const result new Float32Array(readBuffer.getMappedRange().slice(0)); readBuffer.unmap(); return result; }注意rgba32float每个像素 16 字节所以bytesPerRow要按这个值对齐。MAP_READ操作会阻塞等待 GPU 完成计算频繁读取会带来性能开销。如果只是验证功能每个帧读回一次可以接受如果在大型项目中使用建议改为“模拟一定帧数后只读回一次”的策略或者直接用纹理交叉渲染不走 CPU 回读。7.2 绘制到 Canvas得到Float32Array水量数据后我们把它映射成颜色并绘制到 Canvas。为了调试方便可以先用灰度表示水量水量越大颜色越亮后续再替换成蓝白色渐变或者水文专题配色。// 文件路径src/cesium/waterOverlay.js import * as Cesium from cesium; export function createWaterOverlay(viewer, rectangle, canvas) { return viewer.entities.add({ rectangle: { coordinates: rectangle, material: new Cesium.ImageMaterialProperty({ image: canvas, transparent: true, }), }, }); }Canvas 对象本身可以作为ImageMaterialProperty的 image 使用Cesium 会实时读取 Canvas 内容。核心逻辑是每帧更新 Canvas 的像素数据然后借助 Cesium 的渲染机制把它绘制到指定的矩形区域内。如果你需要更贴地形的渲染效果可以把rectangle调整成刚好覆盖模拟范围并给材质加上透明通道。7.3 可视化效果的最小闭环当雨水不断累积后Canvas 中水量大的区域会变亮。叠加到 Cesium 地形表面后你能看到的效果就是山谷区域出现发亮的“水流”痕迹低洼区域逐渐形成积水区。当降雨停止、蒸发量大于降雨量时积水区会慢慢消失。这套方案的好处是简单直接尤其适合第一版技术验证。缺点也很明显每帧 CPU 回读 GPU 数据并在 Canvas 上绘制会带来额外开销。如果模拟网格偏大或帧率要求较高需要换成 GPU 纹理直接参与场景渲染的方案这可以作为后续进阶优化的方向。8. 运行验证、性能观察与常见问题8.1 如何判断模拟是否成功启动项目后把相机对准模拟区域调整视角到能看到地形起伏的角度。接下来观察几个关键信号降雨开启后水量场中高处区域水量较少山谷和低洼区域水量明显增多。停止降雨后蒸发参数能让水量逐渐下降而不是一直累积到溢出。相机拖动时水量叠加层与地形贴合没有明显的经纬度偏移。帧率保持在可接受范围没有因为计算导致整个页面卡死。如果水量始终在某个固定地方堆积甚至不随时间变化说明水流方向计算可能有误优先检查高程数据的方向映射关系Cesium 经纬度左上角和 WebGPU 纹理左上角是否对应行列顺序是否反了。8.2 常见问题排查表问题现象可能原因排查方式解决方案页面空白或没有地形Cesium 地形服务加载失败Token 不可用或网络受限打开 Network 面板查看地形请求状态控制台查看 Cesium 报错使用本地地形切片或配置有效的合法地形服务浏览器报navigator.gpu is undefined当前浏览器不支持 WebGPU在地址栏输入chrome://gpu检查状态升级 Chrome/Edge 到较新版本确认浏览器设置水量一直不增加降雨参数未生效或没有写入水量纹理在 WGSL 中临时把降雨量调大检查参数 Buffer 内容检查updateParams是否在每帧被调用Buffer 大小是否匹配水纹闪烁或出现条纹读写同一张纹理产生竞争或者浮点精度问题单步停止模拟观察输出纹理变化使用双缓冲对水量做轻微模糊或限制dt范围帧率低每帧 CPU 回读 GPU 数据开销太大或网格过大打开浏览器性能面板查看 CPU/GPU 耗时降低网格分辨率减少回读频率或改用纹理直传方案水量叠加层和地形错位模拟网格经纬度范围与 Cesium Rectangle 范围不一致打印采样范围和 Rectangle 范围对比保证使用同一组west/south/east/north参数旧系统要求兼容 IE 或 ActiveX 控件WebGPU 和现代 Web 渲染能力无法在旧内核下运行查看项目依赖是否包含旧版浏览器兼容要求降雨汇流模拟类功能必须运行在现代浏览器中不能兼容 IE8.3 性能观察的基准建议第一版 Demo 不要一上来就挑战大网格。建议先用128 x 128网格跑通流程确认降雨、汇流、积水、蒸发这些逻辑都正确后再逐步提升到256 x 256或512 x 512。每次提升网格分辨率时观察两个指标模拟帧耗时Compute Shader 每帧执行耗时。回读耗时从 GPU 把水量数据复制回 CPU 的耗时。如果模拟耗时可忽略但回读耗时增长明显说明瓶颈不在计算而在数据搬运需要优化回读策略或走 GPU 纹理直传路线。9. 工程化建议与后续扩展9.1 先让管道通再追求效果你做降雨汇流模拟时最容易犯的错误是一开始就陷入颜色、粒子、水纹材质这些视觉效果里结果发现底层的水量场根本没算对。正确顺序是先验证“水量场”在正确变化再叠加美观的渲染结果。一个最简单的灰度 Canvas如果水量场运动方向正确、低洼处能积水就已经完成了 80% 的核心工作。后面替换成更漂亮的材质方案只是时间问题。9.2 参数配置化降雨强度、蒸发系数、模拟时间步长、网格分辨率、模拟范围这些都应该抽成配置项。模拟区域不一样时最佳参数差异很大。一个在山地地形上表现良好的参数组合放到平原地区可能完全看不出水流方向。建议把参数放在一个 JSON 或 JS 配置对象里方便不同场景切换时调整。export default { west: 113.2, south: 30.1, east: 113.5, north: 30.4, gridSize: 256, rainRate: 0.02, evaporation: 0.005, dt: 0.16, };9.3 离线与内网环境部署很多三维 GIS 项目最终部署在政务内网或者企业内网无法访问在线地形服务。这时候你需要提前准备本地地形切片方案尽量使用包含高程信息的本地地形数据源再通过内网地址加载。Cesium 的离线地图和本地瓦片方案已经相当成熟把地形服务从在线切换为本地之后降雨汇流模拟核心代码并不需要改动太多影响的只是高程数据的来源。9.4 从降雨汇流扩展到更多仿真场景降雨汇流模拟的核心架构是“地形高程 动态标量场 GPU 逐帧计算 三维渲染回传”。这个架构完全可以复用到很多相邻场景洪涝淹没推演修改输入项从降雨变成边界水位或上游来水。污染扩散模拟把水量场替换成污染物浓度场加入扩散系数。雷达回波驱动洪水演进将雷达估算降雨量作为动态输入让水位实时变化。可视域分析、天际线分析、动态日照模拟这些本质上也是“在三维场景中做空间计算并渲染结果”可以共用一套“Cesium 表现 GPU 计算”的架构。Cesium 的社区里已经有很多雷达效果、动态光照、wall 流动材质、可视域分析相关的案例它们大多停留在“材质表现”这一层。而如果你把 WebGPU 计算能力引入进来很多效果可以从“看起来像”升级成“算出来”。9.5 浏览器兼容与团队协作最后提醒一点WebGPU 是较新的技术即使 Cesium 本身在现代浏览器上表现良好WebGPU 代码仍然要做兼容兜底。建议在项目入口处增加能力检测不支持的浏览器给出友好提示而不是白屏报错。如果团队里还有其他同学维护这个项目一定要把 WGSL 代码和 JS 调度代码拆开并在注释里写清楚每个 Buffer、每个纹理的生命周期和坐标系约定否则后来者很容易把行列顺序改错。10. 总结与后续学习方向降雨汇流模拟听起来像一个“可视化特效需求”但真正落地时会发现它的本质是一个并行计算问题。Cesium 负责表现WebGPU 负责计算是一条非常合理的技术路径。你不需要在 Cesium 内部强行塞入物理模拟逻辑也不需要为了渲染效果去伪装一个没有状态的水流贴图而是把水量场作为一条独立的数据管道让计算结果自然流向渲染层。下一步的实践建议是先搭一个最小的128 x 128网格用灰度图验证水量场变化跑通“采样高程 - 上传 GPU - Compute Shader 计算 - 读回绘制 - 叠加到 Cesium”这条完整链路。确认链路稳定后再逐步加入更精细的地形数据、更复杂的流向算法、更漂亮的渲染材质。这套底子打好了后续无论是接雷达降雨数据还是做淹没分析都会顺畅很多。