你的第一個 WebGPU 應用程式

1. 簡介

WebGPU 標誌由多個藍色三角形組成,每個三角形的樣式為「W」

什麼是 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 Game of Life。您的應用程式將會:

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

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

生命遊戲是一種稱為細胞自動機的系統,其中的單元格會根據一組規則隨時間變換狀態。在 Game of Life 儲存格中,系統會根據鄰近儲存格中有多少處於活動狀態,判斷是否處於有效或無效狀態。這會導致有趣的模式在你觀看時出現波動。

課程內容

  • 如何設定 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 找到一些可做為檢查點的有效範例。如果您遇到問題,可以查看這些範例,並在進行時參考。

歡迎使用 Play 管理中心!

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

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

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

3. 初始化 WebGPU

先從 <canvas> 開始

如果您只想使用 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

有了裝置後,如果想在頁面上顯示任何內容,請再完成一項設定:將畫布設定為與剛才建立的裝置搭配使用。

  • 如要這樣做,請先呼叫 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,
});

您可以傳遞幾個選項,但最重要的是您要使用內容脈絡的 device,以及 format,也就是內容脈絡應使用的紋理格式

紋理是 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",則代表您希望在算繪通道開始時清除紋理。
  • storeOp 值為 "store" 表示在轉譯作業完成後,您希望將轉譯作業期間完成的任何繪圖結果儲存至紋理。

算繪通道開始後,就什麼都沒做!至少目前是如此。使用 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 才能繪製這些內容,且這些三角形必須由其角點定義。

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

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

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

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

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

定義頂點

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

也就是說,您必須為 GPU 提供四個不同點,正方形的四個角都要各一個點。舉例來說,在畫布中央繪製的正方形,從邊緣拉進的話,其角落座標如下:

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

為了將這些座標提供給 GPU,您必須將這些值放入 TypedArray 中。如果您不熟悉 TypedArray,請先瞭解一下。TypedArray 是一組 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,因此您可以在建立緩衝區時使用。

最後,您需要指定緩衝區的用途。這是一或多個 GPUBufferUsage 標記,系統會將多個標記與 | ( bitwise OR) 運算子合併使用。在這種情況下,您會指定要將緩衝區用於頂點資料 (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) 建構要傳回的新 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);
    }
  `
});

建立轉譯管道

著色器模組無法單獨用於顯示。相反地,您必須將其用於 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 階段的詳細資料。這也包括著色器模組entryPoint,例如頂點階段。最後位元是定義要用於這個管道的 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 時最困難的步驟之一。您可以透過這裡執行的所有操作,以較小的步驟完成,方便您隨時驗證進度。

在本節中,您將瞭解:

  • 如何從 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 除以 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 新增至著色器輸入內容。請按照與位置相同的方式執行這項操作,但請使用 @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 和格線寬度的 模數,您可以在 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 上執行《遊戲版》模擬作業。

幸運的是,還有另一種緩衝選項可避免所有這些限制。

建立儲存空間緩衝區

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

  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,而將幾何圖形縮放為 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-pong 緩衝區模式

您建構的模型通常會使用至少「兩份」狀態複本。在模擬的每個步驟中,他們都會從一個狀態副本讀取資料,再寫入另一個狀態。接著,在下一個步驟中,將其翻轉,並從先前寫入的狀態讀取。這通常稱為「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) 方法。您會有一個儲存緩衝區,供資訊提供檢視格目前的狀態,另一個則寫出格線新狀態。

  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) {
  
}

最後,看看這項功能是否正常運作,實作一個簡單的演算法:如果儲存格目前為開啟狀態,其會自動關閉,反之亦然。這還不是 Game of Life,但足以證明運算著色器運作正常。

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

以上就是 Compute Shader 的內容,至少目前是如此!不過,您還是必須進行一些變更,才能看見結果。

使用 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 函式會傳回一個值,因此如果為八個週邊儲存格新增呼叫 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;
        }
      }
    }
  `
});

就是這樣!大功告成!重新整理頁面,看看新建的細胞自動機器如何成長!

生命遊戲模擬的示例狀態螢幕截圖,其中以深藍色背景顯示色彩鮮豔的方格。

9. 恭喜!

您已建立經典的 Conway's Game of Life 模擬版本,並使用 WebGPU API 在 GPU 上完全執行!

後續步驟

其他資訊

參考文件