In diesem Kapitel werden wir einen Sternenhimmel in den Hintergrund laden und dafür sorgen, dass einige Objekte statisch im Raum platziert sind. Damit entsteht der Eindruck von großer Entfernung.

Eine Sky Box basiert auf dem Prinzip des Cube Mappings. In Computerspielen wird dieses Verfahren genutzt um Teile der weit entfernten Umgebung des Spielers mittels einer speziellen Projektionstechnik auf einen Würfel zu projizieren. Auf diese Weise können komplexe Landschaften oder weit entfernte Objekte angezeigt werden ohne die tatsächliche Geometrie dieser Objekte laden zu müssen. In unserem Anwendungsfall nutzen wir es als einfache Möglichkeit die Sterne zu rendern.

Cube Map Mesh und Star Map Textur

Für dieses Kapitel benötigen wir wieder einige neue Dateien. Einerseits die Mesh Datei für eine Cube Map auf die wir später die Sterne rendern wollen. Im Grunde genommen handelt es sich dabei um einen einfachen Würfel dessen 6 Seiten nach einem bestimmten Projektionsverfahren auf eine Textur gemappt sind.

Hier der Würfel für die Cube Map, links ohne Backface Culling und rechts mit.
Und hier die Textur die auf diesen Würfel gemappt ist.

Mesh Datei

Laden Sie sich die Mesh Datei herunter und speichern Sie sie in den passenden Ordner:

Texturen

Laden Sie sich die folgenden beiden Texturen herunter und speichern Sie sie in den passenden Ordner:

Die cubemap_x.jpg ist eine Referenzgrafik die man zum Testen der Implementierung und zum besseren Verständnis verwenden kann.

Skybox Model

Die .model Datei bringt Mesh und Textur zusammen. Als Shader kommt der einfache Shader ohne Beleuchtung zum Einsatz, damit die Sterne gleichmäßig hell erscheinen.

models/skybox.model

m meshes/skybox.obj s shaders/vertex_shader.glsl shaders/fragment_shader.glsl t Diffuse textures/cubemap8k.jpg

Zum Testen laden wir dieses Modell und schauen uns an wie es aussieht.

renderer.cpp

Renderer::Renderer(const Settings &settings, Window &window) { ... size_t skyboxModelID = loadModel("models/skybox.model"); updateModel(skyboxModelId, Vector3(0, 0, 0), Vector3(0, 0, 0), 1.0); size_t thmModelID = loadModel("models/thm.model"); updateModel(thmModelID, Vector3(-1, 0, 0), Vector3(0, 0, 0), 1.0); size_t earthModelID = loadModel("models/earth.model"); updateModel(earthModelID, Vector3(1, 0, 0), Vector3(0, 0, 0), 1.0); }

Wenn Sie dieses Modell laden und die Engine starten, dann sollten Sie bereits Sterne sehen. Der optische Effekt, dass diese Sterne auch im Hintergrund sind und man nicht um die Sterne herumlaufen kann, fehlt allerdings noch.

Model Klasse erweitern

Die Sterne veranschaulichen sehr schön, dass in der Computergrafik ab und zu mit Trick gearbeitet werden muss um bestimmte optische Effekte zu erreichen. Die im vorherigen Abschnitt geladene Skybox funktioniert nicht als solche, weil Sie wie ein Objekt in der 3D Umgebung positioniert wird. Damit die Skybox den Effekt von weiter Entfernung suggeriert, muss sie so gezeichnet werden, also wäre sie fest mit der Position der Kamera verbunden.

Um das zu erreichen, erweitern wir unsere Model Klasse um das Flag fixed.

Modelle die als fixed markiert werden, sollen sich mit der Kamera mit bewegen. Diese Fixierung gilt nur für die Translation, nicht aber für die Rotation. Außerdem sollen diese Modelle den Tiefenpuffer ignorieren.

model.h

class Model { public: Model(const std::string &filename, bool fixed); ... private: bool fixed = false; ... };

Im Konstruktor wird das neue Feld befüllt. Der Rest bleibt unverändert.

model.cpp

Model::Model(const std::string &filename, bool fixed) : fixed(fixed) { ... }

Die Model::render() Funktion erhält die oben beschriebene Funktionsweise.

model.cpp

void Model::render(const Matrix4 &projectionMatrix, const Matrix4 &viewMatrix, const Vector3 &sunDirection, const Vector3 &cameraPosition) { Vector3 pos = position; if (fixed) { pos.x += cameraPosition.x; pos.y += cameraPosition.y; pos.z += cameraPosition.z; glDepthMask(GL_FALSE); glDepthFunc(GL_ALWAYS); } Matrix4 worldMatrix = Matrix4::translate(pos.x, pos.y, pos.z) * Matrix4::rotate(rotation.x, rotation.y, rotation.z) * Matrix4::scale(scale); ... mesh->draw(); if (fixed) { glDepthMask(GL_TRUE); glDepthFunc(GL_LESS); } }

