Der Kompositor
Der Kompositor ist ein neues Feature in Godot 4, das die Kontrolle über die Rendering-Pipeline beim Rendern des Inhalts eines Viewport ermöglicht.
Er kann auf einem WorldEnvironment-Node konfiguriert werden, wo er für alle Viewports gilt, oder er kann auf einem Camera3D konfiguriert werden und gilt nur für den Viewport, der diese Kamera verwendet.
The Compositor resource is used to configure the compositor. To get started, create a new compositor on the appropriate node:
Bemerkung
Der Kompositor ist derzeit eine Funktion, die nur von den Renderern Mobile und Forward+ unterstützt wird.
Kompositor-Effekte
Mit Kompositor-Effekten können Sie an verschiedenen Stellen zusätzliche Logik in die Rendering-Pipeline einfügen. Dies ist eine fortgeschrittene Funktion, die ein hohes Maß an Verständnis der Rendering-Pipeline erfordert, um sie optimal zu nutzen.
Da die Kernlogik des Kompositor-Effekts von der Rendering-Pipeline aufgerufen wird, ist es wichtig zu beachten, dass diese Logik innerhalb des Threads ausgeführt wird, in dem das Rendering stattfindet. Es muss darauf geachtet werden, dass es nicht zu Threading-Problemen kommt.
Zur Veranschaulichung der Verwendung von Kompositor-Effekten werden wir einen einfachen Post-Processing-Effekt erstellen, der es Ihnen ermöglicht, Ihren eigenen Shader-Code zu schreiben und diesen über einen Compute-Shader bildschirmfüllend anzuwenden. Das fertige Demoprojekt finden Sie hier.
Wir beginnen mit der Erstellung eines neuen Skripts namens post_process_shader.gd. Wir machen dies zu einem Tool-Skript, damit wir die Auswirkung des Kompositor-Effekts im Editor sehen können. Wir müssen unseren Node von CompositorEffect erweitern. Wir müssen unserem Skript auch einen Klassennamen geben.
@tool
extends CompositorEffect
class_name PostProcessShader
Als nächstes werden wir eine Konstante für unseren Shader-Vorlagencode definieren. Dies ist der Standardcode, mit dem unser Compute-Shader funktioniert.
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);
}
"""
Weitere Informationen über die Funktionsweise von Compute-Shadern finden Sie in Verwendung von Compute-Shadern.
Der wichtige Teil hier ist, dass für jedes Pixel auf unserem Bildschirm unsere Hauptfunktion ausgeführt wird, und innerhalb dieser laden wir den aktuellen Farbwert unseres Pixels, führen unseren Anwendercode aus und schreiben unsere geänderte Farbe zurück in unser Farbbild.
#COMPUTE_CODE wird durch unseren Benutzercode ersetzt.
Um unseren Benutzercode zu setzen, benötigen wir eine Exportvariable. Wir werden auch einige Skriptvariablen definieren, die wir verwenden werden:
@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
Beachten Sie die Verwendung eines Mutex in unserem Code. Der größte Teil unserer Implementierung wird von der Rendering-Engine aufgerufen und läuft daher innerhalb unseres Rendering-Threads.
Wir müssen sicherstellen, dass wir unseren neuen Shader-Code setzen und unseren Shader-Code als "dirty" markieren, ohne dass unser Render-Thread gleichzeitig auf diese Daten zugreift.
Als nächstes initialisieren wir unseren Effekt.
# Called when this resource is constructed.
func _init():
effect_callback_type = EFFECT_CALLBACK_TYPE_POST_TRANSPARENT
rd = RenderingServer.get_rendering_device()
Die Hauptsache ist hier die Einstellung unseres effect_callback_type, welcher der Rendering-Engine mitteilt, in welchem Stadium der Render-Pipeline sie unseren Code aufrufen soll.
Bemerkung
Derzeit haben wir nur Zugang zu den Stufen der 3D-Rendering-Pipeline!
Wir erhalten auch einen Verweis auf unser Rendering-Gerät, was sich als sehr nützlich erweisen wird.
Wir müssen auch hinter uns aufräumen, dazu reagieren wir auf die Benachrichtigung 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)
Beachten Sie, dass wir unseren Mutex hier nicht benutzen, obwohl wir unseren Shader innerhalb unseres Render-Threads erstellen. Die Methoden unseres Rendering-Servers sind thread-sicher und free_rid wird während des Aufräumen des Shaders aufgeschoben, bis alle Bilder, die gerade gerendert werden, fertig sind.
Beachten Sie auch, dass wir unsere Pipeline nicht freigeben. Das Rendering-Gerät führt eine Abhängigkeitsverfolgung durch und da die Pipeline vom Shader abhängig ist, wird sie automatisch freigegeben, wenn der Shader zerstört wird.
Von diesem Zeitpunkt an wird unser Code auf dem Rendering-Thread ausgeführt.
Unser nächster Schritt ist eine Hilfsfunktion, die den Shader neu kompiliert, wenn der Benutzercode geändert wurde.
# 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()
Am Anfang dieser Methode verwenden wir wieder unseren Mutex, um den Zugriff auf unseren Benutzer-Shader-Code und unser "is dirty"-Flag zu schützen. Wir erstellen eine lokale Kopie des Benutzer-Shader-Codes, wenn unser Benutzer-Shader-Code "dirty" ist.
Wenn wir kein neues Codefragment haben, geben wir true zurück, wenn wir bereits eine gültige Pipeline haben.
Wenn wir ein neues Codefragment haben, betten wir es in unseren Vorlagencode ein und kompilieren es dann.
Warnung
Der hier gezeigte Code kompiliert unseren neuen Code während der Laufzeit. Dies eignet sich hervorragend für das Prototyping, da wir die Auswirkungen des geänderten Shaders sofort sehen können.
Dies verhindert die Vorkompilierung und das Zwischenspeichern dieses Shaders, was auf einigen Plattformen wie Konsolen ein Problem darstellen kann. Beachten Sie, dass das Demoprojekt ein alternatives Beispiel enthält, bei dem eine glsl-Datei den gesamten Compute-Shader enthält und diese verwendet wird. Godot ist in der Lage, den Shader mit diesem Ansatz vorzukompilieren und zu cachen.
Schließlich müssen wir unseren Effekt-Callback implementieren, den die Rendering-Engine in der richtigen Phase des Renderings aufrufen wird.
# 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()
Zu Beginn dieser Methode prüfen wir, ob wir ein Rendering Device haben, ob unser Callback-Typ der richtige ist und ob wir unseren Shader haben.
Bemerkung
Die Prüfung auf den Effekttyp ist nur ein Sicherheitsmechanismus. Wir haben dies in unserer _init-Funktion festgelegt, aber es ist möglich, dass der Benutzer dies in der Benutzeroberfläche ändert.
Unser Parameter p_render_data gibt uns Zugriff auf ein Objekt, das Daten enthält, die spezifisch für den Frame sind, den wir gerade rendern. Im Moment sind wir nur an den Puffern unserer Render-Szene interessiert, die uns Zugang zu allen internen Puffern geben, die von der Rendering-Engine verwendet werden. Beachten Sie, dass wir dies in RenderSceneBuffersRD umwandeln, um die vollständige API für diese Daten freizugeben.
Als nächstes erhalten wir unsere interne Größe, also die Auflösung unserer 3D-Renderpuffer, bevor sie hochskaliert werden (falls zutreffend); die Hochskalierung erfolgt, nachdem unsere Postprozesse gelaufen sind.
Aus unserer internen Größe berechnen wir unsere Gruppengröße, siehe unsere lokale Größe in unserem Template-Shader.
Wir füllen auch unsere Push-Konstante aus, damit unser Shader unsere Größe kennt. Godot unterstützt hier noch keine Structs, also benutzen wir ein PackedFloat32Array um diese Daten zu speichern. Beachten Sie, dass wir dieses Array mit einem 16 Byte Alignment auffüllen müssen. Mit anderen Worten, die Länge unseres Arrays muss ein Vielfaches von 4 sein.
Jetzt durchlaufen wir unsere Views in einer Schleife, falls wir das Multiview-Rendering verwenden, das für das Stereo-Rendering (XR) geeignet ist. In den meisten Fällen werden wir nur einen View haben.
Bemerkung
Die Verwendung von Multiview für die Nachbearbeitung bringt hier keinen Performance-Vorteil. Durch die getrennte Behandlung der Ansichten kann der Grafikprozessor die Parallelisierung dennoch nutzen, wenn dies sinnvoll ist.
Als nächstes erhalten wir den Farbpuffer für diese Ansicht. Dies ist der Puffer, in den unsere 3D-Szene gerendert wurde.
Dann bereiten wir ein Uniform-Set vor, damit wir den Farbpuffer an unseren Shader weitergeben können.
Beachten Sie die Verwendung unseres UniformSetCacheRD-Caches, der sicherstellt, dass wir bei jedem Frame nach unserem Uniform-Set suchen können. Da sich unser Farbpuffer von Frame zu Frame ändern kann und unser UniformSet-Cache automatisch die UniformSets aufräumt, wenn die Puffer freigegeben werden, ist dies der sichere Weg, um sicherzustellen, dass wir kein Speicherleck produzieren oder ein veraltetes Set verwenden.
Zum Schluss bauen wir unsere Berechnungsliste auf, indem wir unsere Pipeline binden, unsere einheitliche Menge binden, unsere Push-Konstanten-Daten übertragen und Dispatch für unsere Gruppen aufrufen.
Nachdem unser Kompositor-Effekt fertiggestellt ist, müssen wir ihn nun zu unserem Kompositor hinzufügen.
In unserem Kompositor erweitern wir die Property "Kompositor-Effekte" und drücken auf Element hinzufügen.
Jetzt können wir unseren Kompositor-Effekt hinzufügen:
Nachdem wir unseren PostProcessShader ausgewählt haben, müssen wir unseren Benutzer-Shader-Code einstellen:
float gray = color.r * 0.2125 + color.g * 0.7154 + color.b * 0.0721;
color.rgb = vec3(gray);
Damit ist die Ausgabe in Graustufen erfolgt.
Bemerkung
Ein fortgeschrittenes Beispiel für Post-Effekte finden Sie in dem Beispielprojekt Radial blur based sky rays von Bastiaan Olij.