Minecraft-Erstellung in einer Woche in C ++ und Vulkan

Ich habe mir die Aufgabe gestellt, Minecraft in einer Woche mit meiner eigenen Engine in C ++ und Vulkan von Grund auf neu zu erstellen. Ich wurde von Hopson inspiriert , der dasselbe mit C ++ und OpenGL tat. Im Gegenzug wurde er von Shane Beck inspiriert, der von Minecraft inspiriert war, dessen Inspirationsquelle Infiniminer war, dessen Entstehung vermutlich vom echten Bergbau inspiriert war.


Das GitHub-Repository für dieses Projekt ist hier . Jeder Tag hat sein eigenes Git-Tag.

Natürlich hatte ich nicht vor, Minecraft buchstäblich neu zu erschaffen. Dieses Projekt sollte ein pädagogisches sein. Ich wollte lernen, wie man Vulkan in etwas Komplizierterem als vulkan-tutorial.com oder der Demo von Sasha Willem verwendet. Daher liegt der Schwerpunkt auf dem Design der Vulkan-Engine und nicht auf dem Design des Spiels.

Aufgaben


Die Entwicklung auf Vulkan ist viel langsamer als auf OpenGL, daher konnte ich viele der Funktionen dieses Minecraft nicht in das Spiel integrieren. Es gibt keine Mobs, kein Handwerk, keinen roten Stein, keine Blockphysik usw. Die Ziele des Projekts waren von Anfang an folgende:

  • Erstellen eines Terrain-Rendering-Systems
    • Maischen
    • Beleuchtung
  • Erstellen eines Geländegeneratorsystems
    • Linderung
    • Bäume
    • Biomes
  • Hinzufügen der Fähigkeit, das Gelände zu ändern und Blöcke zu bewegen

Ich musste einen Weg finden, dies alles zu implementieren, ohne dem Spiel eine GUI hinzuzufügen, da ich keine GUI-Bibliotheken finden konnte, die mit Vulkan funktionieren und einfach zu integrieren sind.

Bibliotheken


Natürlich wollte ich keine Vulkan-Anwendung von Grund auf neu schreiben. Um den Entwicklungsprozess zu beschleunigen, werde ich nach Möglichkeit vorgefertigte Bibliotheken verwenden. Nämlich:


Tag 1


Am ersten Tag bereitete ich eine Vulkan-Kesselplatte und ein Motorskelett vor. Der größte Teil des Codes war ein Boilerplate und ich konnte ihn einfach von vulkan-tutorial.com kopieren . Es enthielt auch einen Trick zum Speichern von Scheitelpunktdaten als Teil eines Scheitelpunkt-Shaders. Dies bedeutete, dass ich nicht einmal die Speicherzuordnung anpassen musste. Nur ein einfacher Förderer, der nur eines kann: ein Dreieck zeichnen.

Die Engine ist einfach genug, um den Renderer von Dreiecken zu unterstützen. Es hat ein Fenster und eine Spielschleife, an die Systeme angeschlossen werden können. Die GUI ist durch die im Fenstertitel angezeigte Bildrate begrenzt.

Das Projekt gliedert sich in zwei Teile: VoxelEngineund VoxelGame.


Tag 2


Ich habe die Vulkan Memory Allocator-Bibliothek integriert. Diese Bibliothek kümmert sich um die meisten Funktionen der Vulkan-Speicherzuweisung: Speichertypen, Gerätespeicherhaufen und sekundäre Zuweisung.

Nachdem ich eine Speicherzuordnung hatte, erstellte ich Klassen für Netze und Scheitelpunktpuffer. Ich habe den Renderer der Dreiecke so geändert, dass er die Klasse der Netze verwendet und nicht die im Shader integrierten Arrays. Derzeit werden Netzdaten durch manuelles Rendern der Dreiecke an die GPU übertragen.


Es hat sich wenig geändert

Tag 3


Ich habe ein Graph-Rendering-System hinzugefügt. Dieser Beitrag wurde als Grundlage für die Erstellung dieser Klasse verwendet , die Klasse ist jedoch sehr vereinfacht. Mein Rendering-Diagramm enthält nur die wesentlichen Elemente für die Synchronisierung mit Vulkan.

Mit dem Rendering-Diagramm kann ich Knoten und Kanten festlegen. Knoten sind die von der GPU ausgeführten Arbeiten. Rippen sind Datenabhängigkeiten zwischen Knoten. Jeder Knoten erhält einen eigenen Befehlspuffer, in den er schreibt. Der Graph beschäftigt sich mit doppelten Pufferbefehlspuffern und synchronisiert diese mit vorherigen Frames. Kanten werden verwendet, um Förderbarrieren automatisch vor und nach dem Schreiben eines Knotens in jeden Befehlspuffer einzufügen. Pipeline-Barrieren synchronisieren die Verwendung aller Ressourcen und übertragen den Besitz zwischen Warteschlangen. Außerdem fügen Kanten Semaphoren zwischen Knoten ein.

