用Three.js搞个炫酷风场图

5,336 阅读14分钟

风场图,指根据风速风向数据进行渲染,以表征空气流动方向、流动速度的一种动态流场图。接下来让我们学一下怎么实现炫酷的2D和3D风场图吧!

vvvv.gif

一、 获取风场数据

  1. 打开NCEP(美国气象环境预报中心)
  2. 查看Climate Models(气候模型)的部分
  3. 点击Climate Forecast System 3D Pressure Products(气候预报系统3D大气压产品)的grib fiter选择数据下载

image.png 4. 界面会有不同日期的数据提供下载,我们选择默认最新的那个日期就好

  1. 一堆看不懂的参数,没关系,我们只需要在Levels图层这里勾选max wind这个就好(因为我们要画风场图),不推荐Levels勾选all,数据太大,下载慢,并且看不懂,用不到。
  2. 点击Start download就可以下载了

image.png

二、处理风场数据

grib这个数据格式打不开,看不懂,需要转换成json,有位大牛A写了个java的grib处理工具(grib2json),然而我用maven打包失败了,然后发现有另一位大牛B封装了大牛A的jar包成node脚本,正好给前端开发者使用。

  1. 安装@weacast/grib2json
pnpm add -D @weacast/grib2json
  1. 执行脚本,将grib转换成json

使用说明

Usage: grib2json (or node bin.js) [options] <file>
  -V, --version                   输出版本号
  -d, --data                       输出GRIB记录数据
  -c, --compact                    压缩json
  -fc, --filter.category <value>   选择类目值
  -fs, --filter.surface <value>    选择表面类型
  -fp, --filter.parameter <value>  选择参数值
  -fv, --filter.value <value>      选择表面值
  -n, --names                      打印数字代码的名称
  -o, --output <file>              输出文件名
  -p, --precision <precision>      使用小数点后几位数的精度(默认值:-1)
  -v, --verbose                    启用stdout日志记录
  -bs, --bufferSize <value>        stdout或stderr上允许的最大数据量(以字节为单位)
  -h, --help                       使用帮助
pnpm exec  grib2json -c --names --data --fp 2 --fs 103 --fv 10.0 -o output.json D:/code/wind/pgbf2024103000.01.2024103000.grb2

注意:

  • --fs 103表面类型103(地面以上指定高度)
  • --fv 10.0 距离GRIB2文件10.0米的表面值
  • --fp 2 将参数2(U-component_of_wind)的记录输出到stdout
  • 需要转换的grib文件放在最后,文件路径要用完整的路径名称
  1. 数据格式说明
{
       "header":{
              //数据更新时间
           "refTime":"2024-10-30T00:00:00.000Z",
           
           "parameterCategory":2,//类目号,2表示风力
           "parameterCategoryName":"Momentum",
           "parameterNumber":2,//2表示u,3表示v
           "parameterNumberName":"U-component_of_wind",            
           "numberPoints":65160,//点数量            
           "nx":360,//横向栅格数量
           "ny":181, //纵向栅格数量
           "lo1":0.0,//开始经度
           "la1":-90.0,//开始纬度
           "lo2":359.0,//结束经度
           "la2":90.0,//结束纬度
           "dx":1.0,//横向步长
           "dy":1.0//纵向补偿
       },
       "data":[//方向数据,u数据,要搭配另一个v的数据使用
           -7.8,
           -7.9,
           ]
        }

U表示横向风速,V表示纵向风速,UV的正负值表示风向

  1. output.json有2.25MB大,数据里面除了uv方向的数据,还包含了其他的数据,我们只需要有用的一个header和uv数据即可,可以用node处理一下,得到一个header信息数据info.json和风向数据wind.json
const fs = require('fs');
const output = require('./output.json');
let uData = [];
let vData = [];
let header = {};
for (let i = 0; i < output.length; i++) {
 if (output[i].header.parameterNumber === 2) {//u的数据集
   uData = output[i].data;
   header = output[i].header;
 } else if (output[i].header.parameterNumber === 3) {//v的数据集
   vData = output[i].data;
 }
}

