注册
web

用Three.js搞个炫酷风场图

风场图,指根据风速风向数据进行渲染,以表征空气流动方向、流动速度的一种动态流场图。接下来让我们学一下怎么实现炫酷的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] 
-V, --version 输出版本号
-d, --data 输出GRIB记录数据
-c, --compact 压缩json
-fc, --filter.category 选择类目值
-fs, --filter.surface 选择表面类型
-fp, --filter.parameter 选择参数值
-fv, --filter.value 选择表面值
-n, --names 打印数字代码的名称
-o, --output 输出文件名
-p, --precision 使用小数点后几位数的精度(默认值:-1)
-v, --verbose 启用stdout日志记录
-bs, --bufferSize 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);
//根据透明度合并世界贴图和风场贴图
vec4 c = w * (1. - a) + color * a;

gl_FragColor = c;
}
  • 创建风场贴图
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

参考


作者:敲敲敲敲暴你脑袋
来源:juejin.cn/post/7433055938418933787

0 个评论

要回复文章请先登录注册