Pythonstuff GLSL in English Pythonstuff GLSL auf Deutsch Pythonstuff GLSL Pythonstuff
PythonStuff Home
 

 

Das Programm height2bump.py

Diese Seite erklärt mein plattform-unabhängiges Open Source Programm height2bump.py, mit dem man Heightmaps in Normalmaps umwandeln kann.

Wie man es benutzt, steht hier.

Hier nochmal die Source:

Was ist eine Heightmap ?

Eine Heightmap ist ein Graustufen-Bild, dass die kleinräumige Geometrie eines Objektes beschreibt, indem es die Höhe (als Helligkeit: 0=schwarz=“unten”, 255=weiss=“oben”) für jedes Textur-Pixel (“Texel”) angibt. Diese wendet man auf ein viel gröberes Polygon-Modell an, um den Eindruck feiner Details der Oberfläche zu erwecken, die im Modell gar nicht wirklich da sind. Abhängig vom Shader-Programm kann man die Heightmap verwenden,

  • um die lokale Beleuchtung zu verändern (dafür muss man normalerweise zuerst eine Normalmap erzeugen :-)) - hier zu sehen: Bump Mapping
  • um die Textur-Position zu verschieben (Parallax Mapping)
  • um die Geometrie des Modells zu verändern (Ecken = Vertices verschieben mit einem Vertex Offset Shader oder neue Vertices erzeugen mit einem Geometry Shader)

Was ist eine Normalmap ? Was ist eine Bumpmap ?

Wie Du siehst, kann man die Unebenheiten (“Bumps”) auf einer Polygon-Oberfläche (die in Wirklichkeit aus flachen Facetten besteht) auf verschiedene Weise erzeugen. Wenn Du die Beleuchtung abhängig von einer Textur verändern willst, brauchts Du üblicherweise die Normalen zur Oberfläche.

Die Oberflächennormalen einer flachen Facette ist über die Facette konstant (A). Die erste Annäherung an eine “glatte” Oberfläche erhält man durch interpolation der Normalen der Ecken (Vertex Normals) (B). Eine Normalmap gibt die Richtung der Normalen (relativ zur interpolierten Normale) für jedes Pixel in der Textur an (C).

Surface Normals

Um die Normalen (Vx, Vy, Vz mit Länge = 1, Richtung x, y, z) in einem RGB-Bild unterzubringen, definieren wir:

R = (Vx + 1.0) * 127
G = (Vy + 1.0) * 127
B = (Vz + 1.0) * 127

Vx, Vy, Vz haben Werte zwischen -1 und +1. Indem wir ”+1” dazuzählen, bekommen wir den Bereich 0..+2 und nach der Multiplikation haben wir schliesslich 0..255, was perfekt in einen 8-bit-Farb-Kanal passt.

  • Die Normale zeigt senkrecht von der Oberfläche weg, wenn sich die Höhe nicht ändert (x = 0, y = 0, z = 1 ⇒ color = 7F 7F FF)
       
  • Die Normale zeigt nach links, wenn die Oberfläche aprupt nach Osten ansteigt (x = -1, y = 0, z = 0 ⇒ color = 00 7F 7F)
       
  • Die Normale zeigt nach rechts, wenn die Oberfläche aprupt nach Osten abfällt (x = 1, y = 0, z = 0 ⇒ color = FF 7F 7F)
       
  • Die Normale zeigt hinauf, wenn die Oberfläche aprupt nach Süden ansteigt (x = 0, y = 1, z = 0 ⇒ color = 7F FF 7F)
       
  • Die Normale zeigt hinunter, wenn die Oberfläche aprupt nach Süden abfällt (x = 0, y = -1, z = 0 ⇒ color = 7F 00 7F)
       

Da sich die Höhe über weite Bereiche nur wenig ändert, sehen Normalmaps im Grossen und Ganzen Pastell-Blau aus (ein Beispiel gibts hier).

Der Filter Kernel

Um eine Normalmap zu berechnen, müssen wir

  • kleine lokale Änderungen der Heightmap filtern
  • x-Änderungen in Richtung West-Ost finden (für den “R”-Kanal)
  • y-Änderungen in Richtung Nord-Süd finden (für den “G”-Kanal)
  • die z-Komponente so berechnen, dass die Länge der Normalen “1” ist (für den “B”-Kanal)

Filter

Das bedeutet, eine gewichtete Summe einer Gruppe von Pixel zu berechnen - hier eine so genannte “Gauß-Unschärfe”.

x-Änderungen und y-Änderungen

Hier wird die Differenz in der Höhe zwischen Pixel weiter “Östlich” und weiter “Westlich” gebildet (für x), bzw. “Nördlich” und “Südlich” (für y). Das nennt sich eine “partielle Ableitung”.

Das ganze geht in einem Schritt - das nennt man dann einen “Edge Detection Filter”. Ich verwende zwei verschiedene Arten:

  • Sobel Operator
  • Scharr Filter

Für Details zum Sobel-Operator hilft Wikipedia. Der “Scharr”-Filter sieht so änlich aus wie der Sobel-Filter, hat aber etwas bessere Eigenschaften für Kanten in beliebiger Lage.