const len = uData.length;
const list = [];
const info = {
 minU: Number.MAX_SAFE_INTEGER,
 maxU: Number.MIN_SAFE_INTEGER,
 minV: Number.MAX_SAFE_INTEGER,
 maxV: Number.MIN_SAFE_INTEGER,
 ...header
};
for (let i = 0; i < len; i++) {
//uv数据组合
 list.push([uData[i], vData[i]]);
 //计算最大最小边界值
 info.minU = Math.min(uData[i], info.minU);
 info.maxU = Math.max(uData[i], info.maxU);
 info.minV = Math.min(vData[i], info.minV);
 info.maxV = Math.max(vData[i], info.maxV);
}

fs.writeFileSync('./wind.json', JSON.stringify(list));
fs.writeFileSync('./info.json', JSON.stringify(info));

三、绘制2D风场图

重头戏来了!瞪大你的眼睛(0 v 0),看好了!

1. 创建风场网格

nx和ny对应横向纵向网格数量,然后uv数据按照nx行,ny列组装添加到二维数组里面就是网格了。

 this.grid = [];
    let index = 0;
for (let j = 0; j < header.ny; j++) {
        const row = [];
        for (let i = 0; i < header.nx; i++) {
          const item = this.data[index++];
          row.push(item);
        }
        this.grid.push(row);
      }

2. 获取点xy对应的风向uv

根据风场网格获取该xy先在应的风向uv,点xy可能不是整数,那么这时候需要使用双线性插值(根据临近的周围四个点计算出插值)算出对应的风向uv。

  • 根据xy获取风向uv
 getUV(x, y) {
    let x0 = Math.floor(x),
      y0 = Math.floor(y);
    //正好落在网格里
    if (x0 === x && y0 === y) return this.getGrid(x, y);

    let x1 = x0 + 1;
    let y1 = y0 + 1;

    //临近四周的点
    let g00 = this.getGrid(x0, y0),
      g10 = this.getGrid(x1, y0),
      g01 = this.getGrid(x0, y1),
      g11 = this.getGrid(x1, y1);
    return this.bilinearInterpolation(x - x0, y - y0, g00, g10, g01, g11);
  }
  • 不落在整数网格里面的采用双线性插值计算出风向uv
  /**双线性插值
   * g00, g10, g01, g11对应临近可映射的四个点
   * x为当前点与最近点x坐标差
   * y为当前点与最近点y坐标差
   * ***/
 bilinearInterpolation(x, y, g00, g10, g01, g11) {
    let rx = 1 - x;
    let ry = 1 - y;
    let a = rx * ry,
      b = x * ry,
      c = rx * y,
      d = x * y;
    let u = g00[0] * a + g10[0] * b + g01[0] * c + g11[0] * d;
    let v = g00[1] * a + g10[1] * b + g01[1] * c + g11[1] * d;
    return [u, v];
  }
 
  • 获取网格数值,需规整超出的边界值
getGrid(x, y) {
    const h = this.header;
    if (x < 0) {
      x = 0;
    } else if (x > h.nx - 1) {
      x = h.nx - 1;
    }

    if (y < 0) {
      y = 0;
    } else if (y > h.ny - 1) {
      y = h.ny - 1;
    }

    return this.grid[y][x];
  }

3. 创建随机点

 createRandParticle() {
 //必须在风场网格范围内才能获取到风向uv
    const x = Math.random() * this.header.nx;
    const y = Math.random() * this.header.ny;

    const uv = this.getUV(x, y);

    return {
      //起点位置
      x,
      y,
      //终点位置=当前位置加上风向偏移
      tx: x + this.speed * uv[0],
      ty: y + this.speed * uv[1],
      //生命周期,将生命周期归零的时候重新设置起点坐标
      age: Math.floor(Math.random() * this.maxAge)
    };
  }
  //重新设置随机点
    setParticleRand(p) {
    const newp = this.createRandParticle();
    for (let k in p) {
      p[k] = newp[k];
    }
  }
  • 生成随机点
 
    this.particles = [];
    for (let i = 0; i < this.particlesCount; i++) {
      this.particles.push(this.createRandParticle());
    }

4. 绘制风场图

canvas绘制风场即用线段的起点和终点跟随着风向不断运动形成风场图。

  • 设置canvas
