Criar e exibir uma malha básica

Os jogos 3D da Plataforma Universal do Windows (UWP) normalmente usam polígonos para representar objetos e superfícies no jogo. As listas de vértices que compõem a estrutura desses objetos e superfícies poligonais são chamadas de malhas. Aqui, criamos uma malha básica para um objeto de cubo e fornecemos ao processo de shader para renderização e exibição.

Importante O código de exemplo incluído aqui usa tipos (como DirectX::XMFLOAT3 e DirectX::XMFLOAT4X4) e métodos embutidos declarados em DirectXMath.h. Se você estiver cortando e colando esse código, #include <> DirectXMath.h em seu projeto.

 

O que precisa de saber

Tecnologias

  • Direct3D

Pré-requisitos

  • Conhecimento básico de álgebra linear e sistemas de coordenadas 3D
  • Um modelo Direct3D do Visual Studio 2015 ou posterior

Instruções

Estas etapas mostrarão como criar um cubo de malha básico.

Etapa 1: Construir a malha para o modelo

Na maioria dos jogos, a malha de um objeto de jogo é carregada a partir de um arquivo que contém os dados de vértice específicos. A ordenação desses vértices depende da aplicação, mas eles geralmente são serializados como tiras ou leques. Os dados do Vertex podem vir de qualquer fonte de software ou podem ser criados manualmente. Cabe ao seu jogo interpretar os dados de forma que o sombreador de vértice possa processá-los efetivamente.

No nosso exemplo, usamos uma malha simples para um cubo. O cubo, como qualquer malha de objetos neste estágio do pipeline, é representado usando seu próprio sistema de coordenadas. O sombreador de vértice pega suas coordenadas e, aplicando as matrizes de transformação fornecidas, retorna a projeção final da visualização 2D em um sistema de coordenadas homogêneo.

Defina a malha para um cubo. (Ou carregue-o de um ficheiro. A decisão é sua!)

SimpleCubeVertex cubeVertices[] =
{
    { DirectX::XMFLOAT3(-0.5f, 0.5f, -0.5f), DirectX::XMFLOAT3(0.0f, 1.0f, 0.0f) }, // +Y (top face)
    { DirectX::XMFLOAT3( 0.5f, 0.5f, -0.5f), DirectX::XMFLOAT3(1.0f, 1.0f, 0.0f) },
    { DirectX::XMFLOAT3( 0.5f, 0.5f,  0.5f), DirectX::XMFLOAT3(1.0f, 1.0f, 1.0f) },
    { DirectX::XMFLOAT3(-0.5f, 0.5f,  0.5f), DirectX::XMFLOAT3(0.0f, 1.0f, 1.0f) },

    { DirectX::XMFLOAT3(-0.5f, -0.5f,  0.5f), DirectX::XMFLOAT3(0.0f, 0.0f, 1.0f) }, // -Y (bottom face)
    { DirectX::XMFLOAT3( 0.5f, -0.5f,  0.5f), DirectX::XMFLOAT3(1.0f, 0.0f, 1.0f) },
    { DirectX::XMFLOAT3( 0.5f, -0.5f, -0.5f), DirectX::XMFLOAT3(1.0f, 0.0f, 0.0f) },
    { DirectX::XMFLOAT3(-0.5f, -0.5f, -0.5f), DirectX::XMFLOAT3(0.0f, 0.0f, 0.0f) },
};

O sistema de coordenadas do cubo coloca o centro do cubo na origem, com o eixo y correndo de cima para baixo usando um sistema de coordenadas canhoto. Os valores de coordenadas são expressos como valores flutuantes de 32 bits entre -1 e 1.

Em cada emparelhamento entre parênteses, o segundo grupo de valores DirectX::XMFLOAT3 especifica a cor associada ao vértice como um valor RGB. Por exemplo, o primeiro vértice em (-0,5, 0,5, -0,5) tem uma cor verde completa (o valor G é definido como 1,0 e os valores "R" e "B" são definidos como 0).

Portanto, você tem 8 vértices, cada um com uma cor específica. Cada emparelhamento vértice/cor é o dado completo para um vértice em nosso exemplo. Ao especificar nosso buffer de vértice, você deve ter esse layout específico em mente. Fornecemos esse layout de entrada para o sombreador de vértice para que ele possa entender seus dados de vértice.

