51. fejezet - Modern OpenGL


A 2016-os év ismét forradalmat hozott a 3D grafika programozásban, ugyanis megjelentek az új generációs, nagyon hardverközeli API-k (DirectX 12, [Metal], Vulkan). Azt gondolná az egyszerű programozó, hogy az indokolatlan hype miatt a sokáig szeretett OpenGL megkezdi jól megérdemelt nyugdíjas éveit, és az unokáinknak fogunk majd csak anekdotákat mesélni, mint például: "aAaaaAaaa réÉégi szép időŐőkben, amikor lefagyasztottam a számítógéÉéépet egy rossz ópengéÉéel hívássaaAal..."

És persze a mai fiatalok ezt nem érthetik... pedig vulkánnal sokkal könnyebb lefagyasztani a számítógépet...

Korábbi Későbbi cikkekben említettem olyasmiket, mint az OpenGL AZDO (Approaching Zero Driver Overhead), ami tulajdonképpen egy előfutára volt az említett alacsony szintű API-knak: a CPU túl sok időt tölt a driver hívásokban, próbáljuk meg ezeket minimalizálni. Ahogy sejteni lehet, az ehhez szükséges tömérdek mennyiségű extension és a vele járó káosz is szerepet játszott abban, hogy a Khronos alkoholterápiás csoport kifejlesztette a Vulkan szabványt; azonban (meglepő módon) most 2017-ben hamvaiból feléledve publikálták az OpenGL 4.6 szabványt is! Meglepő, merthogy olyan újdonságok is vannak benne, amikkel eddig vulkánban sem találkoztam...

Még meglepőbb, hogy időtlen könyörgések után végül a munkámban is elkezdhettem használni az OpenGL egy modernebb változatát (3.3), aminek kapcsán rengeteg új dolgot tanultam meg (például, hogy miért nem jó a 3.3-as szabvány...); ugyanis már ez a verzió is mutat alacsony szintű hardverprogramozási vonásokat. Ebben a cikkben áttekintem, hogy mire is képes az (modern) OpenGL, szemléltetve a 3.3-as verzió hátrányait, illetve, hogy a későbbi verziók ezen milyen megoldásokkal javítottak.

A cikk vége felé pedig letisztázom az OpenGL 4.6 "újdonságaként" beharangozott parameter buffer-t (ami abszolút nem újdonság, hanem most lett bevéve a szabványba). A többi dolog tulajdonképpen a Vulkan-ból lett átemelve, úgyhogy azokkal nem foglalkozok.

(megj.: amikor azt mondom, hogy valami elérhető, az úgy értendő, hogy a szövegkörnyezet szerinti GL [core] verziónak része. Amennyiben nem, azt külön jelzem, és megadom a verziót amikortól extension-ként már elérhető)



Röviden összefoglalnám, hogy a 3.2 előtti verziókhoz képest mi változott. Core profile kontextet ugyanis csak 3.2-től lehet létrehozni, de valódi core profile-nak a 3.3-as verziót tekintem. Az alábbi szabályok innentől kezdve kötelezőek (ezekről majd részletesebben is írok):

  • a fixed function pipeline teljesen megszűnt, azaz kötelező shadereket használni
  • megszűnt a glBegin/glEnd, a vertex array-ek, a display list-ek, stb.
  • nincs többé rendszermemóriából rajzolás, minden adat kötelezően VBO-kban kell legyen
  • az input layout-ot VAO-val kötelező megadni (vertex array object)
  • a GLSL új szintaxisát kötelező használni
  • user clip plane-ek megszűntek, helyette a gl_ClipDistance[]-t lehet használni
  • bekerültek a sampler object-ek, így a sampler state leválasztható a textúráról (sőt, ajánlott)
  • alpha test megszűnt (!!!)
  • GL_CLAMP megszűnt (helyette GL_CLAMP_TO_EDGE van)
  • glLineWidth van ugyan, de csak 1-el garantált a működése (helyette geometry shader-t lehet használni)
Ezek tehát betartandó szabályok, illetve korlátozások. Az újdonságokat, amik ezeket indokolttá teszik külön bekezdésekben fogom ismertetni. Előtte viszont elmondanám hogyan kell core profile kontextet létrehozni.

A Windows ebből a szempontból a butábbik oprendszer, ugyanis először létre kell hozni egy szokványos kontextet egy dummy ablakra; nehogy a tényleges ablakra hozd létre, mert a pixel format nem állítható be egynél többször! Ezután le kell kérdezni a megfelelő függvénypointereket, majd azokkal lehet létrehozni az új kontextet az alábbi módon:

CODE
HDC hdc = ...; // az ablakhoz (HWND) tartozó device context // WGL_ARB_pixel_format, WGL_ARB_create_context, WGL_ARB_create_context_profile szükségesek wglGetPixelFormatAttribivARB = wglGetProcAddress("wglGetPixelFormatAttribivARB"); wglGetPixelFormatAttribfvARB = wglGetProcAddress("wglGetPixelFormatAttribfvARB"); wglChoosePixelFormatARB = wglGetProcAddress("wglChoosePixelFormatARB"); wglCreateContextAttribsARB = wglGetProcAddress("wglCreateContextAttribsARB"); // surface format kiválasztása int attribs[] = { WGL_DRAW_TO_WINDOW_ARB, TRUE, WGL_ACCELERATION_ARB, WGL_FULL_ACCELERATION_ARB, WGL_SUPPORT_OPENGL_ARB, TRUE, WGL_DOUBLE_BUFFER_ARB, TRUE, WGL_PIXEL_TYPE_ARB, WGL_TYPE_RGBA_ARB, WGL_COLOR_BITS_ARB, 32, WGL_ALPHA_BITS_ARB, 0, WGL_DEPTH_BITS_ARB, 24, WGL_STENCIL_BITS_ARB, 8, 0 }; int pixelformat = -1; UINT numformats = 0; wglChoosePixelFormatARB(hdc, attribs, 0, 1, &pixelformat, &numformats); PIXELFORMATDESCRIPTOR pfd = { ... }; // hasonlóan kitöltve, mint attribs SetPixelFormat(hdc, pixelformat, &pfd); // kontext létrehozás int contextattribs[] = { WGL_CONTEXT_MAJOR_VERSION_ARB, 3, WGL_CONTEXT_MINOR_VERSION_ARB, 3, WGL_CONTEXT_FLAGS_ARB, 0, // vagy WGL_CONTEXT_DEBUG_BIT (csak 4.3-tól) WGL_CONTEXT_PROFILE_MASK_ARB, WGL_CONTEXT_CORE_PROFILE_BIT_ARB, 0 }; HGLRC hrc = wglCreateContextAttribsARB(hdc, NULL, contextattribs); wglMakeCurrent(hdc, hrc); // aktiválás

(megj.: a vízilovas viccet meséltem már?)

A WGL_CONTEXT_FORWARD_COMPATIBLE_BIT_ARB flaget nem kell megadni, ugyanis 3.3 óta minden régi funkcionalitás implicit módon ki van hajítva. Ez egy rövidített kód, mert nem szeretnék túl sok helyet elhasználni, éppen ezért külön felüdülés, hogy macOS-en (korábban OS X) sokkal rövidebb a kód. Hátrány viszont, hogy csak kétféle verziót enged: 3.2 vagy 4.1... Nagyon remélem, hogy a 3.2-es is tudja a 3.3-as funkcionalitást, mert ha nem, akkor rövid időn belül bajban leszek... (megjegyzem, ha a kártya tud 4.1-et, akkor a válasz igen):

CODE
NSView* view = ...; // ez tulajdonképpen a HWND/HDC megfelelője NSOpenGLPixelFormatAttribute attributes[] = { NSOpenGLPFAColorSize, 24, NSOpenGLPFAAlphaSize, 8, NSOpenGLPFADepthSize, 24, NSOpenGLPFAStencilSize, 8, NSOpenGLPFADoubleBuffer, NSOpenGLPFAAccelerated, NSOpenGLPFANoRecovery, NSOpenGLPFAOpenGLProfile, NSOpenGLProfileVersion3_2Core, 0 }; NSOpenGLPixelFormat* format = [[NSOpenGLPixelFormat alloc] initWithAttributes:attributes]; NSOpenGLContext* context = [[NSOpenGLContext alloc] initWithFormat:format shareContext:nil]; [context setView:view]; // ez nem kötelező, de retina display esetén szükséges if ([view respondsToSelector:@selector(setWantsBestResolutionOpenGLSurface:)]) { [view setWantsBestResolutionOpenGLSurface:YES]; } [context update]; // ha valami változik a kontexten, akkor ezt meg kell hívnod [context makeCurrentContext]; // aktiválás

Ennyi szerintem bőven elég a kontext létrehozásról, a részletekért ld. a kódmellékletet. Az extension-ök lekérdezésében azonban változás állt be (mert miért is ne), így mostantól két opciód van:
  • tudod az extension sorszámát (glcorearb.h)
  • nem tudod...ekkor enumerálnod kell őket
Én azt javaslom, hogy enumeráld (bár egy igazi OpenGL guru fejből tudja mindet):

CODE
GLint numext = 0; const char* name = "GL_ARB_..."; // amit le szeretnél kérdezni glGetIntegerv(GL_NUM_EXTENSIONS, &numext); for( GLint i = 0; i < numext; ++i ) { ext = (const char*)glGetStringi(GL_EXTENSIONS, i); if( 0 == strcmp(ext, name) ) return true; // támogatott }

Amint látható, egy új függvény került a képbe glGetStringi néven, amit természetesen ugyanúgy le kell kérdezned; szerencsére csak Windows-on. Megemlítendő, hogy az EXT és hasonló szuffixeket nem kell (nem szabad) megadni, hacsaknem a kívánt extension nem része az adott verziónak (pl. GL 4.6 előtt az anizotróp szűrés ilyen). Még lényegesebb, hogy macOS-en a core profile-ba már bevett kiterjesztéseket le se lehet kérdezni (tehát explicit módon létezőnek tekintendőek)!

Ha ezeken a lépéseken túljutottál (és javaslom, hogy először ez működjön), akkor készen állsz fejest ugrani az API programozásába. Persze ez ahhoz hasonlít, mint amikor vízbe ugrasz fejest: rövid távolságon semmi bajod nem lesz, ha viszont elég magasról ugrasz le (kb. 150m), akkor a víz felületi feszültsége miatt ugyanaz fog történni, mintha betonnak ütköznél...


Azért említem ezt meg, mert az egyik debug eszköz (RenderDoc) csak úgy tud több kontext esetén működni, ha azok meg vannak osztva. Ez olyasmi fogalom, mint az univerzumok a multiverzumban (gondolom hallottatok már erről). A shared context-ek egy multiverzumot alkotnak, aminek az egyik következménye például, hogy az erőforrásnevek (számok) generálása a multiverzumra nézve szekvenciális.

Amiből következik, hogy több kontext is használhatja ugyanazt az erőforrást. Szándékosan így írtam le, ugyanis ez objektum szinten már nem igaz: a konténer objektumok sohasem közösek (framebuffer, vertex array object, program pipeline, transform feedback object).