//缓存canvas context之前的合成操作类型
      const pre = ctx.globalCompositeOperation;
      //'destination-in'仅保留现有画布内容和新形状重叠的部分。其他的都是透明的。
      ctx.globalCompositeOperation = 'destination-in';
      //之前绘制的保留重叠部分
      ctx.globalAlpha = 0.5;
      ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
      //还原合成操作类型
      ctx.globalCompositeOperation = pre;
      
      
      //设置线的全局透明度
      ctx.globalAlpha = 0.8;

注意cxt.fillRect本来清空之前的画布内容,但采用了globalCompositeOperation='destination-in'globalAlpha=0.5的透明度作为重叠标准,重叠部分以0.5的透明度重新绘制并保留下来,通过这种方式,可以形成很多连续点的感觉,如果设置为1的透明度则会全部保留,并且不停叠加,等价于没有清空画布的状态。

  • 遍历随机点更新位置
      this.particles.forEach((p) => {
        if (p.age <= 0) {
          //生命周期耗尽重新设置随机点值
          this.setParticleRand(p);
        } else {
          if (!this.inBound(p.x, p.y)) {
            //画出范围外重新设置随机点值
            this.setParticleRand(p);
          } else {
            //根据下一个点的风向,计算出下一个点的位置
            const uv = this.getUV(p.tx, p.ty);
            const nextx = p.tx + this.speed * uv[0];
            const nexty = p.ty + this.speed * uv[1];
            //将起点换成之前的终点
            p.x = p.tx;
            p.y = p.ty;
            //终点设置成计算出的下一个点
            p.tx = nextx;
            p.ty = nexty;
            //生命周期递减
            p.age--;
          }
        }
        //起始点和终点转换成显示的画布大小
        const start = this.getCanvasPos(p.x, p.y);
        const end = this.getCanvasPos(p.tx, p.ty);
        //渐变跟随线段的方向
        const gradient = ctx.createLinearGradient(start[0], start[1], end[0], end[1]);
        for (let k in this.color) {
          gradient.addColorStop(+k, this.color[k]);
        }
        //绘制线段
        ctx.beginPath();
        ctx.strokeStyle = gradient;
        ctx.moveTo(start[0], start[1]);
        ctx.lineTo(end[0], end[1]);
        ctx.stroke();
      });

5. 使用封装类绘制

async function main() {
    //风场信息数据
        const header = await getData('./info.json');
        //风场uv方向数据
        const data = await getData('./wind.json');

        const canvas = document.getElementById('canvas');
        canvas.width = 1200;
        canvas.height = 600;         
        const cw = new Windy({
          header,
          data,
          canvas,
          //运动速度
          speed: 0.1,
          //随机点数量
          particlesCount: 1000,
          //生命周期
          maxAge: 120,
          //1秒更新次数
          frame: 10,
          //线渐变
          color: {
            0: 'rgba(255,255,0,0)',
            1: '#ffff00'
          },
          //线宽度
          lineWidth: 3 
        });
      }

20241102_204252.gif

效果非常好,线段顺着风向在运动!

  • 上面的线段因为一段段渐变呈现出一个个小蝌蚪的样子,然而利用叠加保留的效果,可以自动将线段绘制渐变色。只需要改变一下绘制顺序就行
  
        //线段绘制开始
        ctx.beginPath();
        //设置纯颜色
        ctx.strokeStyle = this.color;
        //遍历随机点更新位置
        this.particles.forEach((p) => {
           //同上面更新随机点的位置
           //...
           
          //起始点和终点转换成显示的画布大小
          const start = this.getCanvasPos(p.x, p.y);
          const end = this.getCanvasPos(p.tx, p.ty);
          //通过moveTo和lineTo绘制多个线段
          ctx.moveTo(start[0], start[1]);
          ctx.lineTo(end[0], end[1]);
        });
        //最终统一绘制线段
        ctx.stroke();

20241102_213723.gif

这样看上去流动线段连续性更强,不那么零散了!

6. 利用图片信息存储数据的优化

wind.json风场uv方向数据有739KB接近1MB,这着实有点大,要是网络稍微有点卡都会很影响首屏加载时间!从webgl-wind中我看到了用Canvas的ImageData中颜色来存储与解析数值,这操作太优秀了!

实现逻辑:用nx*ny与风场网格同样大小的canvas,获取到ImageData,将像素颜色四个数值中red红色和green绿色分别赋值成uv转换后的颜色值,注意透明度一定要置为不透明,然后put回canvas里面绘制,再利用canvas.toDataURL导出图片。