Um das Ergebnis zu sehen, müssen wir das Fixed Flag im Konstruktor der Skybox setzen.

Renderer um Fixed-Flag erweitern

renderer.h

class Renderer { public: .. size_t loadModel(const std::string &filename, bool fixed = false); ...

renderer.cpp

size_t Renderer::loadModel(const std::string &filename, bool fixed) { size_t modelId = ++currentModelId; models.emplace( std::piecewise_construct, std::forward_as_tuple(modelId), std::forward_as_tuple(filename, fixed) ); return modelId; }

renderer.cpp

Renderer::Renderer(const Settings &settings, Window &window) { ... size_t skyboxModelID = loadModel("models/skybox.model", true); ... }

Nun sollten Sie in der Lage sein, alles zu testen. In den nachfolgenden Schritten werden wir das Laden von Modellen aus dem Renderer entfernen, da dies zukünftig extern gesteuert werden soll. Prüfen Sie daher jetzt ob bis hier hin alles funktioniert.

Die Szene als letzte Abstraktionsebene

In den letzten Kapiteln haben wir durch die Einführung von Modellen bereits erreicht, dass wir Änderungen an einzelnen Modellen vornehmen können, ohne den Code neu kompilieren zu müssen. So können wir beispielsweise die Textur eines Modells austauschen oder den Shader wechseln.

Auf dem Weg zur universellen Engine fehlt aber noch die Definition der gesamten Szene, so dass auch komplett neue Modelle hinzugefügt werden können, ohne den Code neu zu kompilieren. Dafür definieren wir nun ein entsprechendes Format für .scene Dateien.

scenes/main.scene

e Sky m models/skybox.model fixed p 0.0 0.0 0.0 r 0.0 0.0 0.0 e Earth m models/earth.model fixed p 0.0 1.0 -2.0 r 0.0 0.0 0.0 e THM m models/thm.model p 1.0 0.0 0.0 r 0.0 0.0 0.0

Die folgenden Parameter werden unterstützt:

  • e: Name der Entity
  • m: Modell-Datei [optional: fixed]
  • p: Position
  • r: Rotation
  • s: Skalierung

Eine Szene besteht aus einer Liste von Entities

Der Begriff Entity ist eben schon in der Beschreibung der .scene Dateien vorgekommen. Als Entity bezeichnen wir ab sofort alle logischen Objekte mit denen unser Engine zu tun hat. Eine Entity kann ein 3D Modell besitzen, muss aber nicht. Der Renderer ist dann in der Lage, dynamisch Modelle zu laden und zu entladen, währen die Entities weiterhin zum Beispiel für physikalische Berechnungen geladen bleiben. Auf diese Weise werden Grafik und Logik voneinander entkoppelt.

Wir definieren zuerst die Entity Klasse:

entity.h

#pragma once #include "cgmath.h" #include <cstdint> #include <functional> #include <optional> #include <string> using LoadModelCallback = std::optional<std::function<size_t(const std::string &filename, bool fixed)>>; using UpdateModelCallback = std::optional<std::function<void(size_t modelId, const Vector3 &position, const Vector3 &rotation, float scale)>>; using UnloadModelCallback = std::optional<std::function<void(size_t modelId)>>; class Entity { public: Entity(std::string name); ~Entity(); void setModelId(uint32_t modelId); void setPosition(Vector3 position); void setRotation(Vector3 rotation); void setScale(float scale); void load(); void update(double time, UpdateModelCallback); uint32_t getModelId() const; Vector3 getPosition() const; Vector3 getRotation() const; double getScale() const; private: std::string name; uint32_t modelId = 0; Vector3 position = Vector3(0, 0, 0); Vector3 rotation = Vector3(0, 0, 0); double scale = 1.0; };

Mit Hilfe der using Anweisungen definieren wir leichter lesbare Aliase für 3 verschiedene Callback Funktionen. Diese Callback werden wir gleich verwenden damit die Entity mit dem Renderer Kommunizieren kann aber auch damit die gesamte Simulation ohne Renderer lauffähig bleibt.

entity.cpp

#include "entity.h" Entity::Entity(std::string name) { this->name = name; } void Entity::setModelId(uint32_t modelId) { this->modelId = modelId; } void Entity::setPosition(Vector3 position) { this->position = position; } void Entity::setRotation(Vector3 rotation) { this->rotation = rotation; } void Entity::setScale(float scale) { this->scale = scale; } void Entity::update(double time, UpdateModelCallback updateModel) { // Perform physics updates here if (updateModel.has_value()) { updateModel.value()(modelId, position, rotation, scale); } } uint32_t Entity::getModelId() const { return modelId; } Vector3 Entity::getPosition() const { return position; } Vector3 Entity::getRotation() const { return rotation; } double Entity::getScale() const { return scale; } Entity::~Entity() { }

Wie sie sehen wird in der Implementierung lediglich das UpdateModelCallback verwendet. Dies ist aber ein optionaler Parameter und kann auch leer bleiben.

Nun definieren wir die Klasse Scene:

scene.h

#pragma once #include "entity.h" #include <string> #include <vector> class Scene { public: Scene(const std::string &filename, LoadModelCallback loadModelCallback); ~Scene(); void update(double time, UpdateModelCallback updateModelCallback); void unload(UnloadModelCallback unloadModelCallback); private: std::vector<Entity> entities; };

scene.cpp

#include "scene.h" #include "entity.h" #include "filereader.h" #include <iostream> Scene::Scene(const std::string &filename, LoadModelCallback loadModelCallback) { FileReader reader(filename); while (reader.hasLine()) { std::string type = reader.getString(); if (type == "e") { entities.emplace_back(reader.getString()); } else if (type == "m") { std::string modelFile = reader.getString(); std::string flag = reader.getString(); if (loadModelCallback.has_value()) { entities[entities.size() - 1].setModelId(loadModelCallback.value()(modelFile, flag == "fixed")); } } else if (type == "p") { entities[entities.size() - 1].setPosition(reader.getVector3()); } else if (type == "r") { Vector3 rotation = reader.getVector3(); rotation.x = deg2rad(rotation.x); rotation.y = deg2rad(rotation.y); rotation.z = deg2rad(rotation.z); entities[entities.size() - 1].setRotation(rotation); } else if (type == "s") { entities[entities.size() - 1].setScale(reader.getFloat()); } } } Scene::~Scene() { std::cout << "Scene destructor" << std::endl; entities.clear(); } void Scene::update(double time, UpdateModelCallback updateModel) { for (auto &entity : entities) { entity.update(time, updateModel); } } void Scene::unload(UnloadModelCallback unloadModel) { if (unloadModel.has_value()) { auto unloadModelCallback = unloadModel.value(); for (auto &entity : entities) { unloadModelCallback(entity.getModelId()); } } }

Hier finden wir nun die beiden übrigen Callbacks. Jeweils beim Laden und Entladen einer Szene kann mit Hilfe des Callbacks dem Renderer signalisiert werden, dass Modelle geladen oder entladen werden sollen.

Renderer anpassen und Modelle extern laden

Nun da wir Szenen haben, können wir die gesamte Logik zum Laden von Modelle aus dem Renderer entfernen. Der neue Konstruktor sieht dann fie folgt aus:

renderer.cpp

Renderer::Renderer(const Settings &settings, Window &window) { if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) { throw std::runtime_error("Failed to initialize GLAD"); } glfwSwapInterval(settings.vsync ? 1 : 0); if (settings.msaa) glEnable(GL_MULTISAMPLE); if (settings.culling) glEnable(GL_CULL_FACE); if (settings.depth) glEnable(GL_DEPTH_TEST); glClearColor(0.19f, 0.26f, 0.3f, 1.0f); window.onSizeChanged([this](int width, int height) { std::cout << "Resolution: " << width << "x" << height << std::endl; viewportWidth = width; viewportHeight = height; resizeViewport = true; }); }