Itt említeném meg, hogy amikor egy objektumot törölsz, az még esetleg használatban van a GPU által, tehát tipikusan csak a neve fog törlődni (és egy következő generálófüggvény azt fogja megkapni). Nagyon figyelj oda tehát, hogy ha valamilyen erőforrást kitöröltél, azt a többi kontext se használja tovább (és ez meggondolásokat jelent a cache-elési logikádban is). Hasonló igaz bármifajta állapotváltoztatásra is: a többi kontextben ez nem fog azonnal megjelenni, ilyenkor szinkronizálnod kell fence-el (később) vagy a glFinish() függvénnyel.

Ha egy név (szám/azonosító) nem túl intuitív neked, akkor GL 4.3-tól lehetőség van címkét adni neki a glObjectLabel() függvénnyel. Szerintem ez nincs összehangban az igazi programozó szemléletével, úgyhogy magától értetődő módon én nem használom.

Windows-on a wglCreateContextAttribsARB() második paraméterében kell megadni a kontextet, amivel közösülni akarsz (hehe), macOS-en pedig természetesen a kontext létrehozásakor (shareContext:).

Mivel a shaderek használata kötelező lett, logikusnak gondoltam, hogy először azokat mutatom be, ebben a bekezdésben csak GL 3.3-hoz; az újdonságokat majd a további bekezdésekben említem meg. Szintaxist úgy a legkönnyebb bemutatni, ha rögtön kódot mutatok, úgyhogy vadul írjunk is egy vertex-fragment shader párost:

CODE
#version 330 in vec3 my_Position; in vec3 my_Normal; uniform mat4 matWorld; uniform mat4 matViewProj; uniform vec4 lightPos; out vec3 wnorm; out vec3 ldir; void main() { vec4 wpos = matWorld * vec4(my_Position, 1.0); ldir = lightPos.xyz - wpos.xyz; wnorm = (matWorld * vec4(my_Normal, 0)).xyz; gl_Position = matViewProj * wpos; }
CODE
#version 330 in vec3 wnorm; in vec3 ldir; out vec4 my_FragColor0; void main() { vec3 n = normalize(wnorm); vec3 l = normalize(ldir); float d = clamp(dot(l, n), 0.0, 1.0); my_FragColor0.rgb = vec4(d, d, d, 1.0); }

Amint látható (?), minden shader a #version makróval kell kezdődjön, mögéírva a használni kívánt GLSL verziót. GL 3.2-ben ez 150, onnantól fölfelé viszont az OpenGL verziója (azaz GL 3.3-ban 330). A kulcsszavak közül kikerült az attribute és a varying, helyettük az in és az out kulcsszavakat kell használni. A vertex input layout-ot mostantól csak így lehet megadni (azaz nincs többé gl_MultiTexCoord és hasonlók).

Beépített vertex shader inputok a gl_VertexID illetve a gl_InstanceID. Ezek segítségével nem muszáj bufferben megadni az adatot, hanem generálható közvetlenül a shaderben. A vertex shader outputjai a gl_PerVertex nevű ún. interface block-ban vannak megadva, aminek egyik tagja a gl_Position, így az természetesen megmaradt. További outputok a már említett gl_ClipDistance[], melyet újra kell deklarálni a konkrét méretével, illetve a gl_PointSize ha valaki még használja...

Fragment shader oldalról továbbra is input a gl_FragCoord; erről azt kell tudni, hogy a viewport terében van megadva (így különösen jól használható együtt a texelFetch() függvénnyel). Outputként csak a gl_FragDepth maradt meg, a kiadott színértékeket közvetlenül kell deklarálni és a glBindFragDataLocation() hívással hozzákötni a megfelelő framebuffer attachment-hez.

A textúrából való olvasás egységesen a texture() függvénnyel történik, azaz nincs többé a nevében a sampler típusa. Ugyanez igaz a többi hasonló függvényre is (pl. textureGrad). A texelFetch() függvényhez szintén kell mintavételező, viszont a textúra koordináta egész szám (ez egyébként az ún. buffer texture-höz lett bevezetve; erről nem írok).

Ezeken kívül megjelent a layout minősítő, például:

CODE
layout(location = 0) in vec3 my_Position;

Ekkor nem szükséges lekérdezni OpenGL oldalon az attribútum helyét a glGetAttribLocation() hívással. Annyit elmondanék, hogy ez uniform-okra is használható, ekkor viszont a (shader) programra nézve kell ezeket szekvenciálisan megadni (mint vulkánban). Ha nem így adod meg és működik, az bug (nálam pl. sokáig működött, aztán egy lényegtelen változtatás után hirtelen mégsem).

(megj.: ez abból adódik, hogy két fogalom is van az uniformok elérésére. Az egyik az index [ami feltehetően mindig a deklarációs sorrend szerint van], a másik a location. Ha felveszel egy új uniformot, akkor a korábbiak location-je megváltozhat. A hiba szerintem akkor nem jön elő, ha az index megegyezik a location-nel; de erre ne építs !!!)

Egyéb layout minősítőkkel később foglalkozok. Az elinduláshoz ennél részletesebb ismertetésre nincs szükség.


Megtévesztő név a VAO, ugyanis a régi OpenGL-ben létező fogalom a vertex array, ami az egyes vertex attribútumok tömbösített megadását tette lehetővé (glVertexPointer, glNormalPointer, stb.). A vertex array object-nek ehhez csak névlegesen van köze: ez is a vertex attribútumok tulajdonságait írja le (glVertexAttribPointer), viszont egy előre definiált objektumban. Az előnye az, hogy rajzoláskor elég csak ezt a bizonyos objektumot beállítani, szemben a régi módszerrel, ami az attribútumokat egyesével állítja be minden rajzolás előtt.

Nem mindenkinek egyértelmű, hogy mik tartoznak a VAO-hoz és mik nem (és sajnos a GL verziók is különböznek etekintetben). Éppen ezért újabb API-kban nincs ilyen összetett fogalom, hanem különválik a vertex input layout, ami megmondja a vertex attribútumok tulajdonságait (binding, típus, méret, offset, stb.) és a vertex binding layout, ami olyan dolgokat ad meg, mint pl. a vertex mérete (stride), illetve az instancing-hoz kapcsolódó frekvenciája (erről majd később).

A szomorú hír az, hogy GL 4.4 alatt a fentieken túl a VAO-hoz tartozik a konkrét vertex buffer is, amiből az adatot szedni fogja (holott elég lenne a binding). Ez hatalmas és indokolatlan korlátozás, ezért ha közös interfészt szeretnél írni Metal, Vulkan és OpenGL fölé, akkor GL 4.4 a minimum, amit támogatnod kell (de akkor már inkább 4.5).

Először nézzük meg a GL 3.3-as VAO-t, majd javítsuk fel a modernebb API-khoz:

CODE
GLuint inputlayout = 0; GLSizei stride = 24;

