In Teil RS2 haben wir Objekte als Drahtgitter-Modelle fix im Raum dargestellt. In diesem Teil sollen diese Objekte mithilfe der Matrizen aus Teil 2 animiert werden (s. Abb. 1).
Transformationen
Weil es einfacher ist, erstellen wir 3D-Modelle von Objekten üblicherweise an einer Standardposition in einer Standardorientierung. Da wissen wir schnell, welcher Teil wo ist und können das Modell einfacher »anmalen« (mit einer Textur bedecken – wird noch kommen). Mit Hilfe von Transformationen bewegen wir dieses Modell dann an eine bestimmte Stelle in der Szene (die »Welt«).
Wie wir in Teil 2 gesehen haben, werden diese Transformationen mit Hilfe von Matrizen durchgeführt. Als erstes brauchen wir daher eine Klasse Transform3D, die so eine (affine) Transformationsmatrix speichert.
class Transform3D {
// affine Transformationsmatrix;
// wir starten mit der Einheitsmatrix
private float[][] M = {{1.0, 0.0, 0.0, 0.0},
{0.0, 1.0, 0.0, 0.0},
{0.0, 0.0, 1.0, 0.0},
{0.0, 0.0, 0.0, 1.0}};
// mit dem Standardkonstruktor bleibt es bei
// der Einheitsmatrix
Transform3D() {}
// ersetzt die gespeicherte Matrix m durch B*m
private void mul(final float[][] B) {
// weil wir die Elemente von m für die Multiplikation
// benötigen, müssen wir das Produkt zuerst in c
// speichern
float[][] C = new float[4][4]; // Java initialisiert Arrays mit 0
for (int i = 0; i <= 3; ++i) {
for (int j = 0; j <= 3; ++j) {
for (int k = 0; k <= 3; ++k) {
C[i][j] += B[i][k]*M[k][j];
}
}
}
// jetzt können wir das Produkt nach M kopieren
M = C;
}
// multipliziert die aktuelle Transformation mit
// einer Skalierung in x-, y- und z-Richtung
void scale(final float Sx, final float Sy, final float Sz) {
final float[][] B = {{ Sx, 0.0, 0.0, 0.0},
{0.0, Sy, 0.0, 0.0},
{0.0, 0.0, Sz, 0.0},
{0.0, 0.0, 0.0, 1.0}};
mul(B);
}
// multipliziert die aktuelle Transformation mit
// einer Translation in x-, y- und z-Richtung
void translate(final float dX, final float dY, final float dZ) {
final float[][] B = {{1.0, 0.0, 0.0, dX},
{0.0, 1.0, 0.0, dY},
{0.0, 0.0, 1.0, dZ},
{0.0, 0.0, 0.0, 1.0}};
mul(B);
}
// multipliziert die aktuelle Transformation mit
// einer Rotation um die x-Achse
void rotateX(final float angle) {
final float[][] B = {{1.0, 0.0, 0.0, 0.0},
{0.0, cos(angle), -sin(angle), 0.0},
{0.0, sin(angle), cos(angle), 0.0},
{0.0, 0.0, 0.0, 1.0}};
mul(B);
}
// multipliziert die aktuelle Transformation mit
// einer Rotation um die y-Achse
void rotateY(final float angle) {
final float[][] B = {{ cos(angle), 0.0, sin(angle), 0.0},
{ 0.0, 1.0, 0.0, 0.0},
{-sin(angle), 0.0, cos(angle), 0.0},
{ 0.0, 0.0, 0.0, 1.0}};
mul(B);
}
// multipliziert die aktuelle Transformation mit
// einer Rotation um die z-Achse
void rotateZ(final float angle) {
final float[][] B = {{cos(angle), -sin(angle), 0.0, 0.0},
{sin(angle), cos(angle), 0.0, 0.0},
{ 0.0, 0.0, 1.0, 0.0},
{ 0.0, 0.0, 0.0, 1.0}};
mul(B);
}
// wendet die aktuelle Transformation auf
// einen Punkt an;
// weil die w-Koordinate der Punkte vorerst immer
// 1 ist, können wir sie ignorieren
Point3D apply(final Point3D p) {
return new Point3D(M[0][0]*p.x + M[0][1]*p.y + M[0][2]*p.z + M[0][3],
M[1][0]*p.x + M[1][1]*p.y + M[1][2]*p.z + M[1][3],
M[2][0]*p.x + M[2][1]*p.y + M[2][2]*p.z + M[2][3]);
}
}
Jede neue Transformation startet zunächst als Einheitsmatrix, die gar nichts tut. Der Aufruf der Funktionen scale
, translate
, rotateX
, rotateY
und rotateZ
sorgt dafür, dass die entsprechende Transformation zusätzlich nach allen bisher gespeicherten ausgeführt wird (s. Teil 2). Das passiert durch Aufruf der privaten Funktion void mul(final float[][] B)
, die die aktuell gespeicherte Matrix von links mit der entsprechenden Matrix
multipliziert. Wer möchte, könnte auch noch Spiegelungen und Rotationen um beliebige Achsen implementieren.
Die Matrizenmultiplikation besteht aus 3 Schleifen, weil im Detail
bedeutet. Wir müssen also über alle Zeilen und Spalten des Ergebnisses gehen und jeweils mehrere Produkte addieren. Da Java im Gegensatz zur Mathematik bei 0 zu zählen beginnt, laufen die Schleifen jeweils von 0 bis 3.
Die Funktion Point3D apply(final Point3D p)
wendet die momentane Transformation auf den Punkt p an. Weil wir dessen w-Komponente noch nicht speichern (sie ist momentan immer 1), sparen wir uns die Multiplikation mit der 4. Zeile der Matrix , und die 4. Spalte wird einfach addiert. (Nochmal, Java beginnt bei 0 zu zählen!)
Drahtgittermodelle
Eine Szene kann aus mehreren Drahtgittermodellen bestehen, die jeweils unterschiedlich transformiert werden. Daher müssen wir die WireFrame-Klasse erweitern und neben den Punkten und Linien zusätzlich eine Transformation speichern. Weil sie das Modell im Raum transformiert, nennt man sie die Modelltransformation bzw. model transformation oder model matrix.
class WireFrame {
// Listen der zu zeichnenden Linien und ihrer
// Endpunkte (Vertices)
final ArrayList<Line> lines;
final ArrayList<Point3D> vertices;
// wie dieser WireFrame zu transformieren ist
Transform3D modelTransform;
// der Konstruktor legt leere Listen an
WireFrame() {
lines = new ArrayList<Line>();
vertices = new ArrayList<Point3D>();
modelTransform = new Transform3D();
}
// fügt dem Modell einen Endpunkt (Vertex) hinzu
void addVertex(final Point3D p) {
vertices.add(p);
}
// fügt dem Modell eine Linie hinzu
// es werden nur die Nummern der Punkte (Vertices)
// gespeichert
void addLine(final int v0, final int v1) {
lines.add(new Line(v0, v1));
}
// fügt dem Modell eine Linie mit Farbe pigment hinzu
void addLine(final int v0, final int v1, color pigment) {
lines.add(new Line(v0, v1, pigment));
}
// setzt die Transformation dieses Modells
// auf die Einheitsmatrix zurück
void resetTrafo() {
modelTransform = new Transform3D();
}
// die folgenden Methoden sind nur ein
// einfacheres Interface zur Modellmatrix
// skaliert das Modell in alle Richtungen gleich
void scale(final float S) {
modelTransform.scale(S, S, S);
}
// skaliert das Modell in in x-, y- und z-Richtung
// unterschiedlich
void scale(final float Sx, final float Sy, final float Sz) {
modelTransform.scale(Sx, Sy, Sz);
}
// verschiebt das Modell in x-, y- und z-Richtung
void translate(final float dX, final float dY, final float dZ) {
modelTransform.translate(dX, dY, dZ);
}
// rotiert das Modell den Winkel angle um die x-Achse
void rotateX(final float angle) {
modelTransform.rotateX(angle);
}
// rotiert das Modell den Winkel angle um die y-Achse
void rotateY(final float angle) {
modelTransform.rotateY(angle);
}
// rotiert das Modell den Winkel angle um die z-Achse
void rotateZ(final float angle) {
modelTransform.rotateZ(angle);
}
// wendet die Gesamttransformation
// auf einen Punkt an
Point3D applyModelTransform(Point3D vertex) {
return modelTransform.apply(vertex);
}
}
Es ist etwas leichter, statt model.modelTransform.scale
einfach model.scale
usw. schreiben zu können. Daher habe ich die Funktionen scale
, translate
, rotateX
, rotateY
, rotateZ
und applyModelTransform
hinzugefügt, die einfach die entsprechenden Funktionen der modelTransform
aufrufen. Die Funktion resetTrafo()
setzt die Transformation des Modells auf die Einheitsmatrix zurück.
Zusätzlich habe ich die Standardfarbe der Linien auf Bernstein gesetzt, und wir können jetzt auch Linien mit einem anderen Farbpigment
hinzufügen. In der Animation in Abb. 1 habe ich das verwendet, um die lokalen x-, y– und z-Achsen der Modelle rot, grün bzw. blau zu zeichnen.
Die Standardmodelle
Weil wir Objekte jetzt mittels Transformationen beliebig drehen, skalieren und verschieben können, müssen wir in Würfel und Kugel keine Position und Größe mehr speichern. Beide haben den Ursprung als Standardmittelpunkt und Seitenlänge bzw. Radius 1. Schauen wir uns das anhand des Würfels an.
class Cube extends WireFrame {
// der Konstruktor legt einen Würfel zentriert um den Urpsrung
// und mit Seitenlänge 1 an; die Würfelkanten sind parallel zu
// den Koordinatenachsen
Cube() {
super();
// Punkte hinzufügen
addVertex(new Point3D(-0.5, -0.5, 0.5)); // 0
addVertex(new Point3D( 0.5, -0.5, 0.5)); // 1
addVertex(new Point3D( 0.5, 0.5, 0.5)); // 2
addVertex(new Point3D(-0.5, 0.5, 0.5)); // 3
addVertex(new Point3D(-0.5, -0.5, -0.5)); // 4
addVertex(new Point3D( 0.5, -0.5, -0.5)); // 5
addVertex(new Point3D( 0.5, 0.5, -0.5)); // 6
addVertex(new Point3D(-0.5, 0.5, -0.5)); // 7
// Punkte für das lokale Koordinatensystem
addVertex(new Point3D(0.0, 0.0, 0.0)); // 8
addVertex(new Point3D(1.0, 0.0, 0.0)); // 9
addVertex(new Point3D(0.0, 1.0, 0.0)); // 10
addVertex(new Point3D(0.0, 0.0, 1.0)); // 11
// Linien hinzufügen
// Vorderseite
addLine(0, 1);
addLine(1, 2);
addLine(2, 3);
addLine(3, 0);
// Rückseite
addLine(4, 5);
addLine(5, 6);
addLine(6, 7);
addLine(7, 4);
// Linien der Seitenflächen
addLine(0, 4);
addLine(1, 5);
addLine(2, 6);
addLine(3, 7);
// lokales Koordinatensystem
addLine(8, 9, color(255, 0, 0)); // rot = x
addLine(8, 10, color(100, 255, 0)); // grün = y
addLine(8, 11, color(0, 0, 255)); // blau = z
}
}
Zusätzlich zu den Punkten des Würfels habe ich den Ursprung und Punkte entlang der x-, y– und z-Achse hinzugefügt, damit wir die lokalen Koordinatenachsen farbig zeichnen können.
Die Kugel wurde ähnlich angepasst.
Rasterung
Der Rasterizer entspricht im Großen und Ganzen dem aus Teil RS2. Der wesentliche Unterschied ist, dass wir vor dem Einfügen der Punkte die model transformation des WireFrames anwenden.
// füge die Linien aller Wireframe-Modelle in der Szene
// dem Rasterizer hinzu
void stageLines() {
// in jedem Frame der Animation können
// unterschiedliche Punkte und Linien sichtbar sein;
// daher müssen wir die Listen zu Beginn immer löschen
vertices.clear();
lines.clear();
// wir bearbeiten alle Wireframe-Modelle in der Szene
for (WireFrame wf : scene.wireFrames) {
// in den Wireframe-Modellen starten die Punktnummern
// immer bei 0; hier werden sie jedoch hintereinander
// eingefügt; wir müssen die bisherige Anzahl an
// Punkten also speichern
int numOfVertices = vertices.size();
// die maximale z-Koordinate eines WireFrames
float maxZ = Float.NEGATIVE_INFINITY;
// jetzt wird die Model-Transformation auf alle Punkte
// angewandt und das Ergebnis eingefügt
for (Point3D v : wf.vertices) {
Point3D p = wf.applyModelTransform(v);
vertices.add(p);
// falls die z-Koordinate größer als die bisher maximale
// ist, speichern wir sie
maxZ = max(maxZ, p.z);
}
// zum Schluss werden die Linien eingefügt und die Punktnummern angepasst
// wenn die maximale z-Koordinate des Wireframe-Modells vor der Kamera liegt,
// können wir die Linien einfach so einfügen
if (maxZ <= -1.0) {
for (Line l : wf.lines) {
lines.add(new Line(l.v0 + numOfVertices, l.v1 + numOfVertices, l.pigment));
}
}
// wenn eine z-Koordinate hinter dem Schirm liegt, müssen wir
// die Linie ev. abschneiden
else {
for (Line l : wf.lines) {
clipLine(new Line(l.v0 + numOfVertices, l.v1 + numOfVertices, l.pigment));
}
}
}
}
Typischerweise ändern sich die Koordinaten im WireFrame-Objekt nicht, sondern werden jedesmal vor dem Zeichnen neu transformiert. Wenn sich die Transformation nicht ändert, könnte man die transformierten Punkte natürlich zwischenspeichern.
In der Funktion paintScene
wird zusätzlich noch die Linienfarbe angepasst.
// male die Szene
void paintScene() {
// male alle zu rasternden Linien
for (Line l : lines) {
// lade die Endpunkte der aktuellen Linie
final Point3D v0 = vertices.get(l.v0);
final Point3D v1 = vertices.get(l.v1);
// und berechne ihre screen coordinates
final int x0 = (int)((1.0 - v0.x/v0.z)*width/2.0);
final int y0 = (int)((1.0 + v0.y/v0.z)*height/2.0);
final int x1 = (int)((1.0 - v1.x/v1.z)*width/2.0);
final int y1 = (int)((1.0 + v1.y/v1.z)*height/2.0);
// der line-Befehl von Processing kümmert sich
// um das Clipping links/rechts und oben/unten;
// jede Linie wird mit der Farbe ihres Pigments gemalt
stroke(l.pigment);
line(x0, y0, x1, y1);
}
}
Das Hauptprogramm
Die Animation in Abb. 1 besteht aus 3 Würfeln (Cube c1
, c2
und c3
) und einer Kugel (Sphere s1
). Die Funktion setup()
legt diese Objekte an und fügt sie der Szene bzw. dem Rasterizer hinzu.
Scene theScene;
Rasterizer theRasterizer;
Cube c1;
Cube c2;
Cube c3;
Sphere s1;
void setup() {
// öffne ein Zeichenfenster mit 640x640 Pixeln
size(640, 640);
theScene = new Scene();
// alle Modelle werden angelegt und der Szene hinzugefügt
c1 = new Cube();
theScene.addWireFrame(c1);
c2 = new Cube();
theScene.addWireFrame(c2);
c3 = new Cube();
theScene.addWireFrame(c3);
s1 = new Sphere();
theScene.addWireFrame(s1);
theRasterizer = new Rasterizer(theScene);
}
Die Animation hat eine Periodendauer . Die Winkelgeschwindigkeit ist
, wobei
ist.
Die Animation erfolgt in der Funktion draw()
.
float T = 5.0; // Periodendauer in s
float omega = TAU/T; // Winkelgeschwindigkeit in s^{-1}
float alpha;
void draw() {
// wir füllen das Fenster schwarz
background(0);
int t = millis(); // Zeit seit Beginn der Animation in ms
alpha = omega*t*1.0e-3; // *1.0e-3 Umrechnung ms -> s
// die Objekte werden in jedem Frame neu transformiert
c1.resetTrafo();
c1.scale(0.25, 0.375, 0.5);
c1.rotateX(alpha);
c1.rotateY(2.0*alpha);
c1.translate(0.75*sin(alpha), -0.75, -1.5);
c2.resetTrafo();
c2.scale(0.25);
c2.rotateX(2.0*alpha);
c2.translate(-0.75, 0.75, -1.5 - sin(alpha));
c3.resetTrafo();
c3.scale(0.25);
c3.rotateZ(2.0*alpha);
c3.translate(0.75, 0.75, -1.5);
s1.resetTrafo();
s1.scale(0.35);
s1.rotateY(alpha);
s1.translate(0.0, 0.25, -1.5);
// der Rasterizer erhält zuerst die zu malenden
// Linien der Szene und malt sie dann
theRasterizer.stageLines();
theRasterizer.paintScene();
}
Die Variable t speichert die Zeit seit Beginn der Animation in Millisekunden. Damit kann aus der Winkelgeschwindigkeit der »Winkel« berechnet werden.
Der Würfel c1
ist der ganz unten. Er wird in x-, y– und z-Richtung unterschiedlich skaliert und erscheint daher als Quader. Anschließend wird er den Winkel um die x-Achse und den Winkel
um die y-Achse rotiert. Die Rotation um die y-Achse ist also doppelt so schnell, wodurch er in der Animation etwas »eiert«. Zum Schluss wird er noch verschoben, wobei die y– und z-Koordinaten konstant sind, aber die x-Koordinate durch die Sinus-Funktion zwischen +0.75 und -0.75 schwankt. Er bewegt sich daher periodisch waagrecht hin und her.
Der Würfel c2
ist der links oben. Er wird entlang aller Achsen gleich skaliert und bleibt daher ein Würfel. Danach wird er mit doppelter Geschwindigkeit um die x-Achse rotiert. Zum Schluss wird er so verschoben, dass seine z-Koordinate zwischen -2.5 und -0.5 schwankt. Dadurch bewegt er sich periodisch vor und zurück. Weil sich die Bildschirmebene bei befindet ist er zeitweise (zum Teil) abgeschnitten. Er kollidiert also mit der Kamera.
Der Würfel c3
ist der rechts oben. Er wird ebenfalls als Würfel skaliert (gleich groß wie c2
). Danach wird er mit doppelter Geschwindigkeit um die z-Achse rotiert. Die Verschiebung zum Schluss belässt ihn an einer fixen Position.
Die Kugel s1
wird ebenfalls in alle Richtungen gleich skaliert (andernfalls hätten wir ein Ellipsoid). Sie wird mit einfacher Geschwindigkeit um die y-Achse rotiert und konstant etwas nach oben und hinten verschoben.
Wir müssen diese Transformationen für jeden Frame neu durchführen. Außerdem müssen wir für jedes Objekt die Transformation zunächst auf die Einheitsmatrix zurücksetzen. Andernfalls würden sich z.B. Skalierungen mit jedem Frame multiplizieren. Bei einem Faktor kleiner als 1 wären die Objekte schnell zu klein, um sie zu sehen.
Nachdem alle Objekte wie gewünscht in der Szene platziert sind, werden sie dem Rasterizer übergeben und gemalt.
Der gesamte Processing-Code findet sich hier.
Diskussion
In der Animation in Abb. 1 haben wir die lokalen Achsen der Objekte mit transformiert, um zusehen, wie sie sich zeitlich ändern. Aus der Sicht der einzelnen Objekte hat sich jedoch überhaupt nichts geändert. Ihre x-Achsen z.B., zeigen immer aus derselben Fläche heraus.
Von der Seite sieht unsere Animation übrigens aus wie in Abb. 2. Die weiße Pyramide ist unsere Standardkamera. Ihre Spitze ist das Loch unserer inversen Lochkamera und ist im Nullpunkt der Welt. Die quadratische Grundfläche ist unser Bildschirm. Und ja, die Kamera ist im Vergleich zu den Objekten so groß und auch so nahe dran.
Wenn wir mehrere Transformationen hintereinander durchführen, müssen wir mehrere Matrizenmultiplikationen pro Frame durchführen. Am Ende gibt es aber pro Objekt nur eine model matrix, mit der alle Punkte des Objekts transformiert werden. Das könnte man auf jeden Fall parallelisieren, weil ein Punkt normalerweise nicht von einem anderen abhängt.
Mithilfe der Sinus-Funktion haben wir periodische Bewegungen erzeugt. Mit komplizierteren Funktionen kann man beliebig komplizierte Bewegungen eines Objekts simulieren. Trotzdem ist die Translation eine (affin) lineare Operation, weil wir in jedem Frame ein Objekt nur an eine bestimmte Position verschieben.
Wie schon mehrfach betont, sind Matrizen nicht unbedingt notwendig, vereinfachen die Sache aber ungemein. Leider ist die Matrizenrechnung aus den Gymnasien praktisch verschwunden, und auch in den HTLs wird sie eher stiefmütterlich behandelt. In den HAKs gibt es sie noch, allerdings nur in wirtschaftlichen Zusammenhängen. (Man kann alles mutwillig langweilig machen …)
Im nächsten Teil werden wir uns überlegen, wie wir die Kamera beliebig positionieren können (wie in Abb. 2). Dazu werden wir im Wesentlichen nur eine weitere Matrixmultiplikation benötigen.
Sehr interessanter und gut verfasster Beitrag!