Criação de Minecraft em uma semana em C ++ e Vulkan

Eu me propus a recriar o Minecraft do zero em uma semana usando meu próprio mecanismo em C ++ e Vulkan. Fui inspirado por Hopson , que fez o mesmo com C ++ e OpenGL. Por sua vez, ele foi inspirado por Shane Beck , inspirado em Minecraft, cuja fonte de inspiração era Infiniminer, cuja criação, presumivelmente, foi inspirada em mineração real.


O repositório GitHub para este projeto está aqui . Cada dia tem sua própria tag git.

Claro, eu não planejava recriar literalmente o Minecraft. Este projeto deveria ser educacional. Eu queria aprender sobre o uso do Vulkan em algo mais complicado do que o vulkan-tutorial.com ou a demonstração de Sasha Willem. Portanto, a ênfase principal está no design do mecanismo Vulkan, e não no design do jogo.

Tarefas


O desenvolvimento no Vulkan é muito mais lento que no OpenGL, então não pude incorporar muitos dos recursos deste Minecraft no jogo. Não há multidões, artesanato, pedra vermelha, física de blocos, etc. Desde o início, os objetivos do projeto foram os seguintes:

  • Criando um sistema de renderização de terreno
    • Mashing
    • Iluminação
  • Criando um sistema gerador de terreno
    • Alívio
    • Árvores
    • Biomas
  • Adicionando a capacidade de mudar de terreno e mover blocos

Eu precisava encontrar uma maneira de implementar tudo isso sem adicionar uma GUI ao jogo, porque não consegui encontrar nenhuma biblioteca de GUI que funcionasse com o Vulkan e fosse fácil de integrar.

Bibliotecas


Claro, eu não ia escrever um aplicativo Vulkan do zero. Para acelerar o processo de desenvolvimento, usarei bibliotecas prontas sempre que possível. Nomeadamente:

  • VulkanWrapper - meu próprio wrapper C ++ para a API Vulkan
  • GLFW - para janelas e entrada do usuário
  • VulkanMemoryAllocator - para alocar memória Vulkan
  • GLM - para vetores e matrizes matemáticos
  • entt - para sinais / slots e ECS
  • stb - para utilitários de carregamento de imagens
  • FastNoise - para gerar ruído 3D

Dia 1


No primeiro dia, preparei um clichê Vulkan e um esqueleto do motor. A maior parte do código era um clichê e eu podia copiá-lo do vulkan-tutorial.com . Também incluiu um truque para armazenar dados de vértices como parte de um sombreador de vértices. Isso significava que nem precisava ajustar a alocação de memória. Apenas um transportador simples que pode fazer apenas uma coisa: desenhar um triângulo.

O mecanismo é simples o suficiente para suportar o renderizador de triângulos. Possui uma janela e um ciclo de jogo aos quais os sistemas podem ser conectados. A GUI é limitada pela taxa de quadros exibida no título da janela.

O projeto está dividido em duas partes: VoxelEnginee VoxelGame.


Dia 2


Integrei a biblioteca do Vulkan Memory Allocator. Esta biblioteca cuida da maior parte do padrão da alocação de memória da Vulkan: tipos de memória, pilhas de memória do dispositivo e alocação secundária.

Agora que eu tinha uma alocação de memória, criei classes para malhas e buffers de vértice. Mudei o renderizador dos triângulos para que ele use a classe de malhas, e não as matrizes embutidas no shader. Atualmente, os dados de malha são transferidos para a GPU renderizando manualmente os triângulos.


Pouco mudou

Dia 3


Eu adicionei um sistema de renderização gráfica. Esta postagem foi tomada como base para a criação desta classe , mas a classe é muito simplificada. Meu gráfico de renderização contém apenas o essencial para lidar com a sincronização com o Vulkan.

O gráfico de renderização permite definir nós e arestas. Nós são o trabalho realizado pela GPU. Nervuras são dependências de dados entre nós. Cada nó recebe seu próprio buffer de instruções, no qual escreve. O gráfico está envolvido em buffers de comando com buffer duplo e sincronizando-os com quadros anteriores. As arestas são usadas para inserir automaticamente barreiras do transportador antes e depois que um nó grava em cada buffer de instruções. As barreiras de pipeline sincronizam o uso de todos os recursos e transferem a propriedade entre filas. Além disso, as arestas inserem semáforos entre os nós.

Nós e arestas formam um gráfico acíclico direcionado . Em seguida, o gráfico de renderização executa a classificação topológica.nós, o que leva à criação de uma lista simples de nós classificados para que cada nó siga todos os nós dos quais depende.

O mecanismo possui três tipos de nós. AcquireNoderecebe uma imagem de uma cadeia de buffer (swapchain), TransferNodetransfere dados da CPU para a GPU e PresentNodefornece uma imagem de uma cadeia de buffer a ser exibida.

