Darstellungsräume in der Bearbeitungskette
von OpenGL
In diesem Beitrag geht es um die Lage von Punkten im Raum und die Art und Weise, wie diese Positionen in den verschiedenen Stationen vom Mesh bis hin zum zweidimensionalen Bildschirm dargestellt werden. Dinge wie Farben oder Texturen werden hier nicht betrachtet. Es ist so, als wenn die Vektoren, mit denen einzelnen Punkte beschrieben werden, durch verschiedene Räume (spaces) geschickt würden, wobei bei jedem Raumwechsel eine Umrechnung in ein anderes Koordinatensystem erfolgt. Für den Umgang mit eigenen Shadern ist es außerordentlich wichtig, die Zusammenhänge zu verstehen, denn die genannten Umrechnungen müssen zum großen Teil vom Programmierer vorgenommen werden. - Ich möchte mich gleich im Vorfeld dafür entschuldigen, dass der folgende Text eine fürchterliche Mischung aus englischen und deutschen Begriffen sein wird. Ich habe einfach noch keinen Weg gefunden, alles sauber auf deutsch zu formulieren. Also, sprachlich ist der Beitrag keine Perle, aber ich hoffe, dass er dennoch verständlich ist.
Auf meinem Schreibtisch liegt neben der Tastatur folgende ausgedruckte und laminierte Übersicht:

1. Die lokalen Objektkoordinaten
Objekte (models) werden in der Regel in einem 3D-Programm wie Blender gestaltet. Dabei entsteht ein polygonales Netz, ein Mesh, das aus einer Vielzahl von Punkten (Vertices) mit jeweils eigenen Koordinaten zusammengesetzt ist. Alle Koordinaten beziehen sich auf einen Koordinaten-Nullpunkt, der in diesem Fall als Bezugsspunkt (Origin) des Objektes fungiert. Wo der Origin liegt, ist egal. Bei einem Objekt wie einer Kugel wird er in der Mitte liegen (muss aber nicht); bei einem Objekt wie einem Baum, der ja auf dem Terrain stehen soll, wird man den Origin zweckmäßigerweise nach unten verlegen und irgendwo innerhalb der Grundfläche platzieren. Wie gesagt, das geschieht im 3D-Programm.
Das Bündel mit Vertex-Koordinaten wird nun als Datei gespeichert und vom OpenGL-Programm in ein Array geladen. Um mit diesen lokalen Koordinaten weiterarbeiten zu können, müssen sie in homogene Koordinaten umgewandelt werden, was aber nichts anderes heißt als dass allen Vektoren eine 4. Komponente, die zunächst den Wert 1.0 hat, hinzugefügt wird. Den Zweck dieser Maßnahme habe ich in einem anderen Beitrag erläutert. Üblicherweise nimmt man diesen kleinen, aber wichtigen Schritt im Shaderprogramm vor.
2. Die globalen Weltkoordinaten
Die Übertragung ins Welt-Koordinatensystem ist der erste und gleichzeitig umfangreichste Schritt, der vorgenommen wird. Ein Objekt mag ja noch so schön und perfekt aus Vertices geformt sein, aber wo soll das Ding nun hin? Außerdem kann es ja sein, dass mehrere von den Objekten in der Szene platziert werden sollen, natürlich an verschiedenen Stellen. Außerdem möchte man, dass die Dinger unterschiedlich groß sein sollen und außerdem noch in verschiedene Richtungen weisen. Dieses alles ist Sache der Model-Transformation,
Durch die Model-Transformation werden die lokalen Koordinaten in sogenannte Weltkoordinaten umgerechnet, bei denen ein anderer Koordinaten-Nullpunkt gilt, der Welt-Origin. Dieses ist der Bezugspunkt für alle Objekte. Natürlich kann sich die Position eines Objektes nur auf einen einzelnen Punkt beziehen, und das ist der unter 1. genannte lokale Origin. Dieser wird positioniert, die anderen Vertices des Objektes werden mitgezogen.
Als erstes jedoch wird das Objekt skaliert, also in die gewünschten Proportionen gebracht. Anschließend dreht man es in die gewünschte Richtung und gleichzeitig so, dass es vernünftig auf dem Terrain steht. Das gilt z.B. für Autos, doch Gebäude sollte man doch besser lotrecht stehen lassen, sonst wird's in Hanglagen unbequem für die Bewohner. Erst im dritten Schritt wird das skalierte und gedrehte Koordinatenbündel an die richtige Stelle geschoben. Während die Skalierung (scale) und die Positionierung (translate) mit jeweils nur einem Vektor als Parameter erfolgen kann, benötigt eine Drehung um drei Achsen drei aufeinanderfolgende Schritte. Insgesamt besteht also die Model-Transformation aus fünf Schritten:
- 1. Skalierung mit Vektor (x, y, z)
- 2. Rotation um X-Achse mit Drehwinkel wx
- 3. Rotation um y-Achse mit Drehwinkel wy
- 4. Rotation um Z-Achse mit Drehwinkel wz
- 5. Verschiebung mit Vektor (x, y, z)
Die Reihenfolge der Operationen ist im übrigen nicht beliebig, wobei es bei der Rotation in bestimmten Fällen auch eine andere Reihenfolge der Schritte 2-4 geben kann. Noch etwas: Auch wenn für die Skalierung oder die Verschiebung nur ein Dreikomponentenvektor erforderlich ist, die Meshpunkte werden auch im Global Space grundsätzlich als homogene Vierkomponentenvektoren verwaltet.
3. Die Kamera-Koordinaten
Nachdem die Szenerie gestaltet und die einzelnen Objekte positioniert wurden, geht es in die nächste Phase. Es muss ja noch der richtige Blick in die Szene hergestellt werden, d.h. der Betrachter soll sich das Ganze von verschiedenen Positionen aus und unter verschiedenen Winkeln betrachten können. Vor allem aber soll er in die Szenerie hineingehen können, um z.B. einem bewegten Objekt folgen zu können. Dazu gibt es in jeder 3D-Szene eine Kamera, die das Auge des Betrachters repäsentiert. "Camera space", "eye space" und "view space" sind verschiedene Begriffe für dasselbe, nämlich das Koordinatensystem, in welches die Sichtweise auf die Szene hineingerechnet wurde, was mit der View-Transformation erledigt wird.
Doch wie geschieht das, und zwar so, dass nicht noch einmal alles neu zurechtgerückt werden muss? Ganz einfach: Die Kamera wird nicht gedreht oder verschoben, sondern die komplette Szenerie als Ganzes, und zwar so, dass der Kamera-Standort den neuen Koordinaten-Nullpunkt darstellt. Dabei geschieht aber alles anders herum als bei der Model-Transformationen. Wenn wir z.B. die Kamera nach rechts verschieben, ist es so, als würde die Szene nach links verschoben. Wenn wir die Kamera nach unten drehen, scheint sich die Szene nach oben zu bewegen, und zwar nicht linear, sondern um die Kamera-Position als Drehpunkt. Im Grunde geschieht dasselbe wie bei der Model-Transformation, nur eben mit entgegengesetzten Vorzeichen. Und natürlich fällt die Skalierung nun weg, denn die macht ja keinen Sinn.
4. Die homogenen Clip-Koordinaten
Die Projection-Transform ist die letzte, die wir in unserem Programm vornehmen. So wie wir an einer Kamera den Bildwinkel am Zoom-Objektiv einstellen, so justieren wir die Sicht auf die Szene so, dass wir einen passenden Ausschnitt der Szene sehen - mal mehr (Weitwinkeleffekt), mal weniger (Teleeffekt). Der Bildwinkel heißt hier "fow" (field of view) und liegt üblicherweise in der Gegend won 70°. Aber in diesem Schritt wird noch mehr eingestellt, etwa die Nahgrenze (z.B. 1) und die Ferngrenze (z.B. 100) des sichtbaren Bereichs. Alles was außerhalb liegt, wird ignoriert, wodurch die Szene u.U. erheblich kleiner wird, was wiederum der Performance zugute kommt. Dem plötzlichen Aufpoppen und Verschwinden von Elementen kann man mit Dunst oder ähnlichen Maßnahmen beikommen. - Schließlich spielt noch das Verhältnis der Fensterbreite zur Fensterhöhe eine Rolle, denn die Szenerie soll ja nicht in der Breite oder Höhe verzerrt dargestellt werden.
Erneut werden also die Koordinaten umgerechnet, und zwar so, dass sie eine perspektivische Sicht auf die Dinge ermöglichen oder aber eine orthogonale Sicht mit einer Parallelprojektion. Im ersten Fall werden die Objekte in eine Sichtpyramide ("view frustum") transportiert, im zweiten Fall in einen Quader. Bei perspektivischer Projektion wird die Entfernung von der Kamera-Position in der vierten Stelle der homogenen Koordinaten eingetragen, üblicherweise als "W" bezeichnet. Alles, was nun außerhalb der Frustums oder den orthogonalen Quaders liegt, wird weggeschnitten, da nicht sichtbar. Deshalb die Bezeichnung "Clip-Koordinaten".
5. Normalisierte Device-Koordinaten und Bildschirm-Koordinaten
Mit den letzten beiden Stationen (Räumen) hat der Anwender nichts mehr zu tun, zumindest solange es um die Transformationen im Umfeld des Vertex-Shaders geht. Nach der Projection-Transformation übernimmt OpenGL die weitere Bearbeitung. Zuerst wird alles normalisiert, d.h. in den Wertebereich -1 ... +1 hineingerechnet, damit die anschließende Umrechnung in zweidimensionale Pixelkoordinaten einheitlich (homogen) vonstatten gehen kann. Dazu gehört auch, dass die Koordinaten durch W dividiert werden. Das bewirkt, dass weiter entfernte Objekte entsprechend kleiner dargestellt werden. Nur dadurch kann ja die Tiefenwirkung zustande kommen. Beim finalen 2D-Koordinatensystem ist allerdings nicht der gesamte Bildschirm bzw. das gesamte Fenster maßgebend, sondern der darin festgelegte Ausschnitt: der Viewport. In vielen Fällen stimmen Fensterabmessungen und Viewport überein, es kann aber vorkommen, dass man mehrere Viewports anlegt, z.B. links einen Bereich für die dreidimensionale Szene, rechts eine zweidimensionale Spalte für Bedienelemente oder Anzeigen. Der Viewport wird OpenGL mit glViewport () mitgeteilt, was allerdings nichts mit dem Shader zu tun hat.
6. Die Matrixkette
Beim Gang durch die einzelnen Spaces sind wir vom Speziellen zum Allgemeinen, vom Lokalen zum Globalen vorgegangen. Das heißt, nicht nur der Raum, sondern auch der Geltungsbereich wurde jedes Mal erweitert. So muss die Projection-Transformation oft nur einmal während des gesamten Programmablaufs durchgeführt werden, während die View-Projektion meistens einmal pro Frame in Aktion tritt. Nur die Model-Transformation muss in jedem Frame für jedes einzelne Objekt angewandt werden.
Entscheidend bei diesen Vorgängen ist, dass alle Transformationen (auch deren Teilschritte) mit Hilfe von Matrizen erfolgen, zwar über eine Kette von Matrix-Multiplikationen. Dabei werden aber nicht die Vertices umgerechnet, sondern die jeweiligen Koordinatensysteme. Es ist so, als würde man den Fußboden unter den Füßen verschieben oder drehen, ohne dass man selber verschoben oder gedeht wird. Damit verbunden ist eine Richtungsumkehr der Schritte; es geht nun vom Allgemeinen zum Speziellen, von der globalen Sicht zur lokalen. Wenn ein neuer Umrechnungsschritt hinzukommt, wird die aktuelle Matrix, die alle vorangegangen Umrechnungen enthält, mit der neuen multipliziert und das Ergebnis als aktuelle Matrix gesetzt.
- Ausgangspunkt: Identity-Matrix (macht nichts)
- Projektionsmatrix
- View: Translationsmatrix
- View: Rotationsmatrix X-Achse (camera pitch)
- View: Rotationsmatrix Y-Achse (camera yaw)
- View: Rotationsmatrix Z-Achse (camera roll)
- Model: Translationsmatrix
- Model: Rotationsmatrix X-Achse
- Model: Rotationsmatrix Y-Achse
- Model: Rotationsmatrix Z-Achse
- Model: Skalierungsmatrix
Bei Matrix-Multiplikation gilt nicht das Kommunativgesetz, d.h. die Reihenfolge ist nicht beliebig. Es wird nichts passieren, wenn man die Reihenfolge ändert, aber das Ergebnis könnte überraschend bis frustrierend sein. Wohl aber gilt das Assoziativgesetz, das bedeutet, dass nicht immer die komplette Matrixkette abgearbeitet werden muss. Man kann z.B. erst eine Projektionsmatrix, die View-Matrix und Modelmatrix jeweils für sich erstellen und diese 3 Matrizen dann miteinander multiplieren oder einzeln an den Shader übergeben, wo dann die Multiplikation stattfindet, am Schluss dann noch mit dem lokalen Vertex-Vektor. Gelegentlich findet man den Vorschlag, die Model- und Viewmatrizen zu einer Model-View-Matrix zu verbinden. Wie man vorgeht, hängt von der Organsisation des Programms ab. Ich selber übergebe zunächst noch die drei Matrizen getrennt an den Shader, bis ich eine grundlegende Arbeitsweise gefunden habe. Im übrigen müssen nicht alle Schritte durchgeführt werden. Eine Kamera zum Beispiel, bei der der Horizont immer waagerecht verlaufen soll, braucht nie um die Z-Achse gerollt zu werden.
Die weitgehend festlegte Abfolge von Schritten legt nahe, einen Matrix-Stack anzulegen. Bei der "alten" OpenGL-Programmierung würde ständig mit so einem Stack gearbeitet. Wer kennt nicht die Anweisung glLoadIdentity (), mit der der Matrizenstapel zurückgesetzt wurde. So ein Stack ist kinderleicht zu programmieren, ich werde ihn in einem anderen Beitrag vorstellen.