Előadás Home Introduction Environment setup Best homeworks

Shaders

A shader-ek kis programok, melyek a grafikus pipeline specifikus részeihez köthetők és a GPU processzorain futtathatjuk őket. A shader feladata, hogy az adott input adatot a megfelelő formájú output adattá transzformálja, továbbá a shader-ek kizárólag az input-output változókon keresztül kommunikálhatnak egymással és a "külvilággal".

GLSL

A GLSL egy C típusú nyelv, mely kifejezetten grafikai feladatokra lett kifejlesztve, továbbá specifikus vektor, mátrix műveleteket is támogat.

Egy shader mindig a használni kívánt OpenGL verzió definiálásával kezdődik, majd meg kell adni a használandó input (in) és output (out) attribútumukat és az esetleges uniform változókat. Minden shader belépési pontja egy main függvény, ahol feldolgozzuk az input változókat és az eredményeket a megfelelő output változóba töltjük.

Egy tipikus shader szignatúra:

In [ ]:
#version version_number
in type in_variable_name;
in type in_variable_name;

out type out_variable_name;
  
uniform type uniform_name;
  
void main() {
  // process input(s) and do some graphics stuff
  ...
  // output processed stuff to output variable
  out_variable_name = processed_data;
}
A vertex shader esetén az input változókat vertex attribútum-nak nevezzük. A definiálható vertex attribútumok száma maximalizálva van az adott hardware által. Az OpenGL garantálja, hogy legalább 16 db 4 komponensű vertex attribútum mindig használható, de egyes hardware-ek ennél többet is engedhetnek, melynek a számát a GL_MAX_VERTEX_ATTRIBS konstanssal kérdezhetjük le:
In [ ]:
int nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;

Types

A GLSL rendelkezik a C nyelvben is ismert legtöbb típussal: int, float, double, uint és bool. Továbbá a GLSL tartalmaz két beépített container osztályt is: vector és matrix.

Vectors

A vektort a GLSL-ben 1,2,3 vagy 4 komponensű container-ként definiálhatjuk, ahol a komponensek az említett C típusok lehetnek. A következő vektor típusokat használhatjuk (n a komponensek száma):

  • vecn: alapértelmezett vektor n db float értékkel
  • bvecn: bool vektor
  • ivecn: integer vektor
  • uvecn: unsigned integer vektor
  • dvecn: double vektor

Egy vektor komponenseit a következő képpen lehet elérni: vec.x, ahol az x a vektor első komponense. Hasonlóan érhetjük el az y, z és a w komponenseket. A GLSL lehetővé teszi az rgba használatát a color információhoz, továbbá a stpq-t a textúra koordináták eléréséhez.

A vektor típus lehetővé teszi az egyes komponensek rugalmas kezelését és elérését:

In [ ]:
vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;
Egy új vektor létrehozásához az engedélyezett 4 betűt (komponenst) tettszőleges formában használhatjuk, amíg az eredeti vektor rendelkezik azokkal. (Egy két elemű vektornak vec2 nem lehet elérni a z komponensét.) Továbbá egy vektor átadhatunk egy másik vektor konstruktorába, ezzel csökkentve a kód redundanciát.
In [ ]:
vec2 vect = vec2(0.5, 0.7);
vec4 result = vec4(vect, 0.0, 0.0);
vec4 otherResult = vec4(result.xyz, 1.0);

A vektorokat használhatjuk az egyes shader-ek input és output változóikként. A mátrix osztály használatáról a későbbi tutorialok során lesz szó.


In és Out

