Donnerstag, 21. Juni 2012

Bound

Was ist Bound?

Bound ist ein erzählendes 2D-Jump'n'Run, das ich zusammen mit einigen Kollegen als Studienprojekt begonnen habe und nun als Hobbyprojekt weiterführe. Da es in den kommenden Posts für das ein oder andere Beispiel herhalten wird, ist es vielleicht schön mal ein Bild davon zu sehen.



Gemacht haben wir es mit XNA 4.0, verwenden Farseer Physics, Mercury Particles, Irrklang Audio und ... nein, das wars glaub ich. Die Engine haben wir von Grund auf selbst gebaut. Wir haben Animationsklassen, Physik-Objekte, ein Eventsystem, ein generisches Layer-System, haufenweise Effekte, Gamestate-Funktionalitäten... uuund einen überkrassen Editor, mit dem wir Level bauen und Spielabläufe skripten können (sc ist der King!). Unserer Kreativabteilung belieferte uns mit schicken Grafiken, Storyboards und schönen Rendersequenzen, die die Geschichte des Spiels erzählen. Unsere letzten Errungenschaften sind einige Effekte, wie die Godrays, die im Bild zu sehen sind und deren Implementierung ich wahrscheinlich im nächsten Post vorstellen werde, ein Lua-Hook zum Einbinden von Skripten.. und überhaupt ist immer alles im Umbruch und noch mehr im Kommen.

Spielen tut man übrigens die niedliche kleine Spinne, die ihr im Bild seht. Die Animationen sind der Hammer, vielleicht gibts irgendwann mal ein Video.

Dienstag, 19. Juni 2012

Einfache SSIL-Implementierung (XNA 4.0)

Wozu SSIL?

Mein vorheriger Post zeigte SSAO als eine einfache Methode zur Bildverbesserung für euer Videospiel und sollte vor diesem hier gelesen werden. Auf Screen Space Indirect Lightning bin ich hier gestoßen - vorher habe ich davon auch noch nie etwas gehört. Aber die Idee ist nicht schlecht: Wenn wir beim SSAO sowieso für Bildpunkte Strahlen in zufällige Richtungen schicken und Tiefenwerte umliegender Pixel zur Verdeckung anschauen, wieso schauen wir dann nicht gleich noch, wie sie sich bezüglich Reflexion von Licht verhalten? Und das ist genau was passiert: Es werden Bildpunkte um einen Pixel gesucht und zusätzlich zum SSAO deren Albedo (Rückstrahlvermögen) betrachtet, um zusätzliches indirektes Licht auf den Bildpunkt zu übertragen wenn die Verdeckung vorhanden ist. So etwas wie indirektes Licht kennt ein lokales Beleuchtungsmodell nämlich nicht.

Schritt 1: Änderungen am SSAO-Shader

Der SSAO-Shader beinhaltet schon fast alles von dem, was wir brauchen. Zu Color-, Depth-, Normal- und Noise-Werten könnten wir noch den Licht-Wert brauchen. Ich habe hier den Vorteil, dass ich eine Deferred Rendering Enginge verwende, da habe ich den Licht-Wert sowieso in einem seperaten Rendertarget und er steht mir als Textur zur Verfügung. Im ersten Schritt kommt also hinzu:

float3 light = tex2D(lightSampler,input.TexCoord).rgb;

Vor der Schleife definieren wir noch eine Variable, wo wir die Information des indirekten Lichts speichern.

float3 resultRadiosity = 0;

In der Schleife kommen vier Zeilen Code hinzu. Zusätzlich zu den Fragmentkoordinaten brauchen wir noch den Lichtwert des verdeckenden Pixels und dessen Albedowert. Die Intensität der Rückstrahlung ist der Farbwert (Vektor), den wir mit Punktprodukt auf einen Skalar abbilden. Der Farbwert des Samples wird je nach Intensität (also Rückstrahlvermögen) auf den ursprünglichen Bildpunkt übertragen. 