CODE
glGenVertexArrays(1, &inputlayout); glBindVertexArray(inputlayout); { // létrehozás utáni első bind-oláskor történik a definíció glBindBuffer(GL_ARRAY_BUFFER, vertexbuffer); glEnableVertexAttribArray(0); glEnableVertexAttribArray(1); glEnableVertexAttribArray(2); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (const GLvoid*)0); // in vec3 my_Position; glVertexAttribPointer(1, 4, GL_UNSIGNED_BYTE, GL_TRUE, stride, (const GLvoid*)12); // in vec4 my_Color; glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, stride, (const GLvoid*)16); // in vec2 my_TexCoord0; } glBindVertexArray(0);

CODE
// rajzolás glBindVertexArray(inputlayout); glDrawArrays(...);

Innentől kezdve a VAO definiált és módosíthatatlan (nem igaz, GL 4.5-től módosítható). Kényelmesebb, mint a zárójelben levő részt állandóan elvégezni nem? Sőt, az bár igaz, hogy a konkrét vertex buffer része az állapotnak, de a mögötte levő adat nem! Azt tehát nyugodtan lehet módosítani később is.

Na oké, de mi van, ha változó méretű adatom van (ami nem fér be egyben a bufferbe)? Amíg nem törlöd ki a vertex buffer-t, addig az OpenGL ad egy (nem szabványos) menekülési útvonalat a glBufferData() ismételt meghívásával; ekkor a régi tárolót megjegyzi (hiszen még használatban lehet) és allokál egy újat (buffer orphaning). Ez DirectX-ben is bevett szokás, amikor megadod a D3DLOCK_DISCARD flaget. De vajon hányszor lehet ezt eljátszani mielőtt szörnyethal a GPU?

Mondhatjuk azt, hogy "a driverre van bízva", csak sajnos ez azt is jelentheti, hogy a driver egyszerűen nem csinálja meg (és mivel a szabvány szerint a régi tároló törlődik, nem is köteles megcsinálni). Másrészt eleve indokolatlan, hogy miért van a vertex buffer hozzákötve a VAO-hoz: semmilyen információt nem használ fel belőle. Lássuk hogyan néz ki egy modern (GL 4.4-es) megoldás:

CODE
GLuint inputlayout = 0;

CODE
glGenVertexArrays(1, &inputlayout); glBindVertexArray(inputlayout); { glBindVertexBuffers(0, 1, NULL, NULL, NULL); glEnableVertexAttribArray(0); glVertexAttribBinding(0, 0); glVertexAttribFormat(0, 3, GL_FLOAT, GL_FALSE, 0); // in vec3 my_Position; glEnableVertexAttribArray(1); glVertexAttribBinding(1, 0); glVertexAttribFormat(1, 4, GL_UNSIGNED_BYTE, GL_TRUE, 12); // in vec4 my_Color; glEnableVertexAttribArray(2); glVertexAttribBinding(2, 0); glVertexAttribFormat(2, 2, GL_FLOAT, GL_FALSE, 16); // in vec2 my_TexCoord0; } glBindVertexArray(0);

CODE
// rajzolás glBindVertexArray(inputlayout); GLuint buffers[] = { vertexbuffer }; GLintptr offsets[] = { 0 }; GLsizei strides[] = { 24 }; // megj.: van glBindVertexBuffer() is glBindVertexBuffers(0, 1, buffers, offsets, strides); glDrawArrays(...);

Egyre inkább hasonlít a kód a Vulkan-hoz: innentől kezdve nem kell a driverre hagyatkozni, ha az adat mérete meghaladja a bufferét. Sőt, dinamikus adat esetén a driver munkáját (frame queueing) is tudom segíteni ha 2-3 vertex buffer-t előre létrehozok és round-robin módon mindig csak az aktuálisat módosítom.


Mivel a két dolog eléggé hasonlít, egy bekezdésbe vettem, azonban ne felejtsük el, hogy az utóbbi (SSBO) csak GL 4.3-tól van. Maga a GLSL szintaxis megengedi a régi fajta uniformok használatát is (glUniformXX-el beállítva), a modern API-khoz viszont ez nem illeszkedik, tehát érdemes minél előbb áttérni az uniform buffer (UBO) használatára. Először nézzük meg a kódot, utána pedig elmesélem, hogy miért baromi nehéz ezt jól használni:

CODE
// shader oldal #version 330 in vec3 my_Position; in vec3 my_Normal; uniform VertexUniformData { mat4 matWorld; mat4 matViewProj; vec4 lightPos; } vsuniforms; out vec3 wnorm; out vec3 ldir; void main() { vec4 wpos = vsuniforms.matWorld * vec4(my_Position, 1); ldir = vsuniforms.lightPos.xyz - wpos.xyz; wnorm = (vsuniforms.matWorld * vec4(my_Normal, 0)).xyz; gl_Position = vsuniforms.matViewProj * wpos; }
CODE
// OpenGL oldal GLuint uniformbuffer = 0; GLint idx = -1; glGenBuffers(1, &uniformbuffer); // allokálás glBindBuffer(GL_UNIFORM_BUFFER, uniformbuffer); glBufferData(GL_UNIFORM_BUFFER, ..., 0, GL_DYNAMIC_DRAW); // binding megadása idx = glGetUniformBlockIndex(program, "VertexUniformData"); glUniformBlockBinding(program, idx, 0); // feltöltés és rajzolás glBindBuffer(GL_UNIFORM_BUFFER, uniformbuffer); glBufferSubData(GL_UNIFORM_BUFFER, 0, ...); glBindBufferBase(GL_UNIFORM_BUFFER, 0, uniformbuffer);

(megj.: ha több shader stage is használja ugyanazt a blokkot, akkor írd ki elé a layout(shared) minősítőt)

Láthatóan már a tördeléssel is gondjaim vannak...ha ez lenne az egyetlen gond, akkor még meg is bocsátanám. Shader oldalon az uniformokat egy már említett interface block definiálja, és bár hasonlít a C-ből ismert struktúrára, mégsem az. Ugyanis, amint a jobb oldalon látható, a program oldali hivatkozáskor nem a definiált változó (vsuniforms) nevét kell megadni, hanem az interfészblokk nevét (VertexUniformData). Ebből rögtön következik, hogy egy adott shader stage-en belül ez nem egy újrafelhasználható konstrukció (lenyeljük...).

Na de mi a nehézség ebben, hiszen első ránézésre elég egyszerű kód...? Tömören megfogalmazva az adat módosítása, kicsit bővebben a glBufferSubData() működése. Azt gondolná az ember, hogy ez majdnem ugyanaz, mint vulkánban a vkCmdUpdateBuffer(), de sajnos nem. Addig rendben van, hogy ez is lemásolja a kapott adatot, ezután viszont (hardvertől függően) elindít egy aszinkron DMA transfer-t, aminek az a következménye, hogy szinkronizáció nélkül nem hívhatom meg ismételten ugyanarra a területre. Oké, akkor próbálkozzunk máshogy:

CODE
glBindBuffer(GL_UNIFORM_BUFFER, uniformbuffer); void* data = glMapBufferRange(GL_UNIFORM_BUFFER, 0, ..., GL_MAP_WRITE_BIT|GL_MAP_INVALIDATE_RANGE_BIT); { memcpy(data, ...); } glUnmapBuffer(GL_UNIFORM_BUFFER); glBindBufferBase(GL_UNIFORM_BUFFER, 0, uniformbuffer);

Erről legalább tudni lehet, hogy (most éppen) blokkol, emiatt persze kb. fele olyan lassú, mint az előző megoldás. A kulcs mindenesetre ez a hívás lesz (glMapBufferRange), mégpedig azért, mert rendkívül jól paraméterezhető. Tekintsük át a (GL 3.3-ban) megadható flageket:
  • GL_MAP_READ_BIT: olvasni akarom (ez rögtön ki is zár egy csomó más flaget)
  • GL_MAP_WRITE_BIT: írni akarom (a többi flag tulajdonképpen csak erre vonatkozik)
  • GL_MAP_INVALIDATE_RANGE_BIT: a megadott régió tartalma nem érdekel
  • GL_MAP_INVALIDATE_BUFFER_BIT: a teljes buffertartalom nem érdekel (elvileg ez is orphan-ol)
  • GL_MAP_FLUSH_EXPLICIT_BIT: én akarom megmondani, hogy mikor másolódjon az adat a videókártyára
  • GL_MAP_UNSYNCHRONIZED_BIT: én akarok szinkronizálni és megígérem, hogy nem módosítok rajzolás alatt levő régiókat
Előzetesen elárulom, hogy a GL 3.3-ban való (orphaning-tól eltekintve) egyetlen hatékony megoldás az utolsó, tehát az explicit szinkronizáció, éppen ezért külön bekezdésbe raktam. Most ezt egyelőre ugorjuk át és menjünk tovább GL 4.3-ba.

Az UBO egyéb hátrányai a fentieken kívül, hogy fix és limitált a mérete (ált. 64 kB), illetve csak olvasható. A célnak ez általában elég, viszont kiterjesztve a fogalmat felmerül az igény arra, hogy sokkal nagyobb méretű adatot is fel lehessen dolgozni (akár írni!) a shaderben. GL 4.3-tól erre használható a shader storage buffer (SSBO). Ahogy az előbb, egy példán keresztül mutatom meg ezt is:

CODE
// shader oldal #version 430 struct LightParticle { vec4 color; vec4 previous; vec4 current; vec4 velocity; }; layout(std140, binding = 1) readonly buffer LightBuffer { LightParticle data[]; } lightbuffer; void main() { gl_Position = lightbuffer.data[gl_VertexID].current; }
CODE
// OpenGL oldal GLuint storagebuffer = 0; glGenBuffers(1, &storagebuffer); // allokálás glBindBuffer(GL_SHADER_STORAGE_BUFFER, storagebuffer); glBufferData(GL_SHADER_STORAGE_BUFFER, ...); // feltöltés glMapBufferRange(...); // rajzolás glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, storagebuffer);

Láthatóan a GL 4.3 shader oldalról is sokkal fejlettebb, például rögtön megadható a binding (UBO-kra is természetesen). Az std140 egy ún. memory layout, ami azt mondja meg, hogy hogyan legyenek az adattagok igazítva; jelen esetben 16 B-os határokra. Ha szabványosan szeretnéd tömöríteni az adatot, akkor GL 4.3-tól elérhető az std430 is, ami lehetőség szerint 4 B-os határokra igazít, viszont csak SSBO-val használható. A packed-et inkább ne használd, mert hardverfüggő.

Az std140 és az std430 egyben shared is, tehát utóbbit nem kell külön kiírni.

Megjegyezném, hogy bár sokféle fajta buffer típus létezik, maga a buffer fogalom OpenGL-ben sem típusos! Tehát tetszőleges buffert beállíthatok a glBindBufferBase() hívással, mint SSBO (de akár atomic counter-ként is).

Gyakorlati példákat lehet találni a későbbi, compute shaderek-ről szóló cikkemben, úgyhogy tovább nem részletezem a használatot. Helyette ismertetek egy új megoldást (GL 4.4) az említett lassúsági problémára, amit persistent mapping-nak hívnak. Ez arról szól, hogy az adatot a program elején leképezem CPU oldalra, majd közvetlen glFlushMappedBufferRange() hívásokkal jelzem a GPU-nak, ha módosítottam.

De van egy még jobb hír: egy perzisztens leképezés lehet koherens is, amely esetben még csak jelezni sem kell a GPU-nak. Vigyázzunk azonban, mert bár az OpenGL szabvány ezt kimondja, nem jelenti azt, hogy a kártya tényleg tudja (Vulkan-ban ez könnyen ki is deríthető). Tehát ez a fajta megoldás is hardverfüggő, a hatékonysága viszont szinte garantált. Azért csak "szinte", mert ha egy koherens leképezést (ún. write-combined memory) olvasni és írni is akarsz, akkor bizony bitang lassú lesz.

CODE
GLuint uniformstorage = 0; void* persistentdata = 0;

CODE
glGenBuffers(1, &uniformstorage); // allokálás és leképezés glBindBuffer(GL_UNIFORM_BUFFER, uniformstorage); glBufferStorage( GL_UNIFORM_BUFFER, ..., NULL, GL_DYNAMIC_STORAGE_BIT|GL_MAP_WRITE_BIT|GL_MAP_PERSISTENT_BIT|GL_MAP_COHERENT_BIT); persistentdata = glMapBufferRange( GL_UNIFORM_BUFFER, 0, ..., GL_MAP_WRITE_BIT|GL_MAP_PERSISTENT_BIT|GL_MAP_COHERENT_BIT);

CODE
// feltöltés és rajzolás *persistentdata = ...; glBindBufferBase(GL_UNIFORM_BUFFER, 0, uniformstorage);

A felhasználható arzenál tehát elég nagy, viszont sok kártya nem támogatja ezen újdonságokat. Ez leginkább macOS-en igaz olyan kártyákkal, amik még nem tudnak Metal-t, a legmagasabb OpenGL verzió pedig (az Apple lustasága által) meg van rekedve 4.1-en. Ez gyakorlatilag minden 2012 előtti Mac gépre igaz.


Üljünk akkor vissza a tervezőasztalhoz. A kimondott verzió GL 3.3, szeretnénk baromi gyorsan uniform adatokat frissíteni, sőt szélsőséges esetben stream-elni (egy frame alatt többször módosítani ugyanazt a buffert). Ha eleve a Vulkan filozófiájával gondolkozunk, akkor egyértelmű, hogy az uniform adatokat osztályozni kell az (egy frame-en belüli) sűrűségük szerint. Például a view/projection mátrixokat tipikusan egyszer állítod be, viszont a world mátrixot rajzolóhívásonként!

Mit csináljunk? Vegyünk fel több UBO-t a módosítás gyakorisága szerint? Vulkan-ban a válasz a descriptor set (nem megyek bele, ld. a cikket). OpenGL-ben a válasz nem (ellenben sok fórum az igent ajánlja; én azért nem ajánlom, mert mint láttuk, GL 3.3-ban nagyon erős a hardverfüggés).

Az első felvetett problémára (csak frame-enként módosul az adat) mondok két rövid megoldást. Az egyik a buffer orphaning (de mint megbeszéltük nem szabványos), a másik egy round-robin alapú váltogatás (ekkor persze a VAO-t is váltogatnod kell).

Amit ebben a bekezdésben be szeretnék mutatni, az a második probléma (streaming) megoldása, ami - mint látni fogjuk - az első problémára is ad egy hatékonyabb megoldási lehetőséget. És itt egy pillanatra álljunk meg. A driver nem közvetlenül hajtja végre a hívásokat, hanem ún. (command) buffer-ekbe veszi fel őket (nem feltétlenül vulkános értelemben!). Ezekből nem tudni, hogy mennyi van konkrétan, ugyanis a driver saját kedve szerint végezheti el a szükséges szinkronizációkat, ami azt jelenti, hogy ha hatalmas hülyeségek jönnek le neki, akkor kénytelen új (command) buffer-t kezdeni. Amikor kiadod a glFlush() utasítást (a SwapBuffers() is implicit meghívja!), akkor ezek a (command) buffer-ek bekerülnek a command queue-ba (mindezeket együttesen command stream-nek hívják).

Azaz a hatékony rajzolás nem csak a hardvertől függhet, hanem a programozási technikától is (és azt hiszem értitek már, hogy miért vulkánnal ajánlom kezdeni a tanulást...). Azonban az esetek többségében feltehető, hogy a driver 2-3 frame-et tárolgat a buffereiben, mielőtt konkrétan leküldené őket a GPU-nak (frame queueing). Ezt nagyon észben kell tartani a most következő megoldást tekintve (szándékosan zanzásítva, hogy a lényeg maradjon meg, ami sajnos viszonylag hosszú):

CODE
#define UNIFORM_COPIES 512 // ennyi rajzolóhívásonként szinkronizálok struct EffectUniformBlock; GLsync sync = 0; GLuint ringbuffer = 0; int currentcopy = 0;

CODE
glBindBuffer(GL_UNIFORM_BUFFER, ringbuffer); glBufferData(GL_UNIFORM_BUFFER, UNIFORM_COPIES * sizeof(EffectUniformBlock), NULL, GL_DYNAMIC_DRAW);

CODE
// streamelt feltöltés (for 1:1000) if( currentcopy >= UNIFORM_COPIES ) { sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); } GLintptr baseoffset = currentcopy * sizeof(EffectUniformBlock); if( sync != 0 ) { GLenum result = 0; GLbitfield waitflags = GL_SYNC_FLUSH_COMMANDS_BIT; do { result = glClientWaitSync(sync, waitflags, 500000); waitflags = 0; } while( result == GL_TIMEOUT_EXPIRED ); glDeleteSync(sync); sync = 0; currentcopy = 0; baseoffset = 0; } glBindBuffer(GL_UNIFORM_BUFFER, ringbuffer); GLbitfield flags = GL_MAP_WRITE_BIT|GL_MAP_INVALIDATE_RANGE_BIT|GL_MAP_UNSYNCHRONIZED_BIT; void* data = glMapBufferRange(GL_UNIFORM_BUFFER, baseoffset, sizeof(EffectUniformBlock), flags); { memcpy(data, ...); } glUnmapBuffer(GL_UNIFORM_BUFFER); glBindBufferRange(GL_UNIFORM_BUFFER, 0, ringbuffer, baseoffset, sizeof(EffectUniformBlock)); glDrawArrays(...); ++currentcopy;

Első látásra ez bizony kemény. A cikk elején említettem, hogy GL 3.3-ban is van lehetőség alacsony szintű hardverprogramozásra; hát most látjuk is, hogy mit jelent ez. Az OpenGL egyetlen szinkronizációs objektuma a GLsync, ami bár általános fogalom, csak egyetlen fajtája hozható létre, mégpedig a fence. Megjegyzem, ez majdnem ugyanaz, mint a vulkános fence (GPU és a CPU között szinkronizál). Azért csak majdnem, mert GL-ben használható semaphore-ként is (glWaitSync), ezt a glBufferSubData() esetén kell(het) használni.

(megj.: létezik egyébként barrier is, de azt a glMemoryBarrier() függvény végzi el)

Kezdjük ott, hogy mit jelent a ringbuffer: ciklikus buffer, azaz ha betelt, akkor egy szinkronizáció után újra elkezdem feltölteni. A kódrészlet tetszőleges számú rajzolóhívásra működik, az egyetlen megfontolandó dolog, hogy mennyi memóriát szeretnél feláldozni ennek a ringbuffer-nek. Ez természetesen az uniform adatok méretétől függ.

A szinkronizáció azt jelenti, hogy a (lehetőleg) legutolsó utasítás után, ami a buffert használja meghívom a glFenceSync() függvényt, ami beilleszt egy várakozási pontot (fence) a command stream-be (de ő maga természetesen nem vár). A várakozást én kezdeményezem egy glClientWaitSync() hívással. Nem mindegy tehát, hogy mikor raktad be ezt a bizonyos várakozási pontot! Könnyen csökkenhet a hatékonysága, ha például rögtön a várakozás előtt rakod be.

Egy további megemlítendő, bár intuitív dolog a glBindBufferRange() hívás. Ahogy a nevében is benne van, ez a buffernek csak a megadott régióját állítja be, tehát a drivernek úgymond "bizonyítom", hogy tényleg csak azt a részt akarom kirajzolni, amit módosítottam (így nem fog fölöslegesen szinkronizálni).


Amikor ugyanazt az objektumot többször szeretnéd kirajzolni minimálisan eltérő tulajdonságokkal (pl. transzformációs mátrix), akkor azt instancing-nak hívják. Magyarra ezt valahogy úgy lehetne lefordítani, hogy "példányozás", de elég hülyén hangzik, úgyhogy maradok az angol nevénél. Sokféle technika van, ami ehhez kötődik; megpróbálom időrendben felsorolni őket:

  • konyha instancing: annyi rajzolóhívást adsz ki, ahány példány van
  • static instancing: a példányokat előre betranszformálod anyagtípus szerinti nagy bufferekbe
  • dynamic instancing: uaz. mint előbb, de frame-enként újra elvégzed (pl. mert CPU oldalon animálsz)
  • hardware instancing: az instance adatokat külön bufferbe gyűjtve egyetlen rajzolóhívást kell csak kiadni
  • skinned instancing: ugyanazt az animált objektumot a példányok aktuális animációs fázisában hardware instance-olni
  • multi-draw indirect: static batching és hardware instancing keveréke
A multi-draw indirect-ról külön bekezdésben fogok írni, most a hardware instancing-ot mutatom be. Azokat az adatokat, amik szerint példányokat szeretnék generálni egy objektumból, külön vertex buffer-be fogom pakolni. Az adat jelen esetben legyen mezei transzformációs mátrixok halmaza. A megemlítendő dolog az, hogy ezt a VAO-ban hogyan írjuk le; ugyanis a vertex attribútumok között nem szerepel olyan, hogy mátrix. Semmi gond, akkor adjuk le soronként (oszloponként):

CODE
GLuint vertexbuffer = ...; // vertex adat GLuint instancebuffer = ...; // instance adat GLuint inputlayout = 0; GLuint vertexstride = 24; GLuint instancestride = 64;

CODE
glGenVertexArrays(1, &inputlayout); glBindVertexArray(inputlayout); { // vertex layout glBindBuffer(GL_ARRAY_BUFFER, vertexbuffer); glEnableVertexAttribArray(0); glEnableVertexAttribArray(1); glEnableVertexAttribArray(2); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, vertexstride, (const GLvoid*)0); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, vertexstride, (const GLvoid*)12); glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, vertexstride, (const GLvoid*)24); // instance layout glBindBuffer(GL_ARRAY_BUFFER, instancebuffer); glEnableVertexAttribArray(3); glEnableVertexAttribArray(4); glEnableVertexAttribArray(5); glEnableVertexAttribArray(6); glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, instancestride, (const GLvoid*)0); glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, instancestride, (const GLvoid*)16); glVertexAttribPointer(5, 4, GL_FLOAT, GL_FALSE, instancestride, (const GLvoid*)32); glVertexAttribPointer(6, 4, GL_FLOAT, GL_FALSE, instancestride, (const GLvoid*)48); glVertexAttribDivisor(3, 1); glVertexAttribDivisor(4, 1); glVertexAttribDivisor(5, 1); glVertexAttribDivisor(6, 1); } glBindVertexArray(0);

CODE
// rajzolás glBindVertexArray(inputlayout); glDrawArraysInstanced(GL_TRIANGLES, 0, numvertices, numinstances);

(megj.: az instance adat VAO-ban való megadása megkerülhető UBO-val vagy SSBO-val)

A glVertexAttribDivisor() a következőket mondja: ha a divisor nulla, akkor ugyanaz, mintha meg sem hívtam volna, azaz a rendes vertexekkel együtt kapom meg az instance adatokat is. Ha viszont nem nulla, akkor a kapcsolódó attribútumok akkor fogják a soron következő értéküket megkapni a shaderben, ha már divisor darab instance kirajzolódott. A mostani példában divisor = 1, azaz numinstances példányban fog megjelenni az objektum a képernyőn.

Amikor a divisor más értéket vesz fel, akkor valamilyen más attribútum szerint is szeretném használni a technikát, de annak a frekvenciája kisebb. Például három példányonként új színt szeretnék megadni, ekkor a megfelelő attribútumnak a divisor-ja értelemszerűen 3.

Amit észben kell tartani, hogy a hardware instancing hatékonysága függ az objektum poligonszámától, illetve a példányok számától. Kis poligonszámú objektumok (pl. fűszál) esetén eszméletlenül gyors tud lenni, ugyanis a vertex adat egyben befér a pre-transform cache-be. Nagy poligonszámú objektumok esetén a konyha instancing felé fog közelíteni teljesítményben, szélsőséges esetben lassabb is lehet (de ez többnyire csak régi kártyákon igaz).

Általános szabály, hogy ha kevés példány van, akkor konyha instancing, ha közepesen sok példány van, akkor static instancing, ha nagyon sok példány van, akkor hardware instancing a nyerő (azonban elég sok példány esetén van egy fordulópont, ahol már lassabb).

Amire viszont nem jó a hardware instancing, az ha példányonként textúrát akarok váltani. Azt ugyanis ebben a formában nem lehet megtenni. Ilyenkor a textúrákat pakold össze egy texture atlas-ba és a shaderben emuláld le a szükséges címzési módokat. Ezt nem fogom részletezni, mert rengeteg szabályt kell betartani, de természetesen megtehető. Alternatív megoldás a texture array, de akkor a textúrák mérete meg kell egyezzen.

(megj.: ez a bizonyos megegyezés nem különösebben kritikus dolog: fölfelé nyugodtan skálázhatod a textúrákat, hiszen a tényleges jelenetben való felbontásuk a "normalizált" textúra koordinátáktól függ, viszont értelemszerűen memóriapazarló)


A geometry shader nem megoldás a problémáidra az ARM szerint. Oké, ahol van SSBO, ott valóban emulálható vertex shader-rel (pl. Metal-ban), illetve adott esetben compute shader-rel. Én viszont nem értek egyet ezzel az állítással, ugyanis a compute shader előtt/után mindenképpen szinkronizálni kell, míg a geometry shader a graphics pipeline része. Ezenkívül GL 4.3 alatt nincsenek az említett lehetőségek, szóval igenis sok esetben a geometry shader a jó választás.

Kicsit kezdőbb szintről indulva a geometry shader egy új shader stage, ami a vertex shader után, de még a raszterizáció előtt fut le (tehát már előállított primitíveket kap meg). Írjunk egy vertex-geometry shader párost, ami vastag vonalakat tud rajzolni (a fragment shader-t nem írom le, mindenki el tudja képzelni):

CODE
#version 330 in vec3 my_Position; uniform mat4 matProj; void main() { gl_Position = matProj * vec4(my_Position, 1.0); }
CODE
#version 330 layout(lines) in; layout(triangle_strip, max_vertices = 4) out; uniform vec2 thickness; // pl. 3 / screenwidth void main() { // clip space pozíció (DC) vec4 cpos1 = gl_in[0].gl_Position; vec4 cpos2 = gl_in[1].gl_Position; // screen space pozíció (NDC) vec4 spos1 = cpos1 / cpos1.w; vec4 spos2 = cpos2 / cpos2.w; // a vonal iránya és normálvektora vec2 d = normalize(spos2.xy - spos1.xy); vec2 n = vec2(d.y, -d.x); // generálok 4 vertexet a vastag vonalhoz vec4 v1 = spos1 + vec4(n * thickness, 0.0, 0.0); vec4 v2 = spos1 - vec4(n * thickness, 0.0, 0.0); vec4 v3 = spos2 + vec4(n * thickness, 0.0, 0.0); vec4 v4 = spos2 - vec4(n * thickness, 0.0, 0.0); // kiküldöm a vertexeket (DC) gl_Position = v1 * cpos1.w; EmitVertex(); gl_Position = v3 * cpos2.w; EmitVertex(); gl_Position = v2 * cpos1.w; EmitVertex(); gl_Position = v4 * cpos2.w; EmitVertex(); }

(megj.: nem írtam el; gondoljunk bele, hogy a screen space az a [-1, 1]3 tartomány, tehát nem kell megfeleznem a vastagságot!)

Na akkor szép sorjában: először is meg kell mondanom a shadernek, hogy milyen topológiájú adat jön be (most lines), illetve megy majd ki (point, line_strip, vagy triangle_strip). Azt is meg kell mondanom, hogy legfeljebb hány vertexet fogok generálni primitívenként (ugyanis a GPU így előre lefoglal magának egy buffert, amibe ezeket bele tudja pakolni).

A shader inputjai hasonló interfészblokkban vannak megadva, mint a vertex shader outputjai, mivel azonban itt primitíveket kapok meg, a deklaráció egy gl_in[] nevű tömb. Ezen kívül természetesen megkapom a gl_PrimitiveID-t is (ha esetleg valamilyen buffert szeretnék címezni vele).

A számolások után az alábbi függvényeket lehet meghívni:
  • EmitVertex(): a kitöltött outputok átkerülnek a bufferbe, mint vertex
  • EndPrimitive(): ezzel jelzem, hogy újra akarom indítani a számozást (ezzel szimulálható pl. a triangle list)
Nem egetrengető dolgok... Az viszont újdonság az eddigiekhez képest, hogy bekerültek új primitív topológiák, amiket a shader megkaphat:
  • lines_adjacency: 4 vertex jön be; a vonal vertexei és a szomszédos vertexek (amik együtt 3 vonalat alkotnak)
  • triangles_adjacency: 6 vertex jön be; a háromszög vertexei és a szomszédos vertexek (együtt 4 háromszög)
Megjegyezném, hogy bár a shader egy szomszédságot kap meg, az eredeti primitív topológiának ehhez csak annyi köze van, hogy jelezni kell benne (tehát van GL_TRIANGLES_ADJACENCY és GL_TRIANGLE_STRIP_ADJACENCY is). Vigyázat, ez azt is jelenti, hogy az index bufferben nekem kell megadnom a szomszédságot (ezek tehát fizikailag értendő topológiák, akár a többi). Ebből persze következik, hogy amilyen sorrendben megadtam, úgy fogom visszakapni.

Szintén érdemes tudni, hogy az, hogy én szomszédságot kapok meg, nem jelenti, hogy úgy is kell továbbadnom. Például egy 4 háromszögből álló szomszédságot átalakíthatok 4 darab tetraéderré (16 háromszög), vagy akár 3 darab vonalat egy négyszöggé (2 háromszög).

Ez volt a bevezetés, most viszont jöjjön néhány GL 4.0 utáni újdonság. Először is, egy darab output stream helyett egyszerre többe is lehet írni (EmitStreamVertex); ez a transform feedback-ben hasznos (később). Ezen kívül a geometry shader is lehet instance-olt, ami azt jelenti, hogy egy adott bejövő primitívre többször futtatható. Ekkor az alábbi minősítőt kell megadni:

CODE
layout(invocations = <amennyiszer szeretnéd>) in;

Limitáció van, de legalább 32 támogatott. Ekkor megkapod a hívás azonosítóját is (gl_InvocationID). Egyre inkább közeledünk a compute shader felé...most akkor miért is jó ez a geometry shader?

Ha másért nem, akkor azért amit mondtam: a graphics pipeline-hoz tartozik, és emiatt olyan kitüntett tulajdonsága is van, hogy egyszerre több viewport-ba (gl_VieportIndex) vagy egy rétegelt textúra (akár cubemap) több rétegébe (gl_Layer) is rajzolhat. Tehát bizonyos esetekben sokkal jobb választás, mint a compute shader (egy példa: cubemap alapú dinamikus tükröződés generálása).

(megj.: metálban ezek a funkcionalitások ugyanígy megvannak, de a geometry shader-t/transform feedback-et emulálni kell)

Egy ehhez kapcsolódó vicces fogalom a provokáló vertex (tudod, aki mindig beszól neked munka közben). Aki emlékszik még a flat shading-re, ott a raszterizált primitív összes fragmentje egy konkrét vertexből (a provokáló vertexből) kapja meg az adatait. A geometry shader szempontjából a viewport index vagy a layer lesz az, amit provokál (meg persze téged, hogy miért nem működik).

Ha a driver megengedi, akkor ez program oldalon módosítható a glProvokingVertex() függvénnyel a primitív első vagy utolsó vertexére. Ha nem engedi meg, akkor nem tudhatod, szóval az a biztonságos megoldás, ha mindegyik kiadott vertexnek azt adod meg, amiben látni szeretnéd majd... Oké, de miért is akarnál mást...?

Eddig nem mondtam el, de tetszőleges (fragment) shader adattagra kikapcsolható az interpoláció (pl. flat in vec3 wnorm;); ekkor az összes többi shader stage-ben is írd ki rá ezt a kulcsszót. Mivel egy adott topológián belül ez a változó tetszőleges értéket felvehet, nem mindegy, hogy melyik volt a provokáló vertex!

A shadow volume-okkal foglalkozó cikkemben említettem, hogy a sziluettdetektálás és a shadow volume generálás átvihető geometry shader-be, ezáltal hardveresen gyorsítható. Ha viszont így szeretnéd megoldani, akkor különösen fontos, hogy az objektum ún. 2-manifold legyen. A GPU által lefoglalt buffer mérete ugyanis véges, azaz ha az objektum összes háromszögét egyesével húzod ki (meg nem nevezett program), akkor rövid idő alatt szörnyethal a videókártya (egy más jellegű dolog kapcsán ugyan, de tapasztaltam).


Ez a témakör külön cikket érdemelne, de mivel megkértek rá, hogy foglalkozzak vele, röviden bemutatom a lényegét. A leggyakoribb felhasználási területe subdivision surface-ek, illetve parametrikus görbék és felületek előállítása. Általános célú háromszögelésre csak ha nagyon okos vagy speciális esetben használható.

Játékokban úgy jelenik meg, hogy egy viszonylag alacsony poligonszámú modellt megfelelő tesszellációs logikával baromi részletessé varázsolnak (újabb játékokban pl. a szereplők arca jó eséllyel ilyen). Az előny nyilvánvaló: memóriát és sávszélességet spórolsz vele (viszont a modelleket a konkrét logikához igazodva kell megcsinálni).

Maga a tesszelláció a vertex shader és a geometry shader között helyezkedik el, és az alábbi három lépésből áll:

  • tessellation control shader (TCS)
  • tessellation primitive generator
  • tessellation evaluation shader (TES)
A középső egy beépített algoritmus, nem lehet (nem is kell) beleszólni. Ezeknek a szerepét részletesen el fogom magyarázni, az elinduláshoz viszont meg kell ismerni egy új topológiát, a GL_PATCHES-t.

Egy patch-et fogjunk fel úgy, mint vertexek sorozatát. Azt, hogy egy patch konkrétan hány vertexből áll, az előállítandó geometria határozza meg, de legalább 32 vertex garantálva van mint maximális hossz. OpenGL oldalon a glPatchParameteri() hívással kell megmondani, hogy hány vertexből álljon egy patch.

A részletek előtt nézzünk meg egy shader kódot, ami harmadfokú Hermite görbéket tud rajzolni (azaz egy patch most 4 darab vertexből áll):

CODE
// tessellation control shader #version 400 layout(vertices = 4) out; void main() { gl_TessLevelOuter[0] = 1.0; gl_TessLevelOuter[1] = 16.0; gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position; }
CODE
// tessellation evaluation shader #version 400 uniform mat4 matProj; layout(isolines, equal_spacing) in; void main() { float u = gl_TessCoord.x; float h0 = 2.0 * u * u * u - 3.0 * u * u + 1.0; float h1 = -2.0 * u * u *u + 3.0 * u * u; float h2 = u * u * u - 2.0 * u * u + u; float h3 = u * u * u - u * u; vec4 pos = h0 * gl_in[0].gl_Position + // P0 h1 * gl_in[1].gl_Position + // P1 h2 * gl_in[2].gl_Position + // dP0 h3 * gl_in[3].gl_Position; // dP1 gl_Position = matProj * vec4(pos.xyz, 1.0); }

Oké, mi a búbánat ez? Először nézzük a TES-t, ugyanis ez mondja meg, hogy miket akarok generálni a patch-ből; most éppen vonalakat (isolines), egyenletes felosztással (equal_spacing). Ezen kívül megadható még winding order is (cw vagy ccw), nyilván akkor, amikor háromszögeket/négyszögeket generálsz.

Itt egy pillanatra álljunk meg. Említettem a tessellation primitive generator-t, ami ezen két shader között fut le, azonban fontos tudni róla, hogy abszolút nem érdekli, hogy bejövő patch konkrétan micsoda, vagy hogy hány vertexből áll. A generálás egy ún. absztrakt patch-en dolgozik, aminek a típusát a TES-ben megadott topológia határozza meg:
  • isolines: az absztrakt patch párhuzamos vonalakból álló blokk, a kimenet vonalak sorozata
  • triangles: az absztrakt patch egy háromszög, a kimenet háromszögek sorozata
  • quads: az absztrakt patch egy négyszög, a kimenet háromszögek sorozata
Illetve megadható ezek mellett a point_mode is, ekkor a kimenet pontok sorozata lesz. A generált vertexek koordinátáit az absztrakt patch-en belül a gl_TessCoord beépített változó mondja meg, ami mindig egy [0, 1]3-beli vektor (tehát pl. háromszög esetén baricentrikus koordinátát jelent).

Ha még nem vesztettétek el a fonalat, akkor most átugranék a TCS-re. Ennek kitüntetett tulajdonsága, hogy bejövő patch hosszát módosíthatja (azaz hozzáadhat vagy elvehet belőle vertexeket). Ezt figyelmbe véve erősen hasonlít egy compute shader-re: annyiszor fut le, amennyi a kimenő patch hossza (gl_InvocationID). A hasonlóság abban is megmutatkozik, hogy az egyes TCS futások olvashatják egymás kimenetét, illetve írhatják az adott patch-re vonatkozó kimenő változókat (pl. patch out vec4 valami); ehhez nyilván szinkronizálni kell a futások között, ami a barrier() utasítással tehető meg.

(megj.: metálban egy compute shader tölti be a TCS szerepét, ezután megtörténik a generálás, és a vertex shader lesz a TES)

A TCS-t nem kötelező megadni; ekkor OpenGL oldalon állíthatóak be a (patch szintű) kötelező ún. tesszellációs szintek (külső és belső). Az egyes TES topológiákra lebontva:
  • isolines: külső 0,1
  • triangles: külső 0,1,2, belső 0
  • quads: külső 0,1,2,3, belső 0,1
A külső tesszellációs szintek az absztrakt patch éleire vonatkoznak, a belsők pedig (mily meglepő) a belsejére. Mivel én most háromszögekkel/négyszögekkel nem szeretnék foglalkozni, javaslom elolvasni az OpenGL Wiki  ide vonatkozó részét. A megfelelő tesszellációs szintek dinamikus módosításával a level of detail  ingyen meg van oldva.

Vonalak esetén, mint mondtam az absztrakt patch tere egy vonalakból álló négyzet: a két külső tesszellációs szint ennek az éleire vonatkozik: gl_TessLevelOuter[0] azt mondja meg, hogy hány darab vonal generálódjon egy patch-ből (mindig equal_spacing), gl_TessLevelOuter[1] pedig ezeknek a részletességét mondja meg (hány szegmensből álljon). A maximális szegmensek száma legalább 64 (GL_MAX_GEN_TESS_LEVEL). Az egyes felosztástípusok a következőket jelentik az absztrakt patch-ben:
  • equal_spacing: a vertexek egyenlő távolságra vannak
  • fractional_even_spacing: páros számú szegmensszám, a szegmensek hossza kifelé csökken
  • fractional_odd_spacing: páratlan számú szegmensszám, egyébként ua.
Az említett linken ez is részletesen el van magyarázva, nem megyek bele mélyebben.

A példában egy görbét generáltam (gl_TessLevelOuter[0] = 1), de nyilván lehet többet is: ekkor a TES-ben gl_TessCoord.y mondja meg, hogy melyik vonalra fut éppen (de vigyázat, ez a [0, 1) intervallumban van megadva!). Emellett persze tetszőleges számú patch leküldhető, tehát nyugodtan lehet bármiféle spline-t csinálni, akár úgy is, hogy az egyes részgörbék több algörbéből állnak. A képen egy harmadfokú Hermite spline látható, 7 darab patch-ből.

hermite_spline

Egy további kérdés volt, amivel a korábbi cikkemben nem foglalkoztam, hogy használható-e ez NURBS generálásra. Mivel az is parametrikus görbe, szerintem igen (a súlyokat és a knot vektort uniformként lepasszolva). A GLSL nem tud rekurziót, tehát a görbe együtthatóit továbbra is át kell alakítani polárformába. Szintén figyelmbe kell venni, hogy a patch-ek nem tudják elérni egymás adatait, szóval vagy redundánsan kell megadni a kontrollpontokat, vagy szintén uniformként elérhetővé tenni, vagy az összes kontrollpontot egy patch-ben leküldeni (de akkor 32 a maximum).


Elsőre bonyolultnak tűnő dolog, ugyanis az eddigi OpenGL oldali (shader) program használatot szinte teljesen áttúrja. Ne gondolja tehát senki, hogy a core profile egzakt és megingathatatlan szabvány...tulajdonképpen egy ideje ugyanaz a helyzet, mint ami előtte volt; lassan araszolva a Vulkan felé...

A motiváció a következő: van valahány darab már lefordított és összelinkelt shader programom, viszont szeretnék bizonyos shader stage-eket összekeverni. Például az egyik programból a vertex shader-t együtt használni egy másik program fragment shader-ével. Röviden ezt mix-and-match-nek szokták mondani.

Ennek eléréséhez egy új konkténer objektum került be GL 4.1-be, amit program pipeline-nak hívnak, és már összelinkelt shader programokból tud tetszőlegesen szemezgetni. Ennek előfeltétele, hogy a GL_PROGRAM_SEPARABLE paraméter meg legyen adva linkelés előtt. Ekkor kötelező újradeklarálni minden bejövő és kimenő interfészblokkot a shaderben (gl_PerVertex).

(megj.: van egy új függvény shader program létrehozására (glCreateShaderProgramv), de ezzel a program csak egyféle shader stage-et fog tartalmazni és nem lehet további paramétereket hozzáadni. GL 4.4-ben ki lett bővítve)

CODE
GLuint vertprogram = 0; GLuint geomfragprogram = 0; GLuint progpipeline = 0; GLuint inputlayout = ...;

CODE
vertprogram = glCreateShaderProgramv(GL_VERTEX_SHADER, 1, &vs_code); geomfragprogram = glCreateProgram(); // ... glProgramParameteri(geomfragprogram, GL_PROGRAM_SEPARABLE, GL_TRUE); glLinkProgram(geomfragprogram); glGenProgramPipelines(1, &progpipeline); glUseProgramStages(progpipeline, GL_VERTEX_SHADER_BIT, vertprogram); glUseProgramStages(progpipeline, GL_GEOMETRY_SHADER_BIT|GL_FRAGMENT_SHADER_BIT, geomfragprogram);

CODE
glProgramUniformMatrix4fv(vertprogram, 0, 1, GL_FALSE, viewproj); glProgramUniform2fv(geomfragprogram, 1, 1, pointsize); glBindProgramPipeline(progpipeline); glBindVertexArray(inputlayout); glDrawArrays(...);

A használat elég intuitív, úgyhogy shader kódot nem is írok (pontokat rajzol ki a megadott méretben). Viszont az uniformokkal már megint mi történt... Szóval az új funkcionalitás miatt gyakorlatilag az összes glUniformXX függvényt meg kellett duplikálni glProgramUniformXX néven... Persze lehet, hogy a régi módszerrel is megoldható, viszont alapszabály OpenGL-ben, hogy nem keverjük a régit az újjal. Ha mégis szeretnéd, akkor a glActiveShaderProgram() hívást használd (ne a glUseProgram()-ot!). Sőt, a legjobb az, ha inkább uniform buffer-t használsz.


Tovább általánosítható a shaderek használata az ún. szubrutinokkal. Régen, ha csak picit szerettél volna módosítani egy shader működésén, akkor vagy egy külön shadert írtál, vagy nekiálltál makrókat használni (az eredmény persze így is több külön shader program lett).

A szubrutinok segítségével futási időben váltogathatod, hogy egy (shader) program milyen alprogramot hívjon meg, akár drasztikusan más működéssel! Vadul írok is egy példát (az 51_TessellationShader példaprogramban konkrétan használom is):

CODE
// (vertex) shader oldal #version 430 // szubrutin deklaráció subroutine vec4 CalcVertexFunc(); in vec3 my_Position; uniform mat4 matProj; subroutine uniform CalcVertexFunc vertexFunc; // szubrutin példányosítások layout(index = 0) subroutine(CalcVertexFunc) vec4 CalcVertexNoProj() { return vec4(my_Position, 1.0); } layout(index = 1) subroutine(CalcVertexFunc) vec4 CalcVertexProj() { return matProj * vec4(my_Position, 1.0); } void main() { gl_Position = vertexFunc(); }
CODE
// OpenGL oldal GLuint funcindex; glUseProgram(program); // nincs vetítés (screen space) funcindex = 0; glUniformSubroutinesuiv(GL_VERTEX_SHADER, 1, &funcindex); glDrawArrays(...); // van vetítés (view space) funcindex = 1; glUniformSubroutinesuiv(GL_VERTEX_SHADER, 1, &funcindex); glUniformMatrix4fv(...); glDrawArrays(...);

Gondolom feltűnt, hogy GL 4.3 szintaxissal írtam a példát; ezt csak kényelmi okokból tettem (az index megadható a shaderben), de persze a szubrutinok már GL 4.0 óta elérhetőek. Értelemszerűen olyankor az indexeket program oldalon kell lekérdezni a glGetSubroutineIndex() függvénnyel. Különleges dolog ebben, hogy meg kell adni a konkrét shader stage-et, amiben a szubrutin szerepel; hát persze, hiszen ez már nem a globális (shader) programra vonatkozik, hanem lokális az egyes shader szakaszokban!

Némi magyarázat azért szükséges. A szubrutinok fejléceit előre kell deklarálni egy konkrét névvel, amire később majd hivatkozni lehet a shaderen belüli (kvázi) példányosításokkal. Értelemszerűen egy példányosítás a deklarált szubrutin egy konkrét implementációja (a kívánt logika szerint).

A program oldalhoz való kötődés az ún. subroutine uniform-okkal valósul meg. Bár első ránézésre hasonló, ez nem egy szokványos uniform adat. Ugyanis a többivel ellentétben ennek nem location-t kell megadni, hanem (mint mondtam) a shader stage-et (és hogy azon belül mennyit akarsz beállítani).

A bekezdés végén annyit mondanék, hogy bár a metál shader nyelve még mindig toronymagasan vezet, nem kell különösebb észrevétel ahhoz, hogy lássuk honnan lopott. Egyértelmű haszon azonban, hogy ezáltal könnyebb közös interfészt húzni a kettő fölé.


Ez egy nagyon izgalmas dolog, ugyanis használható debug célokra is (pl. adott shader stage kimenetét visszaolvashatom), de van egy népszerű gyakorlati alkalmazása is, mégpedig a részecskerendszerek. Használható egyéb dolgokra is, Rákos Dániel például ezzel oldja meg a view frustum culling-ot instance-olt rajzolásra (nevet is adott a technikának: instance cloud reduction).

A részecskerendszer egy olyan állat, amivel (pongyolán mondva) folyadékdinamikán alapuló rendszereket lehet modellezni és megjeleníteni. Ebbe beletartozik a folyadékok és a gázok fizikája is, tehát grafika szempontból egy kalap alatt van a tűz, víz, füst, eső, stb. megjelenítésével. Ha emlékeztek még a kódmellékletemre, abban van egy tűz-részecskerendszer példa DirectX 9-el, amiben a részecskéket CPU oldalon animálom egy viszonylag egyszerű képlet alapján, majd ezekből screen aligned (mindig a kamera felé néző) textúrázott négyszögeket (billboard) csinálok. Ígértem hozzá cikket, de az elmélet bonyolultsága miatt nem akartam foglalkozni vele. A transform feedback úgy tud ezen javítani, hogy a részecskék teljes élettartamát (generálás, animáció, halál) átviszi a GPU-ra.

(megj.: mint mondtam ez megtehető SSBO használatával is, így metálban ez sincs; emulálni kell)

Magának a módszernek két változata van: az első csak primitive capture-t tud, de GL 3.0 óta elérhető; ilyenkor egy glBeginTransformFeedback() hívás utáni rajzolóhívások által előállított primitívek a beállított bufferbe íródnak. Ezzel külön nem foglalkozok.

A kibővített változatba bekerült a transform feedback object, amihez GL 4.0 szükséges. Ez egy konténer objektum, ami a transform feedback állapotát tárolja, pl. az említett buffert (ilyen szempontból hasonlít a VAO-hoz). A funkcionalitása szélesebbkörű azáltal, hogy egy ilyen objektum "tartalmát" egyből ki is lehet rajzolni (vagy visszaetetni egy másik feedback objektumba) a glDrawTransformFeedback() hívással. A részecskerendszer szempontjából az alábbi lépések történnek egy frame alatt:

  • a generátor részecskék frissítése (pl. egy fáklya lángja veled együtt mozog)
  • új részecskék generálása a generátorokból (pl. a láng)
  • hozzáadás a már létező részecskékhez
  • a részecskék frissítése (pozíció, sebesség, szín, halál, stb.)
Mivel generátorokból általában kevés van, az első lépést CPU oldalon szokták elvégezni (de persze semmi akadálya, hogy átvidd ezt is shaderbe). A további lépésekhez három darab feedback object, illetve ugyanennyi hozzájuk tartozó vertex buffer-re lesz szükség. Az egyik buffer mindig az újonnan generált részecskéket tartalmazza, a másik kettő pedig ping-pong jelleggel frissül a régi és az új részecskék úniójaként.

Shader oldalról rögtön a legújabb, GL 4.4-es megközelítést fogom használni, mert kényelmesebb (pl. a már említett új program API-ra van szabva). Az alábbi vertex-geometry shader páros generál új részecskéket:

CODE
#version 440 layout(location = 0) in vec4 particlePos; layout(location = 1) in vec4 particleVel; layout(location = 3) in vec4 particleColor; out vec4 vs_particlePos; out vec4 vs_particleVel; out vec4 vs_particleColor; void main() { vs_particlePos = particlePos; vs_particleVel = particleVel; vs_particleColor = particleColor; }
CODE
#version 440 layout(points) in; layout(points, max_vertices = 40) out; in vec4 vs_particlePos[]; in vec4 vs_particleVel[]; // w = age in vec4 vs_particleColor[]; layout(location = 2) uniform float emitRate; layout(xfb_buffer = 0) out GS_OUTPUT { layout(xfb_offset = 0) vec4 particlePos; layout(xfb_offset = 16) vec4 particleVel; layout(xfb_offset = 32) vec4 particleColor; } gl_out; void main() { if( particleVel[0].w >= emitRate ) { for( int i = 0; i < 40; ++i ) { // valamilyen logika szerint vec3 dir = ...; gl_out.particlePos = vs_particlePos[0]; gl_out.particleVel = vec4(dir, 0,0); gl_out.particleColor = vec4(1.0); EmitVertex(); } } }

A transform feedback azokat a kimenő értékeket fogja eltárolni, amikhez meg van adva xfb_offset. GL 4.4 előtt ezeket OpenGL oldalon kell megadni a glTransformFeedbackVaryings() függvénnyel, még a (shader) program linkelése előtt. Az xfb_buffer azt mondja meg, hogy melyik binding point-ban levő bufferbe kerüljenek az adatok (írhat ugyanis egyszerre több bufferbe is).

Vegyük észre, hogy egyik shader sem ad ki gl_Position-t, sőt nem is adhat, mert ilyenkor a raszterizációt ki kell kapcsolni (GL_RASTERIZER_DISCARD). A bufferbe ugyanakkor konkrét primitívek fognak íródni (jelen esetben pontok). Most akkor térjünk át ennek a CPU oldali részére:

CODE
GLuint emittersbuffer = ...; // ebben vannak a generátorok GLuint particlebuffers[3] = { 0, 0, 0 }; GLuint transformfeedbacks[3] = { 0, 0, 0 }; GLuint inputlayout = ...; GLuint emitpipeline = ...; // úgy, mint fent GLuint updatepipeline = ...; int currentbuffer = 0; bool prevbufferusable = false;

CODE
// inicializálás glGenBuffers(3, particlebuffers); glGenTransformFeedbacks(3, transformfeedbacks); for( int i = 0; i < 3; ++i ) { glBindBuffer(GL_ARRAY_BUFFER, particlebuffers[i]); glBufferData(GL_ARRAY_BUFFER, MAX_PARTICLES * sizeof(Particle), NULL, GL_STATIC_DRAW); glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, transformfeedbacks[i]); glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, particlebuffers[i]); }

CODE
// generálás (az új részecskék az utolsó bufferbe mennek) glEnable(GL_RASTERIZER_DISCARD); glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, transformfeedbacks[2]); { glBindProgramPipeline(emitpipeline); glBindVertexArray(inputlayout); glBindVertexBuffer(0, emittersbuffer, 0, sizeof(Particle)); glBeginTransformFeedback(GL_POINTS); { glDrawArrays(GL_POINTS, 0, NUM_EMITTERS); } glEndTransformFeedback(); } // ... folyt ...

A generálás az egyetlen hely, ahol a glDrawArrays() függvényt használom, onnantól a továbbiakat lehet közvetlenül a transform feedback-ből rajzolni. A részecskék frissítése, ahogy mondtam ping-pong stílusban történik:

CODE
// ... folyt ... glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, transformfeedbacks[currentbuffer]); { glBindProgramPipeline(updatepipeline); glBindVertexArray(inputlayout); glBeginTransformFeedback(GL_POINTS); { // beleírom az imént generált részecskéket glBindVertexBuffer(0, particlebuffers[2], 0, sizeof(Particle)); glDrawTransformFeedback(GL_POINTS, transformfeedbacks[2]); // hozzáadom az előző buffer tartalmát if( prevbufferusable ) { glBindVertexBuffer(0, particlebuffers[1 - currentbuffer], 0, sizeof(Particle)); glDrawTransformFeedback(GL_POINTS, transformfeedbacks[1 - currentbuffer]); } } glEndTransformFeedback(); } glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, 0); glDisable(GL_RASTERIZER_DISCARD); currentbuffer = 1 - currentbuffer; prevbufferusable = true;

A kapott eredményből ezután már lehet billboard-okat csinálni és a megfelelő módszerrel kirajzolni. Füst esetén például a részecskéket előbb rendezni kell mélység szerint (pl. compute shader-rel), tűz esetén rögtön ki lehet rajzolni additive blending-gel.

smoke

Tudni kell - és a kódból is látható - hogy a glDrawTransformFeedback() nem állítja be magától a buffert amiből rajzol; azt neked kell megcsinálni. Szintén ehhez kapcsolódik, hogy addig hibát fog dobni, amíg legalább egyszer fel nem lett töltve (ezért kell a prevbufferusable változó).

Természetesen transform feedback-et is lehet instance-olva rajzolni, illetve mint említettem, ha több bufferbe (stream-be) lett kiírva az adat, akkor egy konkrét stream-et a glDrawTransformFeedbackStream()-mel lehet kirajzolni.


A lekérdezések a hatékonyság érdekében ún. query object-ekkel vannak megvalósítva. A legtöbb ilyennek hatóköre (scope) van, ami egy glBeginQuery()/glEndQuery() blokkot jelent. Ezen blokkon belüli hívásokra vonatkozik a lekérdezés. Tekintsük át a lehetséges típusokat:

  • GL_SAMPLES_PASSED: hány fragment ment át a depth test-en (occlusion query)
  • GL_ANY_SAMPLES_PASSED: volt-e olyan ami átment (occlusion query boolean)
  • GL_ANY_SAMPLES_PASSED_CONSERVATIVE: gyorsabb, viszont gyakran hamis eredményt (false positive) ad
  • GL_PRIMITIVES_GENERATED: hány vertexet generált a geometry shader (vagy a vertex shader)
  • GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN: hány vertex került az aktuális transform feedback object-be
  • GL_TIME_ELAPSED: mennyi ideig tartottak a blokkon belüli hívások
  • GL_TIMESTAMP: visszaadja az aktuális GPU időt (ennek nincs hatóköre)
Mivel a geometry shader és a transform feedback több bufferbe is írhat, az erre vonatkozó lekérdezések indexeltek, szóval ezekre a glBeginQueryIndexed() és glEndQueryIndexed() függvényeket célszerű használni. A lekérdezés eredményét a glGetQueryObject() függvénnyel lehet megkapni. Természetesen ennek a működését vezérelni lehet az alábbi parancsokkal:
  • GL_QUERY_RESULT: kérem az eredményt
  • GL_QUERY_RESULT_NO_WAIT: ha van eredmény, akkor add ide (GL 4.4)
  • GL_QUERY_RESULT_AVAILABLE: van-e már eredmény
Az utóbbiakat tipikusan az occlusion query-ben szokás használni, ugyanis normális programozónak esze ágában nincs bevárni amíg az a rengeteg rajzolás lefut (így gyakorlatilag az előző frame eredménye alapján célszerű dolgozni). Nézzünk egy rövid példát, ami még véletlenül sem occlusion query :

CODE
GLuint countquery = ...; // glGenQueries() glBeginQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, countquery); { // a fenti transform feedback példakód } glEndQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN); GLuint count = 0; glGetQueryObjectuiv(countquery, GL_QUERY_RESULT, &count);

Nem egy bonyolult dolog; naná, hiszen pont olyan példát választottam, amiben muszáj bevárnom az eredményt (CPU-n rendezem a részecskéket). Az optimalizált változat megírása höfö.

Amíg megírjátok a bitonikus rendezést compute shader-rel (amit majd elküldtök nekem is), bemutatok egy GL 4.4-es új fogalmat, amit query buffer object-nek hívnak. Ez értelemszerűen arra való, hogy ne kelljen CPU oldalra lekérdezned az eredményt, hanem egyből vissza tudd etetni egy shadernek. Lássuk:

CODE
GLuint countquery = ...; // glGenQueries() GLuint querybuffer = ...; // glGenBuffers() // ... glBindBuffer(GL_QUERY_RESULT_BUFFER, querybuffer); glGetQueryObjectuiv(countquery, GL_QUERY_RESULT, 0); glUseProgram(...); glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, querybuffer); for( ... ) glDispatchCompute(...);

Nem is értem miért segítek nektek ennyit...így már csak a bitonikus rendezést kell megírjátok...

Szintén elég hasznos dolog (és GL 3.0 óta elérhető) a feltételes rajzolás, ami a glBeginConditionalRender() hívással kezdeményezhető, viszont csak és kizárólag occlusion query-vel működik. Ez tulajdonképpen a régi változata az imént bemutatottnak. Az alábbi parancsokkal lehet vezérelni:
  • GL_QUERY_WAIT: megvárja az eredményt
  • GL_QUERY_NO_WAIT: nem várja meg, ilyenkor rád van bízva, hogy ne rajzolj hülyeséget
  • GL_QUERY_BY_REGION_WAIT: megvárja, de a rajzolás csak az átment fragmentek helyén fog megjelenni
  • GL_QUERY_BY_REGION_NO_WAIT: ezzel lehet memóriaszemetet rajzolni a képernyőre
Nyilván a feltételes rajzolóblokk végét egy glEndConditionalRender() hívással jelzed. Nagyon fontos megjegyezni, hogy ez a fajta rajzolás sosem blokkoltatja a CPU-t (a GPU-t is csak akkor, ha azt mondtad neki)!


Ebben a bekezdésben is alapvetően egy régi (multi-draw) és egy újabb (multi draw indirect) technikáról lesz szó. A multi-draw arra való, hogy szelektíven rajzolj egy static batch-ből (pl. csak azokat az elemeket, amiket kiválasztottál). GL 2.0 óta elérhető, a használata pedig a következőképpen fest:

CODE
GLsizei counts[] = { obj1indexnum, obj3indexnum, obj52indexnum, ... }; const GLvoid* offsets[] = { obj1offset, obj3offset, obj52offset, ... }; glMultiDrawElements(GL_TRIANGLES, counts, GL_UNSIGNED_INT, offsets, ARRAY_SIZE(offsets));

Amit meg kell említeni, hogy ez a szelektív viselkedés nem feltétlenül hardveresen gyorsított: megeshet, hogy a driver csinál magában egy for ciklust, ami szinte egyenlő a konyha rajzolással (persze kevesebb driver overhead). Szóval nem kell sietni a szelekciórajzolás átírásával...

GL 4.3-tól elérhető hasznosabb technika a multi-draw indirect, ami a static batching-et egyesíti a hardware instancing-al. Effektíve ez azt jelenti, hogy az azonos input layout-tal (és material-lal) rendelkező objektumaidat összepakolod egy bufferbe, a hozzájuk tartozó instance adatokat egy másik bufferbe, a rajzolóhívásokat leíró paramétereket pedig egy indirect buffer-be. Utóbbit nézzük meg:

CODE
GLuint inputlayout = ...; // hasonlóan, mint instancing-nál GLuint multibatchindexbuffer = ...; // 3 különböző objektumot pakoltam össze GLuint instancebuffer = ...; // instance adat GLuint indirectbuffer = 0; // indirekt rajzolóparancsok

CODE
glGenBuffers(1, &indirectbuffer); glBindBuffer(GL_DRAW_INDIRECT_BUFFER, indirectbuffer); glBufferData(GL_DRAW_INDIRECT_BUFFER, 3 * sizeof(DrawElementsIndirectCommand), NULL, GL_STATIC_DRAW); DrawElementsIndirectCommand* cmddata = glMapBuffer(GL_DRAW_INDIRECT_BUFFER, GL_WRITE_ONLY); { cmddata[0] = ...; cmddata[1] = ...; cmddata[2].count = obj3indexnum; cmddata[2].instanceCount = obj3instancenum; cmddata[2].firstIndex = obj1indexnum + obj2indexnum; cmddata[2].baseVertex = 0; cmddata[2].baseInstance = obj1instancenum + obj2instancenum; } glUnmapBuffer(GL_DRAW_INDIRECT_BUFFER);

CODE
// rajzolás glBindVertexArray(inputlayout); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, multibatchindexbuffer); glBindBuffer(GL_DRAW_INDIRECT_BUFFER, indirectbuffer); glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_INT, 0, 3, sizeof(DrawElementsIndirectCommand));

Világos, hogy ezzel a fajta megoldással lényegesen kevesebb memória felhasználásával oldhatóak meg kombinált instancing feladatok. Természetesen ugyanazok a meggondolások vonatkoznak erre is: a textúrákat atlaszba kell pakolni, a címzési módokat pedig emulálni (ha szükséges).

multi-draw

Egy GL 4.0-tól elérhető előzetese ennek a glDrawElementsIndirect(), ami csak annyiban kevesebb, hogy egy objektumot tud indirekt paraméterekkel rajzolni. Na oké, de miért jó az, hogy a paraméterek a GPU-n vannak? Most mindenkinek felcsillan a szeme: mert előállíthatom shaderből is (akár compute shader-rel [SSBO-ként leküldve] vagy transform feedback-kel)!

Zárójelben mondom, hogy létezik GL_DISPATCH_INDIRECT_BUFFER is, amivel compute shader-ekre lehet hasonló hívást tenni; ebben az esetben a workgroup méreteit tartalmazza. Multi-dispatch egyelőre nincs (nem is világos, hogy kell-e).


És ezzel elérkeztünk az OpenGL 4.6 "újdonságához" (amiről megbeszéltük, hogy nem az): nagyon szép, hogy a shader is elő tudja állítani a paramétereket, na de mennyit állított elő? Lekérdezhetem természetesen query-vel vagy atomic counter-el, de az megakasztja a CPU-t. Az új kiterjesztés (GL_ARB_indirect_parameters) célja az, hogy ezt a lekérdezést is megússzuk, így a teljes indirekt rajzolás átvihető a GPU-ra.

Ez a bizonyos általános objektum a parameter buffer, ami bár tetszőleges adatot tárolhat, a rajzolóhívás értelemszerűen csak egy GLsizei típusú értéket fog kiolvasni: ezt a bizonyos "mennyit". Példakódok nincsenek, de szerencsére GL 4.2 óta elérhető ez a dolog extension-ként. Én most egy compute shader-rel fogom előállítani az indirect command-okat:

CODE
#version 450 layout(std430, binding = 0) writeonly buffer IndirectCommands { DrawElementsIndirectCommand data[]; } commands; layout(std430, binding = 1) writeonly buffer ParameterBuffer { int count; } numCommands; void main() { // valamilyen logika szerint kitöltve commands.data = ...; numCommands.count = ...; }
CODE
glUseProgram(computeprogram); glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, indirectbuffer); glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, parameterbuffer); glDispatchCompute(1, 1, 1); glMemoryBarrier(GL_COMMAND_BARRIER_BIT); glBindVertexArray(inputlayout); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, multibatchindexbuffer); glBindBuffer(GL_DRAW_INDIRECT_BUFFER, indirectbuffer); glBindBuffer(GL_PARAMETER_BUFFER, parameterbuffer); glMultiDrawElementsIndirectCount( GL_TRIANGLES, GL_UNSIGNED_INT, 0, 0 3, sizeof(DrawElementsIndirectCommand));