A GLSL-ben az in és out kulcsszavakkal definiálhatjuk az egyes shader-ek input és output változóit, melyen keresztül a shader programok kommunikálhatnak egymással és a pipeline többi részével. Ha egy shader output változója megegyezik (a nevük) a következő shader input változójával, akkor adatot küldhetünk rajtuk keresztül. A vertex és a fragment shader azonban egy kicsit különbözik. A vertex shader-nek fogadnia kell néhány input változót, mert azok nélkül (pl. magába a shader-be kódolva) nagyon hatékonytalan lenne a működése. A vertex shader direkten a vertex data-ból fogadja az inputokat. Ahhoz, hogy meghatározzuk, hogy a vertex data, hogy van reprezentálva, az input változóknál definiáljuk a location meta adatot, vagyis konfiguráljuk a vertex attribútumkat a CPU-n. AZ előző tutorialban már láttunk erre pédát: layout (location = 0).
Note: Az OpenGL glGetAttribLocation függvény segítségével le tudjuk kérdezni a lokációját egy adott attribútumnak, így ki lehetne hagyni a layout (location = 0) deklarációkat, de az egyértelműség kedvéért és hogy az OpenGL ne végezzen annyi feladatot érdemes ezeket manuálisan beállítani.

A másik különbség, hogy a fragment shader esetén definiálni kell egy vec4 típusú color output változót, mert a fragment shader célja, hogy meghatározza a végső színt. Ha nem definiáljuk ezt az output color-t, akkor az OpenGL fekete vagy fehér színnel fogja megjeleníteni az objektumokat.

Tehát, ha egy shader-ből egy másikba adatot szeretnénk küldeni, akkor a küldő shader-ben deklarálnunk kell az output változókat, a fogadó shader-ben pedig létre kell hoznunk az input változókat. Ha a nevek és a típusok egyeznek, akkor az OpenGL össze kapcsolja (link) a megfelelő változókat (ez amikor egy programhoz csatoljuk a shader-eket), hogy adatot lehessen közöttük küldeni.

Az előző tutorial kódját fogjuk módosítani úgy, hogy a vertex shader határozza meg a színtm amit továbbít a fragment shader-nek.

Vertex shader

In [ ]:
#version 400 core
layout (location = 0) in vec3 aPos; // the position variable has attribute position 0
out vec4 vertexColor; // specify a color output to the fragment shader

void main() {
    gl_Position = vec4(aPos, 1.0); // see how we directly give a vec3 to vec4's constructor
    vertexColor = vec4(0.0, 0.0, 1.0, 1.0); // set the output variable to blue color
}

Fragment shader

In [ ]:
out vec4 FragColor;
in vec4 vertexColor; // the input variable from the vertex shader (same name and same type)  

void main() {
    FragColor = vertexColor;
}
A vertex shader-ben deklaráltunk egy vec4 típusú output változót (vertexColor) névvel és deklaráltunk egy azonos nevű és típusú input változót a fragment shader-ben. Mivel ugyanaz a nevük és a típusuk, ezért az OpenGL összekapcsolja (link) őket. Mivel a színt kékre állítottuk a vertex shader-ben, ezért a fragment shader erre a színre fogja beállítani az output FragColor változót:

title


Uniforms

A uniform változók segítségével szintén tudunk adatot küldeni a CPU-ról a GPU-n futó shader programoknak. A uniform változók globális változók, ami azt jelenti, hogy a uniform változók egyediek egy shader program objektumon belül és a grafikus pipeline minden shader programjában el lehet érni őket. Továbbá a uniform változók mindaddig megtartják a beállított értéküket, amíg nem frissítjük vagy töröljük azt.
In [ ]:
#version 400 core
out vec4 FragColor;
  
uniform vec4 ourColor; // we set this variable in the OpenGL code.

void main() {
    FragColor = ourColor;
}
A fragment shader-ben deklaráltunk egy vec4 típusú uniform változót és a fragment output változóját beállítjuk erre a uniform változóra. Mivel a uniform változók globálisak, ezért a pipeline bármelyik shader programában definiálhatjuk őket, sőt magában a fő applikációban is megtehetjük. Mivel ezt a uniform változót nem használjuk a vertex shader-ben ezért a main függvényben fogjuk definiálni.

Note: Azokat a uniform változókat, amiket sehol nem használjuk a GLSL kódban, a fordító eltávolítja, ami később problémát okozhat.

