Cesium三维风场可视化(下):在 Cesium 里实现高性能三维粒子风场

一、整体架构:为什么一定要把“计算”搬到 GPU

三维风场最容易踩的坑是:你用 CPU 去更新上万粒子,再把每个粒子画到 Cesium 图元上——很快就会卡。

本实现的核心策略是:

  • 风场数据一次性上传 GPU(WebGL2 的 sampler3D
  • 粒子状态也存 GPU 纹理里(位置/年龄等)
  • 每帧只做两次 GPU pass:
    1. Update pass:在 fragment shader 里更新粒子位置(并写回纹理)
    2. Draw pass:在 vertex/fragment shader 里把粒子画成箭头线段

这样 CPU 侧工作极少:只负责传参数、必要时重建纹理/命令。


二、Wind3D 的三类“核心 GPU 资源”

1)风场 3D 纹理:windTexture (sampler3D)

数据来自上一篇生成的 wind3d.json,最终在 GPU 里存成一个 3D 纹理:

  • 纹理尺寸:nx × ny × nz
  • 每个体素(voxel)存:
    • R = u
    • G = v
    • B = w
    • A 可空着

关键点:

  • 必须是 WebGL2(WebGL1 没有 3D 纹理)
  • 为了精度与范围,通常用 float/half-float(本实现使用 RGBA16F

2)粒子“位置状态”纹理:particlesTextures[2]

