Compartilhar via


Trabalhar com sombreadores e recursos de sombreador

É hora de aprender a trabalhar com sombreadores e recursos de sombreador no desenvolvimento do seu jogo do Microsoft DirectX para Windows 8. Vimos como configurar o dispositivo gráfico e os recursos, e talvez você até tenha começado a modificar seu pipeline. Então, agora vamos examinar os sombreadores de pixel e vértice.

Se você não estiver familiarizado com idiomas de sombreador, vamos falar um pouco sobre isso. Sombreadores são pequenos programas de baixo nível que são compilados e executados em estágios específicos no pipeline gráfico. Sua especialidade são operações matemáticas de ponto flutuante muito rápidas. Os programas de sombreador mais comuns são:

  • Sombreador de vértice - executado para cada vértice em uma cena. Esse sombreador opera em elementos de buffer de vértice fornecidos pelo aplicativo de chamada e resulta minimamente em um vetor de posição de 4 componentes que será rasterizado em uma posição de pixel.
  • Sombreador de pixel - executado para cada pixel em um destino de renderização. Esse sombreador recebe coordenadas rasterizadas dos estágios anteriores do sombreador (nos pipelines mais simples, esse seria o sombreador de vértice) e retorna uma cor (ou outro valor de 4 componentes) para essa posição de pixel, que é então gravada em um destino de renderização.

Este exemplo inclui sombreadores de vértice e pixel muito básicos que desenham apenas geometria e sombreadores mais complexos que adicionam cálculos básicos de iluminação.

Os programas de sombreador são escritos na Linguagem de Sombreador de Alto Nível da Microsoft (HLSL). A sintaxe HLSL se parece muito com C, mas sem os ponteiros. Os programas de sombreador devem ser muito compactos e eficientes. Se o sombreador for compilado com muitas instruções, ele não poderá ser executado e um erro será retornado. (Observe que o número exato de instruções permitidas faz parte do Nível de recurso do Direct3D.)

No Direct3D, os sombreadores não são compilados em tempo de execução. Eles são compilados quando o restante do programa é compilado. Quando você compila seu aplicativo com o Microsoft Visual Studio 2013, os arquivos HLSL são compilados para arquivos CSO (.cso) que seu aplicativo deve carregar e colocar na memória GPU antes do desenho. Certifique-se de incluir esses arquivos CSO com seu aplicativo ao empacotá-lo. Eles são ativos como malhas e texturas.

Entender a semântica HLSL

É importante levar um momento para discutir a semântica HLSL antes de continuarmos, pois elas geralmente são um ponto de confusão para novos desenvolvedores do Direct3D. Semântica HLSL são cadeias de caracteres que identificam um valor passado entre o aplicativo e um programa de sombreador. Embora possam ser uma variedade de cadeias de caracteres possíveis, a melhor prática é usar uma cadeia de caracteres como POSITION ou COLOR que indique o uso. Você atribui essas semânticas ao construir um buffer ou layout de entrada constante. Você também pode acrescentar um número entre 0 e 7 à semântica para usar registros separados para valores semelhantes. Por exemplo: COLOR0, COLOR1, COLOR2...

A semântica prefixada com "SV_" são valores do sistema gravados pelo programa sombreador; por si só, o aplicativo (em execução na CPU) não pode modificá-la. Normalmente, essas semânticas contêm valores que são entradas ou saídas de outro estágio de sombreador no pipeline gráfico ou que são gerados inteiramente pela GPU.

Além disso, a semântica SV_ tem comportamentos diferentes quando é usada para especificar a entrada ou saída de um estágio do sombreador. Por exemplo, SV_POSITION (saída) contém os dados de vértice transformados durante o estágio do sombreador de vértice e SV_POSITION (entrada) contém os valores de posição de pixel que foram interpolados pela GPU durante o estágio de rasterização.

Aqui estão algumas semânticas HLSL comuns:

  • POSITION(n) para dados de buffer de vértice. SV_POSITION fornece uma posição do pixel ao sombreador de pixel e não pode ser gravado pelo aplicativo.
  • NORMAL(n) para dados normais fornecidos pelo buffer de vértice.
  • TEXCOORD(n) para dados de coordenada uv de textura fornecidos a um sombreador.
  • COLOR(n) para dados de cor RGBA fornecidos a um sombreador. Observe que ele é tratado de forma idêntica para coordenar dados, incluindo a interpolação do valor durante a rasterização; a semântica simplesmente ajuda você a identificar que são dados de cor.
  • SV_Target[n] para gravar de um sombreador de pixel para uma textura de destino ou outro buffer de pixel.

Veremos alguns exemplos de semântica de HLSL enquanto analisamos o exemplo.

Leitura dos buffers constantes

Qualquer sombreador poderá ler de um buffer constante se esse buffer estiver anexado ao estágio como um recurso. Neste exemplo, somente o sombreador de vértice recebe um buffer constante.