(megj.: a barrier-ek közül a Khronos kifelejtette a GL_PARAMETER_BARRIER_BIT-et, de elvileg már tudnak róla)

Az új rajzolóhívás értelemszerűen egy offsetet vár a parameter buffer-be, illetve meg kell neki adni egy maximális rajzolóhívásszámot (ha esetleg többlet van a bufferben, azt figyelmen kívül hagyja).

Az eddig áttekintett (vagy csak megemlített) módszereket ötvözve arra a konklúzióra juthatunk, hogy egy modern megoldással tisztán a GPU-ra lehet áttolni valamilyen funkcionalitás (akár teljes) életciklusát.


Persze ez a rengeteg új tudás gyönyörűen változatos bugokat tud előidézni. Az első debug módszert úgy hívják, hogy AMD kártyán fejlesztés (ha az nincs, akkor Intel is jó). Ugyanis az nVidia driver beképzelt módon sokkal okosabbnak képzeli magát nálad:

"Felülírtad a buffert szinkronizáció nélkül? Ejnye-bejnye...na majd én kijavítom helyetted."

Mert ilyen rendes. Aztán átviszed a programodat egy másik gépre, ahol szanaszét száll, és persze az AMD és az Intel drivereit fogod szidni, hogy milyen ótvarok (ez a "nálam működik" nevű kifogás 3D grafikai megfelelője).

