你的第一個 WebGPU 應用程式

1. 簡介

WebGPU 標誌由多個藍色三角形組成,每個三角形形成風格化的「W」

上次更新時間:2023 年 8 月 28 日

什麼是 WebGPU?

WebGPU 是新型的 API,可用於在網頁應用程式中存取 GPU 的功能。

現代化 API

在推出 WebGPU 前,WebGL 提供部分 WebGPU 功能。至今已實現全新的豐富網頁內容,開發人員也透過這種方式創造了令人驚豔的成果。但此模組是以 2007 年推出的 OpenGL ES 2.0 API 為基礎,該 API 是以較舊的 OpenGL API 為基礎。GPU 在這段時間內已有顯著的發展,而且用來與 GPU 介面的原生 API 也不斷演進,與 Direct3D 12MetalVulkan 也有所改變。

WebGPU 將這些現代 API 的發展引入網路平台。重點在於以跨平台模式啟用 GPU 功能,同時提供與網路環境相似的 API,其詳細程度不如一些以程式碼為基礎建構而成的原生 API。

轉譯

GPU 通常與轉譯快速、精細的圖形有關,WebGPU 也不例外。這項功能除了支援目前大多數的電腦和行動裝置 GPU 的算繪技術,還提供了所需功能,且可隨著硬體功能不斷發展,為日後新增的功能提供未來發展方向。

運算

除了轉譯外,WebGPU 也能充分發揮 GPU 的潛能,用於執行一般用途、高度平行處理的工作負載。這些運算著色器可以單獨使用,無需任何轉譯元件,或與轉譯管道緊密整合。

在今天的程式碼研究室中,您將瞭解如何善用 WebGPU 的轉譯和運算功能,建立簡單的入門專案!

建構項目

在本程式碼研究室中,您將使用 WebGPU 建構 Conway's Life of Life,您的應用程式將會:

  • 使用 WebGPU 的轉譯功能繪製簡單的 2D 圖形。
  • 使用 WebGPU 運算功能執行模擬作業。

本程式碼研究室最終產品的螢幕截圖

Game of Life 指的是所謂的「細胞 Automaton」,依部分規則,「儲存格」網格中的狀態會隨時間而改變。在 Game of Life 儲存格中,系統會根據鄰近儲存格中有多少處於活動狀態,判斷是否處於有效或無效狀態。這會導致有趣的模式在你觀看時出現波動。

課程內容

  • 如何設定 WebGPU 和麵板。
  • 如何繪製簡易 2D 幾何圖形。
  • 如何使用頂點和片段著色器修改繪製的內容。
  • 如何使用運算著色器執行簡單的模擬。

本程式碼研究室著重於介紹 WebGPU 背後的基本概念。目的不是要全面檢視 API,它也不涵蓋 (或需要) 常見的相關主題,例如 3D 矩陣數學。

軟硬體需求

  • ChromeOS、macOS 或 Windows 上的最新版 Chrome (113 以上版本)。WebGPU 是跨瀏覽器、跨平台的 API,但尚未支援所有裝置。
  • 瞭解 HTML、JavaScript 和 Chrome 開發人員工具

不一定要熟悉 WebGL、Meta、Vulkan 或 Direct3D 等其他圖形 API。不過,如果你有任何相關經驗,可能會發現 WebGPU 有許多相似之處,協助你快速上手!

2. 做好準備

取得程式碼

本程式碼研究室沒有任何依附元件,並會逐步引導您建立 WebGPU 應用程式,因此您不需要任何程式碼就能開始使用。不過,您可以前往 https://glitch.com/edit/#!/your-first-webgpu-app 查看部分可做為查核點的工作範例。您可以瀏覽這些資源,以便在遇到困難時參考。

歡迎使用 Play 管理中心!

WebGPU 是相當複雜的 API,設有許多強制規定正確的使用規則。更糟的是,由於 API 的運作原理,這個 API 無法引發許多錯誤的典型 JavaScript 例外狀況,因而更難找出問題的來源。

使用 WebGPU 進行開發時,您「會」遇到問題,尤其是新手可以遇到的問題!這個 API 的開發人員瞭解在處理 GPU 開發作業方面的挑戰,也一直在努力確保 WebGPU 程式碼造成錯誤時,系統都會透過 Play 管理中心傳回詳盡且實用的訊息,協助您找出並修正問題。

在處理任何網頁應用程式時,讓控制台保持開啟總是非常實用,但特別適用於這種情況!

3. 初始化 WebGPU

先從 <canvas> 開始

如果只想使用 WebGPU 進行運算,則可以在不顯示任何畫面的情況下使用 WebGPU 來進行運算。但如果您想算繪任何內容 (例如在程式碼研究室中執行的操作),就需要畫布。不妨從這裡開始著手!

建立新的 HTML 文件,其中含有一個 <canvas> 元素,以及用於查詢畫布元素的 <script> 標記。(或從故障處使用 00-starter-page.html)。

  • 使用下列程式碼建立 index.html 檔案:

index.html

<!doctype html>

<html>
  <head>
    <meta charset="utf-8">
    <title>WebGPU Life</title>
  </head>
  <body>
    <canvas width="512" height="512"></canvas>
    <script type="module">
      const canvas = document.querySelector("canvas");

      // Your WebGPU code will begin here!
    </script>
  </body>
</html>

申請轉接頭和裝置

您現在可以進入 WebGPU 位元了!首先,請考量 WebGPU 等 API 需要一段時間才能傳播到整個網路生態系統。因此,有效的第一步是檢查使用者的瀏覽器是否可以使用 WebGPU。

  1. 如要確認 navigator.gpu 物件 (做為 WebGPU 進入點) 是否存在,請加入下列程式碼:

index.html

if (!navigator.gpu) {
  throw new Error("WebGPU not supported on this browser.");
}

在理想情況下,您希望讓頁面改回不使用 WebGPU 模式,進而在無法使用 WebGPU 時通知使用者。(也許可以改用 WebGL?)不過,就本程式碼研究室而言,您只需擲回錯誤,即可防止程式碼進一步執行。

確定瀏覽器支援 WebGPU 後,為應用程式初始化 WebGPU 的第一步就是要求 GPUAdapter。您可以將轉接器視為 WebGPU 代表裝置上特定 GPU 硬體的表示法。

  1. 如要取得轉接器,請使用 navigator.gpu.requestAdapter() 方法。此函式會傳回保證值,因此呼叫 await 是最便捷的方式。

index.html

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
  throw new Error("No appropriate GPUAdapter found.");
}

如果找不到合適的轉接器,傳回的 adapter 值可能是 null,因此建議您處理這種可能性。如果使用者的瀏覽器支援 WebGPU,但他們的 GPU 硬體不具備使用 WebGPU 所需的全部功能,就可能發生這種狀況。

在大多數情況下,您可以直接讓瀏覽器選擇預設轉接器 (如此處所示),但為了滿足更進階的需求,也可以將引數傳遞requestAdapter(),指定要在搭載多個 GPU 的裝置 (例如某些筆電) 上使用低功耗或高效能硬體。

取得轉接器後,最後一個步驟是要求 GPUDevice,即可開始使用 GPU。裝置是大部分與 GPU 互動的主要介面。

  1. 呼叫 adapter.requestDevice() 即可取得裝置,此作業也會傳回承諾。

index.html

const device = await adapter.requestDevice();

requestAdapter() 一樣,您可以透過這裡的設定使用進階用途,例如啟用特定硬體功能或要求更高的限制,但為了您的用途,預設值就沒有問題。

設定畫布

有了裝置後,還得再執行一項操作:設定畫布,以便與剛剛建立的裝置搭配使用。

  • 如要這樣做,請先呼叫 canvas.getContext("webgpu"),從畫布要求 GPUCanvasContext。(這與您用來初始化 Canvas 2D 或 WebGL 結構定義的呼叫,分別使用 2dwebgl 結構定義類型。)接著,系統傳回的 context 必須使用 configure() 方法與裝置建立關聯,如下所示:

index.html

const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
  device: device,
  format: canvasFormat,
});

這裡可以傳遞一些選項,但最重要的選項是您將與結構定義搭配使用的 deviceformat (也就是結構定義應使用的紋理格式)。

紋理是 WebGPU 用來儲存圖片資料的物件,每個紋理都採用特定格式,讓 GPU 知道資料在記憶體中的顯示方式。紋理記憶體的運作方式不在本程式碼研究室的介紹範圍內。重要的是,畫布結構定義可提供要繪製的程式碼紋理,而您使用的格式會影響畫布顯示這些圖片的效率。使用不同紋理格式時,各類裝置都能發揮最佳成效,如果您不使用裝置偏好的格式,可能會導致額外的記憶體副本在背景執行,圖片才能以頁面上呈現。

幸好,您無需擔心,因為 WebGPU 會告訴您該用何種格式製作畫布!在大部分情況下,您想呼叫 navigator.gpu.getPreferredCanvasFormat() 來傳遞傳回的值,如上文所述。

清除面板

取得裝置並設定好畫布後,即可開始使用裝置變更無框畫的內容。一開始,請以單色清除。

為此,您必須在 WebGPU 中提供某些指令,指示 GPU 執行該作業。

  1. 為此,請讓裝置建立 GPUCommandEncoder,用於記錄 GPU 指令的介面。

index.html

const encoder = device.createCommandEncoder();

您要傳送至 GPU 的指令與轉譯相關 (在本例中為清除畫布),因此下一步是使用 encoder 來啟動 Render Pass。

在 WebGPU 中,所有繪圖作業都發生時,轉譯過程通過。每次呼叫都使用 beginRenderPass() 呼叫開始,這個呼叫定義了用於接收任何繪圖指令輸出內容的紋理。更進階的用法則可提供多個稱為「連接點」的紋理,用途各有不同,例如儲存算繪幾何圖形的深度或提供反鋸齒。但只需要一個應用程式即可。

  1. 透過呼叫 context.getCurrentTexture(),從先前建立的畫布結構定義中取得紋理,此元素會傳回像素寬度和高度與畫布 widthheight 屬性相符的紋理,以及您呼叫 context.configure() 時指定的 format

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
     view: context.getCurrentTexture().createView(),
     loadOp: "clear",
     storeOp: "store",
  }]
});

