Malen mit Zahlen, Teil RS1c – Der Code für Kamera und Punkte

Dieser Beitrag liefert den versprochenen Code zum Teil RS1. Geschrieben habe ich ihn in Processing, das im Wesentlichen ein vereinfachtes Java mit ein paar Goodies ist.

Warum Processing?

Normalerweise veranstalte ich Tagesevents zu 3D-Computergraphik in der letzten Schulwoche. Unsere Schüler können dabei frei aus Events wählen und auch der Klassenverband ist dann aufgelöst. Ich habe also 20+ Teilnehmer aus unterschiedlichen Jahrgängen, Abteilungen und natürlich mit unterschiedlichen Laptops und Betriebssystemen. Manche haben unter Windows noch nie die Kommandozeile gesehen …

Wenn ich anfange, bei jedem C++ und eine Graphikbibliothek wie z.B. SFML zu installieren, werde ich alt. (Wer glaubt, man kann das von den Schülern einfach so verlangen, hat es noch nie mit einer größeren Gruppe ausprobiert.)

Java hat den Vorteil auf allen gängigen Betriebssystemen verfügbar zu sein und man kann direkt Bilder malen, ohne zusätzlich etwas installieren zu müssen. Processing ist ebenfalls fast überall verfügbar und macht die Verwendung von Java noch etwas einfacher, weil es speziell für die schnelle Erstellung von Animationen entwickelt wurde.

Wie schaut ein Processing-Programm prinzipiell aus?

Ein Projekt, genannt Sketch, wird in einem eigenen Verzeichnis gespeichert. Die Hauptdatei hat den Namen des Projekts und die Endung ».pde«. Weitere PDE-Dateien kann man als Tabs im Editor öffnen. Die Hauptdatei enthält im Wesentlichen die beiden folgenden Funktionen:

void setup() {
    // öffne ein Zeichenfenster mit 640x640 Pixeln
    size(640, 640);

    // Rest des Setups
}

void draw() {
    // hier wird gezeichnet

    // ohne noLoop() wird draw() immer wieder aufgerufen
    // (gut für Animationen)
    noLoop();
}

Die setup()-Funktion wird einmal zu Beginn aufgerufen, um alles vorzubereiten. Die size(Breite, Höhe)-Funktion darin öffnet ein Zeichenfenster mit der entsprechenden Anzahl an Pixeln.

In der draw()-Funktion wird wenig überraschend gezeichnet. Das Besondere ist, dass draw() immer wieder aufgerufen wird, bis das Programm beendet oder die noLoop()-Funktion aufgerufen wird. Dadurch lassen sich sehr einfach Animationen programmieren.

Struktur des Rasterung-Programms

Beginnen wir mit der Punktklasse Point3D, die einfach die x-, y– und z-Koordinaten eines Punkts im Raum speichert.

class Point3D {
    // die Koordinaten des Punktes
    final float x;
    final float y;
    final float z;

    // der Konstruktor speichert die Koordinaten einfach ab
    Point3D(final float x, final float y, final float z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }
}

Ein neuer Punkt – z.B. P(1|2|3) – kann dann mit

Point3D p = new Point3D(1.0, 2.0, 3.0);

erzeugt werden.

Die Klasse Scene ist eigentlich nur ein Container, der alles enthält, was zu malen ist. Momentan sind das nur Punkte.

class Scene {
    // die Liste der Punkte, die zu malen sind
    final ArrayList<Point3D> points;

    // der Konstruktor legt eine leere Liste an
    Scene() {
        points = new ArrayList<Point3D>();
    }

    // wir können der Szene jederzeit einen Punkt hinzufügen
    void addPoint(final Point3D p) {
        points.add(p);
    }
}

In einer Schleife über alle Punkte der Szene malt der Rasterizer jetzt das Bild. Dabei ist zu beachten, dass wir nur Punkte vor der Kameraebene bei z = -1 malen. Die z-Koordinate der Punkte muss also <= -1 sein, weil wir ja entgegen die z-Achse schauen. Dieses »Wegschneiden« von Punkten, die wir nicht sehen können, nennt sich Clipping.

class Rasterizer {
    // private Referenz auf die Szene
    private final Scene scene;

    // der Konstruktor speichert eine private Referenz auf die Szene
    Rasterizer(Scene scene) {
        this.scene = scene;
    }

