【譯】How I built a wind map with WebGL

XXHolic 發表於 2022-02-08
WebGL

引子

對風場視覺化的效果感興趣,搜資料的時候發現這篇文章,讀了後覺得翻譯一下以便再次查閱。

正文

95-1

檢視我的基於 WebGL 的風場模擬演示!讓我們深入瞭解它的工作原理。

我要坦白的說:在 Mapbox 工作的最後幾年裡,我像躲避瘟疫一樣避免直接的 OpenGL/WebGL 程式設計。原因之一:OpenGL API 和術語讓我深感恐懼。它看起來總是那麼複雜、混亂、醜陋和冗長,以至於我永遠都無法投入其中。只要聽到 stencil masks、 mipmap、depth culling、 blend functions、 normal maps 等術語,我就會有一種不安的感覺。

今年,我最終決定直面我的恐懼,使用 WebGL 構建一些有意義的東西。2D 風場模擬看起來是一個完美的機會——它很有用,視覺上令人驚歎,而且具有挑戰性,但在能力範圍內感覺仍然可以實現。我驚訝地發現,它遠沒有看上去那麼可怕!

基於 CPU 的風場視覺化

網上有很多風場視覺化的例子,但最受歡迎和最有影響的是 Cameron Beccario 的著名專案 earth.nullschool.net 。它本身不是開源的,但它有一箇舊的開源版本,大多數其它實現都是基於這個版本編寫程式碼的。一個著名的開源派生是 Esri Wind JS 。使用該技術的流行氣象服務包括 WindyVentuSky

95-2

通常,瀏覽器中的這種視覺化依賴於 Canvas 2D API,大致如下所示:

  1. 在螢幕上生成一組隨機粒子位置並繪製粒子。
  2. 對於每個粒子,查詢風的資料以獲取其當前位置的粒子速度,並相應地移動它。
  3. 將一小部分粒子重置到隨機位置。這樣可以確保風吹走的區域永遠不會完全變空。
  4. 逐漸淡出當前螢幕,並在頂部繪製新定位的粒子。

這樣做會有隨之而來的效能限制:

  • 風粒子的數量需保持較低(例如,地球示例使用~5k)。
  • 每次更新資料或檢視時都會有很大的延遲(例如,地球示例大約 2 秒),因為資料處理成本很高,而且發生在 CPU 端。

此外,要將其整合為基於 WebGL 的互動式地圖(如 Mapbox)的一部分,你必須在每一幀上將畫布元素的畫素內容載入到 GPU ,這將大大降低效能。

我一直在尋找一種方法,用 WebGL 在 GPU 端重新實現完整的邏輯,這樣它會很快,能夠繪製數百萬個粒子,並且可以整合到 Mapbox GL 地圖中,而不會造成很大的效能損失。幸運的是,我偶然發現了 Chris Wellons 所寫關於 WebGL 中粒子物理 的精彩教程,並意識到風場視覺化可以使用相同的方法。

OpenGL 基礎

令人困惑的 API 和術語使得 OpenGL 圖形程式設計非常難以學習,但從表面上看,這個概念非常簡單。這裡有一個實用的定義:

OpenGL 為高效繪製三角形提供了 2D API。

所以基本上你用 GL 所做的就是畫三角形。除了可怕的 API 之外,困難還來自於執行此操作所需的各種數學和演算法。它還可以繪製點和基本線(無平滑或圓形連線/封口),但很少使用。

95-3

OpenGL 提供了一種特殊的類 C 語言—— GLSL ——來編寫由 GPU 直接執行的程式。每個程式分為兩部分,稱為著色器——頂點著色器和片元著色器。

頂點著色器提供用於轉換座標的程式碼。例如,將三角形座標乘以 2 ,使我們的三角形看起來兩倍大。我們在繪圖時傳遞給 OpenGL 的每個座標都將執行一次。一個基本的例子:

attribute vec2 coord;
void main() {
    gl_Position = vec4(2.0 * coord, 0, 1);
}

片元著色器提供用於確定每個繪製畫素顏色的程式碼。你可以用它做很多很酷的數學運算,但最終它類似“把三角形的當前畫素畫成綠色”。示例:

