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

Cesium三维风场可视化(下):在 Cesium 里实现高性能三维粒子风场
SEAlencehe一、整体架构:为什么一定要把“计算”搬到 GPU
三维风场最容易踩的坑是:你用 CPU 去更新上万粒子,再把每个粒子画到 Cesium 图元上——很快就会卡。
本实现的核心策略是:
- 风场数据一次性上传 GPU(WebGL2 的
sampler3D) - 粒子状态也存 GPU 纹理里(位置/年龄等)
- 每帧只做两次 GPU pass:
- Update pass:在 fragment shader 里更新粒子位置(并写回纹理)
- Draw pass:在 vertex/fragment shader 里把粒子画成箭头线段
这样 CPU 侧工作极少:只负责传参数、必要时重建纹理/命令。
二、Wind3D 的三类“核心 GPU 资源”
1)风场 3D 纹理:windTexture (sampler3D)
数据来自上一篇生成的 wind3d.json,最终在 GPU 里存成一个 3D 纹理:
- 纹理尺寸:
nx × ny × nz - 每个体素(voxel)存:
R = uG = vB = wA可空着
关键点:
- 必须是 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 的核心流程(概念化):
- 从
currentParticlesPosition读取粒子posNorm与age - 用
posNorm采样windTexture得到windVector posNorm += windVector * speedFactor * dt- 边界 wrap(把粒子留在
[0,1]) - 如果 age 超时:重生(随机新的
posNorm,age 归零) - 写出两路输出(位置 + 速度)
// 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]³,要把它落到地球上,需要做映射:
- 归一化 → 地理范围
lon = mix(boundsMinLon, boundsMaxLon, posNorm.x)
lat = mix(boundsMinLat, boundsMaxLat, posNorm.y)
h = mix(boundsMinHeight, boundsMaxHeight, posNorm.z)
- 大地坐标 → ECEF
在 shader 里实现 geodeticToECEF(lon, lat, h)(WGS84 椭球公式)
- 粒子局部 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 做成全球数据,最常见的需求是:
- 视角/业务区域变化时,只显示某一块区域
- 粒子密度跟着区域大小变化(看起来密度一致)
本实现的思路是:
switchToRealData('/wind3d.json')加载后先缓存全量 JSON(CPU 内存)- 当调用
setBounds(minLon,maxLon,minLat,maxLat):- 根据
header.lo1/la1/dx/dy把 bounds 映射成网格索引范围 - 从缓存里提取子集,重新拼一个更小的 3D 纹理数据
- 重建
windTexture
- 根据
这种策略的好处:
- 网络只拉一次全量(或你也可以只下区域版)
- 前端切换范围时只做 CPU 裁剪 + 重建纹理,运行期仍然是 GPU 粒子
七、粒子密度自适应:区域越大粒子越多,但要“受 GPU 限制”
粒子数是 size²,而 size 受 MAX_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)
十、性能与稳定性建议
- 数据侧:
- 先用
1°跑通,再上0.25° - 能裁剪就裁剪(区域版 JSON 速度质变)
- 先用
- 渲染侧:
- 粒子数不要迷信越多越好:先 16k → 65k,再根据 GPU 调
- 线段/箭头比 “长拖尾曲线” 更容易跑满
- 兼容性:
- 必须 WebGL2(不支持就直接提示降级/不展示)
参考资料
- 槑的秘密基地. 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.
- 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.