用 2 张 2D 浮点纹理做 ping-pong

  • 分辨率:particlesTextureSize × particlesTextureSize
    粒子数 = size²(比如 128×128=16384
  • 每个像素 RGBA 表示 1 个粒子:
    • xyz:粒子归一化位置(都在 [0,1]
    • w:打包信息(年龄 + 风速桶)

3)粒子“速度/方向”纹理:velocityTextures[2]

同样 2 张 ping-pong,用于在 draw 阶段给箭头提供朝向(避免重复采样/重复计算)。


三、Update Pass:用 MRT 一次写出“新位置 + 新速度”

Update pass 的做法是一个经典 GPU 粒子套路:

  • 画一个全屏 quad(覆盖整个粒子纹理 viewport)
  • fragment shader 对“每个像素”做一次粒子更新
  • 通过 MRT(Multiple Render Targets) 一次输出两张纹理:
    • positionOut:下一帧粒子位置纹理
    • velocityOut:下一帧粒子速度纹理

Update pass 的核心流程(概念化):

  1. currentParticlesPosition 读取粒子 posNormage
  2. posNorm 采样 windTexture 得到 windVector
  3. posNorm += windVector * speedFactor * dt
  4. 边界 wrap(把粒子留在 [0,1]
  5. 如果 age 超时:重生(随机新的 posNorm,age 归零)
  6. 写出两路输出(位置 + 速度)
// fragment shader (示意)
vec4 particle = texture(currentParticlesPosition, uv);
vec3 posNorm = particle.xyz;
float age = fract(particle.w);

age += decaySpeed;
if (age > 1.0) {
  age = 0.0;
  posNorm = hashTo3D(uv);   // 重生
}

vec3 wind = texture(windTexture, posNorm).xyz;
posNorm = fract(posNorm + wind * speedFactor * 0.001);

positionOut = vec4(posNorm, packSpeedAndAge(wind, age));
velocityOut = vec4(wind, 0.0);

四、Draw Pass:把每个粒子画成“有拖尾的箭头线段”

4.1 为什么不用“每粒子一组顶点”放 CPU

因为粒子数动辄上万,CPU 侧每帧组织顶点缓冲会很重。

本实现的取巧方式是:

  • 创建一个 只包含粒子索引的 VBO(0..(N*verticesPerParticle-1)
  • vertex shader 根据索引推导:
    • 这个顶点属于哪个粒子(pIdx
    • 这个顶点属于箭头的哪一段(尾部/尖端/左右翼)
  • 再从粒子纹理里采样出该粒子的 posNorm + vel,在 shader 里构造箭头几何

4.2 归一化坐标 → 经纬高 → ECEF → 局部 ENU(球面贴合的关键)

粒子状态存的是 posNorm ∈ [0,1]³,要把它落到地球上,需要做映射:

  1. 归一化 → 地理范围
lon = mix(boundsMinLon, boundsMaxLon, posNorm.x)
lat = mix(boundsMinLat, boundsMaxLat, posNorm.y)
h   = mix(boundsMinHeight, boundsMaxHeight, posNorm.z)
  1. 大地坐标 → ECEF

在 shader 里实现 geodeticToECEF(lon, lat, h)(WGS84 椭球公式)

  1. 粒子局部 ENU → 统一参考 ENU

如果你直接在 ECEF 里做“箭头前进方向”,会出现方向不贴地、不同经纬度方向不一致的问题。
解决办法:选一个“中心参考点”(bounds 中心),把所有粒子都转到这个中心点的 ENU 局部坐标系里绘制:

  • 粒子 ECEF 位置:ecefPos
  • 中心 ECEF:centerECEF
  • 中心 ENU 旋转矩阵:ecefToLocal
  • 得到局部坐标:localPos = ecefToLocal * (ecefPos - centerECEF)

同理,速度向量也做一次“粒子 ENU → ECEF → 中心 ENU”的转换,箭头方向就会贴合球面。


五、颜色映射与透明度:用“风速桶 + 年龄”做可读性

Draw pass 的 fragment shader 做两件事:

  • 颜色:根据归一化风速映射到渐变(蓝→青→绿→黄→红)
  • 透明度:根据年龄做衰减(形成拖尾/流动感)

本实现里粒子 w 分量里打包了“风速桶 + age”,用 floor()fract() 拆出来:

float packed = particle.w;
float age = fract(packed);
float speed01 = floor(packed) / 255.0;

这样每个粒子不用额外存 speed,就能做:

  • 速度剔除(cullSpeedMin/Max
  • 颜色区间(windSpeedMin/Max

六、范围裁剪(bounds)与“只重建必要的数据”

如果你把 wind3d.json 做成全球数据,最常见的需求是:

  • 视角/业务区域变化时,只显示某一块区域
  • 粒子密度跟着区域大小变化(看起来密度一致)

本实现的思路是:

  1. switchToRealData('/wind3d.json') 加载后先缓存全量 JSON(CPU 内存)
  2. 当调用 setBounds(minLon,maxLon,minLat,maxLat)
    • 根据 header.lo1/la1/dx/dy 把 bounds 映射成网格索引范围
    • 从缓存里提取子集,重新拼一个更小的 3D 纹理数据
    • 重建 windTexture

这种策略的好处:

  • 网络只拉一次全量(或你也可以只下区域版)
  • 前端切换范围时只做 CPU 裁剪 + 重建纹理,运行期仍然是 GPU 粒子

七、粒子密度自适应:区域越大粒子越多,但要“受 GPU 限制”

粒子数是 size²,而 sizeMAX_TEXTURE_SIZE 约束。
本实现做了:

  • 记录一个“基准粒子数”(你在 GUI 里设置的值)
  • 根据 bounds 面积比例自动缩放目标粒子数
  • 再把目标粒子数映射到“最近的 2 的幂纹理边长”
  • 最后 clamp 到 GPU 允许上限(并额外限制不超过 2048,避免显存爆)

这能带来更一致的视觉体验:你从 10°×10° 缩到 1°×1°,不会突然“粒子密到一坨”。


八、GeoJSON 一键设定范围:工程上非常实用

做业务时范围通常来自行政区/海域边界等 GeoJSON。
本实现的做法是:

  • 递归收集 GeoJSON 里所有坐标
  • 计算 bbox(minLon/maxLon/minLat/maxLat)
  • 适当加一点 padding(比如 0.5°)
  • 直接调用 setBounds(...)

这能把“数据裁剪 + 粒子密度”完整串起来,用户体验很强。


九、在 Vue / Cesium 里怎么接

import * as Cesium from 'cesium'
import { Wind3D } from './lib/wind/Wind3D'

const viewer = new Cesium.Viewer('cesiumContainer', {
  /* ... */
})

const wind3d = new Wind3D(viewer, {
  minLon: 110,
  maxLon: 120,
  minLat: 20,
  maxLat: 30,
  windThickness: 16000,
})

// 作为 primitive 加入场景(Cesium 会在渲染循环里调用 update)
viewer.scene.primitives.add(wind3d)

十、性能与稳定性建议

  • 数据侧
    • 先用 跑通,再上 0.25°
    • 能裁剪就裁剪(区域版 JSON 速度质变)
  • 渲染侧
    • 粒子数不要迷信越多越好:先 16k → 65k,再根据 GPU 调
    • 线段/箭头比 “长拖尾曲线” 更容易跑满
  • 兼容性
    • 必须 WebGL2(不支持就直接提示降级/不展示)

参考资料

  1. 槑的秘密基地. Cesium 中实现三维风场粒子效果 [EB/OL]. https://www.liaomz.top/2025/09/11/cesium-zhong-shi-xian-san-wei-feng-chang-li-zi-xiao-guo/, 2025-09-11.
  2. jiawanlong. Cesium-Examples 风场 [EB/OL]. https://jiawanlong.github.io/Cesium-Examples/examples/cesiumEx/editor.html#8.1.4%E3%80%81%E9%A3%8E%E5%9C%BA.

演示地址

https://yuangis.site/demo/cesium/3DWind