OpenGL-Grundlagen: Ein Crashkurs in 3D-Programmierung mit C++

📊 Seitenaufrufe: 259

Dieser Crashkurs für 3D-Entwicklung unter Windows (64-Bit) erläutert anhand des Beispielprojektes 3D-Viewer die absoluten Grundlagen der Programmierung mit OpenGL 3.0 in C++. Der 3D-Viewer steht unter der WTFPL-Lizenz und kann leicht an die eigenen Bedürfnisse angepasst werden.

Download

Das Beispielprojekt 3D-Viewer kann aus dem 3D-Viewer GitHub-Repository heruntergeladen werden:

Motivation & Vorwort

Für eine kleine Simulation benötigte ich kürzlich ein Programm, welches den räumlich-zeitlichen Verlauf einer Menge von Partikeln am Rechner darstellt. Hierzu entschied ich mich für die Verwendung eines Voxelgitters, der dreidimensionale Raum wird also in diskrete Volumenelemente („3D-Pixel“) zerlegt, die einzeln ansprechbar sind. Das vorliegende Beispielprojekt erzeugt innerhalb eines solchen Gitters einfach nur ein nettes Wellenmuster, denn die Details der angesprochenen Simulation sollen aus Gründen der Übersichtlichkeit im weiteren Verlauf des Tutorials keine Rolle spielen.

Da ich zuvor — abgesehen von der Benutzung parametrischer Renderer wie POV-Ray oder OpenSCAD — noch keinerlei Erfahrungen mit 3D-Entwicklung hatte und die gewaltige API von OpenGL (die wohl populärste, plattformübergreifende 3D-Grafik-Library) sowie deren Erweiterungen einen Anfänger regelrecht erschlagen können, entschied ich mich, diesen Crashkurs inklusive eines lauffähigen Minimalprojektes zu veröffentlichen, um anderen Interessierten den Einstieg in die Thematik möglicherweise etwas zu erleichtern.

Da OpenGL eine seit 1992 (!) wachsende API besitzt, die im Laufe der Jahre stetig um neue Konstrukte und Aufrufmuster erweitert wird, vermag ich keinerlei Anspruch auf „Modernität“ oder Portierfähigkeit der vorliegenden Implementierung zu erheben. OpenGL 4.5 ist der derzeit aktuellste Standard, welcher jedoch noch nicht auf allen Systemen standardmäßig verfügbar ist. Dieses Tutorial verwendet Aufrufe, welche ab dem OpenGL 3.0 Core Standard verfügbar sind.

Aufbau & verwendete Software

Die einzigen C++-Quellcodedateien sind Main.cpp und Main.hpp (Darstellung) sowie Data.cpp und Data.hpp (Datenquelle) — auch wenn diese (noch) rein prozedural aufgebaut sind und somit keine Klassen verwenden, scheint mir das Projekt so besser skalierbar.

Um das Beispielprojekt kompakt zu halten, habe ich mich entschieden, keine Plattform-übergreifenden Maßnahmen zu ergreifen. An dieser Stelle entschuldige ich mich daher im Vorfeld bei allen 32-Bit- bzw. Linux- oder Mac OS-Usern für die fehlende Unterstützung ihrer Plattform; eine Portierung des Beispielprojektes sollte jedoch keine all zu große Hürde darstellen — Ergänzungen diesbezüglich nehme ich gerne entgegen!

MinGW-W64 4.8.1 wird als 64-Bit-Compiler für C++ verwendet. Nach C:\MinGW64 entpacken und C:\MinGW64\bin zum PATH hinzufügen.

GLFW 3.2.1 wird als OpenGL-Wrapper verwendet. Diese Library ist bereits im Beispielprojekt enthalten und muss daher nicht separat heruntergeladen werden.

Erstmal Kompilieren und gucken!

Das Beispielprojekt wird mit einem Klick auf Launch.bat kompiliert und gestartet. Das aufgerufene Makefile kompiliert das Projekt statisch, d.h. die .exe ist ohne irgendwelche DLLs lauffähig.

Das Beispielprojekt zeigt exemplarisch die Mischung zweier Sinus-Wellen als Voxelgitter