Etapa 2: configurar o layout de entrada

Agora, tens os vértices na memória. Mas, seu dispositivo gráfico tem sua própria memória e você usa Direct3D para acessá-lo. Para colocar seus dados de vértice no dispositivo gráfico para processamento, você precisa abrir o caminho, por assim dizer: você deve declarar como os dados de vértice são dispostos para que o dispositivo gráfico possa interpretá-los quando obtê-los do seu jogo. Para fazer isso, use ID3D11InputLayout.

Declare e defina o layout de entrada para o buffer de vértice.

const D3D11_INPUT_ELEMENT_DESC basicVertexLayoutDesc[] =
{
    { "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 },
};

ComPtr<ID3D11InputLayout> inputLayout;
m_d3dDevice->CreateInputLayout(
                basicVertexLayoutDesc,
                ARRAYSIZE(basicVertexLayoutDesc),
                vertexShaderBytecode->Data,
                vertexShaderBytecode->Length,
                &inputLayout)
);

Neste código, você especifica um layout para os vértices, especificamente, quais dados cada elemento na lista de vértices contém. Aqui, em basicVertexLayoutDesc , você especifica dois componentes de dados:

  • POSITION: Esta é uma semântica HLSL para dados de posição fornecidos a um sombreador. Neste código, é um DirectX::XMFLOAT3, ou mais especificamente, uma estrutura com 3 valores de ponto flutuante de 32 bits que correspondem a uma coordenada 3D (x, y, z). Você também pode usar um float4 se estiver fornecendo a coordenada "w" homogênea e, nesse caso, especificar DXGI_FORMAT_R32G32B32A32_FLOAT. Se você usa um DirectX::XMFLOAT3 ou um float4 está de acordo com as necessidades específicas do seu jogo. Apenas certifique-se de que os dados de vértice para sua malha correspondem corretamente ao formato que você usa!

    Cada valor de coordenada é expresso como um valor de ponto flutuante entre -1 e 1, no espaço de coordenadas do objeto. Quando o sombreador de vértice é concluído, o vértice transformado está no espaço de projeção de visão homogêneo (perspetiva corrigida).

    "Mas o valor de enumeração indica RGB, não XYZ!", você observa inteligentemente. Bom olho! Em ambos os casos de dados de cor e dados de coordenadas, você normalmente usa 3 ou 4 valores de componentes, então por que não usar o mesmo formato para ambos? A semântica HLSL, não o nome do formato, indica como o sombreador trata os dados.

  • COLOR: Esta é uma semântica HLSL para dados coloridos. Como POSITION, consiste em 3 valores de ponto flutuante de 32 bits (DirectX::XMFLOAT3). Cada valor contém um componente de cor: vermelho (r), azul (b) ou verde (g), expresso como um número flutuante entre 0 e 1.

    Os valores de COLOR são normalmente retornados como um valor RGBA de 4 componentes no final do pipeline do sombreador. Neste exemplo, você definirá o valor alfa "A" como 1,0 (opacidade máxima) no pipeline de sombreador para todos os pixels.

Para obter uma lista completa de formatos, consulte DXGI_FORMAT. Para obter uma lista completa da semântica HLSL, consulte Semantics.

Chame ID3D11Device::CreateInputLayout e crie o layout de entrada no dispositivo Direct3D. Agora, você precisa criar um buffer que possa realmente armazenar os dados!

Etapa 3: Preencher os buffers de vértice

Os buffers de vértice contêm a lista de vértices para cada triângulo na malha. Cada vértice deve ser único nesta lista. No nosso exemplo, você tem 8 vértices para o cubo. O sombreador de vértice é executado no dispositivo gráfico e lê a partir do buffer de vértice, e interpreta os dados com base no layout de entrada especificado na etapa anterior.

