【譯】A GPU Approach to Particle Physics

XXHolic發表於2022-02-14

引子

想到原文中有提到參考的教程,就去看了下,發現對一些邏輯的理解很有幫助,順便翻譯記錄一下。

正文

我的 GPGPU 系列的下一個專案是一個粒子物理引擎,它在 GPU 上計算整個物理模擬。粒子受重力影響,會與場景幾何體產生反彈。這個 WebGL 演示使用了著色器功能,並不需要嚴格按照 OpenGL ES 2.0 規範要求,因此它可能在某些平臺上無法工作,尤其是在移動裝置上。這將在本文後面討論。

它是可互動的。滑鼠游標是一個讓粒子反彈的圓形障礙物,單擊將在模擬中放置永久性障礙物。你可以繪製粒子可以流過的結構。

這是示例的 HTML5 視訊展示,出於必要,它以每秒 60 幀的高位元率錄製,所以它相當大。視訊編解碼器不能很好地處理全屏所有粒子,較低的幀率也不能很好地捕捉效果。我還新增了一些在實際演示中聽不到的聲音。

在現代 GPU 上,它可以以每秒 60 幀的速度模擬並且繪製超過 4 百萬個粒子。請記住,這是一個 JavaScript 應用程式,我沒有真正花時間優化著色器,它受 WebGL 的約束,而不是像 OpenCL 或至少桌面 OpenGL 這樣更適合一般計算的東西。

粒子狀態編碼為顏色

就像 Game of Lifepath finding 專案一樣,模擬狀態儲存在成對的紋理中,大部分工作是在片元著色器中通過它們之間逐畫素對映完成。我不會重複這個設定細節,所以如果你需要了解它是如何工作的,請參考 Game of Life 一文。

對於這個模擬,這些紋理中有四個而不是兩個:一對位置紋理和一對速度紋理。為什麼是成對的紋理?有 4 個通道,因此其中的每一個部分(x、y、dx、dy)都可以打包到自己的顏色通道中。這似乎是最簡單的解決方案。

101-1

這個方案的問題是缺乏精確性。對於 R8G8B8A8 內部紋理格式,每個通道為一個位元組。總共有 256 個可能的值。顯示區域為 800×600 畫素,因此顯示區域上的每個位置不能都顯示。幸運的是,兩個位元組(總計 65536 個值)對於我們來說已經足夠了。

101-2
101-3

下一個問題是如何跨這兩個通道編碼值。它需要覆蓋負值(負速度),並應儘量充分利用動態範圍,比如嘗試使用所有 65536 個範圍內的值。

要對一個值編碼,將該值乘以一個標量,將其擴充套件到編碼的動態範圍。選擇標量時,所需的最高值(顯示的尺寸)是編碼的最高值。

接下來,將動態範圍的一半新增到縮放值。這會將所有負值轉換為正值,0 表示最小值。這種表示法稱為 Excess-K 。其缺點是用透明黑色清除紋理(glClearColor)不能將解碼值設定為 0 。

最後,將每個通道視為基數為 256 的數字。OpenGL ES 2.0 著色器語言沒有按位運算子,因此這是使用普通的除法和模來完成的。我用 JavaScript 和 GLSL 製作了一個編碼器和解碼器。JavaScript 需要它來寫入初始值,並且出於除錯目的,它可以讀回粒子位置。

vec2 encode(float value) {
    value = value * scale + OFFSET;
    float x = mod(value, BASE);
    float y = floor(value / BASE);
    return vec2(x, y) / BASE;
}

float decode(vec2 channels) {
    return (dot(channels, vec2(BASE, BASE * BASE)) - OFFSET) / scale;
}

JavaScript 與上面的標準化 GLSL 值(0.0-1.0)不同,這會生成一個位元組的整數(0-255),用於打包到型別化陣列中。

function encode(value, scale) {
    var b = Particles.BASE;
    value = value * scale + b * b / 2;
    var pair = [
        Math.floor((value % b) / b * 255),
        Math.floor(Math.floor(value / b) / b * 255)
    ];
    return pair;
}

