Shader Basics
Da hier schon des öfteren herumgefragt wurde, was genau Shader sind und wie diese funktionieren (bzw. wie man mit denen arbeiten kann), habe ich mich dazu entschieden eine kleine Einführung in die Welt der Grafikprogrammierung zu machen.
Um den folgenden Erklärungen folgen zu können muss man mindestens die Grundkenntnisse der Programmierung besitzen. (z.B: man sollte wissen was eine Variable, ein Array oder eine Funktion ist.)
Jeder der also ein wenig GML programmieren kann, sollte da keine Probleme haben.
Bedenkt eines: Ich bin auf garkeinen Fall ein ProgrammierGuru der sich in diesem Gebiet 100%ig auskennt. Verwendet das hier also nicht als "Quelle" für irgendwelche Arbeiten.
Was sind Shader?
Shader sind im grunde genommen Programme, die beschreiben wie die Daten (wie z.B: Texturen und 3D Modelle) am Bildschirm dargestellt werden sollen.
Diese Shader werden auf der GPU (dem Grafikprozessor > eurer Grafikkarte) ausgeführt.
Man kann deshalb (wenn man von der Shaderprogrammierung spricht) auch von der "Grafikprogrammierung" sprechen.
Shaderunterstützung wurde erst mit GM: Studio 1.3 eingeführt. Davor war es nicht möglich eigene Shader (im GM) zu programmieren.
(Insbesondere die User die mit dem GM 8.1 und darunter gearbeitet waren davon betroffen.)
Aber: Auch wenn es euch nicht bewusst war: Selbst GM 8.1 nutzte im Kern Shader zur Grafikdarstellung.
Das Problem war nur dass man nicht eigene Shader programmieren konnte. Sie waren von anfang an vorgegeben.
Die PS2 hat keinerlei Möglichkeit gehabt auf selbstprogrammierte Shader zurückzugreifen um die grafische Darstellung am Bildschirm zu beeinlussen. Man nennt sowas auch "fixed Shader Pipeline", bei der Anweisungen (wie etwas auf den Bildschirm dargestellt werden soll) bereits "vorprogrammiert" auf der Hardware vorzufinden sind. Das ist so, als ob man nur bestimmte Shader zur Auswahl hätte, diese aber nicht beeinflussen kann. Entwickler mussten tief in die Trickkiste greifen um verschiedene grafische Effekte zu realisieren. Ähnlich wie viele GM User zu GM 8.1 Zeiten und darunter. (Im Bild: Shadow of the Colossus auf der PS2)
Die damalige Xbox hat es erlaubt der GPU anweisungen zu geben, wie etwas am Bildschirm dargestellt werden sollte. (Es gab die Möglichkeit eigene Shader zu entwickeln. > nennt man auch "Custom Shader Pipeline") Viele Spiele nutzten diese Gelegenheit um Effekte wie Reflektionen oder Bump-mapping zu realisieren. Achtet beispielsweise auf die metallischen Reflektionen auf der blauen Rüstung, die Lichtbrechung im Wasser auf der unteren Bildschirmhälfte oder auf die Bump-Mapping Effekte auf der Steinsäule.
(Im Bild: Halo 2 auf der Xbox)
Selbst wenn ihr in eurem GM Studio Projekt kein einziges mal auf einen Shader zurückgreift, schaltet Studio dennoch beim Start des Spiels einen "standard" shader ein, der zur Grafikdarstellung am Bildschirm verwendet wird.
Dieser Shader unterstützt VertexFarben, Texturen/Sprites (je nachdem ob 2D oder 3D) und kann die Objekte an verschiedenen Positionen im Raum darstellen. (xy und z Koordinate.)
Durch die "shader_set()" Anweisung kann man den Shader der gerade verwendet wird durch einen anderen ersetzen.
Welche Arten von Shadern gibt es?
Es gibt mittlerweile verschiedene Arten von Shadern.
Da wären beispielsweise der Pixel Shader, Vertex Shader, Geometry Shader und der Tesselation Shader.
Wir haben in GM: Studio nur auf den Pixel Shader und den Vertex Shader zugriff, weshalb wir die anderen Shader vorerst auslassen werden.
Der Vertex-Shader kümmert sich um die positionierung der Vertexe (und damit Polygone) am Bildschirm.
Ein 3D Modell ist nichts weiter als eine Ansammlung von Koordinaten (Vertexen) die miteinander im Zusammenhang stehen und damit Polygone bilden.
Der Vertex-Shader bestimmt, welcher Vertex an welcher Stelle am Bildschirm platziert wird. Dies wird anhand von verschiedenen Parametern (z.B: Aus der Vertexkoordinate, der Weltmatrix und der Projektionsmatrix) berechnet.
Wenn ihr die virtuelle Kamera beispielsweise nach links bewegt, verschiebt der Shader die Koordinaten des 3D Modells am Bildschirm weiter nach rechts, sodass eine linksbewegung grafisch nachvollziehbar dargestellt wird.
Der Pixel-Shader hingegen kümmert sich um die grafische Darstellung der Polygone. Der Pixel shader berechnet für jeden Pixel am Bildschirm die Farbe die dargestellt werden soll.
Der Pixelshader nimmt beispielsweise die jeweilige Textur die zugewiesen wurde, und befüllt den jeweiligen Pixel am Bildschirm mit dem jeweiligen Teil der Textur.
Wie genau werden Shader programmiert bzw. wie funktionieren sie? (Beispiel)
Wir machen da ein kleines, sehr simples Shaderbeispiel welches ihr selbst ausprobieren könnt.
Erstellt ein neues GM Projekt.
Ladet euch das folgende Bild runter und importiert es in GM:S als Background:
Ihr könnt aber auch jedes beliebige Bild wählen.
Erstellt nun ein neues Objekt und einen neuen Room. Platziert das neue objekt in den Room.
Klickt im Ressource tree mit der rechten Maustaste auf "Shader" und klickt auf "create Shader" um einen neuen Shader zu erstellen.
Ihr werdet im Shader-Fenster auch einige Optionen in Form von Radiobuttons sehen. (GLSL ES, GLSL, HLSL9 und HLSL11)
GLSL ES wird dabei bereits ausgewählt sein. Dies sind verschiedene Shadersprachen die man verwenden kann.
Die Sprachen können sich duch die Syntax als auch durch die verfügbaren Funktionen unterscheiden.
GLSL ES ist dabei die Shadersprache welche auf jeder Plattform ausgeführt werden kann. (HLSL kann z.B: nur auf Windows laufen.)
Deshalb werden wir uns GLSL ES in diesem "Tutorial" näher anschauen.
Der Shader ist in 2 Teile geteilt, den Vertex Shader und den Fragment Shader (auch "Pixel Shader" genannt)
Bevor wir mit der Shadererklärung beginnen, fügt im Draw-Event des einen Objektes noch folgenden Code ein:
Der Vertex Shader
Schauen wir uns mal den Vertex Shader an:
Quellcode
- //
- // Simple passthrough vertex shader
- //
- attribute vec3 in_Position; // (x,y,z)
- //attribute vec3 in_Normal; // (x,y,z) unused in this shader.
- attribute vec4 in_Colour; // (r,g,b,a)
- attribute vec2 in_TextureCoord; // (u,v)
- varying vec2 v_vTexcoord;
- varying vec4 v_vColour;
- void main()
- {
- vec4 object_space_pos = vec4( in_Position.x, in_Position.y, in_Position.z, 1.0);
- gl_Position = gm_Matrices[MATRIX_WORLD_VIEW_PROJECTION] * object_space_pos;
- v_vColour = in_Colour;
- v_vTexcoord = in_TextureCoord;
- }
Nehmen wir mal den ganzen Shader stück für Stück auseinander:
Zu beginn werden Variablen deklariert. Dabei gibt es 3 "Variablenarten" die deklariert werden können:
- attribute
- varying
- uniform
2 von den Arten werden hier bereits verwendet. (nähmlich das "varying" und das "attribute")
Die attribute variablen sind werte, die im 2D bzw. 3D Modell gespeichert sind und an den Shader übergeben werden. z.B: x-/y-/z- Koordinate, vertexfarbe als auch UV koordinate.
(Wichtig: Auch 2D Sprites sind in wahrheit Polygonbasierte modelle!)
Beispiel:
Jeder vertex hat standardmäßig die folgenden Werte:
- xyz Koordinate
- Vertexfarbe
- UV Koordinate (texturkoordinate)
Diese Attribute werden an den Shader als "attribute" variablen übergeben. Die namen "in_Position", "in_Colour" und "in_TextureCoord" sind dabei vorgegeben und dürfen nicht verändert werden.
Die varying variablen, sind Variablen die vom Vertexshader an den Fragmentshader (Pixelshader) übergeben (und interpoliert) werden. (Dazu kommen wir dann beim behandeln des Pixelshaders)
Die GPU verarbeitet nähmlich erst die Vertexe, dannach erst die Pixel. Man kann an den Pixelshader nicht direkt variablen übergeben, die innerhalb der Vertexe gespeichert sind. (wie z.B: die UV-Coordinaten für das Texturemapping). Man muss den Vertex-Shader als "übermittler" verwenden und die Werte (vom Vertex-shader) an den Pixel-shader "senden".
In dem Fall sieht man die variablen "v_vTexcoord" und "v_vColour". Der Pixelshader braucht zur Berechnung der Pixelfarbe am Bildschirm die UV-Koordinaten der gerade verwendeten Textur, als auch die Vertexfarbe.
Diese beiden Informationen kann man nicht einfach aus der Luft greifen. Der Vertexshader muss sie an den Pixelshader übermitteln.
Die Namen der varying variablen sind dabei frei wählbar. (im gegensatz zu den attribute variablen)
Die uniform variablen (die hier nicht vorkommen) werden dazu verwendet um an den Shader im nachinein (z.B: im draw-event) irgendwelche Werte zu übergeben die in den Vertexen nicht vorhanden sind. (sie funktionieren ähnlich wie die "argument0","argument1", etc... variablen in Scripts.)
Wir werden uns später (evtl in einem 2ten tutorial) um diese Kümmern.
zwischen der Variablenart und dem Namen befindet sich eine Typendeklaration > float,vec2,vec3 oder vec4. Diese besagt was für ein Datentyp die Variable ist.
Grafikkarten arbeiten mit vektoren, daher sind die gängigsten Datentypen Vektoren mit einer verschiedenen Anzahl an Dimensionen.
- float ist eine dimension (eine zahl)
- vec2 sind 2 Dimensionen (z.B: xy koordinate)
- vec3 sind 3 Dimensionen (z.B: xy und z)
- vec4 sind 4 Koordinaten (z.B: rgb und alpha)
Man kann durchaus erkennen dass die variable "in_Colour" eine vec4 variable ist, da die Farbe aus rgb und Alpha-Werten besteht während die "in_Position" Variable eine vec3 Variable ist, da die Position im 3Dimensionalem Raum nur die xy und z Koordinate benötigt.
Nun kommen wir zur "main()" Funktion:
Die "main()" Funktion wird für jeden Vertex der dargestellt wird aufgerufen, um die Position am Bildschirm zu bestimmen.
Für jeden Vertex den ihr hier am Bildschirm seht, wird die Funktion 1 mal aufgerufen.
In den ersten 2 Zeilen
wird die Position des jeweiligen Vertexes am Bildschirms berechnet.
Wie man also sehen konnte, ist der default shader also recht einfach gehalten. Er bietet grundsätzlich dieselben Funktionen, wie der Shader den GM beim spielstart verwendet. Euch genau zu erklären wie das mit der Matrixmultiplikation funktioniert kann ich nicht. (Übersteigt mein Wissen und würde das "Tutorial" hier ohnehin sprengen.)
Die "gl_Position" Variable beschreibt dann die finale Position des vertices am Bildschirm.
Wichtig ist aber diese Zeile hier:
Die xyz werte der Variable "in_Position" (welche automatisch vom 2D oder 3D modell an den Shader übergeben wird) wird hier in eine 4Dimensionale Variable namens "object_space_pos" umgewandelt. (die 4te Dimension hat dabei den Wert 1.0).
Wir könnten das ganze objekt jetzt praktisch um den Wert "40" nach rechts verschieben. (das wären 40 Pixel.)
Fügt also bei der x koordinate die zahl 40 hinzu
Wichtig dabei ist, dass ihr bei zahlen IMMER eine Nachkommastelle angebt. Die GPU arbeitet mit Fließkommazahlen und erwartet daher auch welche von euch.
Selbst wenn ihr z.B: nur die zahl "12" irgendwo addieren und subtrahieren wollt, müsst ihr eine ".0" am ende dranhängen. (Sonst wirft euch der Shadercompiler einen error.)
Wenn ihr euer Testprojekt ausführt, sollte das ganze bild um 40 Pixel nach rechts verschoben worden sein:
Dann kommen wir zu den letzten 2 Zeilen:
Hier werden beide Werte der Attribute variablen (in_Colour und in_TextureCoord) an die varying Variablen übergeben, damit diese dann an den FragmentShader (interpoliert) übergeben werden.
Damit springen wir auch gleich zum Pixel-/Fragment-shader.
Der Pixelshader/Fragmentshader
Im gegensatz zum Vertexshader fällt er recht minimalistisch aus:
Auch hier gibt es eine "main()" Funktion. Diese wird diesmal aber für jeden Pixel am Bildschirm aufgerufen.
Mal ein kleiner Denkanstoß: Nehmen wir mal an wir spielen ein Spiel in Full-HD. Dies entspräche einer Auflösung von 1920*1080 Pixel.
Insgesamt wären das also 2073600 Pixel die am Bildschirm existieren würden.
Wenn wir das Spiel in Full-HD auf 60 FPS spielen würden, so hätte die GPU maximal 16,6666667 MILLISEKUNDEN zeit um 2073600 mal die void() Funktion aufzurufen (um ein einziges Frame zu berechnen).
Also, was passiert den nun in dem Shader?
Das erste wäre wieder DIESELBE variablendeklaration wie im Vertex-shader.
Dadurch, dass wir die variablen auf dieselbe Art im Vertex und im Pixelshader deklariert haben, werden die Werte die wir im Vertexshader den beiden Variablen gegeben haben an den Pixelshader automatisch übergeben.
Die variablen sind also im Pixelshader bereits mit Informationen befüllt wenn wir auf sie zugreifen!
Wie genau funktioniert das mit der Interpolation der varyingvariablen die ich im Vertexshader erwähnt habe?
Ihr kennt sicher den effekt, wenn ihr ein Dreieck zeichnet, welches 3 unterschiedliche farben an den jeweiligen Enden besitzt:
Die Farben der Eckpunkte besitzen die Vertexfarben (die v_vColour variable) welche vom Fragmentshader gezeichnet werden.
Nun, man kann deutlich sehen dass die Farben an den Eckpunkten eindeutig sind. Jedoch werden die beim übergang von einer Farbe zur nächsten Interpoliert (man hat einen Übergang von einer Farbe zur nächsten).
Sprich: Wenn der bearbeitete Pixel an derselben Stelle am Bildschirm liegt wie der Vertex nummer 1, dann hat der Pixel zu 100% dieselbe Farbe wie der Vertex nummer 1. Je weiter sich der Pixel dem 2ten Vertex nähert (Vertex nummer 2), desto stärker wird der Wert der v_vColour Variable zur Farbe des 2ten Vertexes umgewandelt. (interpoliert).
Ein Bild dazu
Dies betrifft ALLE varying Variablen!
--------------
In der main Funktion haben wir nun eine einzige Zeile:
Was wird hier gemacht? Nun, gl_FragColor beschreibt die finale farbe des Pixels am Bildschirm.
v_vColour ist ein 4Dimensionaler Vektor (vec4) der die Vertexfarbe beschreibt. (die ist meistens komplett weiß)
Diese Farbe wird mit dem ergebniss der texture2D funktion multipliziert. (diese Funktion liefert die Farbe eines Pixels der jeweiligen Textur die zugewiesen wurde.)
der erste parameter ist "gm_BaseTexture" welcher ein pointer zu der Textur ist.
"gm_BaseTexture" wird dabei vom GM automatisch übergeben. (ihr braucht euch also darum nicht zu kümmern.)
Wenn ihr z.B: draw_sprite() aufruft, dann ist das jeweilige Sprite die gm_BaseTexture, während im 3D Modus beim d3d_model_draw() (z.B: d3d_model_draw(model,x,y,z,texture); ) der letzte Parameter die Textur ist.
Der 2te Parameter "vTexcoord" ist dabei die UV-Koordinate der Textur. (Dadurch weiss der Shader an welcher Stelle auf der Textur er sich befindet.)
Die Farbwerte innerhalb der Shader befinden sich grundsätzlich immer im Bereich 0-1.
Somit wäre die Farbe 1,1,1 (rgb) weiß, während 0,0,0 (rgb) schwarz wäre.
Machen wir mal einen Test und verändern die Zeile:
Dadurch setzen wir die Farbe von jedem Pixel auf Grün.
Beim ausführen des Testprojektes schaut das Bild dann folgendermaßen aus:
Nun machen wir einen anderen Test:
Was macht der Code?
Nun, in der ersten zeile speichern wir das ergebniss der "texture2D()" funktion in einer vec4 variable namens "texturFarbe".
Nun erstellen wir in der 2ten Zeile auch einen 4D Vektor (da die Farbe aus rgba besteht). Aber dort setzen wir den rot-wert und den blau-wert auf 0.
Alpha bleibt 1.0, während nur der Grünwert von der Textur an den Pixel übergeben wird.
Wenn wir einzelne Werte einer Farbe ansprechen wollen, so können wir (wie beim GM) einfach über einen punkt auf den jeweiligen Wert zugreifen.
Beispiele:
Wie schaut das Testbeispiel dann aus wenn wir es ausführen?
Denselben Effekt kann man auch auf eine andere Art erzielen:
Was machen wir hier? Nunja, texture2D gibt uns ja den Pixel auf der Textur als 4D vektor zurück (rgba).
Wir wollen aber nur den Grünanteil auf den Pixel sehen.
Wir können dabei Vektoren miteinander multiplizieren.
Wenn wir 2 Vektoren miteinander multiplizieren, so werden die jeweiligen Elemente der Vektoren miteinander multipliziert.
Stellt euch einen 4D Vektor als ein Array mit 4 Elementen vor:
Quellcode
Eine multiplikation beider vektoren würde dann so ausschauen:
Schauen wir uns nochmal die Zeile an:
Der Rot-Wert des Pixels auf der Textur wird mit 0 multipliziert > ergibt 0.
Der Grün-Wert wird mit 1 multipliziert. > 1*x ergibt x (x = der jeweilige Grünwert auf der Textur) daher ändert er sich nicht.
Der Blau-Wert wird mit 0 multipliziert, daher ergibt er 0.
Der Alphawert wird mit 1 multipliziert.
Wieso sind die GPUs so schnell?
Wenn man mit einer CPU jeden Vertex eines Modells verarbeiten wollen würde, ginge das nur wenn man mit z.B:
einer for-schleife durch alle vertexe bzw. pixel durchgeht und jeden einzeln nacheinander berechnen würde.
Das ist aber viel zu langsam. Wie gesagt, CPUs verarbeiten alles sequenziell. Es kann somit immer nur 1 vertex bzw. 1 Pixel einzeln bearbeitet werden. (Bei einem Quad core, der 4 Threads ausführt, währen dann z.B: 4 Pixel bzw 4 Vertexe gleichzeitig möglich wenn man Multithreading nutzen würde.)
GPUs besitzen im gegensatz zur CPUs sehr viele kleine "Recheneinheiten" die die Vertex bzw. Pixelkalkulationen machen können. (Man könnte grob sagen sie haben tausende kleinerer Kerne die zeitgleich Rechenoperationen durchführen können.)
Links, der Aufbau einer CPU (in dem Fall einer Quad-Core CPU), Rechts der Aufbau einer GPU. Die grünen Flächen sind die Rechenkerne die diese Berechnungen machen können.
GPUs können viele Vertexe/Pixel parallel verarbeiten. Das ist auch der Grund wieso sie selbst (bei entsprechender Leistung) mit z.B: 2073600 Pixeln unter 17 Millisekunden fertig werden können.
Ich hoffe ich konnte euch einen Einblick in Shaderprogrammierung gewähren. Es gab zwar nicht viel Praxis, jedoch hoffe ich dass ihr mit dieser Erklärung wenigstens die funktionsweise der Shader verstanden habt.^^
Wenn ich Zeit haben sollte, könnte ich eine Fortsetzung von Shadern machen. (z.B: Uniform variablen, custom vertex buffer, Vertexshader animationen, etc...)
Ich entschuldige mich im voraus für eventuelle Rechtschreib und Grammatikfehler. Hab das Tutorial jetzt nur innerhalb eines Abends geschrieben. Fehler sind also nicht ausgeschlossen.^^'
Dieser Beitrag wurde bereits 50 mal editiert, zuletzt von LEWA ()