目錄
引子
對風場可視化的效果感興趣,搜資料的時候發現這篇文章,讀了后覺得翻譯一下以便再次查閱。
正文
查看我的基於 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 。使用該技術的流行氣象服務包括 Windy 和 VentuSky 。
通常,瀏覽器中的這種可視化依賴於 Canvas 2D API,大致如下所示:
- 在屏幕上生成一組隨機粒子位置並繪制粒子。
- 對於每個粒子,查詢風的數據以獲取其當前位置的粒子速度,並相應地移動它。
- 將一小部分粒子重置到隨機位置。這樣可以確保風吹走的區域永遠不會完全變空。
- 逐漸淡出當前屏幕,並在頂部繪制新定位的粒子。
這樣做會有隨之而來的性能限制:
- 風粒子的數量需保持較低(例如,地球示例使用~5k)。
- 每次更新數據或視圖時都會有很大的延遲(例如,地球示例大約 2 秒),因為數據處理成本很高,而且發生在 CPU 端。
此外,要將其集成為基於 WebGL 的交互式地圖(如 Mapbox)的一部分,你必須在每一幀上將畫布元素的像素內容加載到 GPU ,這將大大降低性能。
我一直在尋找一種方法,用 WebGL 在 GPU 端重新實現完整的邏輯,這樣它會很快,能夠繪制數百萬個粒子,並且可以集成到 Mapbox GL 地圖中,而不會造成很大的性能損失。幸運的是,我偶然發現了 Chris Wellons 所寫關於 WebGL 中粒子物理 的精彩教程,並意識到風場可視化可以使用相同的方法。
OpenGL 基礎
令人困惑的 API 和術語使得 OpenGL 圖形編程非常難以學習,但從表面上看,這個概念非常簡單。這里有一個實用的定義:
OpenGL 為高效繪制三角形提供了 2D API。
所以基本上你用 GL 所做的就是畫三角形。除了可怕的 API 之外,困難還來自於執行此操作所需的各種數學和算法。它還可以繪制點和基本線(無平滑或圓形連接/封口),但很少使用。
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 顏色——每個像素的水平速度為紅色,垂直速度為綠色。看起來是這樣的:
你可以下載更高分辨率的版本(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 個不同值的范圍。
一個 500×500 的示例圖像將容納 250000 個粒子,我們將使用片元着色器移動每個粒子。生成的圖像如下所示:
以下是在片元着色器中如何從 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 中選擇顏色)。在這一點上,它看起來是這樣的:
如果有點空隙,那是一些東西。但單憑粒子運動很難獲得風向感。我們需要添加粒子軌跡。
繪制粒子軌跡
我嘗試的第一種繪制軌跡的方法是使用 WebGL 的 PreserveDrawingBuffer
選項,它使屏幕狀態在幀之間保持不變,這樣我們可以在粒子移動時在每一幀上反復繪制粒子。然而,這個 WebGL 特性是一個巨大的性能打擊,許多文章建議不要使用它。
相反,與使用粒子狀態紋理的方式類似,我們可以將粒子繪制到紋理中(該紋理依次繪制到屏幕上),然后在下一幀上使用該紋理作為背景(稍微變暗),並每一幀交換輸入/目標紋理。除了更好的性能之外,這種方法的一個優點是我們可以將其直接移植到本機代碼(沒有與 preserveDrawingBuffer
等效的代碼)。
風場插值查找
在緯度/經度柵格上,風數據針對特定點有對應的值,例如(50,30)、(51,30)、(50,31)、(51,31)地理點。如何獲得任意中間值,例如(50.123,30.744)?
OpenGL 在查找紋理顏色時提供自帶插值。然而,它仍然會導致塊狀、像素化的圖案。以下是在縮放時,在風紋理中這些瑕疵的示例:
幸運的是,我們可以通過在每個風探測器中查找 4 個相鄰像素,並在片元着色器中的本地像素上對其進行手動雙線性插值計算,來平滑瑕疵。它的成本更高,但修復了瑕疵並產生更流暢的風場可視化。以下是與此技術相同的區域:
GPU 上的偽隨機生成器
還有一個棘手的邏輯需要在 GPU 上實現——隨機重置粒子位置。如果不這樣做,即使是大量的風粒子也會變為屏幕上的幾行,因為風吹走的區域會隨着時間變空:
問題是着色器沒有隨機數生成器。我們如何隨機決定粒子是否需要重置?
我在 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_rate
和 u_drop_rate_bump
是可以在最終可視化中調整的參數。以下是它如何影響結果的示例:
下一步是什么?
結果是一個完全由 GPU 驅動的風場可視化,可以以 60fps 的速度渲染一百萬個粒子。試着在演示中使用滑塊,並查看最終的代碼——總共大約 250 行,我努力使其盡可能的可讀。
下一步是將其集成到可以探索的實時地圖中。我在這方面取得了一些進展,但還不足以分享一個實時演示。這里有一些部分片段:
感謝你的閱讀,請繼續關注更多更新!如果你錯過了,請查看我上一篇關於空間算法的文章。
非常感謝我的 Mapbox 隊友 kkaefer 和 ansis,他們耐心地回答了我所有關於圖形編程的傻問題,給了我很多寶貴的提示,幫助我學到了很多東西。❤️