Előadás Home Introduction Environment setup Best homeworks

Koordináta rendszerek

Az OpenGL megköveteli hogy a vertex shader kódok futása után, az összes vertex, amit meg szeretnénk jeleníteni normalizált device koordinátákkal (NDC) legyenek adottak. Vagyis a vertexek $x$, $z$, és $z$ koordinátáinak $-1.0$ és $1.0$ közzé kell esnie (azok a koordináták amik ezen az intervallumon kívül esnek nem lesznek láthatóak). Általánosságban a koordinátákat szabadon definiálhatjuk a programban, majd a vertex shader kódban NDC koordinátákká transzformáljuk őket. Végül ezeket az NDC koordináták a raszterizáció során 2D koordinátákká (pixelekké) transzformálja a pipeline.
A vertex koordináták NDC koordinátákká, majd képernyő pixelekké való transzformálása számos egymást követő transzformációból és koordinátarendszer váltásból áll. Ennek az előnye, hogy bizonyos számítások, műveletek egyszerűbben elvégezhetőek más koordinátarendszerekben. A következő öt koordinátarendszer lesz számunkra a legfontosabb:
  • Local space (objektum tér)
  • World space
  • View space (eye space)
  • Clip space
  • Screen space

A koordináták egyik térből egy másik koordináta térbe transzformálásához számos transzformációs mátrixot fogunk használni, ahol a legfontosabbak a modell mátrix , a view mátrix és a projection mátrix. Első lépésben a vertex koordináták a local space-ben adottak, majd sorba a world koordináta térbe, a view space-be, a clip space-be és végül a screen space-be transzformáljuk őket. A következő kép a transzformációk sorozatát szemlélteti:

title


  • A lokális koordináták magukat az objektumokat definiálják a saját lokális koordináta rendszerükbe.
  • A world space koordináták a világot írják le, amibe bele tartoznak a definiált objektumok. Azaz az egyes objektumok egymáshoz viszonyított helyzetét határozza meg.
  • A view space-be transzformált koordináták az adott kamera szemszögéből vagy más néven a néző szemszögéből írja le a teret.
  • A clip space-ben a koordinátákat a $-1.0$ és $1.0$ intervallumra transzformáljuk.
  • Végül a viewport transzformáció során a glViewport függvény segítségével a $-1.0$ és $1.0$ közötti koordinátákat képernyő koordinátákká (pixelekké) transzformáljuk, amiket a raszterizáció során fragmentekké alakít a pipeline.


Az ok amiért végrehajtjuk ezt a számos transzformációt elsősorban az, hogy egyes műveleteket könnyebben végre tudunk hajtani más koordinátarendszerekben. Például az objektumok módosítását értelemszerűen a saját lokális koordináta rendszerükbe érdemes végezni, azonban az egyes objektumok pozícióinak a transzformálását a world space-ben kell megtenni, hiszen ott van értelme. Definiálhatunk olyan traszformációt, ami a local space-ből egyből a clip space-be visz, azonban ezzel sok flexibilitást vesztünk.


Local space

Az objektumok vertex koordinátáit a local space-ben érdemes definiálni, ami magához az adott objektumhoz képest lokális. Tegyük fel, hogy valamilyen modellező szoftverben (Blender, 3dsMax) létrehozunk egy objektumot (például egy kockát), aminek az eredeti középpontja a $(0, 0, 0)$ pozíción adott, azonban az egyes koordinátarendszerek közötti transzformációk során teljesen más pozícióba kerülhet. A local space-ben az objektumokat érdemes a $-0.5$ és $0.5$ intervallumon definiálni, ahol a középpont $(0.0)$.

World space

Ha egyszerűen csak beimportálnánk az összes objektumot az applikációba, akkor valószínűleg egymásra rajzolódnának ki, mivel mind a $(0,0,0)$ pozícióba van definiálva. Helyette minden egyes objektumok pozicionálni szeretnénk egy meghatározott helyre a világon belül. Ehhez az egyes objektumokat a saját lokális koordinátarendszerükből a megfelelő világ koordinátarendszerbe kell transzformálnunk, amit a modell mátrix segítségével tehetünk meg.

A modell mátrix egy transzformációs mátrix, ami eltolja (translate), átméretezi (scale) és forgatja (rotate) az objektumokat és a megfelelő világpeli pozícióba, orientációba helyezi azokat.

View space

