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.
Checking the stable version of the documentation...
O Compositor
O compositor é um novo recurso do Godot 4 que permite o controle sobre o pipeline de renderização ao renderizar os conteúdos de um Viewport.
Ele pode ser configurado em um nó WorldEnvironment onde se aplica a todos os Viewports, ou pode ser configurado em uma Camera3D e se aplicar apenas ao Viewport que usa aquela câmera.
O recurso Compositor é usado para configurar o compositor. Para começar, crie um novo compositor no nó apropriado:
Nota
O compositor é atualmente um recurso suportado apenas pelos renderizadores Mobile e Forward+.
Efeitos do compositor
Efeitos de compositor (Compositor effects) permitem inserir lógica adicional no pipeline de renderização em vários estágios. Este é um recurso avançado que exige um alto nível de compreensão do pipeline de renderização para ser usado da melhor forma.
Como a lógica principal do efeito do compositor é chamada de dentro do pipeline de renderização, é importante notar que essa lógica será executada dentro da thread na qual a renderização ocorre. É preciso tomar cuidado para garantir que não ocorram problemas de concorrência de threads (threading).
Para ilustrar como usar os efeitos do compositor, criaremos um efeito simples de pós-processamento que permite que você escreva seu próprio código de shader e aplique isso em tela cheia por meio de um compute shader. Você pode encontrar o projeto de demonstração finalizado aqui.
Começamos criando um novo script chamado post_process_shader.gd. Faremos deste um script de ferramenta (tool) para que possamos ver o efeito do compositor funcionando no editor. Precisamos estender nosso nó a partir de CompositorEffect. Também devemos dar um nome de classe ao nosso script.
@tool
extends CompositorEffect
class_name PostProcessShader
[GlobalClass, Tool]
public partial class PostProcessShader : CompositorEffect
Em seguida, vamos definir uma constante para o código de modelo do nosso shader. Este é o código padrão que faz o nosso compute shader funcionar.
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);
}
"""
private const string _templateShader = @"
#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);
}
";
Para mais informações sobre como os shaders de computação funcionam, verifique Usando shaders de computação.
A parte importante aqui é que para cada pixel na nossa tela, nossa função main é executada e, dentro dela, carregamos o valor de cor atual do nosso pixel, executamos o nosso código de usuário e gravamos nossa cor modificada de volta na nossa imagem colorida.
#COMPUTE_CODE é substituído pelo nosso código de usuário.
Para definir o nosso código de usuário, precisamos de uma variável de exportação (export). Também definiremos algumas variáveis de script que usaremos:
@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
private string _shaderCode = "";
[Export(PropertyHint.MultilineText)]
public string ShaderCode
{
get { return _shaderCode; }
set
{
_mutex.Lock();
_shaderCode = value;
shaderIsDirty = true;
_mutex.Unlock();
}
}
private RenderingDevice _rd;
private Rid _shader;
private Rid _pipeline;
private Godot.Mutex _mutex = new Godot.Mutex();
private bool _shaderIsDirty = true;
Observe o uso de um Mutex em nosso código. A maior parte da nossa implementação é chamada a partir do motor de renderização e, portanto, roda dentro da nossa thread de renderização.
Precisamos garantir que definimos nosso novo código de shader e marcamos nosso código de shader como modificado (dirty), sem que a nossa thread de renderização acesse esses dados ao mesmo tempo.
Em seguida, inicializamos nosso efeito.
# Called when this resource is constructed.
func _init():
effect_callback_type = EFFECT_CALLBACK_TYPE_POST_TRANSPARENT
rd = RenderingServer.get_rendering_device()
// Called when this resource is constructed.
public PostProcessShader()
{
EffectCallbackType = EffectCallbackTypeEnum.PostTransparent;
_rd = RenderingServer.GetRenderingDevice();
}
A principal coisa aqui é definir o nosso effect_callback_type, que informa ao motor de renderização em qual estágio do pipeline de renderização deve chamar o nosso código.
Nota
Atualmente, só temos acesso aos estágios do pipeline de renderização 3D!
Também obtemos uma referência ao nosso dispositivo de renderização (rendering device), o que será muito útil.
Também precisamos fazer a limpeza depois de terminar; para isso, reagimos à notificação 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)
// System notifications, we want to react on the notification that
// alerts us we are about to be destroyed.
public override void _Notification(int what)
{
if (what == NotificationPredelete)
{
if (_shader.IsValid)
{
// Freeing our shader will also free any dependents such as the pipeline!
_rd.FreeRid(_shader);
}
}
}
Observe que não usamos nosso mutex aqui, embora criemos nosso shader dentro da nossa thread de renderização. Os métodos no nosso servidor de renderização (rendering server) são seguros para threads (thread safe) e o free_rid adiará a limpeza do shader até que todos os quadros atualmente em renderização sejam finalizados.
Observe também que não estamos liberando o nosso pipeline. O dispositivo de renderização faz o rastreamento de dependências e, como o pipeline depende do shader, ele será liberado automaticamente quando o shader for destruído.
A partir deste ponto, nosso código será executado na thread de renderização.
Nosso próximo passo é uma função auxiliar que recompilará o shader se o código do usuário tiver sido alterado.
# 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 = _templateShader.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()
// Check if our shader has changed and needs to be recompiled.
public bool CheckShader()
{
if (_rd is null)
{
return false;
}
var newShaderCode = "";
// Check if our shader is dirty.
_mutex.Lock();
if (_shaderIsDirty)
{
newShaderCode = _shaderCode;
_shaderIsDirty = false;
}
_mutex.Unlock();
// We don't have a (new) shader?
if (newShaderCode == "")
{
return _pipeline.IsValid;
}
// Apply template.
newShaderCode = _templateShader.Replace("#COMPUTE_CODE", newShaderCode);
// Out with the old.
if (_shader.IsValid)
{
_rd.FreeRid(_shader);
_shader = new Rid();
_pipeline = new Rid();
}
// In with the new.
RDShaderSource shaderSource = new RDShaderSource();
shaderSource.Language = RenderingDevice.ShaderLanguage.Glsl;
shaderSource.SourceCompute = newShaderCode;
RDShaderSpirV shaderSpirV = _rd.ShaderCompileSpirVFromSource(shaderSource);
if (shaderSpirV.CompileErrorCompute != "")
{
GD.PushError(shaderSpirV.CompileErrorCompute);
GD.PushError("In: " + newShaderCode);
return false;
}
_shader = _rd.ShaderCreateFromSpirV(shaderSpirV);
if (!_shader.IsValid)
{
return false;
}
_pipeline = _rd.ComputePipelineCreate(_shader);
return _pipeline.IsValid;
}
No topo deste método, usamos novamente o nosso mutex para proteger o acesso ao código de shader do usuário e à nossa flag de modificado (is dirty). Fazemos uma cópia local do código de shader do usuário se o código de shader do usuário estiver marcado como modificado.
Se não tivermos um novo fragmento de código, retornamos verdadeiro se já tivermos um pipeline válido.
Se tivermos um novo fragmento de código, nós o incorporamos em nosso código de modelo e então o compilamos.
Aviso
O código mostrado aqui compila nosso novo código em tempo de execução. Isso é ótimo para prototipagem, pois podemos ver imediatamente o efeito do shader alterado.
Isso evita o pré-carregamento e o cache desse shader, o que pode ser um problema em algumas plataformas, como consoles. Note que o projeto de demonstração vem com um exemplo alternativo onde um arquivo glsl contém todo o compute shader e este é usado. O Godot é capaz de pré-compilar e fazer o cache do shader com essa abordagem.
Por fim, precisamos implementar nosso callback de efeito; o motor de renderização chamará isso no estágio correto da renderização.
# 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()
// Called by the rendering thread every frame.
public override void _RenderCallback(int effectCallbackType, RenderData renderData)
{
if (_rd is not null && effectCallbackType == (int)EffectCallbackTypeEnum.PostTransparent && CheckShader())
{
// 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.
RenderSceneBuffersRD renderSceneBuffers = renderData.GetRenderSceneBuffers() as RenderSceneBuffersRD;
if (renderSceneBuffers is not null)
{
// Get our render size, this is the 3D resolution!
var size = renderSceneBuffers.GetInternalSize();
if (size.X == 0 && size.Y == 0)
{
return;
}
// We can use a compute shader here.
uint xGroups = (uint)((size.X - 1) / 8 + 1);
uint yGroups = (uint)((size.Y - 1) / 8 + 1);
uint zGroups = 1;
// Push Constant.
float[] tempPushConstant = [size.X, size.Y, 0, 0];
byte[] pushConstant = new byte[tempPushConstant.Length * sizeof(float)];
Buffer.BlockCopy(tempPushConstant, 0, pushConstant, 0, pushConstant.Length);
// Loop through views just in case we're doing stereo rendering. No extra cost if this is mono.
var viewCount = renderSceneBuffers.GetViewCount();
for (uint view = 0; view < viewCount; view++)
{
// Get the RID for our color image, we will be reading from and writing to it.
var inputImage = renderSceneBuffers.GetColorLayer(view);
// Create a uniform set.
// This will be cached; the cache will be cleared if our viewport's configuration is changed.
RDUniform uniform = new RDUniform()
{
UniformType = RenderingDevice.UniformType.Image,
Binding = 0,
};
uniform.AddId(inputImage);
var uniformSet = UniformSetCacheRD.GetCache(_shader, 0, [uniform]);
// Run our compute shader.
var computeList = _rd.ComputeListBegin();
_rd.ComputeListBindComputePipeline(computeList, _pipeline);
_rd.ComputeListBindUniformSet(computeList, uniformSet, 0);
_rd.ComputeListSetPushConstant(computeList, pushConstant, (uint)pushConstant.Length);
_rd.ComputeListDispatch(computeList, xGroups, yGroups, zGroups);
_rd.ComputeListEnd();
}
}
}
No início deste método, verificamos se temos um dispositivo de renderização, se nosso tipo de callback é o correto e se temos nosso shader.
Nota
A verificação do tipo de efeito é apenas um mecanismo de segurança. Definimos isso em nossa função _init, no entanto, é possível para o usuário alterar isso na UI.
Nosso parâmetro p_render_data nos dá acesso a um objeto que contém dados específicos do quadro que estamos renderizando atualmente. No momento, estamos interessados apenas em nossos buffers de cena de renderização (render scene buffers), que nos fornecem acesso a todos os buffers internos usados pelo motor de renderização. Note que fazemos o cast disso para RenderSceneBuffersRD para expor a API completa a esses dados.
Em seguida, obtemos nosso internal size, que é a resolução dos nossos buffers de renderização 3D antes de serem upscaled (se aplicável), o upscaling acontece após a execução dos nossos processos de pós-renderização.
A partir do nosso tamanho interno, calculamos o tamanho do nosso grupo; veja nosso tamanho local em nosso shader de modelo.
Nós também preenchemos nossa push constant para que nosso shader conheça o nosso tamanho. O Godot ainda não suporta structs aqui, então usamos um PackedFloat32Array para armazenar esses dados. Note que temos que preencher (pad) este array com um alinhamento de 16 bytes. Em outras palavras, o tamanho do nosso array precisa ser um múltiplo de 4.
Agora percorremos nossas views em um loop; isso é para o caso de estarmos usando renderização multiview, que é aplicável para renderização estéreo (XR). Na maioria dos casos, teremos apenas uma view.
Nota
Não há benefício de desempenho em usar multiview para pós-processamento aqui; tratar as views separadamente assim ainda permitirá que a GPU use paralelismo se for benéfico.
Em seguida, obtemos o buffer de cor para esta view. Este é o buffer no qual nossa cena 3D foi renderização.
Preparamos então um conjunto uniforme (uniform set) para que possamos comunicar o buffer de cor ao nosso shader.
Note o uso do cache do nosso UniformSetCacheRD, que garante que possamos verificar nosso conjunto uniforme a cada quadro. Como nosso buffer de cor pode mudar de quadro para quadro e nosso cache uniforme limpará automaticamente os conjuntos uniformes quando os buffers forem liberados, esta é a maneira segura de garantir que não vazemos memória ou usemos um conjunto desatualizado.
Finalmente, construímos nossa lista de computação vinculando nosso pipeline, vinculando nosso conjunto uniforme, enviando nossos dados de constante push e chamando o dispatch para nossos grupos.
Com nosso efeito de compositor concluído, agora precisamos adicioná-lo ao nosso compositor.
Em nosso compositor, expandimos a propriedade de efeitos do compositor e pressionamos Add Element.
Agora podemos adicionar nosso efeito de compositor:
Após selecionar nosso PostProcessShader, precisamos definir nosso código de shader de usuário:
float gray = color.r * 0.2125 + color.g * 0.7154 + color.b * 0.0721;
color.rgb = vec3(gray);
Com tudo pronto, nossa saída fica em escala de cinza.
Nota
Para um exemplo mais avançado de pós-efeitos, confira o projeto de demonstração Radial blur based sky rays criado por Bastiaan Olij.