Knoten und Kanten bilden einen gerichteten azyklischen Graphen . Anschließend führt das Rendering-Diagramm eine topologische Sortierung durch.Knoten, was zur Erstellung einer flachen Liste von Knoten führt, die so sortiert sind, dass jeder Knoten nach allen Knoten sucht, von denen er abhängt.

Die Engine hat drei Arten von Knoten. AcquireNodeempfängt ein Bild von der Pufferkette (Swapchain), TransferNodeüberträgt Daten von der CPU zur GPU und PresentNodeliefert ein Bild der anzuzeigenden Pufferkette .

Jeder Knoten kann implementieren preRender, renderund postRender, die in jedem Frame ausgeführt. AcquireNodeerhält ein Bild einer Pufferkette während preRender. PresentNodeliefert dieses Bild pünktlich postRender.

Ich habe den Dreiecks-Renderer so überarbeitet, dass er ein Rendering-Diagrammsystem verwendet, anstatt alles selbst zu verarbeiten. Es gibt eine Kante zwischen AcquireNodeundTriangleRenderersowie zwischen TriangleRendererund PresentNode. Dies stellt sicher, dass das Bild der Pufferkette während ihrer Verwendung während des Rahmens korrekt synchronisiert wird.


Ich schwöre, der Motor hat sich verändert

Tag 4


Ich habe eine Kamera und ein 3D-Rendering-System erstellt. Bisher erhält die Kamera einen eigenen permanenten Puffer- und Deskriptorpool.

Ich habe mich an diesem Tag verlangsamt, weil ich versucht habe, die richtige Konfiguration für das 3D-Rendering mit Vulkan zu finden. Das meiste Online-Material konzentriert sich auf das Rendern mit OpenGL, das leicht andere Koordinatensysteme als Vulkan verwendet. In OpenGL wird die Z-Achse des Clip-Bereichs als angegeben [-1, 1]und der obere Rand des Bildschirms befindet sich bei Y = 1. In Vulkan wird die Z-Achse als angegeben [0, 1]und der obere Rand des Bildschirms befindet sich bei Y = -1. Aufgrund dieser kleinen Unterschiede funktionieren die Standard-GLM-Projektionsmatrizen nicht richtig, da sie für OpenGL ausgelegt sind.

GLM hat eine OptionGLM_FORCE_DEPTH_ZERO_TO_ONEDanach kann das Problem mit der Y-Achse behoben werden, indem das Vorzeichen des (1, 1)Projektionsmatrixelements einfach geändert wird (GLM verwendet die Indizierung von 0).

Wenn wir die Y-Achse umdrehen, müssen wir die Scheitelpunktdaten umdrehen, da zuvor die negative Richtung der Y-Achse nach oben zeigte.


Jetzt in 3D!

Tag 5


Ich habe Benutzereingaben und die Möglichkeit hinzugefügt, die Kamera mit der Maus zu bewegen. Das Eingabesystem ist zu ausgefeilt, beseitigt jedoch die Kuriositäten der GLFW-Eingabe. Insbesondere hatte ich das Problem, die Position der Maus beim Blockieren zu ändern.

Die Tastatur- und Mauseingabe ist im Wesentlichen ein dünner Wrapper über GLFW, der über Signalhandler geöffnet wird entt.

Nur zum Vergleich - ungefähr das gleiche, was Hopson am ersten Tag seines Projekts getan hat.


Tag 6


Ich fing an, Code hinzuzufügen, um Voxelblöcke zu generieren und zu rendern. Das Schreiben des Vernetzungscodes war einfach, da ich es zuvor getan hatte und Abstraktionen kannte, mit denen ich weniger Fehler machen konnte.

Eine der Abstraktionen war eine Vorlagenklasse ChunkData<T, chunkSize>, die einen Würfel vom Typ der TGröße chunkSizejeder Seite definiert. Diese Klasse speichert Daten in einem 1D-Array und verarbeitet Indizierungsdaten mit einer 3D-Koordinate. Die Größe jedes Blocks beträgt 16 x 16 x 16, daher sind die internen Daten ein einfaches Array mit einer Länge von 4096.

Eine andere Abstraktion besteht darin, einen Iterator von Positionen zu erstellen, der Koordinaten von (0, 0, 0)bis generiert(15, 15, 15). Diese beiden Klassen stellen sicher, dass Iterationen mit Blockdaten in einer linearen Reihenfolge ausgeführt werden, um die Cache-Lokalität zu erhöhen. Die 3D-Koordinate ist weiterhin für andere Operationen verfügbar, die sie benötigen. Zum Beispiel:

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

Ich habe mehrere statische Arrays, die die Offsets angeben, die üblicherweise im Spiel verwendet werden. Beispielsweise werden Neighbors66 Nachbarn definiert, mit denen der Würfel gemeinsame Flächen hat.

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- Dies sind alles Nachbarn, mit denen der Würfel eine gemeinsame Fläche, Kante oder einen gemeinsamen Scheitelpunkt hat. Das heißt, es ist ein 3x3x3-Raster ohne zentralen Würfel. Es gibt auch ähnliche Arrays für andere Sätze von Nachbarn und für 2D-Sätze von Nachbarn.

