数据可视化之风向图

很多人都见过风向图,直观形象,也是地图数据和现实数据在可视化上很好的结合。

这是我见的第一个风向图,记得是 2012 年吧,当时觉得很有意思,作为一名技术人员,自然好奇它是如何做到的,是 Canvas 还是 SVG?但当时没深究。最近正好有人(大哥)提到了这个,不妨深入了解,一探究竟。于是乎,发现原来还有这么多玩法,大同小异,比如说这个,来自 earth.nullschool.net:

当然还有来自度娘开源的 echarts-x 的:

基本上,这三个效果图基本涵盖了目前风向图的技术点和功能点(我自己的看法,因为 windyty 是基于 earth.nullschool 写的,前者多了一个 worker 线程处理数据,而后者在 github 上开源)。不知道哪一个最对你的胃口?对我而言,图 1 简单易懂,可以快速掌握风向图的实现;图 2 是实时的全球风向数据,而且是二进制格式,是大数据传输的一个方案;图 3 则采用 WebGL 实时渲染,算是大数据渲染的一个方案,所以各有千秋。正好本文就结合这三个例子说一下其中处理好的地方,也是一个由易到难的过程。

原理

乍看上去,多少会觉得无从下手。这是怎么做到的?其实吧,懂与不懂就是那一层纸,就看你愿不愿意戳破而已。我们先从数据说起。

首先介绍一下向量场(Vector Field)的概念。在维基百科的解释是:在向量分析中,向量场是把空间中的每一点指派到一个向量的映射。物理学中的向量场有风场、引力场、电磁场、水流场等等。如图,下面是一个二维的向量场,每一个点都是一个向量。

当然这是一个抽象的数学概念和表达,物理中的电磁场经常会用到它,在现实中其实也随处可见,比如下面这个有意思的磁场的向量表达,密集恐惧症的人请略过~

同理,风场的抽象模型也是一个向量场:每一个点都有一个风速和方向,可以分解为在该点分别在 XY 方向上的向量(我们简化为 XY 两个方向,不考虑 Z,所以可惜不能听《龙卷风》了),则该向量则代表该点 X 方向和 Y 方向的速度。

如上图,是一个真实的风向图数据。简单来说,timestamp 代表当前数据的采集时间,(x0,y0,x1,y1)分别是经纬度的范围,而 grid 是该向量场的行列数,field 就是向量场中每一个点的速度值,如果是(0,0)则表示此点风平浪静。可能不同平台的风向图数据有一定差别,但都大同小异。

向量场和数据格式,直觉上,我们可以知道,就是把这些向量拟合成平滑线,可以形成如下一个真实的风向。

如何形成线,而且看上去全球范围内有总不能只有一阵风吧(让我想起了木星的大红斑,这场风在木星已经吹了至少 200 年从来没停过),这揭露了两个问题,1 向量场是离散点,而线是平滑,这里面有一个插值问题;2 更麻烦的是,这些线有好多好多连接的方式,都可以连接成线,有点类似等高线的算法,怎么连,看上去无从下手啊。

这是我看完数据后,自己觉得要实现风向效果时觉得需要解决的问题,感觉好难啊。怀着这个疑问进入梦乡,第二天 format 了一下 js 脚本,本地调试后,发现我的问题是对的,可是思路是错的。不要一上来就考虑这么多因素,而是基于当前的状态来解决当前的问题,就好比一道非常复杂的代数问题,或许通过几何方式反而可以很简单的解决。

不多废话了,尽管我觉得这些废话才是提高能力的最有价值的,解决问题不过是一个感悟过程的必然而已。好了,有了数据,看看“神诸葛”如何起风的吧。

举个例子,给你一个围棋棋盘(向量场),每一个格子就是一个向量,你随手拿一个棋子,随手(随机)放在一个格子上,这就是风的起点。下一回合(下一帧或下一秒),你根据当前格子的向量值(X 值和 Y 值)移动棋子,就是风在当前的风速下拖着长长的尾巴跳到下一个格子上的效果。这样,这个棋子会根据所在格子的向量值不停的移动,直到格子的向量值为零(风停)。

也就是说只要给一个起点,我就能刮起一股风来。那给你 5000 个棋子(起点),你就能刮起 5000 股风了。当然可能两股气流重叠,这时可能不太符合物理规律了,因为我们的思路下是各吹各的,不过谁关心呢。于是,基于每一帧状态的管理,我们可以很简单的模拟出风向图的效果。很简单巧妙吧。

如何实现

好了,理论上我们知道该怎么做了,看看如何代码实现。我们也整理一下这个流程,把它们模块化。

