合成器(Compositor)
合成器是 Godot 4 的新功能,可讓你在渲染 檢視區 內容時,控制渲染管線。
你可以在 世界環境 節點上設定合成器,使其套用於所有檢視區,或是在 3D 攝影機 上設定,僅套用於該攝影機所屬的檢視區。
合成器 資源用於設定合成器。首先,請在對應的節點上建立新的合成器:
備註
目前合成器僅支援 Mobile(行動裝置)與 Forward+(前向加強)渲染器。
合成器特效
合成器特效讓你能在渲染管線的不同階段插入自訂邏輯。這是一項進階功能,使用時需要對渲染管線有高度的理解,才能發揮其最大效益。
由於合成器特效的核心邏輯會由渲染管線呼叫,因此會在執行渲染的執行緒中運作。請務必小心,避免產生執行緒相關問題。
為了說明如何使用合成器特效,我們將建立一個簡單的後處理特效,讓你可以撰寫自己的著色器程式,並透過運算著色器套用到全螢幕。你可以在這裡找到完成的展示專案:這裡。
首先,建立一個名為 post_process_shader.gd 的新腳本。將其設為 tool 腳本,這樣就可以在編輯器中即時看到合成器特效的效果。這個腳本需要繼承自 CompositorEffect,並為其指定一個類別名稱。
@tool
extends CompositorEffect
class_name PostProcessShader
接著,我們要定義一個常數來存放著色器範本程式碼。這段程式碼是讓我們的運算著色器能夠運作的基礎。
const template_shader: String = """
#version 450
// Invocations in the (x, y, z) dimension
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
layout(rgba16f, set = 0, binding = 0) uniform image2D color_image;
// Our push constant
layout(push_constant, std430) uniform Params {
vec2 raster_size;
vec2 reserved;
} params;
// The code we want to execute in each invocation
void main() {
ivec2 uv = ivec2(gl_GlobalInvocationID.xy);
ivec2 size = ivec2(params.raster_size);
if (uv.x >= size.x || uv.y >= size.y) {
return;
}
vec4 color = imageLoad(color_image, uv);
#COMPUTE_CODE
imageStore(color_image, uv, color);
}
"""
如需更多有關運算著色器運作方式的資訊,請參考 使用運算著色器。
這裡最重要的是,螢幕上的每一個像素都會執行一次 main 函式,過程中會讀取該像素目前的顏色值、執行自訂程式碼,並將修改後的顏色寫回影像。
#COMPUTE_CODE 會被替換為我們撰寫的自訂程式碼。
為了設定自訂程式碼,我們需要一個 export 變數。同時,也會定義一些腳本變數以供使用:
@export_multiline var shader_code: String = "":
set(value):
mutex.lock()
shader_code = value
shader_is_dirty = true
mutex.unlock()
var rd: RenderingDevice
var shader: RID
var pipeline: RID
var mutex: Mutex = Mutex.new()
var shader_is_dirty: bool = true
請注意,我們在程式中使用了 Mutex。大部分的實作都會由渲染引擎呼叫,因此會在渲染執行緒中執行。
我們必須確保在設定新的著色器程式碼並標記為已變更時,渲染執行緒不會同時存取這些資料。
接著初始化我們的特效。
# Called when this resource is constructed.
func _init():
effect_callback_type = EFFECT_CALLBACK_TYPE_POST_TRANSPARENT
rd = RenderingServer.get_rendering_device()
這裡的重點是設定 effect_callback_type,告訴渲染引擎要在渲染管線的哪個階段呼叫我們的程式碼。
備註
目前僅能存取 3D 渲染管線的各個階段!
我們同時也會取得渲染裝置的參考,這在後續會很實用。
我們還需要進行資源清理,這可以透過監聽 NOTIFICATION_PREDELETE 通知來完成:
# System notifications, we want to react on the notification that
# alerts us we are about to be destroyed.
func _notification(what):
if what == NOTIFICATION_PREDELETE:
if shader.is_valid():
# Freeing our shader will also free any dependents such as the pipeline!
rd.free_rid(shader)
請注意,雖然我們在渲染執行緒中建立著色器,但這裡並未使用 mutex。渲染伺服器的方法是執行緒安全的,free_rid 會等到目前正在渲染的所有畫面結束後,才會釋放著色器資源。
同時也請注意,這邊沒有手動釋放 pipeline。渲染裝置會自動追蹤相依性,pipeline 依賴著色器,因此當著色器被銷毀時 pipeline 也會自動釋放。
從這裡開始,我們的程式碼都會在渲染執行緒上執行。
接下來要實作一個輔助函式,當使用者的程式碼更動時,重新編譯著色器。
# Check if our shader has changed and needs to be recompiled.
func _check_shader() -> bool:
if not rd:
return false
var new_shader_code: String = ""
# Check if our shader is dirty.
mutex.lock()
if shader_is_dirty:
new_shader_code = shader_code
shader_is_dirty = false
mutex.unlock()
# We don't have a (new) shader?
if new_shader_code.is_empty():
return pipeline.is_valid()
# Apply template.
new_shader_code = template_shader.replace("#COMPUTE_CODE", new_shader_code);
# Out with the old.
if shader.is_valid():
rd.free_rid(shader)
shader = RID()
pipeline = RID()
# In with the new.
var shader_source: RDShaderSource = RDShaderSource.new()
shader_source.language = RenderingDevice.SHADER_LANGUAGE_GLSL
shader_source.source_compute = new_shader_code
var shader_spirv: RDShaderSPIRV = rd.shader_compile_spirv_from_source(shader_source)
if shader_spirv.compile_error_compute != "":
push_error(shader_spirv.compile_error_compute)
push_error("In: " + new_shader_code)
return false
shader = rd.shader_create_from_spirv(shader_spirv)
if not shader.is_valid():
return false
pipeline = rd.compute_pipeline_create(shader)
return pipeline.is_valid()
在這個方法的一開始,我們會再次使用 mutex 來保護對使用者著色器程式碼及已變更標記的存取。如果使用者著色器程式碼已變更,就複製一份本地副本。
如果沒有新的程式碼片段,且已經有有效的 pipeline,就回傳 true。
如果有新的程式碼片段,則將其嵌入範本程式碼後進行編譯。
警告
這裡的程式碼會在執行時即時編譯新的程式碼。這對於原型設計很有幫助,因為可以立刻看到著色器變更的效果。
這樣做會導致無法預先編譯和快取著色器,這在某些平台(如遊戲主機)上可能會造成問題。請注意,展示專案中也有另一種做法:將完整運算著色器寫在 .glsl 檔案中,然後載入。Godot 可以透過這種方式來預先編譯並快取著色器。
最後要實作特效的回呼函式,渲染引擎會在正確的渲染階段呼叫它。
# Called by the rendering thread every frame.
func _render_callback(p_effect_callback_type, p_render_data):
if rd and p_effect_callback_type == EFFECT_CALLBACK_TYPE_POST_TRANSPARENT and _check_shader():
# Get our render scene buffers object, this gives us access to our render buffers.
# Note that implementation differs per renderer hence the need for the cast.
var render_scene_buffers: RenderSceneBuffersRD = p_render_data.get_render_scene_buffers()
if render_scene_buffers:
# Get our render size, this is the 3D render resolution!
var size = render_scene_buffers.get_internal_size()
if size.x == 0 and size.y == 0:
return
# We can use a compute shader here.
var x_groups = (size.x - 1) / 8 + 1
var y_groups = (size.y - 1) / 8 + 1
var z_groups = 1
# Push constant.
var push_constant: PackedFloat32Array = PackedFloat32Array()
push_constant.push_back(size.x)
push_constant.push_back(size.y)
push_constant.push_back(0.0)
push_constant.push_back(0.0)
# Loop through views just in case we're doing stereo rendering. No extra cost if this is mono.
var view_count = render_scene_buffers.get_view_count()
for view in range(view_count):
# Get the RID for our color image, we will be reading from and writing to it.
var input_image = render_scene_buffers.get_color_layer(view)
# Create a uniform set.
# This will be cached; the cache will be cleared if our viewport's configuration is changed.
var uniform: RDUniform = RDUniform.new()
uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
uniform.binding = 0
uniform.add_id(input_image)
var uniform_set = UniformSetCacheRD.get_cache(shader, 0, [ uniform ])
# Run our compute 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_set_push_constant(compute_list, push_constant.to_byte_array(), push_constant.size() * 4)
rd.compute_list_dispatch(compute_list, x_groups, y_groups, z_groups)
rd.compute_list_end()
在此方法開始時,會檢查是否有渲染裝置、回呼類型是否正確,以及是否已經有著色器。
備註
檢查特效類型只是安全機制。我們已經在 _init 函式中設定,但使用者也有可能在 UI 中修改這個設定。
p_render_data 參數讓我們可以存取目前渲染畫面所用的資料物件。目前我們只需要用到場景渲染緩衝區,這會提供對渲染引擎所有內部緩衝區的存取權限。請注意,這裡我們會將其轉型為 RenderSceneBuffersRD 以使用完整 API。
接下來取得 internal size,也就是 3D 渲染緩衝區在升頻(如果有的話)前的解析度。升頻會在所有後處理效果結束後才執行。
根據 internal size 計算群組大小,詳見著色器範本程式碼中的 local size。
我們也會設定 push constant,讓著色器知道目前的大小。Godot 目前 尚未 支援 struct,因此我們用 PackedFloat32Array 來存放這些資料。請注意,這個陣列必須以 16 位元組對齊,也就是說長度必須是 4 的倍數。
現在我們要遍歷所有 view,這是為了支援多視圖渲染(例如 XR 立體渲染)。大多數情況下只會有一個 view。
備註
在這裡使用多視圖對後處理沒有效能上的好處,但分別處理各個 view 的方式,仍能讓 GPU 視需要使用平行運算。
接下來取得該 view 的顏色緩衝區,也就是 3D 場景渲染結果所儲存的緩衝區。
然後準備一組 uniform set,用來將顏色緩衝區傳遞給著色器。
請注意,我們使用 UniformSetCacheRD 快取,這樣每一幀都可以檢查目前的 uniform set。由於顏色緩衝區每幀都可能不同,uniform 快取也會在緩衝區被釋放時自動清理,這樣才能確保不會有記憶體洩漏或用到過期的 set。
最後,綁定 pipeline 與 uniform set,推送 push constant 資料,並對所有群組呼叫 dispatch,來組建運算清單。
完成合成器特效後,現在要將它加入合成器。
在合成器中展開「合成器特效」屬性,然後按下「新增元素」。
現在就可以新增我們的合成器特效:
選取 PostProcessShader 後,請輸入自訂著色器程式碼:
float gray = color.r * 0.2125 + color.g * 0.7154 + color.b * 0.0721;
color.rgb = vec3(gray);
完成以上步驟後,畫面輸出就會變成灰階。
備註
若想參考更進階的後處理特效範例,可以看看 Bastiaan Olij 製作的 基於徑向模糊的天空光暈 範例專案。