紋理會做為 colorAttachmentview 屬性指定。算繪票證需要提供 GPUTextureView (而非 GPUTexture),用於指定要算繪紋理的哪些部分。這只適用於更進階的用途,因此在這裡您可以呼叫 createView(),但不在紋理上使用引數,表示您希望算繪通道使用整個紋理。

您也必須指定在紋理開始及結束時,您希望算繪通道如何處理紋理:

  • 如果 loadOp 值為 "clear",則代表您希望在算繪通道開始時清除紋理。
  • "store"storeOp 值表示在算繪通道完成後,您會希望在算繪通道期間完成任何繪圖結果,將結果儲存至紋理中。

算繪通道開始後,就什麼都沒做!目前至少使用 loadOp: "clear" 啟動算繪通道的做法,足以清除紋理檢視畫面和畫布。

  1. 立即在 beginRenderPass() 之後新增下列呼叫,以結束算繪通道:

index.html

pass.end();

請務必瞭解,只是發出這些呼叫並不會導致 GPU 實際執行任何動作。只是記錄供 GPU 執行後續作業的指令

  1. 如要建立 GPUCommandBuffer,請在指令編碼器上呼叫 finish()。指令緩衝區是不透明的控制代碼,對已記錄的指令執行。

index.html

const commandBuffer = encoder.finish();
  1. 使用 GPUDevicequeue 將指令緩衝區提交給 GPU。佇列會執行所有 GPU 指令,確保執行作業排序正確且保持同步。佇列的 submit() 方法採用指令緩衝區的陣列,但在這個範例中,您只有一個。

index.html

device.queue.submit([commandBuffer]);

在您提交指令緩衝區後,便無法再次使用,因此不需要將其保留。如要提交更多指令,則需建構另一個指令緩衝區。因此常會看見這兩個步驟收合成一個步驟的原因,如本程式碼研究室的範例頁面所示:

index.html

// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);

將指令提交至 GPU 後,讓 JavaScript 將控制項傳回瀏覽器。此時,瀏覽器會看到您變更了內容目前的紋理,並更新畫布,將該紋理顯示為圖片。如果您之後想要再次更新畫布內容,就必須記錄並提交新的指令緩衝區,然後再次呼叫 context.getCurrentTexture() 來取得算繪通道的新紋理。

  1. 重新載入網頁。請注意,畫布填滿了黑色。恭喜!這表示您已成功建立第一個 WebGPU 應用程式。

一個黑色畫布,表示已成功使用 WebGPU 清除畫布內容。

選個顏色!

說實話,黑色方形十分無聊。因此,花一些時間繼續閱讀下一節,為影片提供一些個人特色。

  1. encoder.beginRenderPass() 呼叫中,新增包含 clearValue 的行至 colorAttachment,如下所示:

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
    view: context.getCurrentTexture().createView(),
    loadOp: "clear",
    clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
    storeOp: "store",
  }],
});

clearValue 會指示算繪通道,在票證開頭執行 clear 作業時要使用的顏色。傳入的字典包含四個值:r 代表「紅色」g 為「綠色」b 為「藍色」,以及「Alpha 版」 (透明度)。a每個值的範圍都可以從 01,也可以一起說明色彩管道的值。例如:

  • { r: 1, g: 0, b: 0, a: 1 }為亮紅色。
  • { r: 1, g: 0, b: 1, a: 1 }為亮紫色。
  • { r: 0, g: 0.3, b: 0, a: 1 }為深綠色。
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 }為中灰色。
  • { r: 0, g: 0, b: 0, a: 0 } 預設為透明的黑色。

本程式碼研究室中的範例程式碼和螢幕截圖使用深藍色,但您可以隨意挑選喜歡的顏色!

  1. 選好顏色後,請重新載入頁面。你應該會在畫布中看到所選的顏色。

畫布已清除為深藍色,示範如何變更預設的清晰色彩。

4. 繪製幾何圖形

本節結束時,應用程式會在畫布上繪製一些簡單的幾何圖形:彩色正方形。提醒你,這種簡單的輸出內容似乎相當龐大,但這是因為 WebGPU 經過特別設計,能夠以極高效率的方式轉譯「許多」幾何圖形。執行這種效率的副作用是,執行相對簡單的工作可能會感覺出乎意料的困難,但如果您開始使用 WebGPU 這類 API,就能達到預期的目標,但這樣的做法會更加複雜。

瞭解 GPU 繪圖方式

在變更更多程式碼前,建議您快速簡單地概略說明 GPU 如何建立畫面上顯示的形狀。(如果您已熟悉 GPU 轉譯運作方式的基本概念,可以直接跳至「定義 Vertices」一節)。

Canvas 2D 這類 API 有大量形狀和選項可供您使用,但 GPU 其實只處理幾種不同類型的形狀 (或是由 WebGPU 所指稱的「原始」):點、線和三角形。在本程式碼研究室中,僅使用三角形。

GPU 幾乎能搭配三角形使用,因為三角形包含大量美觀的數學屬性,以可預期且有效率的方式輕鬆處理。使用 GPU 繪圖的大部分內容都必須分割成三角形,GPU 才能繪製,而且這些三角形必須以角點定義。

這些點 (或稱「端點」) 是以 X、Y 和 (適用於 3D 內容) Z 值的定義,在 WebGPU 或類似 API 定義的卡提斯座標系統上定義點。從座標系統結構的角度來看,最簡單的方式就是思考這套系統與網頁上的畫布有何關係。無論畫布的寬度或高度為何,X 軸左側邊緣一律為 -1,X 軸右側邊緣一律為 +1。同樣地,Y 軸的底部邊緣一律為 -1,Y 軸上則一律為 +1。也就是說,(0, 0) 一律是畫布的中心,(-1, -1) 一律位於左下角,且 (1, 1) 一律位於右上角。這就是「Clip Space」。

視覺化呈現正規化裝置座標空間的簡單圖形。

此座標系統一開始很少定義頂點,因此 GPU 會使用稱為「頂點著色器」的小型程式,執行將頂點轉換為夾扣空間所需的任何數學,以及其他繪製頂點所需的其他計算。舉例來說,著色器可能會套用一些動畫,或計算頂點到光源的方向。這些著色器是由 WebGPU 開發人員編寫,可精確控管 GPU 的運作方式。

之後,GPU 會取用由這些轉換頂點構成的所有三角形,然後判斷要繪製在螢幕上哪些像素需要進行繪製。接著,執行程式會執行您編寫的另一個小型程式 (稱為「片段著色器」),用以計算每個像素的色彩。計算可以像「返回綠色」簡單,也可以相當複雜,也就是計算相對於其他附近表面的陽光照射角度、透過霧氣篩選,以及根據表面的金屬程度來修改相對的表層角度。完全由您掌控,這可能會帶來莫大助力。

這些像素顏色的結果會累積到紋理中,然後顯示於螢幕上。

定義頂點

如前所述,「Game of Life」模擬畫面會以儲存格格線的形式顯示。您的應用程式需要以視覺化方式呈現格線,區分使用中的儲存格和未使用的儲存格。本程式碼研究室採用的方法,是在使用中的儲存格中繪製彩色方塊,並將閒置的儲存格留空。

也就是說,您必須為 GPU 提供四個不同點,正方形的四個角都要各一個點。舉例來說,在畫布中央繪製的正方形,會以各種方式從邊緣往來,具有如下的邊角座標:

正規化的裝置座標圖,顯示正方形邊角的座標

為了將這些座標提供給 GPU,您必須將值放在 TypedArray 中。如果您不熟悉 TypedArrays 並不熟悉,可將 TypedArrays 設為 JavaScript 物件群組,藉此分配連續的記憶體區塊,並將系列中的每個元素解讀為特定資料類型。例如,在 Uint8Array 中,陣列中的每個元素都是一個無正負號的位元組。TypedArrays 非常適合使用易受記憶體配置的 API 來回傳送資料,例如 WebAssembly、WebAudio 和 (當然) WebGPU。

以正方形為例,由於值是小數,因此可以使用 Float32Array

  1. 建立保留圖表中所有頂點位置的陣列,方法是在程式碼中放置下列陣列宣告。適合放置在靠近頂端的位置,就在 context.configure() 呼叫下方。

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8,
   0.8, -0.8,
   0.8,  0.8,
  -0.8,  0.8,
]);

請注意,間距和註解不會影響值。為了方便閱讀,閱讀更方便。它可讓您看到每一對值組成一個頂點的 X 和 Y 座標。

但發生問題!GPU 能依三角形使用,還記得嗎?也就是必須以三個組別提供端點。你有一組 4 人。解決方法是重複兩個頂點,建立兩個橫越正方形中間邊緣的三角形。

這張圖表顯示如何使用正方形的四個頂點形成兩個三角形。

如果要從圖表形成正方形,您必須列出 (-0.8, -0.8) 和 (0.8, 0.8) 頂點兩次,一次是藍色三角形,另一次則代表紅色三角形。(您也可以選擇將正方形與其他兩個邊角分開,但不會有任何差異)。

  1. 更新先前的 vertices 陣列,如下所示:

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8, // Triangle 1 (Blue)
   0.8, -0.8,
   0.8,  0.8,

  -0.8, -0.8, // Triangle 2 (Red)
   0.8,  0.8,
  -0.8,  0.8,
]);

雖然圖表所示,為清楚區分兩個三角形之間明確區隔,但頂點位置完全相同,而且 GPU 會轉譯這些三角形沒有間隙。廣告會呈現為單人正方形。

建立頂點緩衝區

GPU 無法使用 JavaScript 陣列的資料繪製端點。GPU 經常具備自己的記憶體,已針對轉譯作業進行高度最佳化,因此您希望 GPU 在繪圖時能使用的任何資料都需放在該記憶體中。

針對許多值 (包括頂點資料),GPU 端記憶體是透過 GPUBuffer 物件管理。緩衝區是記憶體區塊,可供 GPU 輕鬆存取,並標記為有特定用途。您可以把它想成是 GPU 可見的 TypedArray。

  1. 如要建立用來存放頂點的緩衝區,請在 vertices 陣列的定義後方,將以下呼叫新增至 device.createBuffer()

index.html