async function createCanvas() {
        const data = await getData('./wind.json');
        const info = await getData('info.json');
        const canvas = document.getElementById('theCanvas');
        canvas.width = info.nx;
        canvas.height = info.ny;

        const minU = Math.abs(info.minU);
        const minV = Math.abs(info.minV);
        // uv风方向范围
        const uSize = info.maxU - info.minU;
        const vSize = info.maxV - info.minV;
        const ctx = canvas.getContext('2d');
        //获取imageData像素数据
        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
        data.forEach((item, i) => {
          //值转换成正数
          const u = item[0] + minU;
          const v = item[1] + minV;
          //转换成颜色值
          const r = (u / uSize) * 255;
          const g = (v / vSize) * 255;
          imageData.data[i * 4] = r;
          imageData.data[i * 4 + 1] = g;
          //透明度默认255即不透明
          imageData.data[i * 4 + 3] = 255;
        });
        //用imageData像素颜色值绘制图片
        ctx.putImageData(imageData, 0, 0);
      }

wind.png

这样一张360px*181px的图片存储了65,160个点,但仅仅只需要86.6KB,压缩成原来数据的十分之一了。

  • 如果改用风场方向图片,那么对应需要添加加载和解析数据的流程

加载风场方向数据图片


  loadImageData() {
    return new Promise((resolve) => {
      const image = new Image();
      image.src = this.imageUrl;
      image.onload = () => {
        const c = document.createElement('canvas');
        c.width = image.naturalWidth;
        c.height = image.naturalHeight;
        const ctx = c.getContext('2d');
        //绘制图片
        ctx.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight);
        //获取ImageData像素数据
        const imageData = ctx.getImageData(0, 0, image.naturalWidth, image.naturalHeight);
        
        resolve(imageData.data);
      };
    });
  }

解析图片数据成uv,并组装成风场网格Grid

data = await this.loadImageData();

      const minU = Math.abs(header.minU);
      const minV = Math.abs(header.minV);
      //uv风方向范围
      const uSize = header.maxU - header.minU;
      const vSize = header.maxV - header.minV;

      let index = 0;
      for (let j = 0; j < header.ny; j++) {
        const row = [];
        for (let i = 0; i < header.nx; i++) {
          //将颜色数据转化成风向uv数据
          const u = (data[index] / 255) * uSize - minU;
          const v = (data[index + 1] / 255) * vSize - minV;
          row.push([u, v]);
          index = index + 4;
        }
        this.grid.push(row);
      }

后面的绘制风场逻辑跟上面一样,只不过多了个加载图片解析的过程。

20241102_214148.gif

加上一张世界地图底图可以更清晰得看到风流动的方向!

四、绘制3D风场图

1.利用Canvas风场贴图绘制3D风场图

  • 常规的顶点着色器
varying vec2 vUv;
void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);

}
  • 片元着色器,要将世界底图与风场图合并成一张图
varying vec2 vUv;
uniform sampler2D windTex;
uniform sampler2D worldTex;
void main() {
    vec4 color = texture2D(windTex, vUv);
    float a = color.a;
    if(a < 0.01) {
        a = 0.;
    }

    vec4 w = texture2D(worldTex, vUv);
        //根据透明度合并世界贴图和风场贴图

   gl_FragColor =vec4(mix(w.rgb,color.rgb,a),1.0);
}
  • 创建风场贴图
async createWindCanvas() {
          const header = await getData('./info.json');          
          const canvas = document.createElement('canvas');
          //要足够大,否则会贴图模糊
          canvas.width = 4000;
          canvas.height = 2000;
          this.cw = new Windy({
            header,
            // data,
            canvas,
            //运动速度
            speed: 0.1,
            //随机点数量
            particlesCount: 1000,
            //生命周期
            maxAge: 120,
            //1秒更新次数
            frame: 10,
            //线渐变
            // color: {
            //   0: 'rgba(255,255,0,0)',
            //   1: '#ffff00'
            // },
            color: '#ffff00',
            //线宽度
            lineWidth: 3,
            imageUrl: 'wind.png'
            //autoAnimate: true
          });
          const texture = new THREE.CanvasTexture(canvas);
          //因为是动态canvas,所以要置为需要更新
          texture.needsUpdate = true;
          return texture;
        }
  • 添加球体
