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...
Usando sombreadores(shaders) de computação
Este tutorial guiará você pelo processo de criação de um compute shader mínimo. Mas antes, um pouco de contextualização sobre compute shaders e como eles funcionam com o Godot.
Nota
Este tutorial assume que você está familiarizado com shaders em geral. Se você é novo em shaders, por favor leia Introdução aos shaders e your first shader antes de prosseguir com este tutorial.
Um compute shader é um tipo especial de programa de shader que é orientado para programação de propósito geral. Em outras palavras, eles são mais flexíveis que vertex shaders e fragment shaders, pois não possuem um propósito fixo (ou seja, transformar vértices ou escrever cores em uma imagem). Ao contrário de fragment shaders e vertex shaders, os compute shaders têm muito pouca coisa acontecendo nos bastidores. O código que você escreve é o que a GPU executa e muito pouco além disso. Isso pode torná-los uma ferramenta muito útil para descarregar cálculos pesados para a GPU.
Agora vamos começar criando um pequeno compute shader.
Primeiro, no editor de texto externo de sua preferência, crie um novo arquivo chamado compute_example.glsl na pasta do seu projeto. Quando você escreve compute shaders no Godot, você os escreve diretamente em GLSL. A linguagem de shader do Godot é baseada em GLSL. Se você está familiarizado com shaders normais no Godot, a sintaxe abaixo parecerá um tanto familiar.
Nota
Compute shaders só podem ser usados a partir de renderizadores baseados em RenderingDevice (o renderizador Forward+ ou Mobile). Para acompanhar este tutorial, certifique-se de que está usando o renderizador Forward+ ou Mobile. A configuração para isso está localizada no canto superior direito do editor.
Note que o suporte a compute shaders geralmente é ruim em dispositivos móveis (devido a bugs de driver), mesmo que eles sejam tecnicamente suportados.
Vamos dar uma olhada neste código de compute shader:
#[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;
}
Este código pega uma matriz (array) de floats, multiplica cada elemento por 2 e armazena os resultados de volta na matriz de buffer. Agora vamos analisá-lo linha por linha.
#[compute]
#version 450
Estas duas linhas comunicam duas coisas:
O código a seguir é um compute shader. Este é um aviso específico do Godot necessário para que o editor importe corretamente o arquivo de shader.
O código está usando a versão 450 do GLSL.
Você nunca deve precisar alterar essas duas linhas para seus compute shaders personalizados.
// Invocations in the (x, y, z) dimension
layout(local_size_x = 2, local_size_y = 1, local_size_z = 1) in;
Em seguida, comunicamos o número de invocações a serem usadas em cada grupo de trabalho (workgroup). Invocações são instâncias do shader que estão executando dentro do mesmo grupo de trabalho. Quando lançamos um compute shader a partir da CPU, dizemos a ele quantos grupos de trabalho executar. Os grupos de trabalho funcionam em paralelo uns aos outros. Enquanto executa um grupo de trabalho, você não pode acessar informações em outro grupo de trabalho. No entanto, invocações no mesmo grupo de trabalho podem ter algum acesso limitado a outras invocações.
Pense nos grupos de trabalho e invocações como um loop for aninhado gigante.
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.
}
}
}
}
}
}
Grupos de trabalho e invocações são um tópico avançado. Por enquanto, lembre-se de que executaremos duas invocações por grupo de trabalho.
// A binding to the buffer we create in our script
layout(set = 0, binding = 0, std430) restrict buffer MyDataBuffer {
float data[];
}
my_data_buffer;
Aqui fornecemos informações sobre a memória à qual o compute shader terá acesso. A propriedade layout nos permite dizer ao shader onde procurar pelo buffer; precisaremos corresponder a essas posições de set e binding pelo lado da CPU mais tarde.
A palavra-chave restrict diz ao shader que este buffer só será acessado a partir de um lugar neste shader. Em outras palavras, não vincularemos este buffer em outro índice de set ou binding. Isso é importante pois permite que o compilador de shader otimize o código do shader. Sempre use restrict quando puder.
Este é um buffer sem tamanho definido (unsized), o que significa que ele pode ter qualquer tamanho. Portanto, precisamos ter cuidado para não ler de um índice maior que o tamanho do 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;
}
Finalmente, escrevemos a função main, que é onde toda a lógica acontece. Acessamos uma posição no buffer de armazenamento usando as variáveis integradas gl_GlobalInvocationID. O gl_GlobalInvocationID fornece o ID global exclusivo para a invocação atual.
Para continuar, escreva o código acima em seu arquivo compute_example.glsl recém-criado.
Criar um RenderingDevice local
Para interagir com e executar um compute shader, precisamos de um script. Crie um novo script na linguagem de sua escolha e anexe-o a qualquer Node em sua cena.
Agora, para executar nosso shader, precisamos de um RenderingDevice local que pode ser criado usando o RenderingServer:
# Create a local rendering device.
var rd := RenderingServer.create_local_rendering_device()
// Create a local rendering device.
var rd = RenderingServer.CreateLocalRenderingDevice();
Depois disso, podemos carregar o arquivo de shader recém-criado compute_example.glsl e criar uma versão pré-compilada dele usando isto:
# 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)
// Load GLSL shader
var shaderFile = GD.Load<RDShaderFile>("res://compute_example.glsl");
var shaderBytecode = shaderFile.GetSpirV();
var shader = rd.ShaderCreateFromSpirV(shaderBytecode);
Aviso
RenderingDevices locais não podem ser depurados usando ferramentas como o RenderDoc.
Fornecer dados de entrada
Como você deve se lembrar, queremos passar uma matriz de entrada para o nosso shader, multiplicar cada elemento por 2 e obter os resultados.
Precisamos criar um buffer para passar valores para um compute shader. Estamos lidando com uma matriz de floats, então usaremos um buffer de armazenamento (storage buffer) para este exemplo. Um buffer de armazenamento pega uma matriz de bytes e permite que a CPU transfira dados de e para la GPU.
Então vamos inicializar uma matriz de floats e criar um buffer de armazenamento:
# 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)
// Prepare our data. We use floats in the shader, so we need 32 bit.
float[] input = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
var inputBytes = new byte[input.Length * sizeof(float)];
Buffer.BlockCopy(input, 0, inputBytes, 0, inputBytes.Length);
// 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.StorageBufferCreate((uint)inputBytes.Length, inputBytes);
Com o buffer pronto, precisamos dizer ao dispositivo de renderização para usar este buffer. Para fazer isso, precisaremos criar um uniform (como em shaders normais) e atribuí-lo a um conjunto de uniforms (uniform set) que poderemos passar para o nosso shader mais tarde.
# 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
// Create a uniform to assign the buffer to the rendering device
var uniform = new RDUniform
{
UniformType = RenderingDevice.UniformType.StorageBuffer,
Binding = 0
};
uniform.AddId(buffer);
var uniformSet = rd.UniformSetCreate([uniform], shader, 0);
Definindo uma pipeline de computação
O próximo passo é criar um conjunto de instruções que nossa GPU possa executar. Precisamos de uma pipeline e de uma lista de computação (compute list) para isso.
Os passos que precisamos fazer para computar nosso resultado são:
Crie um novo pipeline.
Iniciar uma lista de instruções para nossa GPU executar.
Vincular nossa lista de computação à nossa pipeline
Vincular nosso uniform de buffer à nossa pipeline
Especificar quantos grupos de trabalho usar
Finalize a lista de instruções
# 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()
// Create a compute pipeline
var pipeline = rd.ComputePipelineCreate(shader);
var computeList = rd.ComputeListBegin();
rd.ComputeListBindComputePipeline(computeList, pipeline);
rd.ComputeListBindUniformSet(computeList, uniformSet, 0);
rd.ComputeListDispatch(computeList, xGroups: 5, yGroups: 1, zGroups: 1);
rd.ComputeListEnd();
Note que estamos despachando o compute shader com 5 grupos de trabalho no eixo X, e um nos outros. Como temos 2 invocações locais no eixo X (especificado em nosso shader), 10 invocações de compute shader serão lançadas no total. Se você ler ou escrever em índices fora do intervalo do seu buffer, você pode acessar memória fora do controle do seu shader ou partes de outras variáveis, o que pode causar problemas em alguns hardwares.
Executar um compute shader
Depois de tudo isso, estamos quase terminando, mas ainda precisamos executar nossa pipeline. Até agora, apenas registramos o que gostaríamos que a GPU fizesse; não executamos realmente o programa de shader.
Para executar nosso compute shader, precisamos enviar a pipeline para a GPU e esperar que a execução termine:
# Submit to GPU and wait for sync
rd.submit()
rd.sync()
// Submit to GPU and wait for sync
rd.Submit();
rd.Sync();
Idealmente, você não chamaria sync() para sincronizar o RenderingDevice imediatamente, pois isso fará com que a CPU espere a GPU terminar o trabalho. No nosso exemplo, sincronizamos imediatamente porque queremos nossos dados disponíveis para leitura logo em seguida. Em geral, você desejará esperar pelo menos 2 ou 3 frames antes de sincronizar para que a GPU possa rodar em paralelo com la CPU.
Aviso
Computações longas podem fazer com que os drivers de vídeo do Windows "travem" devido ao gatilho do TDR pelo Windows. Este é um mecanismo que reinicializa o driver de vídeo após um certo período de tempo sem qualquer atividade do driver (geralmente de 5 a 10 segundos).
Dependendo da duração que o seu compute shader leva para executar, pode ser necessário dividi-lo em múltiplos despachos (dispatches) para reduzir o tempo que cada despacho leva e diminuir as chances de acionar um TDR. Como o TDR depende do tempo, GPUs mais lentas podem ser mais propensas a TDRs ao executar um determinado compute shader em comparação com uma GPU mais rápida.
Recuperando resultados
Você deve ter notado que, no shader de exemplo, modificamos o conteúdo do buffer de armazenamento. Em outras palavras, o shader leu da nossa matriz e armazenou os dados na mesma matriz novamente, de modo que nossos resultados já estão lá. Vamos recuperar os dados e imprimir os resultados no nosso 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)
// Read back the data from the buffers
var outputBytes = rd.BufferGetData(buffer);
var output = new float[input.Length];
Buffer.BlockCopy(outputBytes, 0, output, 0, outputBytes.Length);
GD.Print("Input: ", string.Join(", ", input));
GD.Print("Output: ", string.Join(", ", output));
Liberando memória
As variáveis buffer, pipeline e uniform_set que estivemos usando são, cada uma, um RID. Como o RenderingDevice foi feito para ser uma API de nível mais baixo, os RIDs não são liberados automaticamente. Isso significa que, assim que terminar de usar o buffer ou qualquer outro RID, você é responsável por liberar sua memória manualmente usando o método free_rid() do RenderingDevice.
Com isso, você tem tudo o que precisa para começar a trabalhar com compute shaders.
Ver também
O repositório de projetos de demonstração contém uma Demonstração de Heightmap com Compute Shader. Este projeto realiza a geração de imagens de heightmap na CPU e na GPU separadamente, o que permite comparar como um algoritmo semelhante pode ser implementado de duas maneiras diferentes (sendo a implementação na GPU mais rápida na maioria dos casos).