Utilizando Compute Shaders no Unity para Computação Paralela na GPU

Compute Shaders oferecem uma capacidade excepcional para executar cálculos massivamente paralelos diretamente na GPU, operando fora do pipeline de renderização gráfico convencional. Essa abordagem permite descarregar tarefas computacionalmente intensivas da CPU, explorando a arquitetura paralela da GPU para algoritmos de GPGPU (General-Purpose computing on Graphics Processing Units) e liberando o processador principal para outras operações.

Fundamentos de um Compute Shader

A seguir, um exemplo simplificado de um Compute Shader que preenche uma textura com uma cor uniforme:

#pragma kernel InitializeTextureColor

RWTexture2D<float4> outputSurface;

[numthreads(8, 8, 1)]
void InitializeTextureColor (uint3 threadCoords : SV_DispatchThreadID)
{
    outputSurface[threadCoords.xy] = float4(0,1,0,1); // Verde
}

Vamos detalhar os componentes chave deste código:

  • #pragma kernel InitializeTextureColor: Declara a função principal do shader, conhecida como kernel, que será executada na GPU. Aqui, o nome do kernel é InitializeTextureColor.
  • RWTexture2D<float4> outputSurface;: Define um objeto Texture2D que permite tanto leitura quanto escrita (RW significa Read-Write). Este objeto é o destino para os resultados dos cálculos do Compute Shader. No lado do C#, um objeto RenderTexture configurado para escrita aleatória é geralmente associado a este RWTexture2D através do método ComputeShader.SetTexture. Após a execução do shader, as alterações serão refletidas no RenderTexture do C#.
  • [numthreads(8, 8, 1)]: Especifica a dimensão do grupo de threads. Um grupo de threads é a menor unidade de trabalho que o shader pode despachar. Neste caso, cada grupo contém 8x8x1 = 64 threads. Ao despachar o Compute Shader do C#, especificamos o número de grupos de threads, e o sistema então lança o número total de threads (grupos * numthreads) na GPU.
  • void InitializeTextureColor (uint3 threadCoords : SV_DispatchThreadID): A função kernel. O parâmetro SV_DispatchThreadID é um identificador exclusivo para cada thread que está sendo executada. threadCoords.xy pode ser usado como um índice de pixel para a outputSurface.
  • outputSurface[threadCoords.xy] = float4(0,1,0,1);: Atribui um valor de cor (verde puro) ao pixel correspondente à posição da thread na textura. O identificador threadCoords.xy representa as coordenadas de pixel na textura (não coordenadas normalizadas [0,1]).

Aplicação de Compute Shaders

Exploremos agora duas abordagens comuns para usar Compute Shaders: salvando resultados em texturas e salvando em buffers estruturados.

Salvando Resultados em uma Textura

Neste cenário, o Compute Shader gera um padrão ou imagem que é armazenado em uma RenderTexture, que pode ser então aplicada a um material em um objeto 3D.

Código C# (Exemplo Básico)

using UnityEngine;

public class TextureComputeExample : MonoBehaviour
{
    public ComputeShader computeProgram;
    public Material targetMaterial;
    public int textureDimension = 1024;

    private RenderTexture outputRenderTexture;
    private int kernelIndex;

    void Start()
    {
        InitializeTextureResources();
        DispatchTextureComputation();
        ApplyTextureToMaterial();
    }

    void InitializeTextureResources()
    {
        if (outputRenderTexture != null)
        {
            outputRenderTexture.Release();
        }

        outputRenderTexture = new RenderTexture(textureDimension, textureDimension, 0, RenderTextureFormat.ARGB32);
        outputRenderTexture.enableRandomWrite = true;
        outputRenderTexture.Create();

        kernelIndex = computeProgram.FindKernel("GeneratePatternTexture");
        computeProgram.SetTexture(kernelIndex, "processedTexture", outputRenderTexture);
    }

    void DispatchTextureComputation()
    {
        // O HLSL usa [numthreads(16, 16, 1)]
        // Para uma textura de 1024x1024, precisamos (1024/16) x (1024/16) grupos de threads
        int threadGroupX = textureDimension / 16;
        int threadGroupY = textureDimension / 16;
        computeProgram.Dispatch(kernelIndex, threadGroupX, threadGroupY, 1);
    }

