Up to date

This page is up to date for Godot 4.2. If you still find outdated information, please open an issue.

Verwendung von Compute-Shadern

Dieses Tutorial wird Sie durch den Prozess der Erstellung eines minimalen Compute-Shaders führen. Zunächst aber ein paar Hintergrundinformationen zu Compute-Shadern und wie sie mit Godot funktionieren.

Bemerkung

Dieses Tutorial setzt voraus, dass Sie mit Shadern im Allgemeinen vertraut sind. Wenn Sie neu im Bereich Shader sind, lesen Sie bitte Einführung in Shader und your first shader bevor Sie mit diesem Tutorial fortfahren.

Ein Compute-Shader ist eine besondere Art von Shader-Programm, das auf die allgemeine Programmierung ausgerichtet ist. Mit anderen Worten, sie sind flexibler als Vertex-Shader und Fragment-Shader, da sie keinen festen Zweck haben (z.B. die Umwandlung von Vertices oder das Schreiben von Farben in ein Bild). Im Gegensatz zu Fragment-Shadern und Vertex-Shadern läuft bei Compute-Shadern sehr wenig im Hintergrund ab. Der Code, den Sie schreiben, ist das, was die GPU ausführt, und sehr wenig anderes. Dies kann sie zu einem sehr nützlichen Werkzeug machen, um aufwendige Berechnungen auf den Grafikprozessor zu verlagern.

Beginnen wir nun mit der Erstellung eines kurzen Compute-Shaders.

Erstellen Sie zunächst in einem externen Texteditor Ihrer Wahl eine neue Datei namens compute_example.glsl in Ihrem Projektordner. Wenn Sie Compute-Shader in Godot schreiben, schreiben Sie sie direkt in GLSL. Die Shader-Sprache von Godot basiert auf GLSL. Wenn Sie mit normalen Shadern in Godot vertraut sind, wird Ihnen die folgende Syntax bekannt vorkommen.

Bemerkung

Compute-Shader können nur von RenderingDevice-basierten Renderern (dem Forward+- oder Mobile-Renderer) verwendet werden. Um diesem Tutorial folgen zu können, müssen Sie sicherstellen, dass Sie den Forward+- oder Mobile-Renderer verwenden. Die Einstellung dafür befindet sich in der oberen rechten Ecke des Editors.

Beachten Sie, dass die Unterstützung für Compute Shader auf mobilen Geräten im Allgemeinen schlecht ist (aufgrund von Treiberfehlern), auch wenn sie technisch unterstützt werden.

Werfen wir einen Blick auf diesen Compute-Shader-Code:

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

Dieser Code nimmt ein Array von Floats, multipliziert jedes Element mit 2 und speichert die Ergebnisse zurück in das Puffer-Array. Schauen wir uns den Code nun Zeile für Zeile an.

#[compute]
#version 450

Diese beiden Zeilen vermitteln zwei Dinge:

  1. Der folgende Code ist ein Compute-Shader. Dies ist ein Godot-spezifischer Hinweis, der benötigt wird, damit der Editor die Shader-Datei korrekt importieren kann.

  2. Der Code verwendet GLSL Version 450.

Sie sollten niemals diese beiden Zeilen für Ihre benutzerdefinierten Compute-Shader ändern müssen.

// Invocations in the (x, y, z) dimension
layout(local_size_x = 2, local_size_y = 1, local_size_z = 1) in;

Als Nächstes teilen wir die Anzahl der Aufforderungen mit, die in jeder Arbeitsgruppe verwendet werden sollen. Aufrufe sind Instanzen des Shaders, die innerhalb derselben Arbeitsgruppe ausgeführt werden. Wenn wir einen Compute-Shader von der CPU starten, teilen wir ihm mit, wie viele Arbeitsgruppen er ausführen soll. Arbeitsgruppen werden parallel zueinander ausgeführt. Während eine Arbeitsgruppe ausgeführt wird, können Sie nicht auf Informationen in einer anderen Arbeitsgruppe zugreifen. Allerdings können Aufrufe in derselben Workgroup in begrenztem Umfang auf andere Aufrufe zugreifen.