async createChart(that) {
          this.windTex = await this.createWindCanvas();

          const worldTex = new THREE.TextureLoader().load('../assets/world.jpg');
          {
            const material = new THREE.ShaderMaterial({
              uniforms: {
                worldTex: { value: worldTex },

                windTex: { value: this.windTex }
              },
              vertexShader: document.getElementById('vertexShader').innerHTML,
              fragmentShader: document.getElementById('fragmentShader').innerHTML,
              side: THREE.DoubleSide,
              transparent: true
            });

            const geometry = new THREE.SphereGeometry(2, 32, 16);

            const sphere = new THREE.Mesh(geometry, material);
            this.scene.add(sphere);
          }
        }
  • 让canvas动起来
animateAction() {
          if (this.windTex) {
            if (this.cw) {
              this.cw.render();
            }
            this.windTex.needsUpdate = true;
          }
        }

20241102_230324.gif

地球展开收起动画

  • 将顶点着色器替换成下面的,根据uv计算出压平后球体表面点的位置,然后用mix来让原来球体表面的点过渡变化

注意球体半圆周长,对应球体压平后矩形的宽度,球体贴图正好是2:1,长度对应宽度的两倍。

uniform float time;
uniform float radius;
varying vec2 vUv;
float PI = acos(-1.0);
void main() {
    vUv = uv;
             //半圆周长
    float w = radius * PI;
            //随着时间压平或收起球体点位置
    vec3 newPosition = mix(position, vec3(0.0, (uv.y - 0.5) * w, -(uv.x - 0.5) * 2.0 * w), sin(time * PI * 0.5));
    gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
  • 展开或收起球体动画
openMap() {
          const tw = new TWEEN.Tween({ time: 0.0 })
            .to({ time: 1.0 }, 2000)
            .onUpdate((obj) => {
              if (this.mat) {
                this.mat.uniforms.time.value = obj.time;
              }
            })
            .start();
          TWEEN.add(tw);
        }
        closeMap() {
          const tw = new TWEEN.Tween({ time: 1.0 })
            .to({ time: 0.0 }, 2000)
            .onUpdate((obj) => {
              if (this.mat) {
                this.mat.uniforms.time.value = obj.time;
              }
            })
            .start();
          TWEEN.add(tw);
        }

20241102_232847.gif

除了用贴图来实现,还能用three.js的BufferGeometry+LineSegments实现动态线段,进而实现3D风场图。

2.使用LineSegments绘制风场图

  • 顶点着色器
uniform vec2 uResolution;//nx与ny网格大小
uniform vec2 uSize;//显示的宽高
varying vec2 vUv;
void main() {
    vUv = vec2(position.z);
            // 转换为经纬度坐标
    vec2 p = vec2(position.x, -position.y) - vec2(180., 90.);

    gl_Position = projectionMatrix * modelViewMatrix * vec4((p / uResolution) * uSize + vec2(0., uSize.y), 0.0, 1.);
}

注意:地球的经纬度是从下往上变大的,而平面的坐标是从上往下变大的的,因此随机点的y坐标取反才是正确位置,因为取反的问题,位置会偏移,对应也要将整体位置加上偏移量归位。

  • 片元着色器
varying vec2 vUv;
uniform vec3 startColor;
uniform vec3 endColor;
void main() {
//渐变色
    gl_FragColor = vec4(mix(startColor, endColor, vUv.y), 1.0);
}
  • 绘制线段LineSegments 将随机点的开始结束两个点位置分别赋值到线段position里面,并添加索引。
//点索引
 const points = new Float32Array(num * 6);          
 let i = 0;
 
pointCallback: (p) => {
              // 线段开始位置
              points[i] = p.x;
              points[i + 1] = p.y;
              points[i + 2] = 0;//开始点z坐标标识是0
                // 线段结束位置
              points[i + 3] = p.tx;
              points[i + 4] = p.ty;
              points[i + 5] = 1;//结束点z坐标标识是1
               
              //递增索引             
              i += 6;
            }

添加LineSegments,一定要用LineSegments,因为LineSegments是绘制的线段是gl.LINES模式,就是每两个点一组,形成一个新线段,就是A,B,C,D四个点,就会变成AB一条线段,BC一条线段,就可以绘制多条线段了。

 const material = new THREE.ShaderMaterial({
            uniforms: {
              //nx和ny网格大小
              uResolution: { value: new THREE.Vector2(this.cw.header.nx, this.cw.header.ny) },
              //显示宽高大小
              uSize: { value: new THREE.Vector2(20, 10) },               
              //渐变开始颜色
              startColor: { value: new THREE.Color('#ffff00') },
              //渐变结束颜色
              endColor: { value: new THREE.Color('#ff0000') }
            },
            vertexShader: document.getElementById('vertexShader1').innerHTML,
            fragmentShader: document.getElementById('fragmentShader').innerHTML,
            side: THREE.DoubleSide,
            transparent: true
          });
            
          const geometry = new THREE.BufferGeometry();          
          geometry.setAttribute('position', new THREE.BufferAttribute(points, 3));         
          this.geometry = geometry;
          this.mat = material;
          //添加多个线段
          const lines = new THREE.LineSegments(geometry, material);
          this.scene.add(lines);

渲染的时候移动点的位置并给position属性赋值更新

if (this.frameCount % this.frame === 0 && this.cw && this.geometry) {
            let i = 0;
            const g = this.geometry;
            this.cw.movePoints((p) => {
              g.attributes.position.array[i] = p.x;
              g.attributes.position.array[i + 1] = p.y;
              g.attributes.position.array[i + 3] = p.tx;
              g.attributes.position.array[i + 4] = p.ty;
              i += 6;
            });
            //属性值改变一定要置true,通知更新
            g.attributes.position.needsUpdate = true;
          }

20241103_155901.gif

上面效果的风场图与canvas 2D风场图清空再绘制一样的效果,没有走destination-in叠加保留的过程,点的数量可能看起来偏少,因此为了保证风流向的连续性,最好增加随机点个数。

  • 将平面的LineSegments变成球体 修改一下定点着色器,经纬度坐标转换成三维坐标
float PI = 3.1415926;
float rad = 3.1415926 / 180.;
uniform vec2 uResolution;
uniform vec2 uSize;
//半径
uniform float radius;
 //旋转翻过来
 uniform mat4 rotateX;

varying vec2 vUv;
      //经纬度坐标转为三维坐标
vec3 lnglat2pos(vec2 p) {
    float lng = p.x * rad;
    float lat = p.y * rad;
    float x = cos(lat) * cos(lng);
    float y = cos(lat) * sin(lng);
    float z = sin(lat);
    return vec3(x, z, y);
}
void main() {
    vUv = vec2(position.z);
            //转换成经纬度
    vec2 p = vec2(position.x, -position.y) - vec2(180., 90.);
           //经纬度转三维坐标
    vec3 newPosition = radius * lnglat2pos(p);
    gl_Position = projectionMatrix * modelViewMatrix *rotateX* vec4(newPosition, 1.);

}

注意

  1. three.js高度y轴坐标,那么对应三维坐标里面的z轴坐标,而three.js深度z轴坐标,那么对应三维坐标里面的y轴坐标,就是yz轴要对调一下,才是正确的点的位置,即vec3(x, z, y)
  2. position转经纬度,同上面一样需要将y取反才是正确的位置。 3.地球贴图贴在球体x方向开始位置有PI的偏移,需要将贴图设置一下偏移值才能对上经纬度坐标。
const worldTex = new THREE.TextureLoader().load('../assets/world.jpg');
          worldTex.offset.x = 0.5;
          worldTex.wrapS = THREE.RepeatWrapping;

4.因为y取反了,但在球体不能用位置偏移量解决归位问题,就会导致整个风流向路径反过来了,所以需要添加一个矩阵翻转量,让风流向路径回归正确的样子,

  const matrix = new THREE.Matrix4();
          matrix.makeRotationX(Math.PI);

20241103_170442.gif

终于解决风场位置对齐的问题了!这点小细节调了好久!唉~

五、Github地址

https://github.com/xiaolidan00/my-earth

参考

  1. earth.nullschool气象地球
  2. wind-js
  3. webgl-wind
  4. Cesium-三维风场
  5. Windy.js
  6. canvas globalCompositeOperation
  7. grib2json
  8. @weacast/grib2json