    // male die Szene
    void paintScene() {
        
        // male alle Punkte der Szene
        for (Point3D p : scene.points) {

            // aber nur, wenn sie vor der Kamera liegen
            if (p.z <= -1.0) {

                // berechne die screen coordinates
                // width/height sind Breite/Höhe des Zeichenfensters in Pixel
                final int xS = (int)((1.0 - p.x/p.z)*width/2.0);
                final int yS = (int)((1.0 + p.y/p.z)*height/2.0);

                // um die Punkte besser sehen zu können, zeichnen wir sie
                // als kleine Kreise
                // circle kümmert sich auch darum, ob die Punkte außerhalb
                // unseres Bildschirms sind
                circle(xS, yS, 4);
            }
        }
    }
}

Weil auf einem 640×640-Schirm ein Punkt recht klein ist, wird stattdessen ein kleiner Kreis mit dem circle()-Befehl gemalt. Dieser Befehl kümmert sich vorerst auch darum, dass Punkte links/rechts bzw. ober-/unterhalb des Schirms nicht gezeichnet werden. Um dieses Clipping müssen wir uns also (noch) nicht selber kümmern.

Das Hauptprogramm öffnet zuerst das Zeichenfenster und initialisiert die Szene. Die Funktion addPointsToScene() fügt die Koordinaten der Würfelecken in die Szene ein.

Scene theScene;
Rasterizer theRasterizer;

void setup() {
    // öffne ein Zeichenfenster mit 640x640 Pixeln
    size(640, 640);

    theScene = new Scene();

    addPointsToScene();

    theRasterizer = new Rasterizer(theScene);
}

void draw() {
    // wir füllen das Fenster schwarz
    background(0);

    // und setzen die Zeichenfarbe auf grün
    fill(0, 255, 0);
    stroke(0, 255, 0);

    // der Rasterizer malt jetzt die Szene
    theRasterizer.paintScene();

    // speichert das Bild ab
    save("cube.png");

    // es reicht, das Bild einmal zu malen
    noLoop();
}

void addPointsToScene() {
    // die Eckpunkte eines Würfels
    theScene.addPoint(new Point3D(-0.5, -0.5, -1.5));
    theScene.addPoint(new Point3D( 0.5, -0.5, -1.5));
    theScene.addPoint(new Point3D( 0.5,  0.5, -1.5));
    theScene.addPoint(new Point3D(-0.5,  0.5, -1.5));
    theScene.addPoint(new Point3D(-0.5, -0.5, -2.5));
    theScene.addPoint(new Point3D( 0.5, -0.5, -2.5));
    theScene.addPoint(new Point3D( 0.5,  0.5, -2.5));
    theScene.addPoint(new Point3D(-0.5,  0.5, -2.5));
}

In der draw()-Funktion füllen wir zunächst den Hintergrund schwarz und setzen die Zeichenfarbe auf grün, um einen gewissen Retro-Look zu haben. Anschließend malt der Rasterizer die Szene und das fertige Bild wird als PNG-Datei gespeichert.

Das ganze Programm kann man hier herunterladen. Den Output zeigt Abb. 1; er entspricht genau der Abb. 5 aus Teil RS1.

Abb. 1: Die Eckpunkte eines Würfels gesehen mit unserer inversen Lochkamera. Dieses Bild entspricht Abb.5 aus Teil RS1.

Wie kommt die Kugel in den Rechner?

Eine Kugel können wir nur dadurch rastern, dass wir Punkte auf ihrer Oberfläche der Szene hinzufügen. Wie kommen wir zu diesen Koordinaten?

Zunächst ist die Oberfläche einer Kugel die Menge der Punkte, die von einem vorgegebenen Mittelpunkt C einen fixen Abstand, den Radius r, haben. Diese Definition eignet sich gut fürs Raytracing, aber leider nicht fürs Rastern.

Um eine Kugel zu rastern, können wir auf die Kugelkoordinaten (s. Abb. 2) zurückgreifen, die man aus der Geographie als Breite (\theta) bzw. Länge (\varphi) kennt. Anstelle von +90° bis -90° geht der Polarwinkel \theta hier von 0 (Nordpol) bis 180^\circ=\pi=\tau/2 (Südpol). Der Azimutwinkel \varphi wird nicht von Greenwich weg gemessen, sondern von der positiven z-Achse (zumindest für unser Koordinatensystem). Für den Azimut \varphi sind Werte im Bereich 0 bis 360^\circ=2\pi=\tau möglich.

