Malen mit Zahlen, Teil RS3 – Transformierte Objekte

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).

Abb. 1: Animation einiger Drahtgitter-Modelle mit Hilfe von Matrizen. Die roten, grünen und blauen Linien sind die lokalen x-, y- bzw. z-Achsen der einzelnen Modelle.

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 \mathsf{M} von links mit der entsprechenden Matrix \mathsf{B} multipliziert. Wer möchte, könnte auch noch Spiegelungen und Rotationen um beliebige Achsen implementieren.

Die Matrizenmultiplikation besteht aus 3 Schleifen, weil \mathsf{C}=\mathsf{B}\cdot\mathsf{M} im Detail

\displaystyle\begin{aligned}c_{ij}&=b_{i1}\cdot m_{1j}+b_{i2}\cdot m_{2j}+b_{i3}\cdot m_{3j}+b_{i4}\cdot m_{4j}\\&=\sum_{i=1}^4b_{ik}\cdot m_{kj}\end{aligned}

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 \mathsf{M}, 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 T=5\,\text{s}. Die Winkelgeschwindigkeit ist \omega=\tau/T, wobei \tau=2\pi 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« \alpha=\omega\cdot t 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 \alpha um die x-Achse und den Winkel 2\alpha 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 z = -1 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.

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. Dazu werden wir im Wesentlichen nur eine weitere Matrixmultiplikation benötigen.

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden /  Ändern )

Google Foto

Du kommentierst mit Deinem Google-Konto. Abmelden /  Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden /  Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden /  Ändern )

Verbinde mit %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.