const vertexBuffer = device.createBuffer({
  label: "Cell vertices",
  size: vertices.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

第一個需要注意的是,您必須為緩衝區加上標籤。您可以為您建立的每個 WebGPU 物件加上選用標籤,當然也沒問題!標籤是您想要的任何字串,只要有助於識別物件為何即可。如果遇到任何問題,WebGPU 產生的錯誤訊息會使用這些標籤,協助您瞭解問題所在。

接下來,提供緩衝區的大小 (以位元組為單位)。您需要一個包含 48 個位元組的緩衝區,其方法是將 32 位元浮點值 ( 4 個位元組) 乘以 vertices 陣列中的浮點數 (12) 來計算。幸好,TypedArrays 已為您計算其 byteLength,因此您可以在建立緩衝區時使用。

最後,您必須指定緩衝區的「usage」。這是一或多個 GPUBufferUsage 標記,系統會將多個標記與 | ( bitwise OR) 運算子合併使用。在這種情況下,您會指定要將緩衝區用於頂點資料 (GPUBufferUsage.VERTEX),同時也想將資料複製到該緩衝區 (GPUBufferUsage.COPY_DST)。

傳回的緩衝區物件是不透明的,您無法 (簡單) 檢查其保留的資料。此外,它的大部分屬性都無法變更,建立 GPUBuffer 後即無法調整其大小,也不能變更使用標記。您可以變更記憶體中的內容。

最初建立緩衝區時,其中所含的記憶體將初始化為零。變更其內容的方法有很多種,但最簡單的做法是使用您要複製的 TypedArray 呼叫 device.queue.writeBuffer()

  1. 如要將頂點資料複製到緩衝區的記憶體,請加入以下程式碼:

index.html

device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);

定義頂點版面配置

現在您有一個含有頂點資料的緩衝區,但對於 GPU 的資料來說,這個緩衝區只是一個位元組的 blob。假如您打算利用這項資訊繪製任何項目,就必須提供一些額外資訊。您需能向 WebGPU 進一步瞭解頂點資料的結構。

index.html

const vertexBufferLayout = {
  arrayStride: 8,
  attributes: [{
    format: "float32x2",
    offset: 0,
    shaderLocation: 0, // Position, see vertex shader
  }],
};

這個方式乍看之下可能不夠清楚,卻簡單易懂。

第一個提供的是 arrayStride。這是 GPU 在尋找下一個頂點時需要在緩衝區中跳過的位元組數。正方形的每個頂點是由兩組 32 位元浮點數組成。如前所述,32 位元的浮點值是 4 個位元組,因此兩個浮點數是 8 個位元組。

接下來是 attributes 屬性,也就是陣列。屬性是編碼至各個頂點的個別資訊。頂點只有一個屬性 (頂點位置),但更進階的用途通常會擁有包含多個屬性的頂點,例如頂點的顏色或幾何圖形表面的方向。但是這不在本程式碼研究室的範圍內。

必須先在單一屬性中定義資料的 format,此清單提供一份 GPUVertexFormat 類型清單,用於說明 GPU 可解讀的各種端點資料。每個頂點都有兩個 32 位元浮點值,因此使用格式 float32x2。舉例來說,如果您的頂點資料是由四個 16 位元無正負號整數組成,您必須改用 uint16x4。有看到模式嗎?

接下來,offset 說明此特定屬性在頂點中開始的位元組數量。只有在緩衝區中有多個屬性時,才需要擔心,因為本程式碼研究室不會出現這類屬性。

最後,您擁有 shaderLocation。這個數字是介於 0 到 15 之間的任意數字,且所有您定義的屬性都不得重複。它會將此屬性連結至頂點著色器中的特定輸入,您將在下一節瞭解這點。

請注意,雖然您現在已定義這些值,但實際上並未將這些值傳遞至 WebGPU API。這個功能即將推出,但最簡單的方法就是在定義端點時考慮這些值,所以您現在要設定這些值,方便日後使用。

從著色器開始

現在您已擁有要轉譯的資料,但仍需明確告知 GPU 如何處理資料。大部分作業都是透過著色器完成。

著色器是由您編寫並在 GPU 上執行的小型程式。每個著色器都會在資料的不同「階段」中運作,例如:Vertex 處理、片段處理或一般 Compute這些程式庫位於 GPU,因此結構比一般 JavaScript 更嚴謹。但這種結構可以大幅加快執行速度,而且最重要的是平行處理!

WebGPU 中的著色器是以名為 WGSL (WebGPU 著色語言) 的陰影語言編寫,WGSL 的語法 (語法和 Rust) 類似,其功能旨在讓常見類型的 GPU 工作 (例如向量和矩陣數學) 變得更簡單快速。本程式碼研究室不會說明所有陰影語言,但希望您在逐步瞭解一些簡單的範例時,還能學到一些基本知識。

著色器本身會以字串的形式傳遞至 WebGPU。

  • 建立要輸入著色器程式碼的位置,將下列程式碼複製到 vertexBufferLayout 下方的程式碼中:

index.html

const cellShaderModule = device.createShaderModule({
  label: "Cell shader",
  code: `
    // Your shader code will go here
  `
});

如要建立著色器,您可以呼叫 device.createShaderModule(),並提供選用的 label 和 WGSL code 做為字串。(請注意,在這裡使用倒引號表示允許多行字串!)新增一些有效的 WGSL 程式碼後,函式就會傳回含有已編譯結果的 GPUShaderModule 物件。

定義頂點著色器

從頂點著色器開始,因為這也是 GPU 的啟動位置!

頂點著色器定義為函式,GPU 會針對 vertexBuffer 中的每個頂點呼叫一次函式。由於 vertexBuffer 中有六個位置 (頂點),因此您定義的函式會呼叫六次。每次呼叫函式時,系統都會將 vertexBuffer 的不同位置做為引數傳遞至函式,並使用頂點著色器函式的工作,在裁剪空間中傳回對應位置。

請特別注意,系統不一定會依序呼叫這些函式。相反地,GPU 可平行執行這類著色器,可能會同時處理數百個 (甚至數千個!) 的頂點!這是確保 GPU 速度驚人的重要關鍵,但也有一些限制。為了確保極端平行處理,頂點著色器無法相互通訊。每個著色器叫用一次只能「查看」單一頂點的資料,而且只能輸出單一頂點的值。

在 WGSL 中,頂點著色器函式可以隨意命名,但前方必須有 @vertex 屬性,才能指出其代表的著色器階段。WGSL 會使用 fn 關鍵字代表函式,使用括號宣告任何引數,並使用大括號定義範圍。

  1. 建立空白的 @vertex 函式,如下所示:

index.html (createShaderModule 程式碼)

@vertex
fn vertexMain() {

}

但這個方式無效,因為頂點著色器必須傳回「至少」在裁剪空間中處理的頂點最終位置。這個屬性一律會以 4D 向量表示。在著色器中,向量是很常見的用途,而且在著色器中,系統會將其視為語言中的一級基元,並且使用自己的類型,例如用於 4D 向量的 vec4f。2D 向量 (vec2f) 和 3D 向量 (vec3f) 也有類似的類型!

  1. 如要表示傳回的值是必要位置,請使用 @builtin(position) 屬性加以標示。-> 符號的用途是指出這是函式傳回的內容。

index.html (createShaderModule 程式碼)

@vertex
fn vertexMain() -> @builtin(position) vec4f {

}

當然,如果函式有傳回類型,您實際上必須在函式主體中傳回值。您可以使用語法 vec4f(x, y, z, w) 建構要傳回的新 vec4fxyz 值都是浮點數,傳回值中頂點在裁剪空間中的位置。

  1. 傳回靜態值 (0, 0, 0, 1),而您就技術上具備有效的頂點著色器,但由於 GPU 會辨識出本身產生的三角形,因此不會顯示任何內容,因為 GPU 會辨識出它產生的三角形只是單一點,然後加以捨棄。

index.html (createShaderModule 程式碼)

@vertex
fn vertexMain() -> @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}

您想改為運用自己建立的緩衝區中的資料,然後透過 @location() 屬性宣告函式的引數,並符合 vertexBufferLayout 中描述的類型,即可達到此目的。您指定了 0shaderLocation,因此在 WGSL 程式碼中,使用 @location(0) 標示引數。您也可以將格式定義為 float32x2,也就是 2D 向量,因此在 WGSL 中,引數為 vec2f。您可以自行命名,但這個名稱代表您的頂點位置,因此名稱看起來很自然。pos

  1. 將著色器函式變更為以下程式碼:

index.html (createShaderModule 程式碼)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1);
}

現在您需要傳回該位置。由於位置是 2D 向量,傳回類型為 4D 向量,因此您必須稍微修改該向量。您需要從位置引數取得兩個元件,並放在傳迴向量的前兩個元件中,最後兩個元件則分別做為 01

  1. 明確指出要使用的位置元件,即可傳回正確位置:

index.html (createShaderModule 程式碼)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos.x, pos.y, 0, 1);
}

不過,由於這些對應類型在著色器中十分常見,因此您也可以將位置向量做為第一個引數,透過簡單易懂的方式傳遞,而且這意味著相同。

  1. 使用下列程式碼重新編寫 return 陳述式:

index.html (createShaderModule 程式碼)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos, 0, 1);
}

這就是初始的頂點著色器!這非常簡單,只要傳遞 位置,就會完全沒變,但已足夠您踏出第一步。

定義片段著色器

接下來是片段著色器。片段著色器的運作方式與頂點著色器非常類似,但會在每次繪製像素時叫用這些著色器,而不是針對每個頂點叫用。

片段著色器一律會在頂點著色器之後呼叫。GPU 會取得頂點著色器的輸出結果,然後三角運算,以三個點的組合建立三角形。接著,它會判斷出三角形包含哪些輸出色彩附件的像素,然後分別對這些像素呼叫片段著色器,以光柵各個三角形。片段著色器會傳回顏色 (通常是根據從頂點著色器傳送至該值的值和紋理等資產,GPU 會寫入色彩附件)。

就像頂點著色器一樣,片段著色器也是以大量平行的方式執行。就輸入和輸出而言,這類著色器的輸入和輸出內容略有彈性,但您可以考慮使用直接為每個三角形傳回一個顏色。

WGSL 片段著色器函式會以 @fragment 屬性表示,且也會傳回 vec4f。不過在這種情況下,向量代表顏色,而不是位置。回傳值必須為 @location 屬性,才能指出傳回的顏色是寫入 beginRenderPass 呼叫中的哪個 colorAttachment。由於您只有一個附件,因此位置為 0。

  1. 建立空白的 @fragment 函式,如下所示:

index.html (createShaderModule 程式碼)

@fragment
fn fragmentMain() -> @location(0) vec4f {

}

