使用計算著色器
本教學將帶您一步步建立一個簡單的計算著色器。在開始之前,先簡介計算著色器的背景及它如何與 Godot 配合運作。
計算著色器是一種特殊類型的著色器程式,主要用於通用運算。換句話說,它比頂點著色器和片段著色器更有彈性,因為它沒有固定用途(例如轉換頂點或寫入顏色到影像)。不同於片段或頂點著色器,計算著色器的幕後運作極少。您寫的程式碼就是 GPU 執行的內容,沒有多餘處理。這讓它非常適合將大量運算交由 GPU 處理。
現在讓我們開始建立一個簡單的計算著色器。
首先,請在您喜愛的**外部**文字編輯器中,在專案資料夾內建立一個名為 compute_example.glsl 的新檔案。在 Godot 中編寫計算著色器時,須直接撰寫 GLSL。Godot 的著色器語言是以 GLSL 為基礎。如果您熟悉 Godot 的一般著色器,下面的語法應該會很眼熟。
備註
計算著色器僅能在基於 RenderingDevice 的算繪器(Forward+ 或 Mobile 算繪器)中使用。請確認您已設定為 Forward+ 或 Mobile 算繪器(可在編輯器右上角調整)才能依照本教學操作。
請注意,即使行動裝置在技術上支援計算著色器,實際支援度通常很差(多因驅動程式問題)。
來看看這個計算著色器的範例程式碼:
#[compute]
#version 450
// Invocations in the (x, y, z) dimension
layout(local_size_x = 2, local_size_y = 1, local_size_z = 1) in;
// A binding to the buffer we create in our script
layout(set = 0, binding = 0, std430) restrict buffer MyDataBuffer {
float data[];
}
my_data_buffer;
// The code we want to execute in each invocation
void main() {
// gl_GlobalInvocationID.x uniquely identifies this invocation across all work groups
my_data_buffer.data[gl_GlobalInvocationID.x] *= 2.0;
}
這段程式碼會將一個浮點陣列的每個元素都乘以 2,然後把結果存回緩衝區陣列。現在讓我們逐行說明。
#[compute]
#version 450
這兩行代表了兩個重點:
下列程式碼是一個計算著色器。這是 Godot 專用的提示,讓編輯器能正確匯入該著色器檔案。
這段程式碼採用 GLSL 版本 450。
自訂計算著色器時,這兩行一般都不必修改。
// Invocations in the (x, y, z) dimension
layout(local_size_x = 2, local_size_y = 1, local_size_z = 1) in;
接下來,我們要定義每個工作群組內的呼叫(invocation)數量。呼叫代表在同一工作群組中執行的著色器實例。當我們從 CPU 啟動計算著色器時,需要指定要執行多少個工作群組。各個工作群組之間是平行運作的,彼此無法互相存取資料;但同一群組內的呼叫彼此之間則有部分有限的存取權。
可以把工作群組和呼叫想像成一個巨大的巢狀 for 迴圈。
for (int x = 0; x < workgroup_size_x; x++) {
for (int y = 0; y < workgroup_size_y; y++) {
for (int z = 0; z < workgroup_size_z; z++) {
// Each workgroup runs independently and in parallel.
for (int local_x = 0; local_x < invocation_size_x; local_x++) {
for (int local_y = 0; local_y < invocation_size_y; local_y++) {
for (int local_z = 0; local_z < invocation_size_z; local_z++) {
// Compute shader runs here.
}
}
}
}
}
}
工作群組與呼叫屬於進階主題。現在只需記得:我們將在每個工作群組中執行 2 次呼叫。
// A binding to the buffer we create in our script
layout(set = 0, binding = 0, std430) restrict buffer MyDataBuffer {
float data[];
}
my_data_buffer;
這裡我們說明計算著色器可存取的記憶體資訊。 layout 屬性讓我們指定著色器要從哪個緩衝區讀寫,稍後在 CPU 端也要以相同的 set、binding 位置對應。
restrict 關鍵字用來告知著色器這個緩衝區只會在這個著色器的單一地方存取。換句話說,不會在別的 set 或 binding 索引中再次綁定這個緩衝區。這有助於著色器編譯器最佳化程式碼。只要能用 restrict,就盡量使用。
這是一個*未定大小*的緩衝區,也就是說它可以是任意長度。所以必須小心不要存取超過緩衝區大小的索引。
// The code we want to execute in each invocation
void main() {
// gl_GlobalInvocationID.x uniquely identifies this invocation across all work groups
my_data_buffer.data[gl_GlobalInvocationID.x] *= 2.0;
}
最後,我們撰寫 main 函式,所有邏輯都在這裡執行。可利用內建變數 gl_GlobalInvocationID 存取儲存緩衝區的指定位置。gl_GlobalInvocationID 會提供目前呼叫的全域唯一 ID。
接下來,請將上述程式碼寫入你剛剛建立的 compute_example.glsl 檔案中。
建立本地 RenderingDevice
為了操作並執行計算著色器,我們需要寫一份腳本。請用你慣用的語言新增一支腳本,並掛載到場景裡任一個節點。
現在要執行剛剛的著色器,必須建立一個本地 RenderingDevice,可以透過 RenderingServer 來產生:
# Create a local rendering device.
var rd := RenderingServer.create_local_rendering_device()
// Create a local rendering device.
var rd = RenderingServer.CreateLocalRenderingDevice();
完成後,可以載入新建立的著色器檔案 compute_example.glsl,並依下列方式建立它的預編譯版本:
# Load GLSL shader
var shader_file := load("res://compute_example.glsl")
var shader_spirv: RDShaderSPIRV = shader_file.get_spirv()
var shader := rd.shader_create_from_spirv(shader_spirv)
// Load GLSL shader
var shaderFile = GD.Load<RDShaderFile>("res://compute_example.glsl");
var shaderBytecode = shaderFile.GetSpirV();
var shader = rd.ShaderCreateFromSpirV(shaderBytecode);
警告
本地 RenderingDevice 無法用如 RenderDoc 等工具偵錯。
提供輸入資料
如前所述,我們希望將一個陣列作為輸入,讓著色器把每個元素乘以 2,取得運算結果。
我們需要建立一個緩衝區,把資料傳給計算著色器。這裡我們要處理的是浮點數陣列,因此會使用儲存緩衝區(storage buffer)。儲存緩衝區允許 CPU 與 GPU 間以位元組陣列方式傳輸資料。
現在我們來初始化一個浮點數陣列並建立對應的儲存緩衝區:
# Prepare our data. We use floats in the shader, so we need 32 bit.
var input := PackedFloat32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
var input_bytes := input.to_byte_array()
# Create a storage buffer that can hold our float values.
# Each float has 4 bytes (32 bit) so 10 x 4 = 40 bytes
var buffer := rd.storage_buffer_create(input_bytes.size(), input_bytes)
// Prepare our data. We use floats in the shader, so we need 32 bit.
float[] input = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
var inputBytes = new byte[input.Length * sizeof(float)];
Buffer.BlockCopy(input, 0, inputBytes, 0, inputBytes.Length);
// Create a storage buffer that can hold our float values.
// Each float has 4 bytes (32 bit) so 10 x 4 = 40 bytes
var buffer = rd.StorageBufferCreate((uint)inputBytes.Length, inputBytes);
緩衝區準備就緒後,還需告知算繪裝置使用這個緩衝區。為此我們要建立一個 uniform(就像在一般著色器裡做的),再把它加入 uniform set,之後就能傳遞給著色器。
# Create a uniform to assign the buffer to the rendering device
var uniform := RDUniform.new()
uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_STORAGE_BUFFER
uniform.binding = 0 # this needs to match the "binding" in our shader file
uniform.add_id(buffer)
var uniform_set := rd.uniform_set_create([uniform], shader, 0) # the last parameter (the 0) needs to match the "set" in our shader file
// Create a uniform to assign the buffer to the rendering device
var uniform = new RDUniform
{
UniformType = RenderingDevice.UniformType.StorageBuffer,
Binding = 0
};
uniform.AddId(buffer);
var uniformSet = rd.UniformSetCreate([uniform], shader, 0);
定義計算管線
下一步,要建立一組 GPU 能執行的指令。我們需要準備一個管線(pipeline)及一個計算清單(compute list)。
計算結果所需的步驟如下:
建立新的管線。
開始一組 GPU 要執行的指令清單。
將計算清單綁定到管線
將緩衝區 uniform 綁定到管線
指定要使用的工作群組數量
結束指令清單
# Create a compute pipeline
var pipeline := rd.compute_pipeline_create(shader)
var compute_list := rd.compute_list_begin()
rd.compute_list_bind_compute_pipeline(compute_list, pipeline)
rd.compute_list_bind_uniform_set(compute_list, uniform_set, 0)
rd.compute_list_dispatch(compute_list, 5, 1, 1)
rd.compute_list_end()
// Create a compute pipeline
var pipeline = rd.ComputePipelineCreate(shader);
var computeList = rd.ComputeListBegin();
rd.ComputeListBindComputePipeline(computeList, pipeline);
rd.ComputeListBindUniformSet(computeList, uniformSet, 0);
rd.ComputeListDispatch(computeList, xGroups: 5, yGroups: 1, zGroups: 1);
rd.ComputeListEnd();
請注意,我們在 X 軸分派了 5 個工作群組,其餘軸各 1 個。由於著色器設定 X 軸有 2 次本地呼叫,總共會執行 10 次計算著色器呼叫。若你讀寫的索引超過緩衝區範圍,可能會誤存取到著色器以外的記憶體或其他變數,可能導致某些硬體出現問題。
執行計算著色器
經過上述步驟,我們已經快完成了,但還需執行剛剛建立的管線。到目前為止,我們只是記錄 GPU 要執行的內容,尚未真正啟動著色器程式。
要執行計算著色器,必須將管線提交給 GPU,並等待執行完成:
# Submit to GPU and wait for sync
rd.submit()
rd.sync()
// Submit to GPU and wait for sync
rd.Submit();
rd.Sync();
理想情況下,不要立刻呼叫 sync() 同步 RenderingDevice,因為這會讓 CPU 停等 GPU 完成。這裡為了立即取得資料方便教學才馬上同步。通常建議至少等 2~3 幀後再同步,這樣 GPU 才能與 CPU 平行運作,提升效率。
警告
長時間運算可能會因 Windows 觸發 TDR 而讓圖形驅動程式「當機」。這是 Windows 在圖形驅動無回應一段時間(通常 5~10 秒)後,會自動重新初始化驅動程式的機制。
依據計算著色器執行所需時間,可能需要拆成多次調度,以縮短每次執行時間、降低觸發 TDR 的風險。由於 TDR 與執行時間有關,較慢的 GPU 執行同樣著色器時比高速 GPU 更容易遇到 TDR 問題。
取得運算結果
您可能注意到,在範例著色器中,我們直接修改了儲存緩衝區內容。換言之,著色器從原本的陣列讀取資料後,再把結果寫回同一陣列。因此結果已經在那裡。現在我們來取出資料,並將結果印在控制台上。
# Read back the data from the buffer
var output_bytes := rd.buffer_get_data(buffer)
var output := output_bytes.to_float32_array()
print("Input: ", input)
print("Output: ", output)
// Read back the data from the buffers
var outputBytes = rd.BufferGetData(buffer);
var output = new float[input.Length];
Buffer.BlockCopy(outputBytes, 0, output, 0, outputBytes.Length);
GD.Print("Input: ", string.Join(", ", input));
GD.Print("Output: ", string.Join(", ", output));
Freeing memory
The buffer, pipeline, and uniform_set variables we've been using are
each an RID. Because RenderingDevice is meant to be a lower-level
API, RIDs aren't freed automatically. This means that once you're done using
buffer or any other RID, you are responsible for freeing its memory
manually using the RenderingDevice's
free_rid() method.
至此,您已具備開始操作計算著色器的一切基礎知識。
也參考
示範專案倉庫中有一個 Compute Shader Heightmap demo,這個專案會分別在 CPU 和 GPU 上產生高度圖,讓您比較同一演算法在兩種方式下的實作(大多數情況下 GPU 會更快)。