Az AMD driver nem ilyen pirított ribanc módjára viselkedik, hanem hozzád alkalmazkodik: ha hülyeséget mondtál neki, akkor hülyeséget fog csinálni. A hülyeségek megtalálásához pedig nagyon hasznos debug eszközök léteznek. GL 4.3 óta például ott van az ARB_debug_output, ami hasonlóan működik mint vulkánban, tehát azonnal szólni fog ha egy klasszikus OpenGL hibát vétettél, de akár tippeket is adhat neked, hogy mitől lehet hatékonyabb a programod.

CODE
static void APIENTRY ReportGLError( GLenum source, GLenum type, GLuint id, GLenum severity, GLsizei length, const GLchar* message, const void* userdata) { __debugbreak(); }

CODE
glEnable(GL_DEBUG_OUTPUT); glDebugMessageControl(GL_DONT_CARE, GL_DONT_CARE, GL_DONT_CARE, 0, 0, GL_TRUE); glDebugMessageCallback(ReportGLError, 0);

Mint mondtam, a kontext létrehozásakor meg kell adni a WGL_CONTEXT_DEBUG_BIT-et. Mivel macOS-en nincsen GL 4.3, ott ezt nem használhatod (cserébe normális debug eszköz sincs).

Windows-on két különálló debug programot emelnék ki. Az egyik az AMD CodeXL, ami a már régóta halott gDebugger örököse. Egyetlen dologra használom: a hibaüzenetek elkapására. Másra ugyanis teljesen használhatatlan (leginkább lefagy). A breakpoints menüben a debug output-nál lehetőleg mindent jelölj be.