A view space az amire általában az OpenGL kamerájaként is hivatkozunk, vagy más néven kamera space vagy eye space. A view space nem más, mint annak a transzformációnak az eredménye, ami a world space koordinátákat a felhasználó szemszögéből mutatja be, azaz az a terület, amit a kamera nézőpontjából látunk. Ezt általában eltolás és forgatás transzformációk kombinációjával érjük el. Ezt a kombinált transzformációs mátrixot, ami a world koordinátákat a view space-be transzformálja view mátrixnak nevezzük.

Clip space

Minden vertex shader lefutása után az OpenGL feltételezi, hogy a koordináták egy specifikus intervallumon belül helyezkednek el és az összes olyan koordinátát, ami ezen kívül esik levágja. A levágot koordinátákat eldobja a program, a megmaradt koordinátákat pedig a képernyőn megjelenő fragmentekké konvertálja.

Ahhoz, hogy a view space-ből a clip space-be konvertáljuk a koordinátákat bevezetjük a projection mátrixot, ami meghatározza koordináták egy intervallumát pl.: $-1000$ és $1000$ között, majd NDC koordinátákká transzformálja $(-1.0, 1.0)$ őket. Jelen esetben azok a koordináták, amik kívül esnek a $(-1000, 1000)$ intervallumon (pl. 1200, 500, 500) nem konvertálódnak NDC koordinátákkal és eldobjuk őket.

Note: Ha egy geometriai primitív (pl. háromszög) csak egy része esik kívül a clipping területen, akkor az OpenGL rekonstruálni fogja a háromszöget, több kisebb háromszög felhasználásával, hogy kitöltse a clipping területet.

Ezt a látómezőt, amit a projection mátrix hoz létre frustum-nak (gúla) nevezzük és minden olyan koordináta, ami a frustrum-on belülre esik meg fog jelenni a képernyőn. A projection mátrix az NDC koordinátákat a 3D koordinátákat a 2D view space-be transzformálja.

Miután az összes vertexet a clip space-be transzformáltuk egy perspective division nevű művelet fut le, ami leosztja az $x$, $y$ és $z$ pozíció komponenseket a $w$ homogén koordinátával, azaz a perspective division művelet a 4D clip space koordinátákat 3D NDC koordinátákká konvertálja. Ez a művelet minden vertex shader végén automatikusan lefut.

Ezután a lépés után a koordináták képernyő koordinátákká konvertálódnak és fragmentek jönnek létre. A projection mátrix, a view koordináták clip koordinátákká transzformálásához két különböző formát vehet fel, ahol mind a két forma a saját specifikus fustrumát definiálja. Így vagy egy orthographic projection mátrixról vagy egy perspective projection mátrixról beszélünk.

Orthographic projection


Egy orthographic projection mátrix egy kocka alakú frustum-ot definiál. Minden vertex, ami ezen kívül esik eliminálódik. Egy orthographic projection mátrix esetén a frustum szélességét, a magasságát és egy hosszt kell definiálnunk, ami meghatározza a látható terület mélységét. A frustum a következő képpen néz ki:

title

In [ ]:
glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);
Az első két paraméter határozza meg a frustum bal illetve jobb oldali koordinátákat, míg a harmadik és a negyedik értékek definiálják a frustum alját és tetejét. Ezzel a négy ponttal határozzuk meg a közeli és a távoli vágósík méretét. Az 5. és 6. psrsméter a távolságot határozza meg a két vágósík között.

Ugyan az orthographic projection mátrix egy az egyben a 2D képernyő síkjára projektálja a 3D koordinátákat, azonban egy ilyen projekció mesterséges hatást kelt, mivel nem veszi számításba a perspektivitást. Ezt a perspective projection mátrix fogjuk biztosítani.

Perspective projection


A valóságban megfigyelhetjük, hogy a távolabbi objektumok kisebbnek tűnnek. Ezt a hatást perspective-nek nevezzük. Különösen észrevehető a következő képen, ahol egy a végtelenbe tűnő utat láthatunk:

title


Ahogy látható a perspective hatás miatt a vonalak egyre jobban közelítik egymést, ahogy távolodunk. Ez az az effektus, amit a perspective projection segítségével el szeretnénk érni. A perspective projection mátrix az adott frustum által meghatározott területből képez le a clip space-re, azonban minden vertex koordinátát esetén úgy manipulálja a homogén (w) koordinátát, hogy minél nagyobbá válljon, ahogy minél távolabb kerülünk az adott nézőponttól. Amikor a clip space-be transzformálja a koordinátákat, azok értékei $-w$ és $w$ között adottak, azonban mivel az OpenGL megköveteli, hogy minden látható koordináta NDC fromában legyen adott az utolsó vertex shader lefutása után, ezért a perspective division műveletét (w koordinátával való osztás) alkalmazzuk a clip space koordinátáira:

\begin{equation*} out = \begin{pmatrix} x /w \\ y / w \\ z / w \end{pmatrix} \end{equation*}


A vertex koordináta minden komponensét leosztjuk a homogén $w$ koordinátával. Minél nagyobb ez a w koordináta, annál kisebb lesz az osztás eredméyne, azaz minél távolabb van egy objektum a vertex koordinátái annál kisebbek lesznek. Az osztás után a vertex koordináták már NDC formában adottak. A perspective projection mátrixot a következő képpen definiáljuk a GML segítségével:

In [ ]:
glm::mat4 proj = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);
glm::perspective egy frustum-ot definiál, ami meghatározza a látható térrészt, tehát ami ezen a frustum területen kívül esik az nem fog megjelenni a clip space-ben. A perspective frustum a következő képpen vizualizálható:

title

Az első paraméter a látható területet definiálja, azaz, hogy milyen nagy a viewspace (field of view(fov)). A realisztikus nézethez ezt általában $45$ foknak állítják be, de persze el lehet ettől térni. A második paraméter állítja be a nézőpont arányt (aspect ratio), amit a viewport szélesség és magasság paramétereinek a hányadosaként kapjuk meg. A 3. és a 4. paraméter határozza meg a frustum közeli és távoli vágósíkját. Általánosságban a közeli vágósíkot $0.1f$, míg a távolit $100.0f$ értékre állítjuk.

Note: Ha a közeli (near) vágósík értékét egy kicsit túl nagyra állítjuk a perspective mátrixban (pl. 10.0f), akkor az OpenGL az egyes vertex adatokat közel fogja vágni a kamerához (0.0f és 10.0f közzé), ami hasonló érzést kelt, mint amikor egy számítógépes játékban túl közel megyünk egy objektumhoz.

Amikor orthographic projection-t használunk, akkor a vertex koordináták egy az egyben (perspective division nélkül (technikailag az, de a w komponens 1!)) rendelődnek hozzá a clip space-hez. Mivel az orthographic projection nem használ perspective projection-t, ezért a távolabbi objektumok nem tűnnek kisebbnek, ami mesterséges hatást kelt. Ezért az orthographic projection-t főleg csak 2D rendereléseknél használják. Az olyan 3D modellező szoftverek, mint pl. a Blender néha orthographic projection-t használ modellezési célból, mivel így pontosabban át lehet látni az egyes dimenziókat a szerkesztés után. A következő kép a Blender két projekciós véltozatát mutatja:

title


A fenti képen látható, hogy a perspective projection esetén a vertexek, amik távolabb helyezkednek el a kamerától kisebbnek tűnnek, míg az orthographic projection esetén ez nem figyelhető meg.

Összegzés

Létrehoztunk egy model, egy view és egy projection transzformációs mátrixot, majd a vertex koordinátákat a clip space-be transzformáltuk a következő képpen:

\begin{equation*} V_{clip} = M_{projection} \cdot M_{view} \cdot M_{model} \cdot V_{local} \end{equation*}
A mátrixok szorzási sorrendje fordított, ahogy már említettük ezt jobbról balra kell olvasni. Ezután a vertexeket a gl_Position adattaghoz rendeljük a vertex shaderen belül, majd az OpenGL automatikusan lefuttatja a perspective division és clipping műveleteket.

A vertex shader kimenetként (output) clip space beli koordinátákat követel meg és pont ez az amit a transzformációs mátrixokkal elértünk. Majd az OpenGL alkalmazza a perspective division műveletet a clip space beli koordinátákra, hogy NDC koordinátákká transzformálja azokat. Majd a glViewPort függvény segítségével az NDC koordinátákat képernyő (screen) koordinátákká transzformálja, ahol minden koordináta hozzárendelődik egy képernyő beli ponthoz. Ezt a műveletet viewport transzformációnak nevezzük.

3D renderelés


Ahhoz, hogy 3D-ben rajzoljunk először létre kell hoznunk a modell mátrixot. A modell mátrix eltolások, skálázás és forgatások összessége, ami segítségével az egyes objektumokat transzfromálhatjuk a globális world koordináta térbe. Transzformáljuk a síkot az $x$ tengely mentén, hogy úgy nézzen ki mintha a földön heverne. A modell mátrix a következő képpen néz ki:

In [ ]:
glm::mat4 model = glm::mat4(1.0f);
model = glm::rotate(model, glm::radians(-55.0f), glm::vec3(1.0f, 0.0f, 0.0f));
Azáltal, hogy megszorozzuk a vertex koordinátákat a modell mátrixal a globális world space-be transzformáljuk őket. Következő lépésben a view mátrixot kell létrehoznunk. Ha a world space-ben a $(0,0,0)$ pozícióban van a kamera, azaz a nézőpont, egy kissé hátra kell mozdítani a kamerát, ahhoz, hogy az objektumok láthatóak legyenek a world space-ben. (Azzal, hogy a kamerát hátra mozgatjuk, olyan hatást érünk el, mintha az objektumokat tolnánk előre.)

A view mátrix pontosan ezt csinálja, azaz a teljes teret inverz módon mozgatja a kamerához képest. Mivel most hátra fele szeretnénk mozogni és az OpenGL egy jobbsodrású rendszer, ezért a $y$ tengely mentén pozitív irényba kell mozogni. Vagyis ez azt jelenti, hogy a világot a negatív irányba toljuk a $z$ tengely mentén. Ez azt az illúziót kelti, mintha hátra fele mozognánk. A view mátrix a következő képpen néz ki:
In [ ]:
glm::mat4 view = glm::mat4(1.0f);
// note that we're translating the scene in the reverse direction of where we want to move
view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f));
Végül a projection mátrixot is definiálnunk kell. Perspective projection-t szeretnénk használni, ezért következő képpen kell definiálnunk a mátrixot:
In [ ]:
glm::mat4 projection;
projection = glm::perspective(glm::radians(45.0f), screenWidth / screenHeight, 0.1f, 100.0f);
Most, hogy létrehoztuk a transzformációs mátrixot át kell adni a shader programnak. Első lépésben a vertex shaderen belül deklaráljuk a transzformációs mátrixot mint egy uniform változó és összeszorozzuk a vertex koordinátákkal:
In [ ]:
#version 400 core
layout (location = 0) in vec3 aPos;
...
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main() {
    // note that we read the multiplication from right to left
    gl_Position = projection * view * model * vec4(aPos, 1.0);
    ...
}

Ezután a mátrixokat elküldjük a shader programnak (ez rendszerint a render loopban történik, mert a mátrixok sokat változnak):

In [ ]:
int modelLoc = glGetUniformLocation(ourShader.ID, "model");
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
... // same for View Matrix and Projection Matrix
Most, hogy a vertex koordinátákat transzformáltuk a modell, view és a projection mátrixon keresztül az eredménynek a következő képpen kell kinéznie:
  • Elfektettük a földre
  • Kicsit távolabb mozogtunk tőle
  • Perspektív hatást alkalmaztunk, azaz a távolabbi vertexeknek kisebbnek kell látszani

title


More 3D

A következőekben kiegészítjük a síkunkat és egy 3D kockát fogunk létrehozni. Egy kocka rendereléséhez összesen $36$ vertexre van szükségünk (6 oldallap * 2 háromszög * 3 vertex mindegyik esetén):
In [ ]:
float vertices[] = {
    -0.5f, -0.5f, -0.5f,  0.0f, 0.0f,
     0.5f, -0.5f, -0.5f,  1.0f, 0.0f,
     0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
     0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
    -0.5f,  0.5f, -0.5f,  0.0f, 1.0f,
    -0.5f, -0.5f, -0.5f,  0.0f, 0.0f,

    -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
     0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 1.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 1.0f,
    -0.5f,  0.5f,  0.5f,  0.0f, 1.0f,
    -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,

    -0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
    -0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
    -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
    -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
    -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
    -0.5f,  0.5f,  0.5f,  1.0f, 0.0f,

     0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
     0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
     0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
     0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
     0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 0.0f,

    -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
     0.5f, -0.5f, -0.5f,  1.0f, 1.0f,
     0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
     0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
    -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
    -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,

    -0.5f,  0.5f, -0.5f,  0.0f, 1.0f,
     0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
    -0.5f,  0.5f,  0.5f,  0.0f, 0.0f,
    -0.5f,  0.5f, -0.5f,  0.0f, 1.0f
};

A kockát folyamatosan forgatni fogjuk a következő képpen:

In [ ]:
model = glm::rotate(model, (float)glfwGetTime() * glm::radians(50.0f), glm::vec3(0.5f, 1.0f, 0.0f));

És végül a glDrawArrays segítségével kirajzoljuk:

In [ ]:
glDrawArrays(GL_TRIANGLES, 0, 36);

Valami hasonlót kell látnunk:


Nagyjából hasonlít egy kockára, de valami nem az igazi. Néhány oldala a kockának más oldalak fölé rajzolódtak ki. Ez azért van, mert amikor az OpenGL kirajzolja a kockát háromszögről háromszögre, felülírja az egyes pixeleket amit már kirajzolt. Ezért néhány háromszög egymás tetejére rajzolódik ki, azonban nem kellene átfedniük.