Das Ergebnis lässt sich im obigen Bild bewundern: Innerhalb des Grafikfensters wird ein umrandetes, quaderförmiges Volumen abgesteckt, worin das durch die Datenquelle erzeugte dreidimensionale Wellenmuster bunt koloriert angezeigt wird.

Wer den Quellcode ohne Makefile kompilieren möchte, der möge dies mit der folgenden Kommandozeile erledigen:

g++ -std=c++11 -Wall -I"glfw\include" -o 3D-Viewer.exe Data.cpp Main.cpp App.res -static-libgcc -static-libstdc++ -L"glfw\lib" -Wl,-static -lstdc++ -lwinpthread -lglfw3 -lwinmm -lgdi32 -lopengl32

Um das nette Anwendungs-Icon mit in die .exe zu kompilieren, muss der in MinGW enthaltene Ressourcencompiler windres vorher wie folgt aufgerufen werden, um die Ressourcendatei App.rc (Einzeiler) in das Ressourcenobjekt App.res zu übersetzen:

windres App.rc -O coff -o App.res

Im Folgenden werden nun die wesentlichen Stellen im Quellcode des Beispielprojektes erläutert. Neben diesem Artikel sollte auch der originale Quellcode aus dem GitHub-Repository angezeigt werden, um die einzelnen Codeschnipsel im Gesamtkontext besser einordnen zu können.

Dieses Tutorial ist — neben dem funktionieren Minimalprojekt für einen schnellen, experimentellen Einstieg — insbesondere auch als „Referenz“ für die Feinheiten bei der Benutzung von OpenGL gedacht, die ich mir erst mühsam „ergooglen“ musste.

In der Hoffnung, dass ich jemandem mit diesem Tutorial etwas Zeit und Mühe ersparen kann, werden nun in 5 Schritten die absoluten OpenGL-Basics umrissen. In diesem Sinne wünsche ich viel Spaß und gutes Gelingen!

Schritt 1: GLFW/OpenGL-Initialisierung

Zuallererst wird GLFW in Main.cpp mit glfwInit(); initialisiert. Bevor das eigentliche Grafikfenster geöffnet wird, legt der folgende Aufruf fest, dass der zu erzeugende Grafikpuffer das sogenannte Multisampling unterstützen möge: glfwWindowHint(GLFW_SAMPLES, 8);

Das bedeutet, dass jeder Pixel des Grafikfensters in diesem Fall gleich acht-mal berechnet wird, wobei in jedem Schritt teilweise die benachbarten Pixelwerte mit einberechnet werden. Das Ergebnis ist eine sanftere Darstellung, auch Anti-Aliasing genannt. Auch für die korrekte Darstellung der Transparenz (Alpha-Kanal) ist diese Einstellung wichtig.

Nun wird das eigentliche Grafikfenster mit der in Main.hpp definierten Auflösung (SCREEN_WIDTH, SCREEN_HEIGHT) erzeugt und aktiviert:

GLFWwindow* window = glfwCreateWindow(SCREEN_WIDTH, SCREEN_HEIGHT, "Fenstertitel", NULL, NULL);
glfwMakeContextCurrent(window);

Nun kann das oben erwähnte Multisampling mit folgendem Aufruf aktiviert werden: glEnable(GL_MULTISAMPLE);

Damit dreidimensionale Objekte, die „weiter hinten im Bildschirm“ liegen, bei deren Projektion auch derartig dargestellt werden, ist die Aktivierung des sogenannten Z-Buffers vonnöten; dieser sortiert die Objekte nach ihren Z-Koordinaten:

glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);

Zu guter Letzt wird zur korrekten Darstellung transparenter Objekte noch der Alpha-Kanal aktiviert:

glEnable(GL_SAMPLE_ALPHA_TO_COVERAGE);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

Schritt 2: Projektion und Hauptschleife

Die folgende Callback-Funktion dient dazu, auf Größenänderungen des Grafikfensters zu reagieren und ein quadratisches (verzerrungsfreies) Aspektverhältnis über den Aufruf von glViewport sicherzustellen.

void windowResizeCallback(GLFWwindow* window, int width, int height)
{
    //  Setup square viewport
    if (width > height)
    {
        glViewport(0, (height - width) / 2, width, width);
    }
    else
    {
        glViewport((width - height) / 2, 0, height, height);
    }

    //  Setup orthogonal projection
    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();
    glOrtho(0.0f, width, height, 0.0f, 0.0f, 1.0f);
}