float3 occluderLight = tex2D(lightSampler,se.xy);
float3 occluderAlbedo = tex2D(colorSampler,se.xy).rgb;
float intensity = dot(occluderAlbedo,1);

resultRadiosity += step(falloff,depthDifference)*normDiff*(smoothstep(color,occluderAlbedo,intensity));

Anschließend muss die Radiosity noch durch die Anzahl der Samples geteilt werden.

Bei der Betrachtung des Bildes ist mir aufgefallen, dass nun zwar Flächen indirekte Beleuchtung abbekommen, die Lichtfarbe dabei aber nicht berücksichtigt wird. Normalerweise reflektiert ein weißer Pixel, der mit rotem Licht beschienen aber kein weißes indirektes Licht, sondern leicht rötliches Zum Farbwert sollte also noch der Verdecker-Lichtwert (+occluderLight hinter der Klammer) dazurechnen werden. Ich habs bei mir mal so gemacht, die Szene wird dadurch aber merklich heller, das sollte man beachten. Eventuell muss man hier den Alphakanal hinzuziehen, und Bild und indirektes Licht hernach mit Alphablending zusammenführen, oder eine Abschwächung durchführen.

resultRadiosity += step(falloff,depthDifference)*normDiff*(smoothstep(color,occluderAlbedo,intensity)) + occluderLight;

Schritt 2: Fertigmachen

Eigentlich sind wir bereits fertig, da wir für jeden Pixel die Radiosity haben und diese einfach zurückgeben und über das Bild legen können. Jemand, der auch XNA 4.0 verwendet und damit auf SM 3.0 beschränkt ist, wird aber merken, dass die Samplerate sehr niedrig gestellt werden muss, damit er nicht über die Zahl der erlaubten Instruktionen kommt. Besonders da man SSAO- und SSIL-Anweisungen in einem Shader hat, hat man nicht viele Instruktionen übrig. Ich hab zu Demozwecken dafür kurz den SSIL-Shader ausgelagert und 13 Samples eingestellt. Das Ergebnis sieht folgendermaßen aus.

SSIL, kein Blur


Da die Körnung im finalen Bild erkennbar ist, wende ich wie beim SSAO einen Blur an. Lässt sich gut kombinieren. Das Ergebnis sieht schon ein wenig besser aus.

SSIL, mit Blur


Eventuell kann man noch etwas geschickter Normalen- und Tiefenwertvergleich einsetzen, sodass man ein besseres Ergebnis erhält. Ich wüsste im Augenblick aber nicht wie.

Der Vergleich zwischen dem Endergebnis mit und ohne SSIL seht ihr hier. Besonders gut sichtbar ist das Ergebnis am Bauch der Echse, wo ohne SSIL so gut wie überhaupt kein Licht hinkommen würde. Oder an der Unterseite des Raumschiffs. Die Lichtwirkung ist im untersten Bild vielleicht etwas heftig, vielleicht sollte man dies händisch abschwächen.

Bild ohne SSIL
Bild mit SSIL (nur Texturfarbe)
Bild mit SSIL (Texturfarbe und Lichtfarbe)
Bild mit SSIL (Texturfarbe und Lichtfarbe, Wirkung 25%)

Jetzt wo die Helligkeit nicht mehr all zu übertrieben ist, bleibt noch ein letzter Versuch, der allerdings wieder einige Instruktionen beansprucht: Anstatt die Intensität nur am Farbwert des Samples auszumachen, addieren wir noch dot(occluderLight,1), um die Intensität vom einfallenden Licht abhängig zu machen. Wo also wenig Licht hinfällt, wird auch wenig zurückgestrahlt, egal ob die Fläche jetzt sehr hell ist (und somit ohne Berücksichtigung ein hohes Rückstrahlvermögen hätte). Das Ergebnis ist das folgende. 

Bild mit SSIL (Texturfarbe und Lichtfarbe, Wirkung 25%), Rückstrahlvermögen berücksichtigt einfallendes Licht




