你的第一個 WebGPU 應用程式

1. 簡介

WebGPU 標誌由幾個藍色三角形組成,形成特殊的「W」字母

什麼是 WebGPU?

WebGPU 是全新的現代化 API,可在網頁應用程式中存取 GPU 功能。

Modern API

在 WebGPU 推出之前,WebGL 提供 WebGPU 的部分功能。這項技術可支援全新類型的豐富網頁內容,開發人員也已利用這項技術打造出令人驚豔的內容。不過,該 API 是以 2007 年發布的 OpenGL ES 2.0 API 為基礎,而該 API 又是以更早期的 OpenGL API 為基礎。這段期間 GPU 已大幅進化,用於與 GPU 介接的本機 API 也隨著 Direct3D 12MetalVulkan 而進化。

WebGPU 將這些新型 API 的進步功能帶入網頁平台。這個 API 著重於以跨平台方式啟用 GPU 功能,同時提供在網路上使用起來自然的 API,且比其所建構的部分原生 API 更精簡。

轉譯

GPU 通常與轉譯快速、精細的圖形有關,WebGPU 也不例外。這項工具具備所需功能,可支援目前在電腦和行動裝置 GPU 上最常見的多種轉譯技術,並隨著硬體功能持續進化,提供可在日後新增新功能的途徑。

運算

除了轉譯之外,WebGPU 還能發揮 GPU 的潛力,執行一般用途的高度平行工作負載。這些運算著色器可獨立使用,無需任何算繪元件,也可以與算繪管線緊密整合。

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

建構項目

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

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

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

生命遊戲是一種稱為細胞自動機的系統,其中的單元格會根據一組規則隨時間變換狀態。在生命遊戲中,每個方塊的啟用或停用狀態,取決於相鄰方塊的啟用數量,因此會產生有趣的變化模式。

課程內容

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

本程式碼研究室著重於介紹 WebGPU 背後的基本概念。這並非 API 的完整檢閱,也不涵蓋 (或要求) 3D 矩陣數學等常見相關主題。

軟硬體需求

  • ChromeOS、macOS 或 Windows 上的最新版 Chrome (113 以上版本)。WebGPU 是跨瀏覽器、跨平台的 API,但尚未在所有地方推出。
  • 具備 HTML、JavaScript 和 Chrome 開發人員工具的相關知識。

不必熟悉其他圖形 API (例如 WebGL、Metal、Vulkan 或 Direct3D),但如果您有相關經驗,就會發現 WebGPU 與這些 API 有很多相似之處,有助於您快速掌握相關知識!

2. 做好準備

取得程式碼

本程式碼研究室沒有任何依附元件,而且會逐步引導您建立 WebGPU 應用程式,因此您不需要任何程式碼即可開始。不過,您可以在 https://glitch.com/edit/#!/your-first-webgpu-app 找到一些可做為檢查點的有效範例。如果您遇到問題,可以查看這些範例,並在進行時參考。

使用開發人員控制台!

WebGPU 是相當複雜的 API,內含許多規則,可確保正確使用。更糟的是,由於 API 的運作方式,它無法針對許多錯誤產生典型的 JavaScript 例外狀況,因此更難找出問題的確切來源。

使用 WebGPU 進行開發時,您遇到問題,尤其是初學者,但這沒關係!API 背後的開發人員瞭解使用 GPU 開發時可能遇到的挑戰,因此他們努力確保 WebGPU 程式碼發生錯誤時,開發人員工具控制台會顯示非常詳細且實用的訊息,協助您找出並修正問題。

在處理任何網路應用程式時,保持控制台開啟狀態總是很有幫助,但在本例中特別實用!

3. 初始化 WebGPU

先從 <canvas> 開始

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

請建立新的 HTML 文件,其中包含單一 <canvas> 元素,以及用於查詢畫布元素的 <script> 標記。(或使用 Glitch 中的 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

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

  • 如要執行這項操作,請先呼叫 canvas.getContext("webgpu"),從畫布要求 GPUCanvasContext。(這個呼叫與您使用 2dwebgl 上下文類型分別初始化 Canvas 2D 或 WebGL 上下文時使用的呼叫相同)。接著,系統傳回的 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 開始算繪通道。

算繪階段是指 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 算繪的運作方式,請直接跳到「定義頂點」一節)。

Canvas 2D 這類 API 有大量形狀和選項可供使用,GPU 僅支援點、線和三角形等不同類型的形狀 (也就是 WebGPU 所指的「基本」)。在本程式碼研究室中,您只會使用三角形。