Erfreulicherweise hat PIL (die Python Image Library) eine schnelle Implementierung zum Filtern von Bildern. Man muss nur den “Filter Kernel” (die Faktoren für die gewichtete Summe) angeben und los gehts:

r = heightBand.filter(ImageFilter.Kernel((5,5), kernel[0], scale=scale, offset=128.0))
g = heightBand.filter(ImageFilter.Kernel((5,5), kernel[1], scale=scale, offset=128.0))

Die Berechnung der z-Komponente

Da die Länge der Normalen 1 sein soll, müssen wir den guten alten Pythagoras bemühen, also x^2 + y^2 + z^2 = 1.

Ich habe es nicht geschafft, diese Berechnung der PIL unterzujubeln (obwohl das eigentlich möglich sein sollte). Daher muss ich über alle Pixel im Bild eine Schleife laufen lassen - eine eher langsame Angelegenheit in Python:

  for y in range( r.size[1] ):
      for x in range( r.size[0] ):
          op = 1.0 - (rr[x,y]*2.0/255.0 - 1.0)**2 - (gg[x,y]*2.0/255.0 - 1.0)**2
          bb[x,y] = 128.0 + 128.0 * sqrt(op)

(Die Behandlung von “negativen Wurzeln” zur Klarheit weggelassen).

Ich habe das Performance-Problem entschärft indem ich

  • den schnellen Pixel-Zugriff von PIL verwende
  • die Normalmap nur berechne, wenn sich die Heightmap geändert hat (also das Heightmap-File jünger ist als das Normalmap-File):
infile_stamp = os.path.getmtime(infn)
outfile_stamp = os.path.getmtime(outfn)
if infile_stamp < outfile_stamp:
    ....

Programm Struktur

Die eigentliche Schwerarbeit macht die Funktion

def height2bump( heightBand, filter="Scharr" ): # normal[0..2] band-array

Eingabe sit ein PIL-“band” das man aus jedem PIL-Image mit Image.split() bekommt. Die Ausgabe sind 3 Bänder, aus denen man bei Bedarf ein 4-Band-Image (RGBA) zum Texturieren oder Abspeichern machen kann. Um ein 4-Band-Bild zu speichern, muss das Format übrigens ”.tga” sein !

Abhängig von der “filter” Option wählt man unterschiedliche Filter Kernel. Wegen der Symmetrie des Filters braucht man nur 6 Zahlen, um einen 5×5-Filter zu definieren.

Für den schnellen Pixel-Zugriff erzeugt die Funktion r.load() die notwendigen Zugriffsmethoden. Damit kann man auf das Bild wie auf ein Array zugreifen (siehe die PIL Dokumentation wegen der Details).

Ich hätte wirklich gerne das ImageMath Modul von PIL verwendet, um die z-Komponente der Normalmap zu berechnen - ich habe es nicht geschafft, also muss ich in 2 geschachtelten Schleifen über das Bild laufen. Wenn das Ergebnis der Filterberechnung zu gross ist ( x*x + y*y > 1.0 ) dann würde die Wurzelberechnung sqrt() versagen - also gibts in der Schleife auch noch die Behandlung dieses Spezialfalls (obwohl die Schleife ohnehin schon langsam ist).

Die Skalierung des Filterergebnisses erfolgt dynamisch - wenn Du mit Deinem eigenen Filter experimentieren willst, geht das ziemlich einfach:

Ein neues “if” für den Filternamen und 6 Fließkomma-Zahlen für den Filter Kernel.

Die Funktion

readHeight2Bump( infn, outfn )

ist das “high level”-Interface für die Konvertierungsroutine: anhand der Zeitstempel wird entschieden, ob die Berechnung überhaupt ausgeführt werden muss. Mit der Option “v” erhältst Du eine Menge Ausgaben darüber, was das Programm sich so denkt :-)

Der Teil nach

if __name__ == "__main__":

erledigt die Verwaltung der Optionen. Wenn irgendwas schief geht, kommt eine kurze Hilfe-Info und das Programm endet. Dieser Teil wird ausgelassen, wenn der Code als Modul geladen wird.

height2normal.py als Modul

Einfach importieren und die readHeight2Bump() funktion verwenden:

from height2bump import readHeight2Bump
result = readHeight2Bump( heightfilename, bumpfilename, options="tqa" )

Das Ergebnis ist ein 4-Band PIL-Image mit dem Inhalt x,y,z,h.

Die Filenamen definieren die (einfärbige) Heightmap und die Normalmap. Wenn die Normalmap existiert und neuer ist als die Heightmap, wird sie gelesen und zurückgegeben - das ist schneller als die Berechnung. Ist die Heightmap neuer, so wird die Normalmap berechnet und in das Normalmap-Verzeichnis geschrieben. In diesem Fall muss der Schreibzugriff auf das Normalmap-File/Directory erlaubt sein !

Wenn die Normalmap existiert, muss es keine Heightmap geben.


English version, Start
Impressum & Disclaimer