function decode(pair, scale) {
    var b = Particles.BASE;
    return (((pair[0] / 255) * b +
             (pair[1] / 255) * b * b) - b * b / 2) / scale;
}

更新每個粒子的片元著色器在該粒子的“索引”處對位置和速度紋理進行取樣,解碼它們的值,對它們進行操作,然後將它們編碼回一種顏色,以便寫入輸出紋理。因為我使用的是 WebGL ,它缺少多個渲染目標(儘管支援 gl_FragData ),所以片元著色器只能輸出一種顏色。位置在一個過程中更新,速度在另一個過程中更新為兩個單獨的繪圖。緩衝區在兩個過程完成才會交換,因此速度著色器(有意)不會使用更新的位置值。

最大紋理大小有一個限制,通常為 8192 或 4096 ,因此紋理不是以一維紋理排列,而是保持方形。粒子由二維座標索引。

看到直接繪製到螢幕上而不是正常顯示的位置或速度紋理非常有趣。這是觀看模擬的另一個領域,它甚至幫助我發現了一些其它方面很難看到的問題。輸出是一組閃爍的顏色,但有明確的模式,展示了系統的許多狀態(或不在其中的狀態)。我想分享一段視訊,但編碼比普通顯示更不切實際。以下是截圖:位置,然後是速度。這裡沒有捕捉到阿爾法分量。

101-4
101-5

狀態保持

在 GPU 上執行這樣的模擬最大的挑戰之一是缺少隨機值。著色器語言中沒有 rand() 函式,因此預設情況下整個過程都是確定性的。所有的狀態都來自 CPU 填充的初始紋理狀態。當粒子聚集並匹配狀態時,可能是一起流過一個障礙物,很難將它們重新分離,因為模擬會以相同的方式處理它們。

為了緩解這個問題,第一條規則是儘可能地保持狀態。當一個粒子離開顯示區域底部時,會將其移回頂部進行“重置”。如果通過將粒子的 Y 值設定為 0 來完成此操作,那麼資訊將被銷燬。這必須避免!儘管在相同的迭代過程中退出,但顯示底部邊緣以下粒子的 Y 值往往略有不同。代替重置為 0 的做法是新增一個常量:顯示區域的高度。Y 值仍然不同,因此這些粒子在碰撞障礙物時更有可能遵循不同的路線。

我使用的下一種技術是通過 uniform 為每次迭代提供一個新隨機值,該值被新增到重置粒子的位置和速度。相同的值用於該特定迭代的所有粒子,因此這無助於重疊粒子,但有助於分離“流”。這些都是清晰可見的粒子線,都沿著相同的路徑。每一個都會在不同的迭代中退出顯示的底部,因此隨機值會將它們稍微分開。最終,這會在每次迭代的模擬中加入一些新的狀態。

或者,可以向著色器提供包含隨機值的紋理。CPU 必須經常填充和上傳紋理,另外還有選擇紋理取樣位置的問題,紋理本身需要一個隨機值。

最後,為了處理完全重疊的粒子,在重置時縮放粒子的唯一二維索引,並將其新增到位置和速度中,將它們分開。隨機值的符號乘以索引,以避免在任何特定方向上出現偏差。

為了在演示中看到所有這些,製作一個大圓形來捕獲所有粒子,讓它們流入一個點。這將去除系統中的所有狀態。現在清除障礙。它們都會變成一個緊密的團。在頂部重置時,它仍然會有一些聚集,但你會看到它們稍微分開(新增了粒子索引)。它們將在稍有不同的時間離開底部,因此隨機值發揮了作用,使它們更加分離。幾輪之後,粒子應該會再次均勻分佈。

狀態的最後一個來源是你的滑鼠。在場景中移動它時,會干擾粒子,並向模擬引入一些噪聲。

紋理作為頂點屬性緩衝區

在閱讀 OpenGL ES 著色器語言規範(PDF)時,我產生了這個專案的想法。我一直想做一個粒子系統,但我卡在如何繪製粒子的問題上。表示位置的紋理資料需要以某種方式作為頂點反饋到管道中。通常,緩衝區紋理——由陣列緩衝區支援的紋理——或畫素緩衝區物件——非同步紋理資料複製——可用於此操作,但 WebGL 沒有這些功能。從 GPU 中提取紋理資料,並將其作為每幀上的陣列緩衝區重新載入是不可能的。