No próximo exemplo, você fornece uma descrição e um subrecurso para o buffer, que informam ao Direct3D várias coisas sobre o mapeamento físico dos dados de vértice e como tratá-los na memória no dispositivo gráfico. Isso é necessário porque você usa um genérico ID3D11Buffer, que pode conter qualquer coisa! As estruturas D3D11_BUFFER_DESC e D3D11_SUBRESOURCE_DATA são fornecidas para garantir que o Direct3D compreenda o layout de memória física do buffer, incluindo o tamanho de cada elemento de vértice no buffer, bem como o tamanho máximo da lista de vértices. Você também pode controlar o acesso à memória buffer aqui e como ela é atravessada, mas isso está um pouco além do escopo deste tutorial.

Depois de configurar o buffer, você chama ID3D11Device::CreateBuffer para realmente criá-lo. Obviamente, se você tiver mais de um objeto, crie buffers para cada modelo exclusivo.

Declare e crie o buffer de vértice.

D3D11_BUFFER_DESC vertexBufferDesc = {0};
vertexBufferDesc.ByteWidth = sizeof(SimpleCubeVertex) * ARRAYSIZE(cubeVertices);
vertexBufferDesc.Usage = D3D11_USAGE_DEFAULT;
vertexBufferDesc.BindFlags = D3D11_BIND_VERTEX_BUFFER;
vertexBufferDesc.CPUAccessFlags = 0;
vertexBufferDesc.MiscFlags = 0;
vertexBufferDesc.StructureByteStride = 0;

D3D11_SUBRESOURCE_DATA vertexBufferData;
vertexBufferData.pSysMem = cubeVertices;
vertexBufferData.SysMemPitch = 0;
vertexBufferData.SysMemSlicePitch = 0;

ComPtr<ID3D11Buffer> vertexBuffer;
m_d3dDevice->CreateBuffer(
                &vertexBufferDesc,
                &vertexBufferData,
                &vertexBuffer);

Vértices carregados. Mas qual é a ordem de processamento desses vértices? Isso é tratado quando você fornece uma lista de índices para os vértices — a ordem desses índices é a ordem na qual o sombreador de vértice os processa.

Etapa 4: preencher os buffers de índice

Agora, você fornece uma lista dos índices para cada um dos vértices. Estes índices correspondem à posição do vértice no buffer de vértice, começando com 0. Para ajudá-lo a visualizar isso, considere que cada vértice exclusivo em sua malha tem um número exclusivo atribuído a ele, como um ID. Este ID é a posição inteira do vértice no buffer de vértice.

um cubo com oito vértices numerados

No nosso cubo de exemplo, você tem 8 vértices, que criam 6 quads para os lados. Você divide os quadriláteros em triângulos, resultando num total de 12 triângulos que utilizam os nossos 8 vértices. Com 3 vértices por triângulo, existem 36 entradas no nosso buffer de index. Em nosso exemplo, esse padrão de índice é conhecido como uma lista de triângulos e você o indica para Direct3D como um D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST quando define a topologia primitiva.

Esta é provavelmente a maneira mais ineficiente de listar índices, pois há muitas redundâncias quando triângulos compartilham pontos e lados. Por exemplo, quando um triângulo compartilha um lado em forma de losango, você lista 6 índices para os quatro vértices, assim:

ordem dos índices ao construir um losango

  • Triângulo 1: [0, 1, 2]
  • Triângulo 2: [0, 2, 3]

Em uma topologia de tira ou ventilador, você ordena os vértices de uma forma que elimina muitos lados redundantes durante a travessia (como o lado do índice 0 ao índice 2 na imagem). Para malhas grandes, isso reduz drasticamente o número de vezes que o sombreador de vértice é executado e melhora significativamente o desempenho. No entanto, vamos mantê-lo simples e ficar com a lista de triângulos.

Declare os índices para o buffer de vértice como uma topologia de lista de triângulo simples.

unsigned short cubeIndices[] =
{   0, 1, 2,
    0, 2, 3,

    4, 5, 6,
    4, 6, 7,

    3, 2, 5,
    3, 5, 4,

    2, 1, 6,
    2, 6, 5,

    1, 7, 6,
    1, 0, 7,

    0, 3, 4,
    0, 4, 7 };

Trinta e seis elementos de índice no buffer é muito redundante quando você tem apenas 8 vértices! Se você optar por eliminar algumas das redundâncias e usar um tipo de lista de vértices diferente, como uma faixa ou um ventilador, deverá especificar esse tipo ao fornecer um valor de D3D11_PRIMITIVE_TOPOLOGY específico para o ID3D11DeviceContext::IASetPrimitiveTopology método.