    void ApplyTextureToMaterial()
    {
        if (targetMaterial != null)
        {
            targetMaterial.SetTexture("_MainTex", outputRenderTexture);
        }
    }

    void OnDisable()
    {
        if (outputRenderTexture != null)
        {
            outputRenderTexture.Release();
        }
    }
}

Código HLSL

#pragma kernel GeneratePatternTexture

RWTexture2D<float4> processedTexture;

[numthreads(16, 16, 1)]
void GeneratePatternTexture (uint3 threadIndex : SV_DispatchThreadID)
{
    uint texWidth, texHeight;
    processedTexture.GetDimensions(texWidth, texHeight);

    // Cria um padrão radial
    float centerX = texWidth / 2.0f;
    float centerY = texHeight / 2.0f;
    float dist = distance(float2(threadIndex.x, threadIndex.y), float2(centerX, centerY));
    float normalizedDist = dist / (min(texWidth, texHeight) / 2.0f);

    float red = sin(normalizedDist * 10.0f) * 0.5f + 0.5f;
    float blue = cos(normalizedDist * 10.0f) * 0.5f + 0.5f;

    processedTexture[threadIndex.xy] = float4(red, 0.2f, blue, 1.0f);
}

Observações importantes:

  • O RenderTexture em C# deve ter enableRandomWrite = true e ser explicitamente criado com Create().
  • A combinação de computeProgram.Dispatch(kernelIndex, 64, 64, 1); e [numthreads(16,16,1)] no shader resulta em um total de (64 * 16) x (64 * 16) = 1024 x 1024 threads, cobrindo cada pixel da textura.

Salvando Resultados em um Buffer Estruturado

A gravação em buffers oferece mais flexibilidade, pois permite armazenar estruturas de dados personalizadas. Este exemplo demonstra como inicializar dados de objetos na CPU, passá-los para a GPU para transformação e, em seguida, recuperar os resultados para atualizar objetos na cena.

A ideia é simular 100 objetos, gerar matrizes de transformação iniciais na CPU e, em seguida, usar o Compute Shader para aplicar transformações ou alterar propriedades desses objetos, recebendo os dados atualizados de volta na CPU.

Fluxo Geral:

  1. Definir uma estrutura de dados (struct) em C# e HLSL, garantindo que o layout de memória seja compatível.
  2. Criar um ComputeBuffer em C# e preenchê-lo com os dados iniciais.
  3. Passar o ComputeBuffer para o Compute Shader usando ComputeShader.SetBuffer.
  4. No Compute Shader, processar os dados do buffer.
  5. Recuperar os dados processados do ComputeBuffer para o C# usando ComputeBuffer.GetData.

Código C# (Exemplo Básico)

using UnityEngine;

public class BufferComputeExample : MonoBehaviour
{
    public ComputeShader computeProgram;
    public Transform prefabObject;

    private const int MaxElements = 100;
    private ComputeBuffer dataComputeBuffer;
    private ObjectPayload[] elementDataArray;
    private Transform[] instantiatedObjects;

    private int kernelIndex;
    private const int ElementStride = sizeof(float) * (4 + 3 + 16); // Vector4 + Vector3 + Matrix4x4

    private struct ObjectPayload
    {
        public Vector4 currentPosition;
        public Vector3 currentScale;
        public Matrix4x4 transformationMatrix;
    }

    void Awake()
    {
        kernelIndex = computeProgram.FindKernel("ApplyTransformationsToElements");
        instantiatedObjects = new Transform[MaxElements];
        for (int i = 0; i < MaxElements; i++)
        {
            instantiatedObjects[i] = Instantiate(prefabObject, transform);
        }
    }

    void OnGUI()
    {
        if (GUI.Button(new Rect(0, 150, 200, 50), "Executar GPU"))
        {
            ExecuteShaderOperations();
        }

        if (GUI.Button(new Rect(210, 150, 200, 50), "Obter Resultados"))
        {
            RetrieveResultsAndApply();
        }
    }

