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.

Il compositore

Il compositore è una nuova funzionalità di Godot 4 che consente di controllare la pipeline di rendering durante il rendering del contenuto di una Viewport.

È possibile configurarlo su un nodo WorldEnvironment, dove si applica a tutte le Viewport, oppure su un nodo Camera3D e applicato solo alla Viewport che utilizza quella telecamera.

La risorsa Compositor serve per configurare il compositore. Per cominciare, creare un nuovo compositore sul nodo appropriato:

../../_images/new_compositor.webp

Nota

Il compositore è attualmente una funzionalità supportata solo dai renderer Mobile e Forward+.

Effetti compositore

Gli effetti compositore consentono di inserire logica in più nella pipeline di rendering in varie fasi. È una funzionalità avanzata che richiede una profonda conoscenza della pipeline di rendering per essere sfruttata al meglio.

Poiché la logica principale dell'effetto compositore è richiamata dalla pipeline di rendering, è importante notare che tale logica sarà eseguita all'interno del thread in cui avviene il rendering. È necessario prestare attenzione per evitare problemi di threading.

Per illustrare come utilizzare gli effetti compositore, creeremo un semplice effetto di post-elaborazione che consente di scrivere il proprio codice shader e applicarlo a tutto lo schermo tramite un compute shader. Il progetto demo completo è disponibile qui:.

Iniziamo creando un nuovo script chiamato post_process_shader.gd. Lo renderemo uno script strumento in modo da poter vedere l'effetto del compositore in azione nell'editor. Dobbiamo estendere il nostro nodo da CompositorEffect. Dobbiamo anche assegnare un nome di classe al nostro script.

post_process_shader.gd
@tool
extends CompositorEffect
class_name PostProcessShader