Para obter mais informações sobre diferentes técnicas de lista de índice, consulte Topologias primitivas.

Etapa 5: Criar um buffer constante para suas matrizes de transformação

Antes de começar a processar vértices, você precisa fornecer as matrizes de transformação que serão aplicadas (multiplicadas) a cada vértice quando ele for executado. Para a maioria dos jogos 3D, existem três deles:

  • A matriz 4x4 que se transforma do sistema de coordenadas do objeto (modelo) para o sistema de coordenadas do mundo geral.
  • A matriz 4x4 que transforma do sistema de coordenadas global para o sistema de coordenadas da câmara (vista).
  • A matriz 4x4 que se transforma do sistema de coordenadas da câmara para o sistema de coordenadas de projeção de vista 2D.

Essas matrizes são passadas para o sombreador em um buffer constante . Um buffer constante é uma região de memória que permanece constante durante a execução do próximo passo do pipeline de shaders e que pode ser acessada diretamente pelos shaders a partir do seu código HLSL. Você define cada buffer constante duas vezes: primeiro no código C++ do jogo e (pelo menos) uma vez na sintaxe HLSL semelhante a C para o código de shader. As duas declarações devem corresponder diretamente em termos de tipos e alinhamento de dados. É fácil introduzir erros difíceis de detetar quando o sombreador usa a declaração HLSL para interpretar dados declarados em C++, e os tipos não correspondem ou o alinhamento de dados está incorreto!

Os buffers constantes não são alterados pelo HLSL. Você pode alterá-los quando o jogo atualizar dados específicos. Muitas vezes, os desenvolvedores de jogos criam 4 classes de buffers constantes: um tipo para atualizações por quadro; um tipo para atualizações por modelo/objeto; um tipo de atualização por atualização do estado do jogo; e um tipo para dados que nunca mudam ao longo da vida útil do jogo.

Neste exemplo, temos apenas um que nunca muda: os dados DirectX::XMFLOAT4X4 para as três matrizes.

Nota O código de exemplo apresentado aqui usa matrizes coluna-maior. Pode utilizar matrizes por linha em vez disso, usando a palavra-chave row_major no HLSL e garantindo que os dados da matriz de origem também sejam por linha. O DirectXMath usa matrizes em ordem de linha e pode ser usado diretamente com matrizes HLSL definidas com a palavra-chave row_major.

 

Declare e crie um buffer constante para as três matrizes usadas para transformar cada vértice.

struct ConstantBuffer
{
    DirectX::XMFLOAT4X4 model;
    DirectX::XMFLOAT4X4 view;
    DirectX::XMFLOAT4X4 projection;
};
ComPtr<ID3D11Buffer> m_constantBuffer;
ConstantBuffer m_constantBufferData;

// ...

// Create a constant buffer for passing model, view, and projection matrices
// to the vertex shader.  This allows us to rotate the cube and apply
// a perspective projection to it.

D3D11_BUFFER_DESC constantBufferDesc = {0};
constantBufferDesc.ByteWidth = sizeof(m_constantBufferData);
constantBufferDesc.Usage = D3D11_USAGE_DEFAULT;
constantBufferDesc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
constantBufferDesc.CPUAccessFlags = 0;
constantBufferDesc.MiscFlags = 0;
constantBufferDesc.StructureByteStride = 0;
m_d3dDevice->CreateBuffer(
                &constantBufferDesc,
                nullptr,
                &m_constantBuffer
             );

m_constantBufferData.model = DirectX::XMFLOAT4X4( // Identity matrix, since you are not animating the object
            1.0f, 0.0f, 0.0f, 0.0f,
            0.0f, 1.0f, 0.0f, 0.0f,
            0.0f, 0.0f, 1.0f, 0.0f,
            0.0f, 0.0f, 0.0f, 1.0f);

);
// Specify the view (camera) transform corresponding to a camera position of
// X = 0, Y = 1, Z = 2.  