Außerdem wird durch obiges Callback die Art der verwendeten Projektion — diese wird, wie fast alles in OpenGL, durch Transformationsmatrizen beschrieben — an die neue Fenstergröße angepasst. Hierfür wird mit glMatrixMode(GL_PROJECTION); die Projektionsmatrix angewählt und mittels glLoadIdentity(); auf die Einheitsmatrix initialisiert.

Für meine Zwecke reicht eine orthogonale (engl. auch orthographic genannt) Projektion aus, welche keine Tiefenverzerrung berücksichtigt, sodass parallele Linien aus jedem Blickwinkel parallel bleiben; die Funktionsweise von glOrtho wird im folgenden StackOverflow-Beitrag näher beschrieben: How to use glOrtho in OpenGL

Die Callback-Funktion wird wie folgt registriert und einmal „manuell“ aufgerufen, um das Fenster erstmalig einzurichten:

glfwSetFramebufferSizeCallback(window, windowResizeCallback);
windowResizeCallback(window, SCREEN_WIDTH, SCREEN_HEIGHT);

Nun kann die Hauptschleife angelegt werden, welche so lange läuft, bis das Grafikfenster geschlossen wird. Darin wird mit jedem Durchlauf zuerst das Grafikfenster sowie der Z-Buffer gelöscht, danach kann gezeichnet werden und zuletzt wird das Grafikfenster aktualisiert:

while (!glfwWindowShouldClose(window))
{
    //  Clear screen / Z-buffer
    glClearColor(0.0, 0.0, 0.0, 1.0);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    ...

    //  Update screen
    glfwSwapBuffers(window);
}

Schritt 2: Transformation und Kamera

Nachdem im Schritt 1 die Projektionsmatrix eingerichtet wurde, wird die eigentliche Arbeit zur Darstellung des Voxelgitters (im Folgenden auch Modell genannt) auf der sogenannten Model View-Transformationsmatrix verrichtet. Diese enthält die Information über die Position, Skalierung und Rotation des aktuellen Koodinatensystems. Ähnlich wie in Schritt 1 wird diese Matrix mittels folgendem Code angewählt und initialisiert:

glMatrixMode(GL_MODELVIEW_MATRIX);
glLoadIdentity();

Den aktuellen Zustand der Transformationsmatrix kann man sich als eine Art „Cursor“ oder lokales Koordinatensystem vorstellen. Dieser Cursor kann mittels glPushMatrix(); gespeichert und mittels glPopMatrix(); wiederhergestellt werden; hierbei handelt es sich um den sogenannten Matrix-Stack, der nach einer Folge hintereinander ausgeführter Transformationen wieder eine Rückkehr zum ursprünglichen Koordinatensystem erlaubt.

OpenGL besitzt keine dedizierte Kamera, stattdessen wird das Modell — im vorliegenden Beispielprojekt also das Voxelgitter — transformiert, also rotiert (in Grad, mittels cameraAngleX und cameraAngleY
in X- und Y-Richtung) und skaliert (Wert von 0 bis 1, mittels cameraScale):

glScalef(cameraScale, cameraScale, cameraScale);
glRotatef(cameraAngleX, 0, 1, 0);
glRotatef(cameraAngleY, 1, 0, 0);

Schritt 3: Würfel für Würfel

Das Beispielprojekt zeichnet als „Container“ ein großes Würfelgitter (Wireframe, bestehend aus Umrandungslinien statt Flächen) um das gesamte Modellvolumen. Das eigentliche Voxelgitter besteht aus kleineren ausgefüllten (und bunt kolorierten) Würfeln, die für eine bessere Unterscheidbarkeit ihrerseits noch einmal von einem gleich großen (semi-transparenten) Würfelgitter umgeben sind.

Ein simpler Block verschachtelter Schleifen soll nun anhand der Informationen, die von der Datenquelle geliefert werden, das Voxelgitter Würfel für Würfel aufbauen:

for (int z = 0; z < DATA_SIZE_Z; ++z)
{
    for (int y = 0; y < DATA_SIZE_Y; ++y)
    {
        for (int x = 0; x < DATA_SIZE_X; ++x)
        {
            if (getData(x, y, z) != 0)
            {
                ...
            }
        }
    }
}