然而,我想出了一個很酷的技巧,比這兩個都好。著色器函式 texture2D 用於對紋理中的畫素進行取樣。通常情況下,片元著色器將其用作計算一個畫素顏色過程的一部分。但是著色器語言規範提到,texture2D 也可以在頂點著色器中使用。就在那時,一個點子擊中了我。頂點著色器本身可以執行從紋理到頂點的轉換

它的工作原理是將前面提到的二維粒子索引作為頂點屬性傳遞,使用它們從頂點著色器中查詢粒子位置。著色器將以 GL_POINTS 模式執行,發射點粒子。這是簡略的版本:

attribute vec2 index;

uniform sampler2D positions;
uniform vec2 statesize;
uniform vec2 worldsize;
uniform float size;

// float decode(vec2) { ...

void main() {
    vec4 psample = texture2D(positions, index / statesize);
    vec2 p = vec2(decode(psample.rg), decode(psample.ba));
    gl_Position = vec4(p / worldsize * 2.0 - 1.0, 0, 1);
    gl_PointSize = size;
}

真實版本也會對速度進行取樣,因為它會調節顏色(緩慢移動的粒子比快速移動的粒子更亮)。

然而,有一個潛在的問題:允許實現將頂點著色器紋理繫結的數量限制為 0(GL_MAX_vertex_texture_IMAGE_UNITS)。所以從技術上講,頂點著色器必須始終支援 texture2D ,但它們不需要支援實際的紋理。這有點像飛機上不載客的餐飲服務。有些平臺不支援這種技術。到目前為止,我只在一些移動裝置上遇到過這個問題。

除了缺乏一些平臺的支援之外,這允許模擬的每個部分都留在 GPU 上,併為純 GPU 粒子系統鋪平道路。

障礙物

一個重要的觀察結果是粒子之間不相互作用。這不是一個 n 體模擬。然而,它們確實與世界其它地方互動:它們直觀地從這些靜止的圓圈上反彈。該環境由另一個紋理表示,該紋理在正常迭代期間不會更新。我稱之為障礙物紋理。

障礙物紋理上的顏色是曲面法線。也就是說,每個畫素都有一個指向它的方向,一個將粒子導向某個方向的流。空隙有一個特殊的常值(0,0)。這不是單位向量(長度不為1),因此它是對粒子沒有影響的帶外值。

101-6

粒子只需對障礙物紋理進行取樣即可檢查碰撞。如果在其位置找到法線,則使用著色器函式 reflect 更改其速度。該函式通常用於在 3D 場景中反射光線,但對於緩慢移動的粒子同樣適用。其效果是粒子以自然的方式從圓上反彈。

有時粒子以低速或零速度落在障礙物上或落在障礙物中。為了把它們從障礙物上移開,會將它們朝著正常的方向輕輕推一推。你會在斜坡上看到這一點,在那裡,緩慢的粒子像跳躍的豆子一樣搖動著向下自由移動。

為了使障礙物紋理對使用者友好,實際的幾何圖形在 JavaScript 的 CPU 端進行維護。這些圓會保留在一個列表中,並在更新時,這個列表中重新繪製障礙物紋理。例如,每次在螢幕上移動滑鼠時,都會出現這種情況,從而產生移動障礙物。紋理提供了對幾何體的著色器友好訪問。兩種表現有兩個目的。

當我開始編寫程式的這一部分時,我設想除了圓以外其它可以放置的形狀。例如,實心矩形:法線看起來像這樣。

101-7

到目前為止,這些尚未得到實施。

未來想法

我還沒試過,但我想知道粒子是否也可以通過將自己繪製到障礙物紋理上來相互作用。附近的兩個粒子會相互反彈。也許整個 liquid demo 可以像這樣在 GPU 上執行。如果我猜想正確,粒子會增大體積,形成碗狀的障礙物會填滿,而不是將粒子集中到一個點上。

我認為這個專案還有一些需要探索的地方。

參考資料

相關文章