Creación de Minecraft en una semana en C ++ y Vulkan

Me propuse la tarea de recrear Minecraft desde cero en una semana usando mi propio motor en C ++ y Vulkan. Me inspiró Hopson , que hizo lo mismo con C ++ y OpenGL. A su vez, se inspiró en Shane Beck , quien se inspiró en Minecraft, cuya fuente de inspiración fue Infiniminer, cuya creación, presumiblemente, se inspiró en la minería real.


El repositorio de GitHub para este proyecto está aquí . Cada día tiene su propia etiqueta git.

Por supuesto, no planeé recrear literalmente Minecraft. Se suponía que este proyecto era educativo. Quería aprender a usar Vulkan en algo más complicado que vulkan-tutorial.com o la demostración de Sasha Willem. Por lo tanto, el énfasis principal está en el diseño del motor Vulkan, y no en el diseño del juego.

Tareas


El desarrollo en Vulkan es mucho más lento que en OpenGL, por lo que no pude incorporar muchas de las características de este Minecraft en el juego. No hay mobs, ni artesanías, ni piedra roja, ni física de bloques, etc. Desde el principio, los objetivos del proyecto fueron los siguientes:

  • Crear un sistema de representación del terreno
    • Trituración
    • Encendiendo
  • Crear un sistema generador de terreno
    • Alivio
    • Arboles
    • Biomas
  • Agregar la capacidad de cambiar el terreno y mover bloques

Necesitaba encontrar una manera de implementar todo esto sin agregar una GUI al juego, porque no pude encontrar ninguna biblioteca de GUI que funcionara con Vulkan y fuera fácil de integrar.

Bibliotecas


Por supuesto, no iba a escribir una aplicación Vulkan desde cero. Para acelerar el proceso de desarrollo, utilizaré bibliotecas listas siempre que sea posible. A saber:

  • VulkanWrapper : mi propio contenedor de C ++ para la API de Vulkan
  • GLFW - para ventanas y entrada del usuario
  • VulkanMemoryAllocator - para asignar memoria Vulkan
  • GLM - para vectores matemáticos y matrices
  • entt - para señales / slots y ECS
  • stb : para utilidades de carga de imágenes
  • FastNoise : para generar ruido 3D

Día 1


El primer día, preparé una caldera Vulkan y un esqueleto del motor. La mayor parte del código era repetitivo y podía copiarlo de vulkan-tutorial.com . También incluyó un truco con el almacenamiento de datos de vértices como parte de un sombreador de vértices. Esto significaba que ni siquiera tenía que ajustar la asignación de memoria. Solo un transportador simple que solo puede hacer una cosa: dibujar un triángulo.

El motor es lo suficientemente simple como para soportar el renderizador de triángulos. Tiene una ventana y un bucle de juego al que se pueden conectar los sistemas. La GUI está limitada por la velocidad de fotogramas que se muestra en el título de la ventana.

El proyecto se divide en dos partes: VoxelEnginey VoxelGame.


Dia 2


Integré la biblioteca Vulkan Memory Allocator. Esta biblioteca se encarga de la mayor parte de la plantilla de asignación de memoria de Vulkan: tipos de memoria, montones de memoria del dispositivo y asignación secundaria.

Ahora que tenía una asignación de memoria, creé clases para mallas y búferes de vértices. Cambié el renderizador de los triángulos para que use la clase de mallas, y no las matrices integradas en el sombreador. Actualmente, los datos de malla se transfieren a la GPU renderizando manualmente los triángulos.


Poco ha cambiado

Día 3


Agregué un sistema de representación gráfica. Esta publicación se tomó como base para crear esta clase , pero la clase está muy simplificada. Mi gráfico de representación contiene solo los elementos esenciales para manejar la sincronización con Vulkan.

El gráfico de renderizado me permite establecer nodos y bordes. Los nodos son el trabajo realizado por la GPU. Las costillas son dependencias de datos entre nodos. Cada nodo recibe su propio búfer de instrucciones, en el que escribe. El gráfico está involucrado en búferes de comando de almacenamiento en búfer doble y sincronizándolos con fotogramas anteriores. Los bordes se utilizan para insertar automáticamente barreras transportadoras antes y después de que un nodo escriba en cada búfer de instrucciones. Las barreras de tubería sincronizan el uso de todos los recursos y transfieren la propiedad entre colas. Además, los bordes insertan semáforos entre nodos.

