Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

Using compute shaders

This tutorial will walk you through the process of creating a minimal compute shader. But first, a bit of background on compute shaders and how they work with Godot.

Nota

This tutorial assumes you are familiar with shaders generally. If you are new to shaders please read Introduzione agli shader and your first shader before proceeding with this tutorial.

A compute shader is a special type of shader program that is orientated towards general purpose programming. In other words, they are more flexible than vertex shaders and fragment shaders as they don't have a fixed purpose (i.e. transforming vertices or writing colors to an image). Unlike fragment shaders and vertex shaders, compute shaders have very little going on behind the scenes. The code you write is what the GPU runs and very little else. This can make them a very useful tool to offload heavy calculations to the GPU.

Ora cominciamo creando un breve shader di calcolo.

First, in the external text editor of your choice, create a new file called compute_example.glsl in your project folder. When you write compute shaders in Godot, you write them in GLSL directly. The Godot shader language is based on GLSL. If you are familiar with normal shaders in Godot, the syntax below will look somewhat familiar.

Nota

Compute shaders can only be used from RenderingDevice-based renderers (the Forward+ or Mobile renderer). To follow along with this tutorial, ensure that you are using the Forward+ or Mobile renderer. The setting for which is located in the top right-hand corner of the editor.

Note that compute shader support is generally poor on mobile devices (due to driver bugs), even if they are technically supported.

Diamo un'occhiata a questo codice per uno shader di calcolo:

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

This code takes an array of floats, multiplies each element by 2 and store the results back in the buffer array. Now let's look at it line-by-line.

#[compute]
#version 450

Queste due righe comunicano due cose:

  1. Il codice seguente è uno shader di calcolo. Questo è un suggerimento specifico di Godot necessario affinché l'editor importi correttamente il file dello shader.

  2. Il codice utilizza la versione 450 di GLSL.

Non dovresti mai aver bisogno di cambiare queste due righe per i tuoi shader di calcolo personalizzati.

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

Next, we communicate the number of invocations to be used in each workgroup. Invocations are instances of the shader that are running within the same workgroup. When we launch a compute shader from the CPU, we tell it how many workgroups to run. Workgroups run in parallel to each other. While running one workgroup, you cannot access information in another workgroup. However, invocations in the same workgroup can have some limited access to other invocations.

Think about workgroups and invocations as a giant nested for loop.

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

Workgroups and invocations are an advanced topic. For now, remember that we will be running two invocations per workgroup.

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

Here we provide information about the memory that the compute shader will have access to. The layout property allows us to tell the shader where to look for the buffer, we will need to match these set and binding positions from the CPU side later.

The restrict keyword tells the shader that this buffer is only going to be accessed from one place in this shader. In other words, we won't bind this buffer in another set or binding index. This is important as it allows the shader compiler to optimize the shader code. Always use restrict when you can.

Questo è un buffer non dimensionato, il che significa che può avere qualsiasi dimensione. Pertanto bisogna fare attenzione a non leggere da un indice più grande della dimensione del 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;
}

Finally, we write the main function which is where all the logic happens. We access a position in the storage buffer using the gl_GlobalInvocationID built-in variables. gl_GlobalInvocationID gives you the global unique ID for the current invocation.

Per continuare, inserisci il codice sopra riportato nel file compute_example.glsl appena creato.

Creare un RenderingDevice locale

To interact with and execute a compute shader, we need a script. Create a new script in the language of your choice and attach it to any Node in your scene.

Ora, per eseguire il nostro shader, abbiamo bisogno di un RenderingDevice locale che si può creare attraverso il RenderingServer:

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

Dopodiché, possiamo caricare il file shader compute_example.glsl appena creato e crearne una versione precompilata usando questo:

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

Avvertimento

I RenderingDevice locali non si possono sottoporre a debug tramite strumenti come RenderDoc.

Provide input data

As you might remember, we want to pass an input array to our shader, multiply each element by 2 and get the results.

We need to create a buffer to pass values to a compute shader. We are dealing with an array of floats, so we will use a storage buffer for this example. A storage buffer takes an array of bytes and allows the CPU to transfer data to and from the GPU.

So let's initialize an array of floats and create a storage buffer:

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

Una volta creato il buffer, dobbiamo indicare al dispositivo di rendering di utilizzarlo. Per farlo, dovremo creare un'uniforme (come nei normali shader) e assegnarla a un set di uniformi che potremo poi passare al nostro shader.

# 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

Defining a compute pipeline

The next step is to create a set of instructions our GPU can execute. We need a pipeline and a compute list for that.

I passaggi necessari per calcolare il risultato sono i seguenti:

  1. Creare una nuova pipeline.

  2. Iniziare un elenco di istruzioni che la nostra GPU dovrà eseguire.

  3. Associare il nostro elenco di calcolo alla nostra pipeline

  4. Associare il nostro buffer di uniformi alla nostra pipeline

  5. Specificare quanti gruppi di lavoro utilizzare

  6. Finire l'elenco di istruzioni

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

Note that we are dispatching the compute shader with 5 work groups in the X axis, and one in the others. Since we have 2 local invocations in the X axis (specified in our shader), 10 compute shader invocations will be launched in total. If you read or write to indices outside of the range of your buffer, you may access memory outside of your shaders control or parts of other variables which may cause issues on some hardware.

Execute a compute shader

After all of this we are almost done, but we still need to execute our pipeline. So far we have only recorded what we would like the GPU to do; we have not actually run the shader program.

To execute our compute shader we need to submit the pipeline to the GPU and wait for the execution to finish:

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

Idealmente, non dovresti chiamare sync() per sincronizzare immediatamente il RenderingDevice, poiché ciò costringerebbe la CPU ad attendere che la GPU finisca di lavorare. Nel nostro esempio, sincronizziamo subito perché vogliamo che i dati siano disponibili subito per la lettura. In generale, è consigliabile attendere almeno 2 o 3 frame prima di sincronizzare, così che la GPU possa funzionare in parallelo con la CPU.

Avvertimento

Long computations can cause Windows graphics drivers to "crash" due to TDR being triggered by Windows. This is a mechanism that reinitializes the graphics driver after a certain amount of time has passed without any activity from the graphics driver (usually 5 to 10 seconds).

Depending on the duration your compute shader takes to execute, you may need to split it into multiple dispatches to reduce the time each dispatch takes and reduce the chances of triggering a TDR. Given TDR is time-dependent, slower GPUs may be more prone to TDRs when running a given compute shader compared to a faster GPU.

Retrieving results

Avrai notato che, nell'esempio di shader, abbiamo modificato il contenuto del buffer di archiviazione. In altre parole, lo shader ha letto dal nostro array e ha memorizzato nuovamente i dati nello stesso array, quindi i nostri risultati ci sono già. Recuperiamo i dati e stampiamo i risultati sulla console.

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

Liberare la memoria

Le variabili buffer, pipeline e uniform_set che stiamo usando sono ciascuna un RID. Poiché RenderingDevice è concepito come un'API di livello più basso, i RID non vengono liberati automaticamente. Ciò significa che, dopo aver finito di usare di buffer o di qualsiasi altro RID, è tua responsabilità liberare manualmente la memoria corrispondente attraverso il metodo free_rid() del RenderingDevice.

With that, you have everything you need to get started working with compute shaders.

Vedi anche

Il repository dei progetti demo contiene una demo di Compute Shader Heightmap. Questo progetto esegue la generazione di immagini heightmap sulla CPU e GPU separatamente, consentendo di confrontare come è possibile implementare un algoritmo simile in due modi diversi (con l'implementazione sulla GPU che risulta più veloce in gran parte dei casi).