Auch hier: Flächen an den Unterseiten kriegen normalerweise fast garkein Licht ab, mit SSIL das Umgebungslicht. Die Unterkante des Steins ist ebenfalls merklich anders beleuchtet. Ein sichtbarer Unterschied auch bei dem Pinüppel, dessen Unterseide nun nicht pechschwarz ist.

Bild mit SSIL (Texturfarbe und Lichtfarbe), Intensität berücksichtigt kein einfallendes Licht, keine Abschwächung

Bild ohne SSIL

Beurteilung und Einschränkungen

Ich will nicht erneut auf der hohen Instruktionszahl rumreiten, aber gerade wenn man zusätzliches Feintuning, wie die Intensität vom Lichtwert abhängig zu machen, reinbringt, bleibt nicht mehr viel übrig. Andere Einschränkungen dieser Implementierung sind, dass das indirekte Licht mit nur einem Lichtabprall berücksichtigt wird. Es wird also nur einmal reflektiert - und das funktioniert mit wenigen Samples auch nur dann gut, wenn der Radius der Strahlen für die Samples relativ klein bleibt. Außerdem wird nur berücksichtigt, was auf dem Screen zu sehen ist. Ist etwas, das stark abstrahlen würde, knapp aus dem Bild, ist dem Shader das völlig egal. Ist auch nichts dran zu ändern.

Wenn euch noch etwas auf- oder einfällt, wo ich vielleicht Denkfehler habe oder man Performance rausholen könnte, immer raus damit! Ansonsten gefällt mir das ganze schon ganz gut und ich werde weiter versuchen die Lichtfarbe etwas stärker mit in die Wirkung aufzunehmen.
Danke an alle, die irgendwas zum Thema online gestellt haben - wie immer gilt: Sie haben mich bestimmt beeinflusst.

Freitag, 15. Juni 2012

Einfache SSAO-Implementierung (XNA 4.0)

Wozu SSAO?

Normalerweise gibt es in Spielen nur lokale Beleuchtung, das heißt Licht wird für einzelne Punkte von Flächen ausgewertet. Dabei ist nur wichtig, ob ein Strahl einen Punkt trifft, so etwas wie eine Verdeckung, also eine Wechselwirkung von Flächen und Punkten untereinander spielt für dieses Modell keine Rolle. Wenn man aber mal in die echte Welt schaut, ist die Beleuchtung um einen Gegenstand, der auf einer ebenen Fläche steht und Beleuchtet wird nicht sauber, sondern in der nähe des Gegenstandes stellenweise dunkler. Mal ganz abgesehn davon, dass Flächen selten wirklich glatt sind - die meisten Materialien sind rau und uneben. Screen Space Ambient Occlusion ist deswegen aus vielen Gründen eine richtig feine Sache: Erstens ist es so einfach, dass sogar ich es ansatzweise verstanden habe (hoffentlich) und zweitens wertet es das Gesamtbild eines Videospiels als Pixelshader merklich auf, ohne dass die restliche Architektur des Spiels groß oder überhaupt angefasst werden muss - es funktioniert also mit fast jedem Spiel. Der dritte Grund ist, dass der Shader eventuell sogar noch um indirekte Beleuchtung (SSIL) erweitert werden kann, dazu aber in einem späteren Post mehr.

Wer verstehen möchte, wie man SSAO implementieren kann, der sollte Schritt für Schritt den Post lesen und versuchen den Code zu verstehen. Die Kommentare sollten dies einfach machen. Wie bei allem heutzutage gilt: Angucken und Können ist nicht - es braucht ein bisschen Zeit und Konzentration um zu verstehen und Übung um es zu implementieren. Wer von Computergrafik absolut keine Ahnung hat, der braucht nicht weiterlesen, weil Begriffe fallen werden, mit denen er nichts anfangen kann. Wer schon mal einen Shader geschrieben hat und ansatzweise versteht worum es geht, der kann mit den folgenden Absätzen sicherlich etwas anfangen.

Wie funktionierts?