A uniform változót most a main függvényben fogjuk definiálni. Először meg kell határoznunk a shader-ben használt uniform változó indexét/lokációját, majd utána frissíthetjük az értékét. Ahelyett, hogy egy konstans színt állítanánk be, folyamatosan frissítjük azt az egyes iterációk alatt:

In [ ]:
float timeValue = glfwGetTime();
float blueValue = (sin(timeValue) / 2.0f) + 0.5f;
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, 0.0f, blueValue, 1.0f);
A glfwGetTime segítségével lekérdezzük a window létrehozása óta eltelt időt másodpercben. Majd a sin függvény segítségével az aktuális idő érték alapján változtatjuk a színt a 0.0 és 1.0 tartomány között. A glGetUniformLocation függvénnyel lekérdezzük a uniform lokációját (ha -1 a visszatérési érték, akkor nem találta meg az adott uniform változót), végül beállítjuk a uniform értékét a glUniform4f függvény segítségével a kék szín egy megfelelő árnyalatának.

Note: Mivel az OpenGL Core library C nyelv alapú, ezért nem támogatja natívan a túlterhelést. Ezért minden típushoz külön függvényt kell definiálni, így a glUniform függvénynél is az utolsó karakterek jelölik a típust:
  • f: float típus
  • i: int típus
  • ui: unsigned int típus
  • 3f: 3 db float típus
  • fv: float vektor

A mi esetünkben 4 db float értéket kell beállítanunk a uniform változónak ezért a glUniform4f verziót használjuk. A render loop-ban minden egyes iterációban kiszámoljuk a háromszög aktuális színét és frissítjük a uniform változón keresztül:

In [ ]:
while(!glfwWindowShouldClose(window)) {
    // input
    processInput(window);

    // render
    // clear the colorbuffer
    glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    // be sure to activate the shader
    glUseProgram(shaderProgram);
  
    // update the uniform color
    float timeValue = glfwGetTime();
    float blueValue = sin(timeValue) / 2.0f + 0.5f;
    int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
    glUniform4f(vertexColorLocation, 0.0f, 0.0f, blueValue, 1.0f);

    // now render the triangle
    glBindVertexArray(VAO);
    glDrawArrays(GL_TRIANGLES, 0, 3);
  
    // swap buffers and poll IO events
    glfwSwapBuffers(window);
    glfwPollEvents();
}
A teljes forráskód: Code

Szín minden vertexhez

Hogy minden egyes vertex-nek különböző színt adjunk, megtehetnénk sok uniform változó bevezetésével, de ez nagyon költséges lenne. Helyette a vertex attribútumba fogunk több adatot felvenni. Az előző tutorialba láttuk, hogy hogyan tölthetjük fel a VBO-t, hogyan konfigurálhatjuk a vertex attribútum pointer-t és mindezt hogyan tárolhatjuk a VAO-ban. Most a pozíció adat mellett szín információt (3db float) is fogunk tölteni a vertex array-be. (A háromszög minden egyes csúcspontjához rendelünk egy rgb színinformációt.)
In [ ]:
float vertices[] = {
    // positions         // colors
     0.5f, -0.5f, 0.0f,  1.0f, 0.0f, 0.0f,   // bottom right
    -0.5f, -0.5f, 0.0f,  0.0f, 1.0f, 0.0f,   // bottom left
     0.0f,  0.5f, 0.0f,  0.0f, 0.0f, 1.0f    // top 
};

Mivel most több adatot fogunk küldeni, ezért ennek megfelelően fel kell venni egy input color attribútumot a vertex shader-ben, hogy fogadja a szín adatokat is. A color attribútum lokációját a layout kulcsszóval 1-re állítjuk:

In [ ]:
#version 400 core
layout (location = 0) in vec3 aPos;   // the position variable has attribute position 0
layout (location = 1) in vec3 aColor; // the color variable has attribute position 1
  
out vec3 ourColor; // output a color to the fragment shader

void main() {
    gl_Position = vec4(aPos, 1.0);
    ourColor = aColor; // set ourColor to the input color we got from the vertex data
}