Successivamente, definiremo una costante per il codice del nostro template shader. È codice boilerplate che permette al nostro compute shader di funzionare.

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);
}
"""

Per ulteriori informazioni su come funzionano gli shader di calcolo, si prega di consultare Utilizzo di shader di calcolo.

La cosa importante da capire è che per ogni pixel del nostro schermo è eseguita la nostra funzione main, al cui interno carichiamo il valore del colore attuale del pixel, eseguiamo il nostro codice utente e riscriviamo il colore modificato nell'immagine a colori.

#COMPUTE_CODE è sostituito dal nostro codice utente.

Per impostare il nostro codice utente, abbiamo bisogno di una variabile da esportare. Definiremo anche alcune variabili di script che utilizzeremo:

@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

Si noti l'utilizzo di un Mutex nel nostro codice. La maggior parte della nostra implementazione è richiamata dal motore di rendering e quindi è eseguita all'interno del nostro thread di rendering.

Dobbiamo assicurarci di impostare il nostro nuovo codice shader e di contrassegnarlo come modificato, senza che il thread di rendering acceda a questi dati allo stesso tempo.

Successivamente inizializziamo l'effetto.

# Called when this resource is constructed.
func _init():
    effect_callback_type = EFFECT_CALLBACK_TYPE_POST_TRANSPARENT
    rd = RenderingServer.get_rendering_device()

La cosa fondamentale qui è impostare il nostro effect_callback_type, che indica al motore di rendering in quale fase della pipeline di rendering richiamare il nostro codice.

Nota

Al momento abbiamo accesso solo alle fasi della pipeline di rendering 3D!

Riceviamo anche un riferimento al nostro dispositivo di rendering, che si rivelerà molto utile.

Dobbiamo anche ripulire alla fine, per questo reagiamo alla notifica 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)

Si noti che qui non utilizziamo il nostro mutex, anche se creiamo lo shader all'interno del thread di rendering. I metodi sul nostro server di rendering sono thread-safe e la pulizia dello shader tramite free_rid sarà posticipata fino quando il rendering di tutti i frame non abbia completato.

Si noti inoltre che non stiamo liberando la nostra pipeline. Il dispositivo di rendering gestisce le dipendenze e, poiché la pipeline dipende dallo shader, verrà liberata automaticamente quando lo shader viene distrutto.

Da questo punto in poi, il nostro codice sarà eseguito sul thread di rendering.

Il nostro prossimo passo è una funzione ausiliare che ricompila lo shader se il codice utente è stato modificato.

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

All'inizio di questo metodo utilizziamo nuovamente il nostro mutex per proteggere l'accesso al codice dello shader utente e il nostro flag di modifica. Creiamo una copia locale del codice dello shader utente se quest'ultimo è stato modificato.

Se non abbiamo un nuovo frammento di codice, restituiamo true se abbiamo già una pipeline valida.

Se invece abbiamo un nuovo frammento di codice, lo incorporiamo nel nostro codice template e lo compiliamo.

Avvertimento

Il codice qui mostrato compila il nostro nuovo codice in fase di esecuzione. Questo è ottimo per la prototipazione, poiché possiamo vedere immediatamente l'effetto dello shader modificato.

In questo modo si evita di precompilare e conservare lo shader nella cache, il che potrebbe essere un problema su alcune piattaforme come le console. Si noti che il progetto demo include un esempio alternativo in cui un file glsl contiene l'intero shader di calcolo e questo è utilizzato. Con questo approccio, Godot è in grado di precompilare e conservare lo shader nella cache.

Infine, dobbiamo implementare il nostro callback per gli effetti; il motore di rendering lo richiamerà nella fase corretta del rendering.

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

All'inizio di questo metodo controlliamo se abbiamo un dispositivo di rendering, se il tipo del nostro callback è corretto e se abbiamo il nostro shader.

Nota

La verifica del tipo di effetto è solo un meccanismo di sicurezza. Lo abbiamo impostato nella nostra funzione _init, tuttavia è possibile che l'utente lo cambi nell'interfaccia utente.

Il nostro parametro p_render_data ci dà accesso a un oggetto che contiene dati specifici per il frame che stiamo attualmente renderizzando. Al momento siamo interessati solo ai buffer della scena di rendering, che ci forniscono accesso a tutti i buffer interni utilizzati dal motore di rendering. Si noti che lo convertiamo in un RenderSceneBuffersRD per esporre l'API completa a questi dati.

Next we obtain our internal size which is the resolution of our 3D render buffers before they are upscaled (if applicable), upscaling happens after our post processes have run.

From our internal size we calculate our group size, see our local size in our template shader.

Popoliamo anche la nostra costante push in modo che il nostro shader conosca le dimensioni. Godot non supporta ancora le struct in questo caso, quindi utilizziamo un PackedFloat32Array per memorizzare questi dati. Nota che dobbiamo imbottire questo array con un allineamento di 16 byte. In altre parole, la lunghezza del nostro array deve essere un multiplo di 4.

Ora iteriamo tra le nostre viste, nel caso in cui si utilizzi il rendering multivista, applicabile al rendering stereoscopico (XR). Nella maggior parte dei casi avremo una sola vista.

Nota

There is no performance benefit to use multiview for post processing here, handling the views separately like this will still enable the GPU to use parallelism if beneficial.

Successivamente otteniamo il buffer dei colori per questa vista. Questo è il buffer in cui è stata renderizzata la nostra scena 3D.

Prepariamo quindi un set di uniformi in modo da poter comunicare il buffer di colore al nostro shader.

Si noti l'utilizzo della nostra cache UniformSetCacheRD, che ci garantisce di poter controllare il nostro set di uniformi a ogni frame. Poiché il nostro buffer di colore può cambiare da un frame all'altro e la nostra cache di uniformi eliminerà automaticamente i set di uniformi quando i buffer sono liberati, questo è il modo sicuro per garantire che non ci siano perdite di memoria o che non sia utilizzato un set obsoleto.

Infine, creiamo la nostra lista di calcolo collegando la nostra pipeline, collegando il nostro set di uniformi, inserendo i dati della nostra costante di push e chiamando dispatch per i nostri gruppi.

Ora che abbiamo completato l'effetto compositore, dobbiamo aggiungerlo al compositore stesso.

Nel nostro compositore espandiamo la proprietà Effetti compositore e premiamo Aggiungi elemento.

Ora possiamo aggiungere il nostro effetto compositore:

../../_images/add_compositor_effect.webp

Dopo aver selezionato il nostro PostProcess Shader, dobbiamo impostare il codice dello shader utente:

float gray = color.r * 0.2125 + color.g * 0.7154 + color.b * 0.0721;
color.rgb = vec3(gray);

Fatto ciò, il nostro risultato è in scala di grigi.

../../_images/post_process_shader.webp

Nota

Per un esempio più avanzato di effetti di post-elaborazione, consultare il progetto di esempio Radial blur based sky rays creato da Bastiaan Olij.