傳回的向量的四個元件為紅色、綠色、藍色及 Alpha 顏色值,其解讀方式與您先前在 beginRenderPass 中設定的 clearValue 相同。因此,vec4f(1, 0, 0, 1) 是亮紅色,看起來是正方形的亮色。你可以隨意設定自己喜歡的顏色!

  1. 設定傳回的色彩向量,如下所示:

index.html (createShaderModule 程式碼)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}

這就是完整的片段著色器!這題不算有趣每個三角形的像素就只有紅色,但目前已經足夠。

複習一下,新增上述著色器程式碼後,createShaderModule 呼叫現在會如下所示:

index.html

const cellShaderModule = device.createShaderModule({
  label: 'Cell shader',
  code: `
    @vertex
    fn vertexMain(@location(0) pos: vec2f) ->
      @builtin(position) vec4f {
      return vec4f(pos, 0, 1);
    }

    @fragment
    fn fragmentMain() -> @location(0) vec4f {
      return vec4f(1, 0, 0, 1);
    }
  `
});

建立轉譯管道

著色器模組無法單獨用於顯示。必須改成透過呼叫 device.createRenderPipeline() 建立,做為 GPURenderPipeline 的一部分。轉譯管道可控制繪製幾何圖形的「方式」,包括使用哪些著色器、如何解讀頂點緩衝區中的資料、應算繪哪種幾何形狀 (線條、點、三角形...) 等等!

轉譯管道是整個 API 中最複雜的物件,不過別擔心!大多數可傳遞的值都屬於選用性質,只需提供幾個值即可開始使用。

  • 建立轉譯管道,如下所示:

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: "auto",
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

每個管道都需要 layout,用於說明管道所需的輸入類型 (頂點緩衝區除外),但您實際上並沒有任何。幸運的是,您可以暫時傳遞 "auto",讓管道透過著色器建構自己的版面配置。

接下來,您必須提供「vertex階段的詳細資料。module 是包含頂點著色器的 GPUShaderModule,而 entryPoint 則提供對每個頂點叫用所呼叫的著色器程式碼中的函式名稱。(單一著色器模組中可以有多個 @vertex@fragment 函式!)緩衝區GPUVertexBufferLayout 物件的陣列,說明資料如何在使用這個管道的頂點緩衝區中封裝。幸好,您之前已在 vertexBufferLayout 中定義了這個變數!請在這裡輸入。

最後,您已瞭解 fragment 階段的詳細資料。這也包含著色器 moduleentryPoint,例如頂點階段。最後位元是定義要用於這個管道的 targets。這是字典陣列,可以提供管道輸出的顏色附件詳細資料,例如 format 紋理。這些詳細資料必須符合搭配這個管道使用的任何算繪通道 colorAttachments 中指定的紋理。算繪通道會使用畫布結構定義的紋理,並使用儲存在 canvasFormat 中的值做為格式,因此你可以在這裡傳遞相同的格式。

這並不接近建立轉譯管道時可以指定的所有選項,但足以滿足本程式碼研究室的需求!

畫正方形

完成上述步驟後,您現在就擁有繪製正方形所需的各項資源了!

  1. 如要繪製正方形,請跳到 encoder.beginRenderPass()pass.end() 一對呼叫,然後在這兩個呼叫之間新增下列新指令:

index.html

// After encoder.beginRenderPass()

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices

// before pass.end()

這樣 WebGPU 就能提供繪製正方形所需的所有資訊。首先,請使用 setPipeline() 指明要使用哪個管道進行繪圖。這包括使用的著色器、頂點資料的版面配置,以及其他相關狀態資料。

接下來,請使用包含正方形頂點的緩衝區呼叫 setVertexBuffer()。這個緩衝區會對應目前管道 vertex.buffers 定義中的第 0 個元素,因此您可以使用 0 呼叫該緩衝區。

最後,您要進行 draw() 呼叫,這似乎在完成所有設定後,似乎非常簡單。您唯一需要傳入的端點,就是其應轉譯的頂點數量,它會從目前設定的頂點緩衝區提取,並與目前設定的管道解讀。您可以只對 6 進行硬式編碼,但是從頂點陣列 (12 浮點值 / 每個頂點 2 個座標 == 6 頂點) 計算。也就是說,當您決定將正方形替換為圓形時,可以少手動更新。

  1. 重新整理畫面,(最終) 查看所有努力成果:一個大彩色正方形。

使用 WebGPU 轉譯的一個紅色正方形

5. 繪製格線

首先,花點時間祝賀自己!使用大多數 GPU API 時,在畫面上取得第一個位元通常是最困難的步驟之一。在這裡執行的所有操作,都是透過較小的步驟完成,方便您在途中驗證進度。

本節將說明:

  • 如何將變數 (稱為 uniforms) 從 JavaScript 傳遞至著色器。
  • 如何使用統一變數變更顯示行為。
  • 如何使用例項繪製同一個幾何圖形的多個不同變體。

定義格線

如要算繪格狀檢視畫面,你必須對網格的其中一項基本資訊,寬度和高度皆包含多少儲存格?這可以自行決定開發人員,但為了簡單起見,請將格線視為相同寬度和高度的正方形,並使用兩者的二次方。(這樣之後就可以更輕鬆地完成部分數學)。您希望最終可以放大,但是在本節的其他部分,請將網格大小設為 4x4,因為這樣更容易示範本節中使用的數學運算。之後再向上擴充!

  • 在 JavaScript 程式碼頂端加入常數即可定義格線大小。

index.html

const GRID_SIZE = 4;

接下來,您必須更新正方形的呈現方式,這樣才可將正方形GRID_SIZE放到畫布上GRID_SIZE。這表示正方形必須大幅縮小,而且需要相當大。

現在,以下可以採用的其中一種方法是讓頂點緩衝區大幅放大,並在其大小和位置中定義GRID_SIZE倍數GRID_SIZE相當於幾平方英尺的方格。事實上,這樣的程式碼還不會太糟糕!只使用幾個 FOR 迴圈和一些數學運算。不過,這也不會充分運用 GPU,且佔用過多記憶體來達到效果。本節會探討更適合 GPU 的方法。

建立統一緩衝區

首先,您需要將所選的格線大小提供給著色器,因為陰影會用來變更內容的顯示方式。您只須以硬式編碼的方式將大小寫入著色器中,但這樣每次要變更格線大小時,就必須重新建立著色器和轉譯管道,但成本會高昂。更好的方法是將格線大小以「制式」的形式提供給著色器。

您稍早已學到,頂點緩衝區的不同值會傳遞至每個頂點著色器的叫用。統一是緩衝區中的值,每次叫用時都是相同的值。如要傳達部分幾何圖形 (例如位置)、整個動畫 (如目前時間) 或應用程式的整個生命週期 (例如使用者偏好設定),這類值可派上用場。

  • 新增下列程式碼,建立統一緩衝區:

index.html

// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
  label: "Grid Uniforms",
  size: uniformArray.byteLength,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);

這應該看起來非常熟悉,因為與先前建立頂點緩衝區的程式碼幾乎相同!這是因為制服是透過頂點的相同 GPUBuffer 物件向 WebGPU API 傳達,主要差別在於 usage 這次包含 GPUBufferUsage.UNIFORM,而不是 GPUBufferUsage.VERTEX

在著色器中存取制服

  • 加入下列程式碼來定義統一:

index.html (createShaderModule 呼叫)

// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos / grid, 0, 1);
}

// ...fragmentMain is unchanged 

這會在著色器中定義統一的 grid 著色器,該著色器是 2D 浮點向量,與您剛複製到統一緩衝區的陣列相符。這個屬性也會指定統一形式繫結至 @group(0)@binding(0)。您稍後就能瞭解這些值代表的意義。

然後,在著色器程式碼的其他地方,您可以視需要使用格線向量。在這個程式碼中,您將頂點位置除以格線向量。由於 pos 是 2D 向量,而 grid 是 2D 向量,因此 WGSL 會執行元件級除法。換句話說,結果與說出 vec2f(pos.x / grid.x, pos.y / grid.y) 相同。

這些類型的向量運算在 GPU 著色器中非常常見,因為許多轉譯和運算技術都依賴這類運算!

在這個例子中,如果使用 4 的格線大小,則顯示的正方形會是原始大小的四分之一。假如您想在單列或欄中調整 4 欄的尺寸,這個做法就非常適合您!

建立繫結群組

但是,在著色器中宣告統一變數,不會將其與您建立的緩衝區連結。為此,您必須建立並設定「繫結群組」

繫結群組是您希望著色器同時存取的一系列資源。可能包含多種類型的緩衝區,例如統一緩衝區,以及其他本文未涵蓋的紋理和取樣器,是 WebGPU 轉譯技術的常見部分。

  • 建立統一緩衝區和轉譯管道後,新增下列程式碼,藉此建立與統一緩衝區的繫結群組:

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  }],
});

除了目前採用的標準 label 外,您還需要 layout,說明這個繫結群組包含的資源類型。您可能會在日後需要深入瞭解,但由於您是使用 layout: "auto" 建立管道,因此您可以立即要求管道使用繫結群組版面配置。如此一來,管道會根據您在著色器程式碼本身宣告的繫結,自動建立繫結的群組版面配置。在本例中,您需要要求 getBindGroupLayout(0),其中 0 會對應至您在著色器中輸入的 @group(0)

指定版面配置後,請提供 entries 的陣列。每個項目都是一個至少包含下列值的字典:

  • binding,與您在著色器中輸入的 @binding() 值相對應。在本例中:0
  • resource,這是您要對指定繫結索引變數公開的實際資源。在這種情況下,請採用統一緩衝區。

這個函式會傳回 GPUBindGroup,這是不透明且不可變動的控制代碼。建立繫結群組後,您就無法變更資源指向的資源,但「可以」變更這些資源的內容。舉例來說,如果變更統一緩衝區以納入新的格線大小,日後使用這個繫結群組繪製呼叫時就會反映這個情況。

繫結至已繫結的群組

現在已經建立繫結群組,您仍需要求 WebGPU 在繪圖時加以使用。幸好這很簡單。

  1. 返回算繪通道,並在 draw() 方法之前新增這一行:

index.html

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);

pass.setBindGroup(0, bindGroup); // New line!

pass.draw(vertices.length / 2);

做為第一個引數傳遞的 0 會對應至著色器程式碼中的 @group(0)。您所謂的 @group(0) 中的每個 @binding 都會使用這個繫結群組中的資源。

