Postprocessing

Autor: Lukas Kalde

Das Postprocessing umfasst Manipulationen, die nach dem Rendering der Welt geschehen und die die sich der eigentlichen 3d-Umgebung nicht bewusst sind. Das gerenderte Bild wird hierfür in einen Framebuffer, der eine Textur vorhält, gespeichert, anstatt über den Default Framebuffer direkt auf dem Screen angezeigt zu werden.

Die Textur des Framebuffers wird dann mit OpenGL (bzw Glium) auf ein screen quad, also ein Rechteck, das den gesamten Bildschirm ausfüllen soll, angebracht und anschließend erneut gerendert. Durch einen entsprechenden Fragment Shader kann dabei die Textur angepasst werden, um beispielsweise Bloom darstellen zu können. Denkbar wären auch FXAA, colour grading (um zb. der Szene einen wärmeren Farbton zu geben) oder allgemein alle Nachbearbeitungen, die lediglich die Textur benötigen.

In Plantex wurde Bloom und HDR implementiert.

HDR

Ohne HDR-Unterstützung wären die unterschiedlichen Lichtverhältnisse in diesem Bild nur schlecht ohne größere Umstände abzubilden gewesen. Warum das so ist ist im Folgenden weiter ausgeführt.

Überblick

HDR steht für High Dynamic Range und meint die Möglichkeit, Farben in größeren Wertbereichen als nur 0-255 für jeden RGB-Kanal Abbilden zu können. In Plantex sind 32-bit Floats für die Speicherung der Textur vorgesehen. Da Monitore aber lediglich Farben in den üblichen 8-Bit-Dimensionen unterstützen, muss eine HDR-Textur anschließend wieder zurückgemappt werden (dieser Schritt nennt sich Tone Mapping, siehe hierzu weiter unten). Die Herausforderung liegt darin, die HDR-Textur sinnvoll zu nutzen und eine geeignete Rückabbildung zu finden.

Nutzen

In der realen Welt varriiert die Beleuchtungsstärke in Größenordnungen zwischen 101lux10^{-1} lux nachts und 105lux10^5 lux tagsüber1. Dies ist weit jenseits des Bereiches, der mit 2^8 bit großen Farbkanälen angezeigt werden kann, eine realistische Umsetzung von Beleuchtung ist somit nicht möglich und muss künstlich erzeugt werden.

Will man eine physikalisch korrektere Farb- und Helligkeitsabbildung nutzen, bietet HDR dafür ein weit größeres Spektrum verfügbarer Helligkeitswerte. In Plantex finden sich helle und dunkle Bereiche gleichzeitig in der Spielwelt, und beide lassen sich gleichzeitig abbilden ohne an den Texturen an sich etwas ändern zu müssen. In Abhängigkeit vom Exposure-Wert (siehe dazu Eye Adaption) können dann entweder die hellen oder die dunklen Bereiche tatsächlich auf dem Monitor angezeigt werden - in der Realität findet sich dies bei der Blende von Kameras oder beim Weiten oder Zusammenziehen der Pupille.

Ein konkreter Nutzen ist, wie bereits erwähnt, Bloom, das von der differenzierten Darstellung der Helligkeit profitiert.

Implementierung in Plantex

Die Implementierung der für HDR notwendigen Datenstrukturen ist simpel: Es genügt, bei der Erstellung der Zieltextur für den Frame Buffer zu spezifizieren, dass 32-bit Floats verwendet werden sollen. Die Komplexität ergibt sich erst in der korrekten Verwendung der HDR-Werte.

Genutzt werden kann HDR nur dann, wenn die verschiedenen, für das Rendering von Pflanzen, Terrain, Himmel usw. verwendeten Fragment Shader auch tatsächlich korrekte Farbwerte ausgeben, wenn aus den Farbwerten dann über die Durchschnittshelligkeit des Bildschirminhalts ein guter exposure-Wert ermittelt werden kann und dieser dann dafür genutzt wird, ein exposure-sensitives Tone Mapping durchzuführen.

