dieser Beitrag wird keine wirkliche Frage. Ich möchte vielmehr eine Geschichte erzählen, wie ich der Splitterwelten-Engine HDR beigebracht habe und was ich dabei alles gelernt habe. Vielleicht nützen die Erkenntnisse auch anderen Leuten. Denn HDR beeinflusst so ziemlich *alles*, was man in einer Engine bisher programmiert hat. Die Engine ist ausschließlich DirectX9-Niveau - etwas anderes kommt vorerst nicht in die Tüte.
Zuerst mal ein paar Bilder, wie das Endergebnis aussieht. Recht oben in der Ecke steht jeweils die für das Bild ermittelte Durchschnittshelligkeit und daneben die aktuelle Aussteuerung, die sich der ersten Zahl fortlaufend anpasst.
Renderformat
Wer HDR sehen will, muss ein Rendertarget jenseits der A8R8G8B8 benutzen. Dafür gibt es verschiedene Optionen:
A16B16G16R16F - 16Bit-Fließkomma-Format. Der Klassiker. Wertebereich grob von 65000 bis 1e-4, entspricht 8 Dekaden.
A16B16G16R16 - 16Bit-Festkomma-Format. Also praktisch ushort - Wertebereich von 65000 bis 1, entspricht 4-5 Dekaden.
RGBE - 8Bit-Format, bei dem der Alphakanal zu einem gemeinsamen Exponenten umdefiniert wurde. Wertebereich von 1e36 bis 1e-36, aber kein Alphakanal.
A32B32G32R32F - 32Bit-Fließkomma-Format. Die volle Keule. Wertebereich von 1e36 bis 1e-36.
Das RGBE-Format ist das schnellste, sollte man meinen. Allerdings fällt prinzipiell jedes AlphaBlending aus, selbst ein simples ADD - das muss man selber implementieren. Da wir uns an einigen Ecken auf AlphaBlending verlassen, war das für uns damit gestorben. 32Bit-Floats sind genauso untauglich - es mag der bequemste Weg sein, aber jegliche Grafikkarte kleiner als die Geforce8000-Serie oder Radeon3000-Serie kann darauf auch kein AlphaBlending oder Texturfilter. Ganz abgesehen von dem enormen Speicher- und Bandbreitenverbrauch. Bleiben die beiden 16Bit-Formate - Festkomma oder Fließkomma. Ich wollte hier ursprünglich das Integer-Format nehmen - auch die zweieinhalb zusätzlichen Dekaden Dynamikbereich hätten mir erstmal gereicht. Allerdings reicht der Dynamikbereich eben doch nicht. Allein die Gammakorrektur wirkt wie eine Kompression von 1e4 auf 1e2, da braucht man bereits ein Format mit 4 Dekaden Genauigkeit und hat noch kein bisschen Helligkeit ausgesteuert. Außerdem stellte sich heraus, das NVidia-Karten selbst der neuesten Generation keine 16Bit-Integer-Formate filtern oder blenden können. Bleibt damit der Klassiker: das 16Bit-Fließkomma-Format A16B16G16R16F. Und das ist es dann auch geworden.
Nebenbei: NVidia unterstützt keine Rendertarget-Formate mit weniger als 32Bit pro Pixel. Wir haben z.B. Neben-Rendertargets mit R16F benutzen wollen. Geht prima auf ATI-Karten, auf NVidia scheitert die Texturerzeugung. Wir mussten auf G16R16F aufstocken, dann ist ein Pixel wieder 32Bit groß und NVidia hat wieder mitgespielt.
Bestimmung der Bildhelligkeit
Hier kann man einiges falsch machen. Zunächst die Runterskalierung: wir benutzen 3 Stufen von je 4x4 zu 1, wobei die erste Stufe noch Zusatz-Rechnungen für Bloom macht. Ich habe da früher oft empfohlen, den bilinearen Filter auszunutzen, indem man genau in der Mitte von 2x2 Texeln sampelt und so 2x2 Pixel von der Grafikkarte frei Haus aufsummiert bekommt. In der Praxis habe ich das aber trotz einiger Stunden Jagd mit PIX nicht hinbekommen - die Textur-Zugriffskoordinaten stimmen, aber trotzdem bekomme ich nur einen der 2x2 Texel. Dadurch wurde der Bloom extrem unruhig mit jeder Bildbewegung. Ich habe das DownScaling dann auf 4x4 explizite Texturzugriffe umgebaut. Hier übrigens Vorsicht mit der Mathematik unter SM2.0-Hardware: eine harmlose Zeile wie "tex2D( sampler, texkoord + offset.xy * skal.zw)" wurde zu linearen Mathe-Ops, weswegen der Shader die 64-MatheOps-Grenze von SM2-Hardware gesprengt hatte. Mit "skal.xy" geht es, die Multiplikation lässt sich dann vektorisieren.
Als Helligkeit habe ich die Wurzel des Grauwertes jedes Pixels genommen:
Code: Alles auswählen
// 4x4 Pixel zusammenrechnen
float4 f = 0.0f;
for( float a = 0; a < 16; ++a)
f += tex2D( TexBild, rein.mTexKoords + gOffset[a] * rein.mOffsetSkal.xy);
f *= 0.0625f;
// Helligkeit im Alphakanal durchschleifen.
static const float3 grau = float3( 0.3f, 0.59f, 0.11f);
f.a = sqrt( max( dot( f.rgb, grau), 1e-6));
Nach der dritten Skalierungsstufe hatte ich damit ein 64stel-Renderziel mit der Helligkeitsverteilung des Bildes. Diese Stufe ging jedes Frame reihum in mehrere Renderziele, wobei ich jeweils in eins gerendert und ein anderes mit der CPU gelockt und ausgelesen habe. Das sollte ursprünglich den CPU-GPU-Sync verhindern, den man sonst mit solchen Aktionen provoziert. Leider prüft sowohl der ATI-Treiber noch der NVidia-Treiber überhaupt nicht, ob das Renderziel noch benutzt wird - ein Lock synct *immer*, selbst wenn vor 20 Frames das letzte Mal da reingerendert wurde. Daher sind die Frameraten auf den Bildern auch ca. 20 bis 50% niedriger als normal. Die Lösung dafür wäre, das Rendertarget mit IDirect3DDevice9::CreateRenderTarget() zu erzeugen, um dann beim Locken D3DLOCK_DONOTWAIT anzugeben. Habe ich aber noch nicht getan, unsere Rendertargets sind momentan nur normale D3D-Texturen, bei denen man diesen Flag nicht benutzen kann.
Licht und Schatten
HDR ist grausam - es bringt jeden kleinen Renderfehler in gleißender Helligkeit hervor. Die oben beschriebene NAN-Fortpflanzung ist nur ein Aspekt dieses Ärgernisses. Wenn die ShadowMapping-Implementation nicht in jeder Lebenslange absolut wasserdicht ist, bekommt man verirrtes Sonnenlicht in dunklen Höhlen, was wegen des HDRs dann klatschhell wird. Wenn die Spiegelebene vereinzelt Fehlpixel von jenseits der Landschaft liest, werden die weithin leuchten. Wir haben dabei auch festgestellt, dass unser Terrain nicht dicht ist. Durch die verschiedenen Transformationsmatrizen für verschiedene Terrainsegmente passen die Eckpunkte der Segmente nicht perfekt aufeinander. Fiel vorher nie jemandem auf, aber jetzt sieht man in dunklen Höhlen Linien aus strahlend weißen Fehlpixeln durch die Wände ziehen. Eine subtile Vergrößerung der Terrain-Meshes an den Kanten brachte Abhilfe.
Nebel
Nebel ist eigentlich eine schlichte, nette Sache: in Abhängigkeit von der Entfernung mischt man in jeden Bildschirmpixel zunehmend die Nebelfarbe rein. Aber was für Außenareale prima funktioniert, wird in Innenräumen mit meist hoher Helligkeitsaussteuerung zu einer weißen Wand, die man vor sich her schiebt. Ich habe dafür momentan noch keine Lösung gefunden. Aktuell haben wir den Nebel so eingestellt, dass er erst ~50m vor dem Spieler überhaupt beginnt. Dadurch wurde die weise Wand so weit rausgeschoben, dass die meisten Höhlen nebelfrei sind. Das ist aber keine Lösung, sondern nur ein Verstecken der Symptome, bis mir was besseres einfällt.
Spiegelungen
Auch Spiegel-Ebenen oder -CubeMaps sollte man jetzt als HDR-Texturen anlegen. Das letzte Bild oben zeigt, wie die Mischung dann aussieht: helle Stellen der Spiegelung überstrahlen korrekt dunkle Untergründe, die Sonne strahlt auch im Wasser noch hell zwischen den Wolken hervor. Vorher hatte man tagsüber draußen nur matte Spiegelungen. Und vor allem in Höhlen hilft die zusätzliche Genauigkeit, die Spiegelungen auch in der dunkelsten Ecke noch ordentlich aussehen zu lassen. Eine 8Bit-Reflection Map brauchte bereits bei HDR x100 ein kunterbuntes Pixelwirrwarr zu Tage.
Gamma-Korrektur
Schon vorher empfehlenswert, aber mit HDR erst wirklich hübsch. Wie ihr sicher wisst, hat jede Grafikkarte eine Helligkeitskorrektur im DA-Wandler. Das Auge nimmt Helligkeiten nur logarithmisch wahr - doppelte Lichtenergie wird weniger als doppelt so hell wahrgenommen. Damit der Standard-Wertebereich von Rot-Grün-Blau trotzdem von 0 bis 1 als linearer Helligkeitsverlauf erscheint, verstärkt die Grafikkarte die hellen Farbwerte. Die genaue Formel ist einstellbar, liegt aber meist im Bereich "pow( farbwert, 2.2f)" - also etwas mehr als eine Quadrierung. Wenn man eine Engine schreibt, will man aber meist, dass der Helligkeitsverlauf als natürlich empfunden wird. Man muss daher die Gammakorrektur der Grafikkarte entweder abschalten oder vorab rückgängig machen, indem man den Ergebnis-Farbwert vor der Ausgabe mit "pow( farbwert, 0.45f)" vorkorrigiert. Wir benutzen stattdessen nur ein "sqrt( farbwert)", das ist minimal schneller. Mit dieser Vorkorrektur überlagern sich dann Farbwerte, wie es das menschliche Auge erwartet. Helle Elemente überstrahlen dunkle, der Helligkeitsverlauf entlang einer zur Sonne geneigten Oberfläche wird glaubwürdig, und vor allem das AlphaBlending sieht besser aus. Das letzte Bild in obiger Liste zeigt das Seewasser, das jetzt nur noch aus einer Spiegeltextur besteht, die mit Alphablending gezeichnet wird. Man sieht dann, dass z.B. die helle Sonnenspiegelung alle Pixel hinter sich überstrahlt, während an dunklen Spiegelungen der Untergrund durchscheint. Das ist nur noch die Folge von AlphaBlending auf HDR-Formaten - es war keine weitere Shaderlogik nötig, um solche natürlichen Effekte zu erreichen.
Das Bild wirkt statisch etwas seltsam, sieht in Bewegung aber viel glaubwürdiger aus als vorher. Übrigens: die Farbtexturen, die man üblicherweise benutzt, sind natürlich auch für die GPU-Gammakorrektur ausgelegt. Wenn man die einfach so weiterverwendet, wird bei denen die oben beschriebene Gamma-Vorkorrektur doppelt angewendet, was das farbliche Erscheinungsbild ruiniert. Ich empfehle dazu, die Farbtexturen im Shader nach dem Samplen zu konterkorrigieren. Theoretisch wäre das wieder ein "pow( diffuse, 2.2f)", aber das ist sehr teuer. Nahezu identische Ergebnisse zeigt ein schlichtes Quadrieren.
So. Theoretisch könnte man noch viel mehr dazu erzählen, aber der Text ist sowieso schon ewig lang. Ich hoffe, es waren ein paar Infos erhalten, die Euch interessieren.
Bye, Thomas