現在,著色器會取得統一緩衝區!

  1. 重新整理頁面,畫面應如下所示:

深藍色背景中央的小型紅色正方形。

太棒了!您的正方形現在是原本大小的四分之一!這份簡報指出系統確實套用了統一設定,而且著色器現在可以存取格線大小。

在著色器中繪製幾何圖形

因此,您現在可以在著色器中參照格線大小,然後開始執行一些工作,配合您想要的格線模式調整顯示的幾何圖形。為達到這個目標,請務必仔細思考自己想要達成的目標。

你必須按照概念將畫布分成個別儲存格。為維持慣例,當您向右移動時,X 軸會增加,Y 軸則會隨著移動時增加而增加,例如第一個儲存格位於畫布左下角。這樣的版面配置看起來就會像這樣,中間是目前正方形的幾何圖形:

插圖:正規化裝置座標空間。如要視覺化呈現每個儲存格,且位於中心方目前算繪的方形幾何圖形,將以圖表呈現位置。

您的挑戰是在著色器中找到方法,可讓您根據儲存格座標,將正方形幾何圖形放置於任何這些儲存格中。

首先,你可以看到正方形並未與任何儲存格完美對齊,因為這是畫布中心的邊界。建議您將正方形移到半個儲存格,讓正方形可以和彼此對齊。

修正此問題的一種方法是更新正方形的頂點緩衝區。例如,移動頂點讓右下角位於 (0.1, 0.1) 而非 (-0.8, -0.8),就能用更適當的方式移動這個方塊,讓儲存格邊界更對齊。不過,由於您可以完全控制著色器中頂點的處理方式,只要使用著色器程式碼,就能輕鬆地將頂點送進定位!

  1. 使用下列程式碼變更頂點著色器模組:

index.html (createShaderModule 呼叫)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Add 1 to the position before dividing by the grid size.
  let gridPos = (pos + 1) / grid;

  return vec4f(gridPos, 0, 1);
}

這樣做會將每個頂點向上移動,向右移動一個 (提醒您,裁剪空間的一半是剪輯空間的一半),「然後」再按照格線大小將其分割。結果會在原點外形成完整對齊的方格。

以視覺化方式呈現畫佈在概念上劃分為 4x4 方格,儲存格中有一個紅色正方形 (2, 2)

接下來,由於畫布的座標系統位在中央位置 (0, 0),而左下方顯示 (-1, -1),而您希望 (0, 0) 在左下角,必須在以格線大小除以格線大小「之後」,將幾何圖形的位置轉成 -1, -1,這樣才能移到這個角落。

  1. 翻譯幾何圖形位置,如下所示:

index.html (createShaderModule 呼叫)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Subtract 1 after dividing by the grid size.
  let gridPos = (pos + 1) / grid - 1;

  return vec4f(gridPos, 0, 1); 
}

而且您的正方形已妥善放置在儲存格中 (0, 0)!

以視覺化方式呈現畫佈在概念上劃分為 4x4 方格,儲存格內有紅色正方形 (0, 0)

如果要將標題插入其他儲存格,該怎麼做?做法是在著色器中宣告 cell 向量,並以 let cell = vec2f(1, 1) 等靜態值填入該向量。

如果將其新增至 gridPos,系統就會復原演算法中的 - 1,因此這不是您想要的結果。想將正方形只要移動至每個儲存格的方格 (即畫布的四分之一) 內,看來您還需要再除以 grid

  1. 變更格線位置,如下所示:

index.html (createShaderModule 呼叫)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1); // Cell(1,1) in the image above
  let cellOffset = cell / grid; // Compute the offset to cell
  let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!

  return vec4f(gridPos, 0, 1);
}

如果現在重新整理,您會看到以下內容:

畫佈在概念上分割成 4x4 的方格,其中有儲存格 (0, 0)、儲存格 (0, 1)、儲存格 (1, 0) 和儲存格 (1, 1) 的中央紅色正方形

嗯。不太符合你的需求。

這是因為畫布座標介於 -1 到 +1 之間,所以實際上是 2 個單位。換句話說,如果想將畫布的前四分之一移到畫布上,就必須將該畫布移動至 0.5 個單位。使用 GPU 座標推理時,這個錯誤很容易發生!不過別擔心,修正方法依然非常簡單。

  1. 將偏移量乘以 2,如下所示:

index.html (createShaderModule 呼叫)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

進而提供最符合需求的服務。

以視覺化方式呈現畫佈在概念上劃分為 4x4 方格,儲存格中有一個紅色正方形 (1, 1)

螢幕截圖如下所示:

深藍色背景上的紅色方塊螢幕截圖。紅色方塊所繪製的位置與上一張圖表相同,但沒有格線疊加層。

此外,您現在可以將 cell 設為格線邊界內的任何值,然後重新整理即可查看正方形在所需位置算繪的結果。

繪製例項

現在,您可以將想要的正方形加上一點數學運算,下一步就是在格線中的每個儲存格中呈現一個正方形。

其中一種做法是將儲存格座標寫入統一緩衝區,然後針對格線中的每個正方形呼叫一次 draw,每次都會更新統一值。但該處理速度非常慢,因為每次 GPU 都必須等待 JavaScript 寫入新的座標。讓 GPU 獲得優異效能的其中一項關鍵,是盡可能縮短 GPU 等待系統其他部分的時間!

可以改用稱為「例項」的技術。例項是一種指示 GPU 透過單次呼叫 draw 即可繪製相同幾何圖形的多個副本,比起一次對所有副本呼叫一次 draw 快得多。每個幾何圖形複本稱為一個「執行個體」

  1. 如要讓 GPU 知道正方形的例項數量足以填滿網格,請在現有的繪製呼叫中新增一個引數:

index.html

pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

這會告知系統您要繪製正方形 16 (GRID_SIZE * GRID_SIZE) 的六個頂點 (vertices.length / 2)。不過,即便您重新整理頁面,仍會看到以下內容:

與上一張圖表相同的圖像,表示沒有任何變更。

這是因為這是因為您把這 16 個正方形畫在同一地點。您需要在著色器中設定一些額外的邏輯,以根據個別執行個體重新調整幾何圖形的位置。

在著色器中,除了頂點緩衝區的頂點屬性 (例如 pos) 外,您還可以存取 WGSL 的內建值。這些值由 WebGPU 計算,其中一個值為 instance_indexinstance_index 是從 0number of instances - 1 的無正負號 32 位元號碼,可做為著色器邏輯的一部分。在同一個執行個體中,每個處理的端點都會擁有相同的值。這表示頂點著色器會使用 0instance_index 呼叫六次,針對頂點緩衝區中的每個位置分別呼叫一次。接著,instance_index1 再六次,接著使用 2instance_index 再增加六次,依此類推。

如要實際查看,請將內建 instance_index 新增至著色器輸入內容。做法與位置相同,但不是以 @location 屬性來標記,而是使用 @builtin(instance_index),然後隨需要為引數命名。(您可以將其命名為 instance 來比對範例程式碼)。然後將其做為著色器邏輯的一部分使用!

  1. 請使用 instance 取代儲存格座標:

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {
  
  let i = f32(instance); // Save the instance_index as a float
  let cell = vec2f(i, i);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

如果現在重新整理,就能發現你確實擁有多個正方形!但您無法查看全部 16 個範本。

深藍色背景中,由左下角到右上角以對角線構成的四個紅色方形。

這是因為產生的儲存格座標是 (0、0)、(1、1)、(2、2) 到 (15、15),但只會出現在畫布上的前四。如要達到所需的格線,您需要轉換 instance_index,讓每個索引都對應到格線中不重複的儲存格,如下所示:

以視覺化方式呈現畫佈在概念上劃分為 4x4 方格,每個儲存格也對應一個線性執行個體索引。

數學函式非常易於理解。針對每個儲存格的 X 值,您需要 instance_index模數和格線寬度,您可以使用 % 運算子在 WGSL 中執行此操作。而對於每個儲存格的 Y 值,您希望 instance_index 除以格線寬度,並捨棄任何分數的餘數。您可以使用 WGSL 的 floor() 函式執行這項操作。

  1. 變更計算,如下所示:

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {

  let i = f32(instance);
  // Compute the cell coordinate from the instance_index
  let cell = vec2f(i % grid.x, floor(i / grid.x));

  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

對程式碼完成這項更新後,您終於備受期待的方塊了!

深藍色背景上有四列由四欄紅色正方形所組成。

  1. 確認無誤後,請返回並把網格大小放大!

index.html

const GRID_SIZE = 32;

深藍色背景上有 32 列 32 欄的紅色方塊。

好了!實際上,這個網格實在夠大,而且平均的 GPU 可以處理得不錯。每當您遇到任何 GPU 效能瓶頸,系統就不會再顯示個別方塊。

6. 額外課程:色彩更繽紛!

由於您已為程式碼研究室的其餘部分奠定基礎,因此可以輕鬆跳到下一節。但既然大家都具有相同的顏色,但這些方格的網格都具有相同顏色,但這不算是令人興奮的事嗎?幸好,只要多做一些數學和著色器程式碼,就可以讓介面更加明亮!

在著色器中使用結構體

到目前為止,您已從頂點著色器傳遞一項資料:轉換後的位置。不過,您其實可以從頂點著色器傳回更多資料,然後在片段著色器中使用!

將資料從頂點著色器傳遞的唯一方法是傳回該著色器。頂點著色器一律需要傳回位置,因此如果您想一併傳回任何其他資料,就必須將它放在結構體中。WGSL 中的結構體是包含一或多個已命名屬性的已命名物件類型,這些屬性也可使用 @builtin@location 等屬性加上標記。您可以在任何函式外宣告這些函式,然後視需要傳入或傳出函式的例項。舉例來說,假設您目前的頂點著色器如下:

index.html (createShaderModule 呼叫)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> 
  @builtin(position) vec4f {

  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;
  
  return  vec4f(gridPos, 0, 1);
}
  • 使用結構體輸入函式輸入和輸出內容:

index.html (createShaderModule 呼叫)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
};

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
  
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  return output;
}

請注意,您需要使用 input 參照輸入位置和執行個體索引,而首先傳回的結構必須宣告為變數,並設定其個別的屬性。在此情況下,這不會造成太多差異,且實際上會使著色器函式變得更長,但隨著著色器變得越來越複雜,使用結構體非常適合用來整理資料。