今天就和围棋干上了,还是这个例子,首先呢就是数据,也就是棋盘和格子,也就是 Vector 和 Vector Field 这两个对象来方便数据的读取、管理等;其次,当然是棋子了,记录每一个棋子的生命周期,当前的位置,下一步的位置,也就是风上对应的每一个帧的位置信息,这个是 Particle 类来记录这些信息;最后,有了棋盘和棋子,还需要一个推手来落子,这里称作 MotionDisplay 把,负责管理每一回合(帧)下棋子对应棋盘的位置,这个类要做的事情很多:有多少个棋子、哪一个还收回、需要新增几个棋子(风粒子的管理),怎么在棋盘上放置(渲染);等等,最后还少了一个,就是时钟啊,每一回合可是要读秒的哦,也就是 Animation。感觉例子比喻的很贴切啊,忍不住要放一张棋魂的图片来庆祝一下。

还是得上代码,不然显得不专业。下面先把上面提到的这些对象中一些关键的属性和方法说明一下,可以知道哪些关键的属性是由哪些类来管理,而一些关键的方法进行一个说明,大家可以先专注类和函数本身的内容,了解这个拼图的部分内容。最终会有一个初始化的函数来一个整体流程的介绍,这时大家会了解整个拼图的面貌。

向量比较简单,就是 X 和 Y 两个分量,其他的比如长度,角度这些方法就不在此赘述:

1
2
3
4
var Vector = function(x, y) {
this.x = x;
this.y = y;
}

下面是向量场类读取 JSON 数据并解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
VectorField.read= function(data, correctForSphere) {
var field = [];
var w = data.gridWidth;
var h = data.gridHeight;
for (var x = 0; x < w; x++) {
field[x] = [];
for (var y = 0; y < h; y++) {
var vx = data.field[i++];
var vy = data.field[i++];
var v = new Vector(vx,vy);
……
field[x][y] = v;
}
}
var result = newVectorField(field,data.x0,data.y0,data.x1,data.y1);
return result;
};

如此,向量场已经布置完善,当然,对照 JSON 数据仔细看一下代码,有保存了经纬度的范围,行和列等信息,当然,该类中有其他几个函数没有在此列出,比如判断一个点是否在棋盘内,另外还有插值,因为每一个网格位置都是离散的,行和列都是整数,而现实中风的走向是连续的,可能在当前时刻的位置是分数,则需要根据临近的整数点的值插值获取当前点的一个近似值,这里采用的是双线性插值,取的周围四个点:

1
2
3
4
5
6
7
8
9
10
VectorField.prototype.bilinear= function(coord, a, b) {
var na = Math.floor(a);
var nb = Math.floor(b);
var ma = Math.ceil(a);
var mb = Math.ceil(b);
var fa = a - na;
var fb = b - nb;
return this.field[na][nb][coord] _ (1 - fa)_ (1 - fb) +
this.field[ma][nb][coord] _ fa _ (1 - fb) + this.field[na][mb][coord] _ (1 - fa) _ fb + this.field[ma][mb][coord] _ fa _ fb;
};

如上是向量和向量场的一些关键函数和属性。实现了读取数据,通过 getValue 函数获取任意一个位置(可以使小数)的速度的 X 和 Y 分量。

下面就是棋子了,每一回合棋子的位置也就是风在每一帧的位置:

1
2
3
4
5
6
7
var Particle =function(x, y, age) {
this.x = x;
this.y = y;
this.oldX = -1;
this.oldY = -1;
this.age = age;
}

如上,XY 是当前的位置,而 old 则是上一帧的位置,age 是它的生命周期,有的时候棋子会被吃,起风了也有风停的那一刻,都是通过 age 来记录它还能活多久(每一帧减一)。

现在就开始介绍这只下棋的手了,看如何起风如何刮。

1
2
3
4
5
6
7
8
9
varMotionDisplay = function(canvas, imageCanvas, field, numParticles,opt_projection) {  
this.field = field;
this.numParticles = numParticles;
this.x0 = this.field.x0;
this.x1 = this.field.x1;
this.y0 = this.field.y0;
this.y1 = this.field.y1;
this.makeNewParticles(null, true);
};

这是它的构造函数,用来记录向量场的信息(范围和速度向量),同时 numParticles 表示粒子数,即同时有多少条风线在地图上显示。projection 用于经纬度和向量场之间的映射换算。最后 makeNewParticles 则会构建 numParticles 个风,并随机赋给它们一个起点和生命周期,代码如下:

1
2
3
4
5
6
MotionDisplay.prototype.makeNewParticles= function(animator) {
this.particles = [];
for (var i = 0; i < this.numParticles;i++) {
this.particles.push(this.makeParticle(animator));
}
};
1
2
3
4
5
6
7
MotionDisplay.prototype.makeParticle= function(animator) {
var a = Math.random();
var b = Math.random();
var x = a * this.x0 + (1 - a) *this.x1;
var y = b _ this.y0 + (1 - b) _ this.y1;
return new Particle(x,y,1 + 40 * Math.random());
};