GPU 幾乎只處理三角形,因為三角形具有許多優異的數學特性,可讓 GPU 以可預測且有效率的方式輕鬆處理。幾乎所有使用 GPU 繪製的內容都必須先分割成三角形,GPU 才能繪製這些內容,且這些三角形必須由其角點定義。

這些點 (或稱頂點) 會以 X、Y 和 (3D 內容的) Z 值提供,這些值會定義 WebGPU 或類似 API 定義的笛卡兒座標系統上的點。要思考座標系統的結構,最簡單的方法就是思考它與網頁上畫布的關係。無論畫布的寬度或高度為何,左邊緣一律會位於 X 軸的 -1 處,右邊緣一律會位於 X 軸的 +1 處。同樣地,底部邊緣在 Y 軸上永遠是 -1,頂部邊緣在 Y 軸上則是 +1。也就是說,(0, 0) 一律是畫布的中心,(-1, -1) 一律是左下角,(1, 1) 一律是右上角。這就是所謂的「剪輯區域」

這張簡單的圖表可視覺化呈現標準化裝置座標空間。

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

接著,GPU 會取用這些經過轉換的頂點所構成的所有三角形,並判斷畫面上哪些像素需要繪製這些三角形。接著,它會執行您撰寫的另一個小程式,也就是稱為「片段著色器」的程式,用來計算每個像素應有的顏色。這項計算可以簡單到傳回綠色,也可以複雜到計算表面相對於陽光反射的角度,以及附近其他表面、經過霧氣過濾,以及表面金屬程度的影響。這項計算完全由您控制,既能發揮效用,也可能令人不知所措。

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

定義頂點

如先前所述,生命遊戲模擬功能會以格狀單元格顯示。您的應用程式需要一種方法來呈現格線,區分活動儲存格和非活動儲存格。本程式碼研究室採用的方法是在有效儲存格中繪製彩色方塊,並讓無效儲存格保持空白。

也就是說,您必須為 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 是以三角形運作,還記得嗎?也就是必須以三個組別提供端點。您有一個四人群組,解決方法是重複兩個頂點,藉此建立兩個三角形,並在正方形中間共用一條邊。

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

如要從圖表中形成正方形,您必須列出 (-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,因此您可以在建立緩衝區時使用這個值。

最後,您需要指定緩衝區的用途。這會是 GPUBufferUsage 標記的其中一個或多個,多個標記會與 | ( 位元或) 運算子結合。在本例中,您指定要將緩衝區用於頂點資料 (GPUBufferUsage.VERTEX),並且要能夠將資料複製到其中 (GPUBufferUsage.COPY_DST)。

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

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

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

index.html

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

定義頂點版面配置

您現在有一個包含頂點資料的緩衝區,但就 GPU 而言,它只是一串位元組。如果您打算使用這項功能繪製任何內容,就需要提供更多資訊。您必須能夠向 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 上執行的小型程式。每個著色器都會在資料的不同階段上運作:頂點處理、片段處理或一般運算。它們適用於 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() {

}

不過,這並非有效做法,因為頂點著色器必須「至少」傳回在剪輯空間中處理的頂點最終位置。這一律會以 4 維向量提供。向量在著色器中是常見的用法,因此會在該語言中視為一流原始元素,並具有自己的類型,例如 vec4f 是 4 維向量。2D 向量 (vec2f) 和 3D 向量 (vec3f) 也有類似的類型!

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

index.html (createShaderModule 程式碼)

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

}

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

  1. 傳回 (0, 0, 0, 1) 的靜態值,在技術上您擁有有效的頂點著色器,但這個頂點著色器永遠不會顯示任何內容,因為 GPU 會將產生的三角形視為單一點,然後捨棄。

index.html (createShaderModule 程式碼)

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