void main() {
    gl_FragColor = vec4(0, 1, 0, 1);
}

在頂點著色器和片元著色器中,都可以做的一件很酷的事情是新增一個影像(稱為紋理)作為引數,然後在該影像的任何點中查詢畫素顏色。我們將在風場視覺化中很依賴這個。

片元著色器程式碼的執行是大規模並行的,並且硬體加速很快,因此通常比 CPU 上的等效計算快很多數量級。

獲取風場資料

美國國家氣象局每 6 小時釋出一次全球天氣資料,稱為 GFS,以緯度/經度網格的形式釋出相關數值(包括風速)。它以一種稱為 GRIB 的特殊二進位制格式編碼,可以使用一組特殊的工具將其解析為人類可讀的 JSON 。

我編寫了幾個小指令碼,下載並將風資料轉換成一個簡單的 PNG 影像,風速編碼為 RGB 顏色——每個畫素的水平速度為紅色,垂直速度為綠色。看起來是這樣的:

95-4

你可以下載更高解析度的版本(2x和4x),但 360×180 網格對於低縮放視覺化來說已經足夠了。PNG 壓縮非常適合這種資料,上面的影像通常只有 80 KB 左右。

基於 GPU 移動粒子

現有風場視覺化將粒子狀態儲存在 JavaScript 陣列中。我們如何在 GPU 端儲存和操作該狀態?一種稱為計算著色器(在 OpenGL ES 3.1 和等效的 WebGL 2.0 規範中)的新 GL 功能允許你在任意資料上執行著色器程式碼(無需任何渲染)。不幸的是,跨瀏覽器和移動裝置對新規範的支援非常有限,因此我們只剩下一個實用選項:紋理。

OpenGL 不僅允許你繪製螢幕,還允許繪製紋理(通過稱為幀緩衝區的概念)。因此,我們可以將粒子位置編碼為影像的 RGBA 顏色,將其載入到 GPU ,在片著色器中根據風速計算新位置,將其重新編碼為 RGBA 顏色,並將其繪製到新影像中。

X 和 Y 為了儲存足夠的精度,我們將每個元件儲存在兩個位元組中——分別為 RG 和 BA ,為每個元件提供 65536 個不同值的範圍。

95-5

一個 500×500 的示例影像將容納 250000 個粒子,我們將使用片元著色器移動每個粒子。生成的影像如下所示:

95-6

以下是在片元著色器中如何從 RGBA 中解碼和編碼位置:

// lookup particle pixel color
vec4 color = texture2D(u_particles, v_tex_pos);
// decode particle position (x, y) from pixel RGBA color
vec2 pos = vec2(
    color.r / 255.0 + color.b,
    color.g / 255.0 + color.a);
... // move the position
// encode the position back into RGBA
gl_FragColor = vec4(
    fract(pos * 255.0),
    floor(pos * 255.0) / 255.0);

在下一幀中,我們可以將這個新影像作為當前狀態,並將新狀態繪製到另一個影像中,以此類推,每幀交換兩個狀態。因此,藉助兩個粒子狀態紋理,我們可以將所有風模擬邏輯移動到 GPU 。

這種方法的速度非常快,我們不需要在瀏覽器上每秒更新 5000 個粒子 60 次,而是可以突然處理一百萬個

需要記住的一點是,在兩極附近,粒子沿 X 軸的移動速度應該比赤道上的粒子快得多,因為相同的經度表示的距離要小得多。以下著色器程式碼可以處理這一點:

float distortion = cos(radians(pos.y * 180.0 - 90.0));
// move the particle by (velocity.x / distortion, velocity.y)

繪製粒子

正如我前面提到的,除了三角形,我們還可以繪製基本的點——很少使用,但非常適合這樣的 1 畫素粒子。

要繪製每個粒子,我們只需在頂點著色器中的粒子狀態紋理上查詢其畫素顏色以確定其位置;然後通過從風紋理查詢其當前速度來確定片元著色器中的粒子顏色;最後將其對映到一個漂亮的顏色漸變(我從可靠的 ColorBrewer2 中選擇顏色)。在這一點上,它看起來是這樣的:

95-7

如果有點空隙,那是一些東西。但單憑粒子運動很難獲得風向感。我們需要新增粒子軌跡。