O buffer constante é declarado em dois locais: no código C++ e nos arquivos HLSL correspondentes que o acessarão.

Veja como o struct de buffer constante é declarado no código C++.

typedef struct _constantBufferStruct {
    DirectX::XMFLOAT4X4 world;
    DirectX::XMFLOAT4X4 view;
    DirectX::XMFLOAT4X4 projection;
} ConstantBufferStruct;

Ao declarar a estrutura para o buffer constante em seu código C++, verifique se todos os dados estão alinhados corretamente ao longo dos limites de 16 bytes. A maneira mais fácil de fazer isso é usar tipos DirectXMath, como XMFLOAT4 ou XMFLOAT4X4, como visto no código de exemplo. Você também pode se proteger contra buffers desalinhados fazendo uma declaração estática:

// Assert that the constant buffer remains 16-byte aligned.
static_assert((sizeof(ConstantBufferStruct) % 16) == 0, "Constant Buffer size must be 16-byte aligned");

Essa linha de código causará um erro no momento da compilação se ConstantBufferStruct não estiver alinhado a 16 bytes. Para obter mais informações sobre o alinhamento e o empacotamento constantes do buffer, consulte Regras de Empacotamento para Variáveis Constantes.

Agora, veja como o buffer constante é declarado no sombreador de vértice HLSL.

cbuffer ModelViewProjectionConstantBuffer : register(b0)
{
    matrix mWorld;      // world matrix for object
    matrix View;        // view matrix
    matrix Projection;  // projection matrix
};

Todos os buffers — constante, textura, sampler ou outros — devem ter um registro definido para que a GPU possa acessá-los. Cada estágio de sombreador permite até 15 buffers constantes e cada buffer pode conter até 4.096 variáveis constantes. A sintaxe da declaração de registro-uso é a seguinte:

  • b*#*: um registro para um buffer constante (cbuffer).
  • t*#*: um registro para um buffer de textura (tbuffer).
  • s*#*: um registro para um sampler. (Um sampler define o comportamento de pesquisa para texels no recurso de textura.)

Por exemplo, o HLSL para um sombreador de pixel pode usar uma textura e um sampler como entrada com uma declaração como esta.

Texture2D simpleTexture : register(t0);
SamplerState simpleSampler : register(s0);

Cabe a você atribuir buffers constantes aos registros. Ao configurar o pipeline, você anexa um buffer constante ao mesmo slot ao qual o atribuiu no arquivo HLSL. Por exemplo, no tópico anterior, a chamada para VSSetConstantBuffers indica '0' para o primeiro parâmetro. Isso informa ao Direct3D para anexar o recurso de buffer constante e registrar 0, que corresponde à atribuição do buffer para registrar(b0) no arquivo HLSL.

Ler dos buffers de vértice

O buffer de vértice fornece os dados de triângulo para os objetos de cena para os sombreadores de vértice. Assim como acontece com o buffer constante, o struct do buffer de vértice é declarado no código C++, usando regras de empacotamento semelhantes.

typedef struct _vertexPositionColor
{
    DirectX::XMFLOAT3 pos;
    DirectX::XMFLOAT3 color;
} VertexPositionColor;

Não há um formato padrão para dados de vértice no Direct3D 11. Em vez disso, definimos nosso próprio layout de dados de vértice usando um descritor. Os campos de dados são definidos usando uma matriz de estruturas D3D11_INPUT_ELEMENT_DESC. Aqui, mostramos um layout de entrada simples que descreve o mesmo formato de vértice que o struct anterior:

D3D11_INPUT_ELEMENT_DESC iaDesc [] =
{
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },

    { "COLOR", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 },
};

hr = device->CreateInputLayout(
    iaDesc,
    ARRAYSIZE(iaDesc),
    bytes,
    bytesRead,
    &m_pInputLayout
    );

Se você adicionar dados ao formato de vértice ao modificar o código de exemplo, atualize o layout de entrada também ou o sombreador não poderá interpretá-lo. Você pode modificar o layout de vértice da seguinte maneira:

typedef struct _vertexPositionColorTangent
{
    DirectX::XMFLOAT3 pos;
    DirectX::XMFLOAT3 normal;
    DirectX::XMFLOAT3 tangent;
} VertexPositionColorTangent;

Nesse caso, você modificaria a definição de layout de entrada da seguinte maneira.

D3D11_INPUT_ELEMENT_DESC iaDescExtended[] =
{
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },

    { "NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 },

    { "TANGENT", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 },
};

hr = device->CreateInputLayout(
    iaDesc,
    ARRAYSIZE(iaDesc),
    bytes,
    bytesRead,
    &m_pInputLayoutExtended
    );