Betrachten Sie Arbeitsgruppen und Aufrufe als eine riesige verschachtelte for-Schleife.

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

Arbeitsgruppen und Aufrufe sind ein Thema für Fortgeschrittene. Denken Sie zunächst daran, dass wir zwei Aufrufe pro Arbeitsgruppe durchführen werden.

// A binding to the buffer we create in our script
layout(set = 0, binding = 0, std430) restrict buffer MyDataBuffer {
    float data[];
}
my_data_buffer;

Hier stellen wir Informationen über den Speicher zur Verfügung, auf den der Compute-Shader Zugriff haben wird. Die Property layout erlaubt es uns, dem Shader mitzuteilen, wo er nach dem Puffer suchen soll. Wir werden diese set und binding-Positionen später auf der CPU-Seite abgleichen müssen.

Das Schlüsselwort restrict sagt dem Shader, dass auf diesen Puffer nur von einer Stelle in diesem Shader zugegriffen werden soll. Mit anderen Worten, wir werden diesen Puffer nicht in einen anderen set oder binding-Index einbinden. Dies ist wichtig, da es dem Shader-Compiler erlaubt, den Shader-Code zu optimieren. Benutzen Sie immer restrict wenn Sie können.

Es handelt sich um einen unsized Puffer, d.h. er kann beliebig groß sein. Wir müssen also aufpassen, dass wir nicht aus einem Index lesen, der größer ist als die Größe des Puffers.

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

Schließlich schreiben wir die Funktion main, in der die gesamte Logik abläuft. Wir greifen auf eine Position im Speicherpuffer zu, indem wir die Built-in-Variable gl_GlobalInvocationID verwenden. gl_GlobalInvocationID gibt Ihnen die global eindeutige ID für den aktuellen Aufruf.

Um fortzufahren, schreiben Sie den obigen Code in Ihre neu erstellte Datei compute_example.glsl.

Erstellen eines lokalen RenderingDevices

Um mit einem Compute-Shader zu interagieren und ihn auszuführen, benötigen wir ein Skript. Erstellen Sie ein neues Skript in einer Sprache Ihrer Wahl und fügen Sie es an einen beliebigen Node in Ihrer Szene an.

Um nun unseren Shader auszuführen, benötigen wir ein lokales RenderingDevice, das mit Hilfe des RenderingServer erstellt werden kann:

# Create a local rendering device.
var rd := RenderingServer.create_local_rendering_device()

Danach können wir die neu erstellte Shaderdatei compute_example.glsl laden und damit eine vorkompilierte Version erstellen:

# 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)

Warnung

Lokale RenderingDevices können nicht mit Tools wie RenderDoc debuggt werden.

Bereitstellung von Eingabedaten

Wie Sie sich vielleicht erinnern, wollen wir ein Eingabe-Array an unseren Shader übergeben, jedes Element mit 2 multiplizieren und das Ergebnis erhalten.

Wir müssen einen Puffer erstellen, um Werte an einen Compute-Shader zu übergeben. Da wir es mit einem Array von Floats zu tun haben, werden wir für dieses Beispiel einen Speicherpuffer verwenden. Ein Speicherpuffer nimmt ein Array von Bytes auf und ermöglicht der CPU die Übertragung von Daten zur und von der GPU.

Initialisieren wir also ein Array von Floats und erstellen einen Speicherpuffer:

# 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)

Nachdem der Puffer jetzt vorhanden ist, müssen wir dem Rendering-Gerät mitteilen, dass es diesen Puffer verwenden soll. Dazu müssen wir ein Uniform erstellen (wie in normalen Shadern) und sie einem Uniform-Set zuweisen, das wir später an unseren Shader übergeben können.

# 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

Definieren einer Berechnungs-Pipeline

Der nächste Schritt besteht darin, eine Reihe von Anweisungen zu erstellen, die unsere GPU ausführen kann. Dazu benötigen wir eine Pipeline und eine Berechnungs-Liste.

