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.

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 () bzw. Länge (
) kennt. Anstelle von +90° bis -90° geht der Polarwinkel
hier von 0 (Nordpol) bis
(Südpol). Der Azimutwinkel
wird nicht von Greenwich weg gemessen, sondern von der positiven z-Achse (zumindest für unser Koordinatensystem). Für den Azimut
sind Werte im Bereich 0 bis
möglich.

Am einfachsten erhalten wir die y-Koordinate (grüne Linie in Abb. 2): . Die Projektion des Radius in die xz-Ebene hat die Länge
. Deren Projektion auf die x-Achse liefert die x-Koordinate (rot):
. Entsprechend erhalten wir die z-Koordinate (blau):
.
Addieren wir zu den Koordinaten noch den Mittelpunkt C, erhalten wir für jeden beliebigen Punkt P auf der Kugeloberfläche
.
Wollen wir N Punkte entlang des Äquators beträgt der Winkelabstand . Von Nord nach Süd (und nicht wieder zurück) benötigen wir für denselben Winkelabstand
nur die Hälfte N/2 der Punkte. Ich beginne die Kugel vom Südpol nordwärts aufzubauen, deshalb startet
bei
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.

Ändern wir die z-Koordinate 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.

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.