Cada uma das definições de elemento de layout de entrada é prefixada com uma cadeia de caracteres, como "POSITION" ou "NORMAL", que é a semântica que discutimos anteriormente neste tópico. É como um identificador que ajuda a GPU a identificar esse elemento ao processar o vértice. Escolha nomes comuns e significativos para seus elementos de vértice.

Assim como acontece com o buffer constante, o sombreador de vértice tem uma definição de buffer correspondente para elementos de vértice de entrada. (É por isso que fornecemos uma referência ao recurso de sombreador de vértice ao criar o layout de entrada. O Direct3D valida o layout de dados por vértice com o struct de entrada do sombreador.) Observe como a semântica corresponde entre a definição de layout de entrada e essa declaração de buffer HLSL. No entanto, COLOR tem um "0" acrescentado a ele. Não é necessário adicionar o 0 se você tiver apenas um elemento COLOR declarado no layout, mas é uma boa prática anexá-lo caso você escolha adicionar mais elementos de cor no futuro.

struct VS_INPUT
{
    float3 vPos   : POSITION;
    float3 vColor : COLOR0;
};

Passar dados entre sombreadores

Os sombreadores pegam tipos de entrada e retornam tipos de saída de suas funções principais após a execução. Para o sombreador de vértice definido na seção anterior, o tipo de entrada era a estrutura VS_INPUT e definimos um layout de entrada correspondente e um struct C++. Uma matriz desse struct é usada para criar um buffer de vértice no método CreateCube.

O sombreador de vértice retorna uma estrutura PS_INPUT, que deve conter minimamente a posição de vértice final de 4 componentes (float4). Esse valor de posição deve ter o valor do sistema semântico, SV_POSITION, declarado para ele para que a GPU tenha os dados necessários para executar a próxima etapa de desenho. Observe que não há uma correspondência 1:1 entre a saída do sombreador de vértice e a entrada do sombreador de pixel. O sombreador de vértice retorna uma estrutura para cada vértice que é dado, mas o sombreador de pixel é executado uma vez para cada pixel. Isso ocorre porque os dados por vértice passam pela fase de rasterização pela primeira vez. Esse estágio decide quais pixels "cobrem" a geometria que você está desenhando, calcula dados interpolados por vértice para cada pixel e, em seguida, chama o sombreador de pixel uma vez para cada um desses pixels. Interpolação é o comportamento padrão ao rasterizar valores de saída e é essencial, em particular, para o processamento correto de dados de vetor de saída (vetores leves, normais por vértice e tangentes, entre outros).

struct PS_INPUT
{
    float4 Position : SV_POSITION;  // interpolated vertex position (system value)
    float4 Color    : COLOR0;       // interpolated diffuse color
};

Examinar o sombreador de vértice

O sombreador de vértice de exemplo é muito simples: pegue um vértice (posição e cor), transforme a posição das coordenadas do modelo em coordenadas projetadas em perspectiva e retorne-a (juntamente com a cor) para o rasterizador. Observe que o valor da cor é interpolado diretamente junto com os dados de posição, fornecendo um valor diferente para cada pixel, mesmo que o sombreador de vértice não tenha realizado nenhum cálculo no valor de cor.

VS_OUTPUT main(VS_INPUT input) // main is the default function name
{
    VS_OUTPUT Output;

    float4 pos = float4(input.vPos, 1.0f);

    // Transform the position from object space to homogeneous projection space
    pos = mul(pos, mWorld);
    pos = mul(pos, View);
    pos = mul(pos, Projection);
    Output.Position = pos;

    // Just pass through the color data
    Output.Color = float4(input.vColor, 1.0f);

    return Output;
}

Um sombreador de vértice mais complexo, como um que configura os vértices de um objeto para sombreamento phong, pode ser mais parecido com este. Nesse caso, estamos aproveitando o fato de que os vetores e normais são interpolados para aproximar uma superfície de aparência suave.

// A constant buffer that stores the three basic column-major matrices for composing geometry.
cbuffer ModelViewProjectionConstantBuffer : register(b0)
{
    matrix model;
    matrix view;
    matrix projection;
};

cbuffer LightConstantBuffer : register(b1)
{
    float4 lightPos;
};

struct VertexShaderInput
{
    float3 pos : POSITION;
    float3 normal : NORMAL;
};

// Per-pixel color data passed through the pixel shader.

struct PixelShaderInput
{
    float4 position : SV_POSITION; 
    float3 outVec : POSITION0;
    float3 outNormal : NORMAL0;
    float3 outLightVec : POSITION1;
};