Es gibt ein Array, das die Daten definiert, die zum Erstellen einer Seite des Cubes erforderlich sind. Die Richtungen jeder Fläche in diesem Array entsprechen den Richtungen in dem Array 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),
    },
    ...
};

Dank dessen ist der Code zur Netzerstellung sehr einfach. Es umgeht einfach die Daten der Blöcke und fügt eine Fläche hinzu, wenn der Block fest ist, sein Nachbar jedoch nicht. Der Code überprüft einfach jede Seite jedes Würfels in einem Block. Dies ähnelt der hier beschriebenen "naiven" Methode .

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

Ich habe ersetzt TriangleRendererdurch ChunkRenderer. Ich habe auch einen Tiefenpuffer hinzugefügt, damit das Blocknetz korrekt gerendert werden kann. Es war notwendig, dem Rendering-Diagramm zwischen TransferNodeund eine weitere Kante hinzuzufügen ChunkRenderer. Diese Kante überträgt den Besitz der Ressourcen der Warteschlangenfamilie zwischen der Übertragungswarteschlange und der Grafikwarteschlange.

Dann habe ich die Engine so geändert, dass Fensterwechselereignisse korrekt verarbeitet werden können. In OpenGL geschieht dies einfach, in Vulkan jedoch eher verwirrend. Da die Pufferkette explizit erstellt werden muss und eine konstante Größe hat, müssen Sie das Fenster beim Ändern der Größe neu erstellen. Sie müssen alle Ressourcen neu erstellen, die von der Pufferkette abhängen.

Alle Befehle, die von der Pufferkette abhängen (und jetzt sind dies alle Zeichenbefehle), müssen die Ausführung abschließen, bevor die alte Pufferkette zerstört wird. Dies bedeutet, dass die gesamte GPU inaktiv ist.

Sie müssen die Grafikpipeline ändern, um ein dynamisches Ansichtsfenster bereitzustellen und die Größe zu ändern.

Eine Pufferkette kann nicht erstellt werden, wenn die Fenstergröße auf der X- oder Y-Achse 0 beträgt, auch wenn das Fenster minimiert ist. Das heißt, wenn dies geschieht, wird das gesamte Spiel angehalten und nur fortgesetzt, wenn sich das Fenster öffnet.

Jetzt ist das Netz ein einfaches dreidimensionales Schachbrett. Die RGB-Farben des Netzes werden entsprechend seiner XYZ-Position multipliziert mit 16 eingestellt.



Tag 7


Ich habe den Spielprozess nicht nur für einen, sondern für mehrere Blöcke gleichzeitig durchgeführt. Mehrere Blöcke und ihre Netze werden von der ECS-Bibliothek verwaltet entt. Dann habe ich den Block-Renderer so überarbeitet, dass alle Blöcke in ECS gerendert wurden. Ich habe immer noch nur einen Block, aber ich könnte bei Bedarf neue hinzufügen.

Ich habe das Netz überarbeitet, damit seine Daten nach der Erstellung aktualisiert werden können. Auf diese Weise kann ich das Blocknetz in Zukunft aktualisieren, wenn ich die Möglichkeit zum Hinzufügen und Entfernen von Cubes hinzufüge.

Wenn Sie einen Würfel hinzufügen oder entfernen, kann die Anzahl der Scheitelpunkte im Netz möglicherweise zunehmen oder abnehmen. Der zuvor ausgewählte Scheitelpunktpuffer kann nur verwendet werden, wenn das neue Netz dieselbe Größe oder kleiner hat. Wenn das Netz jedoch größer ist, müssen neue Scheitelpunktpuffer erstellt werden.

Der vorherige Scheitelpunktpuffer kann nicht sofort gelöscht werden. Es können Befehlspuffer aus vorherigen Frames ausgeführt werden, die für ein bestimmtes Objekt spezifisch sind VkBuffer. Die Engine muss einen Puffer behalten, bis diese Befehlspuffer vollständig sind. Das heißt, wenn wir ein Netz in einem Frame zeichnen i, kann die GPU diesen Puffer verwenden, bevor der Frame startet i + 2. Der Puffer kann nicht aus der CPU entfernt werden, bis die GPU ihn nicht mehr verwendet. Deshalb habe ich das Rendering-Diagramm so geändert, dass es die Lebensdauer der Ressourcen verfolgt.

Wenn der Rendering-Diagrammknoten eine Ressource (Puffer oder Bild) verwenden möchte, muss er die Methode syncinnerhalb der Methode aufrufen preRender. Diese Methode erhält einen Zeiger shared_ptrauf eine Ressource. Dieseshared_ptrstellt sicher, dass die Ressource nicht gelöscht wird, während Befehlspuffer ausgeführt werden. (In Bezug auf die Leistung ist diese Lösung nicht sehr gut. Dazu später mehr.)

Jetzt wird das Blocknetz in jedem Frame neu generiert.


Fazit


Das ist alles, was ich in einer Woche getan habe - ich habe die Grundlagen für das Rendern der Welt mit mehreren Voxelblöcken vorbereitet und werde in der zweiten Woche weiterarbeiten.

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


All Articles