
简介本资源是一套基于JavaScript与Cesium三维地理信息平台实现的气象可视化毕业设计项目面向计算机、地信、遥感等专业本科生及课程设计开发者解决GFS全球气象数据解析与动态风场粒子渲染的技术难点。项目完整复现Windy风格的实时风向/风速粒子动画效果涵盖数据预处理、矢量场插值、GPU粒子着色器含frag/vert文件、Cesium图层集成与交互控制等核心模块。压缩包共535个文件11.39MB包含142个JS逻辑脚本、94个source map调试文件、35个JSON配置与GFS元数据、27个CSS样式文件如CesiumWidget.css、Timeline.css等、144个PNG图标及UI资源结构清晰、模块解耦便于学习源码逻辑与二次开发。已有229人学习下载配套完整项目文档、可运行Demo及README说明开箱即用支持在本地快速部署验证是气象可视化方向实践落地的高参考价值方案。 很多同学第一次看到 Windy.com 上那种全球风场粒子效果时第一反应往往是“这东西肯定特别难”。我最初做这个选题时也是这么想的但真正把整个链路走通之后才发现粒子动画本身的代码量并不大真正费时间的是数据解析和坐标转换。这篇内容就把我实现“Cesium GFS 数据风场粒子”的完整过程拆开讲从 GFS 数据长什么样、怎么在 JavaScript 里解析到粒子系统怎么写、怎么叠加到 Cesium 地球上全部覆盖。如果你正在做 Web 三维可视化方向的毕业设计或课程设计希望复现一个类似 Windy 的动态风场效果这篇文章可以直接帮你少走很多弯路。我会把源码结构、踩过的坑、关键参数怎么调都交代清楚。1. 项目整体设计思路与选型1.1 先认清 GFS 数据到底是什么GFS 是全球预报系统Global Forecast System的缩写它提供的是覆盖全球的规则经纬度网格预报数据。默认输出的风场变量通常叫 U东西方向风分量和 V南北方向风分量单位是米每秒。数据文件常见的格式有两种GRIB2 和 NetCDF。这里要强调一下GRIB2 在浏览器端基本没有成熟可用的 JavaScript 解码库所以我的做法是先把数据转成 NetCDF或者干脆在后端/本地用脚本预处理最终导出前端友好的轻量格式。这也是很多类似项目中默认的工程路线。文件命名有规律比如gfs.t00z.pgrb2.0p25.f000拆开看就是t00z表示 00 时起报0p25表示 0.25 度分辨率f000表示预报时效 0 小时也就是分析场后续还有f003、f006这些代表未来 3 小时、6 小时的预报。做课程设计时用f000就足够演示做“时间轴动画”扩展时才需要读取多个时效。搞清数据结构之后整个项目的链路就很清晰了下载 GFS 数据到本地用 Node.js 脚本解析出 U/V 网格数组降采样后导出 JSON 或二进制文件前端拿到这个轻量数据在 Canvas 上运行粒子系统最后叠加到 Cesium 三维地球上形成动态风场效果。1.2 渲染方案怎么选叠加 Canvas、WebGL 还是后处理我见过不少人在粒子渲染方案上纠结这里直接说结论。实现风场粒子主要有三条路Canvas 2D 叠加绘制用离屏 Canvas 绘制粒子尾迹再把结果叠加到 Cesium 场景中。优点是代码量少、调试方便、CPU 也能跑得动粒子数量控制在 5000 以下效果很稳。缺点是粒子量大了之后性能下降无法做非常复杂的配色和尾迹效果。WebGL 粒子系统把 U/V 风场数据编码到纹理里粒子的位置也存放在纹理中每一帧通过 Fragment Shader 计算粒子下一帧位置。这是 Mapbox 开源项目 webgl-wind 的做法性能很好几万粒子也能跑满帧。但需要额外处理纹理格式、Shader 调试对课程设计来说工程量偏大。Cesium 后处理阶段PostProcessStage在 Cesium 渲染管线里加一个自定义后处理把粒子绘制到整个屏幕四边形上。这样粒子能和地球融合得更好但实现难度最高需要理解 Cesium 的内部渲染流程。我的建议是如果不是要冲高分或者有足够时间直接用 Canvas 2D 方案。毕设答辩时重点是讲清楚“数据解析 → 插值 → 粒子系统 → 地图集成”这条链路Canvas 2D 方案足够拿得出手效果也已经很像 Windy 了。1.3 为什么选择“数据预处理 前端轻量解析”GFS 原始数据非常大0.25 度分辨率的全球风场文件动辄上百 MB而且 NetCDF 内部还包含很多用不到的变量和维度。如果让浏览器直接去解析不仅加载慢还容易出现内存溢出。我在项目里采用的是两步走第一步用 Node.js 写解析脚本读取 NetCDF 里的 U/V 变量降采样到一个合适的网格尺寸导出成一个集中的wind.json文件第二步浏览器端只负责加载这个轻量 JSON 并渲染。这样做的好处有三个前端代码逻辑简单不需要引入体积庞大的 NetCDF 解析依赖数据量从几十 MB 降到几百 KB页面秒开降采样可以在脚本里精确控制比如把全球网格从 0.25 度降到 1 度甚至 2 度根据目标机器性能灵活调整。“所有环节都用 JavaScript”这个要求是满足的只是把解析环节从浏览器挪到了 Node 环境工程上更合理。2. GFS 数据解析从二进制文件到风场网格2.1 数据下载与预处理工具链GFS 数据可以从 NCEP 的 NOMADS 服务器下载也可以找一些数据镜像站。挑选文件时注意分辨率和变量范围。如果是课程设计不追求实时数据建议下载某个历史时刻的文件放在本地使用。下载下来的原始文件大多还是 GRIB2我的习惯是先用 Python 的xarray和cfgrib库转成 NetCDF再只保留u-component_of_wind_height_above_ground、v-component_of_wind_height_above_ground这两个变量。这一步虽然用到了 Python但只做格式转换后续还是 Node.js 负责解析。如果你不想依赖 Python也可以直接下载 NetCDF 格式的数据跳过转换这一步。转换命令大致是import xarray as xr ds xr.open_dataset(gfs.t00z.pgrb2.0p25.f000, enginecfgrib) ds[[u-component_of_wind_height_above_ground, v-component_of_wind_height_above_ground]].to_netcdf(wind_raw.nc)转换完打开 NetCDF 我建议先打印一下维度结构因为不同来源的数据维度顺序可能不一样。最常见的是(lat, lon)有的则是(lon, lat)。这一步搞错后面插值出来的风场就会乱。2.2 基于 Node.js 的解析脚本拿到 NetCDF 后用 Node.js 的netcdf4包来读取。我这里只贴核心读取逻辑完整代码在项目里可以找到const netcdf4 require(netcdf4); const fs require(fs); const file new netcdf4.File(path.join(__dirname, wind_raw.nc)); const root file.root; const uVar root.variables[u-component_of_wind_height_above_ground]; const vVar root.variables[v-component_of_wind_height_above_ground]; const latData root.dimensions.latitude.data(); const lonData root.dimensions.longitude.data(); const u uVar.read(); const v vVar.read(); console.log(lat:, latData.length, latData[0], latData[latData.length - 1]); console.log(lon:, lonData.length, lonData[0], lonData[lonData.length - 1]); console.log(u shape:, u.length, u[0].length);读取之后先别急着导出先确认两件事纬度是从大到小还是从小到大经度是 0 到 360 还是 -180 到 180。这两点非常关键也是我第一次做时踩坑最多的地方。比如 GFS 多数文件的经度是 0 到 359.75而 Cesium 和 Canvas 上的习惯是 -180 到 180需要重排function reorderLon(u, v, lonData) { // 找到经度数组中第一个大于 180 的位置 const splitIndex lonData.findIndex(lon lon 180); const uReordered u.map(row [...row.slice(splitIndex), ...row.slice(0, splitIndex)]); const vReordered v.map(row [...row.slice(splitIndex), ...row.slice(0, splitIndex)]); return { u: uReordered, v: vReordered }; }纬度方向也要注意。绘制时习惯纬度从南到北递增-90 到 90如果原始数据的 lat 数组是从北极开始需要翻转数组。2.3 降采样与导出前端友好格式原始 0.25 度全球网格大约是 1440 x 721在 Canvas 2D 方案里直接使用这个分辨率每帧插值计算量会非常大必须降采样。我通常把全球网格降到 180 x 90也就是约 2 度间隔粒子数量 3000 左右普通笔记本上表现流畅。降采样最简单的方式就是固定间隔抽点const targetWidth 180; // 经度方向网格数 const targetHeight 90; // 纬度方向网格数 function downsample(grid, srcH, srcW, dstH, dstW) { const result []; for (let y 0; y dstH; y) { const srcY Math.floor((y / (dstH - 1)) * (srcH - 1)); const row []; for (let x 0; x dstW; x) { const srcX Math.floor((x / (dstW - 1)) * (srcW - 1)); row.push(grid[srcY][srcX]); } result.push(row); } return result; }如果只想做某个区域比如中国区域可以按经纬度范围裁剪后再降采样这样同样网格数下细节更丰富。做完之后把数据写成一个统一的 JSON{ width: 180, height: 90, lonMin: -180, lonMax: 180, latMin: -90, latMax: 90, u: [/* 二维数组按行存储 */], v: [/* 二维数组按行存储 */] }有人会直接把 U/V 数组转成 PNG 纹理再加载目的是用 GPU 读取。Canvas 2D 方案不需要这么做JSON 足够。但如果后续想升级到 WebGL 版本建议数据导出时同时生成一套 U 通道、V 通道的 PNG 图片会省很多事。3. 粒子效果核心算法实现3.1 网格插值双线性插值怎么算风场数据是离散的格点粒子的位置又是连续的经纬度所以每一步都要用当前粒子位置周围的四个格点做双线性插值得到该位置的 U/V 值。公式不复杂核心是把经纬度映射到网格索引function getWind(lon, lat) { // 经纬度映射到网格坐标浮点 const x ((lon - lonMin) / (lonMax - lonMin)) * (width - 1); const y ((latMax - lat) / (latMax - latMin)) * (height - 1); const x0 Math.floor(x); const y0 Math.floor(y); const x1 Math.min(x0 1, width - 1); const y1 Math.min(y0 1, height - 1); const fx x - x0; const fy y - y0; const u00 u[y0][x0], u10 u[y0][x1]; const u01 u[y1][x0], u11 u[y1][x1]; const uInterp (u00 * (1 - fx) u10 * fx) * (1 - fy) (u01 * (1 - fx) u11 * fx) * fy; // v 方向同理 const v00 v[y0][x0], v10 v[y0][x1]; const v01 v[y1][x0], v11 v[y1][x1]; const vInterp (v00 * (1 - fx) v10 * fx) * (1 - fy) (v01 * (1 - fx) v11 * fx) * fy; return { u: uInterp, v: vInterp }; }注意我这里映射纬度的方式网格数组行索引从上到下对应高纬度到低纬度所以lat越大y越小。如果你的数组是纬度从小到大公式里的latMax - lat要反过来。还有一种情况是粒子越过经度边界比如从 179 度跑到 -179 度网格索引会跳变。处理方式是查询前把经度做归一到 -180 到 180 范围内。3.2 粒子更新、拖尾与重置逻辑粒子系统的核心逻辑非常像“质点追踪”每个粒子都记录自己的经纬度每帧根据当前位置的风速算出一个位移更新坐标。更新公式particle.lon particle.u * dt * lonScale; particle.lat - particle.v * dt * latScale;这里dt是时间步长lonScale和latScale是调节粒子速度显示快慢的参数。有人会困惑为什么风速单位是 m/s但经纬度单位是度直接相加是对的么其实在这个系统里并不追求物理准确我们只是把风矢量当成“引导粒子移动的力”通过lonScale和latScale来控制视觉效果所以叫粒子系统而不是数值预报。这个比例我一般取 0.001 到 0.01 左右具体要看网格间隔和帧率调到粒子流动自然即可。拖尾效果是 Windy 视觉上最吸引人的一点。在 Canvas 2D 方案里最经典的实现是“半透明黑填充 新粒子点”ctx.globalCompositeOperation source-over; ctx.fillStyle rgba(0, 0, 0, 0.08); ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.globalCompositeOperation lighter;先画半透明黑色矩形再画粒子点。由于之前帧留下了一些亮色轨迹而每一帧都被叠加了一层半透明黑越早的轨迹越暗看起来就像粒子托着一条尾巴流动。但这里有个问题如果你要把粒子叠加到 Cesium 地球上面用黑色填充会把地球盖住所以必须改成透明拖尾方案。做法是用destination-out合成模式做半透明擦除ctx.globalCompositeOperation destination-out; ctx.fillStyle rgba(0, 0, 0, 0.05); ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.globalCompositeOperation lighter;这样背景保持透明粒子轨迹依然有渐隐效果。粒子的重置条件也很重要。我一般处理三种情况粒子跑出经度范围、纬度超出南北极、当前位置风速太小小于 0.1 m/s或者粒子存活时间超过阈值。重置时把它随机丢回画布内避免画面粒子逐渐变少。3.3 风速可视化色带与透明度的调优Windy 的视觉核心除了粒子流动就是颜色编码。风速大小用Math.sqrt(u * u v * v)计算然后映射到一个色带。常用的色带是低风速青蓝色、中风速黄绿色、高风速橙红色。我用 RGB 数组插值实现const colorRamp [ { max: 2, color: [96, 202, 255] }, { max: 6, color: [80, 255, 180] }, { max: 12, color: [255, 240, 100] }, { max: 20, color: [255, 130, 60] }, { max: 40, color: [255, 60, 60] } ]; function speedColor(speed) { for (let i 0; i colorRamp.length; i) { if (speed colorRamp[i].max) { const ramp colorRamp[i - 1] || colorRamp[i]; const t (speed - ramp.max) / (colorRamp[i].max - ramp.max); // 在 ramp.color 和 colorRamp[i].color 之间线性插值 } } }透明度也可以随速度变化。低风速区域粒子较淡高风速区域粒子抢眼。这样画面会更有层次感。这个细节很多人忽略但实际效果差异很大。4. Cesium 集成让粒子真正“贴”在地球上4.1 方案 ApostRender 叠加绘制想要粒子效果和 Cesium 地球融合得最好直接在 Cesium 的scene.postRender事件里把粒子绘制到主 Canvas 上。具体流程是创建 Cesium Viewer用viewer.scene.postRender.addEventListener回调在回调中获取主场景 Canvas 的 2D 上下文逐个粒子把经纬度转成屏幕坐标绘制到主 Canvas 上。经纬度转屏幕坐标可以用 Cesium 自带的接口const scratchCartesian new Cesium.Cartesian3(); const scratchWindow new Cesium.Cartesian2(); function lonLatToWindow(lon, lat) { Cesium.Cartesian3.fromDegrees(lon, lat, 0, scratchCartesian); return Cesium.SceneTransforms.worldToWindowCoordinates( viewer.scene, scratchCartesian, scratchWindow ); }这个方案视觉效果最好粒子会跟随地球旋转、缩放而且有真实的透视关系。缺点是每帧对每个粒子做worldToWindowCoordinates计算量不小粒子数量上了 5000 之后性能会比较吃紧。而且直接在 Cesium 的主 Canvas 上绘制要特别注意lighter合成模式和透明拖尾操作不会影响 Cesium 本身的地球渲染。我的实际经验是如果你要对效果做细调这个方案会让你比较痛苦因为粒子层和 Cesium 的画面完全混在一起调试时难以分辨是粒子问题还是场景问题。4.2 方案 B全屏覆盖层与视口坐标映射推荐这个方案是我在课程设计里最终采用的也是这篇文章重点推荐的。思路很简单在 Cesium 容器上用 CSS 覆盖一个独立的 Canvas 作为粒子层和 Cesium 场景分开。粒子层和 Cesium 之间怎样同步不是直接读取每个粒子的 3D 坐标而是获取当前相机视口在球面上看到的四个角的经纬度然后做一个线性映射把粒子经纬度换算到粒子层 Canvas 的像素坐标。获取四个角经纬度的方法const canvas viewer.canvas; const rect viewer.camera.computeViewRectangle(); // 返回 Rectangle 或 undefinedcomputeViewRectangle()在视角跨过 180 度经线或靠近极地时可能会返回 undefined所以要加判断。如果返回 undefined就用默认范围。更稳的做法是通过射线拾取四个屏幕角function getViewBounds(viewer) { const scene viewer.scene; const gl viewer.scene.canvas; const width gl.clientWidth; const height gl.clientHeight; const corners [ [0, 0], [width, 0], [0, height], [width, height] ]; const rects corners.map(([x, y]) { const ray viewer.camera.getPickRay(new Cesium.Cartesian2(x, y)); const cartesian viewer.scene.globe.pick(ray, scene); if (cartesian) { const carto Cesium.Cartographic.fromCartesian(cartesian); return { lon: Cesium.Math.toDegrees(carto.longitude), lat: Cesium.Math.toDegrees(carto.latitude) }; } return null; }); return rects; }拿到四角后通常取南北纬度的最大最小值作为latMin/latMax取东西经度作为lonMin/lonMax建立屏幕坐标和经纬度的近似映射。由于粒子层 Canvas 是平面这种线性映射在视角比较俯视、视场角不大时精度足够。当相机大角度倾斜时会产生偏移我的处理是限制用户最小俯仰角不让视角太歪既保证效果又减少畸变。粒子层 Canvas 的尺寸不需要和 Cesium 容器完全一致建议按照容器实际像素尺寸设置并开启ctx.setTransform(dpr, 0, 0, dpr, 0, 0)处理高分屏。这个方案的优点是调参非常简单独立 Canvas 上想怎么画就怎么画不会影响地球渲染粒子层透明背景拖尾擦除操作也安全。缺点是粒子不会真正贴在球面上视角旋转时会有一些视觉偏差但对课程设计来说完全足够。4.3 相机联动与性能优化无论用方案 A 还是方案 B相机变化之后都要重算粒子层。方案 B 里我监听camera.changed事件在相机停止后重建投影范围并触发重绘。viewer.camera.changed.addEventListener(function () { if (!viewer.camera.percentageChanged) return; windLayer.updateProjection(); });percentageChanged是 Cesium 相机距离发生了超过 0.5% 的变化才触发避免每帧都重算。性能优化方面我实测的一组参考参数配置项推荐值备注粒子数量3000 - 5000超过 8000 建议上 WebGL网格尺寸180 x 902 度间隔粒子层 Canvas 尺寸容器 CSS 像素宽高不要超过实际像素太多渲染方式Canvas 2D destination-out 拖尾背景透明不影响地球帧率控制requestAnimationFrame配合暂停开关如果发现帧率低优先降粒子数量再降网格分辨率。网格从 180 x 90 降到 120 x 60粒子从 5000 降到 2000帧率能提升三到四倍肉眼上看不出太明显差别。5. 源码结构与 Demo 运行说明5.1 项目目录与核心模块拿到项目源码后先看目录结构。我习惯把数据解析、风场逻辑、Cesium 集成、页面入口分得很清楚windy-cesium/ ├── index.html ├── package.json ├── README.md ├── data/ │ ├── parseGFS.js # Node.js 解析脚本 │ └── wind.json # 解析后导出的风场数据 ├── src/ │ ├── main.js # 入口文件负责初始化 Cesium 和风场 │ ├── core/ │ │ ├── WindField.js # 风场数据模型负责插值查询 │ │ ├── ParticleSystem.js# 粒子系统负责更新、绘制、重置 │ │ └── ColorRamp.js # 风速颜色映射 │ ├── cesium/ │ │ └── CesiumWindLayer.js # Cesium 集成层负责叠加 Canvas │ └── utils/ │ └── geo.js # 经纬度与网格坐标转换工具 └── docs/ ├── 项目设计文档.md └── 使用说明.mdparseGFS.js是纯 Node 环境脚本不需要浏览器运行。WindField.js不依赖 Cesium所以可以单独测试这对调试非常有用。ParticleSystem.js也是纯 Canvas 逻辑脱离地图环境也能跑。最后一个CesiumWindLayer.js才真正和服务绑定。5.2 关键代码导读WindField.js的核心就是构造函数和插值方法export default class WindField { constructor(data) { this.width data.width; this.height data.height; this.lonMin data.lonMin; this.lonMax data.lonMax; this.latMin data.latMin; this.latMax data.latMax; this.u data.u; this.v data.v; } getWind(lon, lat) { // 双线性插值代码见上文 } }ParticleSystem.js里比较重要的是粒子数组的管理。初始化时随机撒粒子每帧先擦除旧尾迹再更新所有粒子最后绘制export default class ParticleSystem { constructor(windField, options) { this.windField windField; this.count options.count || 3000; this.particles []; this.init(); } init() { for (let i 0; i this.count; i) { this.particles.push({ lon: Math.random() * 360 - 180, lat: Math.random() * 180 - 90, maxAge: Math.random() * 100, age: 0 }); } } update(dt) { for (const p of this.particles) { const { u, v } this.windField.getWind(p.lon, p.lat); p.lon u * dt * 0.008; p.lat - v * dt * 0.008; p.age; if (p.lon 180) p.lon - 360; if (p.lon -180) p.lon 360; if (p.lat 90 || p.lat -90 || p.age p.maxAge) { this.resetParticle(p); } } } }CesiumWindLayer.js负责创建独立 Canvas把它挂到 Cesium 容器上同时维护投影参数。对外暴露的核心方法就三个start()、stop()、setProjection()。5.3 从零跑通 Demo 的完整步骤如果你拿到的是一份完整的源码按下面步骤操作即可cd windy-cesium npm install npm run devnpm run dev实际是在本地起一个静态服务因为浏览器加载模块需要 HTTP 环境。打开http://localhost:8080后页面会先初始化 Cesium 地球如果没看到粒子检查控制台是否有报错最常见的是 Cesium ion token 没替换。新版 Cesium 从 CDN 加载时需要用Cesium.Ion.defaultAccessToken设置自己的 token。如果没有 token最简单的办法是在创建 Viewer 时指定一个本地影像const viewer new Cesium.Viewer(cesiumContainer, { imageryProvider: new Cesium.TileMapServiceImageryProvider({ url: Cesium.buildModuleUrl(Assets/Textures/NaturalEarthII) }) });这样不需要任何 token 就能看到地球底图适合本地演示。在线影像更美观但需要自行申请 token。启动成功后你会看到类似 Windy 的粒子在地球表面流动。页面右上角一般会放几个控件粒子开关、速度调节、粒子数量选择。这些都可以直接改ParticleSystem的count和update里的速度系数来实现。6. 常见问题与排查技巧实录6.1 典型问题速查表我把自己做这个项目时踩过的问题整理成了一张表按场景对号入座现象可能原因解决办法Cesium 白屏token 无效或网络加载 CDN 失败换有效 token或改用本地 NaturalEarthII 影像页面正常但无粒子wind.json 没加载成功F12 查看 Network确认 JSON 是否请求到粒子满天乱飞U/V 数组方向不对或经纬度映射反了打印 lat 数组确认是升序还是降序检查插值公式粒子集中在某一条线上经度重排没做粒子跨边界时跳变做 lon 从 0-360 到 -180-180 的重排拖尾把地球盖住粒子 Canvas 叠加层使用source-over 黑色填充改destination-out 半透明擦除粒子不跟随地图旋转方案 B 的投影矩阵没有随相机更新监听camera.changed重算四角经纬度帧率很低粒子数太多或 Canvas 尺寸过大降粒子数到 2000网格降到 120x60粒子速度过快看不清楚速度系数太大把update里的 0.008 调到 0.001 到 0.005 之间粒子方向不对向西吹的变成向东U 分量符号取反检查数据单位个别数据集 U 正方向定义不同6.2 排错思路与工具遇到粒子效果不对时我的习惯是先去调试数据而不是直接看渲染效果。一个很好用的办法是画箭头图随便选一个网格子集在 Canvas 上把每个网格点的风向画成小箭头这样一眼就能看出风场整体走向是否符合现实。如果箭头图上能看出本文还有配套的精品资源点击获取