OpenGL + Qt Tutorial (2024)

In diesem Tutorial geht es primär um Maus- und Tastatureingaben. Und damit das irgendwie Sinn macht, brauchen wir ein (schön großes) 3D Modell, und deshalb ist dieses Tutorial auch sehr sehr lang. Und nebenbei geht es noch um Verwaltung von Shaderobjekten, Zeichenobjekten, Nebeleffekt beim Gitterraster und und und…​

OpenGL + Qt Tutorial (1)

Figure 5. Tutorial_05 (Linux Screenshot), "Die Welt aus 10000 und einer Box"

Quelltext für dieses Tutorial liegt im github repo: Tutorial_05

In diesem Tutorial werden viele neue Dinge verwendet:

  • zwei Modelle (eins für die Boxen und eins für das Gitter), nebst dazugehörigen, unterschiedlichen Shaderprogrammen (das vom Gitter verwendet in die Tiefe abgeblendete Farben)

  • Tiefenpuffer, sodass Gitterlinien/Boxen korrekt vor/hintereinander gezeichnet werden

  • Model2World und World2View-Matrizen (mit perspektivischer Projektion)

  • Shaderprogramme und Renderobjekte (bzw. Objektgruppen) sind in Klassen zusammengefasst, wodurch der Quelltext deutlich übersichtlicher wird

  • eine Maus+Tastatursteuerung mit WASDQE + Mauslook, incl. Shift-Langsam-Bewege-Modus

  • und das Ganze wieder mit dem Schwerpunkt: Rendern nur wenn notwendig (Akku sparen!)

5.1. Überblick

Das Tutorial ist sehr lang, und der Quelltext entsprechend auch. Daher gehen wir in diesem Tutorial schrittweise vor. Die gezeigten Quelltextausschnitte stimmen daher nicht immer 100% mit dem finalen Quelltext überein (ich hab da aus didaktischen Gründen immer mal was weggelassen).

Folgende Implementierungsschritte werden besprochen:

  • Anpassung der Klasse OpenGLWindow an die in QOpenGLWidget bzw. QOpenGLWindow verwendeten Funktionsnamen

  • Vorstellung der Klasse SceneView, die das bisherige TriangleWindow oder RectangleWindow ersetzt

  • Transformationsmatrizen: Model → World → Kamera → Projektion (Klassen Transform3D und Camera)

  • Tastatur- und Mauseingabebehandlung (Klasse KeyboardMouseHandler)

  • Kapselung der Shaderprogramme und Initialisierung und Verwendung derselben (Klasse ShaderProgram)

  • Kapselung der Zeichenroutinen für das Gitterraster, Abblendeffekt am Horizont im Shader (Klasse GridObject und Shader grid.vert und grid.frag)

  • Kapselung der Zeichenroutinen für die Boxen (Klassen BoxObject und BoxMesh, und Shader withWorldAndCamera.vert und simple.frag)

5.2. Fenster-Basisklasse OpenGLWindow

Als Grundlage für die Implementierung wird die Klasse OpenGLWindow aus Tutorial 01 verwendet, allerdings etwas abgewandelt. Letztlich wird die Schnittstelle angepasst, um ungefähr der des QOpenGLWidget zu entsprechen:

class OpenGLWindow : public QWindow, protected QOpenGLFunctions {Q_OBJECTpublic:explicit OpenGLWindow(QWindow *parent = nullptr);public slots:void renderLater();void renderNow();protected:bool event(QEvent *event) override;void exposeEvent(QExposeEvent *event) override;void resizeEvent(QResizeEvent *) override;virtual void initializeGL() = 0;virtual void resizeGL(int width, int height) { Q_UNUSED(width) Q_UNUSED(height) }virtual void paintGL() = 0;QOpenGLContext *m_context;private:void initOpenGL();};

Die Funktionen initializeGL() und paintGL() sind aus den vorangegangen Tutorials bekannt. Die Funktion resizeGL() ist eigentlich nur eine Bequemlichkeitsfunktion, welche aus dem Eventhandler resizeEvent() aufgerufen wird.

Neu ist jedoch die Funktion initOpenGL(), in der die OpenGL-Initialisierung (OpenGL Context) gemacht wird.

OpenGLWindow.cpp:initOpenGL()