Egy mérföldekkel jobb program a RenderDoc, ami bár OpenGL-el limitáltan működik (csak core profile, nem tud shadert debugolni) még így is felülmúlja a néhai PIX-et. Annyi megszorítás van, hogy a program 64 bites kell legyen, illetve ahogy mondtam, ha több kontext van, azokat meg kell osztani.

Képes megmutatni a teljes pipeline állapotát az egyes rajzolóhívásokban, azon belül megnézhetőek a buffer/textúra tartalmak, sőt a geometria adatokat a Mesh Output fülön meg is jeleníti (a navigáció kicsit hektikus, de szokható).

renderdoc

Különösen tetszik, hogy a Texture Viewer fülön megtekinthető a framebuffer aktuális állapota az összes létező attachment-jével. Külön állítani lehet, hogy pl. milyen tartományban mutassa a depth/stencil buffer tartalmát, és persze tetszőleges pixelre meg tudja mondani az értékeket. Meg egy csomó egyéb dolog.

Annyi hátránya azért van, hogy driver hibákkal nem foglalkozik, szóval ha a driver bugos, akkor azt egy elszállással fogja jutalmazni. Ilyenkor állíts be kb. 20 másodperc várakozást és csatlakozz rá a programra Visual Studio-ból. Ha te fordítottad le az eszközt, akkor könnyen megtalálható, hogy mi okozta a driver hibát (én kénytelen voltam átírni a kódját, hogy megkerülje valahogy).

