HomeMapIndexSearchNewsArchivesLinksAbout LF
[Top Bar]
[Bottom Bar]

von:Miguel Ángel Sepúlveda

Inhalt:
Einführung
3D Modelle

3D Szenenbeschreibung

Zusammenfassung: Dieser Artikel ist die Fortführung einer Serie von Artikeln die im Januar 1998 mit "Zeichnen einfacher Vielecke" und "Mehr über Linien" begonnen hat.


Worst seen with Explorer. Try Netscape instead. 

Einführung

Nach einem langen Ausflug in andere Themengebiete haben wir endlich den Punkt erreicht, an dem wir 3D-Grafik unter OpenGL diskutieren können. Ich will nicht lügen und euch gegenüber behaupten, das sei einfach - es ist nämlich nicht so. Jeder gute Programmierer, der mit OpenGL 3D Anwendungen und ganz besonders Animationen schreibt, sollte einiges Wissen über Lineare  Algebra, Analytische Geometrie, Physik (Mechanik) und natürlich Numerik mitbringen.

Ich werde versuchen, den Rest dieser Serie für jeden so zugänglich wie möglich zu machen. Unglücklicherweise führt kein Weg daran vorbei zu wissen, was Matrizen und Vektoren sind, wie man Ebenen und Oberflächen mathematisch im dreidimensionalen Raum beschreibt oder Kurven durch Polygonzüge annähert - um nur einige Punkte zu nennen.

Während der letzten Wochen habe ich mir überlegt, wie man dieses komplizierte Material einer breiten Öffentlichkeit präsentieren könnte. Die meisten Bücher gehen hier Schritt für Schritt vor, mehr oder weniger analog wie ich es in den beiden vorangehenden Artikeln versucht habe. Ich habe beschlossen, diesen Weg nicht weiterzugehen, denn es würde zu lange dauern (Monate!) die Leser(innen) an einen Punkt zu bringen, ab welchem sie selbst in der Lage sind eigene Programme zu schreiben. Also habe ich mich zu einer anderen Vorgehensweise entschlossen. Man könnte das durchaus als "Roßkur" bezeichnen. Diesesmal werde ich dem Artikel ein Demo meiner 3D Simulation beifügen und dann versuchen, Stück für Stück die Funktionsweise dieses Programms zu erläutern. Bei Gelegenheit werden wir tiefergehend all jene Themen genauer unter die Lupe nehmen, die für gewöhnlich in der Standardliteratur zu OpenGL behandelt werden. Aber ich glaube, daß ein kurzer Sprung ans Ende, wo der geneigte Leser ein Beispielprogramm findet, das ein paar interessante Dinge vorführt, den Leser durchaus zu eigenen Experimenten anstacheln wird - selbst wenn ich diesesmal nicht ganz genau erklären werde, wie es im Detail funktioniert. Ich hoffe, daß dieser Ansatz richtig ist vom Leser als geradlinig und direkt betrachtet werden wird.

3D Modelle

Also zurück zu den Anfängen. Ich habe in den letzten 6 Monaten an der Universität Pittsburgh an einem objektorientierten Toolkit gearbeitet, das die Entwicklung von Simulationen für Gele und Polymere vereinfachen soll. Das Projekt ist bereits recht weit fortgeschritten und die Physik daran ist sehr interessant sogar für jemanden aus der Informatik, weil Gele vom Prinzip her Neuronale Netze aus Polymeren darstellen und viele der Techniken, die für neuronale Netze entwickelt wurden auch auf die Konstruktion eines Gels angewandt werden können. Ich habe aus diesem Toolkit einige abgespeckte Objekte herausgenommen und in das einfache Demoprogramm ../../common/May1998/example2.tar.gz verpackt. Es läßt sich unter Linux, jedem anderen UNIX oder auch Windows 95/NT kompilieren - vorausgesetzt, die GLUT-Bibliothek wurde installiert. Das Demo zeigt ein einfaches Polymer (eine lineare Kette miteinander verbundener Monomere), das in einer Suspension gelöst ist und sich dort bei vorgegebener Temperatur bewegt. Die Dynamik ist bestechend: Es sieht aus wie eine Schlange unter Drogeneinfluß! Die Kollisionen mit den Molekülen des Lösungsmittels verleiht dem Polymer etwas Lebendiges. Das Lösungsmittel selbst sieht man nicht, aber es wird in den Bewegungsgleichungen für das Polymer berücksichtigt.