Cada nó pode implementar preRender, rendere postRender, que são executados em cada quadro. AcquireNodeobtém uma imagem de uma cadeia de buffers durante preRender. PresentNodefornece esta imagem a tempo postRender.

Refatorei o renderizador de triângulo para que ele usasse um sistema de gráficos de renderização, em vez de processar tudo sozinho. Há uma vantagem entre AcquireNodeeTriangleRendererbem como entre TriangleRenderere PresentNode. Isso garante que a imagem da cadeia de buffer seja sincronizada corretamente durante seu uso durante o quadro.


Eu juro que dentro do motor mudou

Dia 4


Criei uma câmera e um sistema de renderização em 3D. Até agora, a câmera recebe seu próprio buffer persistente e conjunto de descritores.

Eu diminuí a velocidade naquele dia porque estava tentando encontrar a configuração correta para renderização em 3D com o Vulkan. A maioria dos materiais on-line se concentra na renderização usando o OpenGL, que usa sistemas de coordenadas um pouco diferentes do Vulkan. No OpenGL, o eixo Z do espaço do clipe é especificado como [-1, 1]e a borda superior da tela está em Y = 1. No Vulkan, o eixo Z é especificado como [0, 1]e a borda superior da tela está em Y = -1. Devido a essas pequenas diferenças, as matrizes de projeção GLM padrão não funcionam corretamente porque foram projetadas para o OpenGL.

GLM tem uma opçãoGLM_FORCE_DEPTH_ZERO_TO_ONE, eliminando o problema com o eixo Z. Depois disso, o problema com o eixo Y pode ser eliminado simplesmente alterando o sinal do elemento da (1, 1)matriz de projeção (o GLM usa a indexação de 0).

Se invertermos o eixo Y, precisamos inverter os dados do vértice, porque antes disso, a direção negativa do eixo Y apontava para cima.


Agora em 3D!

Dia 5


Eu adicionei a entrada do usuário e a capacidade de mover a câmera com o mouse. O sistema de entrada é muito sofisticado, mas elimina as esquisitices da entrada GLFW. Em particular, tive o problema de alterar a posição do mouse enquanto o bloqueava.

A entrada do teclado e do mouse é essencialmente um invólucro fino no GLFW, aberto por meio de manipuladores de sinal entt.

Apenas para comparação - sobre a mesma coisa que Hopson fez no primeiro dia de seu projeto.


Dia 6


Comecei a adicionar código para gerar e renderizar blocos voxel. Escrever o código da malha foi fácil, porque eu fiz isso antes e conhecia abstrações que me permitiam cometer menos erros.

Uma das abstrações era uma classe de modelo ChunkData<T, chunkSize>que define um cubo do tipo o Ttamanho chunkSizede cada lado. Esta classe armazena dados em uma matriz 1D e processa dados de indexação com uma coordenada 3D. O tamanho de cada bloco é 16 x 16 x 16, portanto, os dados internos são uma matriz simples com um comprimento de 4096.

Outra abstração é criar um iterador de posições que gera coordenadas de (0, 0, 0)a(15, 15, 15). Essas duas classes garantem que as iterações com os dados do bloco sejam executadas em uma ordem linear para aumentar a localidade do cache. A coordenada 3D ainda está disponível para outras operações que precisam. Por exemplo:

for (glm::ivec3 pos : Chunk::Positions()) {
    auto& data = chunkData[pos];
    glm::ivec3 offset = ...;
    auto& neighborData = chunkData[pos + offset];
}

Eu tenho várias matrizes estáticas que especificam as compensações que são comumente usadas no jogo. Por exemplo, Neighbors6define 6 vizinhos com os quais o cubo tem faces comuns.

static constexpr std::array<glm::ivec3, 6> Neighbors6 = {
        glm::ivec3(1, 0, 0),    //right
        glm::ivec3(-1, 0, 0),   //left
        glm::ivec3(0, 1, 0),    //top
        glm::ivec3(0, -1, 0),   //bottom
        glm::ivec3(0, 0, 1),    //front
        glm::ivec3(0, 0, -1)    //back
    };

Neighbors26- são todos os vizinhos com quem o cubo tem uma face, aresta ou vértice comum. Ou seja, é uma grade 3x3x3 sem um cubo central. Também existem matrizes semelhantes para outros conjuntos de vizinhos e para conjuntos 2D de vizinhos.

Há uma matriz que define os dados necessários para criar uma face do cubo. As direções de cada face nesta matriz correspondem às direções na matriz Neighbors6.

static constexpr std::array<FaceArray, 6> NeighborFaces = {
    //right face
    FaceArray {
        glm::ivec3(1, 1, 1),
        glm::ivec3(1, 1, 0),
        glm::ivec3(1, 0, 1),
        glm::ivec3(1, 0, 0),
    },
    ...
};