Bloom

Bloom bezeichnet einen Glüh-Effekt von hellen Texturen oder Lichtquellen. Obwohl der Effekt bewusst meist nicht auffällt, verstärkt er das Empfinden der betrachteten Oberfläche als "hell". Beispiele sind Schnee bei Lichteinfall, die Sonne oder der Himmel an sich.

Um den Effekt zu erzeugen wird zunächst eine Helligkeitstextur des aktuellen Bildschirminhaltes erstellt, in der nur die hellen Oberflächen abgebildet sind. Diese Textur wird dann verwischt und etwas unscharf gemacht, um schließlich wieder mit dem ursprünglichen Bildschirminhalt zu verschmelzen. Im Folgenden werden diese einzelnen Schritte detaillierter ausgeführt.

Erstellen der Helligkeitstextur

Zunächst gilt es aus der gerenderten Textur die Helligkeitstextur zu erstellen. In dieser Textur sollen nur Werte über einem bestimmten Helligkeitsschwellenwert liegen, alles andere soll schwarz sein.

Hierbei stellt sich die Frage wie hoch dieser Schwellenwert sein sollte. Die Antwort hängt im Wesentlichen von zwei Faktoren ab:

  • Wie hell ist die Umgebung im Allgemeinen? In einer dunklen Höhle sollte die in der Hand gehaltene Fackel als Lichtquelle erkannt werden, aber im grellen Tageslicht möglicherweise nicht, da der Himmel und die Sonne deutlich heller sind.
  • Wie hoch ist der Schwellenwertfaktor gesetzt? Zusätzlich zum exposure kommt noch ein Schwellenwertfaktor zum Tragen, der eine eine Änderung unter ästhetischen Gesichtspunkten erlaubt. Soll der Schnee bei Tag als "hell" erkannt werden? Oder der Himmel?

Zur Berechnung des eigentlichen Bloom Schwellenwertes wird dann der ermittelte Exposure-Wert mit den angegebenen, festen Schwellenwertfaktor verrechnet.

Zum Erstellen der Helligkeitstextur wird ein neuer Framebuffer angelegt, der als Quelltextur die urprüngliche, in HDR gerenderte Szene bekommt. Im Fragment Shader des Buffers wird dann die Filterung Fragment für Fragment vorgenommen: Alle Fragments, deren Helligkeit nicht über den Schwellenwertfaktor hinauskommen, werden schwarz gefärbt. Alles andere kann direkt in die Zieltextur "passieren". Der Code des Fragment Shaders, in dem die Filterung geschieht, ist im Folgenden angegeben:

 vec3 col = texture(decal_texture, i.frag_texcoord).rgb;

 // transform proper brightness. Values adapt for eye vision, for explanation see:
 // https://en.wikipedia.org/wiki/Luma_%28video%29#Use_of_relative_luminance
 if (dot(col, vec3(0.2126, 0.7152, 0.0722)) > bloom_threshhold) {
     out_color = vec4(col, 1.0);
 } else {
     out_color = vec4(0);
 }

(Zusätzlch zur Filterung geschieht hier noch eine Umrechnung in relative Luminanz, um die spezielle Farb- und Helligkeitswahrnehmung des Auges zu berücksichtigen.)

Hieran zeigt sich im Übrigen auch erneut der Nutzen von HDR: Mit nur 255 möglichen Farbwerten kann es passieren, dass weiße Texturen ungewollt als leuchtend erkannt werden, da der Auswahlbereich für Farbwerte so klein ist. Mit den größeren Farbbereichen von HDR ist das Festlegen eines geeigneten Schwellenwertes deutlich einfacher, da Texturen, die als "hell" erkannt werden sollen, beispielsweise um eine Größenordnung höhere RGB-Werte haben können.

Blurring