Na de mi a helyzet macOS-en? Tulajdonképpen az egyetlen valamire használható debug eszköz az OpenGL Profiler, amit külön kell letölteni a fejlesztői oldalról. Lehetőleg mindig a legújabbat töltsed le, még ha béta is, ugyanis ami neked kellene az sosem működik. Hibákat ez is el tud kapni, illetve rá lehet állni tetszőleges OpenGL hívásra; ekkor meg tudja mutatni a buffer/textúra tartalmakat. Szódával elmegy, de nem mondanám túl okosnak.

Helyette használható még az apitrace, de körülményes a beállítása és a használata... Ha minden más kudarcot vallott, akkor próbálkozhatsz vele. Ha ez se segít, akkor marad a zseniális printf() nevű találmány.


A leírt technikákra bár adtam konkrét példákat (a szokott helyen), az alkalmazási területeik ennél sokkal messzebbre nyúlnak; természetesen az adott applikációra kell ezeket szabni. A célom az, hogy tudjatok ezekről, amikor egy új alkalmazást szeretnétek fejleszteni (vagy egy régit gyorsítani), ugyanis a GPU ezen új API-k által valóban használható általános célú programozásra; különösen fontosnak tartom emiatt, hogy legalább fogalmi szinten meglegyenek ezek a dolgok nem csak a GPU programozók fejében.