    void ExecuteShaderOperations()
    {
        if (dataComputeBuffer != null)
        {
            dataComputeBuffer.Release();
        }

        PrepareInitialData();

        dataComputeBuffer = new ComputeBuffer(elementDataArray.Length, ElementStride);
        dataComputeBuffer.SetData(elementDataArray);
        computeProgram.SetBuffer(kernelIndex, "structuredDataBuffer", dataComputeBuffer);

        // HLSL usa [numthreads(8, 1, 1)]
        // Para 100 elementos, precisamos de (100 / 8) = 13 grupos de threads (arredondado para cima)
        // Isso garante que todas as threads sejam processadas.
        int numGroupsX = Mathf.CeilToInt((float)MaxElements / 8);
        computeProgram.Dispatch(kernelIndex, numGroupsX, 1, 1);
    }

    void RetrieveResultsAndApply()
    {
        if (dataComputeBuffer == null) return;

        dataComputeBuffer.GetData(elementDataArray);

        for (int i = 0; i < MaxElements; i++)
        {
            instantiatedObjects[i].localPosition = elementDataArray[i].currentPosition;
            instantiatedObjects[i].localScale = elementDataArray[i].currentScale;
        }
    }

    void PrepareInitialData()
    {
        if (elementDataArray == null || elementDataArray.Length != MaxElements)
        {
            elementDataArray = new ObjectPayload[MaxElements];
        }

        const float positionRange = 10f;
        for (int i = 0; i < MaxElements; i++)
        {
            elementDataArray[i].currentPosition = new Vector4(Random.Range(-positionRange, positionRange),
                                                            Random.Range(-positionRange, positionRange),
                                                            Random.Range(-positionRange, positionRange),
                                                            1f);
            elementDataArray[i].currentScale = Vector3.one * Random.Range(0.5f, 2f);

            // Cria uma matriz de transformação inicial (ex: translação e rotação)
            Matrix4x4 initialMatrix = Matrix4x4.TRS(
                new Vector3(Random.value * 2 - 1, Random.value * 2 - 1, Random.value * 2 - 1) * 2,
                Quaternion.Euler(Random.value * 360, Random.value * 360, Random.value * 360),
                Vector3.one // Scale will be handled by currentScale
            );
            elementDataArray[i].transformationMatrix = initialMatrix;
        }
    }

    void OnDisable()
    {
        if (dataComputeBuffer != null)
        {
            dataComputeBuffer.Release();
        }
    }
}

Código HLSL

#pragma kernel ApplyTransformationsToElements

struct ElementData
{
    float4 currentPosition;
    float3 currentScale;
    float4x4 transformationMatrix;
};
RWStructuredBuffer<ElementData> structuredDataBuffer;

[numthreads(8, 1, 1)]
void ApplyTransformationsToElements (uint3 elementId : SV_DispatchThreadID)
{
    ElementData dataToProcess = structuredDataBuffer[elementId.x];

    // Aplica uma pequena translação baseada na matriz de transformação
    dataToProcess.currentPosition.xyz += dataToProcess.transformationMatrix[0].xyz * 0.005f; // Move ligeiramente no eixo X da matriz
    dataToProcess.currentPosition.xyz += dataToProcess.transformationMatrix[1].xyz * 0.005f; // Move ligeiramente no eixo Y da matriz
    dataToProcess.currentPosition.xyz += dataToProcess.transformationMatrix[2].xyz * 0.005f; // Move ligeiramente no eixo Z da matriz

    // Aumenta gradualmente a escala
    dataToProcess.currentScale *= 1.01f;

    structuredDataBuffer[elementId.x] = dataToProcess;
}

Neste exemplo de buffer, o HLSL usa [numthreads(8,1,1)]. Se tivermos 100 objetos, precisamos despachar ceil(100/8) = 13 grupos de threads na direção X. Cada thread é responsável por processar um elemento do buffer.

