KAPITEL 4 VON 7

Kamera und Pixel

Die Welt besteht aus Dreiecken im Raum, der Bildschirm aus einem Raster von Pixeln. Der Weg dazwischen hat vier Schritte.

  1. Alle Punkte aus Sicht der Kamera angeben
  2. Punkte auf die Bildebene projizieren
  3. Für jedes Dreieck die Pixel bestimmen und einfärben (Rasterisierung)
  4. Pro Pixel entscheiden, was vorn liegt (Tiefenpuffer)

Schritt 1: Die Sicht der Kamera

Die Kamera steht irgendwo in der Welt und blickt in eine Richtung. Damit sich leichter rechnen lässt, verschiebt und dreht man die ganze Welt so, dass die Kamera im Ursprung steht und entlang der z-Achse blickt. Das sind die Operationen aus Kapitel 1: erst die Kameraposition abziehen, dann um den Blickwinkel zurückdrehen. Danach ist z einfach die Entfernung in Blickrichtung.

Schritt 2: Projektion

Vor der Kamera steht im Abstand f die Bildebene. Der Bildpunkt eines Punktes P liegt dort, wo die Gerade von der Kamera zu P die Bildebene schneidet. In der Seitenansicht entstehen zwei ähnliche Dreiecke, und der Strahlensatz liefert:

y′ ÷ f = y ÷ z   ⟹   y′ = y · f ÷ z

Ziehe den Punkt PSeitenansicht: z nach rechts, y nach oben

Für x gilt dasselbe. Durch die Division durch z erscheint Entferntes kleiner: Das ist die ganze Perspektive.

def projiziere(p, f):
    x, y, z = p
    return [x * f / z, y * f / z]
Ziehen zum Drehen

Schritt 3: Rasterisierung

Nach der Projektion kennt der Computer von jedem Dreieck drei Punkte auf dem Bildschirm. Der Bildschirm ist aber ein Raster aus Pixeln, wie du es von der Pixelgrafik kennst. Die Rasterisierung beantwortet zwei Fragen: Welche Pixel gehören zum Dreieck? Und: Welche Farbe bekommt jedes davon?

Frage 1: Welche Pixel gehören zum Dreieck?

Das Verfahren ist schlicht: Der Computer geht alle Pixel in der Nähe des Dreiecks der Reihe nach durch. Bei jedem prüft er, ob die Pixelmitte im Dreieck liegt. Wenn ja, wird das Pixel gefärbt. „In der Nähe“ heißt: im kleinsten Rechteck, das das Dreieck umschließt.

Ecken lassen sich ziehen

Gestrichelt ist das umschließende Rechteck. Ein kleiner Punkt bedeutet: geprüft, liegt außerhalb. Ein gefülltes Kästchen bedeutet: gehört zum Dreieck.

Wie prüft man „liegt im Dreieck“?

Die Prüfung wird auf eine einfachere Frage zurückgeführt: Auf welcher Seite einer Geraden liegt ein Punkt? Die Gerade von a nach b teilt die Ebene in zwei Hälften. Die Funktion kante liefert für die eine Hälfte positive Zahlen, für die andere negative und genau auf der Geraden null.