PixelShaderInput main(VertexShaderInput input)
{
    // Inefficient -- doing this only for instruction. Normally, you would
 // premultiply them on the CPU and place them in the cbuffer.
    matrix mvMatrix = mul(model, view);
    matrix mvpMatrix = mul(mvMatrix, projection);

    PixelShaderInput output;

    float4 pos = float4(input.pos, 1.0f);
    float4 normal = float4(input.normal, 1.0f);
    float4 light = float4(lightPos.xyz, 1.0f);

    // 
    float4 eye = float4(0.0f, 0.0f, -2.0f, 1.0f);

    // Transform the vertex position into projected space.
    output.gl_Position = mul(pos, mvpMatrix);
    output.outNormal = mul(normal, mvMatrix).xyz;
    output.outVec = -(eye - mul(pos, mvMatrix)).xyz;
    output.outLightVec = mul(light, mvMatrix).xyz;

    return output;
}

Examinar o sombreador de pixel

Este sombreador de pixel neste exemplo é possivelmente a quantidade mínima absoluta de código que você pode ter em um sombreador de pixel. Ele usa os dados de cor de pixel interpolados gerados durante a rasterização e os retorna como saída, onde serão gravados em um destino de renderização. Que chato!

PS_OUTPUT main(PS_INPUT In)
{
    PS_OUTPUT Output;

    Output.RGBColor = In.Color;

    return Output;
}

A parte importante é a semântica SV_TARGET de valor do sistema no valor retornado. Indica que a saída deve ser gravada no destino de renderização primário, que é o buffer de textura fornecido à cadeia de troca para exibição. Isso é necessário para sombreadores de pixel. Sem os dados de cor do sombreador de pixel, o Direct3D não teria nada para exibir!

Um exemplo de um sombreador de pixel mais complexo para executar o sombreamento phong pode ter esta aparência. Como os vetores e os normais foram interpolados, não precisamos computá-los por pixel. No entanto, precisamos normalizá-los novamente devido à forma como a interpolação funciona. Conceitualmente, precisamos gradualmente "girar" o vetor da direção no vértice A para a direção no vértice B, mantendo seu comprimento. A interpolação wheras, em vez disso, corta uma linha reta entre os dois pontos de extremidade de vetor.

cbuffer MaterialConstantBuffer : register(b2)
{
    float4 lightColor;
    float4 Ka;
    float4 Kd;
    float4 Ks;
    float4 shininess;
};

struct PixelShaderInput
{
    float4 position : SV_POSITION;
    float3 outVec : POSITION0;
    float3 normal : NORMAL0;
    float3 light : POSITION1;
};

float4 main(PixelShaderInput input) : SV_TARGET
{
    float3 L = normalize(input.light);
    float3 V = normalize(input.outVec);
    float3 R = normalize(reflect(L, input.normal));

    float4 diffuse = Ka + (lightColor * Kd * max(dot(input.normal, L), 0.0f));
    diffuse = saturate(diffuse);

    float4 specular = Ks * pow(max(dot(R, V), 0.0f), shininess.x - 50.0f);
    specular = saturate(specular);

    float4 finalColor = diffuse + specular;

    return finalColor;
}

Em outro exemplo, o sombreador de pixel usa seus próprios buffers constantes que contêm informações de luz e material. O layout de entrada no sombreador de vértice seria expandido para incluir dados normais e espera-se que a saída desse sombreador de vértice inclua vetores transformados para o vértice, a luz e o vértice normal no sistema de coordenadas de exibição.

Se você tiver buffers de textura e samplers com registros atribuídos (t e s, respectivamente), também poderá acessá-los no sombreador de pixel.

Texture2D simpleTexture : register(t0);
SamplerState simpleSampler : register(s0);

struct PixelShaderInput
{
    float4 pos : SV_POSITION;
    float3 norm : NORMAL;
    float2 tex : TEXCOORD0;
};

float4 SimplePixelShader(PixelShaderInput input) : SV_TARGET
{
    float3 lightDirection = normalize(float3(1, -1, 0));
    float4 texelColor = simpleTexture.Sample(simpleSampler, input.tex);
    float lightMagnitude = 0.8f * saturate(dot(input.norm, -lightDirection)) + 0.2f;
    return texelColor * lightMagnitude;
}

Sombreadores são ferramentas muito poderosas que podem ser usadas para gerar recursos processuais, como mapas de sombra ou texturas de ruído. Na verdade, as técnicas avançadas exigem que você pense em texturas de forma mais abstrata, não como elementos visuais, mas como buffers. Eles contêm dados como informações de altura ou outros dados que podem ser amostrados na passagem do sombreador de pixel final ou nesse quadro específico como parte de uma passagem de efeitos de vários estágios. A amostragem múltipla é uma ferramenta poderosa e o backbone de muitos efeitos visuais modernos.

Próximas etapas

Espero que você esteja confortável com o DirectX 11 neste ponto e esteja pronto para começar a trabalhar em seu projeto. Aqui estão alguns links para ajudar a responder outras perguntas que você pode ter sobre desenvolvimento com DirectX e C++:

Trabalhar com recursos do dispositivo DirectX

Entender o pipeline de renderização do Direct3D 11