Die Schritte, die wir zur Berechnung unseres Ergebnisses durchführen müssen, sind:

  1. Erstellen einer neuen Pipeline.

  2. Beginnen mit einer Liste von Anweisungen, die unsere GPU ausführen soll.

  3. Binden unserer Berechnungsliste an unsere Pipeline

  4. Binden unseres Puffer-Uniforms an unsere Pipeline

  5. Festlegen, wie viele Arbeitsgruppen verwendet werden sollen

  6. Beenden der Liste von Anweisungen

# 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()

Beachten Sie, dass wir den Compute-Shader mit 5 Arbeitsgruppen in der X-Achse und einer in den anderen Achsen starten. Da wir 2 lokale Aufrufe in der X-Achse haben (angegeben in unserem Shader), werden insgesamt 10 Compute-Shader-Aufrufe gestartet. Wenn Sie in Indizes außerhalb des Bereichs Ihres Puffers lesen oder schreiben, greifen Sie möglicherweise auf Speicher außerhalb der Kontrolle Ihres Shaders oder auf Teile anderer Variablen zu, was auf mancher Hardware zu Problemen führen kann.

Ausführen eines Compute-Shaders

Nach all dem sind wir fast fertig, aber wir müssen noch unsere Pipeline ausführen. Bisher haben wir nur aufgezeichnet, was wir von der GPU erwarten; wir haben das Shader-Programm noch nicht ausgeführt.

Um unseren Compute-Shader auszuführen, müssen wir die Pipeline an die GPU senden und warten, bis die Ausführung abgeschlossen ist:

# Submit to GPU and wait for sync
rd.submit()
rd.sync()

Idealerweise sollten Sie nicht sync() aufrufen, um das RenderingDevice sofort zu synchronisieren, da dies dazu führt, dass die CPU auf die Beendigung der Arbeit der GPU wartet. In unserem Beispiel synchronisieren wir sofort, weil wir unsere Daten sofort zum Lesen zur Verfügung haben wollen. Im Allgemeinen sollten Sie mindestens 2 oder 3 Frames warten, bevor Sie synchronisieren, damit die GPU parallel zur CPU arbeiten kann.

Warnung

Lange Berechnungen können dazu führen, dass Windows-Grafiktreiber "abstürzen", weil TDR von Windows ausgelöst wird. Dabei handelt es sich um einen Mechanismus, der den Grafiktreiber neu initialisiert, nachdem eine bestimmte Zeitspanne ohne Aktivität des Grafiktreibers verstrichen ist (normalerweise 5 bis 10 Sekunden).

Je nachdem, wie lange die Ausführung Ihres Compute-Shaders dauert, müssen Sie ihn möglicherweise in mehrere Dispatches aufteilen, um die Zeit für jeden Dispatch zu verkürzen und die Wahrscheinlichkeit der Auslösung eines TDR zu verringern. Da TDR zeitabhängig ist, können langsamere GPUs bei der Ausführung eines bestimmten Compute-Shaders anfälliger für TDRs sein als schnellere GPUs.

Abrufen von Ergebnissen

Sie haben vielleicht bemerkt, dass wir im Beispiel-Shader den Inhalt des Speicherpuffers verändert haben. Mit anderen Worten, der Shader hat aus unserem Array gelesen und die Daten in demselben Array wieder gespeichert, so dass unsere Ergebnisse bereits vorhanden sind. Lassen Sie uns die Daten abrufen und die Ergebnisse auf unserer Konsole ausgeben.

# 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)

Damit haben Sie alles, was Sie brauchen, um mit Compute-Shadern zu arbeiten.

Siehe auch

Das Repository für Demoprojekte enthält eine Compute Shader Höhenkarten-Demo. Dieses Projekt führt die Erzeugung von Höhenkarten-Bildern auf der CPU und der GPU getrennt durch, wodurch Sie vergleichen können, wie ein ähnlicher Algorithmus auf zwei verschiedene Arten implementiert werden kann (wobei die GPU-Implementierung in den meisten Fällen schneller ist).