Ich steh nicht so auf pure Theorie, daher ist meine Umgebung abgesteckt: XNA 4.0 und HLSL, weil ich damit grade n Projekt machen "musste". Wir brauchen sonst nicht viel, um SSAO mit HLSL zu implementieren: Den Depthbuffer, die Colormap und sowas wie ne Noisemap. Außerdem integrieren wir unsere Normalen. Das wars schon. Meine Implementierung basiert übrigens auf mehreren Internetblogs, zum Beispiel diesem hier. Grob gesprochen nehmen wir jeden Pixel des Bildes und betrachten den Tiefenwert der umliegenden, nahen Pixel, die wir in einer Halbkugel um die Normale des Pixels zufällig auswählen. Sind diese weiter vorne als der Pixel, ist davon auszugehen, dass der ursprüngliche Pixel räumlich hinter ihnen liegt, also von ihnen verdeckt wird. Je nach dem, wie groß die Strecke zwischen dem aktuellen Pixel und einem umliegenden ist, kann man hier eine Verdeckung sichtbar machen.

Schritt 1: Samples holen, Zufall schaffen

Es kommt natürlich ein bisschen darauf an, wie ihr euer Spiel baut - ich habe eine Deferred Rendering Engine aus irgendeinem XNA-Tuto nachgebaut und liefere meine Maps als Texturen an meine Posteffekt-Shader.

float depth = tex2D(depthSampler,input.TexCoord).r;
float3 color = tex2D(colorSampler,input.TexCoord).rgb;
float3 noise = tex2D(noiseSampler,input.TexCoord).rgb;
float3 norm = tex2D(normalSampler,input.TexCoord).rgb;

Jetzt brauchen wir noch so etwas wie einen Zufall. Ich habe hier ein paar Random-Vektoren in ner Kugel mit 1er Durchmesser.

float3 pSphere[16] = {float3(0.53812504, 0.18565957, -0.43192),float3(0.13790712, 0.24864247, 0.44301823),float3(0.33715037, 0.56794053, -0.005789503),float3(-0.6999805, -0.04511441, -0.0019965635),float3(0.06896307, -0.15983082, -0.85477847),float3(0.056099437, 0.006954967, -0.1843352),float3(-0.014653638, 0.14027752, 0.0762037),float3(0.010019933, -0.1924225, -0.034443386),float3(-0.35775623, -0.5301969, -0.43581226),float3(-0.3169221, 0.106360726, 0.015860917),float3(0.010350345, -0.58698344, 0.0046293875),float3(-0.08972908, -0.49408212, 0.3287904),float3(0.7119986, -0.0154690035, -0.09183723),float3(-0.053382345, 0.059675813, -0.5411899),float3(0.035267662, -0.063188605, 0.54602677),float3(-0.47761092, 0.2847911, -0.0271716)};

Als nächstes generieren wir Normalen, die wir später zur Reflektion verwenden. Hier kommt die Noisemap zum Einsatz. Für jeden Bildpunkt kriegen wir also eine zufällige Normale. Das Noise Sample kann noch ein zusätzliches Offset kriegen.

float3 fres = normalize(noise*2) - float3(1.0, 1.0, 1.0);

Meine Quelle speichert sich Fragmentkoordinaten etwas komfortabler ab, daher mach ich das mal nach. Was wir dann in einer Variable haben sind die UV-Screenkoordinaten, also Pixel nach x und y und den Tiefenwert im Bildschirmplatz...den Depthbuffer.

float3 ep = float3(input.TexCoord,currentPixelDepth);

Außerdem macht er den Radius wo wir samplen von der tatsächlichen Tiefe abhängig. Je größer die Tiefe, desto größer der Radius (glaube ich :) ). Wahrscheinlich, damit man einen stärkeren Effekt im Hintergrund hat, der ist ansonsten schlecht wahrnehmbar. Für rad wählt meine Quelle den Wert 0.006. Kann man machen, rumspielen hilft.

float radD = rad/currentPixelDepth;

Schritt 2: Die Schleife

Nun müssen wir für jeden Pixel des Bildes einige Schritte durchführen. Bevor wir die Schleife schreiben, definieren wir mal unsere benötigten Variablen und die Variable, wo wir das Ergebnis speichern.