void OpenGLWindow::initOpenGL() {Q_ASSERT(m_context == nullptr);m_context = new QOpenGLContext(this);m_context->setFormat(requestedFormat());m_context->create();m_context->makeCurrent(this);Q_ASSERT(m_context->isValid());initializeOpenGLFunctions();initializeGL(); // call user code}

Normalerweise wird die Initialisierung beim ersten Anzeigen des Fensters (genaugenommen beim ersten ResizeEvent) aufgerufen, bzw. beim ersten Zeichnen.

OpenGLWindow.cpp: Funktion resizeEvent()

void OpenGLWindow::resizeEvent(QResizeEvent * event) {QWindow::resizeEvent(event);// initialize on first callif (m_context == nullptr)initOpenGL();resizeGL(width(), height());}

Unabhängig von dieser Initializierungsfunktion muss man natürlich die Funktion initializeGL() implementieren. Alles andere in der Klasse ist altbekannt.

5.3. Klasse SceneView - die konkrete Implementierung

5.3.1. Klassendeklaration

Zwecks Überblick ist hier zunächst die Klassendeklaration in Teilen. Zuerst die üblichen Verdächtigen:

SceneView.h, Deklaration der Klasse SceneView

class SceneView : public OpenGLWindow {public:SceneView();virtual ~SceneView() override;protected:void initializeGL() override;void resizeGL(int width, int height) override;void paintGL() override;

Dann kommen die Ereignisbehandlungsroutinen für die Tastatur- und Mauseingaben. Dazu gehören auch die Hilfsfunktionen checkInput() und processInput(), die im Abschnitt zur Tastatur- und Mauseingabe erklärt sind. Die Member-Variablen m_keyboardMouseHandler und m_inputEventReceived gehören auch dazu.

SceneView.h, Deklaration der Klasse SceneView, fortgesetzt

void keyPressEvent(QKeyEvent *event) override;void keyReleaseEvent(QKeyEvent *event) override;void mousePressEvent(QMouseEvent *event) override;void mouseReleaseEvent(QMouseEvent *event) override;void mouseMoveEvent(QMouseEvent *event) override;void wheelEvent(QWheelEvent *event) override;private:void checkInput();void processInput();KeyboardMouseHandlerm_keyboardMouseHandler;boolm_inputEventReceived;

Dann kommt die Funktion updateWorld2ViewMatrix() zur Koordinatentransformation und die dazugehörigen Member-Variablen.

SceneView.h, Deklaration der Klasse SceneView, fortgesetzt

void updateWorld2ViewMatrix();QMatrix4x4m_projection;Transform3Dm_transform;Cameram_camera;QMatrix4x4m_worldToView;

Zuletzt kommen Member-Variablen, die die Shader-Programme und Zeichenobjekte kapseln (beinhalten Shader, VAO, VBO, EBO, etc.)

SceneView.h, Deklaration der Klasse SceneView, fortgesetzt

QList<ShaderProgram>m_shaderPrograms;BoxObjectm_boxObject;GridObjectm_gridObject;};

Und das war’s auch schon - recht kompakt, oder?

5.3.2. Das Aktualisierungskonzept

Erklärtes Ziel dieser OpenGL-Implementierung ist nur dann zu rendern, wenn es wirklich notwendig ist. Also:

  • wenn die Fenstergröße (Viewport) verändert wurde,

  • wenn das Fenster angezeigt/sichtbar wird (exposed),

  • wenn durch Nutzerinteraktion die Kameraposition verändert wird, und

  • wenn die Szene selbst transformiert/verändert wird (z.B. programmgesteuerte Animation…​)

Wenn man jetzt bei jedem Eintreffen eines solchen Ereignisses jedesmal neu zeichnen würde, wäre das mit ziemlichem Overhead verbunden. Besser ist es, beim Eintreffen eines solchen Ereignisses einfach nur ein Neuzeichnen anzufordern. Da die UpdateRequest-Ereignisse normalerweise mit der Bildschirmfrequenz synchronisiert sind, kann es natürlich sein, dass mehrfach hintereinander UpdateRequest-Events an die Eventloop angehängt werden. Dabei werden diese aber zusammengefasst und nur ein Event ausgeschickt. Es muss ja auch nur einmal je angezeigtem Frame gezeichnet werden.

Grundsätzlich muss man also nur die Funktion QWindow::requestUpdate() (oder unsere Bequemlichkeitsfunktion renderLater()) aufrufen, damit beim nächsten VSync wieder neu gezeichnet wird.

Leider funktionier das Verfahren im Fall des ExposeEvent bzw. ResizeEvent nicht perfekt. Gerade unter Windows führt das beim Vergrößern des Fensters zu unschönen Artefakten am rechten und unteren Bildschirmrand. Daher muss man in diesem Fall tatsächlich sofort in der Ereignisbehandlungsroutine neu zeichnen und dabei den OpenGL Viewport bereits an die neue Fenstergröße anpassen. Das Neuzeichnen wird direkt im ExposeEvent-Handler von OpenGLWindow ausgelöst:

OpenGLWindow.cpp:exposeEvent()

void OpenGLWindow::exposeEvent(QExposeEvent * /*event*/) {renderNow(); // update right now}

Bei Größenveränderung des Fensters sendet Qt immer zuerst ein ResizeEvent gefolgt von einem ExposeEvent aus. Daher sollte man in der Funktion SceneView::resizeEvent() nicht renderLater() aufrufen!

Ohne eine Aufruf von renderLater() im ResizeEvent-Handler erhält man folgende Aufrufreihenfolge bei der Fenstervergrößerung:

OpenGLWindow::resizeEvent()OpenGLWindow::exposeEvent()SceneView::paintGL(): Rendering to: 1222 x 891OpenGLWindow::resizeEvent()OpenGLWindow::exposeEvent()SceneView::paintGL(): Rendering to: 1224 x 892

Ruft man stattdessen renderLater() auf, erhält man:

OpenGLWindow::resizeEvent()OpenGLWindow::exposeEvent()SceneView::paintGL(): Rendering to: 1283 x 910SceneView::paintGL(): Rendering to: 1283 x 910OpenGLWindow::resizeEvent()OpenGLWindow::exposeEvent()SceneView::paintGL(): Rendering to: 1288 x 912SceneView::paintGL(): Rendering to: 1288 x 912

Wie man sieht, wird jedes Mal doppelt gezeichnet, was eine deutlich spürbare Verzögerung bedeutet. Grundsätzlich hilf es zu wissen, dass:

  • beim ersten Anzeigen eines Fensters immer erst ein ResizeEvent, gefolgt von einem ExposeEvent geschickt wird

  • beim Größenändern eines Fensters ebenfalls immer ein ResizeEvent, gefolgt von einem ExposeEvent geschickt wird

  • beim Minimieren und Maximieren eines Fensters nur je ein (oder auf dem Mac mehrere) ExposeEvent geschickt werden. Dies kann man nutzen, um eine Animation zu stoppen und beim erneuten Anzeigen (isExposed() == true) wieder zu starten. Dies ist aber nicht der Fokus in diesem Tutorial. Daher könnte man auch das ExposeEvent komplett ignorieren und renderNow() direkt am Ende von OpenGLWindow::resizeEvent() aufrufen. So wie es aktuell implementiert ist, wird beim Minimieren und Maximieren mehrfach ExposeEvent mit isExposed() == true aufgerufen und damit wird mehrfach gezeichnet, trotz unverändertem Viewport und unveränderte Szene. Das ist aber nicht weiter bemerkbar und verschmerzbar.

5.3.3. Verwendung der Klasse SceneView

Die Klasse SceneView wird als QWindow-basierte Klasse selbst via Widget-Container in den Testdialog eingebettet (siehe Tutorial 03).

Bei der Analyse des Tutorialquelltextes kann man sich von außen nach innen "arbeiten":

  • main.cpp - Instanziert TestDialog

  • TestDialog.cpp - Instanziert SceneView und bettet das Objekt via Window-Container ein.

Es gibt im Quelltext von TestDialog.cpp nur ein neues Feature: Antialiasing (siehe letzter Abschnitt "Antialiasing" dieses Tutorials).

5.3.4. Implementierung der Klasse SceneView

Und da wären wir auch schon bei der Implementierung des Klasse SceneView.

Im Konstruktor werden letztlich 3 Dinge gemacht:

  • dem Tastatur/Maus-Eingabemanager werden die für uns interessanten Tasten mitgeteilt, siehe Abschnitt "Tastatur- und Mauseingabe"

  • die beiden ShaderProgramm-Container Objekte werden erstellt und konfiguriert, siehe Abschnitt "Shaderprogramme"

  • die Kamera- und Welttransformationsmatrizen werden auf ein paar Standardwerte eingestellt, siehe Abschnitt "Transformationsmatrizen"

SceneView.cpp, Konstruktor

SceneView::SceneView() :m_inputEventReceived(false){// tell keyboard handler to monitor certain keysm_keyboardMouseHandler.addRecognizedKey(Qt::Key_W);m_keyboardMouseHandler.addRecognizedKey(Qt::Key_A);m_keyboardMouseHandler.addRecognizedKey(Qt::Key_S);m_keyboardMouseHandler.addRecognizedKey(Qt::Key_D);m_keyboardMouseHandler.addRecognizedKey(Qt::Key_Q);m_keyboardMouseHandler.addRecognizedKey(Qt::Key_E);m_keyboardMouseHandler.addRecognizedKey(Qt::Key_Shift);// *** create scene (no OpenGL calls are being issued below, just the data structures are created.// Shaderprogram #0 : regular geometry (painting triangles via element index)ShaderProgram blocks(":/shaders/withWorldAndCamera.vert",":/shaders/simple.frag");blocks.m_uniformNames.append("worldToView");m_shaderPrograms.append( blocks );// Shaderprogram #1 : grid (painting grid lines)ShaderProgram grid(":/shaders/grid.vert",":/shaders/simple.frag");grid.m_uniformNames.append("worldToView"); // mat4grid.m_uniformNames.append("gridColor"); // vec3grid.m_uniformNames.append("backColor"); // vec3m_shaderPrograms.append( grid );// *** initialize camera placement and model placement in the world// move objects a little bit to the back of the scene (negative z coordinates = further back)m_transform.translate(0.0f, 0.0f, -5.0f);m_camera.translate(0,5,0);m_camera.rotate(-30, m_camera.right());}

Im Konstruktor werden nur Eigenschaften für die Shaderprogramme festgelegt, die eigentliche Initialisierung (OpenGL-Aufrufe) findet in initializeGL() statt.

Im Destruktor der Klasse werden die OpenGL-Objekte wieder freigegeben:

SceneView.cpp, Destruktor

SceneView::~SceneView() {m_context->makeCurrent(this);for (ShaderProgram & p : m_shaderPrograms)p.destroy();m_boxObject.destroy();m_gridObject.destroy();}

Wichtig ist hier, dass der OpenGL-Context für das aktuelle Fenster aktuell gesetzt wird (m_context->makeCurrent(this)). Damit können dann die OpenGL-Objekte freigegeben werden. Dies erfolgt in den destroy() Funktionen der Shaderprogramm-Wrapper-Klasse und Zeichen-Objekt-Wrapper-Klassen.

5.3.5. OpenGL-Initialisierung

Die eigentlich Initialisierung der OpenGL-Objekte (Shaderprogramme und Pufferobjekte) erfolgt in initializeGL():

SceneView.cpp:initializeGL()

#define SHADER(x) m_shaderPrograms[x].shaderProgram()void SceneView::initializeGL() {// initialize shader programsfor (ShaderProgram & p : m_shaderPrograms)p.create();// tell OpenGL to show only faces whose normal vector points towards usglEnable(GL_CULL_FACE);// enable depth testing, important for the grid and for the drawing order of several objectsglEnable(GL_DEPTH_TEST);// initialize drawable objectsm_boxObject.create(SHADER(0));m_gridObject.create(SHADER(1));}

Dank der Kapselung der Shaderprogramm-Initialisierung in der Klasse ShaderProgram und der Kapselung der Zeichenobjekt-spezifischen Initialisierung in den Objekten ist diese Funktion sehr viel übersichtlicher als in den bisherigen Tutorials.

Das Makro SHADER(x) wird verwendet, um bequem auf das QOpenGLShaderProgram Objekt in der Wrapper-Klasse zuzugreifen.

Die beiden glXXX Befehle in der Mitte der Funktion schalten zwei für 3D Szenen wichtige Funktionen ein:

  • GL_CULL_FACE - Zeichne Flächen nicht, welche mit dem "Rücken" zu uns stehen

  • GL_DEPTH_TEST - Führe beim Zeichnen der Fragmente einen Tiefentest durch, und verwerfe weiter hintenliegende Fragmente. Das ist wichtig dafür, dass die gezeichneten Boxen das dahinterliegende Gitter überdecken. Der dafür benötigte Tiefenpuffer wird über QSurfaceFormat konfiguriert (QSurfaceFormat::setDepthBufferSize()).

Die Funktion glDepthFunc(GL_LESS) muss nicht aufgerufen werden, da das bei OpenGL der Standard ist.

Man kann testweise mal das Flag GL_DEPTH_TEST nicht setzen - die etwas verwirrende Darstellung ist, nun ja, verwirrend.

Für den Tiefentest ist ein zusätzlicher Tiefenpuffer notwendig (bisher hatten wir nur den Farbpuffer (engl. Color Buffer). Wichtig ist daher, dass bei Verwendung eines Tiefenpuffers dieser Puffer ebenso wie der Farbpuffer zu Beginn des Zeichnens gelöscht wird. Dies passiert in paintGL():

SceneView.cpp:paintGL()

void SceneView::paintGL() { ...glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);...}

Falls bei Verwendung des Tiefenpuffers/Tiefentests das Problem des z-Fighting auftritt, kann man die Genauigkeit des Tiefenpuffers erhöhen. Dies erfolgt durch Aufruf der Funktion QSurfaceFormat::setDepthBufferSize(). In diesem Tutorial liegen die Boxen immer schön weit auseinander, sodass eine Genauigkeit von 8bit ausreicht. Dieser wird bei Konfiguration des QSurfaceFormat-Objekts in TestDialog.cpp gesetzt:

format.setDepthBufferSize(8);

5.4. Tastatur- und Mauseingabe

Qt stellt in QWindow und QWidget Ereignisbehandlungsroutinen für Tastatur- und Mauseingaben zur Verfügung. Die Deklaration dieser Funktion sind oben in der SceneView Klassendeklaration zu sehen.

Wenn man eine Taste auf der Tastatur drückt wird ein QEvent::KeyPress ausgelöst und die Memberfunktion keyPressEvent(QKeyEvent *event) aufgerufen. Das passiert auch, wenn man die Taste gedrückt hält. Unterscheiden kann man dieses durch Prüfen der Eigenschaft AutoRepeat (QKeyEvent::isAutoRepeat()).

Für die Navigation in einer 3D Umgebung hält man die Tasten (z.B. WASD oder ähnliche) längere Zeit gedrückt (d.h. über mehrere Frames hinweg). Man benötigt also einen Zustandsmanager, der sich den aktuellen Zustand der Tasten merkt.

Ein solcher "Inputmanager" hält intern also für jede (berücksichtigte) Taste einen Zustand:

  • Nicht gedrückt

  • Gerade gedrückt

  • Wurde gedrückt

Letzterer ist eigentlich nur dann wichtig, wenn auf einzelne Tastendrücke reagiert werden soll, während eventuell eine aufwändige Neuzeichenroutine läuft.

5.4.1. Der Tastatur- und Maus-Zustandsmanager

Man könnte die gesamte Tastatur- und Mausbehandlung natürlich auch direkt in der Klasse SceneView implementieren, in der auch die Ereignisbehandlungsfunktionen aufgerufen werden. Es ist aber übersichtlicher, diese in der Klasse KeyboardMouseHandler zu kapseln.

Die Aufgabe dieser Klasse ist letztlich sich zu merken, welche Taste/Mausknopf gerade gedrückt ist. Die Implementierung der Klasse ist für das Tutorial eigentlich nicht so wichtig, vielleicht lohnt aber ein Blick auf die Klassendeklaration:

KeyboardMouseHandler.h

class KeyboardMouseHandler {public:KeyboardMouseHandler();virtual ~KeyboardMouseHandler();// functions to manage known keysvoid addRecognizedKey(Qt::Key k);void clearRecognizedKeys(); // event handler helpersvoid keyPressEvent(QKeyEvent *event);void keyReleaseEvent(QKeyEvent *event);void mousePressEvent(QMouseEvent *event);void mouseReleaseEvent(QMouseEvent *event);void wheelEvent(QWheelEvent *event); // state changing helper functionsbool pressKey(Qt::Key k);bool releaseKey(Qt::Key k);bool pressButton(Qt::MouseButton btn, QPoint currentPos);bool releaseButton(Qt::MouseButton btn); // query functionsbool keyDown(Qt::Key k) const;bool buttonDown(Qt::MouseButton btn) const;QPoint mouseDownPos() const { return m_mouseDownPos; }int wheelDelta() const; // state reset functionsQPoint resetMouseDelta(const QPoint currentPos);int resetWheelDelta();void clearWasPressedKeyStates();private:enum KeyStates {StateNotPressed,StateHeld,StateWasPressed};std::vector<Qt::Key>m_keys;std::vector<KeyStates>m_keyStates;KeyStatesm_leftButtonDown;KeyStatesm_middleButtonDown;KeyStatesm_rightButtonDown;QPointm_mouseDownPos;intm_wheelDelta;};

Eine KeyboardMouseHandler-Klasse wird nach der Erstellung durch Aufrufe von addRecognizedKey() konfiguriert (siehe Konstruktor der Klasse SceneView).

Für die Tastatur- und Maus-Ereignisbehandlungsroutinen gibt es passende Hilfsfunktionen, sodass man von den Event-Funktionen der eigenen View-Klasse einfach diese Hilfsfunktionen aufrufen kann. Die Zustandsänderungslogik (auch das Prüfen auf AutoRepeat) wird in diesen Funktionen gemacht. Bei bekannten Tasten wird der QKeyEvent oder QMouseEvent akzeptiert, sonst ignoriert.

Den Zustand einzelner Tasten kann man auch programmgesteuert durch die pressXXX und releaseXXX Funktionen ändern.

Danach kommen die Funktionen zum Abfragen des Zustands. Bei Tasten ist die Abfrage mit keyDown() oder buttonDown() recht klar (sowohl der Zustand "gerade gedrückt", als auch "gedrückt und wieder losgelassen" liefern hier true zurück).

Bei der Mausbewegung und Scroll-Rad muss immer die Veränderung zwischen zwei Abfragezeitpunkten angeschaut werden. Bei Verwendung einer Free-Mouse-Look-Taste (hier rechte Maustaste), wird beim Drücken dieser Taste die globale Cursorpostion abgelegt, welche über mouseDownPos() abgefragt werden kann. Bei Mouse-Wheel-Ereignissen werden die Drehstufen (Winkel/Ticks) addiert.

Wenn man diese Änderungen nun in eine Bewegung umwandelt, muss man diese nach dem Auslesen wieder zurücksetzen. Dies erfolgt mit den Funktionen resetMouseDelta() und resetWheelDelta(), welche beide die bislang erfassten Differenzen zurückliefern. Die const-Abfragefunktionen mouseDownPos() und wheelDelta() können also verwendet werden, um zu Testen, ob es eine Maus-/Scrollradbewegung gab. Und beim Anwender der Änderungen ruf man die resetXXX() Funktionen auf.

Zuletzt muss man die Funktion clearWasPressedKeyStates() nach Abfrage der Tasten aufrufen, um die "wurde gedrückt" Zustände wieder in den "Nicht gedrückt" Zustand zurückzusetzen.

Die Implementierung der Klasse ist recht einfach und selbsterklärend und muss hier nicht näher ausgeführt werden. Interessant ist die Verwendung der Klasse. Dazu müssen wir uns zunächst den Programmauflauf der Ereignisschleife und Auswertung der Tasteneingabe genauer anschauen.

5.4.2. Die Ereignisschleife und Tastatur-/Mausevents

Zwischen zwei Frames (also Aufrufen von paintGL()) läuft das Programm in der Ereignisschleife. Sobald eine Taste gedrückt oder losgelassen wird, ruft Qt die entsprechende Ereignisbehandlungsfunktion auf, d.h. keyPressEvent() bzw. keyReleaseEvent(). Ebenso werden bei Mausaktionen die entsprechenden Aktionen ausgelöst.

Die Aufrufe werden an die gleichnamigen Funktionen in Zustandsmanager (KeyboardMouseHandler) weitergereicht. Wenn die betreffende Taste dem Zustandsmanager bekannt ist, wird der aktuelle Zustand im Zustandsmanager entsprechend geändert.

Nun wird noch geprüft, ob die Taste eine Szenenveränderung (bspw. Kamerabewegung) bewirkt. Dies erfolgt in der Funktion SceneView::checkInput().

SceneView.cpp:checkInput()

void SceneView::checkInput() {// trigger key held?if (m_keyboardMouseHandler.buttonDown(Qt::RightButton)) {// any of the interesting keys held?if (m_keyboardMouseHandler.keyDown(Qt::Key_W) ||m_keyboardMouseHandler.keyDown(Qt::Key_A) ||m_keyboardMouseHandler.keyDown(Qt::Key_S) ||m_keyboardMouseHandler.keyDown(Qt::Key_D) ||m_keyboardMouseHandler.keyDown(Qt::Key_Q) ||m_keyboardMouseHandler.keyDown(Qt::Key_E)){m_inputEventReceived = true;renderLater();return;}// has the mouse been moved?if (m_keyboardMouseHandler.mouseDownPos() != QCursor::pos()) {m_inputEventReceived = true;renderLater();return;}}// scroll-wheel turned?if (m_keyboardMouseHandler.wheelDelta() != 0) {m_inputEventReceived = true;renderLater();return;}}

In dieser Funktion werden nun die Abfragefunktionen verwendet, d.h. der Zustand des Tastatur-/Maus-Zustandsmanagers wird nicht verändert. Auch ist zu beachten, dass die Abfrage nach dem Mausrad separat erfolgt.

Wird eine relevante Taste oder Mausbewegung erkannt, wird durch Aufruf von renderLater() ein Zeichenaufruf in die Event-Schleife eingereiht (kommt beim nächsten VSync) und das Flag m_inputEventReceived wird gesetzt dann geht die Kontrolle wieder zurück an die Ereignisschleife.

Es sollte wirklich nur neu gezeichnet werden, wenn dies durch Tastendruck- oder Mausbewegung notwendig wird. Dadurch, dass das UpdateRequest nur bei Bedarf gesendet wird, kann man ansonsten wild auf der Tastatur herumhämmern, ohne dass auch nur ein OpenGL-Befehl aufgerufen wird.

Es ist nun möglich, dass ein weiteres Tastaturereignis eintrifft, bevor das UpdateRequest-Ereignis eintritt. Bspw. könnte dies das QEvent::KeyRelease-Ereignis eines gerade zuvor eingetroffenen QEvent::KeyPress-Ereignisses derselben Taste sein. Deshalb wird der Zustand einer Taste beim keyReleaseEvent() auf "Wurde gedrückt" geändert, und nicht einfach wieder zurück auf "Nicht gedrückt". Sonst hätte man im Zustandsmanager keine Information mehr darüber, dass die Taste in diesem Frame kurz gedrückt wurde. Das ist zwar bei hohen Bildwiederholfrequenzen hinreichend unwahrscheinlich, kann aber bei sehr komplexen Szenen (bzw. schwacher Hardware) hilfreich sein.

5.4.3. Auswertung der Eingabe und Anpassung der Kameraposition- und Ausrichtung

Die eigentliche Auswertung der Tastenzustände und Bewegung der Kamera erfolgt am Anfang der SceneView::paintGL()-Funktion:

SceneView.cpp:paintGL()

void SceneView::paintGL() {// process input, i.e. check if any keys have been pressedif (m_inputEventReceived)processInput(); ...

Da die Zeichenfunktion aus einer Vielzahl von Gründen aufgerufen werden kann, dient das Flag m_inputEventReceived dazu, nur dann die Eingaben auszuwerten, wenn es tatsächlich welche gab.

Der Zeitaufwand für die Auswertung der Eingaben ist nicht wirklich groß. Da aber einige Matrizenoperationen involviert sind, kann man sich die Arbeit auch sparen, daher das "dirty" Flag m_inputEventReceived.

Die Auswertung des Tastatur- und Mauszustandes erfolgt in der Funktion SceneView::processInput():

SceneView:processInput()

void SceneView::processInput() {m_inputEventReceived = false;if (m_keyboardMouseHandler.buttonDown(Qt::RightButton)) {// Handle translationsQVector3D translation;if (m_keyboardMouseHandler.keyDown(Qt::Key_W)) translation += m_camera.forward();if (m_keyboardMouseHandler.keyDown(Qt::Key_S)) translation -= m_camera.forward();if (m_keyboardMouseHandler.keyDown(Qt::Key_A)) translation -= m_camera.right();if (m_keyboardMouseHandler.keyDown(Qt::Key_D)) translation += m_camera.right();if (m_keyboardMouseHandler.keyDown(Qt::Key_Q)) translation -= m_camera.up();if (m_keyboardMouseHandler.keyDown(Qt::Key_E)) translation += m_camera.up();float transSpeed = 0.8f;if (m_keyboardMouseHandler.keyDown(Qt::Key_Shift))transSpeed = 0.1f;m_camera.translate(transSpeed * translation);// Handle rotations// get and reset mouse delta (pass current mouse cursor position)QPoint mouseDelta = m_keyboardMouseHandler.resetMouseDelta(QCursor::pos());static const float rotatationSpeed = 0.4f;const QVector3D LocalUp(0.0f, 1.0f, 0.0f); // same as in Camera::up()m_camera.rotate(-rotatationSpeed * mouseDelta.x(), LocalUp);m_camera.rotate(-rotatationSpeed * mouseDelta.y(), m_camera.right());// finally, reset "WasPressed" key statesm_keyboardMouseHandler.clearWasPressedKeyStates();}int wheelDelta = m_keyboardMouseHandler.resetWheelDelta();if (wheelDelta != 0) {float transSpeed = 8.f;if (m_keyboardMouseHandler.keyDown(Qt::Key_Shift))transSpeed = 0.8f;m_camera.translate(wheelDelta * transSpeed * m_camera.forward());}updateWorld2ViewMatrix();}

Auch in dieser Funktion werden Bewegungen der Kamera durch Tastendrücke und Schwenker durch Mausbewegung unabhängig vom Scrollrad-Zoom behandelt. Am Ende der Funktion werden die Welt-zu-Perspektive-Transformationsmatrizen angepasst. Die relevanten Matrizen und auch das Kamera-Objekt (Klasse Camera) sind im Abschnitt "Transformationsmatrizen und Kamera" weiter unten beschrieben.

Die Bewegung der Kamera ist recht einfach nachvollziehbar - je nach gedrückter Taste wird eine Verschieberichtung auf den Vektor translation addiert. Der tatsächliche Verschiebevektor wird durch Multiplikation mit einer Geschwindigkeit transSpeed berechnet. Hier ist auch die "Verlangsamung-bei-Shift-Tastendruck"-eingebaut.

Die Geschwindigkeit ist hier als "Bewegung je Frame" zu verstehen, was bei stark veränderlichen Frameraten (z.B. bei komplexer Geometrie) zu einer variablen Fortbewegungsgeschwindigkeit führen kann. Hier kann man alternativ eine Zeitmessung einbauen und den Zeitabstand zwischen Abfragen des Eingabezustands in die Berechnung der Verschiebung einfließen lassen.

Die Drehung der Kamera hängt von der Mausbewegung ab. Hier wird die Funktion resetMouseDelta() aufgerufen, welche zwei Funktionen hat:

  • die Bewegung der Maus seit dem Druck auf die rechte Maustaste bzw. seit letztem Aufruf von resetMouseDelta() wird zurückgeliefert, und

  • mouseDownPos wird auf die aktuelle Maus-Cursorposition gesetzt (sodass beim nächsten Aufruf

Bei der Bewegung erfolgt die Neigung der Kamera um die x-Achse des lokalen Kamerakoordinatensystems (wird zurückgeliefert durch die Funktion m_camera.right(). Analog könnte man die Kamera auch um die lokale y-Achse der Kamera schwenken (wie in einem Flugsimulator üblich), dies führt aber zu recht beliebigen Ausrichtungen. Möchte man die Kamera eher parallel zum "Fußboden" halten, dann dreht man die Kamera um die y-Achse des Weltenkoordinatensystems (Vektor 0,1,0).

Am Ende des Tastaturabfrageteils werden noch die "wurde gedrückt"-Zustände zurückgesetzt.

Das Scrollrad soll in diesem Beispiel ein deutlich schnelleres Vorwärts- oder Rückwärtsbewegen durch die Szene ermöglichen. Deshalb werden die Mausradbewegungen mit größerer Verschiebegeschwindigkeit skaliert. Wie auch bei der Abfrage der Mausbewegung wird in der Funktion resetWheelDelta() der aktuell akkumulierte Scrollweg zurückgeliefert und intern im Zustandsmanager wieder auf 0 gesetzt.

5.4.4. Auf gedrückte Tasten reagieren

Wie oben erläutert wird das Neuzeichnen nur bei Registrieren eines Tastendrucks angefordert. Nehmen wir mal an, die rechte Maustaste ist gedrückt und die Vorwärtstaste W wird gedrückt gehalten. Dann sendet das Betriebssystem (bzw. Window-Manager) in regelmäßigen Abständen KeyPress-Events (z.B. 50 je Sekunde, je nach Einstellung). Diese sind dann als AutoRepeat gekennzeichnet und führen damit nicht zu einer Änderung im Eingabe-Zustandsmanager, aber zu einer erneuten Prüfung der Neuzeichnung (Aufruf von checkInput()). Und da eine Kamera-relevante Taste gedrückt gehalten ist, wird ein Neuzeichnen via renderLater() angefordert. Als Konsequenz ruckelt das Bild dann im Rythmus der Tastenwiederholrate…​ nicht sehr angenehm anzusehen.

Daher muss das Prüfen auf gedrückte Tasten regelmäßig, d.h. einmal pro Frame erfolgen. Und der geeignete Ort dafür ist das Ende der paintGL()-Funktion:

SceneView.cpp:paintGL()

void SceneView::paintGL() { ...checkInput();}

Ganz zum Schluss wird nochmal auf eine Tasteneingabe geprüft und damit bei Bedarf ein UpdateRequest eingereiht.

Damit wäre die Tastatur- und Mauseingabe auch schon komplett.

5.5. Shaderprogramme

Die Verwaltung der Shaderprogramme macht Qt ja eigentlich schon durch die Klasse QOpenGLShaderProgram. Wenn man eine weitere Wrapper-Klasse außen herum packt, dann wird der Quelltext noch deutlich übersichtlicher. In der Deklaration der Wrapper-Klasse ShaderProgram findet man die gekapselte Qt Klasse wieder:

ShaderProgram.h

class ShaderProgram {public:ShaderProgram();ShaderProgram(const QString & vertexShaderFilePath, const QString & fragmentShaderFilePath);void create();void destroy();QOpenGLShaderProgram * shaderProgram() { return m_program; } // paths to shader programs, used in create()QStringm_vertexShaderFilePath;QStringm_fragmentShaderFilePath;QStringListm_uniformNames; // uniform (variable) namesQList<int>m_uniformIDs; // uniform IDs (resolved in create())private:QOpenGLShaderProgram*m_program;};

Zur Verwaltung von Shaderprogrammen gehören auch die Variablen, die man dem Vertex- und/oder Fragment-Shaderprogramm übergeben möchte (siehe Shaderprogramme in Abschnitt "Zeichenobjekte"). Die Verwendung der Klasse sieht vor, dass man erst alle Eigenschaften setzt (Resourcen-Pfade zu den Shaderprogrammen, und die uniform-Namen im Vektor m_uniformNames). Dies wird im Konstruktor der SceneView-Klasse gemacht:

SceneView.cpp:SceneView()

SceneView::SceneView() :m_inputEventReceived(false){ ...// Shaderprogram #0 : regular geometry (painting triangles via element index)ShaderProgram blocks(":/shaders/withWorldAndCamera.vert",":/shaders/simple.frag");blocks.m_uniformNames.append("worldToView");m_shaderPrograms.append( blocks );// Shaderprogram #1 : grid (painting grid lines)ShaderProgram grid(":/shaders/grid.vert",":/shaders/grid.frag");grid.m_uniformNames.append("worldToView"); // mat4grid.m_uniformNames.append("gridColor"); // vec3grid.m_uniformNames.append("backColor"); // vec3m_shaderPrograms.append( grid ); ...}

Die Konfiguration aller Shaderprogramme kann vor der eigentlichen OpenGL-Initialisierung erfolgen. Diese erfolgt für jedes Shaderprogramm beim Aufruf der Funktion ShaderProgram::create(). Die macht dann die eigentliche Initialisierung, die in den vorangegangenen Tutorials in der initializeGL() Funktion gemacht wurde:

ShaderProgram.cpp:create()

void ShaderProgram::create() {Q_ASSERT(m_program == nullptr);m_program = new QOpenGLShaderProgram();if (!m_program->addShaderFromSourceFile(QOpenGLShader::Vertex, m_vertexShaderFilePath))qDebug() << "Vertex shader errors:\n" << m_program->log();if (!m_program->addShaderFromSourceFile(QOpenGLShader::Fragment, m_fragmentShaderFilePath))qDebug() << "Fragment shader errors:\n" << m_program->log();if (!m_program->link())qDebug() << "Shader linker errors:\n" << m_program->log();m_uniformIDs.clear();for (const QString & uniformName : m_uniformNames)m_uniformIDs.append( m_program->uniformLocation(uniformName));}

Dank der netten Hilfsfunktionen QOpenGLShaderProgram::addShaderFromSourceFile() und QOpenGLShaderProgram::uniformLocation() ist das auch recht übersichtlich. Die Fehlerbehandlung könnte noch besser sein, aber das kann man ja schnell nachrüsten.

Beim Aufruf von QOpenGLShaderProgram::addShaderFromSourceFile() ist das erste Argument zu beachten, welches den Typ des Shaderprogramms festlegt!

Die Funktion uniformLocation() sucht in beiden Shaderprogrammen nach uniform Deklarationen, also Variablen, die unabhängig von Vertex oder Fragment dem Shaderprogramm zur Verfügung stehen. Diese werden beim compilieren und linken durchnummeriert und den zu einem uniform-Variablennamen passenden Index kann man mit uniformLocation() ermitteln.

Bei der Verwendung des Shaders kann man dann mit setUniformValue() den entsprechenden Wert setzen (siehe auch Shaderprogramm-Beispiele im Abschnitt "Zeichenobjekte").

Die Shaderprogramme wissen selbst nicht, für welche Objekte sie zum Zeichnen gebraucht werden. Auch werden die Variablen (uniforms), die sie zur Funktion benötigen, meist woanders gespeichert. Daher gibt es in der Klasse nicht mehr zu tun.

5.6. Transformationsmatrizen und Kamera

5.6.1. Transformationen

Das Thema Transformationsmatrizen ist in den in der Einleitung zitierten Webtutorials/Anleitungen ausreichend beschrieben. Die Format zur Transformation eines Punktes/Vektors pModel in den Modellkoordinaten zu den View-Koordinaten pView benötigt 3 Transformationsmatrizen:

pView = M_projection * M_World2Camera * M_Model2World * pModel

Dies entspricht den Schritten:

  1. Transformation des Punktes von Modellkoordinaten in das Weltenkoordinatensystem. Dies ist bei bewegten/animierten Objekten sinnvoll, d.h. eine Objekteigenschaft. Manchmal möchte man auch die gesamte Welt transformieren, auch dafür nimmt man die Model-zu-Welt-Transformationsmatrix.

  2. Transformation von Welt- zu Beobachterkoordinatensystem (Kamera). Ist eigentlich das Gleiche, jedoch ist die Kamera, deren Ausrichtung und Position modellunabhängig.

  3. Projektionstransformation (othogonal, perspektivisch, …​), kann z.B. durch near/far-plane und Angle-of-View definiert werden.

Da die Objekte in Modell bzw. Weltkoordinaten definiert und verwaltet werden, sollte besser OpenGL die Transformationen durchführen (dafür ist es ja gemacht). Je nach Anzahl der zu transformierenden Objekte kann nun den objektspezifischen ersten Transformationsschritt in das Weltenkoordinatensystem auf der CPU durchführen (idealerweise parallelisiert). Die Transformation von Weltkoordinaten in die projezierte Darstellung macht dann OpenGL. Da diese Matrix für alle Objekte gleich ist, kann man diese auch bequem den Shaderprogrammen übergeben. D.h. die Matrix:

M_World2View = M_Projection * M_World2Camera * M_Model2World

wird als uniform-Variable an die Shaderprogramme übergeben. Die Transformieren dann damit hocheffizient auf der Grafikkarte alle Vertex-Koordinaten.

5.6.2. Aktualisierung der World2View Matrix

Die Projektionsmatrix ändert sich bei jeder Viewport-Änderung, da sich damit zumeist das Breite/Höhe-Verhältnis ändert. Sonst ändert sich diese Matrix eigentlich nie, außer vielleicht in den Benutzereinstellungen (wenn z.B. Linseneigenschaften wie Öffnungswinkel oder Zoom verändert werden).

Die Model2World-Matrix bleibt wie oben geschrieben außen vor, da objektabhängig.

Die Kameramatrix (World2Camera) ändert sich jedoch ständig während der Navigation durch die Szene. Da die Navigation am Anfang der Neuzeichenroutine ausgewertet wird, erfolgt die Neuberechnung der Matrix (falls notwendig) auch direkt vorm Neuzeichnen.

Es ist denkbar, dass ein MouseMove-Event mehrfach während eines Frames ausgelöst wird. Wenn man nun die Neuberechnung der Matrix daran koppelt, führt das mitunter zu unnützer Rechenarbeit. Daher ist es sinnvoller, die Berechnung erst zu Beginn des Zeichenzyklus durchzuführen.

Die eigentliche Berechnung erfolgt in der Funktion updateWorld2ViewMatrix. Dank der Funktionalität der Matrixklasse QMatrix4x4 eine sehr kompakte Funktion.

void SceneView::updateWorld2ViewMatrix() {// transformation steps:// model space -> transform -> world space// world space -> camera/eye -> camera view// camera view -> projection -> normalized device coordinates (NDC)m_worldToView = m_projection * m_camera.toMatrix() * m_transform.toMatrix();}

Die Multiplikation mit der Modell-Transformationsmatrix (m_transform) ist eigentlich nicht zwingend notwendig, dient aber der Demonstration der Animationsfähigkeit (konstantes Rotieren der Welt um die y-Achse). Dazu den #if 0 Block in paintGL() nach #if 1 ändern.

Die ganze Arbeit der Konfiguration und Erstellung der Translations, Rotations, und Skalierungsmatrizen macht die Klasse Transform3D. In der Funktion toMatrix() werden diese einzelnen Matrizen zur Gesamtmatrix kombiniert (implementiert mit Lazy-Evaluation):

Transform3D.cpp:toMatrix()

const QMatrix4x4 &Transform3D::toMatrix() const {if (m_dirty) {m_dirty = false;m_world.setToIdentity();m_world.translate(m_translation);m_world.rotate(m_rotation);m_world.scale(m_scale);}return m_world;}

Die Kamera-Klasse ist davon abgeleitet und beinhaltet letztlich nur die inverse Transformation vom Welten- zum Beobachterkoordinatensystem (siehe auch https://www.trentreed.net/blog/qt5-opengl-part-3b-camera-control). Im Prinzip hilft es sich vorzustellen, dass die Kamera ein positioniertes und ausgerichtetes Objekt selbst ist. Nun wollen wir dieses Kamera-Objekt nicht mittels einer Model2World-Transformationsmatrix in das Weltenkoordinatensystem hieven, sondern uns eher aus der Weltsicht in die lokale Sicht des Kamera-Objekts bewegen. Dies bedeuted, wir müssen alle Weltkoordinaten mittels der Inversen der Kamera-Objekt-Model2World-Matrix multiplizieren. Das macht dann die entsprechend spezialisiert toMatrix()-Funktion:

Camera.h:toMatrix()

const QMatrix4x4 & toMatrix() const {if (m_dirty) {m_dirty = false;m_world.setToIdentity();m_world.rotate(m_rotation.conjugated());m_world.translate(-m_translation);}return m_world;}

Daneben bietet die Kameraklasse noch 3 interessante Abfragefunktionen, welche die Koordinatenrichtungen des lokalen Kamera-Koordinatensystems im Weltenkoordinatensystem zurückliefern:

Camera.h

// negative Kamera-z-AchseQVector3D forward() const {const QVector3D LocalForward(0.0f, 0.0f, -1.0f);return m_rotation.rotatedVector(LocalForward);}// Kamera-y-AchseQVector3D up() const {const QVector3D LocalUp(0.0f, 1.0f, 0.0f);return m_rotation.rotatedVector(LocalUp);}// Kamera-x-AchseQVector3D right() const {const QVector3D LocalRight(1.0f, 0.0f, 0.0f);return m_rotation.rotatedVector(LocalRight);}

Die eigentliche Arbeit macht hier die Klasse QQuaternion, welche man dankenswerterweise nicht selbst implementieren muss.

5.7. Zeichenobjekte

In diesem Abschnitt geht es um die Verwaltung von Zeichenobjekten. Dies ist nicht wirklich ein Qt-Thema, da diese Art von Datenmanagement in der einen oder anderen Art in jeder OpenGL-Anwendung zu finden ist. Wen also nur die Qt-spezifischen Dinge interessieren, kann dieses Kapitel gerne überspringen.

5.7.1. Effizientes Zeichnen großer Geometrien

Es gibt eine wesentliche Grundregel in OpenGL:

Wenn man effizient große Geometrien zeichnen möchte, dann muss man die Anzahl der glDrawXXX Aufrufe so klein wie möglich halten.

Ein Beispiel: wenn man 2 Würfel zeichen möchte, hat man folgende Möglichkeiten:

  • alle 12 Seiten einzeln Zeichen (12 glDrawXXX Aufrufe), z.B. als:

    • GL_TRIANGLES (6 Vertices je Seite)

    • GL_TRIANGLE_STRIP (4 Vertices je Seite)

    • GL_QUADS (4 Vertices je Seite)

  • jeden Würfel einzeln zeichnen (2 glDrawXXX Aufrufe), dabei alle Seiten des Würfels zusammen zeichnen via:

    • GL_TRIANGLES (8 Vertices, 6*6 Elementindices)

    • GL_QUADS (8 Vertices, 6*4 Elementindices)

  • beide Würfel zusammen zeichnen (1 glDrawXXX Aufruf), dabei alle Seiten beider Würfels zusammen zeichnen via:

    • GL_TRIANGLES (2*8 Vertices, 2*6*6 Elementindices)

    • GL_QUADS (2*8 Vertices, 2*6*4 Elementindices)

Die oben angegebene Anzahl der Vertexes gilt natürlich nur für einfarbige Würfel. Sollen die Seitenflächen unterschiedlich gefärbt sein, braucht man natürlich für jede Seite 4 Vertices, also bspw. bei GL_TRIANGLES brauch man für die 2 Würfel 2*6*4 Vertices.

Wenn man Objekte mit gemischten Flächenprimitiven hat (also z.B. Dreiecke und Rechtecke, oder Polygone), dann kann man entweder nach Flächentyp zusammenfassen und je Flächentyp ein glDrawXXX Aufruf ausführen, oder eben alles als Dreiecke behandeln und nur einen Zeichenaufruf verwenden. Kann man mal durch Profiling ausprobieren, was dann schneller ist. Der Speicherverbrauch spielt auch eine Rolle, da der Datentransfer zwischen CPU und GPU immer auch an der Geschwindigkeit der Speicheranbindung hängt.

Die Gruppierung von Zeichenelementen erfolgt im Prinzip nach folgenden Kriterien:

  • Vertexdaten bei interleaved Storage (z.B. nur Koordinaten wie beim Gitter unten, Koordinaten-und-Farben, Koordinaten-Normalen-Texturcoords-Farben)

  • Geometrietyp (siehe oben)

  • Objektveränderlichkeit

  • Transparenz (dazu in einem späteren Tutorial mehr)

Das Ganze hängt also stark von der Anwendung ab. Im Tutorial 05 gibt es zwei Arten von Objekten:

  • das Gitter, bestehend aus Linien und ausschließlich Koordinaten, gezeichnet via GL_LINES

  • die Boxen, mit GL_TRIANGLES gezeichnet.

5.7.2. Verwaltung von Zeichenobjekten

Eine Möglichkeit, die für das Zeichnen derart gruppierter Daten benötigten Objekte, d.h. VertexArrayObject (VAO), VertexBufferObject (VBO) und ElementBufferObject (EBO), zu verwalten, ist eigene Datenhalteklassen zu verwenden. Diese sehen allgemein so aus:

Deklaration einer Zeichenobjektklasse

class DrawObject {public:DrawObject(); // create native OpenGL objects void create(QOpenGLShaderProgram * shaderProgramm); // release native OpenGL objectsvoid destroy(); // actual render objectsvoid render(); // Data members to store state .... QOpenGLVertexArrayObjectm_vao;QOpenGLBufferm_vbo; // Vertex bufferQOpenGLBufferm_ebo; // Element/index buffer// other buffer objects....};

Die drei wichtigen Lebenszyklusphasen der Objekte sind durch die Funktionen create(), destroy() und render() abgebildet.

Speichermanagement bei OpenGL Objekten sollte explizit erfolgen, und nicht im Destruktor der Zeichenobjekt-Klassen. Es ist beim Aufräumen im Destruktor durch die automatisiert generierte Aufrufreihenfolge der einzelnen Destruktoren schwierig sicherzustellen, dass der dazugehörige OpenGL-Kontext aktiv ist. Daher empfiehlt es sich, stets eine explizite destroy() Funktion zu verwenden.

Außerdem werden die Zeichenobjekte so kopierbar und können, unter anderem, in std::vector oder ähnlichen Container verwendet werden.

Am Besten wird das Datenmanagement in einer Beispielimplementierung sichtbar.

5.7.3. Zeichenobjekt #1: Gitterraster in X-Z Ebene

Beginnen wir mit einem einfachen Beispiel: Ein Gitterraster soll auf dem Bildschirm gezeichnet werden, sozusagen als "Boden". Es werden also Linien in der X-Z-Ebene (y=0) gezeichnet, wofür der Elementtyp GL_LINES zum Zeichnen verwendet wird.

Für jede Linie sind Start- und Endkoordinaten anzugeben, wobei die y-Koordinate eingespart werden kann.

Man muss nicht immer alle Koordinaten (x,y,z) an den Vertexshader übergeben, wenn es nicht notwendig ist.

Wir stellen also den Vertexpuffer mit folgendem Schema zusammen:

x1sz1sx1ez1ex2sz2sx2ez2e... also jeweils x und z Koordinatentuple für je Start- (s) und Endpunkt (e) einer Linie nacheinander.

Diese Geometrieinformation wird in der Klasse GridObject zusammengestellt:

GridObject.h, Klassendeklaration

class GridObject {public:void create(QOpenGLShaderProgram * shaderProgramm);void destroy();void render();unsigned intm_bufferSize;QOpenGLVertexArrayObjectm_vao;QOpenGLBufferm_vbo;};

Die Implementierung der create() Funktion ist das eigentlich Interessante:

GridObject.cpp:create()

void GridObject::create(QOpenGLShaderProgram * shaderProgramm) {const unsigned int N = 100; // number of lines to draw in x and z direction// width is in "space units", whatever that means for you (meters, km, nanometers...)float width = 500;// grid is centered around origin, and expands to width/2 in -x, +x, -z and +z direction// create a temporary buffer that will contain the x-z coordinates of all grid linesstd::vector<float>gridVertexBufferData;// we have 2*N lines, each line requires two vertexes, with two floats (x and z coordinates) each.m_bufferSize = 2*N*2;gridVertexBufferData.resize(m_bufferSize);float * gridVertexBufferPtr = gridVertexBufferData.data();// compute grid lines with z = constfloat x1 = -width*0.5;float x2 = width*0.5;for (unsigned int i=0; i<N; ++i, gridVertexBufferPtr += 4) {float z = width/(N-1)*i-width*0.5;gridVertexBufferPtr[0] = x1;gridVertexBufferPtr[1] = z;gridVertexBufferPtr[2] = x2;gridVertexBufferPtr[3] = z;}// compute grid lines with x = constfloat z1 = -width*0.5;float z2 = width*0.5;for (unsigned int i=0; i<N; ++i, gridVertexBufferPtr += 4) {float x = width/(N-1)*i-width*0.5;gridVertexBufferPtr[0] = x;gridVertexBufferPtr[1] = z1;gridVertexBufferPtr[2] = x;gridVertexBufferPtr[3] = z2;}

Im ersten Teil wird ein linearer Speicherbereich (bereitgestellt in einem std::vector) mit den Liniendaten gefüllt. Das Raster besteht aus Linien in X und Z Richtung (2), jeweils N Linien, und jede Linie hat einen Start- und einen Endpunkt (2) und jeder Punkt besteht aus 2 Koordinaten. Dies macht 2*N*2*2 floats (=NVertices).

Es ist ok an dieser Stelle den Speicherbereich in einem temporären Vektor anzulegen, da beim Erzeugen des OpenGL-Vertexpuffers die Daten kopiert werden und der Vektor danach nicht mehr benötigt wird. Dies ist im Falle von veränderlichen Daten (siehe BoxObjekte unten) anders.

Im zweiten Teil der Funktion werden dann wie gehabt die OpenGL-Pufferobjekte erstellt:

GridObject.cpp:create(), fortgesetzt

// Create Vertex Array Objectm_vao.create();// create Vertex Array Objectm_vao.bind();// and bind it// Create Vertex Buffer Objectm_vbo.create();m_vbo.bind();m_vbo.setUsagePattern(QOpenGLBuffer::StaticDraw);int vertexMemSize = m_bufferSize*sizeof(float);m_vbo.allocate(gridVertexBufferData.data(), vertexMemSize);// layout(location = 0) = vec2 positionshaderProgramm->enableAttributeArray(0); // array with index/id 0shaderProgramm->setAttributeBuffer(0, GL_FLOAT, 0 /* position/vertex offset */, 2 /* two floats per position = vec2 */, 0 /* vertex after vertex, no interleaving */);m_vao.release();m_vbo.release();}

Die Aufrufe von shaderProgramm->enableAttributeArray und shaderProgramm->setAttributeBuffer definieren, wie der Vertexshader auf diesen Speicherbereich zugreifen soll. Deshalb muss die Funktion create() auch das dazugehörige Shaderprogramm als Funktionsargument erhalten.

Nachdem nun die Puffer erstellt und konfiguriert wurden, ist der Rest der Klassenimplementierung recht übersichtlich:

GridObject.cpp:destroy() und render()

void GridObject::destroy() {m_vao.destroy();m_vbo.destroy();}void GridObject::render() {m_vao.bind();// draw the grid lines, m_bufferSize = number of floats in bufferglDrawArrays(GL_LINES, 0, m_bufferSize);m_vao.release();}

Die Funktion destroy() ist sicher selbsterklärend. Und die Render-Funktion ebenso.

Beachte, dass die Funktion glDrawArrays() als drittes Argument die Länge des Puffers als Anzahl der Elemente vom Typ des Puffers (hier GL_FLOAT) erwartet, und nicht die Länge in Bytes.

Die Funktion render() wird direkt aus SceneView::paintGL() aufgerufen. Hier ist der entsprechende Abschnitt aus der Funktion:

SceneView.cpp:paintGL()

void SceneView::paintGL() { ...// set the background color = clear colorQVector3D backColor(0.1f, 0.15f, 0.3f);glClearColor(0.1f, 0.15f, 0.3f, 1.0f);QVector3D gridColor(0.5f, 0.5f, 0.7f); ...// *** render grid ***SHADER(1)->bind();SHADER(1)->setUniformValue(m_shaderPrograms[1].m_uniformIDs[0], m_worldToView);SHADER(1)->setUniformValue(m_shaderPrograms[1].m_uniformIDs[1], gridColor);SHADER(1)->setUniformValue(m_shaderPrograms[1].m_uniformIDs[2], backColor);m_gridObject.render(); // render the gridSHADER(1)->release(); ...

Hier sieht man auch, wie die Variablen an die Shaderprogramme übergeben werden. In Abschnitt "Shaderprogramme" oben wurde ja gezeigt, wie die IDs der uniform Variablen ermittelt werden. Nun müssen diese Variablen vor jeder Verwendung des Shaderprogramms gesetzt werden. Dies erfolgt direkt vor dem Aufruf der GridObject::render() Funktion.

Das Ergebnis dieses Zeichnens (mit uniformer Gitterfarbe) ist zunächst ganz nett:

OpenGL + Qt Tutorial (2)

Figure 6. Einfaches Gitterraster (einfarbig) mit sichtbarer endlicher Ausdehnung

Aber schöne wäre es, wenn das Gitter mit zunehmender Tiefe verblasst.

Gitter mit Abblendung in der Tiefe

Das Gitter sollte sich nun in weiter Ferne der Hintergrundfarbe annähern. Man könnte das zum Beispiel erreichen, wenn man die Farbe des Gitters an weiter entfernten Punkte einfärbt.

Den Vertexshader könnte man wie folgt erweitern:

#version 330// GLSL version 3.3// vertex shaderlayout(location = 0) in vec2 position; // input: attribute with index '0' // with 2 floats (x, z coords) per vertexout vec4 fragColor; // output: computed vertex color for shaderconst float FARPLANE = 50; // thresholdfloat fragDepth; // normalized depth valueuniform mat4 worldToView; // parameter: the view transformation matrixuniform vec3 gridColor; // parameter: grid color as rgb tripleuniform vec3 backColor; // parameter: background color as rgb triplevoid main() { gl_Position = worldToView * vec4(position.x, 0.0, position.y, 1.0); fragDepth = max(0, min(1, gl_Position.z / FARPLANE)); fragColor = vec4( mix(gridColor, backColor, fragDepth), 1.0);}

Es gibt 3 Parameter, die dem Shaderprogramm gegeben werden müssen (das passiert in SceneView::paintGL(), siehe Quelltextausschnitt oben):

  • worldToView - Transformationsmatrix (von Weltkoordinaten zur perspektivischen Ansicht)

  • gridColor - Farbe des Gitters

  • backColor - Hintergrundfarbe

Die Variable gl_Position enthält nach der Transformation die normalisierten Koordinaten. In der Berechnung wird die zweite Komponente des Vertex-Vektors (angesprochen über .y) als z-Koordinate verwendet.

Für die Abblendefunktionalität ist die Entfernung des Linienstart- bzw. -endpunktes interessant. Nun sind die z-Koordinaten dieser normalisierten Position alle sehr dicht an 1 dran. Deshalb werden sie noch skaliert (entsprechend der perspektivischen Transformationsregeln etwas wie eine Farplane). Nun kann man diese Tiefe, gespeichert in der Variable fragDepth nutzen, um zwischen Gitterfarbe und Hintergrundfarbe linear mit der GLSL-Funktion mix() zu interpolieren.

OpenGL + Qt Tutorial (3)

Figure 7. Gitterraster mit Vertex-basierter Abblendung

Das Ergebnis geht schon in die richtige Richtung, aber es gibt einen unschönen Effekt, wenn man parallel zu den Linien schaut. Die Koordinaten der Endpunkte der seitlich laufenden Linien sind sehr weit weg (in der perspektivischen Projekten), sodass beide Linienenden nahezu Hintergrundfarbe bekommen. Und da die Fragmentfarbe eine lineare Interpolation zwischen den Vertexfarben ist, verschwindet die gesamte Linie.

Das Problem lässt sich nur beheben, wenn man die Ablendfunktionalität in den Fragment-Shader steckt.

Der Vertex-Shader wird dadurch total einfach:

grid.vert (Vertexshader)

#version 330// GLSL version 3.3// vertex shaderlayout(location = 0) in vec2 position; // input: attribute with index '0' // with 2 floats (x, z coords) per vertexuniform mat4 worldToView; // parameter: world to view transformation matrixvoid main() { gl_Position = worldToView * vec4(position.x, 0.0, position.y, 1.0);}

Letztlich werden nur noch die Vertex-Koordinaten transformiert und an den Fragment-Shader weitergereicht. Der sieht dann so aus:

grid.frag (Fragmentshader)

#version 330out vec4 fColor;uniform vec3 gridColor; // parameter: grid color as rgb tripleuniform vec3 backColor; // parameter: background color as rgb tripleconst float FARPLANE = 150; // thresholdvoid main() { float distanceFromCamera = (gl_FragCoord.z / gl_FragCoord.w) / FARPLANE; distanceFromCamera = max(0, min(1, distanceFromCamera)); // clip to valid value range fColor = vec4( mix(gridColor, backColor, distanceFromCamera), 1.0 );}

Die Variable gl_FragCoord wird für jeden einzelnen Bildpunkt von OpenGL bereitgestellt und enthält die Normalized Device Coordinates (NDC). Wenn man beachtet, dass diese Koordinaten durch Division mit w berechnet werden, dann bekommt man die originale z-Koordinate durch Multiplikation mit w. Das ganze wird dann noch mit einem Begrenzungswert (FARPLANE) skaliert. Falls bei der Definition des View-Frustums andere Werte für Near/Farplane verwendet werden, muss man die Formel entsprechend anpassen (siehe https://learnopengl.com/Advanced-OpenGL/Depth-testing für die dahinterliegende Mathematik).

Damit sieht das Ergebnis dann wie gewünscht aus:

OpenGL + Qt Tutorial (4)

Figure 8. Gitterraster mit Fragment-basierter Abblendung (Fog/Nebeleffekt)

5.7.4. Zeichenobjekt #2: Viele viele Boxen

Um die Performance der Grafikkarte (und der Anwendung) zu testen, kann man sehr viele Boxen modellieren und dann mittels eines einzigen glDrawElements()-Aufrufs zeichnen lassen. Bei modernen Grafikkarten sollten locker Millionen von Boxen flüssig gezeichnet werden können.

Die Aufgabe besteht nun darin, die Vertexdaten aller Boxen und die dazugehörigen Elementindexe in die zwei Puffer (VBO und EBO) zu stecken, und den Quelltext auch noch einigermaßen verstehen zu können.

Zunächst wird wie beim Gitter ein Boxen-Zeichenobjekt erstellt:

BoxObject.h

class BoxObject {public:BoxObject(); void create(QOpenGLShaderProgram * shaderProgramm);void destroy();void render();std::vector<BoxMesh>m_boxes;std::vector<Vertex>m_vertexBufferData;std::vector<GLuint>m_elementBufferData; QOpenGLVertexArrayObjectm_vao;QOpenGLBufferm_vbo;QOpenGLBufferm_ebo;};

Sieht erstmal fast genauso aus wie bei der Klasse GridObject.

Beide Klassen stellen ja die gleichen Funktionen zur Verfügung. Man könnte also auf die Idee kommen, hinsichtlich Initialisierung und Aufräumen alle Zeichenobjekte gleich zu behandeln. Geht sicher, hängt aber vom Programm ab (und der Datenveränderlichkeit), ob das sinnvoll ist. Beim Tutorial 05 wäre das sicher gut gewesen (hab ich mir aber wegen nur zwei Objekten gespart).

Vielleicht noch ein Hinweis zu den Puffern. Neben OpenGL-Pufferobjekten m_vbo und m_ebo sind die ursprünglichen Datenpuffer m_vertexBufferData und m_elementBufferData dauerhaft als Membervariablen vorhanden. Dies ermöglicht eine nachträgliche Aktualisierung eines Teils der Daten (z.B. Farben einer einzelnen Box oder einer Seite), ohne dass neu Speicher reserviert werden muss und die Puffer erneut aufgebaut werden.

Teilweise Aktualisierung von Pufferdaten spielt in diesem Tutorial keine Rolle. Es lohnt sich aber, die Funktion QOpenGLBuffer::mapRange anzuschauen (bzw. die darunterliegenden nativen OpenGL-Funktionen glMapBuffer und glMapBufferRange).

Die eigentliche Geometrie, d.h. Größe und Position der Boxen wird durch die BoxMesh-Objekte bereitgestellt, welche im Vektor m_boxes vorgehalten werden.

Die Implementierung der 3 Funktionen ist dann auch recht ähnlich wie beim GridObject.

BoxObject.cpp:destroy() und render()

void BoxObject::destroy() {m_vao.destroy();m_vbo.destroy();m_ebo.destroy();}void BoxObject::render() {m_vao.bind();glDrawElements(GL_TRIANGLES, m_elementBufferData.size(), GL_UNSIGNED_INT, nullptr);m_vao.release();}

Die Funktionen destroy() und render() sind selbsterklärend (wie schon beim GridObject. Zur Vollständigkeit sei nocheinmal der Aufruf der Zeichenfunktion gezeigt:

SceneView.cpp:paintGL()

void SceneView::paintGL() { ...// *** render boxesSHADER(0)->bind();SHADER(0)->setUniformValue(m_shaderPrograms[0].m_uniformIDs[0], m_worldToView);m_boxObject.render(); // render the boxesSHADER(0)->release(); ...
Erstellung der OpenGL-Puffer - struct Vertex

Interessanter ist dann schon die create()-Funktion, in der die Puffer befüllt werden:

BoxObject.cpp:create()

void BoxObject::create(QOpenGLShaderProgram * shaderProgramm) {// create and bind Vertex Array Objectm_vao.create();m_vao.bind();// create and bind vertex bufferm_vbo.create();m_vbo.bind();m_vbo.setUsagePattern(QOpenGLBuffer::StaticDraw);int vertexMemSize = m_vertexBufferData.size()*sizeof(Vertex);m_vbo.allocate(m_vertexBufferData.data(), vertexMemSize); // create and bind element bufferm_ebo.create();m_ebo.bind();m_ebo.setUsagePattern(QOpenGLBuffer::StaticDraw);int elementMemSize = m_elementBufferData.size()*sizeof(GLuint);m_ebo.allocate(m_elementBufferData.data(), elementMemSize); // set shader attributes// index 0 = positionshaderProgramm->enableAttributeArray(0); // array with index/id 0shaderProgramm->setAttributeBuffer(0, GL_FLOAT, 0, 3, sizeof(Vertex));// index 1 = colorshaderProgramm->enableAttributeArray(1); // array with index/id 1shaderProgramm->setAttributeBuffer(1, GL_FLOAT, offsetof(Vertex, r), 3, sizeof(Vertex));m_vao.release();m_vbo.release();m_ebo.release();}

Die create()-Funktion ist inzwischen sicher gut verständlich (ansonsten siehe Tutorial 03 und Tutoral 04):

  1. das Vertex Array Objekt wird erstellt,

  2. die Pufferobjekte werden erstellt und die Inhalte der bereits initialisierten Puffer (m_vertexBufferData und m_elementBufferData werden in die OpenGL-Puffer kopiert)

  3. die Attribute im Shaderprogramm werden gesetzt, d.h. die Zusammensetzung des Puffers

Hier kommt das erste Mal die Struktur Vertex zum Einsatz. Diese gruppiert alle Attribute eines einzelen Vertex:

Vertex.h

struct Vertex {Vertex() {}Vertex(const QVector3D & coords, const QColor & col) :x(float(coords.x())),y(float(coords.y())),z(float(coords.z())),r(float(col.redF())),g(float(col.greenF())),b(float(col.blueF())){}float x,y,z;float r,g,b;};

Die Klasse enthält derzeit lediglich 6 floats, 3 für die Koordinaten, und 3 für das rgb-Farbtuple.

Beim Erstellen eines Puffers im interleaved-Modus werden nun die Vertex-Daten nacheinander in den Puffer kopiert (Details dazu im nächsten Abschnitt).

Dem Shaderprogramm muss man nun mitteilen, wo in diesem kontinuierlichen Speicherbereich die einzelen Attribute zu finden sind. Der stride-Parameter ist die Größe eines Vertex-Datemblocks in Bytes, welches sizeof(Vertex) zurückliefert. Das offset Argument (3. Argument in setAttributeBuffer()) ist die Anzahl der Bytes seit Beginn eines Vertexblocks, bei dem das jeweilige Datenelement beginnt. Im Fall des rgb-Farbtuples beginnt dieser Speicherbereich bei dem float r, und das passende Byte-Offset liefert offset(Vertex, r) zurück.

Man könnte statt offset(Vertex, r) auch 3*sizeof(float) oder 12 schreiben. ABER dann besteht die Gefahr, dass bei komplexeren Strukturen durch implizites Padding ungewollt eine Speicherbereichsverschiebung auftritt und das Shaderprogramm dann auf einen falschen Speicherbereich zugreift (siehe auch http://www.catb.org/esr/structure-packing). Dies ist auch der Grund, warum sizeof(Vertex) statt 6*sizeof(float) als stride verwendet wird. Solange nur floats in der Struktur verwendet werden, wird der Compiler (normalerweise) kein Padding einfügen.

Initialisieren der Vertex- und Elementpuffer für die Boxen

Die ganze Arbeit der Vertex- und Index-Puffer-Erstellung wird im Konstruktor der Klasse BoxObject und der Hilfsklasse BoxMesh gemacht.

BoxObject.cpp:Konstruktor

BoxObject::BoxObject() :m_vbo(QOpenGLBuffer::VertexBuffer), // actually the default, so default constructor would have been enoughm_ebo(QOpenGLBuffer::IndexBuffer) // make this an Index Buffer{// create center boxBoxMesh b(4,2,3);b.setFaceColors({Qt::blue, Qt::red, Qt::yellow, Qt::green, Qt::magenta, Qt::darkCyan});Transform3D trans;trans.setTranslation(0,1,0);b.transform(trans.toMatrix());m_boxes.push_back( b);const int BoxGenCount = 10000;const int GridDim = 50; // must be an int, or use cast below// initialize grid (block count)int boxPerCells[GridDim][GridDim];for (unsigned int i=0; i<GridDim; ++i)for (unsigned int j=0; j<GridDim; ++j)boxPerCells[i][j] = 0;for (unsigned int i=0; i<BoxGenCount; ++i) {// create other boxes in randomize grid, x and z dimensions fixed, height varies discretely// x and z translation in a grid that has 500 units width/depths with 5 m grid line spacingint xGrid = qrand()*double(GridDim)/RAND_MAX;int zGrid = qrand()*double(GridDim)/RAND_MAX;int boxCount = boxPerCells[xGrid][zGrid]++;float boxHeight = 4.5;BoxMesh b(4,boxHeight,3);b.setFaceColors({Qt::blue, Qt::red, Qt::yellow, Qt::green, Qt::magenta, Qt::darkCyan});trans.setTranslation((-GridDim/2+xGrid)*5, boxCount*5 + 0.5*boxHeight, (-GridDim/2 + zGrid)*5);b.transform(trans.toMatrix());m_boxes.push_back(b);}unsigned int NBoxes = m_boxes.size();// resize storage arraysm_vertexBufferData.resize(NBoxes*BoxMesh::VertexCount);m_elementBufferData.resize(NBoxes*BoxMesh::IndexCount);// update the buffersVertex * vertexBuffer = m_vertexBufferData.data();unsigned int vertexCount = 0;GLuint * elementBuffer = m_elementBufferData.data();for (const BoxMesh & b : m_boxes)b.copy2Buffer(vertexBuffer, elementBuffer, vertexCount);}

Wichtig ist zunächst die Initialisierung der QOpenGLBuffer Objekte. Als Konstruktorargument wird der Typ des Buffers angegeben (VertexBuffer ist der Standard, aber beim m_ebo Objekt muss man IndexBuffer festlegen).

Dann wird zunächst eine Testbox erstellt. Dies beinhaltet die folgenden Schritte:

  1. Erstellung eines BoxMesh Objekts mit den Ausdehnungen 4x2x3 (die Box wird zentriert um das eigene Koordinatensystem erstellt, also x=-2…​-2, y=-1…​1, z=-1,5…​1,5):

    BoxMesh b(4,2,3);
  2. Festlegen der Seitenfarben:

    b.setFaceColors({Qt::blue, Qt::red, Qt::yellow, Qt::green, Qt::magenta, Qt::darkCyan});
  3. Verschiebung der Box in das Weltenkoordinatensystem (erst Erstellung der Transformationsmatrix, dann anwenden der Transformation auf die Box):

    Transform3D trans;trans.setTranslation(0,1,0);b.transform(trans.toMatrix());
  4. Zuletzt ablegen der Box im Vektor m_boxes:

    m_boxes.push_back( b);

Die Klasse BoxMesh merkt sich zunächst nur die Koordinaten und Farbzuordnungen.

Als nächstes werden noch eine Reihe weiterer Boxen erstellt, und in einem Raster mit Dimension GridDim x GridDim gestapelt. Wenn man mal die eienen Grafikkarte testen will, kann man gerne BoxGenCount auf eine Million erhöhen und/oder das Gitterraster vergrößern (z.B. GridDim=500) um eine etwas größere "Stadt" zu bekommen.

Bei größeren Rasterdimensionen sieht man auch gut den Effekt des Tiefenclippings, d.h. Objekte hinter der FARPLANE werden nicht mehr gerendert.

Nun kommt der eigentlich interessante Teil. Es werden erst Pufferspeicher reserviert. Dabei liefern die Funktionen BoxMesh::VertexCount und BoxMesh::IndexCount die je Meshobjekt benötigte Anzahl von Elementen zurück. Man hätte hier auch gleich die Anzahl eintragen können, aber so bleibt der Code hinreichend universell und kann auf beliebige andere Meshobjekte übertragen werden.

Zuletzt kommt das Befüllen der Puffer in traditioneller C-Methodik zum Befüllen kontinuierlicher Speicherbereiche mit Elementen:

Vertex * vertexBuffer = m_vertexBufferData.data();unsigned int vertexCount = 0;GLuint * elementBuffer = m_elementBufferData.data();for (const BoxMesh & b : m_boxes)b.copy2Buffer(vertexBuffer, elementBuffer, vertexCount);

Es werden erst Zeiger auf den Beginn des Pufferspeichers geholt und der Startindex der Vertices auf 0 gesetzt. Dann werden in jedem Schleifendurchlauf die Daten eines BoxMeshes in die Puffer geschrieben und die Zeigervariablen entsprechend vorgerückt. Ebenso wird der Startindex der Vertexes erhöht (vertexCount), sodass bei er nächsten Box neue Vertexnummern vergeben werden.

In dieser Art ließen sich ohne weiteres andere Objekttypen verwalten und zusammengefasst in einen Zeichenpuffer kopieren. Die ganze objektspezifische Geometriearbeit passiert im jeweiligen Mesh-Objekt, in diesem Fall in der Klasse BoxMesh.

Die Klasse BoxMesh

Inzwischen sollte die Aufgabe der Klasse BoxMesh klar sein:

  • speichern der originalen Geometrie (im lokalen Koordinatensystem)

  • speichern/anwenden der Transformation zum Weltenkoordinatensystem

  • befüllen des linearen Vertexpuffer-Speichers und Elementpuffer-Speichers

Auch hier gibt es wieder verschiedene Möglichkeiten. Man kann sich, nach dem Prinzip der lazy evaluation erst einmal nur die für die Schritte benötigten Parameter merken, also z.B. Breite, Höhe und Länge der Box, und die Transformationsmatrix. Wenn dann der Vertexpuffer gefüllt werden soll, erstellt man die Vertexkoordinaten, führt die Transformation aus und kopiert dann die resultierenden Koordinaten. Das Verfahren ist sinnvoll, wenn sich die Transformation (also Model-zu-Weltkoordinaten) häufig ändert.

Alternativ kann man, wie hier in Tutorial 05, auch die Koordinaten gleich berechnen, d.h. beim Erstellen des Objekt die Vertexkoordinaten im lokalen Koordinatensystem festlegen, und dann bei Ausführen der Transformation sofort an Ort und Stelle transformieren. Dies reduziert die Arbeit beim eigentlichen Befüllen des OpenGL-Vertex-Puffers, führt aber zu witzigen Effekten bei mehrfacher Anwendung der in-place Transformation (wegen der unvermeidlichen Rundungsfehler…​ einfach mal mehrere 100 Mal im Kreis drehen und sich über die Geometrieveränderung freuen). Da Animation oder Transformation in diesem Tutorial keine Rolle spielt, werden die Boxen gleich zu Beginn ins Weltenkoordinatensystem transformiert.

Bevor wir uns der eigentlichen Implementierung widmen, hift vielleicht die eine oder andere Skizze, die Box-Geometrie zu verstehen:

OpenGL + Qt Tutorial (5)

Figure 9. Nummerierung der Knoten (Vertices) der Box

Die Nummerierung der Vertexes ist zunächst einmal für die Datenhaltung in der BoxMesh-Klasse notwendig. Es werden nämlich im Konstruktor schon einmal die Vertexkoordinaten berechnet:

BoxMesh.cpp, Konstruktor

BoxMesh::BoxMesh(float width, float height, float depth, QColor boxColor) {m_vertices.push_back(QVector3D(-0.5f*width, -0.5f*height, 0.5f*depth)); // a = 0m_vertices.push_back(QVector3D( 0.5f*width, -0.5f*height, 0.5f*depth)); // b = 1m_vertices.push_back(QVector3D( 0.5f*width, 0.5f*height, 0.5f*depth)); // c = 2m_vertices.push_back(QVector3D(-0.5f*width, 0.5f*height, 0.5f*depth)); // d = 3m_vertices.push_back(QVector3D(-0.5f*width, -0.5f*height, -0.5f*depth)); // e = 4m_vertices.push_back(QVector3D( 0.5f*width, -0.5f*height, -0.5f*depth)); // f = 5m_vertices.push_back(QVector3D( 0.5f*width, 0.5f*height, -0.5f*depth)); // g = 6m_vertices.push_back(QVector3D(-0.5f*width, 0.5f*height, -0.5f*depth)); // h = 7setColor(boxColor);}

Die Knotenkoordinaten sind zunächst in einem Vektor von QVector3D abgelegt. Bei einem nachfolgenden Aufruf zur Transformation werden diese Koordinaten einfach verändert:

BoxMesh.cpp:transform()

void BoxMesh::transform(const QMatrix4x4 & transform) {for (QVector3D & v : m_vertices)v = transform*v;}

Bei mehrfacher Ausführung von transform() auf die Rundungsfehler achten!

Nun sind die Boxen also bereits im Weltenkoordinatensystem verankert und der Vertexpuffer und Indexpuffer können befüllt werden.

Für das weitere Vorgehen ist es hilfreich, das Speicherlayout des Vertexpuffers einmal gesehen zu haben. Die folgende Abbildung zeigt das Ziel dieser Kopieraktion.

OpenGL + Qt Tutorial (6)

Figure 10. Speicherlayout des Vertexpuffers

Alle Boxen werden nacheinander im VBO abgelegt. Je Box sind das 6 Seiten, wobei für jede Seite 4 Vertexes mit je Koordinaten und Farbwerten abgelegt werden. Das Kopieren erfolgt in der Funktion copy2Buffer(), wobei jeweils die Daten für eine einzelne Box kopiert werden. In der Abbildung ist auch der stride (Länge eines Vertexdatenblocks) gezeigt.

In der Funktion copy2Buffer() wird zunächst ein temporärer Vektor cols mit Farben für jede Seite angelegt, für den Fall, dass einfarbige Boxen verwendet werden:

BoxMesh.cpp:copy2Buffer()

void BoxMesh::copy2Buffer(Vertex *& vertexBuffer, GLuint *& elementBuffer, unsigned int & elementStartIndex) const {std::vector<QColor> cols;Q_ASSERT(!m_colors.empty());// three ways to store vertex colorsif (m_colors.size() == 1) {cols = std::vector<QColor>(6, m_colors[0]);}else {Q_ASSERT(m_colors.size() == 6);cols = m_colors;}...

Nun werden die Seiten nacheinander in der Reihenfolge vorne, rechts, hinten, links, unten und oben in die Puffer geschrieben:

BoxMesh.cpp:copy2Buffer(), fortgesetzt

void BoxMesh::copy2Buffer(Vertex *& vertexBuffer, GLuint *& elementBuffer, unsigned int & elementStartIndex) const {...// front plane: a, b, c, d, vertexes (0, 1, 2, 3)copyPlane2Buffer(vertexBuffer, elementBuffer, elementStartIndex,Vertex(m_vertices[0], cols[0]),Vertex(m_vertices[1], cols[0]),Vertex(m_vertices[2], cols[0]),Vertex(m_vertices[3], cols[0]));// right plane: b=1, f=5, g=6, c=2, vertexes// Mind: colors are numbered upcopyPlane2Buffer(vertexBuffer, elementBuffer, elementStartIndex,Vertex(m_vertices[1], cols[1]),Vertex(m_vertices[5], cols[1]),Vertex(m_vertices[6], cols[1]),Vertex(m_vertices[2], cols[1]));// back plane: g=5, e=4, h=7, g=6copyPlane2Buffer(vertexBuffer, elementBuffer, elementStartIndex,Vertex(m_vertices[5], cols[2]),Vertex(m_vertices[4], cols[2]),Vertex(m_vertices[7], cols[2]),Vertex(m_vertices[6], cols[2]));// left plane: 4,0,3,7copyPlane2Buffer(vertexBuffer, elementBuffer, elementStartIndex,Vertex(m_vertices[4], cols[3]),Vertex(m_vertices[0], cols[3]),Vertex(m_vertices[3], cols[3]),Vertex(m_vertices[7], cols[3]));// bottom plane: 4,5,1,0copyPlane2Buffer(vertexBuffer, elementBuffer, elementStartIndex,Vertex(m_vertices[4], cols[4]),Vertex(m_vertices[5], cols[4]),Vertex(m_vertices[1], cols[4]),Vertex(m_vertices[0], cols[4]));// top plane: 3,2,6,7copyPlane2Buffer(vertexBuffer, elementBuffer, elementStartIndex,Vertex(m_vertices[3], cols[5]),Vertex(m_vertices[2], cols[5]),Vertex(m_vertices[6], cols[5]),Vertex(m_vertices[7], cols[5]));}

Beim Aufruf der Funktion copyPlane2Buffer() stehen die Zeiger vertexBuffer und elementBuffer stehts am Anfang des Speicherbereichs, in den die nun folgenden Seitendaten geschrieben werden.

Ebenso enthält die Variable elementStartIndex den Vertexindex, bei dem die Nummerierung beginnt. Bei der ersten Box beginnt die Nummerierung auf der Vorderseite mit 0 (d.h. Vertexes 0…​3 sind auf der Vorderseite), siehe auch folgende Abbildung:

OpenGL + Qt Tutorial (7)

Figure 11. Seitennummerierung und generierte Dreieckselemente

Die Koordinaten und Farben werden beim Aufruf in die Vertex-Struktur kopiert.

Nachdem die Daten für die Vorderseite kopiert wurden, sind die Zeiger entsprechend verschoben worden und zeigen nun auf den Speicherbereich der nächsten Seite. Beim Aufruf der Funktion copyPlane2Buffer() muss auf die korrekte Reihenfolge der Vertexes geachtet werden, sodass die Vertices immer entgegen des Uhrzeigersinns übergeben werden.

Die letzte Abbildung zeigt auch die zwei Dreiecke, welche die Seite bilden. Deshalb wird in dieser Funktion sowohl der Vertexpuffer als auch der Indexpuffer befüllt. Innerhalb der Funktion copyPlane2Buffer() wird die Nummerierung relativ durchgeführt, d.h. die Vertices sind immer 0 bis 3, wobei allerdings stets der Startindex addiert wird (siehe Abbildung, rechte Seite).

BoxMesh.cpp:copyPlane2Buffer()

void copyPlane2Buffer(Vertex * & vertexBuffer, GLuint * & elementBuffer, unsigned int & elementStartIndex, const Vertex & a, const Vertex & b, const Vertex & c, const Vertex & d){// first store the vertex data (a,b,c,d in counter-clockwise order)vertexBuffer[0] = a;vertexBuffer[1] = b;vertexBuffer[2] = c;vertexBuffer[3] = d; ...// advance vertexBuffervertexBuffer += 4;// we generate data for two triangles: a, b, d and b, c, delementBuffer[0] = elementStartIndex;elementBuffer[1] = elementStartIndex+1;elementBuffer[2] = elementStartIndex+3;elementBuffer[3] = elementStartIndex+1;elementBuffer[4] = elementStartIndex+2;elementBuffer[5] = elementStartIndex+3;// advance elementBufferelementBuffer += 6;// 4 vertices have been added, so increase start number for next planeelementStartIndex += 4;}

Hier machen wir uns nun eine nette Eigenschaft von C/C++ zu Nutze. Wenn wir einen Speicherbereich als Vektor einer Struktur behandeln, und via Index Objekte zuweisen, dass wird automatisch der Speicherbereich mit den Inhalten der Strukturen in der Reihenfolge der Deklaration der Variablen befüllt.

Da die Addressen und der Startindex als Referenzvariablen übergeben wurden, können wir die Zeiger "weiterschieben" und die Vertexanzahl entsprechend erhöhen.

Das schöne an der Funktion copyPlane2Buffer() ist, dass sie unverändert auch funktioniert, wenn die Vertex-Struktur später um Normalenvektoren und/oder Texturkoordinaten erweitert wird.

Mehr gibt es auch zur Klasse BoxMesh nicht zu sagen, womit wir am Ende des Tutorial 05 angelangt wären. Um das ganze aber noch abzurunden (und etwas schicker aussehen zu lassen) fehlt noch Kantenglättung.

5.8. Antialiasing

Es gibt hier verschiedene Möglichkeiten, Antialiasing (Kantenglättung) zu verwenden. Die wohl einfachste aus Sicht der Programmierung ist das Einschalten von Multisampling (MSAA) (siehe Erläuterung auf https://www.khronos.org/opengl/wiki/Multisampling).

Dazu muss man beim Konfigurieren des QSurfaceFormat-Objekts nur folgende Zeile hinzufügen:

format.setSamples(4);// enable multisampling (antialiasing)

Multisampling braucht mehr Grafikkartenspeicher und ist durch das mehrfache Samplen von Pixeln/Fragmenten natürlich langsamer. Daher gibt es auch die Möglichkeit, Antialiasing in das Shaderprogramm einzubauen. Das ist aber, ebenso wie ein Drahtgittereffekt, ein Thema für ein anderes Tutorial.

OpenGL + Qt Tutorial (2024)
Top Articles
Latest Posts
Article information

Author: Gregorio Kreiger

Last Updated:

Views: 5928

Rating: 4.7 / 5 (77 voted)

Reviews: 84% of readers found this page helpful

Author information

Name: Gregorio Kreiger

Birthday: 1994-12-18

Address: 89212 Tracey Ramp, Sunside, MT 08453-0951

Phone: +9014805370218

Job: Customer Designer

Hobby: Mountain biking, Orienteering, Hiking, Sewing, Backpacking, Mushroom hunting, Backpacking

Introduction: My name is Gregorio Kreiger, I am a tender, brainy, enthusiastic, combative, agreeable, gentle, gentle person who loves writing and wants to share my knowledge and understanding with you.