1. 簡介
什麼是 WebGPU?
WebGPU 是新型的 API,可用於在網頁應用程式中存取 GPU 的功能。
現代化 API
在 WebGPU 推出之前,WebGL 提供 WebGPU 的部分功能。這項技術可支援新類型的豐富網頁內容,開發人員也已利用這項技術打造出令人驚豔的內容。但此架構是以 2007 年推出的 OpenGL ES 2.0 API 為基礎,而此 API 是以較舊的 OpenGL API 為基礎。這段期間,GPU 已大幅進化,用於與 GPU 介接的本機 API 也隨著 Direct3D 12、Metal 和 Vulkan 而進化。
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。
- 如要確認
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 硬體的表示法。
- 如要取得轉接器,請使用
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 互動的主要介面。
- 呼叫
adapter.requestDevice()
即可取得裝置,此作業也會傳回承諾。
index.html
const device = await adapter.requestDevice();
和 requestAdapter()
一樣,您可以透過這裡的設定使用進階用途,例如啟用特定硬體功能或要求更高的限制,但對於您的用途來說,預設值沒有問題。
設定 Canvas
有了裝置後,如果想在頁面上顯示任何內容,請再完成一項設定:將畫布設定為與剛才建立的裝置搭配使用。
- 如要這樣做,請先呼叫
canvas.getContext("webgpu")
,從畫布要求GPUCanvasContext
。(這與您用來初始化 Canvas 2D 或 WebGL 結構定義的呼叫,分別使用2d
和webgl
結構定義類型。)接著,系統傳回的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 執行該作業。
- 如要執行這項操作,請讓裝置建立
GPUCommandEncoder
,這個介面可用於記錄 GPU 指令。
index.html
const encoder = device.createCommandEncoder();
您要傳送至 GPU 的指令與算繪相關 (在本例中為清除畫布),因此下一個步驟是使用 encoder
開始算繪通道。
算繪階段是指 WebGPU 中的所有繪圖作業。每次呼叫都使用 beginRenderPass()
呼叫開始,這個呼叫定義了用於接收任何繪圖指令輸出內容的紋理。更進階的用法則可提供多個稱為「連接點」的紋理,用途各有不同,例如儲存算繪幾何圖形的深度或提供反鋸齒。但只需要一個應用程式即可。
- 呼叫
context.getCurrentTexture()
即可從先前建立的畫布內容取得紋理,此時會傳回像素寬度和高度的紋理,以符合畫布的width
和height
屬性,以及呼叫context.configure()
時指定的format
。
index.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
storeOp: "store",
}]
});
紋理會以 colorAttachment
的 view
屬性提供。算繪票證需要提供 GPUTextureView
(而非 GPUTexture
),用於指定要算繪紋理的哪些部分。這只適用於更進階的用途,因此在這裡呼叫 createView()
時,紋理上不含引數,表示您希望算繪通道使用整個紋理。
您也必須指定您希望算繪通道在開始和結束時,如何處理紋理:
- 如果
loadOp
值為"clear"
,則代表您希望在算繪通道開始時清除紋理。 storeOp
值為"store"
表示在轉譯作業完成後,您希望將轉譯作業期間完成的任何繪圖結果儲存至紋理。
算繪通道開始後,就什麼都沒做!至少目前是如此。使用 loadOp: "clear"
啟動轉譯通道,即可清除紋理檢視畫面和畫布。
- 立即在
beginRenderPass()
之後新增下列呼叫,以結束算繪通道:
index.html
pass.end();
請務必瞭解,只是發出這些呼叫並不會導致 GPU 實際執行任何動作。它們只是記錄 GPU 稍後要執行的指令。
- 如要建立
GPUCommandBuffer
,請在指令編碼器上呼叫finish()
。指令緩衝區是不透明的控制代碼,對已記錄的指令執行。
index.html
const commandBuffer = encoder.finish();
- 使用
GPUDevice
的queue
將指令緩衝區提交給 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()
以取得算繪通道的新紋理。
- 重新載入網頁。請注意,畫布已填入黑色。恭喜!這表示您已成功建立第一個 WebGPU 應用程式。
挑選顏色!
不過老實說,黑色方塊實在很無聊。因此,花一些時間繼續閱讀下一節,為影片提供一些個人特色。
- 在
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
每個值的範圍都可以從 0
到 1
,也可以一起說明色彩管道的值。例如:
{ 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 }
是預設的透明黑色。
本程式碼研究室中的範例程式碼和螢幕截圖使用深藍色,但您可以隨意挑選喜歡的顏色!
- 選好顏色後,請重新載入頁面。畫布中應該會顯示您選擇的顏色。
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
。
- 建立保留圖表中所有頂點位置的陣列,方法是在程式碼中放置下列陣列宣告。建議將其放在頂端,也就是
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) 頂點兩次,一次是藍色三角形,另一次則代表紅色三角形。(您也可以選擇以其他兩個角落分割正方形,這兩種方法沒有差異)。
- 更新先前的
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。
- 如要建立用來存放頂點的緩衝區,請在
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()
。
- 如要將頂點資料複製到緩衝區的記憶體中,請加入下列程式碼:
index.html
device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);
定義頂點版面配置
您現在有一個包含頂點資料的緩衝區,但就 GPU 而言,它只是一串位元組。假如您打算利用這項資訊繪製任何項目,就必須提供一些額外資訊。您必須能夠進一步告知 WebGPU 頂點資料的結構。
- 使用
GPUVertexBufferLayout
字典定義頂點資料結構:
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
關鍵字代表函式,使用括號宣告任何引數,並使用大括號定義範圍。
- 建立空白的
@vertex
函式,如下所示:
index.html (createShaderModule 程式碼)
@vertex
fn vertexMain() {
}
但這個方式無效,因為頂點著色器必須傳回「至少」在裁剪空間中處理的頂點最終位置。這一律會以 4 維向量提供。向量在著色器中是常見的用法,因此會在該語言中視為第一類原始元素,並使用自己的類型,例如 vec4f
是 4 維向量。2D 向量 (vec2f
) 和 3D 向量 (vec3f
) 也有類似的類型!
- 如要表示傳回的值是必要位置,請使用
@builtin(position)
屬性加以標示。->
符號用來表示這是函式傳回的值。
index.html (createShaderModule 程式碼)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
}
當然,如果函式有傳回類型,您實際上必須在函式主體中傳回值。您可以使用語法 vec4f(x, y, z, w)
建構要傳回的新 vec4f
。x
、y
和 z
值都是浮點數,傳回值中頂點在裁剪空間中的位置。
- 傳回靜態值
(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
中描述的類型來宣告函式的引數。您指定了 0
的 shaderLocation
,因此請在 WGSL 程式碼中使用 @location(0)
標示引數。您也可以將格式定義為 float32x2
,也就是 2D 向量,因此在 WGSL 中,引數為 vec2f
。您可以自行命名,但這個名稱代表您的頂點位置,因此名稱看起來很自然。pos
- 將著色器函式變更為以下程式碼:
index.html (createShaderModule 程式碼)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(0, 0, 0, 1);
}
現在您需要傳回該位置。由於位置是 2D 向量,傳回類型為 4D 向量,因此您必須稍微修改該向量。您需要從位置引數取得兩個元件,並放在傳迴向量的前兩個元件中,最後兩個元件則分別做為 0
和 1
。
- 明確指出要使用的定位元件,即可傳回正確的位置:
index.html (createShaderModule 程式碼)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos.x, pos.y, 0, 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。
- 建立空白的
@fragment
函式,如下所示:
index.html (createShaderModule 程式碼)
@fragment
fn fragmentMain() -> @location(0) vec4f {
}
傳回向量的四個元件分別是紅色、綠色、藍色和 alpha 顏色值,這些值的解讀方式與先前在 beginRenderPass
中設定的 clearValue
完全相同。vec4f(1, 0, 0, 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
中的值做為格式,因此你可以在這裡傳遞相同的格式。
這只是您在建立轉譯管道時可以指定的眾多選項之一,但已足以滿足本程式碼研究室的需求!
畫正方形
這樣一來,您現在就擁有繪製方塊所需的一切!
- 如要繪製正方形,請跳到
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 個頂點) 計算,表示如果您決定用圓形取代正方形,手動更新的項目就會比較少。
- 重新整理畫面,(最終) 查看所有努力成果:一個大彩色正方形。
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 使用該群組。幸好這很簡單。
- 返回算繪通道,並在
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
都會使用這個繫結群組中的資源。
現在,著色器會取得統一緩衝區!
- 重新整理頁面,畫面應如下所示:
太棒了!你的方塊現在只有原來的四分之一大小!這份簡報顯示系統確實套用了統一設定,而且著色器現在可以存取格線大小。
在著色器中繪製幾何圖形
因此,您現在可以在著色器中參照格線大小,然後開始執行一些工作,配合您想要的格線模式調整顯示的幾何圖形。請先考量您想達成的目標。
你必須按照概念將畫布分成個別儲存格。為維持慣例,當您向右移動時,X 軸會增加,Y 軸則會隨著移動時增加而增加,例如第一個儲存格位於畫布左下角。這樣一來,您就會得到如下所示的版面配置,其中包含目前的正方形幾何圖形:
您需要在著色器中找出方法,讓您在任何單元格中,根據單元格座標定位正方形幾何圖形。
首先,您可以看到方塊並未與任何單元格對齊,因為它是定義為環繞畫布中心。建議您將正方形移到半個儲存格,讓正方形可以和彼此對齊。
修正此問題的一種方法是更新正方形的頂點緩衝區。例如,移動頂點讓左下角位於 (0.1, 0.1) 而非 (-0.8, -0.8),就能進一步移動這個方塊與儲存格邊界對齊。不過,由於您可以完全控制著色器中頂點的處理方式,只要使用著色器程式碼,就能輕鬆地將頂點送進定位!
- 使用下列程式碼變更頂點著色器模組:
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);
}
這樣做會將每個頂點向上移動,向右移動一個 (提醒您,裁剪空間的一半是剪輯空間的一半),「然後」再按照格線大小將其分割。結果會在原點外形成完整對齊的方格。
接下來,由於畫布的座標系統會將 (0, 0) 放在中央,而 (-1, -1) 則位於左下方,而您希望 (0, 0) 位於左下方,因此您需要將幾何圖形的位置乘以 (-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)!
如果想將圖表放入其他儲存格,做法是在著色器中宣告 cell
向量,並以 let cell = vec2f(1, 1)
等靜態值填入該向量。
如果您將該值加到 gridPos
,就會撤銷演算法中的 - 1
,這不是您想要的結果。想將正方形只要移動至每個儲存格的方格 (即畫布的四分之一) 內,看來你需要再將 grid
除以 grid
!
- 變更格線位置,如下所示:
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);
}
重新整理後,您會看到下列畫面:
嗯。不太符合你的要求。
這是因為畫布座標介於 -1 到 +1 之間,所以實際上是 2 個單位。換句話說,如果想將畫布的前四分之一移到畫布上,就必須將該畫布移動至 0.5 個單位。使用 GPU 座標推理時,這個錯誤很容易發生!不過別擔心,修正方法依然非常簡單。
- 將偏移量乘以 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);
}
進而提供最符合需求的服務。
螢幕截圖如下所示:
此外,您現在可以將 cell
設為格線邊界範圍內的任何值,然後重新整理,即可在所需位置看到正方形圖形。
繪製執行個體
現在,您可以將想要的正方形加上一點數學運算,下一步就是在格線中的每個儲存格中呈現一個正方形。
其中一種做法是將儲存格座標寫入統一緩衝區,然後針對格線中的每個正方形呼叫一次 draw,每次都會更新統一值。不過,這會非常緩慢,因為 GPU 每次都必須等待 JavaScript 寫入新的座標。讓 GPU 獲得優異效能的其中一項關鍵,是盡可能縮短 GPU 等待系統其他部分的時間!
您可以改用稱為「例項化」的技術。例項是一種指示 GPU 透過單次呼叫 draw
即可繪製相同幾何圖形的多個副本,比起一次對所有副本呼叫一次 draw
快得多。每個幾何圖形複本稱為一個「執行個體」。
- 如要告知 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_index
。instance_index
是從 0
到 number of instances - 1
的無正負號 32 位元號碼,可做為著色器邏輯的一部分。在同一個執行個體中,每個處理的端點都會擁有相同的值。也就是說,頂點著色器會以 0
的 instance_index
呼叫六次,每個頂點緩衝區的位置各一次。接著,instance_index
為 1
再六次,接著使用 2
的 instance_index
再增加六次,依此類推。
如要實際查看,請將內建 instance_index
新增至著色器輸入內容。請按照與位置相同的方式執行這項操作,但請使用 @builtin(instance_index)
而非 @location
屬性標記,然後將任何名稱指派給引數。(您可以將其命名為 instance
來比對範例程式碼)。然後將其做為著色器邏輯的一部分使用!
- 使用
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
,讓每個索引都對應到格線中不重複的儲存格,如下所示:
數學函式非常易於理解。針對每個儲存格 X 值,您需要 instance_index
和格線寬度的 模數,您可以在 WGSL 中使用 %
運算子執行此操作。而對於每個儲存格 Y 值,您希望 instance_index
除以格線寬度,並捨去任何小數餘數。您可以使用 WGSL 的 floor()
函式執行這項操作。
- 變更計算,如下所示:
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);
}
更新程式碼後,您終於可以看到期待已久的方格格線了!
- 確認無誤後,請返回並把網格大小放大!
index.html
const GRID_SIZE = 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
函式中設定該座標。
- 變更頂點著色器的傳回值,如下所示:
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;
}
- 在
@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);
}
- 您也可以改用結構體:
index.html (createShaderModule 呼叫)
struct FragInput {
@location(0) cell: vec2f,
};
@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 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
除以另一個值!
- 變更片段著色器,如下所示:
index.html (createShaderModule 呼叫)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell/grid, 0, 1);
}
重新整理頁面後,您會發現新的程式碼「確實」可在整個格狀區塊中產生更漂亮的漸層色彩。
雖然這確實是項改善,但現在左下方有一個不幸的黑暗角落,其中的格線會變成黑色。遊戲中的「遊戲」模擬遊戲開始時,因為遊戲中難以看得見的區塊會掩蓋當前的發展,把它提高亮度會有幫助。
幸運的是,您有一個全新的色彩頻道 (藍色),可以使用這些顏色。理想的效果是讓藍色最亮,其他顏色最暗,然後隨著其他顏色亮度增加而淡出。最簡單的方法是讓管道開始於 1,並減去其中一個儲存格值。可以是 c.x
或 c.y
。請嘗試兩種方法,然後選擇您偏好的方式!
- 在片段著色器中加入更明亮的顏色,如下所示:
createShaderModule 呼叫
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let c = input.cell / grid;
return vec4f(c, 1-c.x, 1);
}
結果看起來相當不錯!
這不是必要步驟!不過,由於這張圖片看起來比較好看,因此我們已將其納入對應的檢查點來源檔案,本程式碼研究室的其他螢幕截圖也反映了這個色彩較鮮豔的格線。
7. 管理儲存格狀態
接下來,您需要根據 GPU 中儲存的部分狀態,控管格線中要顯示哪些儲存格。這對於最終模擬結果至關重要!
只需為每個儲存格都設定定時訊號,因此只要選擇任何選項,就能儲存幾乎任何值類型。您可能會認為這是統一緩衝區的另一個用途!您「可以」完成這項工作,但因為統一緩衝區的大小有限、無法支援動態大小的陣列 (您必須在著色器中指定陣列大小),且無法由運算著色器寫入至該陣列。最後一個項目是最有問題的項目,因為您想使用運算著色器,在 GPU 上執行《遊戲版》模擬作業。
幸運的是,還有另一種緩衝選項可避免所有這些限制。
建立儲存空間緩衝區
儲存緩衝區是一般用途的緩衝區,可在運算著色器中讀取及寫入,並在頂點著色器中讀取。這類元件可能「非常」大,且不需要在著色器中宣告特定大小,因此更類似於一般記憶體。這就是儲存儲存格狀態的方式。
- 如要為儲存格狀態建立儲存空間緩衝區,請使用目前常見的緩衝區建立程式碼。
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()
。由於想瞭解緩衝區在格線上的效果,因此要先在緩衝區中加上可預測的內容。
- 使用下列程式碼啟用每隔一個儲存格:
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);
讀取著色器中的儲存空間緩衝區
接著,請更新著色器,在轉譯格線之前查看儲存緩衝區的內容。這看起來與先前加入製服的方式十分相似。
- 使用下列程式碼更新著色器:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!
首先,您需要新增繫結點,該點位於格線均勻化函式下方。您希望 @group
與 grid
保持一致,但 @binding
數字必須不同。var
類型為 storage
,為了反映不同類型的緩衝區,而非單一向量,您為 cellState
提供的類型會是 u32
值的陣列,以便與 JavaScript 中的 Uint32Array
相符。
接著,在 @vertex
函式的主體中,查詢儲存格狀態。由於狀態是儲存在儲存空間緩衝區的平面陣列中,因此您可以使用 instance_index
查詢當前儲存格的值!
如果狀態顯示儲存格處於閒置狀態,你該如何關閉該儲存格?由於從陣列取得的有效和無效狀態分別為 1 和 0,因此您可以根據有效狀態縮放幾何圖形!將幾何圖形縮放 1 代表只將幾何圖形設為 0,而將幾何圖形縮放為 0,會讓幾何圖形收合成單一點,GPU 會捨棄。
- 更新著色器程式碼,根據單元格的活動狀態縮放位置。狀態值必須轉換為
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);
- 請更新儲存空間緩衝區分配,在自己的程式碼中使用這個模式,以建立兩個相同的緩衝區:
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,
})
];
- 為了以視覺化方式呈現兩個緩衝區之間的差異,請以不同的資料填入這兩個緩衝區:
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);
- 如要在算繪時顯示不同的儲存空間緩衝區,請將繫結群組更新為包含兩個不同的變化版本:
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 次) 相同。
這個應用程式也可以使用該方法,但在這種情況下,您可能會希望更新步驟較長,以便更輕鬆地追蹤模擬作業的執行情形。但你可以自行管理迴圈,以便控制模擬更新的速率。
- 首先,請選擇模擬更新的速度 (200 毫秒即可,但您可以視需要加快或放慢速度),然後追蹤已完成多少模擬步驟。
index.html
const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
- 接著,將目前用於算繪的所有程式碼移至新函式。使用
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
屬性標示。
- 使用以下程式碼建立運算著色器:
index.html
// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
label: "Game of Life simulation shader",
code: `
@compute
fn computeMain() {
}`
});
由於 GPU 經常用於 3D 圖形,因此運算著色器的結構可讓您要求沿著 X、Y 和 Z 軸叫用著色器特定次數。這樣一來,您就能輕鬆調度符合 2D 或 3D 格狀的作業,非常適合您的用途!您需要為此著色器呼叫 GRID_SIZE
次 GRID_SIZE
次,每個模擬資料單元各一次。
由於 GPU 硬體架構的特性,這個格線會分成工作群組。工作團隊擁有 X、Y 和 Z 大小,雖然每個大小可以是 1 個,但能讓工作團隊稍微大一點,通常有助於提升效能。針對著色器,請選擇 8 x 8 的任意工作群組大小。這項功能可用於追蹤 JavaScript 程式碼。
- 定義工作群組大小的常數,如下所示:
index.html
const WORKGROUP_SIZE = 8;
您還需要使用 JavaScript 的範本字面值,將工作群組大小新增至著色器函式,以便輕鬆使用剛剛定義的常數。
- 將工作群組大小新增至著色器函式,如下所示:
index.html (Compute createShaderModule 呼叫)
@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {
}
這會告訴著色器,使用此函式完成的工作是在 (8 x 8 x 1) 群組中完成。(任何偏移的軸預設為 1,但您必須至少指定 X 軸)。
與其他著色器階段一樣,您可以接受各種 @builtin
值,做為輸入至運算著色器函式的值,以便告知您正在執行哪個叫用作業,並決定要執行哪些工作。
- 新增
@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)
,這表示您可以將其視為要操作的儲存格索引!
運算著色器也可以使用統一的樣式,就像在頂點和片段著色器中一樣。
- 搭配運算著色器使用統一樣式來表示格線大小,如下所示:
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) 方法。您會有一個儲存緩衝區,供資訊提供檢視格目前的狀態,另一個則寫出格線新狀態。
- 將儲存格輸入和輸出狀態公開為儲存空間緩衝區,如下所示:
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))
)。
- 編寫函式以便往另一個方向前進。這個函式會將儲存格的 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,但足以證明運算著色器運作正常。
- 新增簡易演算法,如下所示:
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
值,因此您可以在管道之間共用這些值,且轉譯管道會忽略未使用的第二個儲存體緩衝區。您想要建立一個版面配置來說明繫結群組中「所有」的資源,而不只是特定管道使用的資源。
- 如要建立該版面配置,請呼叫
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
鍵定義每個緩衝區的選項。其他選項包括 texture
或 sampler
等,但此處並不需要。
在緩衝區字典中,您可以設定所使用的緩衝區 type
等選項。預設值為 "uniform"
,因此您可以將字典留空,表示繫結 0。(不過,您至少要設定 buffer: {}
,這樣項目才能識別為緩衝區)。繫結 1 獲派的類型為 "read-only-storage"
,因為您在著色器中並未與 read_write
存取權搭配使用,而繫結 2 的類型則為 "storage"
,因為您「是」搭配 read_write
存取權使用!
建立 bindGroupLayout
後,您可以在建立繫結群組時傳入該值,而非從管道中查詢繫結群組。如此一來,您需要為每個繫結群組新增儲存空間緩衝區項目,以便與您剛定義的版面配置相符。
- 更新繫結群組建立作業,如下所示:
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] }
}],
}),
];
現在,繫結群組已更新為使用這個明確的繫結群組版面配置,您需要更新轉譯管道以使用相同的方式。
index.html
const pipelineLayout = device.createPipelineLayout({
label: "Cell Pipeline Layout",
bindGroupLayouts: [ bindGroupLayout ],
});
管道版面配置是繫結群組版面配置的清單 (在這個例子中,您可以使用一或多個管道),陣列中的繫結群組版面配置順序需要與著色器中的 @group
屬性對應。(這表示 bindGroupLayout
與 @group(0)
相關聯)。
- 管道版面配置完成後,請更新算繪管道,以便使用該版面配置,而非
"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
函式。
- 將編碼器建立作業移至函式的頂端,然後開始使用該作業執行運算傳遞 (在
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
數量,讓運算管道的輸出緩衝區成為轉譯管道的輸入緩衝區。
- 接著,在運算通道內設定管道和繫結群組,使用與算繪通道相同的模式,在繫結群組之間進行切換。
index.html
const computePass = encoder.beginComputePass();
// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);
computePass.end();
- 最後,您可以將工作調度至運算著色器,而不是像在轉譯通道中一樣繪製,並告知運算著色器要在每個軸上執行的工作群組數量。
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》起點。不過,您可以隨意隨機決定值,但其實還有一種簡單的方法,可以產生合理的結果。
- 如要以隨機狀態啟動每個儲存格,請將
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 模擬遊戲的邏輯了。經過這麼多步驟才完成的著色器程式碼,可能會讓您大失所望,因為它實在太簡單了!
首先,您必須知道任何特定儲存格有多少相鄰儲存格是活動的。您不需要在意何為有效,只有相關數據。
- 如要更輕鬆地取得相鄰儲存格資料,請新增
cellActive
函式,以便傳回指定座標的cellStateIn
值。
index.html (Compute createShaderModule 呼叫)
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
如果儲存格處於有效狀態,cellActive
函式會傳回一個值,因此如果為八個週邊儲存格新增呼叫 cellActive
的傳回值,即可算出已啟用的鄰近儲存格數量。
- 找出有效鄰點的數量,如下所示:
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 來說,解決這個問題的常見且簡單的方法,就是讓電網邊緣的細胞將網格對邊的細胞視為相鄰,形成一種環繞效果。
- 支援格線環繞,但對
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 陳述式,這類陳述式非常適合這項邏輯。
- 實作《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 上完全執行!
後續步驟
- 查看 WebGPU 範例