In OpenGL können Polygone (Dreiecks- oder Vierecksflächen, definiert durch jeweils 3 bzw. 4 Punkte) oder Linien (definiert durch jeweils 2 Punkte) gezeichnet werden. Ein Punkt wird auch als Vertex (plural Vertices) bezeichnet.

Das Array mit Vertexdaten für ein Würfelgitter ist in Main.hpp enthalten und besteht aus Zweiergruppen von 3D-Punkten:

static GLfloat wireframe_cube[] =
{
    -1, -1, -1,    1, -1, -1,    1, -1, -1,    1,  1, -1,
     1,  1, -1,   -1,  1, -1,   -1,  1, -1,   -1, -1, -1,
    -1, -1,  1,    1, -1,  1,    1, -1,  1,    1,  1,  1,
     1,  1,  1,   -1,  1,  1,   -1,  1,  1,   -1, -1,  1,
    -1, -1, -1,   -1, -1,  1,    1, -1, -1,    1, -1,  1,
     1,  1, -1,    1,  1,  1,   -1,  1, -1,   -1,  1,  1
};

Der Aufruf von glEnableClientState(GL_VERTEX_ARRAY); genügt, um OpenGL auf die Verarbeitung von Vertexdaten vorzubereiten. Nach erledigter Arbeit sollte dieser Modus mit glDisableClientState(GL_VERTEX_ARRAY); wieder verlassen werden.

Der folgende Code sichert die aktuelle Transformationsmatrix, verschiebt den Cursor mittels glTranslated(...); an die gewünschte (x, y, z)-Position, skaliert die Darstellung, setzt die Farbe auf weiß mit 75% Sichtbarkeit, lädt die Wireframe-Vertexdaten, zeichnet den Würfel im Zeichenmodus GL_LINES als Linien (insgesamt 24 Punkte, also 12 Linien) und stellt die vorige Transformationsmatrix wieder her:

glPushMatrix();
glTranslated(x * cubeSize * 2, y * cubeSize * 2, z * cubeSize * 2);
glScalef(cubeSize, cubeSize, cubeSize);
glColor4f(1.0, 1.0, 1.0, 0.75);
glVertexPointer(3, GL_FLOAT, 0, wireframe_cube);
glDrawArrays(GL_LINES, 0, 24);
glPopMatrix();

Darüber hinaus existiert auch noch ein Array mit Vertexdaten für den besagten ausgefüllten Würfel (die eigentlichen soliden Voxel), die im Zeichenmodus GL_QUAD gerendert werden: Hier erwartet OpenGL eine Liste von Vierergruppen von 3D-Punkten, die jeweils als gefülltes Viereck angezeigt werden.

Schritt 4: Datenquelle

Die Größe der dreidimensionalen Datenmatrix wird über die Definitionen von DATA_SIZE_X, DATA_SIZE_Y und DATA_SIZE_Z in Data.hpp festgelegt.

Die folgenden beiden Funktionen dienen dem Visualisierungs-Code in Main.cpp als Interface zur Datenquelle:

  • float getData(int x, int y, int z); liefert einen einzelnen Datenpunkt aus der internen Datenmatrix. Wichtig: Der Visualisierungs-Code, der diese Funktion aufruft, erwartet einen normierten Gleitkommawert von 0 bis 1. Ein Voxel wird nur angezeigt, wenn der Datenwert größer als 0 ist; die restlichen Werte werden zur Einfärbung fließend auf einen „Regenbogen“ abgebildet.
  • void refreshData(int dataMode, int dataTime); wird in jedem Durchlauf der Hauptschleife mit fortlaufender Simulationszeit dataTime aufgerufen, welche im vorliegenden Beispielprojekt zur Animation des Wellenmusters verwendet wird. dataMode ist ein Benutzerparameter, der zur gezielten Veränderung der Darstellung zur Laufzeit verwendet werden kann; im Beispielprojekt dient dieser zur Einstellung der Wellenfrequenz.

Die Generierung des Wellenmusters wird im Folgenden nicht näher behandelt, da dieses vermutlich durch etwas sinnvolleres ersetzt werden wird. Es ist z.B. denkbar, dass der 3D-Viewer in einer erweiterten Version eine CSV-Datei mit Abtastdaten aus einem XYZ-Scanner als Datenquelle verwenden könnte.