float bl = 0.0f;
float occluderDepth, depthDifference, normDiff;

Wie oft die Schleife durchlaufen wird, muss man selbst entscheiden. Je mehr desto besser ist die Qualität des Effekts. Bei mehr als 16 Samples braucht man logisccherweise mehr Zufallsvektoren in pSphere.

for(int i=0; i<9;++i)
   {
      // Einen Vektor in der Kugel holen und reflectieren (mit dem Noise-Vektor)
      float3 ray = radD*reflect(pSphere[i],fres);

      // Wenn der Strahl aus der Halbkugel rausgeht, wird Richtung geändert
      float3 se = ep + sign(dot(ray,norm) )*ray;

      // Tiefenwert des verdeckenden Bildpunktes holen
      float3 occluderFragment = tex2D(depthSampler,se.xy);

      // Normale des verdeckenden Bildpunktes holen
      float3 occNorm = tex2D(normalSampler,se.xy).rgb;

      // Wenn die Diff der beiden Punkte negativ ist, ist der Verdecker hinter dem aktuellen Bildpunkt
      depthDifference = currentPixelDepth-occluderFragment.r;

      // Berechne die Differenz der beiden Normalen als ein Gewicht
      normDiff = (1.0-dot(occNorm,norm));

      // the falloff equation, starts at falloff and is kind of 1/x^2 falling
      bl += step(falloff,depthDifference)*normDiff*(1.0-smoothstep(falloff,strength,depthDifference));
   }

Der letzte Schritt dient, um den Verdeckungswert weich abfallen zu lassen. Wer genauer wissen möchte, was passiert, kann hier schauen. Für falloff wählt meine Quelle den Wert 0.000002. Hab ich genauso.

Schritt 3: Fertigmachen

Nach der Schleife wird der Verdeckungswert nochmal mit einem Steuerwert für die Stärke multipliziert und durch die Samples geteilt. strength ist bei "uns" 0.07, totStrength 1.38. Prinzipiell würde es reichen, wenn wir einen float zurückgeben, aber ich hab den Effekt später noch für was anderes nutzen wollen, daher is mein Rückgabewert etwas verschwenderisch.

float ao = 1.0-totStrength*bl*invSamples;
return float4(ao,ao,ao,ao);

Das war ja einfach

Das wars auch schon. Wieviele Zeilen Code waren das? 15? Diese Technik ist nicht völlig frei von Fehlern. Wenn man ein bisschen zu viel am Radius spielt, kriegt man seltsame Ergebnisse und die Nachteile des Viewspace (nur was auf dem Bildschirm sichtbar ist, wird beachtet...) lassen sich auch nicht verneinen. Ich hab bei zu großen Unterschieden bei den Tiefenwerten (?) ein hässliches Kantenbluten gehabt, das ich auch mit einem zusätzlichen Blur-Pass nicht wegbekommen habe (in den Bildern unten erkennbar). In XNA 4.0 hat man außerdem nur Shadermodel 3.0, das bedeutet limitierte Instruktionszahlen. Gerade wenn man den Effekt noch um indirekte Beleuchtung erweitern will, macht sich das sehr schnell bemerkbar. Ein zusätzlicher Blurpass sollte also fast schon eingeplant werden, wenn man SSAO irgendwie ins XNA bringen will. Ich habe hier jetzt keinen, das Ergebnis kann sich trotzdem sehen lassen:

mit SSAO

ohne SSAO

SSAO

Von meiner Seite aus ein allgemeines Danke an alle Leute bei Nvidia, in den Blogs, meine Quelle und auch sonst jeden, der Informationen zum Thema ins Internet gestellt hat, denn er hat diesen Post mit Sicherheit irgendwie beeinflusst.

Viel Spaß beim Nachmachen, für einen der kommenden Posts habe ich noch eine kleine, aber effektive Erweiterung des Shaders. Wenn irgendwas hier falsch oder zu ungenau ist, kommentiert einfach, die Chance ist hoch, dass ich mich Kommentaren annehme :)