Stattdessen laden wir nun die main.scene in unserer main.cpp. Mit Hilfe der vorher definierten Callbacks wird die Verbindung zum Renderer hergestellt.

main.cpp

int main() { try { ... Window window("Computergrafik", settings); Renderer renderer(settings, window); Simulation simulation(settings, window); size_t sceneId = simulation.loadScene("scenes/main.scene", [&renderer](const std::string &modelFile, bool fixed) -> size_t { return renderer.loadModel(modelFile, fixed); }); double deltaTime = 0.0; while (window.loop(deltaTime)) { simulation.loop(deltaTime, [&renderer](size_t modelId, Vector3 position, Vector3 rotation, float scale) { renderer.updateModel(modelId, position, rotation, scale); }); ... } simulation.unloadScene(sceneId, [&renderer](size_t modelId) { renderer.unloadModel(modelId); }); } ... }

simulation.h

class Simulation { public: ... void loop(const double time, UpdateModelCallback updateModelCallback = {}); ... size_t loadScene(const std::string &filename, LoadModelCallback loadModelCallback = {}); void unloadScene(size_t sceneId, UnloadModelCallback unloadModelCallback = {}); private: ... std::map<size_t, Scene> scenes = {}; size_t currentSceneId = 0; };

simulation.cpp

Simulation::~Simulation() { scenes.clear(); }

simulation.cpp

void Simulation::loop(const double time, UpdateModelCallback updateModelCallback) { ... for (auto &[i, scene] : scenes) { scene.update(time, updateModelCallback); } }

simulation.cpp

size_t Simulation::loadScene(const std::string &filename, LoadModelCallback loadModelCallback) { size_t sceneId = ++currentSceneId; scenes.emplace( std::piecewise_construct, std::forward_as_tuple(sceneId), std::forward_as_tuple(filename, loadModelCallback) ); return sceneId; } void Simulation::unloadScene(size_t sceneId, UnloadModelCallback unloadModel) { if (auto it = scenes.find(sceneId); it != scenes.end()) { it->second.unload(unloadModel); scenes.erase(it); } }