您應該要使用您建立的緩衝區資料,方法是為函式宣告引數,並使用與 vertexBufferLayout 中所述的 @location() 屬性和類型相符的引數。您指定了 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);
    }
  `
});

建立轉譯管道

著色器模組無法單獨用於算繪。相反地,您必須將其用於 GPURenderPipeline 的一部分,並透過呼叫 device.createRenderPipeline() 建立。算繪管道會控制如何繪製幾何圖形,包括要使用的著色器、如何解讀頂點緩衝區中的資料、應算繪哪種幾何圖形 (線條、點、三角形...) 等等。

算繪管道是整個 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()。您會使用 0 呼叫它,因為此緩衝區對應至目前管線 vertex.buffers 定義中的第 0 個元素。

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

  1. 重新整理畫面,終於看到你努力的成果:一個大大的彩色正方形。

使用 WebGPU 算繪的單一紅色正方形

5. 繪製格線

首先,請花點時間祝賀自己!在螢幕上取得幾何圖形的第一個位元,通常是大多數 GPU API 最困難的步驟之一。您可以透過這裡執行的所有操作,以較小的步驟完成,方便您隨時驗證進度。

在本節中,您將瞭解:

  • 如何從 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),您算繪的正方形會是原始大小的四分之一。這樣一來,您就能將四個圖片放入一個資料列或欄中!

建立繫結群組

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

繫結群組是一組資源,您可以同時讓著色器存取這些資源。可能包含多種類型的緩衝區,例如統一緩衝區,以及其他本文未涵蓋的紋理和取樣器,是 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 除以 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);

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

與上一個圖表相同的圖片,用來表示沒有任何變更。

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

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

如要查看實際運作情形,您必須在著色器輸入內容中加入內建的 instance_index。請按照與位置相同的方式執行這項操作,但請使用 @builtin(instance_index) 而非 @location 屬性標記,然後將任何名稱指派給引數。(您可以將其命名為 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); // Updated
  let cellOffset = cell / grid * 2;
  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 和格線寬度的 modulo,您可以在 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);
}

重新整理頁面後,您會發現新的程式碼「確實」可在整個格狀區塊中提供更漂亮的漸層色彩。

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

雖然這確實是項改善,但現在左下方有一個不幸的黑角,其中的格線會變成黑色。開始進行生命遊戲模擬時,格狀區塊中難以辨識的部分會遮住實際情況。把它提高亮度會有幫助。

幸運的是,您還有一個未使用的色彩通道 (藍色) 可用。理想的效果是讓藍色最亮,其他顏色最暗,然後隨著其他顏色亮度增加而淡出。最簡單的方法是讓管道start於 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 上執行 Game 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!

首先,請新增繫結點,這會緊鄰格線統一下方的繫結。您希望 @groupgrid 保持一致,但 @binding 數字必須不同。var 類型為 storage,可反映不同的緩衝區類型。您為 cellState 提供的類型不是單一向量,而是 u32 值的陣列,以便與 JavaScript 中的 Uint32Array 相符。

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

如果狀態指出該儲存格處於停用狀態,您該如何關閉該儲存格?由於您從陣列取得的狀態為 1 或 0,因此您可以依活動狀態調整幾何圖形,縮放比例為 1 時,幾何圖形會保持不變;縮放比例為 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 Pong 模式,您就能確保一律只使用上一個步驟的結果執行模擬的下一個步驟。

// 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(inArray, outArray) {
  outArray[0] = 0;
  for (let i = 1; i < inArray.length; ++i) {
     outArray[i] = inArray[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_SIZEGRID_SIZE 次,每個模擬資料單元各一次。

由於 GPU 硬體架構的特性,這個格線會分成工作群組。工作團隊擁有 X、Y 和 Z 大小,雖然每個大小可以是 1 個,但能讓工作團隊稍微大一點,通常有助於提升效能。針對著色器,請選擇 8 x 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-pong 方法,您有一個儲存緩衝區可用於提供目前的網格狀態,以及一個用於寫入網格新狀態的緩衝區。

  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;
  }
}

以上就是用於運算著色器的說明!不過,您必須先進行其他幾項變更,才能看到結果。

使用 Bind Group 和管道版面配置

您可能會注意到,上述著色器主要使用與轉譯管道相同的輸入內容 (統一緩衝區和儲存空間緩衝區)。您可能會認為只要使用相同的繫結群組即可,對吧?好消息是,您可以!但需要進行更多手動設定才能完成。

每次建立繫結群組時,都必須提供 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 的演算法

在更新運算著色器以實作最終演算法之前,您需要返回用於初始化儲存緩衝區內容的程式碼,並更新該程式碼,以便在每次載入網頁時產生隨機緩衝區。(規則圖案並非很有趣的生命遊戲起始點)。不過,您可以隨意隨機決定值,但其實還有一種簡單的方法,可以產生合理的結果。

  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 函式會傳回 1,因此如果您為所有八個相鄰儲存格呼叫 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 也支援 switch 陳述式,這類陳述式非常適合這項邏輯。

  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's Game of Life 模擬版本,並使用 WebGPU API 完全在 GPU 上執行!

後續步驟

其他資訊

參考文件