[model of polymer]

Das Modell, welches zum Zeichnen des Polymers benutzt wird ist ziemlich einfach; example2 speichert die x, y, z Koordinaten jedes Kettenglieds (Monomers) auf der Polymer-Kette. Für jedes Bild der Animation zeichnen wir eine Kugel an der Position des Monomers und verbinden die Kugeln der einzelnen Monomere mit Zylinderstücken. Wie bei jedem realen Molekül verändert sich der Abstand zwischen den Monomeren mit der Zeit; wir können also nicht nur eine Art von Zylindern für die Verbindungsstücke verwenden, sondern müssen die Länge des jeweiligen Zylinders entsprechend dem aktuellen Abstand benachbarter Monomere anpassen.

Frage 1: Nehmen wir an, wir haben zwei 3D-Objekte, eine Kugel und einen vertikalen Zylinder mit Höhe 1, die beide um den Koordinatenursprung zentriert sind. Wie müssen die Kopien unseres Zylinders gestreckt, rotiert und verschoben werden, um die entsprechenden chemischen Bindungen im Polymer darzustellen, wenn wir nur die Liste der x, y, z Koordinaten der einzelnen Monomere kennen?

Aus irgendeinem mir unverständlichen Grund verwenden die Informatiker hier nicht das gebrächliche kartesische Koordinatensystem. Die x-Achse ist horizontal, die y-Achse vertikal und die z-Achse auf den Betrachter zu gerichtet. Dies sollte man beachten, da Leute mit mathematischem oder naturwissenschaftlichem Hintergrund hierdurch am Anfang leicht verwirrt werden.

Eine kleine Textinformation im oberen Teil des Fensters mit der Animation gibt ständig die Zeit, die aktuelle und durchschnittliche Temperatur des Polymers, die Temperatur des Lösungsmittels, seinen Reibungskoeffizient und den Winkel, um den die Außenkamera gedreht wurde, an. Um einem Blick von allen Seiten auf das Polymer zu ermöglichen, rotiert die Kamera (unsere Blickrichtung) langsam um den Schwerpunkt des Polymers.

Tatsächlich ist die Kettenlänge des Polymers in diesem Demo so kurz, daß es nicht unbedingt nötig ist, die Kamera rotieren zu lassen; irgendwann dreht sich das Polymer ganz von selbst. Der Leser kann hier leicht experimentieren, indem man in der Datei example2.cxx die Definition POLYMERLENGTH auf irgendeinen Wert zwischen 2 und 100 setzt. Die Kamera rotiert, weil ich den Leser auf ein offensichtliches Problem hinweisen will: Den Wechsel des Koordinatensystems. Die Koordinaten der Bausteine werden von den Bewegungsgleichungen verwendet und sind deswegen im "Welt-Koordinatensystem" angegeben, unabhängig von jeweiligen Standort des Beobachters. Diese Koordinaten müssen auf die 2D x-y Koordinaten ihres Computer-Bildschirms abgebildet werden. Jede Veränderung des Standorts des Betrachters verändert also die Formeln zur Umrechnung der internen Koordinaten in das 2D Koordinatensystem des Bildschirms.

Frage 2: Wie würden sie dieses Problem lösen? Die Bewegungsgleichungen vom Weltkoordinatensystem ins 2D Bildschirmkoordinatensystem zu übertragen ist nicht sinnvoll; es würde zu viel Algebra erfordern und wäre ebenso kompliziert wie fehleranfällig.