繪製粒子軌跡

我嘗試的第一種繪製軌跡的方法是使用 WebGL 的 PreserveDrawingBuffer 選項,它使螢幕狀態在幀之間保持不變,這樣我們可以在粒子移動時在每一幀上反覆繪製粒子。然而,這個 WebGL 特性是一個巨大的效能打擊,許多文章建議不要使用它。

相反,與使用粒子狀態紋理的方式類似,我們可以將粒子繪製到紋理中(該紋理依次繪製到螢幕上),然後在下一幀上使用該紋理作為背景(稍微變暗),並每一幀交換輸入/目標紋理。除了更好的效能之外,這種方法的一個優點是我們可以將其直接移植到本機程式碼(沒有與 preserveDrawingBuffer 等效的程式碼)。

風場插值查詢

95-8

在緯度/經度柵格上,風資料針對特定點有對應的值,例如(50,30)、(51,30)、(50,31)、(51,31)地理點。如何獲得任意中間值,例如(50.123,30.744)?

OpenGL 在查詢紋理顏色時提供自帶插值。然而,它仍然會導致塊狀、畫素化的圖案。以下是在縮放時,在風紋理中這些瑕疵的示例:

95-9

幸運的是,我們可以通過在每個風探測器中查詢 4 個相鄰畫素,並在片元著色器中的本地畫素上對其進行手動雙線性插值計算,來平滑瑕疵。它的成本更高,但修復了瑕疵併產生更流暢的風場視覺化。以下是與此技術相同的區域:

95-10

GPU 上的偽隨機生成器

還有一個棘手的邏輯需要在 GPU 上實現——隨機重置粒子位置。如果不這樣做,即使是大量的風粒子也會變為螢幕上的幾行,因為風吹走的區域會隨著時間變空:

95-11

問題是著色器沒有隨機數生成器。我們如何隨機決定粒子是否需要重置?

我在 StackOverflow 上找到了一個解決方案——一個用於生成偽隨機數的 GLSL 函式,它接受一對數字作為輸入:

float rand(const vec2 co) {
    float t = dot(vec2(12.9898, 78.233), co);
    return fract(sin(t) * (4375.85453 + t));
}

這個奇特的函式依賴於 sin 的結果變化。然後我們可以這樣做:

if (rand(some_numbers) > 0.99)
    reset_particle_position();

這裡的挑戰在於為每個粒子選擇一個足夠“隨機”的輸入,以便生成的值在整個螢幕上是一致的,並且不會顯示奇怪的圖案。

使用當前粒子位置作為源並不完美,因為相同的粒子位置將始終生成相同的隨機數,因此某些粒子將在同一區域消失。

使用在狀態紋理中的粒子位置也不起作用,因為相同的粒子將始終消失。

我最終得到的結果取決於粒子位置和狀態位置,再加上在每一幀上計算並傳遞給著色器的隨機值:

vec2 seed = (pos + v_tex_pos) * u_rand_seed;

但我們還有另一個小問題——粒子速度非常快的區域看起來比沒有太多風的區域密度要大得多。我們可以通過對更快的粒子增加粒子重置速率來平衡這一點:

float dropRate = u_drop_rate + speed_t * u_drop_rate_bump;

這裡的 speed_t 是一個相對速度值(從0到1),u_drop_rateu_drop_rate_bump 是可以在最終視覺化中調整的引數。以下是它如何影響結果的示例:

95-12
95-13

下一步是什麼?

結果是一個完全由 GPU 驅動的風場視覺化,可以以 60fps 的速度渲染一百萬個粒子。試著在演示中使用滑塊,並檢視最終的程式碼——總共大約 250 行,我努力使其儘可能的可讀。

下一步是將其整合到可以探索的實時地圖中。我在這方面取得了一些進展,但還不足以分享一個實時演示。這裡有一些部分片段:

95-14

感謝你的閱讀,請繼續關注更多更新!如果你錯過了,請檢視我上一篇關於空間演算法的文章。

非常感謝我的 Mapbox 隊友 kkaeferansis,他們耐心地回答了我所有關於圖形程式設計的傻問題,給了我很多寶貴的提示,幫助我學到了很多東西。❤️

參考資料