Der nächste Schritt ist das Verwischen der Helligkeitstextur, um den Glüh-Effekt zu erzeugen. Da die Textur ohnehin unscharf erscheinen soll und der Verwischungs-Schritt mehrfach ausgeführt wird, bietet es sich an, die Größe der Textur, auf der das blurring geschehen soll, deutlich zu reduzieren. (In der aktuellen Version von Plantex sind Breite und Höhe der Textur halb so groß wie die Fenstergröße.)

Das blurring geschieht in einem Fragment Shader. Für den Unschärfeeffekt wird für jedes Fragment der Farbwert aller anderen Fragments in einer gewissen Umgebung aufaddiert. Da Fragments, die sehr nahe am aktuellen Fragment liegen, einen stärkeren Einfluss haben sollen als die weiter entfernten, muss eine geeignete Wichtungsfunktion gefunden werden.

Hierfür bietet sich die Gaußfunktion an - aber eine Berechnung unter direkter Berücksichtigung aller Fragments innerhalb der Umgebung würde schnell zu rechenintensiv werden. Setzt man als Beschränkung der Umgebung beispielsweise 10 Fragments, dann müssten 100 Fragments gewichtet und auffaddiert werden.

Um dies zu vermeiden wird die Separierbarkeit der Gaußfunktion2 ausgenutzt. Es wird zunächst nur entlang einer Achse (beispielsweise in horizontaler Richtung) verwischt, um anschließend entlang der anderen Achse zu verwischen. Das Resultat ist das gleiche wie bei der oben beschriebenen zweidimensionalen Alternative, aber statt w*h werden nun w+h Schritte durchlaufen (wobei w und h für die Anzahl an berücksichtigten Fragments in horizontaler bzw. vertikaler Richtung beschreiben).

Implementiert wird dies durch "Ping-Pong"-Framebuffer: Die Framebuffer für das horizontale und das vertikale blurring werden (alternierend) wiederholt durchlaufen und haben als Quelltextur die Zieltextur des jeweils anderen.

Für einen besseren Effekt wird der Blur-Schritt wiederholt in horizontaler und vertikaler Richtung durchlaufen. In Plantex werden 8 Fragments um das aktuelle Fragment berücksichtigt.

Die Werte der Gaußfunktion werden jeweils als Konstanten in den Shader geschrieben, da eine Berechnung dort nur unnötig Rechenzeit in Anspruch nehmen würde. Zusätzlich wird ausgenutzt, dass openGL den Farbwert zwischen zwei Fragments schneller interpolieren kann, als dies per Hand im Shader geschehen könnte. Dazu wird als Koordinate des benachbarten Pixels nicht jeweils TexCoords + (1,0), TexCoords + (2,0) usw. benutzt, sondern beide Zugriffe werden zusammengefasst zu cur_position + (1.38461536,0). OpenGL interpoliert dann durch Gewichtung der Abstände zu den nächstmöglichen benachbarten Pixeln selber den durch diesen Zugriff entstandenen Farbwert. Anschließend muss das ermittelte Resultat mit einem Korrekturfaktor multipliziert werden, um den Einfluss der beiden Fragments gemäß der Gaußfunktion wieder zu senken.

Der entsprechende Fragment Shader ist in Auszügen im Folgenden angegeben.

out vec4 FragColor;

in VertexData {
 vec2 frag_texcoord;
} i;

uniform sampler2D image;

uniform bool horizontal; // indicates whether to blur horizontal or vertical
uniform float weight = 0.227027;
uniform float d12 = 1.38461536;
uniform float d34 = 3.23076704;
uniform float k12 = 0.3162162;
uniform float k34 = 0.07027;