Los nodos y los bordes forman un gráfico acíclico dirigido . Luego, el gráfico de representación realiza la clasificación topológica.nodos, lo que lleva a la creación de una lista plana de nodos ordenados para que cada nodo vaya después de todos los nodos de los que depende.

El motor tiene tres tipos de nodos. AcquireNoderecibe una imagen de una cadena de búfer (cadena de intercambio), TransferNodetransfiere datos de la CPU a la GPU y PresentNodeproporciona una imagen de una cadena de búfer para mostrar.

Cada nodo puede implementar preRender, rendery postRender, que se ejecutan en cada marco. AcquireNodeobtiene una imagen de una cadena de buffers durante preRender. PresentNodeproporciona esta imagen a tiempo postRender.

Refactoré el renderizador de triángulos para que use un sistema de representación gráfica, en lugar de procesar todo yo mismo. Hay un borde entre AcquireNodeyTriangleRendererasí como entre TriangleRenderery PresentNode. Esto garantiza que la imagen de la cadena de búfer esté sincronizada correctamente durante su uso durante el marco.


Juro que dentro del motor ha cambiado

Día 4


Creé una cámara y un sistema de renderizado 3D. Hasta ahora, la cámara recibe su propio búfer persistente y grupo de descriptores.

Reduje la velocidad ese día porque estaba tratando de encontrar la configuración correcta para el renderizado 3D con Vulkan. La mayoría del material en línea se enfoca en renderizar usando OpenGL, que usa sistemas de coordenadas ligeramente diferentes de Vulkan. En OpenGL, el eje Z del espacio del clip se especifica como [-1, 1]y el borde superior de la pantalla está en Y = 1. En Vulkan, el eje Z se especifica como [0, 1]y el borde superior de la pantalla está en Y = -1. Debido a estas pequeñas diferencias, las matrices de proyección GLM estándar no funcionan correctamente porque están diseñadas para OpenGL.

GLM tiene una opciónGLM_FORCE_DEPTH_ZERO_TO_ONE, eliminando el problema con el eje Z. Después de eso, el problema con el eje Y puede eliminarse simplemente cambiando el signo del elemento de (1, 1)matriz de proyección (GLM usa la indexación desde 0).

Si volteamos el eje Y, entonces necesitamos voltear los datos del vértice, porque antes de eso, la dirección negativa del eje Y apuntaba hacia arriba.


Ahora en 3D!

Dia 5


Agregué la entrada del usuario y la capacidad de mover la cámara con el mouse. El sistema de entrada es demasiado sofisticado, pero elimina las rarezas de la entrada GLFW. En particular, tuve el problema de cambiar la posición del mouse mientras lo bloqueaba.

La entrada del teclado y el mouse es esencialmente una envoltura delgada en la parte superior de GLFW, abierta a través de controladores de señal entt.

Solo para comparar, casi lo mismo que Hopson hizo el día 1 de su proyecto.


Día 6


Comencé a agregar código para generar y renderizar bloques voxel. Escribir el código de malla fue fácil porque lo hice antes y conocía abstracciones que me permitieron cometer menos errores.

Una de las abstracciones fue una clase de plantilla ChunkData<T, chunkSize>que define un cubo de tipo del Ttamaño chunkSizede cada lado. Esta clase almacena datos en una matriz 1D y procesa datos de indexación con una coordenada 3D. El tamaño de cada bloque es de 16 x 16 x 16, por lo que los datos internos son una matriz simple con una longitud de 4096.

Otra abstracción es crear un iterador de posiciones que genere coordenadas de (0, 0, 0)a(15, 15, 15). Estas dos clases aseguran que las iteraciones con datos de bloque se realicen en un orden lineal para aumentar la localidad de caché. La coordenada 3D aún está disponible para otras operaciones que la necesitan. Por ejemplo:

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

Tengo varias matrices estáticas que especifican las compensaciones que se usan comúnmente en el juego. Por ejemplo, Neighbors6define 6 vecinos con los que el cubo tiene caras comunes.

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- todos estos son vecinos con los que el cubo tiene una cara, borde o vértice común. Es decir, es una cuadrícula de 3x3x3 sin un cubo central. También hay matrices similares para otros conjuntos de vecinos y para conjuntos de vecinos 2D.

