风场可视化与原理剖析

最近因为项目需要,做风场可视化,也不是什么新鲜的东西了,站在前人的肩膀上鼓捣了两天也算是完成了,特此记录一下。

网上关于风场可视化的文章也挺多,可以拜读以下几位博主文章,在此表示感谢。

  1. 数据可视化之风向图(强烈安利这篇博文)
  2. 可视化之Earth NullSchool
  3. cesium实现风场效果
  4. ......(教程很多不一一列举了)

项目中应用案例是在cesium球上进行可视化,效果图如下(部分显示效果还在优化,下一篇会更新如何在cesium中进行风场可视化效果),cesium集成风场已更新(传送门

之前看到的cesium中实现的风场效果使用的primitives方式添加的,这种方式能跟地球很好的贴合,地球缩放风场数据不会消失,不过粒子数太多了性能会大大降低,2000左右粒子FPS保持在11左右,所以项目里面没有使用这种方式,而是用canvas绘制,再与地球贴合,显示效果很不错,15000个粒子fps在38左右,本篇主要还是想剖析一下风场的实现方法,所有不在过多讨论cesium上的可视化效果。

风场脱离了地图,就好像一个人没有了灵魂。刚开始研究风场时几乎所有的效果都是跟地图相关的,不同框架下实现跟地图api结合太紧密(不过原理都是以一样的,内部都是坐标转换),不熟悉地图api(arcgisjs、leaflet、cesium等),看起来还是比较吃力的,所以本篇完全从脱离地图角度,来讲讲单纯用canvas如何绘制风场。

先来几张效果图吧:

1、初始界面

2、粒子数目动态增加

3、颜色改变+切换风场范围

这里把风场的主要运行参数进行了动态配置,可以很方便的控制显示效果。

言归正传,要想实现风场效果,风场数据就少不了了,获取方式这里就不过多讨论了,前面列的几篇博客也有一些说明,这里主要说一下比较常用的一些参数:

以上就是比较常见的风场数据了,由header和data组成。

header是风场的一些参数信息,lo1,lo2,la1,la2是风场的范围,nx,ny代表横向纵向划分为多少网格,如上这个风场数据横向有360个格子,纵向有181个格子,dx,dy代表每个格子代表多少经纬度,主要用这8个参数就能够起风了。

data就是风速数据,表示风速大小,风场数据一般含有两个{header:{},data:[]}这样的结构,第一个里面的data是当前格子的横向风速,第二个指的是纵向风速(有可能反过来不过不影响)。

数据有了,如何进行可视化呢?

首先需要根据风场数据,生成棋盘格,为什么要生成棋盘?因为有了棋盘格,能非常快速的计算粒子的在当前格子的风速值。棋盘格是一个二维数组,row和cols分别代表header中的ny,nx,每一项是当前格子的横向风速和纵向风速,生成方式如下如下:

for (var j = 0; j < this.rows; j++) {
    rows = [];
    for (var i = 0; i < this.cols; i++, k++) {
        uv = this._calcUV(uComponent[k], vComponent[k]);
        rows.push(uv);
    }
    this.grid.push(rows);
}

棋盘格最终格式如下(只做示意):

[
  [
    [
      1.87,3.12
    ],
    [
      2.17,1.59
      ],
    [
      2.17,1.59
    ]
  ],
  [
    [
      1.87,3.12
    ],
    [
      2.17,1.59
    ],
    [
      2.17,1.59
    ]
  ]
]

ok,棋盘有了,下一步就是添加粒子了

风场里面应该是由很多股风在刮的,每股风都有一个初始的位置,所以我们这里就随机在棋盘里创建2000(100000个怎么样,你可以试试,可以间接测试你的电脑性能^_^)个点,作为每股风的起点。粒子属性如下:

/****
*粒子对象
****/
var CanvasParticle = function () {
    this.x = null;//粒子初始x位置(相对于棋盘网格,比如x方向有360个格,x取值就是0-360,这个是初始化时随机生成的)
    this.y = null;//粒子初始y位置(同上)
    this.tx = null;//粒子下一步将要移动的x位置,这个需要计算得来
    this.ty = null;//粒子下一步将要移动的y位置,这个需要计算得来
    this.age = null;//粒子生命周期计时器,每次-1
    this.speed = null;//粒子移动速度,这个属性没有实质作用,可以根据这个数值大小进行不同颜色的渲染
};

这里说一下age参数的作用,标识粒子存活的时间,值越大,风的轨迹拖动的就越长,可以自己试试调整这些参数,看看具体效果。

万事俱备,只待起风了。

requestAnimationFrame,非常熟悉的一个api,这样风就能刮起来了。

var then = Date.now();
(function frame() {
    self.animateFrame = requestAnimationFrame(frame);
    var now = Date.now();
    var delta = now - then;
    if (delta > self.frameTime) {
        then = now - delta % self.frameTime;
        self.animate();
    }
})();

requestAnimationFrame会循环调用animate方法,animate方法主要是实时计算每个粒子的位置、当前位置的速度,下一次将要移动的位置,每计算一次,age参数-1,直到为0。计算完成后进行绘图操作。

比如当前某个粒子,它的经纬度是(118.369898,37.689786),那么根据风场范围,是不是可以计算出当前粒子所在棋盘格的位置,比如是(126.83,96.61),这里问题出现了,它的位置是小数,但棋盘格位置都是整数,怎么办呢?我们可以通过Math.floor或者Math.ceil进行取整,这样就能拿到当前位置的风速了,但是这么计算结果非常不精确,显示效果很差,所以我们通过双线性插值算法(如果感兴趣,可以看看这篇文章:双线性插值)进行计算,这样就能计算出比较准确的速度值了,根据得到的横向、纵向速度值,计算出粒子下一次要到达的位置。如此循环往复,粒子就能在图上动起来了。

现在面临一个问题,比如粒子生命周期结束了,又比如粒子移动到风速是0的位置了,那么这个粒子将永远停留在这个地方,不动了;或者移出屏幕了,我们会发现风越来越少,最后界面上什么都没有了,显然这个时候就需要对该粒子进行重新赋值,以保证屏幕上总是有那么多的风在刮。

以上风场的所有逻辑就结束了,要想在界面上看到风场流动,canvas就登场了:

this.canvasContext.fillStyle = "rgba(0, 0, 0, 0.97)";
this.canvasContext.globalAlpha = 0.6;
this.animate();


_drawLines: function () {
    var self = this;
    var particles = this.particles;
    this.canvasContext.lineWidth = self.lineWidth;
    //后绘制的图形和前绘制的图形如果发生遮挡的话,只显示后绘制的图形跟前一个绘制的图形重合的前绘制的图形部分,示例:https://www.w3school.com.cn/tiy/t.asp?f=html5_canvas_globalcompop_all
    this.canvasContext.globalCompositeOperation = "destination-in";
    this.canvasContext.fillRect(0,0,this.canvasWidth,this.canvasHeight);
    this.canvasContext.globalCompositeOperation = "lighter";//重叠部分的颜色会被重新计算
    this.canvasContext.globalAlpha = 0.9;

    this.canvasContext.beginPath();
    this.canvasContext.strokeStyle = this.color;
    particles.forEach(function (particle) {
        var movetopos = self._map(particle.x, particle.y);
        var linetopos = self._map(particle.tx, particle.ty);
        self.canvasContext.moveTo(movetopos[0],movetopos[1]);
        self.canvasContext.lineTo(linetopos[0],linetopos[1]);
    });
    this.canvasContext.stroke();
},

我们看到的风场,是流动线的效果,但是我们的数据只是每个粒子的当前位置和将要移动的位置,怎么在canvas里就成了流动线的效果呢?这个主要是canvas的globalAlpha和globalCompositeOperation在起作用,每次绘制透明度都是0.6,之前绘制的慢慢的就越来越透明最后看不见,所以看到的就是流动线效果。还有其他几个参数上面都备注做了说明。

以下是风场的封装类,主要包含三个对象:

  1. CanvasWindy:风场类,创建风场,提供了非常丰富的可配置参数
  2. CanvasWindField:棋盘类,初始化时生成棋盘,用来计算粒子的走向
  3. CanvasParticle:粒子,起风就靠它了,
/****
*风场类
****/
var CanvasWindy = function (json,params) {
    //风场json数据
    this.windData = json;
    //可配置参数
    /**
    * extent 风场绘制时的地图范围,范围不应该大于风场源数据的范围,顺序:west/east/south/north,有正负区分,如:[110,120,30,36]
    * extent参数可以结合使用的地图框架(arcgisjs、leaflet、ol等)动态调整,达到地图缩放时风场同步更新,extent参数改变后需要重新生成风场(redraw函数)
    */
    this.extent = params.extent || [];
    this.canvasContext = params.canvas.getContext("2d");//canvas上下文
    this.canvasWidth = params.canvasWidth || 300;//画板宽度
    this.canvasHeight = params.canvasHeight || 180;//画板高度
    this.speedRate = params.speedRate || 0.15;//风前进速率,可以用该数值控制线流动的快慢,值越大,越快
    this.particlesNumber = params.particlesNumber || 20000;//初始粒子总数,根据实际需要进行调节
    this.maxAge = params.maxAge || 120;//每个粒子的最大生存周期
    this.frameTime = 1000/(params.frameRate || 10);//每秒刷新次数,因为requestAnimationFrame固定每秒60次的渲染,所以如果不想这么快,就把该数值调小一些
    this.color = params.color || '#ffffff';//线颜色,提供几个示例颜色['#14208e','#3ac32b','#e0761a']
    this.lineWidth = params.lineWidth || 1;//线宽度
    //内置参数
    this.generateParticleExtent = [];//根据风场绘制时的extent,计算粒子随机生成时的范围(指的是棋盘网格的行列范围)
    this.windField = null;
    this.particles = [];
    this.animateFrame = null;//requestAnimationFrame事件句柄,用来清除操作
    this._init();
};
CanvasWindy.prototype = {
    constructor: CanvasWindy,
    _init: function () {
        var self = this;
        // 创建风场网格
        this.windField = this.createField();
        //如果风场创建时,传入的参数有extent,就根据给定的extent,让随机生成的粒子落在extent范围内
        if(this.extent.length!=0){
            this.extent = [
                Math.max(this.windField.west-180,this.extent[0]),
                Math.min(this.windField.east-180,this.extent[1]),
                Math.max(this.windField.south,this.extent[2]),
                Math.min(this.windField.north,this.extent[3])
            ];

            var resHeader = this.windData[0]['header'];
            var nx=resHeader.nx,
                ny=resHeader.ny,
                west = resHeader['lo1'],
                east = resHeader['lo2'],
                south = resHeader['la2'],
                north = resHeader['la1'];
            //计算extent左上角,右下角所在棋盘格的xy位置,加180是因为原始风向数据东西纬度是0-360表示的,根据实际数据可动态调整
            this.generateParticleExtent.push(((this.extent[0]+180)-west)/(east-west)*(nx-2));//左
            this.generateParticleExtent.push(((this.extent[1]+180)-west)/(east-west)*(nx-2));//右
            this.generateParticleExtent.push((north-(this.extent[2]))/(north-south)*(ny-2));//下
            this.generateParticleExtent.push((north-(this.extent[3]))/(north-south)*(ny-2));//上
        }
        // 创建风场粒子
        for (var i = 0; i < this.particlesNumber; i++) {
            this.particles.push(this.randomParticle(new CanvasParticle()));
        }
        this.canvasContext.fillStyle = "rgba(0, 0, 0, 0.97)";
        this.canvasContext.globalAlpha = 0.6;
        this.animate();

        var then = Date.now();
        (function frame() {
            self.animateFrame = requestAnimationFrame(frame);
            var now = Date.now();
            var delta = now - then;
            if (delta > self.frameTime) {
                then = now - delta % self.frameTime;
                self.animate();
            }
        })();
    },
    //根据现有参数重新生成风场
    redraw:function(){
        window.cancelAnimationFrame(this.animateFrame);
        this.particles = [];
        this.generateParticleExtent = [];
        this._init();
    },
    // _reGenerateGrid:function(){
    //     var resHeader = this.windData[0]['header'];
    //     var nx=resHeader.nx,
    //         ny=resHeader.ny,
    //         west = resHeader['lo1'];
    //         east = resHeader['lo2'];
    //         south = resHeader['la2'];
    //         north = resHeader['la1'];
    //         field=this.windField;
    //     //计算extent左上角,右下角所在棋盘格的xy位置,加180是因为原始方向数据东西纬度是0-360表示的
    //     var west_northuv = field.getIn((this.extent[0]+180-west)/(east-west)*nx,(north-this.extent[3])/(north-south)*ny);
    //     var east_southuv = field.getIn((this.extent[1]+180-west)/(east-west)*nx,(north-this.extent[2])/(north-south)*ny);
    //     var uComponent=[],vComponent=[];
    //     for()
    //     this.windData.lo1 = this.extent[0]+180;
    //     this.windData.lo2 = this.extent[1]+180;
    //     this.windData.la1 = this.extent[3];
    //     this.windData.la2 = this.extent[2];
    //     this.windData[0].data = [];
    //     this.windData[1].data = [];
    //     this.windField = this.createField();
    // },
    createField: function () {
        var data = this._parseWindJson();
        return new CanvasWindField(data);
    },
    animate: function () {
        var self = this,
            field = self.windField;
        var nextX = null,
            nextY = null,
            xy = null,
            uv = null;
        self.particles.forEach(function (particle) {
            if (particle.age <= 0) {
                self.randomParticle(particle);
            }
            if (particle.age > 0) {
                var x = particle.x,
                    y = particle.y,
                    tx = particle.tx,
                    ty = particle.ty;

                if (!field.isInBound(tx, ty)) {
                    particle.age = 0;
                } else {
                    uv = field.getIn(tx, ty);
                    nextX = tx +  self.speedRate * uv[0];
                    nextY = ty +  self.speedRate * uv[1];
                    particle.x = tx;
                    particle.y = ty;
                    particle.tx = nextX;
                    particle.ty = nextY;
                    particle.age--;
                }
            }
        });
        if (self.particles.length <= 0) this.removeLines();
        self._drawLines();
    },
    _resize:function(width,height){
        this.canvasWidth = width;
        this.canvasHeight = height;
    },
    _parseWindJson: function () {
        var uComponent = null,
            vComponent = null,
            header = null;
        this.windData.forEach(function (record) {
            var type = record.header.parameterCategory + "," + record.header.parameterNumber;
            switch (type) {
                case "2,2":
                    uComponent = record['data'];
                    header = record['header'];
                    break;
                case "2,3":
                    vComponent = record['data'];
                    break;
                default:
                    break;
            }
        });
        return {
            header: header,
            uComponent: uComponent,
            vComponent: vComponent
        };
    },
    removeLines: function () {
        window.cancelAnimationFrame(this.animateFrame);
    },
    //根据粒子当前所处的位置(棋盘网格位置),得到canvas画板中的位置,以便画图
    _map: function (x,y) {
        var field = this.windField,
            fieldWidth = field.cols,
            fieldHeight = field.rows,
            newArr = [0,0];

        var noextent = this.generateParticleExtent.length==0;
        newArr[0] = Math.floor((x/fieldWidth)*this.canvasWidth);
        newArr[1] = Math.floor((y/fieldHeight)*this.canvasHeight);

        newArr[0] = Math.floor(((noextent?x:(x-this.generateParticleExtent[0]))/(noextent?fieldWidth:(this.generateParticleExtent[1]-this.generateParticleExtent[0])))*this.canvasWidth);
        newArr[1] = Math.floor(((noextent?y:(y-this.generateParticleExtent[3]))/(noextent?fieldHeight:(this.generateParticleExtent[2]-this.generateParticleExtent[3])))*this.canvasHeight);
        // console.log(newArr);
        return newArr;
    },
    _drawLines: function () {
        var self = this;
        var particles = this.particles;
        this.canvasContext.lineWidth = self.lineWidth;
        //后绘制的图形和前绘制的图形如果发生遮挡的话,只显示后绘制的图形跟前一个绘制的图形重合的前绘制的图形部分,示例:https://www.w3school.com.cn/tiy/t.asp?f=html5_canvas_globalcompop_all
        this.canvasContext.globalCompositeOperation = "destination-in";
        this.canvasContext.fillRect(0,0,this.canvasWidth,this.canvasHeight);
        this.canvasContext.globalCompositeOperation = "lighter";//重叠部分的颜色会被重新计算
        this.canvasContext.globalAlpha = 0.9;

        this.canvasContext.beginPath();
        this.canvasContext.strokeStyle = this.color;
        particles.forEach(function (particle) {
            var movetopos = self._map(particle.x, particle.y);
            var linetopos = self._map(particle.tx, particle.ty);
            self.canvasContext.moveTo(movetopos[0],movetopos[1]);
            self.canvasContext.lineTo(linetopos[0],linetopos[1]);
        });
        this.canvasContext.stroke();
    },
    //随机数生成器(小数)
    fRandomByfloat:function(under, over){ 
       return under+Math.random()*(over-under);
    },
    //根据当前风场网格行列数随机生成粒子
    randomParticle: function (particle) {
        var safe = 30,x, y;

        do {
            x = this.generateParticleExtent.length==0?this.fRandomByfloat(0,this.windField.cols - 2):this.fRandomByfloat(this.generateParticleExtent[0],this.generateParticleExtent[1]);
            y = this.generateParticleExtent.length==0?this.fRandomByfloat(0,this.windField.rows - 2):this.fRandomByfloat(this.generateParticleExtent[3],this.generateParticleExtent[2]);
        } while (this.windField.getIn(x, y)[2] <= 0 && safe++ < 30);
        var field = this.windField;
        var uv = field.getIn(x, y);
        var nextX = x +  this.speedRate * uv[0];
        var nextY = y +  this.speedRate * uv[1];
        particle.x = x;
        particle.y = y;
        particle.tx = nextX;
        particle.ty = nextY;
        particle.speed = uv[2];
        particle.age = Math.round(Math.random() * this.maxAge);//每一次生成都不一样
        return particle;
    }
};


/****
*棋盘类
*根据风场数据生产风场棋盘网格
****/
var CanvasWindField = function (obj) {
    this.west = null;
    this.east = null;
    this.south = null;
    this.north = null;
    this.rows = null;
    this.cols = null;
    this.dx = null;
    this.dy = null;
    this.unit = null;
    this.date = null;

    this.grid = null;
    this._init(obj);
};
CanvasWindField.prototype = {
    constructor: CanvasWindField,
    _init: function (obj) {
        var header = obj.header,
            uComponent = obj['uComponent'],
            vComponent = obj['vComponent'];

        this.west = +header['lo1'];
        this.east = +header['lo2'];
        this.south = +header['la2'];
        this.north = +header['la1'];
        this.rows = +header['ny'];
        this.cols = +header['nx'];
        this.dx = +header['dx'];
        this.dy = +header['dy'];
        this.unit = header['parameterUnit'];
        this.date = header['refTime'];

        this.grid = [];
        var k = 0,
            rows = null,
            uv = null;
        for (var j = 0; j < this.rows; j++) {
            rows = [];
            for (var i = 0; i < this.cols; i++, k++) {
                uv = this._calcUV(uComponent[k], vComponent[k]);
                rows.push(uv);
            }
            this.grid.push(rows);
        }
    },
    _calcUV: function (u, v) {
        return [+u, +v, Math.sqrt(u * u + v * v)];
    },
    //二分差值算法计算给定节点的速度
    _bilinearInterpolation: function (x, y, g00, g10, g01, g11) {
        var rx = (1 - x);
        var ry = (1 - y);
        var a = rx * ry, b = x * ry, c = rx * y, d = x * y;
        var u = g00[0] * a + g10[0] * b + g01[0] * c + g11[0] * d;
        var v = g00[1] * a + g10[1] * b + g01[1] * c + g11[1] * d;
        return this._calcUV(u, v);
    },
    getIn: function (x, y) {
        var x0 = Math.floor(x),
            y0 = Math.floor(y),
            x1, y1;
        if (x0 === x && y0 === y) return this.grid[y][x];

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

        var g00 = this.getIn(x0, y0),
            g10 = this.getIn(x1, y0),
            g01 = this.getIn(x0, y1),
            g11 = this.getIn(x1, y1);
        return this._bilinearInterpolation(x - x0, y - y0, g00, g10, g01, g11);
    },
    isInBound: function (x, y) {
        if ((x >= 0 && x < this.cols - 2) && (y >= 0 && y < this.rows - 2)) return true;
        return false;
    }
};

/****
*粒子对象
****/
var CanvasParticle = function () {
    this.x = null;//粒子初始x位置(相对于棋盘网格,比如x方向有360个格,x取值就是0-360,这个是初始化时随机生成的)
    this.y = null;//粒子初始y位置(同上)
    this.tx = null;//粒子下一步将要移动的x位置,这个需要计算得来
    this.ty = null;//粒子下一步将要移动的y位置,这个需要计算得来
    this.age = null;//粒子生命周期计时器,每次-1
    this.speed = null;//粒子移动速度,可以根据速度渲染不同颜色
};

各个参数及内部方法都已做了详细说明,应该很容易看懂,风场初始化代码如下:

var windycanvas = document.getElementById("windycanvas");
//除了canvas参数必须指定外,其他可以不传
var params = {
    // extent:[73.6666,135.0416,3.86666,53.55],//中国范围
    canvas:windycanvas,
    canvasWidth:window.innerWidth,
    canvasHeight:window.innerHeight,
    speedRate:0.15,
    particlesNumber:10000,
    maxAge:120,
    frameRate:10,
    color:'#e0761a',
    lineWidth:2,
};
windy = new CanvasWindy(response, params);
  • extent:风场绘制时的地图范围,范围不应该大于风场源数据的范围,顺序:west/east/south/north,有正负区分,如:[-110,120,-30,36]
  • speedRate:风前进速率,可以用该数值控制线流动的快慢,值越大,越快
  • frameTime:每秒刷新次数,因为requestAnimationFrame固定每秒60次的渲染,所以如果不想这么快,就把该数值调小一些
  • 其他参数应该很好理解就不做说明了

还是那句老话,只有跟地图结合才显得有生命力,与地图结合只需要随机生成粒子时使用经纬度,再结合地图api,进行坐标转换就可以叠加上去了,无论二维还是三维,都是以一样的。

后续会持续更新二维地图(基于arcgisjs)风场叠加和三维(cesium)风场叠加的案例。

这里提供本示例源码下载,没有做详细测试,如果有bug,可以留言修正。

源码下载

气象数据可视化是将气象数据以图形、图表等形式展示出来,使人们更直观地了解和分析天气情况。UE4(Unreal Engine 4)是一款流行的游戏引擎,其强大的图形渲染功能和灵活的开发环境可以用于实现气象数据的可视化。 首先,通过UE4的脚本编程功能,可以实现气象数据的获取和处理。可以利用UE4的网络功能从气象数据源获取实时的天气数据,或者通过本地的数据文件导入历史天气数据。然后,使用UE4的编程功能对数据进行处理和分析,例如计算平均气温、风速、降水量等指标,或者将数据按照时间和地点等维度进行分类和聚合。 其次,利用UE4强大的图形渲染功能,可以将处理后的气象数据转化为具有可视化效果的图形。可以使用UE4的渲染引擎对数据进行可视化,比如通过绘制折线图或柱状图展示气温的变化,使用粒子系统模拟雨、雪等降水现象,或者创建动态的风场效果来展示风速和风向等信息。 此外,UE4还支持实时交互和用户界面设计。可以通过UE4的Blueprint视觉脚本系统,实现用户对气象数据的交互操作,比如选择不同的时间窗口、切换不同的地理位置或气象现象等。还可以通过UE4的UI设计工具,创建用户友好的界面,使用户能够自由地探索并查看气象数据。 总之,利用UE4的强大功能,可以非常方便地实现气象数据的可视化。通过将气象数据以图形、图表等形式展示出来,人们可以更加直观地了解天气情况,深入分析和研究气象变化,为天气预报、气候研究等领域提供有力的支持。
评论 40
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值