Die Antwort auf Frage 2 ist einfach. Es bleibt uns nur übrig, die Dynamikberechnung und die Repräsentation des 3D-Modells in Weltkoordinaten durchzuführen und danach zum Zeichnen des Bildes auf das 2D Bildschirmkoordinatensystem zu wechseln. OpenGL ist bei solchen Transformationen ziemlich effizient, sogar bis hinunter auf Hardwareebene (Wer eine schnelle OpenGL-Karte hat erkennt leicht den Unterschied). Aber bevor ich beschreibe, wie OpenGL an dieses Problem herangeht, betrachten wir zunächst, wieviele Koordinatentransformationen notwendig sind für den Wechsel vom 3D Welt- in das 2D Bildschirmsystem.

Als erstes kommt die Modelview Koordinatentransformation. Diese bildet die ursprünglichen Welt-Koordinaten auf die Augen-Koordinaten, das System relativ zur Position des Auges des Betrachters (oder der Kamera), ab. Diese Transformation wird als Modelview-Transformation bezeichnet, weil sie in Wirklichkeit mehrere ähnliche und doch leicht unterschiedliche Operationen umfaßt: Die Objekt- (Modeling) und die Blickrichtungs- (Viewing) Projektionen. Letztere kann man mit dem Aufstellen eines Photoapparats im Studio vergleichen, der in Richtung auf die aufzunehmende Szene zeigt. Die Modeling-Projektion entspricht in diesem Bild dem Positionieren der zu photographierenden Objekte vor der Kamera.

Als nächstes in der Reihe der Transformationen werden die Augen-Koordinaten der Projektions Koordinatentransformation übergeben. Der Sinn dieser Transformation klingt im Moment etwas esotherisch. Nachdem die Kamera positioniert, die Blickrichtung festgelegt und die einzelnen Objekte ins Blickfeld gesetzt worden sind, will OpenGL wissen, wie groß der Bereich des Blickfeldes ist, der auf das Fenster des Computerbildschirms abgebildet werden soll. Wenn zum Beispiel die Kamera auf einen weit entfernten Berg gerichtet ist, umfaßt das Blickfeld einen großen Raumbereich. Da Computer nur mit endlich großen Dingen umgehen können müssen wir festlegen, wieviel des gesamten Blickfeldes abgeschnitten (clipped) werden soll. Während dieser Transformation werden auch Flächen entfernt, die nicht im Blickfeld liegen. Letztlich erhält man die Clip Koordinaten, wobei man immer berücksichtigen muß, daß es nicht ausreicht, wenn ihre 3D-Objekte nur vor der Kamera liegen; sie müssen auch innerhalb der Schnittebenen (clipping planes) liegen, die die Projektionstransformation festlegen. Die verschiedenen Arten der 3D-Perspektive (z.B. Parallelprojektion oder Fluchtpunkte) werden auf dieser Ebene definiert.

Für den Moment werden wir nicht soweit ins Detail gehen und erklären, was mit Perspektivischer Zerlegung (perspective division) gemeint ist und was normalisierte Gerätekoordinaten sind. Es ist noch nicht notwendig so tief einzusteigen.

Die letzte wichtige Koordinatentransformation ist die Viewport-Transformation. Sie ist zuständig für die Abbildung unserer 3D Koordinaten, die jetzt schon alle Arten 3D-Transformationen hinter sich haben, auf die zweidimensionale Fläche des Bildschirmfensters.

Koordinatentransformationen werden mathematisch durch Matrizen (zweidimensionale Zahlenfelder) dargestellt. Zu jeder der oben erwähnten Transformationen gibt es eine zugehörige Matrix. Diese Matrizen können an jedem Punkt im Programm angegeben werden, bevor das Bild gezeichnet wird. OpenGL verwaltet einen Stack von Transformationsmatrizen, die auf jeden Koordinatenpunkt der Szene angewandt werden sollen. Dies ist eine sehr effiziente und leistungsfähige Technik. Wir werden uns in zukünftigen Artikeln näher damit beschäftigen. Für heute sehen wir uns den Sourcecode an und klären, wie die Transformationen definiert werden. Auch in der Datei example2.cxx finden wir die schon bekannten reshape Funktionen:


void mainReshape(int w, int h){

// VIEWPORT TRANSFORMATION
glViewport(0, 0, w, h);

// PROJECTION TRANSFORMATION
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
glFrustum(wnLEFT, wnRIGHT, wnBOT, wnTOP, wnNEAR, wnFAR);

// MODELVIEW TRANSFORMATION
glMatrixMode(GL_MODELVIEW);

....

Der Befehl glViewport(x, y, width, height) legt die Viewport-Transformation fest: x, y sind die Koordinaten der unteren linken Ecke des rechteckigen Fensterausschnitts und width, height die Dimensionen des Viewports. Alle Zahlen sind in Pixeln anzugeben.

Mit der Funktion glMatrixMode() wählt man die aktuelle Matrix, in unserem Fall wählen wir mit GL_PROJECTION die Projektionsmatrix. Bevor man jetzt irgendwelche Transformationen festlegt, wird empfohlen, eine Einheitsmatrix zu laden (die an den Koordinaten gar nichts ändert). Das geschieht mit der Funktion glLoadIdentity(), welche die aktuelle Matrix auf die Einheitsmatrix zurücksetzt. Anschließend wird die 3D-Perspektive festgelegt; der Befehl glFrustrum(left, right, bottom, top, near, far) legt die Schnittebenen links, rechts, unten, oben, vorne und hinten fest. Diese werden in Augen-Koordinaten angegeben und ihre Größe legt die Form des im Blickfenster (auf dem Bildschirm) sichtbaren Raumvolumens und damit die Perspektive fest. Das klingt vielleicht kompliziert, ich habe auch eine Weile gebraucht mich daran zu gewöhnen. Um ein Gefühl für die Bedeutung dieser Zahlen zu bekommen experimentiert man am besten einfach etwas mit verschiedenen Werten. Man sollte dabei immer daran denken, daß, egal welche Zahlen man nun wählt, die Koordinaten des Objekts (nach der ModelView-Transformation) innerhalb der Schnittebenen liegen müssen - ansonsten wird auf dem Bildschirm nichts dargestellt. Es gibt noch andere Möglichkeiten, die Projektionstransformation festzulegen. Später gehen wir näher darauf ein.

Am Ende der Funktion verändern wir den aktuellen Wert der Modelview-Matrix, indem wir glMatrixMode() den Parameter GL_MODELVIEW übergeben. Die Funktion mainReshape() fährt mit anderen, hier unwichtigen Dingen, fort und endet dann. Für uns ist nur wichtig, wie Festlegung der Größe des Hauptfensters die Matrizen für Viewport- und Projektionstransformation und letztlich die Modelview-Matrix angegeben wurden.

Als nächstes beendet die Funktion mainDisplay() die Spezifizierung der Modelview-Transformation und zeichnet schließlich das Polymer mit scene():

void mainDisplay(){
glutSetWindow(winIdMain);


glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// Clear the color and depth buffers
// This is like cleaning up the blackboard

// CONTINUE MODELVIEW TRANSFORMATION: Set and orient the camera
glLoadIdentity();                // Load unity on current matrix
glTranslatef(0.0, 0.0, -4.0);    // Move the camera 4 steps back
// we leave the camera pointing in the -z direction. Actually
// this operates by moving the following scene 4 steps in the -z
// direction. Only the elements of the scene that fall inside the
// viewing volume (see projection transformation later) will appear   
//on screen.

// Render polymer
glScalef(0.5, 0.5, 0.5);
glRotatef(Angle, 0, 1, 0);
scene();

glutSwapBuffers();
};

Ich hoffe, die Leser nicht durch die Verwendung zweier Unterfenster verwirrt zu haben. Ich will an dieser Stelle nicht auf Unterfenster eingehen, weil diese bereits eingehend in einem vorangehenden Artikel (Fensterverwaltung (März 1998)) abgehandelt wurden. Im Zweifel sehen sie bitte dort nach und frischen sie ihre Erinnerung auf.

Diese Funktion ist recht geradlinig und einfach aufgebaut. Zunächst wird mit glClear() der Grafikspeicher (color buffer) und der Z-Buffer (auch depth buffer) gelöscht. Der Z-Buffer ist bei 3D-Grafik wichtig, weil hier beim Zeichnen die z-Koordinate jedes Punktes getestet werden muß, um festzustellen ob der Punkt zu einer verdeckten Fläche gehört und folglich nicht gezeichnet werden darf; andernfalls wird der betreffende Punkt gezeichnet und seine z-Koordinate als aktueller Wert an der betreffenden x/y-Position in den Z-Buffer übertragen. Schließlich wird die ModelView-Matrix auf die Einheitsmatrix gesetzt und dann drei Modeling-Transformationen aufgerufen:

Ein Wort zur Vorsicht: Die Reihenfolge der Modeling-Transformationen ist sehr wichtig. Dazu muß man verstehen, was mit der Modelview-Matrix geschieht, nachdem man eine Koordinatentransformation aufruft. Jede Transformation Ti wird mathematisch durch eine Matrix Mi beschrieben. Eine Reihe aufeinanderfolgender Transformationen Tn Tn-1... T1 (z.B. Verschiebung + Skalierung + Drehung) wird mathematisch dargestellt durch ein Matrixprodukt M = Mn Mn-1 .... M1. Matrixmultiplikation ist nicht kommutativ (d.h. die Reihenfolge der Faktoren kann nicht vertauscht werden), also ist die Reihenfolge absolut wichtig. Wenn die zusammengesetzte Transformation M auf einen Vertex v wirkt, werden tatsächlich die einzelnen Transformationen in umgekehrter Richtung darauf angewandt:

M v = Mn Mn-1 .... M1 v

Zuerst M1, dann M2, usw... und am Ende Mn. In unserem Programmbeispiel habe ich die Transformationen in folgender Reihenfolge aufgerufen: Translation -> Skalierung -> Rotation. Deswegen werden die Welt-Koordinaten jedes Punktes unseres Modells erst rotiert, dann skaliert und letztlich verschoben, bevor der Punkt auf unseren Grafikbildschirm projiziert wird.

Beachten sie immer diese umgekehrte Reihenfolge der Transformationen, wenn sie selbst programmieren. Ansonsten kann man sehr überraschende Ergebnisse erhalten.

Die Funktion scene() macht nichts anderes, als das Polymer-Objekt zu zeichnen. Um zu verstehen, wie das 3D Modell aufgebaut wird, betrachten wir die Datei Gd_opengl.cxx, genauer gesagt die Member-Funktion draw(GdPolymer &p). Die Hauptschleife dort läuft durch alle Monomere der Polymer-Kette, holt sich deren x, y, z Koordinaten, zeichnet eine Kugel an dieser Position und schließlich die Zylinder entlang der Bindungen zwischen den einzelnen Monomeren. Erinnern sie sich an Frage 1? Hier ist eine mögliche Lösung... Falls sie eine schnellere gefunden haben, lassen sie es mich bitte wissen.

Es gibt noch etwas, das der Leser wissen sollte, um die Zeichenroutine für das Polymer vollständig verstehen zu können. Wozu dienen die Funktionen glPushMatrix() und glPopMatrix()?

Es gibt nur zwei geometrische Grundobjekte (primitives) im Polymer-Modell, eine Kugel mit Radius 0.40 im Ursprung und ein aufrechter Zylinder mit Höhe 1 und Radius 0.4 . Das Polymer wird auf diesen beiden Grundobjekten aufgebaut; eine Reihe von Transformationen plazieren Kugeln und Zylinder an die gewünschten Positionen. Jedesmal, wenn eine der Anweisungen glCallList(MONOMER) oder glCallList(CYLINDER) ausgeführt wird, wird eine neue Kugel bzw. ein neuer Zylinder im Koordinatenursprung gezeichnet. Um die Kugeln an die gewünschten x, y, z Koordinaten zu schieben benötigen wir nur eine Translation (siehe glTranslatef(x, y, z)). Den Zylinder als Bindung zu plazieren und zu zeichnen ist komplizierter, weil wir zunächst den Zylinder so lang wie die betreffende Bindung machen und in dann noch in die richtige Orientierung bringen müssen. In meinem Algorithmus verwende ich dafür eine kombinierte Transformation bestehend aus einer Skalierung und einer Drehung.

Aber ganz egal, welche Methode verwendet wird, um das 3D-Modell aufzubauen, es werden ohne Zweifel weitere Translationen, Drehungen und andere Transformationen nötig sein. Wenn die Funktion scene() aufgerufen wird, ist die aktuelle Matrix innerhalb der 'OpenGL state machine' mit der ModelView-Matrix identisch. Diese gibt, wie ich ja bereits erwähnt habe, die Projektion der Welt-Koordinaten des Modells auf die Clipping-Koordinaten an. Das ist ein ernstes Problem, denn wenn wir irgendeine weitere Transformation anwenden, während die ModelView-Matrix noch die aktuelle Matrix ist, würden wir wir die zusätzliche Transformation an die aktuelle anhängen, mit der unerwünschten Folge, daß unsere ModelView-Matrix zerstört wird. Ein ähnlicher Fall liegt vor, wenn wir bestimmte 3D Transformationen nur auf einen bestimmten Teil, nicht aber auf den Rest des Modells anwenden möchten (z.B. sollen unsere Zylinder skaliert werden, unsere Kugeln aber nicht). OpenGL löst diese Probleme durch einen internen Stack für Matrizen. Mit zwei grundlegenden Operationen kann dieser Stack verändert werden: glPushMatrix() legt die aktuelle Matrix auf den Stack und glPopMatrix() holt die aktuelle Matrix vom Stack. Genau das erkennen wir in der Funktion scene(): Vor dem Zeichnen der Kugel rufen wir glPushMatrix() auf, um die ModelView-Matrix auf den Stack zu schieben. Am Ende der Schleife wird dann glPopMatrix() benutzt, um die ModelView-Matrix wieder zu restaurieren. Die interne Schleife, die die Polymerbindungen zeichnet benutzt eigene Push- und Pop-Operationen um die Skalierungen und Drehungen des Zylinders von der Translation zu entkoppeln, die beide Objekte, Kugel und Zylinder, betrifft.

Eines möchte ich zu 3D Transformationen und Matrix-Stacks noch sagen: In diesem Artikel haben wir nur an der Oberfläche beider Themen kratzen können. Dabei wollen wir es vorläufig belassen; der interessierte Leser mag den Sourcecode des Demos benutzen und eigene 3D-Objekte ausprobieren. Das Programm example2 benutzt noch eine Menge anderer Fähigkeiten von OpenGL, die wir noch gar nicht betrachtet haben: Materialeigenschaften und Lichtquellen. Diesen Themen soll ein zukünftiger Artikel gewidmet werden. Beim nächstenmal werden wir tiefergehend in 3D-Transformationen und Matrix-Stacks einsteigen und wir werden zeigen, wie man Möglichkeiten von OpenGL benutzen kann, um einem Roboter das Fliegen beizubringen. Bis dann.

Viel Spaß mit OpenGL!


Webpages maintained by Miguel Ángel Sepúlveda
© Miguel Ángel Sepúlveda 1998
Übersetzt von:Frank Hofmann
LinuxFocus 1998