Hay una matriz que define los datos necesarios para crear una cara del cubo. Las direcciones de cada cara en esta matriz corresponden a las direcciones en la 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),
    },
    ...
};

Gracias a esto, el código de creación de malla es muy simple. Simplemente omite los datos de los bloques y agrega una cara cuando el bloque es sólido, pero su vecino no lo es. El código simplemente verifica cada cara de cada cubo en un bloque. Esto es similar al método "ingenuo" descrito aquí .

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));
            }
        }
    }
}

Reemplacé TriangleRenderercon ChunkRenderer. También agregué un búfer de profundidad para que la malla de bloques pueda renderizarse correctamente. Era necesario agregar un borde más al gráfico de representación entre TransferNodey ChunkRenderer. Este borde transfiere la propiedad de los recursos de la familia de colas entre la cola de transferencia y la cola de gráficos.

Luego cambié el motor para que pudiera manejar correctamente los eventos de cambio de ventana. En OpenGL, esto se hace de manera simple, pero bastante confusa en Vulkan. Dado que la cadena de buffers debe crearse explícitamente y tener un tamaño constante, cuando cambie el tamaño de la ventana, debe volver a crearla. Debe recrear todos los recursos que dependen de la cadena de búfer.

Todos los comandos que dependen de la cadena de almacenamiento intermedio (y ahora todos estos son comandos de dibujo) deben completar la ejecución antes de destruir la cadena de almacenamiento intermedio anterior. Esto significa que toda la GPU estará inactiva.

Debe cambiar la canalización de gráficos para proporcionar una ventana gráfica dinámica y cambiar el tamaño.

No se puede crear una cadena de búfer si el tamaño de la ventana es 0 en el eje X o Y. Incluso cuando la ventana está minimizada. Es decir, cuando esto sucede, todo el juego se detiene y continúa solo cuando se abre la ventana.

Ahora la malla es un simple tablero de ajedrez tridimensional. Los colores RGB de la malla se configuran de acuerdo con su posición XYZ multiplicada por 16.



Día 7


Hice el proceso del juego no uno, sino varios bloques a la vez. La biblioteca ECS gestiona múltiples bloques y sus mallas entt. Luego refactoré el renderizador de bloques para que renderizara todos los bloques que están en ECS. Todavía tengo solo un bloque, pero podría agregar otros nuevos si es necesario.

Refactoré la malla para que sus datos pudieran actualizarse después de su creación. Esto me permitirá actualizar la malla de bloques en el futuro cuando agregue la capacidad de agregar y eliminar cubos.

Cuando agrega o elimina un cubo, el número de vértices en la malla puede aumentar o disminuir potencialmente. El búfer de vértices seleccionado previamente solo se puede usar si la nueva malla es del mismo tamaño o menor. Pero si la malla es más grande, entonces se deben crear nuevos búferes de vértices.

El búfer de vértices anterior no se puede eliminar de inmediato. Puede haber almacenamientos intermedios de instrucciones ejecutados desde cuadros anteriores que son específicos de un objeto en particular VkBuffer. El motor debe mantener un búfer hasta que se completen estos búferes de comando. Es decir, si dibujamos una malla en un marco i, la GPU puede usar este búfer antes de que comience el marco i + 2. El búfer no se puede quitar de la CPU hasta que la GPU haya terminado de usarlo. Así que cambié el gráfico de representación para que rastree la vida útil de los recursos.

Si el nodo del gráfico de representación desea utilizar un recurso (búfer o imagen), debe llamar al método syncdentro del método preRender. Este método obtiene un puntero shared_ptra un recurso. Estashared_ptrgarantiza que el recurso no se eliminará mientras se ejecutan las memorias intermedias de comandos. (En términos de rendimiento, esta solución no es muy buena. Más sobre esto más adelante).

Ahora la malla de bloques se regenera en cada cuadro.


Conclusión


Eso es todo lo que hice en una semana: preparé los conceptos básicos para representar el mundo con múltiples bloques de vóxel y continuaré trabajando en la segunda semana.

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


All Articles