在頂點和片段函式之間傳遞資料

提醒您,@fragment 函式盡可能非常簡單:

index.html (createShaderModule 呼叫)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1);
}

您未獲取任何輸入內容,且您的輸出內容送出了純色 (紅色)。不過,如果著色器進一步瞭解該著色的幾何圖形,則可利用額外的資料,讓內容更加有趣。舉例來說,如果您想要根據儲存格座標變更每個方塊的顏色,該怎麼做?@vertex 階段會知道系統正在轉譯哪個儲存格。只需要傳遞至 @fragment 階段即可

如要在頂點和片段階段之間傳遞任何資料,您需要使用我們選擇的 @location 將其納入輸出結構中。由於要傳遞儲存格座標,因此先將座標新增至先前的 VertexOutput 結構體,然後在傳回前於 @vertex 函式中設定該座標。

  1. 變更頂點著色器的傳回值,如下所示:

index.html (createShaderModule 呼叫)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
  @location(0) cell: vec2f, // New line!
};

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
  
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell; // New line!
  return output;
}
  1. @fragment 函式中,新增具有相同 @location 的引數以接收值。(名稱不一定要相符,但如果名稱相符,就可以輕鬆追蹤!)

index.html (createShaderModule 呼叫)

@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
  // Remember, fragment return values are (Red, Green, Blue, Alpha)
  // and since cell is a 2D vector, this is equivalent to:
  // (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
  return vec4f(cell, 0, 1);
}
  1. 您也可以改用結構體:

index.html (createShaderModule 呼叫)

struct FragInput {
  @location(0) cell: vec2f,
};

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. 另一個替代方式****,因為在程式碼中,這兩個函式都是在同一個著色器模組中定義,所以要重複使用 @vertex 階段的輸出結構!因為名稱與位置自然一致,因此傳送值相當簡單。

index.html (createShaderModule 呼叫)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}

無論選擇哪一種圖案,都代表您可以存取 @fragment 函式中的儲存格編號,並使用該數字影響顏色。使用上述任何程式碼時,輸出內容如下所示:

正方形格線,其中最左側的欄是綠色,最下方的列是紅色,所有其他方塊則是黃色的。

而且現在有更多顏色,但看起來不甚理想。您可能好奇為何只有左側和底端列不一樣。這是因為從 @fragment 函式傳回的顏色值會預期每個管道的介於 0 到 1 之間,且會限制該範圍以外的任何值。而儲存格的值則介於每軸的 0 到 32 之間。因此,您可以看到第一列和第一欄在紅色或綠色色的管道中,立刻達成完整的 1 個值,而在這之後的所有儲存格,則會調整為相同的值。

如果您想讓不同顏色的轉換轉換更加順暢,就必須為每個色彩通道傳回分數值,最好從零開始,然後沿著每個軸的結束 (意即另一個除以 grid) 的數值!

  1. 變更片段著色器,如下所示:

index.html (createShaderModule 呼叫)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell/grid, 0, 1);
}

重新整理頁面,您可以看到新程式碼「確實」提升整個格狀空間的色彩漸層。

方形方格,可從不同角落從黑色轉為紅色。

雖然這已算是改善,但現在左下方會出現令人不悅的黑暗角落,而電網會變成黑色。遊戲中的「遊戲」模擬遊戲開始時,因為遊戲中難以看得見的區塊會掩蓋當前的發展,把它提高亮度會有幫助。

幸運的是,您有一個全新的色彩頻道 (藍色),可以使用這些顏色。最理想的效果是將藍色調到最暗 (其他顏色最暗處),然後隨著其他顏色的強度逐漸增加,逐漸淡出。最簡單的方法就是讓聲道數從 1 開始,並減去其中一個儲存格值。可以是 c.xc.y。請嘗試兩種方法,然後選擇您偏好的方式!

  1. 在片段著色器中加入更明亮的顏色,如下所示:

createShaderModule 呼叫

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  let c = input.cell / grid;
  return vec4f(c, 1-c.x, 1);
}

結果看起來很棒!

方格狀方格,每個角落從紅色到綠色變為黃色。

這不是重要步驟!不過,由於看起來更好,因此會將其納入對應的查核點來源檔案,而本程式碼研究室的其餘螢幕截圖則會反映這個色彩鮮豔的網格。

7. 管理儲存格狀態

接下來,您需要根據 GPU 中儲存的部分狀態,控管格線中要顯示哪些儲存格。這對最終模擬來說很重要!

只需為每個儲存格都設定定時訊號,因此只要選擇任何選項,就能儲存幾乎任何值類型。您可能會認為這是統一緩衝區的另一個用途!您「可以」完成這項工作,但因為統一緩衝區的大小有限、無法支援動態大小的陣列 (您必須在著色器中指定陣列大小),且無法由運算著色器寫入至該陣列。最後一個項目是最有問題的項目,因為您想在運算著色器中,在 GPU 上執行 Life of Life 模擬。

幸好,有另一種緩衝選項可避免這些限制。

建立儲存空間緩衝區

儲存空間緩衝區是一般使用的緩衝區,可在運算著色器中讀取和寫入,以及在頂點著色器中讀取。這類元件可能「非常」大,且不需要在著色器中宣告特定大小,因此更類似於一般記憶體。這就是儲存儲存格狀態的方式。

  1. 如要為儲存格狀態建立儲存空間緩衝區,請使用目前常見的緩衝區建立程式碼。

index.html

// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);

// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
  label: "Cell State",
  size: cellStateArray.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});

就像使用頂點和統一緩衝區一樣,使用適當大小呼叫 device.createBuffer(),然後請務必指定 GPUBufferUsage.STORAGE 的用法。

您可以採用與先前相同的方式填入緩衝區,方法是使用值填入相同大小的 TypedArray,然後呼叫 device.queue.writeBuffer()。由於想瞭解緩衝區在格線上的效果,因此要先在緩衝區中加上可預測的內容。

  1. 使用下列程式碼啟用每個第三個儲存格:

index.html

// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);

讀取著色器中的儲存空間緩衝區

接下來,請更新著色器,在算繪格線之前查看儲存空間緩衝區的內容。這看起來與先前加入製服的方式十分相似。

  1. 使用下列程式碼更新著色器:

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!

首先,請新增繫結點,這會緊鄰格線統一下方的繫結。您想保留與 grid 相同的 @group,但 @binding 編號必須不同。var 類型為 storage,為了反映不同類型的緩衝區,而非單一向量,您為 cellState 提供的類型會是 u32 值的陣列,以便與 JavaScript 中的 Uint32Array 相符。

接著,在 @vertex 函式主體中查詢儲存格的狀態。由於狀態是儲存在儲存空間緩衝區的平面陣列中,因此您可以使用 instance_index 查詢當前儲存格的值!

如果狀態顯示儲存格處於閒置狀態,你該如何關閉該儲存格?由於您從陣列取得的狀態為 1 或 0,因此您可以依活動狀態調整幾何圖形,將幾何圖形縮放 1 代表只將幾何圖形設為 0,而將幾何圖形縮放為 0,會讓幾何圖形收合成單一點,GPU 會捨棄。

  1. 更新著色器程式碼,根據儲存格的啟用狀態縮放位置。狀態值必須轉換為 f32,才能符合 WGSL 的類型安全規定:

index.html

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> VertexOutput {
  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let state = f32(cellState[instance]); // New line!

  let cellOffset = cell / grid * 2;
  // New: Scale the position by the cell's active state.
  let gridPos = (pos*state+1) / grid - 1 + cellOffset;

  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell;
  return output;
}

將儲存空間緩衝區新增至繫結群組

將儲存空間緩衝區新增至繫結群組之前,儲存格狀態才會顯示。由於這是與統一緩衝區相同的 @group,因此也請在 JavaScript 程式碼中將其加入同一個繫結群組。

  • 新增儲存空間緩衝區,如下所示:

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  },
  // New entry!
  {
    binding: 1,
    resource: { buffer: cellStateStorage }
  }],
});

確認新項目的 binding 與著色器中對應值的 @binding() 相符!

這樣就應該能重新整理,並在格線中顯示模式。

深藍色背景上由左至右為彩色正方形,有彩色正方形的對角條紋。

使用連線偵測 (ping) 緩衝區緩衝區模式

您建構的模型通常會使用至少「兩份」狀態複本。在模擬的每個步驟中,他們都會從一個狀態副本讀取資料,再寫入另一個狀態。接著在下一個步驟中,翻轉此動作,讀取先前寫入的狀態。這通常稱為「ping pong」模式,因為在狀態複製每個步驟之間,最快的狀態會在狀態複製之間來回跳動。

為什麼要這麼做?看一下簡單的範例:想像一下,您正在撰寫一個簡單的模擬,在每個步驟中將任何有效方塊都移到一個儲存格中。為了方便理解,您可以在 JavaScript 中定義資料和模擬:

// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];

function simulate() {
  for (let i = 0; i < state.length-1; ++i) {
    if (state[i] == 1) {
      state[i] = 0;
      state[i+1] = 1;
    }
  }
}

simulate(); // Run the simulation for one step.

但執行該程式碼時,使用中的儲存格會一步就將全部移至陣列結尾!這是因為因為您需要不斷更新狀態,所以將使用中的儲存格向右移動,接著你看到下一個儲存格,結果...產品已啟用!再把滑桿向右移。但您發現資料更動時,就會導致結果損毀。

採用連線偵測 (ping) 模式,就必須一律使用最後一個步驟的結果執行模擬的下一個步驟。

// Example simulation. Don't copy into the project!
const stateA = [1, 0, 0, 0, 0, 0, 0, 0];
const stateB = [0, 0, 0, 0, 0, 0, 0, 0];

function simulate(in, out) {
  out[0] = 0;
  for (let i = 1; i < in.length; ++i) {
     out[i] = in[i-1];
  }
}

// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA); 
  1. 請更新儲存空間緩衝區分配,在自己的程式碼中使用這個模式,以建立兩個相同的緩衝區:

index.html

// Create two storage buffers to hold the cell state.
const cellStateStorage = [
  device.createBuffer({
    label: "Cell State A",
    size: cellStateArray.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  }),
  device.createBuffer({
    label: "Cell State B",
     size: cellStateArray.byteLength,
     usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  })
];
  1. 為了以視覺化方式呈現兩個緩衝區之間的差異,請以不同的資料填入這兩個緩衝區:

index.html

// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
  cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
  1. 如要在算繪時顯示不同的儲存空間緩衝區,請將繫結群組更新為包含兩個不同的變化版本:

index.html

const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
   device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }],
  })
];

設定轉譯迴圈

目前為止,每次重新整理頁面只會繪製一次,但現在你想顯示資料隨時間更新的情況。如要執行此操作,您需要使用簡單的轉譯迴圈。

算繪迴圈是一種不斷重複循環的迴圈,會以特定時間間隔將內容繪製到畫布上。許多遊戲和其他內容想製作動畫順暢的內容時,都會使用 requestAnimationFrame() 函式排定回呼,與螢幕重新整理的頻率 (每秒 60 次) 相同。

這個應用程式也可以使用該功能,但在這種情況下,建議您延長更新作業的執行時間,以便更輕鬆地進行模擬作業。但你可以自行管理迴圈,以便控制模擬更新的速率。

  1. 首先,請選擇模擬更新的速率 (200 毫秒很好,但您可以視需要加快或加快),然後追蹤您已完成多少模擬步驟。

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. 接著,將目前用於算繪的所有程式碼移至新函式。使用 setInterval() 按照所需時間間隔重複執行該函式。請確認函式也會更新步數,並使用該計數來挑選要繫結的兩個繫結群組。

index.html

// Move all of our rendering code into a function
function updateGrid() {
  step++; // Increment the step count
  
  // Start a render pass 
  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      loadOp: "clear",
      clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
      storeOp: "store",
    }]
  });

  // Draw the grid.
  pass.setPipeline(cellPipeline);
  pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
  pass.setVertexBuffer(0, vertexBuffer);
  pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

  // End the render pass and submit the command buffer
  pass.end();
  device.queue.submit([encoder.finish()]);
}

// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);

現在當您執行應用程式時,您會看到畫佈在顯示您建立的兩個狀態緩衝區之間來回翻轉。

深藍色背景上由左至右為彩色正方形,有彩色正方形的對角條紋。 深藍色背景前有彩色正方形的垂直條紋。

這樣就大功告成了!您現在可以顯示在下一個步驟中建構的 Game of Life 模擬輸出內容,也就是最後會使用運算著色器。

很明顯地,WebGPU 的轉譯功能比您在這裡探索的小片段還多,但其餘內容不在本程式碼研究室的討論範圍內。希望有助您充分瞭解 WebGPU 的轉譯運作方式,但也能讓更多進階技術 (例如 3D 轉譯) 更容易理解。

8. 執行模擬

接下來的最後一個部分是,在運算著色器中執行 Game of Life 模擬!

最後再使用運算著色器!

在本程式碼研究室中,您已大致瞭解運算著色器,但究竟是什麼?

運算著色器與頂點和片段著色器類似,其設計為在 GPU 以極平行的平行處理方式執行,但不同於其他兩個著色器階段,其沒有特定的輸入和輸出內容。您目前只從所選來源讀取和寫入資料,例如儲存空間緩衝區。也就是說,您必須告知其所需著色器函式的叫用次數,而不必針對每個頂點、執行個體或像素執行一次。然後,當您執行著色器時,系統會告知目前正在處理哪個叫用,而您可以決定要在該處存取哪些資料,以及要從該處執行哪些作業。

運算著色器必須在著色器模組中建立,就像頂點和片段著色器一樣,因此在程式碼中加入該著色器即可開始使用。您可能已經猜到,根據您實作的其他著色器結構,運算著色器的主要函式也必須使用 @compute 屬性標示。

  1. 使用下列程式碼建立運算著色器:

index.html

// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
  label: "Game of Life simulation shader",
  code: `
    @compute
    fn computeMain() {

    }`
});

由於 GPU 經常用於 3D 圖形,因此運算著色器的結構如下,您可以要求著色器沿著 X、Y 和 Z 軸叫用特定次數。方便您輕鬆分派符合 2D 或 3D 格線的工作,這對您的用途而言是絕佳選擇!你想對模擬的每個儲存格呼叫 GRID_SIZE 次著色器 GRID_SIZE 次。

由於 GPU 硬體架構的性質緣故,此網格分成多個「工作群組」。工作團隊擁有 X、Y 和 Z 大小,雖然每個大小可以是 1 個,但能讓工作團隊稍微大一點,通常有助於提升效能。對於著色器,任意工作群組大小應設為 8 倍 8。這很適合用來追蹤 JavaScript 程式碼。

  1. 定義工作群組大小的常數,如下所示:

index.html

const WORKGROUP_SIZE = 8;

您還需要將工作群組大小新增至著色器函式本身,這項作業會使用 JavaScript 範本常值進行,以便輕鬆使用您剛定義的常數。

  1. 將工作群組大小新增至著色器函式,如下所示:

index.html (Compute createShaderModule 呼叫)

@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {

}

這會告知著色器在 (8 x 8 x 1) 群組中以這個函式完成工作。(任何偏移的軸預設為 1,但您必須至少指定 X 軸)。

如同其他著色器階段,您可以接受多種 @builtin 值做為運算著色器函式的輸入內容,藉此告知您目前使用的叫用為何,並判斷需要執行哪些工作。

  1. 新增 @builtin 值,如下所示:

index.html (Compute createShaderModule 呼叫)

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

您會傳入 global_invocation_id 內建內容,這是無正負號整數的三維向量,可讓您瞭解自己是在著色器叫用格線中的位置。您可以為格狀中的每個儲存格執行一次這個著色器。您將取得 (0, 0, 0)(1, 0, 0)(1, 1, 0) 等數字,到 (31, 31, 0) 為止,也就是說,您可以將其視為要用於作業的儲存格索引!

運算著色器也可以使用統一變數,就像在頂點和片段著色器中一樣。

  1. 搭配運算著色器使用統一樣式來表示格線大小,如下所示:

index.html (Compute createShaderModule 呼叫)

@group(0) @binding(0) var<uniform> grid: vec2f; // New line

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

就像頂點著色器一樣,您也可以將儲存格狀態公開為儲存體緩衝區。但在這種情況下,您需要兩個!由於運算著色器沒有必要的輸出內容 (例如頂點位置或片段顏色),因此將值寫入儲存空間緩衝區或紋理,是取得運算著色器的唯一方法。使用您之前學到的連線偵測 (ping) 方法。您會有一個儲存緩衝區,供資訊提供檢視格目前的狀態,另一個則寫出格線新狀態。

  1. 將儲存格輸入和輸出狀態公開為儲存空間緩衝區,如下所示:

index.html (Compute createShaderModule 呼叫)

@group(0) @binding(0) var<uniform> grid: vec2f;
    
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

請注意,系統會使用 var<storage> 宣告第一個儲存空間緩衝區,因此該緩衝區會設為唯讀,但第二個儲存空間緩衝區是使用 var<storage, read_write> 宣告。如此一來,您就能使用該緩衝區做為運算著色器的輸出內容,同時讀取及寫入緩衝區。(WebGPU 沒有唯讀儲存模式)。

接下來,您需要將儲存格索引對應至線性儲存空間陣列。基本上,這與您在頂點著色器中執行的相反,使用線性 instance_index 並將其對應至 2D 格線儲存格。(請注意,演算法的數值為 vec2f(i % grid.x, floor(i / grid.x)))。

  1. 編寫要往另一個方向的函式。這個函式會將儲存格的 Y 值乘以網格寬度,然後加上儲存格的 X 值。

index.html (Compute createShaderModule 呼叫)

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

// New function   
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  
}

最後,看看這項功能是否正常運作,實作一個簡單的演算法:如果儲存格目前為開啟狀態,其會自動關閉,反之亦然。這不是遊戲的生命週期,但已經可以證明運算著色器運作正常。

  1. 新增簡易演算法,如下所示:

index.html (Compute createShaderModule 呼叫)

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
   
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines. Flip the cell state every step.
  if (cellStateIn[cellIndex(cell.xy)] == 1) {
    cellStateOut[cellIndex(cell.xy)] = 0;
  } else {
    cellStateOut[cellIndex(cell.xy)] = 1;
  }
}

以上就是用於運算著色器的說明!不過,您還是必須進行一些變更,才能看見結果。

使用繫結群組和管道版面配置

您可能會從上述著色器注意到,主要是將相同的輸入內容 (制式和儲存空間緩衝區) 做為轉譯管道使用。所以您可能會認為只要使用相同的繫結群組即可,對吧?好消息是你可以的!只要再進行一些手動設定即可。

每次建立繫結群組時,都必須提供 GPUBindGroupLayout。先前在算繪管道上呼叫 getBindGroupLayout() 來取得該版面配置,而由於您在建立版面配置時提供了 layout: "auto",因此系統會自動建立版面配置。如果只使用一個管道,這種做法就很管用,但如果有多個管道都想要共用資源,您就必須明確建立版面配置,然後提供給繫結的群組和管道。

為說明原因,請考慮以下幾點:在算繪管道中,您只使用統一緩衝區和單一儲存空間緩衝區,但在剛編寫的運算著色器中,需要第二個儲存空間緩衝區。由於兩個著色器對統一和第一個儲存空間緩衝區使用相同的 @binding 值,因此您可以在管道之間共用這些值,且轉譯管道會忽略第二個不使用的儲存緩衝區。您想要建立一個版面配置來說明繫結群組中「所有」的資源,而不只是特定管道使用的資源。

  1. 如要建立該版面配置,請呼叫 device.createBindGroupLayout()

index.html

// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
  label: "Cell Bind Group Layout",
  entries: [{
    binding: 0,
    // Add GPUShaderStage.FRAGMENT here if you are using the `grid` uniform in the fragment shader.
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: {} // Grid uniform buffer
  }, {
    binding: 1,
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: { type: "read-only-storage"} // Cell state input buffer
  }, {
    binding: 2,
    visibility: GPUShaderStage.COMPUTE,
    buffer: { type: "storage"} // Cell state output buffer
  }]
});

這與建立繫結群組本身的結構類似,也就是您描述 entries 清單。不同之處在於您要說明項目所需的資源類型和使用方式,而不是提供資源本身。

在每個項目中,您都需要提供資源的 binding 數字 (您在建立繫結群組時學到的這個數字) 與著色器中的 @binding 值相符。您也可以提供 visibility,也就是 GPUShaderStage 旗標,指出哪些著色器階段可以使用資源。您希望在頂點和運算著色器中存取統一和第一個儲存空間緩衝區,但第二個儲存空間緩衝區只需要在運算著色器中存取。