Graças a isso, o código de criação da malha é muito simples. Ele simplesmente ignora os dados dos blocos e adiciona uma face quando o bloco é sólido, mas seu vizinho não. O código simplesmente verifica todas as faces de cada cubo em um bloco. Isso é semelhante ao método "ingênuo" descrito aqui .

for (glm::ivec3 pos : Chunk::Positions()) {
    Block block = chunk.blocks()[pos];
    if (block.type == 0) continue;

    for (size_t i = 0; i < Chunk::Neighbors6.size(); i++) {
        glm::ivec3 offset = Chunk::Neighbors6[i];
        glm::ivec3 neighborPos = pos + offset;

        //NOTE: bounds checking omitted

        if (chunk.blocks()[neighborPos].type == 0) {
            Chunk::FaceArray& faceArray = Chunk::NeighborFaces[i];
            for (size_t j = 0; j < faceArray.size(); j++) {
                m_vertexData.push_back(pos + faceArray[j]);
                m_colorData.push_back(glm::i8vec4(pos.x * 16, pos.y * 16, pos.z * 16, 0));
            }
        }
    }
}

Eu substituí TriangleRendererpor ChunkRenderer. Também adicionei um buffer de profundidade para que a malha do bloco possa renderizar corretamente. Foi necessário adicionar mais uma aresta ao gráfico de renderização entre TransferNodee ChunkRenderer. Essa borda transfere a propriedade dos recursos da família de filas entre a fila de transferência e a fila de gráficos.

Então mudei o mecanismo para que ele pudesse manipular corretamente os eventos de alteração de janela. No OpenGL, isso é feito de forma simples, mas bastante confusa no Vulkan. Como a cadeia de buffers deve ser criada explicitamente e ter um tamanho constante, quando você redimensiona a janela, é necessário recriá-la. Você deve recriar todos os recursos que dependem da cadeia de buffers.

Todos os comandos que dependem da cadeia de buffers (e agora todos são comandos de desenho) devem concluir a execução antes de destruir a antiga cadeia de buffers. Isso significa que a GPU inteira ficará ociosa.

Você precisa alterar o pipeline de gráficos para fornecer uma viewport e redimensionamento dinâmicos.

Uma cadeia de buffers não pode ser criada se o tamanho da janela for 0 no eixo X ou Y. Incluindo quando a janela é minimizada. Ou seja, quando isso acontece, o jogo inteiro é pausado e continua apenas quando a janela se abre.

Agora a malha é um simples tabuleiro de xadrez tridimensional. As cores RGB da malha são definidas de acordo com sua posição XYZ multiplicada por 16.



Dia 7


Eu fiz o processo do jogo não um, mas vários blocos de cada vez. Vários blocos e suas malhas são gerenciados pela biblioteca do ECS entt. Em seguida, refatorei o renderizador de blocos para renderizar todos os blocos que estão no ECS. Ainda tenho apenas um bloco, mas poderia adicionar novos, se necessário.

Refatorei a malha para que seus dados pudessem ser atualizados após a criação. Isso permitirá que eu atualize a malha do bloco no futuro quando adicionar a capacidade de adicionar e remover cubos.

Quando você adiciona ou remove um cubo, o número de vértices na malha pode aumentar ou diminuir potencialmente. O buffer de vértice selecionado anteriormente pode ser usado apenas se a nova malha for do mesmo tamanho ou menor. Mas se a malha for maior, novos buffers de vértice deverão ser criados.

O buffer de vértice anterior não pode ser excluído imediatamente. Pode haver buffers de instruções executados a partir de quadros anteriores que são específicos para um objeto específico VkBuffer. O mecanismo deve manter um buffer até que esses buffers de comando estejam completos. Ou seja, se desenharmos uma malha em um quadro i, a GPU poderá usar esse buffer antes do início do quadro i + 2. O buffer não pode ser removido da CPU até que a GPU termine de usá-lo. Então, mudei o gráfico de renderização para acompanhar o tempo de vida dos recursos.

Se o nó do gráfico de renderização quiser usar um recurso (buffer ou imagem), deverá chamar o método syncdentro do método preRender. Este método obtém um ponteiro shared_ptrpara um recurso. esteshared_ptrgarante que o recurso não seja excluído enquanto os buffers de comando forem executados. (Em termos de desempenho, esta solução não é muito boa. Mais sobre isso mais adiante.)

Agora a malha do bloco é regenerada em cada quadro.


Conclusão


Foi tudo o que fiz em uma semana - preparei o básico para renderizar o mundo com vários blocos de voxel e continuará a funcionar na segunda semana.

Source: https://habr.com/ru/post/undefined/


All Articles