Szerencsére azonban az OpenGL a z-bufferben tárolja a mélység információt, ami lehetővé teszi, hogy az OpenGL eldöntse mikor kell egy pixel felé rajzolni vagy nem. A z-buffer segítségével konfigurálhatjuk az OpenGL-t mélységteszt szempontjából.

Z-buffer
A GLFW automatikusan létrehozza a z-buffert (csak úgy mint a color buffert a kimeneti képhez). A mélység minden egyes fragmenten belül tárolódik, mint a fragment $z$ értéke és amikor a fragment színét rajzoljuk ki az OpenGL összehasonlítja a fragment mélységét a z-buffer mélység értékével. Ha az aktuális fragment egy másik fragment mögött helyezkedik el, akkor elvetjük azt, különben felülírjuk. Ezt a folyamatot mélységtesztelésnek nevezzük és az OpenGL automatikusan elvégzi.

Azonban, hogy biztosan legyünk benne, hogy az OpenGL elvégzi a mélységtesztet ahhoz engedélyeznünk kell azt (alapesetben ez ki van kapcsolva). A glEnable kulcsszóval engedélyezhetjük a mélységtesztet. A glEnable és glDisable függvényekkel számos OpenGL funkcionalitást szabályozhatunk. Ez addig lesz ércényben amíg felül nem definiáljuk.
In [ ]:
glEnable(GL_DEPTH_TEST);
Mivel használjuk a mélység buffert (z-buffer), ezért minden egyes új render iteráció előtt ürítenünk kell (clear), különben az előző frame mélységértékei a bufferben maradnak. Ezt a glClear függvénnyel tehetjük meg:
In [ ]:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

A mélységteszt eredményeként a következőt kell látnunk:

  • A teljes forráskód: Code
  • Vertex shader: Code
  • Fragment shader: Code
  • Shader osztály: Code

Több kocka egy képernyőn

Mondjuk, hogy 10 db kockát szeretnénk megjeleníteni a képernyőn. Minden kocka ugyanúgy fog kinézni, azonban mindegyik máshol fog tartózkodni a világban és más lesz az orientációja. Magát a kocka grafikai megjelenését már definiáltuk, ezért nem kell megváltoztatnuk a buffereket és az attribute array-t, enélkül is renderelhetünk több objektumot. Egyedül minden objektum esetén azt kell megváltoztatnunk a modell mátrixban, hogy hova szeretnénk transzformálni az adott kockát a térben. Definiáljunk egy eltolás vektort minden egyes kockához, ami meghatározza az adott kocka helyét a világban. Ezt a következő képpen tehetjük meg:
In [ ]:
glm::vec3 cubePositions[] = {
  glm::vec3( 0.0f,  0.0f,  0.0f), 
  glm::vec3( 2.0f,  5.0f, -15.0f), 
  glm::vec3(-1.5f, -2.2f, -2.5f),  
  glm::vec3(-3.8f, -2.0f, -12.3f),  
  glm::vec3( 2.4f, -0.4f, -3.5f),  
  glm::vec3(-1.7f,  3.0f, -7.5f),  
  glm::vec3( 1.3f, -2.0f, -2.5f),  
  glm::vec3( 1.5f,  2.0f, -2.5f), 
  glm::vec3( 1.5f,  0.2f, -1.5f), 
  glm::vec3(-1.3f,  1.0f, -1.5f)  
};
Most a render loopban 10 alkalommal kell meghívnunk a glDrawArrays függvényt, de minden egyes alkalommal más modell mátrixot küldünk a vertex shadernek. Egy kis ciklust hozunk létre a render loopon belül, ami 10 alkalommal fogja renderelni az objektumot különböző modell mátrixal, továbbá mindegyikhez adunk egy kis forgatást, hogy más legyen az orientációjuk:
In [ ]:
glBindVertexArray(VAO);
for(unsigned int i = 0; i < 10; i++) {
  glm::mat4 model;
  model = glm::translate(model, cubePositions[i]);
  float angle = 20.0f * i; 
  model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
  ourShader.setMat4("model", model);

  glDrawArrays(GL_TRIANGLES, 0, 36);
}

A fenti kódrészlet frissíti a modell mátrixot minden egyes új kocka esetén és összesen 10 kockát fog kirajzolni. Az eredmény a következő:


  • A teljes forráskód: Code
  • Vertex shader: Code
  • Fragment shader: Code
  • Shader osztály: Code