最後,您需要指定使用的資源類型。視您要公開的內容而定,這是不同的字典鍵。這裡,這三個資源都是緩衝區,因此請使用 buffer 鍵定義每個緩衝區的選項。其他選項包括 texturesampler 等,但此處並不需要。

您可以在緩衝區字典中設定選項,例如使用哪些緩衝區的 type。預設值為 "uniform",因此您可以將字典留空,表示繫結 0。(不過,您至少要設定 buffer: {},這樣項目才能識別為緩衝區)。繫結 1 獲派的類型為 "read-only-storage",因為您在著色器中並未與 read_write 存取權搭配使用,而繫結 2 的類型則為 "storage",因為您「是」搭配 read_write 存取權使用!

建立 bindGroupLayout 後,您可以在建立繫結群組時傳入,無須從管道查詢繫結群組。如此一來,您需要為每個繫結群組新增儲存空間緩衝區項目,以便與您剛定義的版面配置相符。

  1. 更新建立繫結群組,如下所示:

index.html

// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: bindGroupLayout, // Updated Line
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[1] }
    }],
  }),
  device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: bindGroupLayout, // Updated Line

    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
];

現在,繫結群組已更新為使用這個明確的繫結群組版面配置,您需要更新轉譯管道以使用相同的方式。

  1. 建立 GPUPipelineLayout

index.html

const pipelineLayout = device.createPipelineLayout({
  label: "Cell Pipeline Layout",
  bindGroupLayouts: [ bindGroupLayout ],
});

管道版面配置是繫結群組版面配置的清單 (在這個例子中,您可以使用一或多個管道),陣列中的繫結群組版面配置順序需要與著色器中的 @group 屬性對應。(這表示 bindGroupLayout@group(0) 相關聯)。

  1. 使用管道版面配置後,請更新轉譯管道以使用,而非 "auto"

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: pipelineLayout, // Updated!
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

建立運算管道

就像您需要轉譯管道使用頂點和片段著色器一樣,您需要運算管道才能使用運算著色器。幸好,運算管道的複雜程度比轉譯管道,因為這些管道沒有任何狀態可設定,只能設定著色器和版面配置。

  • 使用下列程式碼建立運算管道:

index.html

// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
  label: "Simulation pipeline",
  layout: pipelineLayout,
  compute: {
    module: simulationShaderModule,
    entryPoint: "computeMain",
  }
});

請注意,您像更新後的轉譯管道一樣傳入新的 pipelineLayout (而不是 "auto"),確保轉譯管道和運算管道可使用相同的繫結群組。

運算票證

這就可帶領您實際使用運算管線!由於您是在算繪通道中進行算繪,因此可能會猜測您需要在運算通道中執行運算工作。運算和轉譯工作都可以在同一個指令編碼器中進行,因此請稍微隨機調整 updateGrid 函式。

  1. 將編碼器建立作業移至函式頂端,然後開始運算票證 (step++ 之前)。

index.html

// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

// Existing lines
step++; // Increment the step count
  
// Start a render pass...

和運算管道一樣,運算通道的啟動方式比轉譯管道簡單,因為您不必擔心任何附件。

您想要在算繪通道之前執行運算通道,因為算繪通道可立即使用運算通道的最新結果。這也是因為您在傳遞之間遞增 step 數量,讓運算管道的輸出緩衝區成為轉譯管道的輸入緩衝區。

  1. 接著,在運算通道內設定管道和繫結群組,使用與算繪通道相同的模式,在繫結群組之間進行切換。

index.html

const computePass = encoder.beginComputePass();

// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);

computePass.end();
  1. 最後,您不用像在算繪通道中一樣繪圖,而是將工作分派給運算著色器,讓它知道每個軸上您想要執行多少工作群組。

index.html

const computePass = encoder.beginComputePass();

computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);

// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);

computePass.end();

請注意,這裡的非常重要,是傳入 dispatchWorkgroups() 的號碼「不是」叫用數量!而是要執行的工作群組數量,如著色器中 @workgroup_size 所定義。

如果希望著色器執行 32x32 以涵蓋整個網格,且工作群組大小為 8x8,則需要分派 4x4 工作群組 (4 * 8 = 32)。因此,您將格線大小除以工作群組大小,並將該值傳遞至 dispatchWorkgroups()

現在您可以再次重新整理頁面,您應該會看到格線在每次更新時完全反轉。

深藍色背景上由左至右為彩色正方形,有彩色正方形的對角條紋。 深藍色背景中由左至右寬的兩個彩色正方形,對角條紋路由左下至右延伸。上一張映像檔的反轉。

實作 Game of Life 的演算法

更新運算著色器以實作最終演算法之前,可以先返回初始化儲存空間緩衝區內容的初始化程式碼,並且更新程式碼,以便在每次載入網頁時產生隨機緩衝區。(規律的創作模式一開始無法用來製作非常有趣的《Game of Life》起點。不過,您可以隨意隨機決定值,但其實還有一種簡單的方法,可以產生合理的結果。

  1. 如要以隨機狀態啟動每個儲存格,請將 cellStateArray 初始化更新為下列程式碼:

index.html

// Set each cell to a random state, then copy the JavaScript array 
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
  cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

現在,您可以實作 Game of Life 模擬的邏輯。完成所有步驟後,著色器程式碼可能會變得十分簡單!

首先,你必須知道任何指定的儲存格中,有多少個鄰點是有效的。您不需要在意何為有效,只關注計數。

  1. 如要輕鬆取得鄰近儲存格的資料,請新增 cellActive 函式,傳回指定座標的 cellStateIn 值。

index.html (Compute createShaderModule 呼叫)

fn cellActive(x: u32, y: u32) -> u32 {
  return cellStateIn[cellIndex(vec2(x, y))];
}

如果儲存格處於有效狀態,cellActive 函式會傳回一個值,因此如果為八個週邊儲存格新增呼叫 cellActive 的傳回值,即可算出已啟用的鄰近儲存格數量。

  1. 找出使用中的鄰點數量,如下所示:

index.html (Compute createShaderModule 呼叫)

fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines:
  // Determine how many active neighbors this cell has.
  let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                        cellActive(cell.x+1, cell.y) +
                        cellActive(cell.x+1, cell.y-1) +
                        cellActive(cell.x, cell.y-1) +
                        cellActive(cell.x-1, cell.y-1) +
                        cellActive(cell.x-1, cell.y) +
                        cellActive(cell.x-1, cell.y+1) +
                        cellActive(cell.x, cell.y+1);

不過,這會導致一個小問題:當你檢查的儲存格離開面板邊緣時,會發生什麼事?根據 cellIndex() 邏輯,它現在會溢位到下一列或前一列,或者從緩衝區的邊緣執行!

以 Game of Life 來說,解決這個問題的常見且簡單的方法,就是讓電網邊緣的細胞將網格對邊的細胞視為相鄰,形成一種環繞效果。

  1. 支援格線環繞,但對 cellIndex() 函式進行小幅變更。

index.html (Compute createShaderModule 呼叫)

fn cellIndex(cell: vec2u) -> u32 {
  return (cell.y % u32(grid.y)) * u32(grid.x) +
         (cell.x % u32(grid.x));
}

透過使用 % 運算子,在延伸至超過格線大小時納入儲存格 X 和 Y,就能確保您不會在儲存空間緩衝區邊界之外存取資料。因此請放心,activeNeighbors 數量是可預測的。

接著套用以下四種規則的其中之一:

  • 如果儲存格的鄰點小於兩個,則會停用。
  • 任何使用中有兩個或三個鄰點的有效儲存格仍會維持有效狀態。
  • 系統會將剛好有三個鄰點的閒置儲存格變成有效狀態。
  • 任何包含超過三個鄰點的儲存格都會進入停用狀態。

您可以使用一系列的 if 陳述式來執行這項操作,但 WGSL 也支援適合這個邏輯的切換陳述式。

  1. 實作《Game of Life》邏輯,如下所示:

index.html (Compute createShaderModule 呼叫)

let i = cellIndex(cell.xy);

// Conway's game of life rules:
switch activeNeighbors {
  case 2: { // Active cells with 2 neighbors stay active.
    cellStateOut[i] = cellStateIn[i];
  }
  case 3: { // Cells with 3 neighbors become or stay active.
    cellStateOut[i] = 1;
  }
  default: { // Cells with < 2 or > 3 neighbors become inactive.
    cellStateOut[i] = 0;
  }
}

提醒您,最終運算著色器模組呼叫如下所示:

index.html

const simulationShaderModule = device.createShaderModule({
  label: "Life simulation shader",
  code: `
    @group(0) @binding(0) var<uniform> grid: vec2f;

    @group(0) @binding(1) var<storage> cellStateIn: array<u32>;
    @group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

    fn cellIndex(cell: vec2u) -> u32 {
      return (cell.y % u32(grid.y)) * u32(grid.x) +
              (cell.x % u32(grid.x));
    }

    fn cellActive(x: u32, y: u32) -> u32 {
      return cellStateIn[cellIndex(vec2(x, y))];
    }

    @compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
    fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
      // Determine how many active neighbors this cell has.
      let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                            cellActive(cell.x+1, cell.y) +
                            cellActive(cell.x+1, cell.y-1) +
                            cellActive(cell.x, cell.y-1) +
                            cellActive(cell.x-1, cell.y-1) +
                            cellActive(cell.x-1, cell.y) +
                            cellActive(cell.x-1, cell.y+1) +
                            cellActive(cell.x, cell.y+1);

      let i = cellIndex(cell.xy);

      // Conway's game of life rules:
      switch activeNeighbors {
        case 2: {
          cellStateOut[i] = cellStateIn[i];
        }
        case 3: {
          cellStateOut[i] = 1;
        }
        default: {
          cellStateOut[i] = 0;
        }
      }
    }
  `
});

就是這麼簡單!大功告成!請重新整理頁面,看看新建構的行動網路 Automaton 還成長了!

Game of Life 模擬遊戲範例狀態的螢幕截圖,其中彩色儲存格以深藍色背景轉譯。

9. 恭喜!

你建立了傳統版 Conway 的 Life of Life 模擬版本,該版本完全透過 WebGPU API 在 GPU 上執行。

後續步驟

其他資訊

參考文件