如上是一个简单的创建粒子的过程:随机在经纬度(x,y)创建一个能够存活 1 + 40 *Math.random()帧的风,一共创建 numParticles 个这样的随机风。当然这里为了简单示意。并没有考虑随机数是否会超出范围等特殊情况。

对象都构建完成了,那每一帧这只手如何主持大局呢?两件事情:Update 和 Render。

1
2
3
4
MotionDisplay.prototype.animate= function(animator) {
this.moveThings(animator);//update
this.draw(animator); // render
}

先看看如何更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
MotionDisplay.prototype.moveThings= function(animator) {
var speed = .01 _ this.speedScale /animator.scale;
for (var i = 0; i <this.particles.length; i++) {
var p = this.particles[i];
if (p.age > 0 &&this.field.inBounds(p.x, p.y)) {
var a = this.field.getValue(p.x,p.y);
p.x += speed _ a.x;
p.y += speed * a.y;
p.age--;
} else {
this.particles[i] = this.makeParticle(animator);
}
}
};

如上,每一帧都根据速度*时间(帧)=距离来更新所有风粒子位置,同时检测如果 age 为负时,则重新创建一个来替换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
MotionDisplay.prototype.draw= function(animator) {
var g = this.canvas.getContext('2d');
var w = this.canvas.width;
var h = this.canvas.height;
if (this.first) {
g.fillStyle = this.background;
this.first = false;
} else {
g.fillStyle = this.backgroundAlpha;
}

g.fillRect(dx, dy, w , h );
for (var i = 0; i <this.particles.length; i++) {
var p = this.particles[i];
if (p.oldX != -1) {
g.beginPath();
g.moveTo(proj.x, proj.y);
g.lineTo(p.oldX, p.oldY);
g.stroke();
}
p.oldX = proj.x;
p.oldY = proj.y;
}
};

因为代码实在太长,给出的是关键步骤,先看后面的 stroke 过程,很明了,在 moveThings 的函数中我们可以得到上一帧的位置和当前帧的风粒子的位置,在这里连接起来形成了一段线。可以想象,随着帧数的增加,在有限的生命周期里面,这个折线就像贪吃蛇一样的增长:0-1-2-3-4……-n,则模拟出风的效果来下图是第一帧和第二帧的截图对比,仔细观察红线上面的那条风,这是前两帧的长度对比,或者在看一下洛杉矶附近的风,增长的比较明显,说明洛杉矶这几天风比较大哦,不信去看天气预报:

帧一

帧二

似乎这样就完美了,其实不是的。再一想,这条风有生命周期,到时候怎么从地图上把这条风擦除呢?如果不擦除岂不是就和灰一样堆满了,而且这个风明显有一种渐变的效果,这是怎么做到的?

这里面是一个很棒的技巧,透明度 backgroundAlpha,这里采用和背景颜色一样的 RGB,但增加一个透明度为 0.02,fillRect 的作用就好比每一帧都贴一层这样的纸在上面,然后在上面画新的,则之前的变的有点暗了,旧的越来越暗,达到一种逼真的效果,同时也很好的处理了新老交替。

如此,一个基本的风向图就完成了。同样,当你以为一切都明了的时候,问题才刚刚开始。简单说一下下面两个要点:实时数据和 WebGL 渲染。WebGL 介绍有一些入门要求,可能不太容易明白,主要是气质(思路)。

实时数据

代码读多了,上个段子环节一下氛围。上面例子的作者自称艺术家,想要用新的方式来思考数据,感受数据的美与乐趣。于是有了这个风向图,确实是一个很有趣的效果,但有一点不足点,作者主要是为了寻找数据的美,并没有提供一个有效的大数据实时性的方案。换句话说,这个范例还是处于看看而已的程度。一个风向图,你当然希望能在地图上实时的看到具体一个区域的风向和全球的整体效果,这就需要解决数据的高效传输。

下面这个例子则较好的考虑了这个问题,windytv 的作者是一位跳伞爱好者,每次跳伞前都要观察天气状况,特别是风向,于是乎就想到了这样一个风向图的应用。

如上是该网站的一个功能罗列,数据还是非常全的,数据来源是 GFS / NCEP / US National Weather Service,我发现里面的天气数据还是很全,而且风向只是其中一个部分(我相信以后国外的开放大数据+HTML5 下会有很多服务慢慢普及,不要错过哦)。在程序中,风向图的数据格式为 epak 的二进制格式,也是使用 ArrayBuffer 的方式来传输和解析的,对这块有兴趣的可以看看之前写的《ArrayBuffer 简析》。

还有一种很不错的方式就是图片:

注意上面黑条,其实是有八个像素的冗余,里面主要就是高宽,数据采集时间等信息,剩下的是一个全国范围的 360*180 的风向量数据。虽然该数据也不算是实时的,但可以实现六小时的更新,关键是可以进行高效的数据传输解析。