Mivel a uniform fragment color változó helyett, most a vertex shader-ből küldjük a szín információt a fragment shader-nek ezért azt is módosítanunk kell:

In [ ]:
#version 400 core
out vec4 FragColor;  
in vec3 ourColor;
  
void main() {
    FragColor = vec4(ourColor, 1.0);
}

Mivel egy új vertex attribútumot hoztunk létre, ezért újra kell konfigurálnunk a vertex attribútum pointer-t, ami a következő képpen néz ki:

title

In [ ]:
// position attribute
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// color attribute
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3* sizeof(float)));
glEnableVertexAttribArray(1);

Az első két kódsor a 0 lokációhoz tartozó pozíció attribútumot, míg a második két kódsor az 1 lokációhoz tartozó szín attribútumot konfigurálja fel. Ahogy a fenti képen is látszik a stride 6 sizeof(float) érték mind a két változó esetén. A pozíciónál az offset érték 0, míg a színnél 3sizeof(float).

stride: Ugyan olyan típusú adatok közötti eltolás. (pl.: két pozíció v. két szín adat közötti távolság byte-ban.)
offset: A kezdeti pozíciója egy adott típusú adatnak byte-ban.

A kód a következő képet rajzolja:

title

Mivel csak három színt állítottunk be, ezért az eredmény meglepő lehet. Ezt a jelenséget nevezik fragment interpolation-nek, ami a fragment shader-ben megy végbe. Amikor a háromszög renderelése történik a rasterizációs lépés során sokkal több fragment jön létre, mint amennyi vertex definiálva volt, így az egyes fragmentek színét interpolálja az OpenGL. Attól függően, hogy az aktuális fragment hol található a háromszög 3db csúcsa és hozzájuk tartozó színekhez képest. Pl.: Ha az egyik fragment a zöld és a kék csúcs között van, egy kicsit közelebb a zöldhöz, akkor a színe monduk 60% zöld és 40% kék színt fog tartalmazni. Tehát 3db csúcsunk és színünk van a hármonszög esetén de pixel színten kb. 50000 fragment tartozik a háromszöghöz.

Saját shader osztály

Megírni, fordítani, linkelni és kezelni az egyes shader-eket bonyolult és hosszadalmas, főleg, ha sok shader-t használ az applikációnk. Ezért létrehozunk egy shader osztályt, ami sokkal kényelmesebbé teszi a kezelésüket: fájlból shader olvasás, fordítás és linkelés és közben hibakezelés:

In [ ]:
#ifndef SHADER_H
#define SHADER_H

#include <glad/glad.h> // include glad to get all the required OpenGL headers
  
#include <string>
#include <fstream>
#include <sstream>
#include <iostream>
  

class Shader {
public:
    // the program ID
    unsigned int ID;
  
    // constructor reads and builds the shader
    Shader(const GLchar* vertexPath, const GLchar* fragmentPath);
    // use/activate the shader
    void use();
    // utility uniform functions
    void setBool(const std::string &name, bool value) const;  
    void setInt(const std::string &name, int value) const;   
    void setFloat(const std::string &name, float value) const;
};
  
#endif
A shader osztály tartalmazza a shader program azonosítóját (id). A vertex és fragment shader kódját egy text fájlban tárolhatjuk, majd a shader osztály konstruktorában ezeknek a fájloknak az elérési útvonalát kell megadni. Számos segéd függvényt létrehozhatunk, pl.: a use függvény aktiválja a shader programot.

A teljes shader osztály kódja: Code

A shader osztály használatához, először létre hozunk egy shader objektumot, majd a segédfüggvényeken keresztül tudjuk használni:

In [ ]:
Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.fs");
...
while(...) {
    ourShader.use();
    ourShader.setFloat("someUniform", 1.0f);
    DrawStuff();
}

A shader.vs és a shader.fs a megfelelő vertex és fragment shader kód. A teljes forráskód ami a shader osztályt használja:

  • A teljes forrás kód: Code
  • A vertex shader kód: Code
  • A fragment shader kód: Code