m_constantBufferData.view = DirectX::XMFLOAT4X4(
            -1.00000000f, 0.00000000f,  0.00000000f,  0.00000000f,
             0.00000000f, 0.89442718f,  0.44721359f,  0.00000000f,
             0.00000000f, 0.44721359f, -0.89442718f, -2.23606800f,
             0.00000000f, 0.00000000f,  0.00000000f,  1.00000000f);

Observação Você geralmente declara a matriz de projeção quando configura recursos específicos do dispositivo, porque os resultados da multiplicação com ela devem corresponder aos parâmetros atuais de tamanho da janela de visualização 2D (que geralmente correspondem à altura e largura do pixel da tela). Se isso mudar, você deverá dimensionar os valores das coordenadas x e y de acordo.

 

// Finally, update the constant buffer perspective projection parameters
// to account for the size of the application window.  In this sample,
// the parameters are fixed to a 70-degree field of view, with a depth
// range of 0.01 to 100.  

float xScale = 1.42814801f;
float yScale = 1.42814801f;
if (backBufferDesc.Width > backBufferDesc.Height)
{
    xScale = yScale *
                static_cast<float>(backBufferDesc.Height) /
                static_cast<float>(backBufferDesc.Width);
}
else
{
    yScale = xScale *
                static_cast<float>(backBufferDesc.Width) /
                static_cast<float>(backBufferDesc.Height);
}
m_constantBufferData.projection = DirectX::XMFLOAT4X4(
            xScale, 0.0f,    0.0f,  0.0f,
            0.0f,   yScale,  0.0f,  0.0f,
            0.0f,   0.0f,   -1.0f, -0.01f,
            0.0f,   0.0f,   -1.0f,  0.0f
            );

Enquanto estiver aqui, defina os buffers de vértice e índice noID3D11DeviceContext, além da topologia que você está usando.

// Set the vertex and index buffers, and specify the way they define geometry.
UINT stride = sizeof(SimpleCubeVertex);
UINT offset = 0;
m_d3dDeviceContext->IASetVertexBuffers(
                0,
                1,
                vertexBuffer.GetAddressOf(),
                &stride,
                &offset);

m_d3dDeviceContext->IASetIndexBuffer(
                indexBuffer.Get(),
                DXGI_FORMAT_R16_UINT,
                0);

 m_d3dDeviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);

Está bem! Montagem do módulo de entrada concluída. Tudo está pronto para renderização. Vamos pôr esse sombreador de vértice a funcionar.

Etapa 6: Processar a malha com o sombreador de vértice

Agora que você tem um buffer de vértice com os vértices que definem sua malha e o buffer de índice que define a ordem em que os vértices são processados, você os envia para o sombreador de vértice. O código do sombreador de vértice, expresso como linguagem de sombreador de alto nível compilada, é executado uma vez para cada vértice no buffer de vértice, permitindo que você execute suas transformações por vértice. O resultado final é tipicamente uma projeção 2D.

(Você carregou seu sombreador de vértice? Caso contrário, consulte Como carregar recursos no seu jogo em DirectX.)

Aqui, você cria o sombreador de vértice...

// Set the vertex and pixel shader stage state.
m_d3dDeviceContext->VSSetShader(
                vertexShader.Get(),
                nullptr,
                0);

... e defina os buffers constantes.

m_d3dDeviceContext->VSSetConstantBuffers(
                0,
                1,
                m_constantBuffer.GetAddressOf());

Aqui está o código de sombreador de vértice que lida com a transformação das coordenadas do objeto para o sistema de coordenadas do mundo e, em seguida, para o sistema de coordenadas de projeção de vista 2D. Você também aplica uma iluminação simples por vértice para tornar as coisas bonitas. Isso acontece no arquivo HLSL do sombreador de vértice (SimplerVertexShader.hlsl, neste exemplo).

cbuffer simpleConstantBuffer : register( b0 )
{
    matrix model;
    matrix view;
    matrix projection;
};

struct VertexShaderInput
{
    DirectX::XMFLOAT3 pos : POSITION;
    DirectX::XMFLOAT3 color : COLOR;
};

struct PixelShaderInput
{
    float4 pos : SV_POSITION;
    float4 color : COLOR;
};

