很多人都見過風向圖,直觀形象,也是地圖資料和現實資料在視覺化上很好的結合。
這是我見的第一個風向圖,記得是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兩個分量,其他的比如長度,角度這些方法就不在此贅述:
var Vector = function(x, y) { this.x = x; this.y = y; }
下面是向量場類讀取JSON資料並解析:
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資料仔細看一下程式碼,有儲存了經緯度的範圍,行和列等資訊,當然,該類中有其他幾個函式沒有在此列出,比如判斷一個點是否在棋盤內,另外還有插值,因為每一個網格位置都是離散的,行和列都是整數,而現實中風的走向是連續的,可能在當前時刻的位置是分數,則需要根據臨近的整數點的值插值獲取當前點的一個近似值,這裡採用的是雙線性插值,取的周圍四個點:
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分量。
下面就是棋子了,每一回合棋子的位置也就是風在每一幀的位置:
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來記錄它還能活多久(每一幀減一)。
現在就開始介紹這隻下棋的手了,看如何起風如何刮。
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個風,並隨機賦給它們一個起點和生命週期,程式碼如下:
MotionDisplay.prototype.makeNewParticles= function(animator) { this.particles = []; for (var i = 0; i < this.numParticles;i++) { this.particles.push(this.makeParticle(animator)); } }; 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。
MotionDisplay.prototype.animate= function(animator) { this.moveThings(animator);//update this.draw(animator); // render }
先看看如何更新:
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為負時,則重新建立一個來替換。
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的瞭解,所以希望不要深究,注重別人的思路和方法即可。
先看看百度對外提供的介面使用方式:
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函式,每一幀負責驅動狀態更新。
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片元著色器:
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中更新每一個點的位置,然後最終在場景中渲染出來。
voidmain() { vec4 p = texture2D(particleTexture,texcoord); gl_Position = worldViewProjection *vec4(p.xy * 2.0 - 1.0, 0.0, 1.0); }
一個WebGL渲染風向圖的大致思路,說的很不詳細,關鍵是思路。技術的鑽研,只要精益求精,總會有所收穫。在這個過程中,我先想到風向圖怎麼實現的,等看明白了又想看看其他的指令碼有何不同處,發現了資料實時性,也看到了百度的WebGL渲染的方式,可能也會有疏漏的地方,但總體感覺收穫很大,面紗揭開後,也不再神祕。或者換句話說,風場,水流,重力場都可以按照這種方式來實現,只是計算公式上稍微調整一下就可以。
看到這的人也不容易,希望對你也有所收穫.