void main(){
 vec2 TexCoords = i.frag_texcoord;
 vec2 tex_offset = 1.0 / textureSize(image, 0); // gets size of single texel
 // current fragment's contribution:
 vec3 result = texture(image, TexCoords).rgb * weight;

 if(horizontal) {
  //first fragment pair
  result += texture(image, TexCoords + vec2(tex_offset.x * d12, 0.0)).rgb * k12;
  result += texture(image, TexCoords - vec2(tex_offset.x * d12, 0.0)).rgb * k12;
  //second fragment pair
  result += texture(image, TexCoords + vec2(tex_offset.x * d34, 0.0)).rgb * k34;
  result += texture(image, TexCoords - vec2(tex_offset.x * d34, 0.0)).rgb * k34;
 }

d12 beschreibt den Abstand, der genutzt werden muss, um openGL an der korrekten Stelle interpolieren zu lassen. k12 ist der Korrekturfaktor für das erste Pixelpaar. weight ist das Gaußgewicht des aktuellen Fragments.

Blending

Anschließend muss die beim Blur erzeugte Textur wieder mit dem ursprünglichen Bild verschmolzen werden. Hierfür werden die beiden Texturen fragmentweise aufaddiert, wobei die Bloom-Textur aus ästhetischen Gründen noch gewichtet werden kann. In der resultierenden Textur ist dann jedes von bloom unbeeinflusste Fragment noch genau so hell wie vorher, die hellen Bereiche und ihre Umgebungen aber sind zur hellen Textur hin graduell heller geworden.

Zum Vergleich: Eine Szene mit Bloom...

...und eine ohne.

Eye Adaptation (automatic exposure)

Autor: Tobias Fischer

Wenn ein sehr helles Objekt, wie z.B. eine Sonne auf dem Bildschirm ist, dann muss die Belichtung (Exposure) entsprechend angepasst werden. Wenn dies nicht passiert führt es zu unnatürlichen Bildern, denn wenn unsere Augen sich z.B. an einen schlecht belichteten Raum gewöhnt haben und man dann, an einem sonnigen Tag, aus dem Fenster schaut, dann ist das innere des Raumes, nachdem unsere Augen sich an die neuen Lichtverhältnisse angepasst hat, wesentlich dunkler.

Durchschnittliche Helligkeit herausfinden

Um in der Computergrafik die Belichtung anzupassen muss die durchschnittliche Helligkeit des aktuellen Frames berechnet werden. Um die Helligkeit zu bestimmen haben wir verschiedene Möglichkeiten.

Es ist möglich über jeden Pixel des aktuellen Frames zu iterieren, sich für jeden Pixel die Helligkeit zu merken und am Ende durch die Anzahl der Pixel zu teilen, allerdings würde das sehr viel Rechenleistung kosten und unser Spiel würde sehr langsam laufen.

Es gibt noch eine andere Möglichkeit, und zwar die Originaltextur, die das Programm auf den Bildschirm darstellt, in einer 2x2^xx2x2^x-Textur darzustellen und danach immer in eine Textur bei der x um i verringert wurde, wobei i von 1 bis x läuft. So kommt man am Ende auf eine Textur der Größe 1x1, aus dieser 1 Pixel großen Textur kann die durchschnittliche Helligkeit berechnet werden.

Je höher das x, welches die Texturgröße definiert ist, desto genauer wird der errechnete Helligkeitswert dem tatsächlichem Helligkeitswert auf dem Bildschirm entsprechen.

Nun scheint es naheliegend, dass man eine Texturgröße wählt, die möglichst nah an der Größe, der auf dem Bildschirm dargestellten Textur, liegt zu wählen oder sogar eine größere Textur, um den Informationsverlust möglichst gering zu halten. Dies ist jedoch nicht zu empfehlen, da jeder weitere Textur ein weiteres Frambuffer-Objekt benötigt. Ein Framebuffer ist im Prinzip ein unsichtbarer Bildschirm, der nur im Programm existiert. Auf diesen Frambuffer-Objekten wird alles genauso dargestellt, wie auf einem echten Bildschirm. Daher benötigt jede neue Textur, die wir für die Berechnung der durchschnittlichen Helligkeit benutzen, mehr Rechenleistung und da dieser Vorgang im besten Fall 60 mal pro Sekunde ausgeführt werden soll, muss man einen Kompromiss zwischen dem Verbrauch der Rechenleistung und dem Informationsverlust bei der Berechnung finden.

Wir haben dieses Problem mit Hilfe von mehreren Framebuffer-Objekten gelöst, indem wir das Originalbild erst in eine 512x512 Textur darstellen, dann in 256x256,128x128 usw. bis wir eine 1x1 Textur haben und somit nur einen Pixel, aus dem wir die durchschnittliche Helligkeit berechnen können.

     fn initialize_luminosity(facade: &GlutinFacade) -> Vec<Texture2d> {

      let mut lum = Vec::with_capacity(10);
            for i in 0..9 {
       lum.push(Texture2d::empty_with_format(facade,
                                             UncompressedFloatFormat::F32,
                                             MipmapsOption::NoMipmap,
                                             (2u32).pow((9 - i)),
                                             (2u32).pow((9 - i))).unwrap());
                }
                lum
        }

Um Speicherplatz zu sparen haben wir bei der ersten Verkleinerung der Textur, von der Bildschirmgröße zu unserer 512x512 Textur, die Werte aus dem RGB Vektor in eine Einheit der relativen Luminanz(auch Grayscale genannt) umgerechnet. Somit müssen wir immer nur einen Wert auf die immer kleiner werdenden Texturen darstellen.

     // Creates a greyscale texture with relative luminance.

     out float FragColor;

     in VertexData {
         vec2 frag_texcoord;
     } i;

     uniform sampler2D image;

     void main()
     {
         float t = 10;
         float tt = 1/t;
         FragColor = log((tt + dot(texture(image, i.frag_texcoord).rgb,
             vec3(0.2126, 0.7152, 0.0722))) * t);
     }

Mit dieser Technik machen wir uns die Eigenschaft von OpenGL zu nutze, dass wenn man in einem Fragment-Shader eine Texturkoordinate angibt, die zwischen 2 Pixeln liegt, OpenGL die Pixel interpoliert und in die Zieltextur schreibt. Macht man dies nun so, dass die Texturkoordinate genau zwischen den Eckpunkten von 4 Pixeln liegt, macht OpenGL wieder das Gleiche und interpoliert zwischen diesen 4 Pixeln und schreibt es in die Zieltextur.

        let mut adaption_buffers: Vec<SimpleFrameBuffer> = Vec::with_capacity(10);

        let mut image = &self.lum_relative_tex;

        for i in 0..9 {
            adaption_buffers.push(try!(SimpleFrameBuffer::new(self.context.get_facade(),
                                                              self.lum_texs[i]
                                                                .to_color_attachment())));


            if i != 0 {
                image = &self.lum_texs[i - 1];
            }

            let uniforms = uniform!{
                image: image,
            };

            try!(adaption_buffers[i].draw(&self.quad_vertex_buffer,
                                          &self.quad_index_buffer,
                                          &self.adaption_shrink_program,
                                          &uniforms,
                                          &Default::default()));

        }

Diese Methode ist wesentlich hochperformanter als die iterative Methode, allerdings stellte sich heraus, dass Glium, die OpenGL-Rust-Bibliothek keine Funktion besitzt, um aus einer Textur direkt einen Wert herauszulesen. Es ist nötig über einige Umwege zu gehen um am Ende den RGB-Wert auslesen zu können.

        let buf: Vec<Vec<f32>> = self.lum_texs
            .last()
            .unwrap()
            .main_level()
            .first_layer()
            .into_image(None)
            .unwrap()
            .raw_read(&glium::Rect {
                left: 0,
                bottom: 0,
                width: 1,
                height: 1,
            });

        let avg_luminance = buf[0][0];

Eye Adaption

Unter Eye Adaption versteht man die Anpassung des Auges und die Helligkeit im Gesichtsfeld.

Das menschliche Auge kann eine sehr hohe Bandbreite von verschiedenen Helligkeitsstufen unterscheiden. Dies ist zum Teil durch die Anpassung der Iris an plötzliche Unterschiede in der Helligkeit erreicht (z.B. wenn man aus einem dunklen Raum nach draußen in die sonnen-beleuchtete Außenwelt geht).

Die Anpassung der Iris ist nicht der einzige Vorgang der unsere Augen befähigt sich an so viele unterschiedliche Lichtverhältnisse anzupassen. Eine weitere wichtige Rolle, bei der Anpassung an die gegebenen Lichtverhältnisse, spielen die chemischen Änderungen im Auge.

Dieser Vorgang ist allerdings relativ langsam und braucht seine Zeit, daher sollte er in unserem Programm, nicht sofort auftreten, sondern langsam, mit der Zeit passieren.

Ein weitere wichtiger Aspekt, den es zu beachten gilt ist, dass die Anpassung im Auge unterschiedlich schnell passiert, so wird es z.B. wesentlich länger dauern sich an ein grelles Licht, wie die Sonne zu gewöhnen, wenn man aus der Dunkelheit,wie z.B. einem Kinosaal kommt. Auf der anderen Seite können sich unsere Augen vergleichsweise schnell an eine Änderung von hell zu dunkel gewöhnen, als häufig auftretendes Beispiel aus unserem Spiel "Plantex" ist aus einer dunklen Höhle auf die von der Sonne beleuchtete Oberfläche zu treten.

            let adapt = try!(self.adapt_brightness());
            self.last_lum = adapt;
            if adapt >= self.last_lum {
                self.last_lum = (1.0 - ADAPTION_SPEED_DARK_BRIGHT) * self.last_lum +
                            ADAPTION_SPEED_DARK_BRIGHT * adapt;
            } else {
                self.last_lum = (1.0 - ADAPTION_SPEED_BRIGHT_DARK) * self.last_lum +
                            ADAPTION_SPEED_BRIGHT_DARK * adapt
            }
            self.exposure = (1.0 - WE_WANT_OPTIMAL) * self.last_lum +
                        WE_WANT_OPTIMAL * OPTIMAL_EXPOSURE;

Hier noch 2 Bilder zur Veranschaulichung des Effekts:

Blick von außen auf eine Höhle an einem sonnigen Tag:

Und ein Blick auf die selbe Höhle, aber von innen:

Tone Mapping (Dynamikkompression)

Autor: Dennis Lindner

Tone Mapping beschreibt den Vorgang bei dem HDR-Bilder (Hochkontrastbilder) komprimiert werden, um diese zum Beispiel auf einem Ausgabegerät mit geringerer Farbauflösung darzustellen.

Es existieren viele verschiedene Algorithmen die sich alle in 4 Kategorien unterteilen.

Globale Operatoren: Bei diesen Algorithmen werden die Operationen auf jeden einzelnen Pixel angewandt, wodurch sie sehr Rechen-aufwendig sein können.

Lokale Operatoren: Bei diesen Algorithmen werden die Operationen auf (kleine) Bereiche angewendet. Es wird angenommen das das Menschliche Auge sich nur in kleinen Bereichen an die Helligkeit anpasst. In der Praxis kann dieses Verfahren zu sogenannten Halo-Artefakten führen.

Frequenzbasierte Operatoren: Diese Operatoren ähneln den Lokalen Operatoren. Anders als bei den Lokalen Operatoren werden hier jeweils ein HDR Bild mit niedrigen 'Orts-Frequenzen' und ein LDR Bild mit hohen Frequenzen vereint.

Gradientbasierte Operatoren: Diese Berechnen die Gradienten und schwächen sie ab.

Das Tone Mapping findet in Plantex als letzter Schritt des Postprocessing statt, allerdings bedeutet dies nicht das Tone Mapping immer der letzte Schritt des Postprocessing sein sollte/muss.

Reinhard Tone Mapping

Das Reinhard Tone Mapping verfolgt dasselbe Ziel wie andere Tone Mapping Algorithmen, nämlich das komprimieren von HDR-Bildern (High Dynamic Range images) auf einen Farbbereich der dem gewünschten Ausgabegerät entspricht. Für uns ist dieser Farbbereich der normale RGB Bereich der im Bereich [0,255] oder im Fall von OpenGl [0,1] liegt. Reinhard ist dabei einer der am weitesten bekannten Algorithmen, da es sehr einfach ist diesen durch komplexere Berechnungen zu erweitern. Das Reinhard Tone Mapping gehört zu den Globalen Operatoren. Reinhard erreicht dabei weiche Farben, jedoch werden dabei oft die Schwarztöne verwaschen und es entstehen lineare Kurven am oberen und unteren Ende.

Reinhard in Plantex

Tone Mapping in Plantex findet in einem dafür konzipierten Fragment Shader statt. Bevor wir diesen zeigen, müssen wir zunächst auf den Vertex Shader eingehen, der die Informationen an den Fragment Shader weitergibt.

Vertex Shader

Der Vertex Shader ist ein programmierbarer Teil der Render Pipeline in OpenGl und mapped einen eingabe Vertex auf einen ausgabe Vertex. Vertex Shader Mappen immer im Verhältnis 1:1. Da wir keine Berechnungen ausführen müssen, können wir jedes Vertex ohne Probleme weiterreichen. Daraus ergibt sich folgender Tone Mapping Vertex shader:

#version 330

layout(location = 0) in vec4 in_position;
layout(location = 1) in vec2 in_texcoord;

out VertexData {
    vec2 frag_texcoord;
} o;

void main() {
    o.frag_texcoord = in_texcoord;
    gl_Position = in_position;
}
Fragment Shader

Da wir nun unsere Vertex Shader implementiert haben können wir das eigentliche Tone Mapping in unserem Fragment Shader einbauen. Der Fragment Shader rechnet auf der GPU (Graphic Processing Unit) unseres Rechners, dies bedeutet er erstellt für jeden Pixel unseres "Bildes" ein Thread und dieser führt die Operationen für den jeweiligen Pixel aus.

Dabei lesen wir uns für den aktuellen Pixel die Farbwerte aus und mappen diese auf unsere RGB Werte.

vec3 mapped = vec3(1.0) - exp(-hdr_color * exposure);

Zu sehen ist hier zusätzlich die Tatsache, dass wir ebenfalls einen sogenannten Exposure (siehe Eye Adaption) Wert in unsere Gleichung einbeziehen. Dies ermöglicht uns die Formel je nach Lichtverhältnis der Szene anzupassen.


Bild zeigt die dargestellten Farben bei einer exposure von 0.5. (Rechts Tag, links Nacht)

Graph stellt den Mapping Verlauf der Eingabe Werte auf den RGB Bereich [0,1] dar.


Bild zeigt die dargestellten Farben bei einer exposure von 1.0. (Rechts Tag, links Nacht)

Graph stellt den Mapping Verlauf der Eingabe Werte auf den RGB Bereich [0,1] dar.


Am Ende müssen wir lediglich noch Gamma zu unserer Farbe hinzufügen. Der Grund warum wir das tun müssen beruht auf der Tatsache, dass wir das Inverse des Gamma Wertes des Monitors auf unsere Farben mappen. Sobald der Monitor diese auf dem Bildschirm wieder darstellt, wird er seinen Gamma Wert benutzen um die Farben wider zu verdunklen.

// Gamma correction
mapped = pow(mapped, vec3(gamma));

out_color = vec4(mapped, 1.0);

Filmic Tone Mapping

Das Filmic Tone Mapping behebt Probleme die bei Reinhard entstehen. Darunter fallen die verwaschenen Schwarztöne und die linearen Kurven am oberen und unteren Ende. Dabei arbeitet das Filmic Tone Mapping nach dem Prinzip von Kodak Filmen:Kodak Vision Premier Color Print Film 2393. Mit diesem Prinzip ist es möglich, die "schönen" Farben aus Reinhard zu behalten und zusätzlich noch ein scharfes Schwarz zu erreichen.

Filmic in Plantex

Zu erwähnen wäre, dass das Filmic Tone Mapping in Plantex bei default nicht aktiviert ist. Für die Aktivierung ist lediglich eine Änderung im renderer notwendig.

let tonemapping_program = context.load_program("tonemapping").unwrap();

zu

let tonemapping_program = context.load_program("advancedtonemapping").unwrap();

Der Vertex Shader ist identisch zum Reinhard Tone Mapping (siehe Vertex Shader in Reinhard Tone Mapping), darum werden wir hier nicht weiter darauf eingehen.

Fragment Shader

Bevor wir die Eingangsfarben auf unsere RGB Farben mappen, müssen wir diese an unseren dynamischen Exposure Wert anpassen.

vec3 color = texture(decal_texture, i.frag_texcoord).rgb;
//add Exposure
color *= exposure;
//exposure BIAS
color *= 2.0; //this value is not optimized (try range 0.8 - 2.0)

Der Bias Wert ist hierbei lediglich ein Wert der benutzt und verändert werden kann um die dynamische Reichweite des Tone Mapping anzupassen.

Nachdem wir die Exposure einbezogen haben, können wir nun die Farben mappen. Dafür legen wir eine neue Funktion in unserem Fragment Shader an, dem wir unsere Farben übergeben können.

// filmic tonemapping ala uncharted 2
// note if you use it you have to redo all your lighting
// you can not just switch it back on if your lighting is for
// no dynamic range.
vec3 tonemap(vec3 x)
{
    return (
        (x*(shoulder_strength*x+linear_angle*linear_strength)+toe_strength*toe_numerator)/
        (x*(shoulder_strength*x+linear_strength)+toe_strength*toe_denominator)
        )
        -toe_numerator/toe_denominator;
}

A=ShoulderStrengthA = Shoulder Strength

B=LinearStrengthB = Linear Strength

C=LinearAngleC = Linear Angle

D=ToeStrengthD = Toe Strength

E=ToeNumeratorE = Toe Numerator

F=ToeDenominatorF = Toe Denominator

Nun lassen wir durch dieselbe Funktion einen Parameter namens Linear White laufen, der die kleinste Lumineszenz angibt, die auf 1.0 gemappt wird. Damit können wir unseren Farben einen leicht helleren Farbton geben.

vec3 whitecsale = 1.0/tonemap(vec3(linear_white));
color *= whitecsale;

Szene bei Nacht mit und ohne whitescale (oben ohne und unten mit whitescale)


Szene bei Tag mit und ohne whitescale (rechts ohne und links mit whitescale)


Zum Schluss müssen wir lediglich wieder unser Gamma anpassen.

color = pow(color, vec3(gamma));
frag_output = vec4(color, 1.0);

Die Werte der verwendeten Parameter in Plantex sind:

const float shoulder_strength = 0.15;
const float linear_strength = 0.50;
const float linear_angle = 0.10;
const float toe_strength = 0.20;
const float toe_numerator = 0.02;
const float toe_denominator = 0.30;
const float linear_white = 11.2;

// A gamma value of 2.2 is a default gamma value that
// roughly estimates the average gamma of most displays.
// sRGB color space
const float gamma = 2.2;

1: https://www.noao.edu/education/QLTkit/ACTIVITY_Documents/Safety/LightLevels_outdoor+indoor.pdf , aufgerufen am 9.8.2016. Die Angaben sind in Lux, gibt also die Beleuchtungsstärke (illuminance) an. Dies ist gut vergleichbar mit der für die exposure adaption verwendeten Helligkeit, da diese ebenfalls die Sensitivität des Auges berücksichtigt.

2: Für nähere Informationen hierzu siehe https://en.wikipedia.org/wiki/Gaussian_blur und https://en.wikipedia.org/wiki/Separable_filter .

results matching ""

    No results matching ""