PixelShaderInput SimpleVertexShader(VertexShaderInput input)
{
    PixelShaderInput vertexShaderOutput;
    float4 pos = float4(input.pos, 1.0f);

    // Transform the vertex position into projection space.
    pos = mul(pos, model);
    pos = mul(pos, view);
    pos = mul(pos, projection);
    vertexShaderOutput.pos = pos;

    // Pass the vertex color through to the pixel shader.
    vertexShaderOutput.color = float4(input.color, 1.0f);

    return vertexShaderOutput;
}

Está a ver o cbuffer no topo? Esse é o análogo HLSL para o mesmo buffer constante que declaramos em nosso código C++ anteriormente. E o VertexShaderInputstruct? Ora, isso se parece com seu layout de entrada e declaração de dados de vértice! É importante que as declarações de buffer constante e dados de vértice no código C++ correspondam às declarações no código HLSL—o que inclui sinais, tipos e alinhamento de dados.

PixelShaderInput especifica o layout dos dados retornados pela função principal do sombreador de vértice. Quando terminar de processar um vértice, você retornará uma posição de vértice no espaço de projeção 2D e uma cor usada para iluminação por vértice. A placa gráfica usa os dados produzidos pelo sombreador para calcular os "fragmentos" (pixels possíveis) que devem ser coloridos quando o shader de pixel é executado no próximo estágio do pipeline.

Etapa 7: Passando a malha pelo sombreador de pixel

Normalmente, neste estágio do pipeline de gráficos, você executa operações por pixel nas superfícies projetadas visíveis de seus objetos. (As pessoas gostam de texturas.) Para fins de amostragem, no entanto, basta passá-la por esta etapa.

Primeiro, vamos criar uma instância do sombreador de pixel. O sombreador de pixel é executado para cada pixel na projeção 2D da sua cena, atribuindo uma cor a esse pixel. Neste caso, passamos diretamente a cor para o pixel retornado pelo sombreador de vértice.

Defina o sombreador de pixel.

m_d3dDeviceContext->PSSetShader( pixelShader.Get(), nullptr, 0 );

Defina um shader de píxeis de passagem em HLSL.

struct PixelShaderInput
{
    float4 pos : SV_POSITION;
};

float4 SimplePixelShader(PixelShaderInput input) : SV_TARGET
{
    // Draw the entire triangle yellow.
    return float4(1.0f, 1.0f, 0.0f, 1.0f);
}

Coloque esse código em um arquivo HLSL separado do sombreador de vértice HLSL (como SimplePixelShader.hlsl). Esse código é executado uma vez para cada pixel visível em seu visor (uma representação na memória da parte da tela para a qual você está desenhando), que, neste caso, mapeia para a tela inteira. Agora, o teu pipeline de gráficos está completamente definido!

Passo 8: Rasterizar e exibir a malha

Vamos executar o pipeline. Isto é fácil: chame ID3D11DeviceContext::DrawIndexed.

Desenhe esse cubo!

// Draw the cube.
m_d3dDeviceContext->DrawIndexed( ARRAYSIZE(cubeIndices), 0, 0 );
            

Dentro da placa gráfica, cada vértice é processado na ordem especificada no buffer de índice. Depois que o código tiver executado o sombreador de vértices e os fragmentos 2D forem definidos, o sombreador de píxeis é invocado e os triângulos coloridos.

Agora, coloque o cubo na tela.

Apresente esse buffer de quadros ao ecrã.

// Present the rendered image to the window.  Because the maximum frame latency is set to 1,
// the render loop is generally  throttled to the screen refresh rate, typically around
// 60 Hz, by sleeping the app on Present until the screen is refreshed.

m_swapChain->Present(1, 0);

E já está! Para uma cena cheia de modelos, use vários buffers de vértice e índice, e você pode até ter sombreadores diferentes para diferentes tipos de modelo. Lembre-se de que cada modelo tem seu próprio sistema de coordenadas, e você precisa transformá-los para o sistema de coordenadas do mundo compartilhado usando as matrizes definidas no buffer constante.

Observações

Este tópico aborda a criação e exibição de geometria simples que você mesmo cria. Para obter mais informações sobre como carregar geometria mais complexa de um ficheiro e convertê-la para o formato de objeto de buffer de vértice específico de exemplo (.vbo), consulte Como carregar recursos no teu jogo DirectX.