Abb. 2: Kugelkoordinaten Radius r, Polarwinkel \theta (Breite) und Azimutwinkel \varphi (Länge).

Am einfachsten erhalten wir die y-Koordinate (grüne Linie in Abb. 2): y=r\cdot\cos(\theta). Die Projektion des Radius in die xz-Ebene hat die Länge r'=r\cdot\sin(\theta). Deren Projektion auf die x-Achse liefert die x-Koordinate (rot): x=r'\cdot\sin(\varphi). Entsprechend erhalten wir die z-Koordinate (blau): z=r'\cdot\cos(\varphi).

Addieren wir zu den Koordinaten noch den Mittelpunkt C, erhalten wir für jeden beliebigen Punkt P auf der Kugeloberfläche

\displaystyle P=\left(\begin{array}{@{}l@{}}C_x+r\cdot\sin(\theta)\cdot\sin(\varphi)\\C_y+r\cdot\cos(\theta)\\C_z+r\cdot\sin(\theta)\cdot\cos(\varphi)\end{array}\right) .

Wollen wir N Punkte entlang des Äquators beträgt der Winkelabstand \Delta\varphi=\tau/N. Von Nord nach Süd (und nicht wieder zurück) benötigen wir für denselben Winkelabstand \Delta\theta=\tau/N=(\tau/2)/(N/2)=\pi/(N/2) nur die Hälfte N/2 der Punkte. Ich beginne die Kugel vom Südpol nordwärts aufzubauen, deshalb startet \theta bei \pi und wird dann immer kleiner. Die neue addPointsToScene()-Funktion sieht so aus:

void addPointsToScene() {
    // Punkte auf einer Kugel
    final int N = 16;
    final float radius = 1.0;

    // Koordinaten des Mittelpunkts
    final float cx =  0.0;
    final float cy =  0.0;
    final float cz = -2.5;
    
    // Südpol
    theScene.addPoint(new Point3D(cx, cy - radius, cz));
    // alle Punkte zwischen Süd- und Nordpol
    for (int i = 1; i < N/2; ++i) {
        // "geographische" Breite; wir beginnen beim Südpol
        float theta = PI - i*(PI/(N/2));
        for (int j = 0; j < N; ++j) {
            // "geographische" Länge
            float phi = j*(TAU/N);
            theScene.addPoint(new Point3D(cx + radius*sin(theta)*sin(phi),
                                          cy + radius*cos(theta),
                                          cz + radius*sin(theta)*cos(phi)));
        }
    }
    // Nordpol
    theScene.addPoint(new Point3D(cx, cy + radius, cz));
}

Das komplette Programm findet sich hier, und das Ergebnis zeigt Abb. 3. Man kann schön die einzelnen Breitenkreise erkennen.

Abb. 3: Ein paar Punkte auf einer Kugel. Etwa auf diesem Niveau wurde in Star Wars: A New Hope der Todesstern auf einem Display der Rebellen animiert.

Ändern wir die z-Koordinate C_z des Mittelpunkts auf -1, ergibt sich Abb. 4. Wir stehen mit unserer Kamera in der Mitte der Kugel und können die Hälfte der Punkte hinter uns nicht sehen.

Abb. 4: Die Kugel aus Abb. 3, aber der Mittelpunkt liegt jetzt in der Bildschirmebene. Die halbe Kugel liegt daher hinter dem Schirm.

Diskussion

Zusätzlich zu den oben beschriebenen Klassen wird es bald noch eine Kamera-Klasse geben, wenn wir die Kamera beliebig positionieren wollen.

Im nächsten Teil zeichnen wir dann endlich Linien – dadurch werden sich sogenannte Wireframe-Modelle, also Drahtgittermodelle ergeben.

Das Raytracing-Programm wird übrigens sehr ähnlich aufgebaut sein. Statt der Rasterizer- gibt es dann eine Raytracer-Klasse.

Benötigte Mathematik

Zusätzlich zu den schon verwendeten Strahlensatz, Grundrechnungsarten, Abrunden von Dezimalzahlen zu Ganzzahlen: Kugelkoordinaten (inkl. Sinus und Cosinus). Wir werden noch mindestens eine Art kennenlernen, eine Kugel aus Punkten zu konstruieren, die ohne Sinus und Cosinus auskommt.

Im nächsten Teil geht’s mit Linien weiter.

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.