def kante(a, b, p):
    return (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
Ziehe den Punkt p

Schalte auf „Drei Kanten“: Ein Dreieck hat drei Kanten, a nach b, b nach c und c nach a. Liegt p bei allen dreien auf der Innenseite, ist p im Dreieck. Ist auch nur ein Wert negativ, liegt p außerhalb. Mehr als diese drei kleinen Rechnungen braucht der Test nicht.

Zusammen mit der Schleife über das Rechteck ist das der ganze Algorithmus:

for py in range(y_min, y_max + 1):               # Zeile für Zeile
    for px in range(x_min, x_max + 1):           # Pixel für Pixel
        p = [px + 0.5, py + 0.5]                 # Pixelmitte
        if kante(a, b, p) >= 0 and kante(b, c, p) >= 0 and kante(c, a, p) >= 0:
            setze_pixel(px, py, farbe)

Frage 2: Welche Farbe? Die Textur

Im einfachsten Fall hat das ganze Dreieck eine Farbe. So sieht der Würfel links aus. Eine Kiste aus Holz bräuchte so aber Tausende winziger Dreiecke, nur um die Bretter darzustellen.

Deshalb verwendet man eine Textur. Das ist ein ganz gewöhnliches Bild, das wie ein Aufkleber auf die Dreiecke gelegt wird. Der Würfel bleibt bei 12 Dreiecken, die Einzelheiten stecken im Bild.

Ziehen zum Drehen
Links die Textur: ein Bild mit 16 × 16 Pixeln. Rechts derselbe Würfel aus 12 Dreiecken.

Welche Stelle der Textur gehört zu welchem Pixel?

Für die drei Ecken eines Dreiecks ist das festgelegt: Jede Ecke ist an einer Stelle der Textur „festgesteckt“. Diese Stelle wird mit zwei Zahlen (u | v) zwischen 0 und 1 angegeben und zusammen mit der Ecke gespeichert (Kapitel 2).

Ein Pixel irgendwo im Dreieck liegt zwischen den Ecken. Seine Stelle in der Textur ist eine Mischung der drei Eckstellen. Wie stark jede Ecke zählt, hängt davon ab, wie nah das Pixel an ihr liegt. Diese drei Anteile ergeben zusammen immer 100 %.

Ziehe den Punkt p im DreieckRechts: dieselbe Stelle in der Textur

Probiere drei Stellen aus: genau auf einer Ecke (100 % für diese Ecke), in der Mitte einer Kante (je 50 % für die beiden Ecken dieser Kante) und in der Mitte des Dreiecks (je ein Drittel).

Mit den Anteilen wird gemischt. Sind die Ecken an den Texturstellen u_a, u_b und u_c festgesteckt, dann gilt für das Pixel:

u = Anteil_a · u_a + Anteil_b · u_b + Anteil_c · u_c

Für v gilt dasselbe. An der Stelle (u | v) wird die Farbe aus dem Texturbild gelesen. Das ist die Farbe des Pixels.

Wie berechnet der Computer die Anteile?

Verbindet man p mit den drei Ecken, entstehen drei Teildreiecke. Der Anteil von a ist die Fläche des Teildreiecks, das a gegenüberliegt, geteilt durch die Fläche des ganzen Dreiecks. Je näher p an a rückt, desto größer wird dieses Teildreieck. Praktisch: kante(b, c, p) liefert genau das Doppelte dieser Teilfläche. Die drei Zahlen aus dem Innen-Test werden also einfach weiterverwendet.

flaeche   = kante(a, b, c)
anteil_a  = kante(b, c, p) / flaeche
anteil_b  = kante(c, a, p) / flaeche
anteil_c  = kante(a, b, p) / flaeche

Schritt 4: Der Tiefenpuffer

Bisher wurde jedes Dreieck für sich gezeichnet. In einer Szene liegen aber viele Dreiecke hintereinander, und mehrere können dasselbe Pixel bedecken. Sichtbar sein darf nur das vorderste.

Das fertige Bild ist eine Tabelle: Für jedes Pixel ist eine Farbe gespeichert. Der Tiefenpuffer ist eine zweite Tabelle mit genauso vielen Zeilen und Spalten. In ihr steht für jedes Pixel keine Farbe, sondern eine Zahl: wie weit das entfernt ist, was an diesem Pixel gerade zu sehen ist. Am Anfang steht überall „unendlich weit“.

Die Regel beim Zeichnen lautet: Ein Pixel wird nur dann neu gefärbt, wenn das neue Dreieck dort näher ist als der Wert im Tiefenpuffer. Dann wird auch die neue Entfernung eingetragen.

Die ferne Fläche C wird zuletzt gezeichnet und landet trotzdem hinten. Die Reihenfolge, in der die Dreiecke gezeichnet werden, ist also gleichgültig. Niemand muss sie vorher sortieren.

UNENDLICH = float("inf")
bild  = [[HINTERGRUND for x in range(breite)] for y in range(hoehe)]   # Tabelle der Farben
tiefe = [[UNENDLICH   for x in range(breite)] for y in range(hoehe)]   # Tabelle der Entfernungen

def setze_pixel(px, py, z, farbe):
    if z < tiefe[py][px]:           # näher als alles, was hier bisher gezeichnet wurde?
        tiefe[py][px] = z
        bild[py][px] = farbe

In der Demo hat jede Fläche überall dieselbe Entfernung. Ein schräg stehendes Dreieck hat an jeder Ecke eine andere Entfernung z. Für die Pixel dazwischen wird z genauso gemischt wie die Texturstelle: mit den Anteilen.

Alles zusammen und der Aufwand

Die folgende Szene wird in deinem Browser mit genau diesen vier Schritten berechnet, ohne Grafikkarte. Die Würfel durchdringen sich teilweise. Der Tiefenpuffer löst das Pixel für Pixel richtig auf. Verändere die Zahl der Würfel und beobachte die Zähler.

Ziehen zum Drehen
SchrittAnzahl in diesem Bild
Eckpunkte gedreht und projiziert (Schritt 1 und 2)
Dreiecke gezeichnet (Rückseiten weggelassen)
Pixel geprüft (Schritt 3)
davon vom Tiefenpuffer verworfen (Schritt 4)
Pixel gefärbt
Rechenzeit in deinem Browser

Was du ablesen kannst:

Merke
Projektion: x′ = x · f ÷ z und y′ = y · f ÷ z (Strahlensatz). Rasterisierung: Für jedes Pixel im umschließenden Rechteck prüft die Kantenfunktion, ob es im Dreieck liegt. Eine Textur ist ein Bild, das auf die Dreiecke gelegt wird. Die Stelle im Bild wird aus den Anteilen der drei Ecken gemischt. Der Tiefenpuffer ist eine zweite Tabelle, die pro Pixel die Entfernung des bisher Nächsten speichert.

Aufgabe: Berechne kante(a, b, p) für a = [0, 0], b = [4, 0] und die Punkte p = [1, 2] und p = [1, −2]. Was sagt das Vorzeichen?

Lösung anzeigen

Für [1, 2]: 4 · 2 − 0 · 1 = 8. Für [1, −2]: 4 · (−2) − 0 · 1 = −8. Die beiden Punkte liegen auf verschiedenen Seiten der Geraden durch a und b.

Aufgabe: Die Ecken eines Dreiecks sind in der Textur bei u_a = 0, u_b = 1 und u_c = 0,5 festgesteckt. Welches u gehört zu dem Pixel mit den Anteilen 20 %, 60 % und 20 %?

Lösung anzeigen

u = 0,2 · 0 + 0,6 · 1 + 0,2 · 0,5 = 0 + 0,6 + 0,1 = 0,7.

Aufgabe: Drei Flächen werden nacheinander auf dasselbe Pixel gezeichnet, mit den Entfernungen 7, 3 und 5. Welche Werte stehen nacheinander im Tiefenpuffer, und welche Fläche ist am Ende sichtbar?

Lösung anzeigen

Start: unendlich. Nach der ersten Fläche: 7. Nach der zweiten: 3, denn 3 ist kleiner als 7. Die dritte wird verworfen, denn 5 ist nicht kleiner als 3. Sichtbar ist die zweite Fläche.