Schritt 5: Tastatur- und Maussteuerung

Innerhalb des Grafikfensters kann das Voxelgitter mithilfe der Tastatur und der Maus skaliert und gedreht werden. Insgesamt unterstützt das Beispielprojekt die folgende Steuerung:

Beenden:Escape
Modell rotieren:Pfeiltasten oder Mausklick + Ziehen
Modell skalieren:Bild hoch/runter oder Mausrad
Datenmodus verändern:Plus (+) oder Minus (-)

Der folgende Code registriert vor dem Eintritt in die Hauptschleife jeweils eine Callback-Funktion sowohl für die Zeichen-Tastatureingabe (Sondertasten wie Pfeiltasten sind davon ausgeschlossen) als auch für das Mausrad. Der Modus GLFW_STICKY_KEYS dient zur Zwischenpufferung von Tastatur-Events und damit einem flüssigeren Ansprechen.

//  Setup keyboard mode / register character event handler
glfwSetInputMode(window, GLFW_STICKY_KEYS, 1);
glfwSetCharCallback(window, keyboardCharacterCallback);

//  Register mouse scroll event handler
glfwSetScrollCallback(window, mouseScrollCallback);

Die Callbacks besitzen die folgende Signatur:

void keyboardCharacterCallback(GLFWwindow* window, unsigned int code);
void mouseScrollCallback(GLFWwindow* window, double offsetX, double offsetY);

Die anderen Events, also Sondertasten und Mausposition/-tasten werden innerhalb der Hauptschleife abgefragt — hierzu muss jedoch immer vorher die Funktion glfwPollEvents(); aufgerufen werden.

Die Position sowie die gedrückten Tasten der Maus werden wie folgt abgefragt:

if (glfwGetMouseButton(window, GLFW_MOUSE_BUTTON_LEFT) == GLFW_PRESS)
{
    double mouseX, mouseY;
    glfwGetCursorPos(window, &mouseX, &mouseY);
    ...
}

Sondertasten werden wie folgt abgefragt:

if (glfwGetKey(window, GLFW_KEY_LEFT) == GLFW_PRESS) ...
if (glfwGetKey(window, GLFW_KEY_RIGHT) == GLFW_PRESS) ...
if (glfwGetKey(window, GLFW_KEY_UP) == GLFW_PRESS) ...
if (glfwGetKey(window, GLFW_KEY_DOWN) == GLFW_PRESS) ...
if (glfwGetKey(window, GLFW_KEY_PAGE_UP) == GLFW_PRESS) ...
if (glfwGetKey(window, GLFW_KEY_PAGE_DOWN) == GLFW_PRESS) ...
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) ...

Zu guter letzt kann das Grafikfenster bzw. GLFW mit folgendem Aufruf geschlossen bzw. beendet werden: glfwTerminate();

Weiterführende Links

Der nächste Themenkomplex, der für Anfänger interessant sein dürfte, ist die Änderung der verwendeten Projektion. Eine perspektivische Projektion vermittelt im Gegensatz zur in diesem Tutorial verwendeten orthografischen Projektion eine räumliche Tiefe, die insbesondere für die Spieleentwicklung wichtig ist.

Hierfür existieren auch einige OpenGL-Libraries (z.B. GLM) bzw. Erweiterungen (z.B. GLEW), welche die Berechnung der benötigten Projektionsmatrizen vereinfachen bzw. zusätzliche OpenGL-Funktionalitäten freischalten.

Weitere Themen umfassen die Steuerung von Beleuchtung und Texturen mittels eines Shaders und die Optimierung der OpenGL-Aufrufe mithilfe von Vertex Array Objects (VAO), Vertex Buffer Objects (VBO): Beispiel für VAO, VBO, Vertex- und Fragment-Shader

Ein Gedanke zu „OpenGL-Grundlagen: Ein Crashkurs in 3D-Programmierung mit C++

  1. Hi is there a reason why you learnt opengl, I am right now implementing a algorithm in python which is very slow, but ai want to do the same in cpp but limited by visualization library (plotting), since I am trying computational geometry algorithm, can you guide how to proceed? Since you have experience in opengl , you could have right direction

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert