Kapitel 1 · Es schlägt 12

Eine 3D-Uhr mit Python und OpenGL

Man kann die Zeit von unserer 2D-Uhr zwar ablesen, aber wirklich schön ist sie nicht. Also bauen wir sie noch einmal – mit OpenGL, geglätteten Kanten, spitzen Zeigern, einem Zifferblatt, das je nach Uhrzeit die Farbe wechselt, und echtem Licht. Weil wir dabei im Raum zeichnen, kann sich die Uhr sogar drehen.

Aus dem Buch · Kapitel 1 Fortgeschritten Grafik & AnimationMathe & FraktaleCode & Erklärung
Nur zum Lesen OpenGL-Programm mit eigenem Fenster – zum Ausführen auf deinem Rechner mit pygame-ce und PyOpenGL.
# 3D-Uhr mit OpenGL – Kapitel 1 »Es schlägt 12«, Abschnitte 1.7 bis 1.12
# aus »Coding for Fun mit Python«, portiert auf Python 3
# Läuft auf dem eigenen Rechner:  pip install pygame-ce PyOpenGL
import math
import datetime

from OpenGL.GL import *
import pygame
from pygame.locals import *


def resize(width, height):
    if height == 0:
        height = 1
    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    glOrtho(-0.2, 4.2, 4.2, -0.2, -6.0, 0.0)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()


def init():
    glShadeModel(GL_SMOOTH)
    glClearColor(1.0, 1.0, 1.0, 0.0)
    glClearDepth(1.0)
    glEnable(GL_DEPTH_TEST)
    glDepthFunc(GL_LEQUAL)

    # Beleuchtung aktivieren und eine Lichtquelle erstellen
    glEnable(GL_LIGHTING)
    glLightfv(GL_LIGHT0, GL_AMBIENT, [0.1, 0.1, 0.1, 1.0])
    glLightfv(GL_LIGHT0, GL_DIFFUSE, [0.9, 0.9, 0.9, 1.0])
    glLightfv(GL_LIGHT0, GL_POSITION, [0.0, 0.0, -6.0, 1.0])
    glEnable(GL_LIGHT0)

    # Color Tracking: glColor() legt die Materialeigenschaften fest
    glEnable(GL_COLOR_MATERIAL)
    glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
    # Normalen automatisch auf die Länge 1 bringen
    glEnable(GL_NORMALIZE)

    glHint(GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST)


def draw():
    # Tiefenpuffer und Farbpuffer löschen
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

    # Systemzeit auslesen
    now    = datetime.datetime.now()
    micro  = now.microsecond
    hour   = now.hour
    minute = now.minute
    second = now.second

    # Identitätsmatrix laden und Ansicht verschieben
    glLoadIdentity()
    glTranslatef(2.0, 2.0, 3.0)

    # Drehung der Uhr – mit Normale für die Beleuchtung
    glNormal3f(0.0, 0.0, -1.0)
    if 0 <= second <= 10:
        clockRotate(second, micro, 0, 10, (1.0, 0.0, 0.0))
    elif 30 <= second <= 40:
        clockRotate(second, micro, 30, 40, (0.0, 1.0, 0.0))

    # Zeichnen der Uhr
    drawClockBackground(hour, minute, second)
    drawClockFace()
    drawCurrentTime(hour, minute, second, micro)
    drawRing(2.0, 0.1, -0.1)
    drawRing(2.0, 0.1, +0.1)
    connectRings(2.05, -0.1, +0.1)


def clockRotate(second, micro, start, stop, rotation):
    x, y, z = rotation
    clockRotation = (second - start + micro / 1000000.0) / (stop - start) * 360.0
    # Rückseite sichtbar? Dann die Normale umdrehen
    if 90 < clockRotation < 270:
        glNormal3f(0.0, 0.0, +1.0)
    if clockRotation <= 360:
        glRotatef(clockRotation, x, y, z)


def connectRings(radius, depth1, depth2):
    numCircleVertices = 1000
    inner = True
    glColor4f(0.9, 0.4, 0.2, 1.0)
    glBegin(GL_TRIANGLE_STRIP)
    for i in range(numCircleVertices):
        angle = (i / (numCircleVertices - 2)) * 2 * math.pi
        if inner:
            depth = depth1
        else:
            depth = depth2
        x = math.cos(angle) * radius
        y = math.sin(angle) * radius
        inner = not inner
        glVertex3f(x, y, depth)
    glEnd()


def drawRing(radius, width, depth):
    outerRadius = radius + width / 2
    innerRadius = radius - width / 2

    numCircleVertices = 1000
    inner = True
    glColor4f(0.9, 0.4, 0.2, 1.0)
    glBegin(GL_TRIANGLE_STRIP)
    for i in range(numCircleVertices):
        angle = (i / (numCircleVertices - 2)) * 2 * math.pi
        if inner:
            currentRadius = innerRadius
        else:
            currentRadius = outerRadius
        x = math.cos(angle) * currentRadius
        y = math.sin(angle) * currentRadius
        inner = not inner
        glVertex3f(x, y, depth)
    glEnd()


def drawClockFace():
    for hour in range(12):
        angle = hour / 12 * 360.0
        glColor4f(0.4, 0.7, 0.9, 1.0)
        glPushMatrix()
        glRotatef(+angle, 0.0, 0.0, 1.0)
        glBegin(GL_QUADS)
        glVertex3f(-0.01, 1.5, 0.1)
        glVertex3f(+0.01, 1.5, 0.1)
        glVertex3f(+0.05, 1.9, 0.1)
        glVertex3f(-0.05, 1.9, 0.1)
        glEnd()
        glPopMatrix()


def drawClockBackground(hour, minute, second):
    glBegin(GL_TRIANGLE_FAN)
    # Mittelpunkt des Fächers – in der zuletzt gesetzten Farbe
    glVertex3f(0.0, 0.0, 0.0)
    numCircleVertices = 30
    clockRadius = 2.0
    for i in range(numCircleVertices):
        angle = (i / (numCircleVertices - 1)) * 2 * math.pi
        x = math.cos(angle) * clockRadius
        y = math.sin(angle) * clockRadius
        # Rot, Grün und Blau passend zur aktuellen Uhrzeit
        glColor4f(second / 60.0, minute / 60.0, (hour % 12) / 12.0, 1.0)
        glVertex3f(x, y, 0.0)
    glEnd()


def drawPointer(width, length, depth, position, scale):
    angle = position / scale * 360.0

    glPushMatrix()
    glRotatef(+angle, 0.0, 0.0, 1.0)
    glBegin(GL_TRIANGLES)
    glVertex3f(-width / 2, 0.0, depth)
    glVertex3f(+width / 2, 0.0, depth)
    glVertex3f(0.0, -length, depth)

    glVertex3f(-width / 2, 0.0, depth)
    glVertex3f(+width / 2, 0.0, depth)
    if length > 0:
        glVertex3f(0.0, +0.2, depth)
    else:
        glVertex3f(0.0, -0.2, depth)
    glEnd()
    glPopMatrix()


def drawCurrentTime(hour, minute, second, micro):
    glColor4f(0.3, 0.7, 0.2, 1.0)
    drawPointer(0.20, 1.0, 0.05, hour + minute / 60.0, 12)

    glColor4f(0.3, 0.9, 0.5, 1.0)
    drawPointer(0.15, 2.0, 0.10, minute + second / 60.0, 60)

    glColor4f(0.8, 0.4, 0.8, 1.0)
    drawPointer(0.08, 2.0, 0.15, second + micro / 1000000.0, 60)


def main():
    video_flags = OPENGL | DOUBLEBUF
    screenSize = (600, 600)

    pygame.init()
    pygame.display.gl_set_attribute(pygame.GL_MULTISAMPLEBUFFERS, 1)
    pygame.display.gl_set_attribute(pygame.GL_MULTISAMPLESAMPLES, 4)
    pygame.display.set_mode(screenSize, video_flags)
    pygame.display.set_caption("OpenGL Clock")

    resize(*screenSize)
    init()

    frames = 0
    ticks = pygame.time.get_ticks()
    running = True
    while running:
        for event in pygame.event.get():
            if event.type == QUIT or (event.type == KEYDOWN and event.key == K_ESCAPE):
                running = False

        draw()
        pygame.display.flip()
        frames = frames + 1

    print("fps: %d" % ((frames * 1000) / max(1, pygame.time.get_ticks() - ticks)))
    pygame.quit()


if __name__ == '__main__':
    main()

Was du hier lernst

  • OpenGL-Grundlagen
  • Triangle-Strip und Triangle-Fan
  • Rotation statt Trigonometrie
  • Matrizen sichern mit glPushMatrix
  • Farbverläufe mit glShadeModel
  • Licht, Normalen und Materialien

Die Erklärung stammt – leicht überarbeitet für Python 3 – aus dem Buch „Coding for Fun mit Python“ von Lars Heppert, Kapitel 1, Abschnitte 1.7 bis 1.12.

Eine schickere Uhr im 3D-Raum

Von unserer analogen Uhr kann man die Zeit zwar ablesen, aber wirklich schön ist sie nicht gerade. Deshalb bauen wir sie jetzt noch einmal, diesmal direkt mit der OpenGL-API: mit schickeren Zeigern, geglätteten Kanten und – weil wir dabei im 3D-Raum zeichnen – einer Uhr, die sich drehen kann.

OpenGL steht für »Open Graphics Library« und ist eine plattform- und programmiersprachenunabhängige Schnittstelle zur Darstellung von 2D- und 3D-Grafik. Ein großer Vorteil und Hauptgrund für ihre weite Verbreitung ist die Unabhängigkeit von einzelnen Herstellern: Über Erweiterungen des Standards entscheidet ein Gremium, heute die Khronos Group. Das Grundprinzip der Uhr ändert sich nicht, nur weil sie jetzt mit OpenGL gezeichnet wird – und auch das Zeichnen selbst ist nicht schwer.

OpenGL heute

Die Befehle in diesem Kapitel – glBegin, glVertex3f, glEnd – gehören zur klassischen »Fixed Function Pipeline« von OpenGL. Moderne Programme schicken ihre Daten stattdessen gebündelt an kleine Programme auf der Grafikkarte, sogenannte Shader, und neue Projekte setzen oft auf Vulkan, Metal oder im Browser auf WebGL und WebGPU. Apple hat OpenGL 2018 für veraltet erklärt, es läuft aber weiterhin. Zum Lernen ist die klassische Variante unschlagbar: Man sieht jeden Punkt, den man zeichnet.

Die erste Frage lautet wieder einmal: Was müssen wir importieren? Neben math und datetime für Winkel und Uhrzeit brauchen wir OpenGL und pygame:

import math
import datetime

from OpenGL.GL import *
import pygame
from pygame.locals import *

Die Hauptroutine unterscheidet sich nicht grundlegend von der 2D-Uhr. Neu ist das Flag OPENGL, und von besonderer Bedeutung sind zwei Zeilen für die Kantenglättung:

    pygame.display.gl_set_attribute(pygame.GL_MULTISAMPLEBUFFERS, 1)
    pygame.display.gl_set_attribute(pygame.GL_MULTISAMPLESAMPLES, 4)

Sie sorgen für eine sehr glatte und damit viel schönere Darstellung. Dass es vier Samples pro Pixel sind, ergab sich einfach durch Testen. Mit zwei Samples sieht die Glättung nicht so schön aus, mehr als vier sind dagegen unnötig – glatter als glatt geht einfach nicht. Nebenbei zählt die Hauptschleife die Bilder pro Sekunde und gibt sie beim Beenden aus. Für diese Uhr wäre das nicht nötig, aber bei eigenen, aufwendigeren Projekten siehst du so schnell, ob die Hardware noch mitkommt.

resize() richtet passend zur Fenstergröße die Ansicht ein. Zunächst wird die Projektionsmatrix geladen und durch eine Parallelprojektion ersetzt – glOrtho() –, danach wird die Modellmatrix aktiviert, in der wir zeichnen. init() legt fest, wie Flächen gefüllt werden (glShadeModel(GL_SMOOTH) für Farbverläufe), mit welcher Farbe der Hintergrund gelöscht wird und dass der Tiefentest aktiv ist: Weiter hinten liegende, verdeckte Teile sind dann unabhängig von der Zeichenreihenfolge unsichtbar.

Zeichnen mit OpenGL

In OpenGL ist die kleinste Einheit ein Punkt im Raum. Mehrere Punkte ergeben Flächen, die mit Farbe gefüllt werden – einfarbig oder als Verlauf. Gezeichnet wird in Blöcken zwischen glBegin() und glEnd(), die jeweils auf bestimmte Formen spezialisiert sind.

Wie bei der 2D-Uhr beginnen wir mit einem Ring. Dafür verwenden wir einen Triangle-Strip, einen Streifen aus Dreiecken: Du gibst einfach Punkte in Folge an, und ab dem dritten Punkt bildet jeder neue Punkt mit den beiden vorherigen ein weiteres Dreieck. Setzt man die Punkte abwechselnd auf den inneren und den äußeren Rand, schließt sich der Streifen zu einem Ring:

def drawRing(radius, width, depth):
    outerRadius = radius + width / 2
    innerRadius = radius - width / 2

    numCircleVertices = 1000
    inner = True
    glColor4f(0.9, 0.4, 0.2, 1.0)
    glBegin(GL_TRIANGLE_STRIP)
    for i in range(numCircleVertices):
        angle = (i / (numCircleVertices - 2)) * 2 * math.pi
        if inner:
            currentRadius = innerRadius
        else:
            currentRadius = outerRadius
        x = math.cos(angle) * currentRadius
        y = math.sin(angle) * currentRadius
        inner = not inner
        glVertex3f(x, y, depth)
    glEnd()

Je mehr Dreiecke, desto glatter der Ring – 1000 Punkte sind mehr als genug. glVertex3f() setzt jeweils einen Punkt aus drei Koordinaten im float-Format, glColor4f() die Farbe aus Rot, Grün, Blau und Deckkraft, jeweils zwischen 0.0 und 1.0. Ganz egal ist die Reihenfolge der Punkte übrigens nicht: OpenGL nimmt Flächen, deren Punkte im Uhrzeigersinn angegeben sind, als von hinten gesehen an. Diese Auswertung heißt Winding.

Farbe Rot Grün Blau
Schwarz 0.0 0.0 0.0
Rot 1.0 0.0 0.0
Grün 0.0 1.0 0.0
Gelb 1.0 1.0 0.0
Blau 0.0 0.0 1.0
Orange 0.98 0.625 0.12
Lila 0.6 0.4 0.7
Weiß 1.0 1.0 1.0

Mein Rat aus dem Buch gilt noch immer: Rechne bei OpenGL möglichst alles mit Kommazahlen. OpenGL arbeitet intern ebenfalls mit Floats und müsste andere Datentypen ständig umwandeln.

Der Trick mit der Rotation

Nun müssen noch die Zeiger her. Sie werden einfach als spitze Dreiecke dargestellt – und immer auf 12 Uhr gezeichnet:

def drawPointer(width, length, depth, position, scale):
    angle = position / scale * 360.0

    glPushMatrix()
    glRotatef(+angle, 0.0, 0.0, 1.0)
    glBegin(GL_TRIANGLES)
    glVertex3f(-width / 2, 0.0, depth)
    glVertex3f(+width / 2, 0.0, depth)
    glVertex3f(0.0, -length, depth)

    glVertex3f(-width / 2, 0.0, depth)
    glVertex3f(+width / 2, 0.0, depth)
    if length > 0:
        glVertex3f(0.0, +0.2, depth)
    else:
        glVertex3f(0.0, -0.2, depth)
    glEnd()
    glPopMatrix()

Stell dir vor, du willst auf einem Blatt Papier eine Uhr zeichnen. Für den Stundenzeiger auf 3 Uhr drehst du das Blatt um 90 Grad, zeichnest den Zeiger auf 12 Uhr ein und drehst das Blatt wieder zurück – schon zeigt er auf die 3. Auf Papier ist das sicher nicht die geschickteste Methode, aber in OpenGL erspart sie uns eine Menge Arbeit: Wir müssen weder den Endpunkt des Zeigers noch die Eckpunkte des Dreiecks mit Sinus und Kosinus ausrechnen. Die immer gleichen Punkte genügen, den Rest erledigt die Drehung.

Im Buch wurde die Drehung anfangs mit einer zweiten, entgegengesetzten Rotation rückgängig gemacht. Eleganter geht es mit glPushMatrix() und glPopMatrix(): Das erste sichert den aktuellen Zustand der Matrix, das zweite stellt ihn wieder her. Das ist genauer und stört auch eine Drehung der ganzen Uhr nicht, die wir gleich noch einbauen.

In einem GL_TRIANGLES-Block bilden je drei Punkte ein eigenes Dreieck – anders als beim Strip, wo sich benachbarte Dreiecke Punkte teilen. Das zweite, kleine Dreieck zeigt in die Gegenrichtung und lässt die Zeiger ein wenig über die Mitte hinausragen. Die Zeit stellt dann drawCurrentTime() dar, wieder mit der nächstkleineren Zeiteinheit für flüssig laufende Zeiger:

def drawCurrentTime(hour, minute, second, micro):
    glColor4f(0.3, 0.7, 0.2, 1.0)
    drawPointer(0.20, 1.0, 0.05, hour + minute / 60.0, 12)

    glColor4f(0.3, 0.9, 0.5, 1.0)
    drawPointer(0.15, 2.0, 0.10, minute + second / 60.0, 60)

    glColor4f(0.8, 0.4, 0.8, 1.0)
    drawPointer(0.08, 2.0, 0.15, second + micro / 1000000.0, 60)

Ein Zifferblatt für die Uhr

Was noch fehlt, ist eine klare Markierung der Stunden. Das funktioniert genau wie bei den Zeigern, nur dass die Striche nicht in der Mitte beginnen. Weil sie viereckig sein sollen, zeichnen wir sie als GL_QUADS:

def drawClockFace():
    for hour in range(12):
        angle = hour / 12 * 360.0
        glColor4f(0.4, 0.7, 0.9, 1.0)
        glPushMatrix()
        glRotatef(+angle, 0.0, 0.0, 1.0)
        glBegin(GL_QUADS)
        glVertex3f(-0.01, 1.5, 0.1)
        glVertex3f(+0.01, 1.5, 0.1)
        glVertex3f(+0.05, 1.9, 0.1)
        glVertex3f(-0.05, 1.9, 0.1)
        glEnd()
        glPopMatrix()

Für den Hintergrund des Zifferblatts nehmen wir einen Triangle-Fan, einen Fächer aus Dreiecken um einen gemeinsamen Mittelpunkt. Hier siehst du die Wirkung von glShadeModel(GL_SMOOTH): Der Mittelpunkt hat eine andere Farbe als die Randpunkte, und OpenGL malt dazwischen einen Verlauf. Die Randfarbe hängt von der Uhrzeit ab – der Rotanteil von den Sekunden, der Grünanteil von den Minuten, der Blauanteil von den Stunden. So hat deine Uhr zu jeder Zeit ein anderes Zifferblatt.

        # Rot, Grün und Blau passend zur aktuellen Uhrzeit
        glColor4f(second / 60.0, minute / 60.0, (hour % 12) / 12.0, 1.0)
Die OpenGL-Uhr von vorn: rundes Zifferblatt mit Verlauf von dunkler Mitte zu hellem Rand, zwölf Stundenstriche, drei spitze Zeiger
Abbildung 1.9 aus dem Buch: das Zifferblatt der Uhr

Möchtest du lieber ein einfarbiges Zifferblatt, stell das Shademodel testhalber auf GL_FLAT. Dann wird statt eines Verlaufs die Farbe des zuletzt gesetzten Punktes für die ganze Fläche verwendet.

Die Ansicht rotieren lassen

Bisher unterscheidet sich die neue Uhr kaum von der 2D-Version – allenfalls ist sie dank Glättung und spitzer Zeiger etwas schöner. Um ein bisschen mehr zu bieten, lassen wir sie um die x- und die y-Achse rotieren. Den Anlass liefert die Sekunde: In den ersten zehn Sekunden jeder Minute dreht sich die Uhr um die x-Achse, ab der halben Minute zehn Sekunden lang um die y-Achse.

def clockRotate(second, micro, start, stop, rotation):
    x, y, z = rotation
    clockRotation = (second - start + micro / 1000000.0) / (stop - start) * 360.0
    # Rückseite sichtbar? Dann die Normale umdrehen
    if 90 < clockRotation < 270:
        glNormal3f(0.0, 0.0, +1.0)
    if clockRotation <= 360:
        glRotatef(clockRotation, x, y, z)

Der Drehwinkel entspricht dem prozentualen Fortschritt im Drehzeitraum: Er startet bei 0 Grad, erreicht nach der Hälfte der Zeit 180 Grad und schließt nach der ganzen Zeit die volle Umdrehung ab. Damit das ohne Sprünge geschieht, rechnen wir – wie bei den Zeigern – die Mikrosekunden mit ein.

Alle Zeichenoperationen sammelt draw(). Zu Beginn wird die Ansicht komplett gelöscht, und glLoadIdentity() stellt die ursprüngliche Blickrichtung wieder her. Das macht alle bisherigen Verschiebungen und Drehungen rückgängig. Der folgende Aufruf von glTranslatef() rückt die Uhr in die Mitte der Ansicht. Die Drehung aus clockRotate() bleibt dann für alles gültig, was danach gezeichnet wird – genau deshalb sichern Zeiger und Zifferblatt die Matrix mit glPushMatrix(), statt sie zurückzusetzen.

Ganz zum Schluss kommt der Rand: zwei Ringe, einer etwas vor und einer etwas hinter der Uhr. Wegen der Parallelprojektion sieht man frontal nur einen, erst bei der Drehung zeigen sich beide. Und dabei auch eine unrealistisch wirkende Lücke zwischen ihnen:

Die gedrehte Uhr als schmale Ellipse, am Rand zwei getrennte Ringe mit sichtbarer Lücke
Abbildung 1.10 aus dem Buch: die Rotation der Uhr mit frei schwebenden Ringen

Solche »Fehler« beim Perspektivwechsel sind in der 3D-Programmierung nicht unüblich und müssen immer bedacht werden. Wir korrigieren ihn mit einem »Band« am Rand der Uhr – natürlich wieder ein Triangle-Strip. connectRings() wechselt dafür nicht zwischen innerem und äußerem Radius, sondern zwischen vorderem und hinterem Ring. Der Radius 2.05 ist der Außenrand der Ringe: Radius 2.0 plus die halbe Ringbreite von 0.1.

Die gedrehte Uhr als schmale Ellipse, der Rand jetzt als geschlossenes Band ohne Lücke
Abbildung 1.11 aus dem Buch: die Rotation der Uhr ohne Lücken

Individuelle Beleuchtung

Wie so oft im Leben kommt das Interessanteste zum Schluss: das Licht. Bisher hatten alle Farben immer dieselbe Intensität, auch wenn die Uhr sich drehte. In der Realität hängt die Helligkeit aber davon ab, in welchem Winkel das Licht von einer Fläche zu uns zurückgeworfen wird.

OpenGL kennt drei Arten von Licht. Ambientes Licht ist die Grundausleuchtung, die alles unabhängig vom Einfallswinkel aufhellt – ganz nach dem Motto: alles ein bisschen heller bitte. Diffuses Licht ändert die Helligkeit je nach Winkel zwischen Licht und Fläche. Spekulares Licht wirkt punktuell wie ein Spotlight. Meist werden die Arten gemischt:

    glEnable(GL_LIGHTING)
    glLightfv(GL_LIGHT0, GL_AMBIENT, [0.1, 0.1, 0.1, 1.0])
    glLightfv(GL_LIGHT0, GL_DIFFUSE, [0.9, 0.9, 0.9, 1.0])
    glLightfv(GL_LIGHT0, GL_POSITION, [0.0, 0.0, -6.0, 1.0])
    glEnable(GL_LIGHT0)

Schaltest du nur das Licht ein, bleibt die Uhr schwarz: Es fehlen Materialeigenschaften, und es ist unklar, wie das Licht reflektiert werden soll. Die Materialien bekommen wir geschenkt – GL_COLOR_MATERIAL macht aus jedem glColor()-Aufruf eine Materialeigenschaft, das nennt sich Color Tracking. Für die Reflexion braucht OpenGL dagegen Normalen: Vektoren, die senkrecht auf einer Fläche stehen und das Lot für jeden Reflexionswinkel bilden.

Weil die Uhr flach ist, genügt uns eine einzige Normale, senkrecht zur Uhr. draw() setzt sie vor dem Zeichnen mit glNormal3f(0.0, 0.0, -1.0). Dreht sich die Uhr so weit, dass wir ihre Rückseite sehen – zwischen 90 und 270 Grad –, dreht clockRotate() die Normale um. So werden Vorder- und Rückseite richtig ausgeleuchtet. Für nicht ebene Körper legt man je Punkt eine eigene Normale fest.

Das war schon alles?

Zugegeben, auch diese Uhr ist noch nicht der letzte Schrei. Für ein schöneres Zifferblatt könntest du ein Bild auf die Fläche legen. Größere Hürden sind Komplikationen wie eine Mondphasenanzeige oder ein Datum – und wer Automatikuhren mag, baut eine Gangreserve ein, die zum Beispiel anzeigt, wie lange die Uhr schon läuft. Ist die Uhr dann ein Schmuckstück geworden, ließe sich die ganze Anwendung in einen Bildschirmschoner verwandeln. Die anspruchsvollste Ausbaustufe dürfte eine Kuckucksuhr sein. Und wer kleinere Schritte bevorzugt, beziffert das Zifferblatt – dann hätte es seinen Namen auch verdient.

Auf deinem Rechner starten

Im Browser gibt es kein OpenGL-Fenster, deshalb läuft diese Uhr auf deinem eigenen Rechner. Du brauchst Python 3 und zwei Bibliotheken:

pip install pygame-ce PyOpenGL
python main.py

pygame-ce ist die aktiv gepflegte Community-Ausgabe von pygame; importiert wird sie ganz normal mit import pygame. Escape oder das Schließen des Fensters beendet das Programm, danach steht in der Konsole, wie viele Bilder pro Sekunde deine Grafikkarte geschafft hat.

Probier mal

Ideen zum Weiterbauen – auf deinem eigenen Rechner mit Python und den genannten Bibliotheken.

  1. 01Einfarbiges Zifferblatt

    Der Vorschlag aus dem Buch: Stell das Shademodel testhalber auf GL_FLAT. Dann gibt es keinen Farbverlauf mehr vom Mittelpunkt zum Rand – jede Fläche bekommt einfach die Farbe ihres zuletzt gesetzten Punktes.

    -     glShadeModel(GL_SMOOTH)
    +     glShadeModel(GL_FLAT)
  2. 02Mit Lücke zwischen den Ringen

    Ohne das »Band« aus connectRings() siehst du bei jeder Drehung den Fehler, den Abbildung 1.10 zeigt: Zwischen dem vorderen und dem hinteren Ring klafft eine Lücke. Solche Fehler beim Perspektivwechsel sind in der 3D-Programmierung ganz normal.

    -     connectRings(2.05, -0.1, +0.1)
    +     # connectRings(2.05, -0.1, +0.1)   # ohne Band: Lücke sichtbar
  3. 03Die schnelle Pirouette

    In der ersten Fassung aus Abschnitt 1.10 dauerte eine Drehung nur zwei Sekunden – eine flotte Pirouette statt einer gemächlichen Drehung. Du musst dafür nur das Zeitfenster verkleinern.

    -     if 0 <= second <= 10:
    -         clockRotate(second, micro, 0, 10, (1.0, 0.0, 0.0))
    +     if 0 <= second <= 2:
    +         clockRotate(second, micro, 0, 2, (1.0, 0.0, 0.0))
    -     elif 30 <= second <= 40:
    -         clockRotate(second, micro, 30, 40, (0.0, 1.0, 0.0))
    +     elif 30 <= second <= 32:
    +         clockRotate(second, micro, 30, 32, (0.0, 1.0, 0.0))
  4. 04Weniger Kantenglättung

    »Probieren Sie ruhig auch einmal zwei Samples aus«, heißt es im Buch. Mit zwei statt vier Samples pro Pixel werden die Kanten wieder sichtbar treppig. Mehr als vier bringen dagegen kaum noch etwas – glatter als glatt geht nicht.

    -     pygame.display.gl_set_attribute(pygame.GL_MULTISAMPLESAMPLES, 4)
    +     pygame.display.gl_set_attribute(pygame.GL_MULTISAMPLESAMPLES, 2)
  5. 05Eine zweite Lichtquelle

    Das Buch regt an, die Szene um weitere Lichtquellen zu ergänzen. Hier kommt ein orangefarbenes Licht von rechts dazu. OpenGL bietet mindestens acht Lichtquellen, GL_LIGHT0 bis GL_LIGHT7, die sich einzeln einschalten lassen.

    -     glEnable(GL_LIGHT0)
    +     glEnable(GL_LIGHT0)
    +     # eine zweite, orangefarbene Lichtquelle rechts von der Uhr
    +     glLightfv(GL_LIGHT1, GL_DIFFUSE, [0.8, 0.4, 0.0, 1.0])
    +     glLightfv(GL_LIGHT1, GL_POSITION, [6.0, 2.0, -6.0, 1.0])
    +     glEnable(GL_LIGHT1)

Vom Buch in den Browser

Diese Seite zeigt den Code nur zum Lesen: Die Uhr zeichnet mit OpenGL in ein eigenes Fenster, und das gibt es im Browser nicht. Das Buch entwickelt die Uhr Schritt für Schritt über die Listings 1.12 bis 1.27. Der Code oben ist der fertige Stand – so, wie ihn die Abbildungen zeigen und wie er in openGLClock.py auf der Buch-CD steht.

Python-3-Änderungen:

  • resize((width, height)) entpackte ein Tupel direkt in der Parameterliste. Das gibt es seit Python 3 nicht mehr, deshalb heißt es jetzt resize(width, height) und wird mit resize(*screenSize) aufgerufen.
  • print "fps: %d" % ... wird zur Funktion print(...). Die Division ist mit max(1, ...) gegen eine Laufzeit von null Millisekunden abgesichert.
  • float(position) / scale und i / float(numCircleVertices - 2) werden zu einfachen Divisionen: In Python 3 liefert / immer eine Kommazahl. Die Semikolons am Zeilenende, die Python ohnehin ignoriert, entfallen.
  • Statt pygame.event.poll(), das pro Bild nur ein Ereignis abholt, arbeitet die Schleife mit pygame.event.get() alle anstehenden Ereignisse ab, und pygame.quit() räumt am Ende auf.
  • HWSURFACE entfällt, pygame 2 ignoriert das Flag. Die Konstanten für die Kantenglättung heißen ausdrücklich pygame.GL_MULTISAMPLEBUFFERS und pygame.GL_MULTISAMPLESAMPLES, damit sie nicht mit gleichnamigen OpenGL-Konstanten verwechselt werden können.
  • Überall steht glRotatef – das Buch mischte glRotatef und glRotate.

Unterschiede zu einzelnen Listings, die im Buch Zwischenstände zeigen:

  • Hintergrund. Listing 1.15 löscht noch mit Schwarz. Die fertige Uhr in den Abbildungen hat einen weißen Hintergrund, wie in der CD-Datei.
  • Drehung. Listing 1.23 ruft clockRotate() mit vier statt fünf Argumenten auf – ein Überbleibsel einer Zwischenversion mit zwei Sekunden Drehzeit. Die Uhr folgt Listing 1.26 und 1.27: zehn Sekunden um die x-Achse ab der vollen Minute, zehn Sekunden um die y-Achse ab der halben. Die schnelle Variante findest du unter »Probier mal«.
  • Licht. Der diffuse Anteil ist 0.9 wie in Listing 1.25. Die CD-Datei verwendete 0.7.
  • Matrix sichern. Die Listings 1.17 und 1.19 drehen nach dem Zeichnen mit dem negativen Winkel zurück. Wie im Text von Abschnitt 1.10 beschrieben, sichern Zeiger und Stundenstriche die Matrix jetzt mit glPushMatrix() und glPopMatrix().
  • Tiefe. Zeiger und Stundenstriche liegen wie in der CD-Datei ein kleines Stück vor dem Zifferblatt (z = 0.05 bis 0.15 statt 0). Liegen zwei Flächen exakt in derselben Ebene, kann der Tiefentest bei einer Drehung flackern, weil Rundungsfehler mal die eine, mal die andere Fläche vorn sehen. Die Form der Stundenstriche folgt Listing 1.19.
  • Blauanteil. Das Zifferblatt rechnet mit (hour % 12) / 12.0 statt hour / 12.0. Sonst wäre der Blauanteil am Nachmittag größer als 1.0 – OpenGL schneidet das zwar ab, aber das Zifferblatt bliebe dann bis Mitternacht gleich blau.
  • Das ungenutzte from OpenGL.GLU import * entfällt, die Kommentare stehen auf Deutsch.

Getestet ist der Code mit Python 3.14, pygame-ce 2.5 und PyOpenGL 3.1: Er lässt sich übersetzen, und alle verwendeten Namen sind vorhanden.

Original aus dem Buch ansehen openGLClock.py · Python 2
from OpenGL.GL import *
from OpenGL.GLU import *
import pygame, math, datetime
from pygame.locals import *

def resize((width, height)):
    if height==0:
        height=1
    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    glOrtho(-0.2, 4.2, 4.2, -0.2, -6.0, 0.0)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

def init():
    glShadeModel(GL_SMOOTH)
    glClearColor(1.0, 1.0, 1.0, 0.0)
    glClearDepth(1.0)
    glEnable(GL_DEPTH_TEST)
    glDepthFunc(GL_LEQUAL)
    
    # enable Lighting and create lights
    glEnable(GL_LIGHTING)
    glLightfv(GL_LIGHT0, GL_AMBIENT, [0.1, 0.1, 0.1, 1.0])
    glLightfv(GL_LIGHT0, GL_DIFFUSE, [0.7, 0.7, 0.7, 1.0])
    glLightfv(GL_LIGHT0, GL_POSITION, [0.0, 0.0, -6.0, 1.0])
    glEnable(GL_LIGHT0)
    
    # enable color tracking
    glEnable(GL_COLOR_MATERIAL)
    # front material ambient and diffuse colors track glColor
    glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
    # enable normalization of normal vectors
    glEnable(GL_NORMALIZE)
    
    glHint(GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST)

def draw():
    glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)
    
    now         = datetime.datetime.now()
    micro       = now.microsecond
    hour        = now.hour
    minute      = now.minute
    second      = now.second    
    
    glLoadIdentity()
    glTranslatef(2.0, 2.0, 3.0)
    
    glNormal3f(0.0, 0.0, -1.0)
    if   0 <= second <= 10:
        clockRotate(second, micro,  0, 10, (1.0, 0.0, 0.0))
    elif 30 <= second <= 40:
        clockRotate(second, micro, 30, 40, (0.0, 1.0, 0.0))

    drawClockBackground(hour, minute, second)
    drawClockFace()
    drawCurrentTime(hour, minute, second, micro)
    drawRing(2.0, 0.1, -0.1)
    drawRing(2.0, 0.1, +0.1)
    connectRings(2.05, -0.1, +0.1)

def clockRotate(second, micro, start, stop, rotation):
    x, y, z = rotation
    clockRotation = (second - start + micro/1000000.0)/(stop-start)*360.0
    if 90 < clockRotation < 270:
        glNormal3f(0.0, 0.0, +1.0)
    if clockRotation <= 360:
        glRotate(clockRotation, x, y, z)

def connectRings(radius, depth1, depth2):
    numCircleVertices = 1000
    inner = True
    glColor4f(0.9, 0.4, 0.2, 1.0)
    glBegin(GL_TRIANGLE_STRIP)
    for i in range(numCircleVertices):
        angle = (i / float(numCircleVertices-2)) * 2 * math.pi;
        if inner:
            depth = depth1
        else:
            depth = depth2
        x = math.cos(angle) * radius
        y = math.sin(angle) * radius
        inner = not inner        
        glVertex3f(x, y, depth)
    glEnd()

def drawRing(radius, width, depth):
    outerRadius = radius+width/2
    innerRadius = radius-width/2
    
    numCircleVertices = 1000
    inner = True
    glColor4f(0.9, 0.4, 0.2, 1.0)
    glBegin(GL_TRIANGLE_STRIP)
    for i in range(numCircleVertices):
        angle = (i / float(numCircleVertices-2)) * 2 * math.pi;
        if inner:
            currentRadius = innerRadius
        else:
            currentRadius = outerRadius
        x = math.cos(angle) * currentRadius
        y = math.sin(angle) * currentRadius
        inner = not inner        
        glVertex3f(x, y, depth)
    glEnd()

def drawClockFace():
    for hour in range(12):
        angle = float(hour)/12*360.0
        glColor4f(0.4, 0.7, 0.9, 1.0)
        glPushMatrix()
        glRotatef(+angle, 0.0, 0.0, 1.0)
        glBegin(GL_QUADS)
        glVertex3f(-0.02, 1.5, 0.1)
        glVertex3f(+0.02, 1.5, 0.1)
        glVertex3f(+0.04, 1.9, 0.1)
        glVertex3f(-0.04, 1.9, 0.1)
        glEnd()
        glPopMatrix()
        
def drawClockBackground(hour, minute, second):
    glBegin(GL_TRIANGLE_FAN)
    glVertex3f(0.0, 0.0, 0.0) # center of the fan
    numCircleVertices = 30
    clockRadius = 2.0
    for i in range(numCircleVertices):
        angle = (i / float(numCircleVertices-1)) * 2 * math.pi;
        x = math.cos(angle) * clockRadius
        y = math.sin(angle) * clockRadius
        glColor4f(second/60.0, minute/60.0, hour/12.0, 1.0)
        glVertex3f(x, y, 0.0)
    glEnd()

def drawPointer(width, length, depth, position, scale):
    angle = float(position)/scale*360.0

    glPushMatrix()
    glRotatef(+angle, 0.0, 0.0, 1.0)
    glBegin(GL_TRIANGLES)
    glVertex3f(-width/2, 0.0, depth)
    glVertex3f(+width/2, 0.0, depth)
    glVertex3f(0.0, -length, depth)
    
    glVertex3f(-width/2, 0.0, depth)
    glVertex3f(+width/2, 0.0, depth)
    
    if length > 0:
        glVertex3f(0.0, +0.2, depth)
    else:
        glVertex3f(0.0, -0.2, depth)      
        
    glEnd()
    glPopMatrix()

def drawCurrentTime(hour, minute, second, micro):
    glColor4f(0.3, 0.7, 0.2, 1.0)
    drawPointer(0.20, 1.0, 0.05,   hour+(minute/60.0), 12)
    
    glColor4f(0.3, 0.9, 0.5, 1.0)
    drawPointer(0.15, 2.0, 0.10, minute+(second/60.0), 60)
    
    glColor4f(0.8, 0.4, 0.8, 1.0)
    drawPointer(0.08, 2.0, 0.15, second+(micro/1000000.0), 60)
     
def main():
    video_flags = OPENGL|HWSURFACE|DOUBLEBUF
    screenSize  = (600, 600)
    
    pygame.init()
    pygame.display.gl_set_attribute(GL_MULTISAMPLEBUFFERS, 1)
    pygame.display.gl_set_attribute(GL_MULTISAMPLESAMPLES, 4)
    pygame.display.set_mode(screenSize, video_flags)

    resize(screenSize)
    init()

    frames = 0
    ticks = pygame.time.get_ticks()
    while True:
        event = pygame.event.poll()
        if event.type == QUIT or (event.type == KEYDOWN and event.key == K_ESCAPE):
            break
        
        draw()
        pygame.display.flip()
        frames = frames+1

    print "fps:  %d" % ((frames*1000)/(pygame.time.get_ticks()-ticks))

if __name__ == '__main__': main()