Mint említettem a compute shader-ről dedikált cikkem van, ezért nincs itt részletezve. Ugyanabban a cikkben találhatóak példák SSBO-ra, atomic counter-re és shader image load/store-ra is.

További dolgok, amikről itt nem tettem említést megtalálhatóak a Vulkan-ról szóló cikkben. Jelen állapotában az OpenGL, bár részhalmaza a Vulkan funkcionalitásának, gyakorlati szempontból alig (!) marad el tőle; értelemszerűen a programozása valamivel könnyebb.


Höfö:

  • Tanújjá' mert...meeeeeeeert...
  • ...úgyhogy.

Irodalomjegyzék

https://www.khronos.org/registry/OpenGL/specs/gl/glspec46.core.pdf - OpenGL 4.6 specification (Khronos, 2017)
https://www.khronos.org/registry/OpenGL/specs/gl/GLSLangSpec.4.60.pdf - GLSL 4.60 specification (Khronos, 2017)
https://www.khronos.org/opengl/wiki/ - OpenGL Wiki (Khronos)
http://web.engr.oregonstate.edu/~mjb/.../tessellation.1pp.pdf - Tessellation Shaders (Oregon State University, 2017)
https://www.seas.upenn.edu/~cis565/.../GPU%20Tessellation.pptx - GPU Tessellation (University of Pennsylvania, 2010)

back to homepage

Valid HTML 4.01 Transitional Valid CSS!