另外,用图片的好处是可以切片,比如精度不高下可以是全球的风向数据,精度高的时候,则可以更新局部的切片数据,和地图切片的思路完全一样,即避免插值的工作量,也可以更清晰的显示数据。因此,这可以算是对第一个范例一个很好的优化。另外,还是开源的哦,自己去找吧。

WebGL

百度的风向图虽然很耗性能,但确实技术上有很多值得学习的地方,毕竟用 WebGL 渲染,它是如何实现生命周期和向量场的计算,还是有很多创新点。简单说一下几个关键处,能力有限,而且确实需要有一定的 WebGL 和 OpenGL 的了解,所以希望不要深究,注重别人的思路和方法即可。

先看看百度对外提供的接口使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
surfaceLayers:[{
type: 'particle',
distance: 3,
size: [4096, 2048],
particle: {
vectorField: field,
color: 'white',
speedScaling: 1,
sizeScaling: 1,
number: 512 * 512,
motionBlurFactor: 0.99
}
}]

用法比较简单,也是制定一个 particle,里面传入向量场数据,number 则是一帧中风的最大数,后面都是内部来控制。Echart-x 的代码稍微有点乱,最后我是用全局搜索才找到实现代码的。

Map3d

负责图层创建和初始化的相关工作。

首先,当向量数据输入后,生成为一张等宽高的纹理 vectorFieldTexture,每一个向量(X,Y)就是该纹理上的一个点(RGBA),其中 X = R, Y = G, B=0 ,A=255.。则该纹理中每一个像素可以获取它的速度向量。

然后每一帧都会调用该图层的 UpDate 来更新渲染。

VectorFieldParticleSurface 这个就是一个风向图图层,记录风向图图层中的关键属性,关键是 update 函数,每一帧负责驱动状态更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
update: function(deltaTime) {  
this._particlePass.setUniform('velocityTexture',this.vectorFieldTexture);

particlePass.attachOutput(this._particleTexture1);
particlePass.setUniform('particleTexture', this._particleTexture0);
particlePass.setUniform('deltaTime', deltaTime);
particlePass.setUniform('elapsedTime', this._elapsedTime);
particlePass.render(this.renderer,frameBuffer);
this._particleMesh.material.set('particleTexture',this._particleTexture1);

frameBuffer.attach(this.renderer.gl, this._thisFrameTexture);
frameBuffer.bind(this.renderer);
this.renderer.render(this._scene,this._camera);
}

可见,里面 Render 了两次,第一次是渲染到纹理(Render To Texture),其中还有一些时间参数,第二次才是渲染到场景。

这是在更新数据,将每一点对应的速度向量和位置参数传给 shader,而真正的运算都通过 Shader,直接操作显卡来完成渲染过程。参数准备完毕,结合下面的渲染过程来具体理解。

Shader

首先在 ecx.vfParticle.particle.fragment 片元着色器:

1
2
3
4
5
6
7
8
9
10
11
12
vec4 p =texture2D(particleTexture, v_Texcoord);
if (p.w > 0.0) {
vec4 vTex = texture2D(velocityTexture,p.xy);
vec2 v = vTex.xy;
v = (v - 0.5) * 2.0;
p.z = length(v);
p.xy += v * deltaTime / 50.0 *speedScaling; // Make the particle surface seamless
p.xy = fract(p.xy);
p.w -= deltaTime;
}

gl_FragColor = p;

你会看到除了语法和 JS 的不同,里面的思路是一样的,首先从’velocityTexture’里面得到 xy,该纹理就是向量场中的信息,每一个点则对应的是速度向量,而 w 则表示生命周期。经过计算后把值赋给了 particleTexture

然后呢,如果你看懂了,就是如梦初醒的时候了,原来每一帧中,particleTexture 里面每一个点对应了当前风的位置,在 particle.fragment 中更新每一个点的位置,然后最终在场景中渲染出来。

1
2
3
4
voidmain(){
vec4 p = texture2D(particleTexture,texcoord);
gl_Position = worldViewProjection _vec4(p.xy _ 2.0 - 1.0, 0.0, 1.0);
}

一个 WebGL 渲染风向图的大致思路,说的很不详细,关键是思路。技术的钻研,只要精益求精,总会有所收获。在这个过程中,我先想到风向图怎么实现的,等看明白了又想看看其他的脚本有何不同处,发现了数据实时性,也看到了百度的 WebGL 渲染的方式,可能也会有疏漏的地方,但总体感觉收获很大,面纱揭开后,也不再神秘。或者换句话说,风场,水流,重力场都可以按照这种方式来实现,只是计算公式上稍微调整一下就可以。

本文转载自微信公众号 - LET(LET0-0)

0%