Código Completo de Exemplo (C# e HLSL)

Aqui, combinamos as duas funcionalidades em um único script C# e um arquivo de shader, permitindo alternar entre o modo de textura e buffer.

Script C#

using System;
using UnityEngine;
using Random = UnityEngine.Random;

public class GPUCalculationManager : MonoBehaviour
{
    public ComputeShader computeShaderProgram;
    public CalculationMode currentMode;
    public Transform objectPrefab;

    // Nomes dos kernels
    private const string TextureKernelName = "GeneratePatternTexture";
    private const string BufferKernelName = "ApplyTransformationsToElements";

    // Variáveis para o modo de textura
    private RenderTexture outputRenderTexture;
    private const int TextureSize = 1024;
    private Material textureTargetMaterial;
    private Transform singleObjectInstance;

    // Variáveis para o modo de buffer
    private const int MaxDynamicObjects = 100;
    private ComputeBuffer elementComputeBuffer;
    private ObjectPayload[] objectDataArray;
    private Transform[] dynamicObjectInstances;

    public enum CalculationMode : int
    {
        RenderTextureOutput = 0,
        ComputeBufferOutput = 1,
    }

    // Estrutura de dados para o ComputeBuffer, correspondente ao HLSL
    private struct ObjectPayload
    {
        public Vector4 currentPosition;
        public Vector3 currentScale;
        public Matrix4x4 transformationMatrix;
    }

    // Tamanho em bytes da estrutura de dados
    private const int PayloadStride = sizeof(float) * (4 + 3 + 16);

    void Awake()
    {
        switch (currentMode)
        {
            case CalculationMode.RenderTextureOutput:
                singleObjectInstance = Instantiate(objectPrefab);
                singleObjectInstance.position = Vector3.zero;
                singleObjectInstance.localScale = Vector3.one * 5;
                MeshRenderer renderer = singleObjectInstance.GetComponent<MeshRenderer>();
                if (renderer != null)
                {
                    textureTargetMaterial = renderer.material;
                }
                break;

            case CalculationMode.ComputeBufferOutput:
                GameObject parentContainer = new GameObject("DynamicObjects");
                parentContainer.transform.position = Vector3.zero;
                dynamicObjectInstances = new Transform[MaxDynamicObjects];
                for (int i = 0; i < MaxDynamicObjects; i++)
                {
                    Transform obj = Instantiate(objectPrefab);
                    obj.transform.SetParent(parentContainer.transform);
                    obj.transform.localPosition = Vector3.zero;
                    obj.transform.localScale = Vector3.one;
                    dynamicObjectInstances[i] = obj;
                }
                break;
        }
    }

    void OnGUI()
    {
        if (GUI.Button(new Rect(0, 0, 200, 50), "Despachar Kernel"))
        {
            PerformGPUDirective();
        }

        if (GUI.Button(new Rect(210, 0, 200, 50), "Aplicar Resultados"))
        {
            RetrieveAndApplyResults();
        }
    }

    void PerformGPUDirective()
    {
        if (computeShaderProgram == null)
        {
            Debug.LogError("Compute Shader não atribuído.");
            return;
        }

        int kernelId = -1;
        try
        {
            kernelId = computeShaderProgram.FindKernel(GetKernelName(currentMode));
        }
        catch (Exception error)
        {
            Debug.LogErrorFormat("Erro ao encontrar kernel: {0}", error.Message);
            return;
        }

        switch (currentMode)
        {
            case CalculationMode.RenderTextureOutput:
                if (outputRenderTexture != null)
                {
                    outputRenderTexture.Release();
                }

                outputRenderTexture = new RenderTexture(TextureSize, TextureSize, 0, RenderTextureFormat.ARGB32);
                outputRenderTexture.enableRandomWrite = true;
                outputRenderTexture.Create();
                computeShaderProgram.SetTexture(kernelId, "processedTexture", outputRenderTexture);

                // HLSL [numthreads(16,16,1)], para 1024x1024 pixels
                computeShaderProgram.Dispatch(kernelId, TextureSize / 16, TextureSize / 16, 1);
                break;

            case CalculationMode.ComputeBufferOutput:
                if (elementComputeBuffer != null)
                {
                    elementComputeBuffer.Release();
                }

                InitializeObjectData();

                elementComputeBuffer = new ComputeBuffer(objectDataArray.Length, PayloadStride);
                elementComputeBuffer.SetData(objectDataArray);
                computeShaderProgram.SetBuffer(kernelId, "structuredDataBuffer", elementComputeBuffer);

                // HLSL [numthreads(8,1,1)], para MaxDynamicObjects
                int dispatchX = Mathf.CeilToInt((float)MaxDynamicObjects / 8);
                computeShaderProgram.Dispatch(kernelId, dispatchX, 1, 1);
                break;
        }
    }

    void RetrieveAndApplyResults()
    {
        switch (currentMode)
        {
            case CalculationMode.RenderTextureOutput:
                if (textureTargetMaterial != null)
                {
                    textureTargetMaterial.SetTexture("_MainTex", outputRenderTexture);
                }
                break;

            case CalculationMode.ComputeBufferOutput:
                if (elementComputeBuffer == null || dynamicObjectInstances == null || objectDataArray == null)
                {
                    Debug.LogWarning("Buffer ou instâncias de objeto não inicializadas.");
                    break;
                }

                elementComputeBuffer.GetData(objectDataArray);

                for (int i = 0; i < MaxDynamicObjects; i++)
                {
                    dynamicObjectInstances[i].localPosition = objectDataArray[i].currentPosition;
                    dynamicObjectInstances[i].localScale = objectDataArray[i].currentScale;
                }
                break;
        }
    }

    // Inicializa os dados para a GPU
    void InitializeObjectData()
    {
        if (objectDataArray == null || objectDataArray.Length != MaxDynamicObjects)
        {
            objectDataArray = new ObjectPayload[MaxDynamicObjects];
        }

        const float spatialRange = 10f;
        for (int i = 0; i < MaxDynamicObjects; i++)
        {
            objectDataArray[i].currentPosition = new Vector4(Random.Range(-spatialRange, spatialRange),
                                                            Random.Range(-spatialRange, spatialRange),
                                                            Random.Range(-spatialRange, spatialRange),
                                                            1f);
            objectDataArray[i].currentScale = Vector3.one * Random.Range(0.5f, 2f);

            Matrix4x4 initialMatrix = Matrix4x4.TRS(
                new Vector3(Random.value * 2 - 1, Random.value * 2 - 1, Random.value * 2 - 1) * 2,
                Quaternion.Euler(Random.value * 360, Random.value * 360, Random.value * 360),
                Vector3.one
            );
            objectDataArray[i].transformationMatrix = initialMatrix;
        }
    }

    string GetKernelName(CalculationMode mode)
    {
        switch (mode)
        {
            case CalculationMode.RenderTextureOutput: return TextureKernelName;
            case CalculationMode.ComputeBufferOutput: return BufferKernelName;
            default: return "";
        }
    }

    void OnDisable()
    {
        if (elementComputeBuffer != null)
        {
            elementComputeBuffer.Release();
        }
        if (outputRenderTexture != null)
        {
            outputRenderTexture.Release();
        }
    }
}

Arquivo de Shader (HLSL)

// Cada #kernel indica qual função compilar; você pode ter vários kernels
#pragma kernel GeneratePatternTexture
#pragma kernel ApplyTransformationsToElements

// Para o modo de textura
RWTexture2D<float4> processedTexture;

// Estrutura de dados para o modo de buffer
struct ElementData
{
    float4 currentPosition;
    float3 currentScale;
    float4x4 transformationMatrix;
};
RWStructuredBuffer<ElementData> structuredDataBuffer;

// Kernel para geração de textura
[numthreads(16,16,1)]
void GeneratePatternTexture (uint3 threadIndex : SV_DispatchThreadID)
{
    uint texWidth, texHeight;
    processedTexture.GetDimensions(texWidth, texHeight);
    
    float centerX = texWidth / 2.0f;
    float centerY = texHeight / 2.0f;
    float dist = distance(float2(threadIndex.x, threadIndex.y), float2(centerX, centerY));
    float normalizedDist = dist / (min(texWidth, texHeight) / 2.0f);

    float red = sin(normalizedDist * 10.0f) * 0.5f + 0.5f;
    float blue = cos(normalizedDist * 10.0f) * 0.5f + 0.5f;

    processedTexture[threadIndex.xy] = float4(red, 0.2f, blue, 1.0f);
}

// Kernel para transformação de elementos em buffer
[numthreads(8,1,1)]
void ApplyTransformationsToElements (uint3 elementId : SV_DispatchThreadID)
{
    ElementData dataToProcess = structuredDataBuffer[elementId.x];

    dataToProcess.currentPosition.xyz += dataToProcess.transformationMatrix[0].xyz * 0.005f;
    dataToProcess.currentPosition.xyz += dataToProcess.transformationMatrix[1].xyz * 0.005f;
    dataToProcess.currentPosition.xyz += dataToProcess.transformationMatrix[2].xyz * 0.005f;

    dataToProcess.currentScale *= 1.01f;

    structuredDataBuffer[elementId.x] = dataToProcess;
}

Tags: ComputeShader Unity3D GPGPU HLSL GPUProgramming

Publicado em 6-5 21:11 por Thomas