71. fejezet - Vulkan


Tavaly év vége felé, amikor az első hírek röppentek fel a Vulkan-ról (nevezetesen az, hogy késni fog) én csak vidáman legyintettem az egészre, hogy "jólvan kihoznak egy Mantle klónt, azt is késve, ugyan ki fogja ezt használni?". Főleg, mert addigra elérhető volt a DirectX 12 és a Metal is (hacsaknem dokumentáció szinten), emiatt akkor úgy tűnt, hogy a Vulkan-t legfeljebb Android/Linux-on fogják használni, így ezt a cikket is kifigurázó hangvételűnek terveztem.

Most viszont, ahogy már hónapok óta nyüstölöm ezt az új API-t, a kezdeti szkepticizmusom propellergyorsasággal átment mániás depresszióba. A korábbi mondat ilyetén módon a következőre változik: "hát ez valami bitang pusztulatosan nehéz, ugyan ki fogja ezt használni?". A helyzet az, hogy a vulkán abszolút nem kezdőknek való, tehát ha most állsz neki 3D grafikát tanulni (és ezt az irodalmak is kiemelik), akkor kezdd inkább OpenGL-el, vagy legalább egy wrapper library-vel.

Azt elmondanám, hogy még a specifikáció sem teljesen tiszta, fejlődik és gyakran a hardvergyártók is önkényesen értelmezik (így választ sem tudnak adni bizonyos kérdésekre). Hasonló igaz a validációs rétegekre és debug eszközökre.

(megj.: unortodox módon a globális változókat külön kódblokkba fogom tenni)



A 3D grafika nagy elméleti problémái (fotorealizmus) után kicsit gyakorlatiasabb vizekre evezünk, amit úgy lehet megfogalmazni, hogy "hát szép-szép ez a pónilósimogató játék, de nem lehetne gyorsabb?" A problémának (vagy inkább elérendő célnak) neve is van: AZDO, azaz Approaching Zero Driver Overhead. Ezt nem most találták ki, már OpenGL-hez is van egy rakat trükk, amivel el lehet érni; a vulkán viszont kimondottan erre épít.

Rögtön az elején kőbe vésném, hogy a vulkánra való ész nélküli áttérés kidobott munka akkor, ha a programod:

  • CPU limites
  • GPU limites
  • nem párhuzamosítható
Maximum akkor jelent gyorsulást, ha a program kimondottan driver limites (pl. sokszázezer kis poligonszámú objektumot rajzolsz egyesével). Eszedbe ne jusson tehát a jól megírt OpenGL programodat átírni vulkánra, mert egyrészt hússzor annyi kódolás, másrészt legfeljebb ugyanolyan gyors lesz.

A vulkán tehát új fejlesztésekhez ideális, na de miért lesz tőle gyors a program? Röviden azért, mert az OpenGL-el szemben több szálból is használható, nagyon minimális ellenőrzéseket csinál, nem foglalkozik memóriakezeléssel és szintén nem foglalkozik a rajzolóparancsok egymás közti függőségeivel. Ez egyben azt is megindokolja, hogy mitől olyan nehéz (newsflash): azért mert mindezeket neked kell megcsinálnod. Demotiváló ábra következik:

opengl_vs_vulkan

Ez egy hasonló ábra az nVidia prezentációjában megtalálhatóhoz, de kicsit túlzsúfolt kompaktabb. Nem baj, ha most még nem érted, de az jól látszik belőle, hogy a driver (feltevései) által előállított dolgokat most már az applikációnak kell megmondania.

Csak egy példa: a kód tele van pipeline barrier-ekkel, ugyanis explicit meg kell váratnod a driverrel amíg írhatóvá/olvashatóvá válik mondjuk egy textúra. Később érthető lesz, hogy ennek fényében a Metal-t miért nem tekintem alacsony szintűnek (röviden: mert bár állításuk szerint AZDO, mégis rengeteg ilyen dolgot elfed).

Az a tény, hogy a vulkán mindent a kezedbe ad, magával hozza azt a kellemetlen apróságot is, hogy az égvilágon mindent előre meg kell neki mondanod: melyik render subpass hogyan módosítja majd az attachment-jeit, milyen parancsokat fogsz azon belül meghívni, milyen shaderek lesznek beállítva, azokhoz milyen bufferek lesznek bebindolva, a textúrák milyen layout-ban lesznek, etc. etc.

Ne értsük félre: ez indokolt, hiszen a korábbi API-kban sok ilyen változtatgatás a shader implicit újrafordítását eredményezte! A nehézség két dologból tevődik tehát össze: közvetlen szinkronizáció és rengeteg kódolás. De hát azt mi szeretjük, nem?


Az elinduláshoz először is telepíteni kell egy megfelelő (a cikk írásának pillanatában béta) drivert (nVidia, AMD). Nekem a GTX 650-en ez telepítés közben mindig lefagyott; csak úgy tudtam felrakni, hogy safe mode-ban letöröltem a régit. Ha programozni is szeretnél, akkor szükség van még a LunarG SDK-ra (ezt minden driver frissítés után rakd újra!), és a cmake-re (mert bizony néhány dolgot neked kell lefordítani). A cikk kódjába én minden szükséges dolgot bepakoltam, úgyhogy ezt a lépést megúsztátok (szívesen). Zárójelben mondom, hogy a vulkános példaprogijaim csak 64-bitesek (bár nem követelmény, de célszerű...).

Kezdjük akkor az első lépésekkel: a fizikai hardverek lekérdezése és "kontext" (device) létrehozás. Ahhoz, hogy egyáltalán szóba álljon velünk a driver, példányosítani kell a vulkánt, megadva mindenféle lényegtelen infót az alkalmazásról (pl. a nevét...). Az általánosságban jellemző a vulkánra, hogy ki kell tölteni egy vagy több struktúrát, majd meghívni egy függvényt. A struktúrákat helyhiány miatt a cikkben nem mindig fogom teljesen kitölteni:

CODE
VkInstance instance = 0;

CODE
VkApplicationInfo app_info = {}; VkInstanceCreateInfo inst_info = {}; VkResult res; app_info.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; app_info.pNext = NULL; app_info.pApplicationName = "Asylum's Vulkan sample"; app_info.applicationVersion = 1; app_info.pEngineName = "Asylum's sample engine"; app_info.engineVersion = 1; app_info.apiVersion = VK_API_VERSION_1_0; inst_info.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; inst_info.pNext = NULL; inst_info.flags = 0; inst_info.pApplicationInfo = &app_info; // ... res = vkCreateInstance(&inst_info, NULL, &instance); assert(res != VK_ERROR_INCOMPATIBLE_DRIVER);

Ez a kód egyben recept is a továbbiakhoz: minden vulkán struktúra szigorúan típusos, illetve az extension-ök számára fenntart egy pNext pointert.

Ahogy a hagyma és az ogre, úgy a vulkán is rétegekből épül fel. A legalsó réteg nyilván a vulkan core layer, ami önmagában nagyon minimális ellenőrzéseket csinál csak, így roppant egyszerűvé vált a számítógép lefagyasztása (erősen javaslom, hogy rakjál __debugbreak()-et a debug callback-be). Szerencsére a feltelepített SDK-ban mellékelve van a LunarG standard validation layer. Nézzük meg hogyan kell ezt beüzemelni:

CODE
const char* instancelayers[] = { "VK_LAYER_LUNARG_standard_validation" }; const char* instanceextensions[] = { "VK_KHR_surface", "VK_KHR_win32_surface", "VK_EXT_debug_report" // hasonló, mint OpenGL-ben }; inst_info.enabledLayerCount = ARRAY_SIZE(instancelayers); inst_info.ppEnabledLayerNames = instancelayers; inst_info.enabledExtensionCount = ARRAY_SIZE(instanceextensions); inst_info.ppEnabledExtensionNames = instanceextensions; // ...

Egy kalap alá vettem a szükséges extension-ök beállításával. Persze nem biztos, hogy az adott réteg jelen van, ezért úgy tisztességes, hogy előbb lekérdezed a vkEnumerateInstanceLayerProperties() függvénnyel (ehhez nem kell még az instance, szemben az OpenGL-el).

A következő lépésben a fizikai hardverektől (physical device) megkérdezzük a tulajdonságaikat, kiemelten kezelve hogy milyen command queue-kat tudnak, illetve ezek közül melyik alkalmas rajzolásra. A metállal szemben a vulkános command queue-kat tehát a hardver adja, és a majdani (logical) device-al együtt kell létrehozni. Ilyen "hardware queue"-ból a szabvány többfélét enged meg:
  • graphics
  • compute
  • transfer (aszinkron DMA transfer)
  • sparse binding (nem-összefüggő memóriába allokált erőforrások)
Ezeket több szálból is lehet dolgoztatni. Az említett GTX 650-nek két queue-ja van: egy graphics/compute és egy transfer. Újabb hardvereken a compute queue is teljesen különálló lehet, így a rajzolással párhuzamosan lehet futtatni compute shader-eket (kézzel szinkronizálva). Ugyanakkor a szabvány nem teszi kötelezővé ezeknek a meglétét, de pongyolán mondhatjuk azt, hogy ha a Vulkan implementáció tud rajzolni, akkor tud compute shader-t is (de nem feltétlenül ugyanazon a physical device-on; bármily meglepő a CPU is lehet fizikai hardver...).

A vulkán tehát erősen gépfüggő, ami tovább bonyolítja a fejlesztést (erre később több példa is lesz). De nézzük most a hardver tulajdonságainak lekérdezését:

CODE
VkPhysicalDeviceFeatures devicefeatures; VkPhysicalDeviceProperties deviceprops; VkPhysicalDeviceMemoryProperties memoryprops; uint32_t gpucount = 0; uint32_t queuecount = 0;

CODE
vkEnumeratePhysicalDevices(driverinfo.inst, &driverinfo.gpucount, 0); VkPhysicalDevice* gpus = new VkPhysicalDevice[gpucount]; vkEnumeratePhysicalDevices(driverinfo.inst, &gpucount, gpus); // most feltételezem, hogy az első kell, de kódban ezt ellenőrizni illik vkGetPhysicalDeviceQueueFamilyProperties(gpus[0], &queuecount, 0); VkQueueFamilyProperties* queueprops = new VkQueueFamilyProperties[queuecount]; vkGetPhysicalDeviceQueueFamilyProperties(gpus[0], &queuecount, queueprops); // ezek nélkül nem lehet élni vkGetPhysicalDeviceFeatures(gpus[0], &devicefeatures); vkGetPhysicalDeviceProperties(gpus[0], &deviceprops); vkGetPhysicalDeviceMemoryProperties(gpus[0], &memoryprops);

Célszerű mindent megtartani, illetve összeszedni egy (jó nagy...) struktúrába. Nem akarok túl sok kódot írni, úgyhogy a további lépéseket csak felsorolom (ha megnézitek a kódot, akkor megértitek, hogy miért):
  • létrehozni egy Windows-os rajzolható felületet (vkCreateWin32SurfaceKHR)
  • elkérni tőle a megjelenítő formátumot (vkGetPhysicalDeviceSurfaceFormatsKHR)
  • elkérni tőle a prezentációs módokat (vkGetPhysicalDeviceSurfacePresentModesKHR)
  • a queue-k között megkeresni azt, amelyik rajzolni és prezentálni is tud
  • a kigyűjtött adatok alapján létrehozni a VkDevice-t
  • létrehozni a swapchain-t
  • létrehozni a (fő)szál command buffer pool-ját
  • létrehozni a pipeline cache-t (ha akarod; én nem használom)
Ugye nem bánjátok, hogy nem írtam le kóddal? A device létrehozásakor engedélyezni kell a használni kívánt feature-öket (pl. geometry shader), amennyiben támogatva vannak. A device innentől minden egyéb objektum létrehozására lesz használatos.

Mindenkinek figyelmébe ajánlom a VkPhysicalDeviceProperties::limits adattagot, azon belül is a maxMemoryAllocationCount mezőt (melynek értéke desktopon(!) 4096). Vége van ugyanis az OpenGL-ben előszeretettel alkalmazott orbitális mennyiségű resource létrehozásnak: a vulkán rákényszerít a szuballokációra illetve a resource aliasing-ra (több erőforrás ugyanazt a memóriaterületet használja).

A következő N bekezdésben megmutatom hogyan kell eljutni a rajzolásig, majd néhány bónusz bekezdésben részletezem, hogy mit hogyan érdemes használni. A cikk végén megtalálható a fogalmak összefoglaló listája.


Amint az előző bekezdésben megbeszéltük, a vulkán központi objektuma a device, ami elérhetőséget biztosít egy vagy több queue-hoz, amik akár párhuzamosan is dolgozhatnak egymáshoz képest. Létrehozáskor megadható az egyes queue-k prioritása, ami alapján a driver több számítási kapacitást ad a fontosaknak. Egy queue-nak nincs túl sok művelete: "dolgozz" és "várj". Amikor munkát adunk a queue-nak az egy command buffer leküldését jelenti, igény esetén szinkronizációs objektumokkal ("várj előbb ezekre", "szólj ha kész vagy vele").

A konkrét utasítások jelentős részét command buffer-ekbe kell pakolni, amelyeket egy (minden thread-nek saját) command buffer pool-ból lehet létrehozni. Az olyan hardveroptimalizációk miatt, mint a deep pipelining a driver átrendezheti és kipárhuzamosíthatja a bepakolt utasításokat, de a használó felé az elvárt eredményt köteles adni. A leküldött command buffer-ek feldolgozása szintén történhet átfedéssel, de nem rendeződhet át (hacsaknem sparse binding műveletekkel), kivéve ha explicit kikényszerítjük.

A vulkán megkülönböztet elsődleges és másodlagos command buffer-eket. A fő rajzolás mindig egy elsődleges command buffer-ben történik, ami továbbhívhat akár több másodlagos command buffer-t is.

A Metal-al szemben a command buffer-eknek többféle felhasználási módja van (rövidítve a neveket):

  • ONE_TIME_SUBMIT: csak egyszer küldjük le, utána töröljük vagy reseteljük és újra feltöltjük parancsokkal
  • RENDER_PASS_CONTINUE: a másodlagos command buffer egy konkrét render pass-on belül fog végrehajtódni
  • SIMULTANEOUS_USE: már végrehajtásra vár, de ismét le lehet küldeni a drivernek
Nézzünk egy egyszerű példát, ami most még semmi érdemlegeset nem fog csinálni, de egy keretet ad a későbbiekhez:

CODE
VkCommandBuffer commandbuffer = 0; VkCommandBufferAllocateInfo cmdbuffinfo = {}; VkCommandBufferBeginInfo begininfo = {}; VkSubmitInfo submitinfo = {}; // allocate cmdbuffinfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; cmdbuffinfo.pNext = NULL; cmdbuffinfo.commandPool = commandpool; cmdbuffinfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; cmdbuffinfo.commandBufferCount = 1; vkAllocateCommandBuffers(device, &cmdbuffinfo, &commandbuffer); // record begininfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; begininfo.pNext = NULL; begininfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; begininfo.pInheritanceInfo = NULL; vkBeginCommandBuffer(commandbuffer, &begininfo); { // TODO: parancsok } vkEndCommandBuffer(commandbuffer); // submit submitinfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; submitinfo.pNext = NULL; submitinfo.waitSemaphoreCount = 0; // nem várunk senkire submitinfo.pWaitSemaphores = NULL; submitinfo.pWaitDstStageMask = NULL; submitinfo.commandBufferCount = 1; submitinfo.pCommandBuffers = &commandbuffer; submitinfo.signalSemaphoreCount = 0; // nem értesítünk senkit submitinfo.pSignalSemaphores = NULL; vkQueueSubmit(graphicsqueue, 1, &submitinfo, VK_NULL_HANDLE); // "dolgozz" vkQueueWaitIdle(graphicsqueue); // "várj" vkFreeCommandBuffers(device, commandpool, 1, &commandbuffer);

Ez a kódrészlet használható lehetne rajzoláskor is (a command buffer létrehozása/törlése nagyon hatékony), de én csak az inicializáló részben használom transfer műveletekhez. A rajzolásnál kicsit bonyolultabb a helyzet, mert szinkronizációs objektumokat is célszerű használni (a frame queueing érdekében).

A Vulkan legnagyobb előnye a Metal-hoz képest, hogy a command buffer-ek előre legyárthatóak és tetszőlegesen sokszor leküldhetőek. Ennek hozadékait külön bekezdésben ismertetem.


Nagyon kritikus hogy megértsük, mert ezek nélkül nincs élet vulkánban. Négy ilyen objektum van, mindegyik más-más célt szolgál:

  • semaphore: szinkronizáció több queue között vagy egy adott queue-beli submit-ok között
  • barrier: command buffer-en belüli szinkronizáció, tipikusan resource állapotok módosításához
  • event: utasítások közötti finom szinkronizáció; a device és a CPU is használhatja
  • fence: szinkronizáció a device és a CPU között (hasonló, mint a vkQueueWaitIdle())
A semaphore nem valódi szemafor, mert csak két állapota van (signaled, unsignaled) így tulajdonképpen az event-nek egy durvább megfelelője. Ezekkel nem foglalkozom részletesen.

A vkQueueWaitIdle() függvény ekvivalens egy fence-el, ami végtelen ideig várakozik, előbbit az irodalmak mégsem használják. A cél ugyanis az, hogy ne kelljen bevárni az összes command buffer befejeződését, hanem ami kész van, azt rögtön kezdjük el újra feltölteni. Alakítsuk át a fenti kódot, hogy később bővíthető legyen command buffer átfedésekre:

CODE
VkCommandBuffer commandbuffer = ...; VkSemaphore framesema = ...; VkFence drawfence = ...;

CODE
// rajzolás VkCommandBufferBeginInfo begininfo = { ... }; VkSubmitInfo submitinfo = {}; // jelez a szemafornak amikor a prezentáció befejezte az olvasást (ld. Frame queueing) vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, framesema, NULL, &currentimage); vkResetCommandBuffer(commandbuffer, 0); vkBeginCommandBuffer(commandbuffer, &begininfo); { // TODO: parancsok } vkEndCommandBuffer(commandbuffer); VkPipelineStageFlags pipestageflags = VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT; submitinfo.waitSemaphoreCount = 1; submitinfo.pWaitSemaphores = &framesema; // várunk a prezentálóra submitinfo.pWaitDstStageMask = &pipestageflags; // hol várunk (pipeline vége) vkResetFences(device, 1, &drawfence); vkQueueSubmit(graphicsqueue, 1, &submitinfo, drawfence); // most egyelőre megvárom amíg befejeződik vkWaitForFences(device, 1, &drawfence, VK_TRUE, 100000000);

Vulkánban tehát a fence tölti be a metálban használt szemafor szerepét. Vigyázat, a vkQueueSubmit()-ban megadott fence akkor lesz csak értesítve, amikor az összes (abban az egy hívásban) leadott command buffer befejeződött! A prezentálást a frame queueing-al együtt ismertetem egy későbbi bekezdésben.

Hátravan még a leggyakrabban használt szinkronizációs objektum: a barrier. Ebből háromféle is van:
  • memory (mindenféle memóriatípushoz)
  • buffer (csak buffer memóriához)
  • image (csak image memóriához)
Mindegyikben közös (és ezt fontos megérteni), hogy két memóraelérés-fajta között húz egy határt (src/dst access). Ilyen elérés például az, hogy rajzolok egy render target-be (VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT), amit aztán egy shader olvasni fog (VK_ACCESS_SHADER_READ_BIT). Világos, hogy a kettő között függőség van, ezért ezt egy barrier-el fogom jelezni. Mindhárom típust a vkCmdPipelineBarrier() hívás kezeli, de a render pass-ok implicit is előállíthatják! A további bekezdésekben lesz példa.


Az egyik indok az alacsony szintű programozás mellett az, hogy a hardver számára optimális helyre és alignment-el lehet elhelyezni az erőforrásokat, amiket aztán a resource aliasing által több célra is fel lehet használni (pl. egy már nem használt buffer memóriaterületét felhasználni valami rajzolásra).

Memória alatt itt most device memory-t értek, azaz buffer/image-eket lehet ide allokálni. A történet úgy kezdődik, hogy a hardver ad neked valahányféle memory heap-et, amikből adott mennyiségű memóriát allokálhatsz. Egy ilyen memory heap támogat bizonyos memóriatípusokat (ezek nincsenek nevesítve) bizonyos kombinálható tulajdonságokkal:

  • VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT: a device számára optimális memória
  • VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT: CPU oldalra leképezhető memória
  • VK_MEMORY_PROPERTY_HOST_COHERENT_BIT: nem kell explicit láthatóvá tenni a módosításokat a device felé
  • VK_MEMORY_PROPERTY_HOST_CACHED_BIT: CPU oldali elérés optimálisabb
  • VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT: device-only memória, amit on demand jelleggel foglalhat
Ezen flagek kombinálásával lehet kérni shared memóriát is, de a szabvány ezt sem követeli meg (és a GTX 650 nem is adja ide)! Annyit rögzít csak le a szabvány, hogy a heap-ek közül legalább egy device local. Egy ábra az eddig elhangzottakról, illetve a memória hierarchiáról:

hierarchy

Az ábrán próbáltam jelezni, hogy nem foglalható akárhova buffer után image. Van ugyanis egy buffer-image granuality nevű hardverfüggő tulajdonság, ami megmondja, hogy milyen offset-re lehet lineáris adat után optimális adatot foglalni (később). De ha megtaláltad a neked tetsző memory heap-et, akkor foglalhatsz belőle memóriát és hozzákötheted egy buffer-hez vagy image-hez. Nézzük meg:

CODE
VkBuffer buffer = 0; VkDeviceMemory memory = 0;

CODE
VkBufferCreateInfo buffercreateinfo = { ... }; VkMemoryAllocateInfo allocinfo = { ... }; VkMemoryRequirements memreqs; VkFlags flags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT; buffercreateinfo.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; vkCreateBuffer(device, &buffercreateinfo, NULL, &buffer); vkGetBufferMemoryRequirements(device, buffer, &memreqs); // implementációhoz ld. kód allocinfo.memoryTypeIndex = GetMemoryTypeForFlags(memreqs.memoryTypeBits, flags); allocinfo.allocationSize = memreqs.size; vkAllocateMemory(device, &allocinfo, NULL, &memory); vkBindBufferMemory(device, buffer, memory, 0);

Ez egy teljesen veszélytelen buffer (nem biztos, hogy a GPU-n van lefoglalva!), amire meg lehet hívni a vkMapMemory() függvényt és a visszakapott pointerbe lehet írni. Ha befejezted az írást, akkor (előbb) meghívod a vkFlushMappedMemoryRanges() függvényt, hogy a device számára látható legyen a módosítás, majd unmappeled. Vigyázat, a mappelés nem skatulyázható!

Ha megadod még a VK_MEMORY_PROPERTY_HOST_COHERENT_BIT flaget is, akkor a pointer permanensen írható/olvasható marad, nem kell se unmappelni se flusholni. Ez persze kényelmes, de rendszermemóriából igen lassú rajzolni. Ha az eszköz tud megosztott memóriát, akkor megadva még a VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT flaget máris javul a teljesítmény.

De mint mondtam nem minden kártya tud ilyet, speciel az enyém sem. Ilyenkor az a megoldás, hogy csinálsz egy átmeneti staging buffer-t (VK_BUFFER_USAGE_TRANSFER_SRC_BIT-et ne felejtsd el megadni!), illetve egy csak device oldali tényleges buffert (VK_BUFFER_USAGE_TRANSFER_DST_BIT). A GPU-ra felmásoláshoz szükség van egy command buffer-re:

CODE
VkBufferCopy region; region.srcOffset = 0; region.dstOffset = 0; region.size = buffsize; vkCmdCopyBuffer(commandbuffer, stagingbuffer, buffer, 1, &region);

A végrehajtási modellnél megbeszéltek alapján ez a kódrészlet az inicializálós command buffer-be pakolható. A vkQueueWaitIdle() után a staging buffer törölhető is (hacsaknem később szükség van rá).

A buffer usage-nek több értéket is össze lehet vagyolni, tehát például ugyanazon buffer tartalmazhat vertex, index és uniform adatokat is, megfelelő offseteléssel. Sőt, az nVidia kimondottan javasolja, hogy ezt tedd, mert akkor a kártya szeretni fog. Nekem ezt nem sikerült igazolni.

Van két furcsa buffer típus, amiket még OpenGL-ből örökölt a vulkán: uniform texel buffer illetve storage texel buffer. Ez olyan buffer-t jelent, ami mintavételezhető (GLSL: samplerBuffer); a következő bekezdésből kiderül, hogy miért létezik egyáltalán ilyen. Ahhoz, hogy használni lehessen, csinálni kell hozzá egy buffer view-t, ami megmondja, hogy milyen formátumban kell értelmezni az adatot.


Külön bekezdésbe vettem, mert kissé eltér a buffer-ek kezelésétől, merthogy a textúrák nem lineáris módon vannak tárolva. Gondoljunk bele abba, hogy (lineáris tárolást feltételezve) amikor egy mintavételező olvasni akar a textúrából, akkor a texel bal és jobb szomszédját könnyen eléri, az alsó/felső szomszédja viszont bent sincs a cache-ben.

A legtöbb hardver ezért valamilyen blokkhierarchiában tárolja a textúrákat (VK_IMAGE_TILING_OPTIMAL), amiről mi persze nem tudhatunk semmit, emiatt a textúrák feltöltéséhez mindig kötelező egy staging buffer (vagy staging image...) használata. Persze ha tud a hardver lineárisan is tárolni textúrát akkor használhatod, de nem lesz hatékony (VK_IMAGE_TILING_LINEAR).

CODE
VkBufferImageCopy region; region.bufferOffset = 0; region.bufferRowLength = 0; // ha 0, akkor tightly packed-nek tekinti region.bufferImageHeight = 0; // ha 0, akkor tightly packed-nek tekinti region.imageOffset = { 0, 0, 0 }; region.imageExtent = { width, height, depth }; region.imageSubresource = ...; vkCmdCopyBufferToImage( commandbuffer, stagingbuffer, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &region);

Fordított eset is lehetséges: például amikor egy rendertarget tartalmát vissza szeretnéd olvasni. Olyankor célszerűbb a vkCmdCopyImage() parancsot használni. Multisampling esetén ezek nem működnek, olyankor rajzolással/blitteléssel lehet mozgatni az adatot.

Az image-ek egy másik tulajdonsága a felhasználási cél (usage), ami a buffer usage-hez hasonlóan kombinálható flagek halmaza. Hardverfüggő viszont, hogy melyik usage-hez milyen formátumok vannak támogatva és még további megszorítások is lehetnek, mint pl. memóriatípus, felbontás, stb. Gyakori hiba egy adott usage érvénytelen használata (pl. a fenti példa nem használható rendertarget-ként!).

Talán a legnehezebb fogalom image-ek esetén a layout tulajdonság. Létrehozáskor ez lehet VK_IMAGE_LAYOUT_UNDEFINED, vagy VK_IMAGE_LAYOUT_PREINITIALIZED, amennyiben rögtön fel is töltötted (nem a fenti kóddal). Ezen kezdeti layout-al konkrétan semmit nem lehet kezdeni, a használathoz szükség van egy layout transition-re.

Mondok jobbat: bármikor amikor változik az image felhasználási célja, akkor azt egy layout transition-nel kell elvégezni (tehát a fenti kód esetén is!). Kódban ez a következőképpen fest (a fenti kód előtt):

CODE
VkImageMemoryBarrier barrier = {}; barrier.srcAccessMask = 0; // még csak létrejött barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; // írni akarom barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; barrier.image = image; barrier.subresourceRange = ...; vkCmdPipelineBarrier( commandbuffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, // ezek előbb fejeződjenek be (pipeline eleje) VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, // ezek még ne kezdődjenek el (később lesz róla szó) 0, 0, NULL, 0, NULL, // másfajta barrier most nincs 1, &barrier);

Hasonlóan lehet elérni, hogy a textúra olvasható legyen shaderből (VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL), figyelembe véve, hogy mi volt a korábbi layout. Hogy hogyan az egy külön bekezdés... Vigyázat, bizonyos példaprogramok VK_IMAGE_LAYOUT_UNDEFINED-et adnak meg oldlayout-nak azt feltételezve, hogy a driver majd figyelmen kívül hagyja, de a szabvány szerint ez azt jelenti, hogy az adat nem érdekel (és így nem köteles megtartani). Szóval tárold el a régi layout-ot (és az access mask-ot is)!.

Még nagyobb vigyázat: az nVidia nem implementálja a layout transition-t! Nehogy kiadd úgy a programodat, hogy nem tesztelted más kártyákon (sőt talán az a legjobb, ha AMD kártyán fejlesztesz)!

Érdemes tudni, hogy a GPU on the fly módon átrendezheti illetve tömörítgetheti ki/be az adatot (delta color compression), ami layout transition-kor történik meg. Ennek vannak bizonyos teljesítményvonzatai, amikről itt lehet olvasni.

Annyit még elmondok, hogy shader esetén nem maga a textúra lesz beállítva, hanem egy image view. Ez több dologra is használható:
  • más formátumban legyen értelmezve (ne felejtsd el a VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT flaget)
  • megvariáld a komponensek sorrendjét
  • bizonyos subresource-okra hivatkozzon (adott mip szint és/vagy array slice)
Az egymással kompatibilis formátumokat a szabvány rögzíti.


Metálban a render pass tulajdonképpen a framebuffer volt, vulkánban viszont kicsit eltér ettől például azért, mert külön van VkFramebuffer objektum. Utóbbi tulajdonképpen csak egy image view-okból álló halmaz. Az összefüggésekről egy ábra:

render_pass

Vulkánban a render pass egy lista az azon belül használt attachment-ek néhány adatáról (formátum, multisampling) illetve felhasználási módjáról (load/store action). Ezeket az attachment-eket utána halmazokba (render subpass) lehet válogatni, ahogy az ábra is mutatja.

Újdonságnak tekinthetőek az ún. input attachment-ek, amikből a fragment shader közvetlenül tud adatot betölteni. Például egy adott subpass eredménye lehet a következő subpass inputja, amivel nyilván spórolunk, mert egyáltalán nem kell mintavételező. Sőt, mobil GPU-kon ez közvetlenül a tile local storage-ből olvas!

CODE
layout (input_attachment_index = 0, binding = 1) uniform subpassInput input1; void main() { vec4 color = subpassLoad(input1); }

Egy másik fogalom a preserve attachment, ami annyit mond, hogy az adott subpass ugyan nem használja azt az attachment-et, de egy későbbi majd igen, úgyhogy tartsa meg a driver.

A subpass-ok között (sokszor kötelezően) meg lehet adni függőséget (dependency), ami valójában egy implicit barrier (hasonlóan is kell kitölteni). Bármily meglepő egy subpass-nak lehet függősége render pass-on kívüli dolgokkal is, sőt saját magával is! Idézet a szabványból:

"If vkCmdPipelineBarrier is called within a render pass instance, the render pass must declare at least one self-dependency from the current subpass to itself."

Illetve egy csomó további feltétel is. Mivel a barrier ilyen szigorú megszorításokkal van kezelve érthető, hogy miért redundáns itt az API. De mit jelent a self-dependency és az external dependency, illetve egyáltalán mit ír le egy tetszőleges subpass dependency? Két dolgot rögzítenek ezek le (és értsük ugyanezt barrier-ek esetén is!):
  • végrehajtási függőség
  • memóriaelérési függőség
A végrehajtási függőség azt jelenti, hogy addig ne kezdődjenek el a destination-ban megadott parancsok, amíg a source-ban megadott parancsok be nem fejeződtek. A memóriaelérési függőség pedig hasonló módon határt húz a memória elérések között amiket ezen parancsok használnak. Ezek után a self-dependency egy adott subpass-on belül húz határt, az external dependency pedig egy subpass és a render pass előtti vagy utáni parancsok között. Mindezen adatokból a driver egy DAG-ot fog építeni (directed acyclic graph) és automatikusan beilleszti a szükséges barrier-eket.

CODE
VkSubpassDependency dependency = {}; dependency.dependencyFlags = 0; // "subpass 0 rajzol a depth attachment-be, de a fragment operációk legyenek készen -" dependency.srcSubpass = 0; dependency.srcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; // "- mielőtt subpass 1-ben a vertex shader elkezdené olvasni" dependency.dstSubpass = 1; dependency.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; dependency.dstStageMask = VK_PIPELINE_STAGE_VERTEX_SHADER_BIT;

A függőség érvényes lehet ún. régiónként is, ami alatt a szabvány szerint pixelt kell érteni (de implementációfüggő, hogy mit jelent). Ebben az esetben a végrehajtási függőség ezen régiókra egyesével kell érvényesüljön és nem globálisan. Később is meg fogom említeni, de akkor kell ezt megadni, ha a függőség framebuffer stage-ekre vonatkozik.

A framebuffer-t és a render pass-t összerakva kezdődhet el az első subpass a vkCmdBeginRenderPass() függvény által. A további subpass-okba a vkCmdNextSubpass() függvénnyel lehet belépni. Ezen két függvénynek a legfontosabb paramétere a subpass contents, ami azt mondja meg, hogy mi történhet a subpass-on belül:
  • VK_SUBPASS_CONTENTS_INLINE: minden parancs az aktuális elsődleges command buffer-be kerül
  • VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS: csak másodlagos command buffer-eket hív tovább
A kettő nyilván kizárja egymást, tehát egy inline subpass nem hívhatja meg a vkCmdExecuteCommands() függvényt (a másik fajta meg csak azt hívhatja). Vigyázat, az nVidia ezt a szabályt sem tartja be!

A rajzolás befejezése a vkCmdEndRenderPass() függvénnyel történik. Egy jó példa render subpass-ok használatára a deferred shading. Az első subpass a g-buffer-t tölti fel, a második pedig azt felhasználva az akkumuláló textúrákat. A kettő közötti függőség nyilván write/read típusú (mint az ábrán).


Kis küzdelem volt ez, merthogy az assembler csak Visual Studio 2015-től fölfelé fordul. Értitek, C interfész és C++11 van mögötte...tök logikus. Véleményemet frappáns kétszavas mondatokban fejeztem ki a fejlesztőnek. Na de, ha mindannyian lenyugodtunk, akkor nézzünk egy egyszerű kis texúrázós fragment shader-t:

CODE
OpCapability Shader %1 = OpExtInstImport "GLSL.std.450" OpMemoryModel Logical GLSL450 OpEntryPoint Fragment %main "main" %outColor %tex OpExecutionMode %main OriginUpperLeft ; annotációk OpDecorate %outColor Location 0 OpDecorate %sampler1 DescriptorSet 0 OpDecorate %sampler1 Binding 1 OpDecorate %tex Location 0 ; uniform, in, out %void = OpTypeVoid %3 = OpTypeFunction %void %float = OpTypeFloat 32 %v4float = OpTypeVector %float 4 %_ptr_Output_v4float = OpTypePointer Output %v4float %outColor = OpVariable %_ptr_Output_v4float Output %10 = OpTypeImage %float 2D 0 0 0 1 Unknown %11 = OpTypeSampledImage %10 %_ptr_UniformConstant_11 = OpTypePointer UniformConstant %11 %sampler1 = OpVariable %_ptr_UniformConstant_11 UniformConstant %v2float = OpTypeVector %float 2 %_ptr_Input_v2float = OpTypePointer Input %v2float %tex = OpVariable %_ptr_Input_v2float Input ; függvények %main = OpFunction %void None %3 %5 = OpLabel ; blokk eleje %14 = OpLoad %11 %sampler1 %18 = OpLoad %v2float %tex %19 = OpImageSampleImplicitLod %v4float %14 %18 OpStore %outColor %19 OpReturn ; blokk vége OpFunctionEnd

Most miért néztek így? Ez a SPIR-V, a vulkán shader nyelve; pontosabban annak az assembly-szerű változata (merthogy maga a SPIR-V bináris). Jogos felvetés, hogy ha GLSL-ből is lehet SPIR-V-t fordítani, akkor miért küszködnénk ilyen érthetetlen kódokkal? Nem kell...amíg a glslang (vagy a ShaderC) le tudja fordítani amit szeretnél...

Beszéljük azért át, hogy mi micsoda. Maga a bináris program egy (SPIR-V) modul. Az amit a modul használ, azt ő képességnek (capability) nevezi. A futtatókörnyezetnek ezeket a képességeket támogatnia kell. Bizonyos képességek függenek egymástól, így pl. a Shader képesség magával rántja azt is, hogy tudsz mátrixokat használni. (- Használhatok mátrixokat??!!! Úúúúú!!!)

A modulban kötelező címzési módot és memória modellt megadni az OpMemoryModel utasítással. Pl. a GLSL más memóriamodellt használ, mint az OpenCL (és a SPIR-V mindkettőt támogatja, így a vulkán tulajdonképpen az OpenCL utódja is). Az annotációkat nem részletezem, ez ugyanaz, mint GLSL-ben a layout.

Bizonyos utasításoknak van eredménye, amire később hivatkozni lehet (%eredmény = utasítás). A modul mindig úgynevezett SSA alakban van (static single assignment), azaz minden eredményt pontosan egy utasítás állít elő. Vigyázat, ez a szintaxis része, semmi köze nincs az utasítások tényleges eredményéhez! Vegyük például az alábbi zanzásított kódot:

CODE
// SPIR-V %float = OpTypeFloat 32 %vec4 = OpTypeVector %float 4 %mat4 = OpTypeMatrix %vec4 4 %pvec4 = OpTypePointer Function %vec4 %pmat4 = OpTypePointer Function %mat4 %m1 = OpVariable %pmat4 Function %v1 = OpVariable %pvec4 Function %v2 = OpVariable %pvec4 Function %temp = OpMatrixTimesVector %vec4 %m1 %v1 OpStore %v2 %temp
CODE
// GLSL mat4 m1; vec4 v1; vec4 v2 = m1 * v1;

Ez tehát közel sem jelenti azt, hogy temporáris változó jön létre! Furcsának tűnhet, de az OpVariable utasítás egyben definíció (azaz memória foglalódik), viszont pointert ad vissza. A második paramétere a storage class, ami teljesen hasonló, mint GLSL-ben; persze itt mindenre meg kell mondani.

Magával a SPIR-V-vel nem foglalkoznék tovább, mert nehezen kezelhető és hosszabb kódokat eredményez, mint mondjuk a régi HLSL assembly. A továbbiakban GLSL-t fogok használni, glslang-al fordítva (ami azóta HLSL-t is tud). Részletekért lásd a vonatkozó GLSL specifikációt.

Egy megemlítendő újdonság (az eddigiek mellett) az ún. specialization constant, ami a modul létrehozásakor megadható konstans adat, tehát a #if egy elegánsabb formája. Vulkán oldalon ez egy VkSpecializationInfo struktúra kitöltését jelenti a modul létrehozásakor, shader oldalon pedig:

CODE
layout (constant_id = 7) const int sampleCount = 1; void main() { vec4 blursamples[sampleCount]; }

A továbbiakban ez úgy viselkedik, mint egy szokványos konstans kifejezés és ezt a tulajdonságot az alapműveletek (de pl. a vektorrá konvertálás is) megtartják. Egy izgalmas dolog, hogy akár egy beépített változó is újradeklarálható így, például specializálni lehet (akár részlegesen) a workgroup méretét:

CODE
layout (local_size_x = 16, local_size_y = 16) in; layout (local_size_x_id = 3) in;

Így a workgroup y és z mérete rögzített, az x méret viszont megadható később is, fordításkor.

Az atomic counter fogalom kikerült, helyette közvetlenül lehet atomi utasításokkal módosítani a (akár globális) memóriát (pl. imageAtomicIncrement). Tehát ami eddig atomic_uint volt, az most simán uint, egyébként minden ugyanúgy működik.


Ez talán a legnehezebb része a vulkánnak, de ha egyszer megértetted, akkor már könnyebb a továbbhaladás. A rövid és érthető megfogalmazás érdekében forduljunk az Erőhöz:

"A Vulkan-ban textúra váltás nincs, hanem van descriptor set váltás." (Yoda)

Elég vad gondolat, hogy megválunk a glBindXXXX függvényektől, de mint említettem a korábbi API-k vért izzadtak ilyen esetekben, akár a shader-t is implicit újrafordították (de egy rossz driver predikció is micro stuttering formájában jelentkezett)!

Ezt elkerülendő az összes (nem a pipeline-ból jövő) adat tulajdonságát meg kell mondani előre. Egy descriptor set összefoglalja azon (kívülről jövő) adatokat, amiket egy shader használ. Ezek lehetnek:
  • uniform buffer
  • dynamic uniform buffer
  • storage buffer
  • dynamic storage buffer
  • sampled image
  • storage image
  • sampler
  • combined image sampler
  • uniform texel buffer
  • storage texel buffer
A szokványos textúra az a combined image sampler, tehát egy image és egy hozzá tartozó mintavételező (sampler). Nézzünk először egy példát:

CODE
layout (std140, set = 0, binding = 0) uniform UniformData { mat4 matWorld; mat4 matViewProj; } uniforms; void main() { // ... }
CODE
layout (set = 0, binding = 1) uniform sampler2D sampler1; void main() { // ... }

Fontos különbség akár OpenGL, akár Metal-hoz képest, hogy a binding point-ok a teljes (shader) programban szekvenciálisak, tehát ha a vertex shader már foglalja a 0-s binding point-ot, akkor a fragment shader azt már nem használhatja (hacsaknem pontosan ugyanaz a struktúra).

A vulkán oldaláról nézve ezen információkat a descriptor set layout írja le, ami összefoglalja az adatok helyét, illetve tulajdonságait (de nem a konkrét adatokat!), úgy mint:
  • típus (előző lista)
  • binding
  • tömbméret
  • shader stage (vertex, fragment, stb.)
  • módosíthatatlan sampler-ek (ha van és olyan)
A descriptor set layout alatt aztán tetszőlegesen lehet váltogatni a descriptor set-et. Bár több ilyet is használhat egy shader, én most egyelőre csak egyet fogok, úgyhogy a set = 0-t ki se fogom írni. Magukat a descriptor-okat egy descriptor pool-ból fogja foglalni a driver, aminek előre meg kell mondani, hogy melyik descriptor típusból mennyit lehet majd allokálni, illetve, hogy egyáltalán hány descriptor set-et lehet belőle foglalni. Ez nem kötődik a descriptor set layout-hoz, de a kód átláthatósága érdekében hozzákötöttem. Csináljuk akkor meg ezeket a fenti példához:

CODE
VkDescriptorSetLayout descsetlayout = 0; VkDescriptorPool descriptorpool = 0; VkDescriptorSet descriptorset1 = 0; // később lesz több

CODE
VkDescriptorSetLayoutCreateInfo descsetlayoutinfo = {}; VkDescriptorSetLayoutBinding bindings[2]; // két descriptor van bindings[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; bindings[0].descriptorCount = 1; bindings[0].binding = 0; bindings[0].stageFlags = VK_SHADER_STAGE_VERTEX_BIT; bindings[0].pImmutableSamplers = NULL; bindings[1].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; bindings[1].descriptorCount = 1; bindings[1].binding = 1; bindings[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; bindings[1].pImmutableSamplers = NULL; // descriptor set layout descsetlayoutinfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; descsetlayoutinfo.pNext = NULL; descsetlayoutinfo.bindingCount = ARRAY_SIZE(bindings); descsetlayoutinfo.pBindings = bindings; vkCreateDescriptorSetLayout(device, &descsetlayoutinfo, 0, &descsetlayout);

Felmerül az a kérdés, hogy mit kell akkor csinálni, amikor a descriptor set layout "lyukas"; azaz nem mindegyik binding-et akarod használni. A válasz szerencsére rövid: ne csinálj ilyet. Ha mégis szeretnéd és a validációs réteg átengedi, akkor a lyukakban a binding-et add meg, de a descriptorCount-ot állítsd 0-ra. Következzen a descriptor pool:

CODE
VkDescriptorPoolCreateInfo descpoolinfo = {}; VkDescriptorPoolSize poolsizes[2]; // kétféle típust használ a shader, mindkettőből 1-1 descriptor-t poolsizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; poolsizes[0].descriptorCount = 1; poolsizes[1].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; poolsizes[1].descriptorCount = 1; // descriptor pool descpoolinfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; descpoolinfo.pNext = NULL; descpoolinfo.maxSets = ...; // amennyi váltást akarsz descpoolinfo.poolSizeCount = ARRAY_SIZE(poolsizes); descpoolinfo.pPoolSizes = poolsizes; vkCreateDescriptorPool(device, &descpoolinfo, NULL, &descriptorpool);

Itt érdemes megjegyezni, hogy a maxSets és a pPoolSizes mezők között nincs semmiféle kapcsolat: ∑ pPoolSizesi darab descriptor-t lehet szétosztani maxSets darab set között. Foglaljunk le most már akkor valahány (most mondjuk 1) set-et:

CODE
VkDescriptorSetAllocateInfo descsetallocinfo = {}; descsetallocinfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; descsetallocinfo.pNext = NULL; descsetallocinfo.descriptorPool = descriptorpool; descsetallocinfo.descriptorSetCount = 1; descsetallocinfo.pSetLayouts = descsetlayouts; vkAllocateDescriptorSets(device, &descsetallocinfo, &descriptorset1);

Vegyük észre, hogy sehol nem mondtam meg, hogy a shader hány descriptor set-et használ (bár lerögzítettem, hogy most 1-et fog). Azért, mert az nem itt dől el, hanem majd a vkCmdBindDescriptorSets() hívásban.

Na de nem vagyunk még készen, mert eddig csak annyit mondtunk meg, hogy hogyan néz ki a set, de most meg kéne mondani a konkrét adatokat is:

CODE
VkWriteDescriptorSet descsetwrites[2]; descsetwrites[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; descsetwrites[0].pNext = NULL; descsetwrites[0].dstSet = descriptorset1; descsetwrites[0].descriptorCount = 1; descsetwrites[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; descsetwrites[0].dstArrayElement = 0; descsetwrites[0].dstBinding = 0; descsetwrites[0].pBufferInfo = ...; descsetwrites[0].pImageInfo = NULL; descsetwrites[0].pTexelBufferView = NULL; // TODO: descsetwrites[1] hasonló (pImageInfo) vkUpdateDescriptorSets(device, 2, descsetwrites, 0, NULL);

A szabvány itt egy kicsit szórakozott, ugyanis azt mondja, hogy ha a descriptorCount több, mint amennyit egyébként írhatna, akkor a következő binding-ben fogja folytatni. Soha az életben nem jössz rá, hogy hol rontottad el :)

Ez a függvény nem hívható akármikor (pl. render pass-ban), szóval arra hajt az API, hogy a set-eket foglald le előre. A Khronos azonban megemlíti lehetőségként, hogy implementálsz egy saját descriptor set cache-t, amivel render időben is létre lehet ezeket hozni.

A descriptor set layout legyártását erősen segíti a shader reflection, pontosabban a SPIR-V Cross. Ennek segítségével az is megoldható, hogy fordítási időben validáld a shader-eket, de C++ kódot is generál, amivel létrehozhatod a descriptor set-eket!

Egyetlen feladat van még, a vkCmdBindDescriptorSets() meghívása. Ehhez viszont előbb meg kell ismernünk a graphics pipeline-t.


Nem egy bonyolult fogalom, de nagyon sok részből áll, ezért célszerű elfedni egy wrapper osztállyal. A régi API-kkal és a metállal szemben csak nagyon kis részét lehet rajzolás közben módosítani (dynamic state), amit valószínűleg a Khronos is cikinek érzett, ezért használható egy ún. pipeline cache objektum, amit akár a merevlemezre is ki lehet menteni, így a programnak nem kell minden induláskor legyártania az ezerféle pipeline-ját.

A graphics pipeline az alábbi (mondhatni) klasszikus részekből áll:

  • vertex input state
  • input assembly state
  • tessellation state
  • viewport state
  • rasterization state
  • color blend state
  • multisample state
  • depth-stencil state
  • dynamic state
Ezeken kívül tartozik még hozzá valahány shader stage, illetve egy adott render pass. Lehet más render pass-al is használni, ha az kompatibilis a létrehozáskor megadottal (melyet a szabvány határoz meg). Egyikkel sem fogok részletesebben foglalkozni, mert mindenki el tudja képzelni, hogy nagyjából mik ezek.

A történet úgy kezdődik, hogy pipeline layout, ami tulajdonképpen csak a descriptor set layout-okat fogja össze (illetve még a push constant range-eket is, de azokról majd később). Ezután létrehozható a pipeline objektum, minden fent felsorolt állapotot teljesen megadva. A dynamic state-ben fel kell sorolni azokat a dolgokat, amiket nem itt szeretnél beállítani, hanem (mily meglepő) dinamikusan. Ezek közül sajnos nincs sok:
  • viewport
  • scissor
  • line width
  • depth bias (glPolygonOffset)
  • blend constants (ha konstansokkal is blendelsz)
  • depth bounds (glDepthRange megfelelője)
  • stencil compare mask
  • stencil write mask
  • stencil reference
Ezeket lehet tehát később is változtatni, minden mást itt és most, a létrehozáskor kell megadni. Durva mi? Persze ott van a pipeline cache, de kérdéses, hogy mennyire megbízható. Legrosszabb esetben te is leimplementálhatod (a Khronos mutat is erre példát).

Mint mondtam, a graphics pipeline egy adott render pass-hoz kötődik, ami érthető is például a color blend miatt (ami attachment-enként különbözhet), de konkrétan is meg kell adni a VkGraphicsPipelineCreateInfo-ban. De akkor most már rajzolhatnánk valamit (feltételezve, hogy minden szükséges dolgot megcsináltatok):

CODE
VkCommandBuffer commandbuffer = ...; // rajzolós cmdbuff VkRenderPass renderpass = ...; VkDescriptorSet descriptorset1 = ...; VkPipeline pipeline = ...; // viewport/scissor dinamikus VkPipelineLayout pipelinelayout = ...; VkBuffer vertexbuffer = ...; VkBuffer indexbuffer = ...;

CODE
VkRenderPassBeginInfo passbegininfo = { ... }; // ... vkCmdBeginRenderPass(commandbuffer, &passbegininfo, VK_SUBPASS_CONTENTS_INLINE); { vkCmdBindPipeline(commandbuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); vkCmdSetViewport(commandbuffer, 0, 1, ...); vkCmdSetScissor(commandbuffer, 0, 1, ...); vkCmdBindDescriptorSets( commandbuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelinelayout, 0, 1, &descriptorset1, 0, NULL); const VkDeviceSize offsets[1] = { 0 }; vkCmdBindVertexBuffers(commandbuffer, 0, 1, &vertexbuffer, offsets); vkCmdBindIndexBuffer(commandbuffer, indexbuffer, 0, VK_INDEX_TYPE_UINT16); // egy függvény mind felett vkCmdDrawIndexed(commandbuffer, 36, 1, 0, 0, 0); } vkCmdEndRenderPass(commandbuffer);

Vajon miért lehet több viewport-ot (illetve ugyanannyi scissor rect-et) megadni? A válasz az, hogy a geometry shader megadhatja, hogy melyik viewport-ba szeretné kitolni a primitíveit. Ez tehát azt jelenti, hogy rajzolhatunk egyszerre több viewport-ba (mint ahogy annak idején egyszerre rajzoltunk egy cube map mind a hat oldalába).

Vegyük észre, hogy az objektum típusa VkPipeline, mintha semmi köze nem lenne a grafikához. Valóban nincs köze, merthogy a compute pipeline is ugyanilyen objektum, szerencsére kevesebb járulékos adattal.

Egy további hasznos fogalom a pipeline derivative, azaz egy már létező pipeline-ból származtatott "gyerek" pipeline, ami persze sokban hasonlít a szülőjére, így a létrehozása hatékonyabb.

Az alapokkal gyakorlatilag készen vagyunk, egy dolog van még hátra: a képernyőre prezentálás. Ezt most nem írom le, mert a frame queueing kapcsán úgyis előjön.


Említettem, hogy a command buffer-ek előre legyárthatóak, így a rajzolás konkrétan nulla driver overhead lehet. Az nVidia-nak van ez a threaded CAD scene nevű példaprogramja, ami pont ezt teszi, de gondoljunk bele néhány dologba:

  • elég röhejes úgy érvelni a sebesség mellett, hogy a jelenetet bevágom egy előre generált command buffer-be
  • a példa teljesen életszerűtlen, mert semmiféle culling-ot (VFC, OC) nem használ
  • csak addig gyorsabb a vulkán, amíg az elemek néhány poligonból állnak
Emellett az nVidia még csal is, mert az előre generált (primary) bufferét a vkCmdExecuteCommands()-al hívja tovább, amit a szabvány meg se enged! Azt már meg sem említem, hogy mellesleg OpenGL alól hívogatják a vulkánt... Szóval rendhagyó módon most az AMD-re szabad csak hallgatni...

A feladat az, hogy van egy tetszőleges poligonszámú elemekből álló jelenet, melyet szeretnénk minél gyorsabban megjeleníteni. A megvalósításhoz az Imagination egyik cikkét vettem alapul, ami a következőt mondja: a jelenetre alkalmazzunk valamilyen térpartícionálást (akár octree), minden levélbe valahány darab elemet rakva (az lesz egy batch). Ahogy mozog a kamera, minden újonnan bekerülő batch-hez generáljunk le egy másodlagos command buffer-t, a kikerülőket pedig töröljük. Nézzünk egy ábrát:

drawbatch

Ezen megoldással egyrészt elkerülhető, hogy a command buffer pool elszálljon memóriahasználatban, másrészt amíg egy batch a frustum-on belül van, addig nulla driver overhead-el leküldhető. Van azonban néhány rossz hír is a másodlagos command buffer-ekkel kapcsolatban:
  • előre tudnia kell a render pass-t (és subpass-t), amiben futni fog
  • ha azt akarod, hogy hatékony legyen, akkor a framebuffer-t is
Ha a második feltételt be szeretnénk tartani, akkor kapásból annyi command buffer kell minden batch-hez, ahány swapchain image van. Ez megúszható úgy, hogy egy offscreen textúrába renderelsz.

(megj.: akkor miért nem használunk elsődleges command buffereket? Lehetne...de a kártya kevésbé fogja szeretni mint egy darab vkCmdExecuteCommands() hívást...)

CODE
void DrawBatch::Regenerate(VkRenderPass renderpass, uint32_t currentimage) { VkCommandBufferBeginInfo begininfo = {}; VkCommandBufferInheritanceInfo inheritanceinfo = {}; inheritanceinfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_INHERITANCE_INFO; inheritanceinfo.pNext = NULL; inheritanceinfo.renderPass = renderpass; inheritanceinfo.subpass = 0; inheritanceinfo.framebuffer = framebuffers[currentimage]; begininfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; begininfo.pNext = NULL; begininfo.flags = VK_COMMAND_BUFFER_USAGE_RENDER_PASS_CONTINUE_BIT; begininfo.pInheritanceInfo = &inheritanceinfo; vkBeginCommandBuffer(commandbuffers[currentimage], &begininfo); { // TODO: rajzolás } vkEndCommandBuffer(commandbuffers[currentimage]); }

Nem fért ki, de ha frame queueing-ot is használsz, akkor a VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT flaget is add meg. Azzal nem foglalkozom most, hogy egy objektum ne legyen redundánsan bekódolva, megnézitek a kódban.

Mint mondtam, ezt a regenerálást akkor kell meghívni, amikor egy új batch láthatóvá válik. Amiből persze esetenként elég sok lehet, tehát máris adódik egy gyorsítási lehetőség: osszuk szét több szál között! Ehhez szintén nem írok kódot, mindenki elhiszi.

Na de mennyit gyorsultunk? Nos az általam írt program konkrétan lassabb vulkánnal, mint OpenGL-el (de eddig csak a GTX 650-en próbáltam ki, amiről megbeszéltük, hogy csal). Azt még el tudnám fogadni, hogy nem gyorsabb, hiszen a cikk elején említett driver limit nem igaz rá. Azért nem jelentősen lassabb a vulkán (~93 fps, míg az OpenGL ~110 fps), és ki lehetne deríteni, hogy pontosan hol akad el, ugyanis vulkánban (is) lehet GPU időt mérni query pool-okkal.


Az előző bekezdés kapcsán felmerül még néhány kérdés (merthogy a command buffer-t előre legeneráltuk), nevezetesen, hogy hogy a búbánatba váltsunk uniform-okat (pl. material vagy world matrix). Bár fel vagyunk vértezve descriptor set-ekkel, ennél van egyszerűbb (sőt gyorsabb) megoldás. Vulkánban háromféle uniform típus van:

  • uniform buffer binding
  • uniform buffer dynamic binding
  • push constant
Az elsőt nem kell magyarázni, mert már láttuk. A második hasonlít a sima uniform buffer binding-hez, de a vkCmdBindDescriptorSets() hívás utolsó paraméterében megadható neki egy offset (ettől dinamikus). A push constant pedig közvetlenül a command buffer-be kódolható adat, de nem célszerű túl sokat használni (egy mátrix még oké).

A megoldásom a következő: a material-okat bepakolom egy dynamic uniform buffer-be, a world mátrixot pedig (mivel adott elemhez kötődik) push constant-ként küldöm le. Ki kell egészíteni tehát a pipeline layout-ot és a shader-t:

CODE
VkPushConstantRange range; range.offset = 0; range.size = 64; range.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; layoutinfo.pushConstantRangeCount = 1; layoutinfo.pPushConstantRanges = ⦥ vkCreatePipelineLayout(...);
CODE
layout (push_constant) uniform TransformData { mat4 matWorld; } transform; void main() { // ... }

Felhívnám a figyelmet, hogy shader oldalon minden ilyen változó egy struktúrában (std430 packing) kell legyen, program oldalon viszont a push constant range-ek ezekre egyesével kell vonatkozzanak. Ezek után rajzoláskor (feltételezve, hogy a material-okat megcsináltátok):

CODE
vkCmdPushConstants(commandbuffer, pipelinelayout, VK_SHADER_STAGE_VERTEX_BIT, 0, 64, world); vkCmdBindDescriptorSets( commandbuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelinelayout, 0, 1, descriptorset1, 1, &materialoffset);

Ezeket egyébként is gyakran kell használni, mert a vkCmdUpdateBuffer() nem hívható render pass-on belül.


Bár minden lényeges dolgot leírtam a témáról, azért nem árt ha a gyakorlatban is látjuk hogyan működik (ezzel is elősegítve a megértést). Példának a Sponza nevű modellt használom (Sponza palota, Horvátország), amiben elég sok textúra van, tehát descriptor set-et kell váltani az egyes részek rajzolása előtt.

Az egész descriptor set logika mögött az áll, hogy az applikáció változó gyakorisággal váltogatja a shader dolgait (gondoljunk a fenti példában a material vs. world matrix-ra). A javaslat az, hogy ezek az eltérő gyakorisággal változó dolgok külön set layout-okba legyenek szervezve, így elkerülhető egy adott set részleges frissítése (ami abszolút nem hatékony).

(megj.: kicsit összefolyik a set layout és a set fogalma, de értsük meg: a set layout az amit a shaderben leírsz, a set pedig az aktuálisan mögötte levő adat)

A pipeline-hoz tehát tartozik K darab descriptor set layout, mindegyikhez allokálandó Ni darab descriptor set (a példában annyi, ahány material-ja van a modellnek). Ezeket én descriptor set group-nak neveztem el, a használatuk pedig állapotgép-szerű (így volt könnyű megcsinálni...).

(megj.: a saját osztályaimat konvencionálisan VulkanXXXX mintára neveztem el)

CODE
VulkanGraphicsPipeline* pipeline = ...; // minden hozzátartozó dologgal VulkanBasicMesh* model = ...; VulkanImage* supplytexture = ...; // helyettesítő textúra VulkanBuffer* uniforms = ...;

CODE
// 0. group/layout pipeline->SetDescriptorSetLayoutBufferBinding( 0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, VK_SHADER_STAGE_VERTEX_BIT); pipeline->SetDescriptorSetLayoutImageBinding( 1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, VK_SHADER_STAGE_FRAGMENT_BIT); // allokál és új group-ot kezd pipeline->AllocateDescriptorSets(model->GetNumSubsets()); // TODO: további group-ok (most nem kell) for( uint32_t i = 0; i < model->GetNumSubsets(); ++i ) { VulkanMaterial& material = model->GetMaterial(i); if( !material.Texture ) material.Texture = supplytexture; pipeline->SetDescriptorSetGroupBufferInfo(0, 0, uniforms->GetBufferInfo()); pipeline->SetDescriptorSetGroupImageInfo(0, 1, material.Texture->GetImageInfo()); // beleírja az aktuálisan beállított adatokat pipeline->UpdateDescriptorSet(0, i); } assert(pipeline->Assemble(renderpass));

Bár a szabvány lekezeli azokat az eseteket, amikor nincs beállítva textúra, a sampler-t mindenképpen meg kell adni (nem javaslom, hogy üresen hagyd a descriptor-t...). Ezek után rajzoláskor már könnyű dolgunk van, hiszen csak be kell állítani a material-hoz tartozó descriptor set-et.

Természetesen felmerül a kérdés, hogy mennyire hatékony dolog descriptor set-et váltani, de a válasz már a felépítésből is adódik: minimalizálni kell a váltásokat. Ahogy régen a rajzolások csoportokba voltak szedve shader, material, textúra, illetve trafó szerint, úgy kell most megszervezni a set-eket a shader-ben:

CODE
layout (set = 0, binding = 0) uniform GlobalData { ... } globals; layout (set = 1, binding = 0) uniform MaterialData { ... } material; layout (set = 2, binding = 0) uniform sampler2D texture; layout (push_constant) uniform TransformData { ... } transform;

Ezt az nVidia és a Khronos javasolja, az AMD viszont mást mond: csak egy darab set legyen a shader-ben, tömbösítve. A konkrét használandó uniform-ot mondja meg egy push constant, ami ezt a tömböt címzi. Így nem kell rajzolásonként descriptor set-et váltani (One Set Design). Megint kilyukadtunk tehát a hardverfüggőségnél...


A cikkhez írt deferred renderer kapcsán igyekeztem minél jobban kihasználni a vulkán lehetőségeit, így a render subpass-okat is. A renderer lépései:

  • gbuffer pass
  • accumulation compute shader
  • forward subpass
  • tonemap subpass
A program tehát két render pass-ra oszlik, közöttük egy compute shader-el (merthogy azt nem lehet render pass-on belül hívni). A második render pass két subpass-ra oszlik, amiben külön megemlítendő az input attachment-ek használata.
  • forward subpass: az akkumulációs textúrák input attachment-ek
  • tonemap subpass: az forward subpass eredménye input attachment
Ezek beállítása viszont nagyon nem triviális, de nagyobb gond, hogy hiba esetén a validációs réteg nem mond semmit; helyette feketét ad vissza a subpassLoad(). Ennek elkerüléséhez az első fontos dolog, hogy a textúrák létrehozásakor meg kell adni a VK_IMAGE_USAGE_INPUT_ATTACHMENT_BIT flaget.

A következő amit részletezni kell az a render pass attachment-ek leírása (melyekből ezek szerint 5 darab van). Gondoljuk végig mi is fog történni a render pass elkezdésekor (a paraméterek sorrendben: load action, store action, initial layout):
  • attachment 0 (backbuffer):  CLEAR STORE COLOR_ATTACHMENT_OPTIMAL
  • attachment 1 (forward kimenet):  CLEAR STORE COLOR_ATTACHMENT_OPTIMAL
  • attachment 2 (diffúz akkumulátor):  LOAD DONT_CARE SHADER_READ_ONLY_OPTIMAL
  • attachment 3 (spekulár akkumulátor):  LOAD DONT_CARE SHADER_READ_ONLY_OPTIMAL
  • attachment 4 (depth/stencil buffer):  CLEAR DONT_CARE DEPTH_STENCIL_ATTACHMENT_OPTIMAL
Itt a forward pass kimenetére azt mondtam, hogy törölje le, de rajzolás után olvasni fogom (ettől a validációs réteg meghülyül; elvileg már javították). A két darab subpass-t a következőképpen kell összeválogatni:

CODE
uint32_t preserveattachments[1] = { 0 }; // nehogy kidobja! color1reference.attachment = 1; color1reference.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; forwardreferences[0].attachment = 2; forwardreferences[0].layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; forwardreferences[1].attachment = 3; forwardreferences[1].layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; subpasses[0].inputAttachmentCount = 2; subpasses[0].pInputAttachments = forwardreferences; subpasses[0].colorAttachmentCount = 1; subpasses[0].pColorAttachments = &color1reference; subpasses[0].preserveAttachmentCount = 1; subpasses[0].pPreserveAttachments = preserveattachments; color0reference.attachment = 0; color0reference.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; tonemapreference.attachment = 1; tonemapreference.layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; depthreference.attachment = 4; depthreference.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; subpasses[1].inputAttachmentCount = 1; subpasses[1].pInputAttachments = &tonemapreference; subpasses[1].colorAttachmentCount = 1; subpasses[1].pColorAttachments = &color0reference; subpasses[1].pDepthStencilAttachment = &depthreference;

Tehát mivel más layout-ban kell majd használnom az 1-es attachment-et a két subpass-ban, ezért két külön attachment reference-et kell megadni. Még fontosabb a subpass dependency megadása:

CODE
dependency.srcSubpass = 0; dependency.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; dependency.dstSubpass = 1; dependency.dstAccessMask = VK_ACCESS_INPUT_ATTACHMENT_READ_BIT; dependency.dstStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;

Ugye körvonalazódik most már, hogy ezen információkból a driver pontosan ki tudja találni a szükséges barrier-eket! De jöjjön a leglényegesebb dolog, ami abszolút nem nyilvánvaló senkinek: az összes attachment-et le kell törölni valamilyen színnel (azokat is, amikre LOAD-ot mondtam!):

CODE
VkClearValue clearcolors[5] = { ... }; passbegininfo.clearValueCount = VK_ARRAY_SIZE(clearcolors); passbegininfo.pClearValues = clearcolors; vkCmdBeginRenderPass(commandbuffer, &passbegininfo, VK_SUBPASS_CONTENTS_INLINE);

Ez tehát kötelező, érdekes módon a blend state-eket viszont csak color attachment-ekre kell megmondani (de azért ne tessék elfelejteni; a gbuffer pass-hoz szükséges!). Ha ezeket mind megcsináltuk, akkor nagy gond már nem lehet...de mint említettem ha mégis gond van, akkor lehetetlen kideríteni, hogy mit hagytál ki (reménykedjünk, hogy okosítják a validációs rétegeket...).


Mivel implementációs szempontból nem foglalkozik vele senki, kénytelen voltan saját kútfőből kitalálni valamit (a Metal megközelítését véve alapul). Az ún. presentation engine-ről még nem írtam szinte semmit (VK_KHR_swapchain), úgyhogy előbb azt mutatnám be. Ez az ún. Window System Integration (WSI) része, ami extension-ök halmazaként jelenik meg. Maga a WSI elég bonyolult, úgyhogy nem foglalkozom vele részletesen.

Azt, hogy mi a swapchain mindenki el tudja képzelni: nem közvetlenül a képernyőre rajzolunk, hanem egy (vagy több) háttérben levő back buffer-be, majd amikor kész van megcseréljük a front buffer-rel. Hogy éppen melyik buffer (image) rajzolható, azt a vkAcquireNextImageKHR() függvény mondja meg. Visszatérhet azonban úgy is, hogy egyébként még olvassa azt az image-et; ennek befejeződését szemaforral vagy fence-el jelzi (attól függ mit adsz meg neki). Ezt be kell várni mielőtt felülvágnád a tartalmát (különben érdekes dolgok történhetnek).

A prezentáció hasonló elven működik: csak akkor kezdődhet el, ha a rajzolás (és a prezentációt megelőző layout transition) már befejeződött. Ezt a vkQueuePresentKHR() függvény fogja elvégezni.

presentation

Mivel ezek tisztán szemaforokkal megoldhatóak a kérdés arra redukálódik, hogy mikor blokkoltassuk a CPU-t. Ahhoz, hogy a hardver ki tudja használni a deep pipelining képességeit 2-3 frame-et célszerű leküldeni neki, mielőtt elkezdenénk várakozni (az nVidia 2-t javasol). A megoldás a következő: frame-enként két szemafor (acquire/present), egy command buffer és egy fence (hogy tudjuk mikor lehet resetelni a command buffer-t). Egyes irodalmak frame-enkénti külön command buffer pool-t is javasolnak (fragmentáció elkerüléséhez).

CODE
VkCommandBuffer commandbuffers[3]; VkSemaphore acquiresemas[3]; VkSemaphore presentsemas[3]; VkFence fences[3]; uint32_t currentframe = 0; uint32_t currentdrawable = 0; uint32_t buffersinflight = 0;

CODE
vkAcquireNextImageKHR( device, swapchain, UINT64_MAX, acquiresemas[currentframe], NULL, &currentdrawable); // TODO: rajzolás + pre-present barrier VkSubmitInfo submitinfo = {}; VkPresentInfoKHR presentinfo = {}; uint32_t nextframe = (currentframe + 1) % 3; submitinfo.waitSemaphoreCount = 1; submitinfo.pWaitSemaphores = &acquiresemas[currentframe]; submitinfo.commandBufferCount = 1; submitinfo.pCommandBuffers = &commandbuffers[currentframe]; submitinfo.signalSemaphoreCount = 1; submitinfo.pSignalSemaphores = &presentsemas[currentframe]; vkQueueSubmit(graphicsqueue, 1, &submitinfo, fences[currentframe]); presentinfo.pImageIndices = &currentdrawable; presentinfo.waitSemaphoreCount = 1; presentinfo.pWaitSemaphores = &presentsemas[currentframe]; vkQueuePresentKHR(graphicsqueue, &presentinfo); ++buffersinflight; if( buffersinflight == 3 ) { vkWaitForFences(device, 1, &fences[nextframe], VK_TRUE, 100000000); vkResetFences(device, 1, &fences[nextframe]); vkResetCommandBuffer(commandbuffers[nextframe], 0); --buffersinflight; } currentframe = nextframe;

Szemfülesebb programozóknak feltűnhet, hogy nem tripláztam meg az uniform buffer-eket, mint metálban. Ezt azért mertem (nem) megtenni, mert a vkCmdUpdateBuffer() a megkapott adatot belemásolja a command buffer-be, így tulajdonképpen implicit elvégzi nekem (bár az átfedésekkel még elronthatja, ennek eddig nem láttam jelét). De természetesen úgy biztonságos, ha megcsinálod.

A frame queueing-ra adhatóak alternatív megoldások, mert a vkWaitForFences() tud egyszerre több fence-re is várakozni (all/any jelleggel), de azt is meg lehet neki mondani, hogy ne várakozzon (hanem majd én lekérdezem az állapotát). Az acquire szintén történhet nem-blokkoló módon. Ezek mind abban segítenek, hogy a CPU és a GPU is minél több munkát tudjon elvégezni. Ha az applikáció főleg a CPU-n dolgozik, akkor érdemesebb nem-blokkoló megoldásokban gondolkodni.


Az első keményebb dolog amit meg kell írnod ha vulkánozni szeretnél az egy memória szuballokátor (merthogy elmondtam, hogy 4096 a foglalási limit). Azt is elmondtam, hogy egy hardverfüggő érték befolyásolja, hogy buffer után milyen címre lehet image-et foglalni. Belépőszintű megoldásként azt választottam, hogy az allokátor 64 MB-os chunk-okban foglalja a memóriát, aszerint hogy:

  • az allokációs kérés teljesíthető-e az aktuális chunk-ban
  • lineáris vagy optimális memória kell
Új chunk-ot foglal tehát, ha az adott típusú addigiak közül mindegyik betelt (vagy nem is volt). Azaz kikerültem ezt a hardverfüggő dolgot egy kis pazarlás árán. Egy külön probléma volt, hogy a CPU oldalra leképezés nem skatulyázható, ezért az egész chunk-ot egyben képzem le (persze nem muszáj, de akkor tényleg ne skatulyázz). Defragmentációval nem foglalkoztam, mert általában nincs rá szükség, persze vannak olyan elvetemültek akiknek az kell. A hivatalos javaslat az, hogy élettartam szerint csoportosítsd a foglalásokat.

A következő kritikus optimalizáció a barrier batching, az ugyanis rendkívül költséges művelet (üríti a cache-t). Amikor csak lehet szedd össze a barrier-eket egyetlen hívásba. Ehhez én egy VulkanPipelineBarrierBatch nevű osztályt csináltam és az alábbi módon használható:

CODE
VulkanPipelineBarrierBatch barrier( VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT); { barrier.ImageLayoutTransfer( swapchainimages[currentimage], 0, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); barrier.ImageLayoutTransfer( depthbuffer->GetImage(), 0, VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT, VK_IMAGE_ASPECT_DEPTH_BIT|VK_IMAGE_ASPECT_STENCIL_BIT, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL); } barrier.Enlist(commandbuffer);

Ugye emlékszünk még, hogy mit mondtam a VK_IMAGE_LAYOUT_UNDEFINED-ről? Itt szándékosan van az megadva, mert valóban nem érdekel az adat. Bufferekhez a BufferAccessBarrier() metódus használható. A batch gyűjtögeti az adatokat, majd az Enlist()-ben illeszti be a konkrét barrier-t.

Végül megemlíteném az indirect draw/dispatch hívásokat, amik bár OpenGL-ben is léteznek eddig nem nagyon foglalkoztam velük. A hívás azért indirekt, mert nem közvetlenül adod meg a paramétereit, hanem több paraméter csomagot adsz meg egy bufferben. Arra használható, hogy az egy bufferbe szuballokált objektumaidat egyetlen hívással (akár többször) rajzolhasd ki (mint egy batchelt instancing).

CODE
// staging-et lekezeli VulkanBuffer* indirectdata = VulkanBuffer::Create( VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT, 3 * sizeof(VkDrawIndexedIndirectCommand), VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT|VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT); VkDrawIndexedIndirectCommand* commands = (VkDrawIndexedIndirectCommand*)indirectdata->MapContents(0, 0); // indexCount, instanceCount, firstIndex, vertexOffset, firstInstance commands[0] = { 36, 5, 0, 0, 0 }; commands[1] = { 162, 3, 36, 24, 5 }; commands[2] = { 324, 7, 198, 76, 8 }; indirectdata->UnmapContents(); // feltöltés a videómemóriába VkCommandBuffer copycmd = VulkanCreateTempCommandBuffer(); VulkanPipelineBarrierBatch barrier( VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT); { indirectdata->UploadToVRAM(copycmd); barrier.BufferAccessBarrier( indirectdata->GetBuffer(), VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_INDIRECT_COMMAND_READ_BIT); } VulkanSubmitTempCommandBuffer(copycmd, true);

CODE
vkCmdBindVertexBuffers(...); vkCmdBindIndexBuffer(...); vkCmdDrawIndexedIndirect( commandbuffer, indirectdata, 0, 3, sizeof(VkDrawIndexedIndirectCommand));

A többszöri rajzoláshoz szükséges a multidrawindirect feature megléte, anélkül maximum egyszer rajzolhat (de akkor ekvivalens a vkCmdDrawIndexed()-del). Nyilván előfeltétel, hogy az objektumok egy buffer-ben legyenek, illetve a megfelelő adataik típusa megegyezzen. A vkCmdDispatchIndirect() teljesen hasonló, compute shader-ekhez.


A cikk megjelenése után kaptam egy AMD kártyát és hát bizony az összes progim szanaszét szállt rajta. Ez a bekezdés a kb. egy hetes nyomozásomból leszűrt tanulságokat foglalja össze.

Először is a vkCmdUpdateBuffer() egy transfer művelet, tehát buffer barrier-t kell rakni mögé, mielőtt használnád (bár tipikusan olyan gyorsan megtörténik, hogy nem okoz látható hibát). Mivel a command buffer-be másolja az adatot, uniform buffer-ekhez célszerű használni. A frame queueing-al való kapcsolata továbbra sem világos, de duplikálás nélkül is jól működik a program.

Egyéb módon használt buffereket viszont meg kell duplikálni (pl. amit mappelsz/unmappelsz), a deferred renderer-ben ilyen a fényeket tartalmazó buffer. Szintén ne felejtsd el körbevenni barrier-ekkel amikor módosítod a tartalmát.

A legfontosabb tanulság viszont a render pass-okhoz kötődik: ha egymás után több render pass-t használsz, akkor azok között szinkronizálnod kell. Nálam úgy jelentkezett a hiba, hogy a driver nem törölte le a depth buffer-t (de egyébként minden működött). A megoldás erre az, hogy a későbbi render pass-hoz hozzáadsz egy external dependency-t:

CODE
dependency.srcSubpass = VK_SUBPASS_EXTERNAL; dependency.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT|VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; dependency.dstSubpass = 0; dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT|VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; dependency.dependencyFlags = VK_DEPENDENCY_BY_REGION_BIT;

És itt látjuk az első használatát a VK_DEPENDENCY_BY_REGION_BIT-nek, ugyanis ha a végrehajtási függőség framebuffer stage-ekre vonatkozik, akkor ezt meg kell adni.

Mégegyszer megemlítem, hogy nVidia kártyán ne fejlessz! Olyan hiba is volt a programomban, hogy a memória szuballokátor egymást átfedő címekre foglalt szinte mindent...magyarán folyamatosan felülvágták egymás tartalmát, a GTX 650-en viszont semmi probléma nem jött ebből. De ilyen hibát is elkövettem, hogy túlírtam egy index buffer-t és mégis jól jelent meg az objektum...


A legjobb debug eszközök nyilván a validációs rétegek, amiket ajánlott minden nap frissíteni és újrafordítani, mert nagyon gyakran frissülnek és új hibákat hoznak ki a programodban. Azt viszont nem nagyon írják le sehol, hogy hogyan kell őket beállítani. Windows-on az alábbi registry kulcsot kell kitölteni: HKEY_LOCAL_MACHINE/SOFTWARE/Khronos/Vulkan/ExplicitLayers. Minden réteghez fel kell venni egy 0 értékű DWORD-öt, névnek pedig add meg az adott réteg .json fájljának elérési útját.

A rétegeken kívül van még két különálló program is, amiket érdemes bemutatni. Az egyik a RenderDoc, ami eddig OpenGL-hez volt használatos, de azóta vulkánt is tud. Arra vigyázni kell, hogy a validációs rétegeket kapcsold ki mielőtt használod, mert az egyik belső shadere nem fordul le (jeleztem a fejlesztőnek, de tojik rá). Leginkább a néhai PIX-re hasonlít, szóval arra használható, hogy frame capture-t csinálj, és a kirajzolt objektumok/textúrák adatait megnézd a pipeline egyes állapotaiban. A pixel history illetve debugolása nem működik nekem.

A másik a Mali graphics debugger, ami Android-os applikációkhoz van, így érdemben nem tudtam kipróbálni. A leírás és a képek alapján viszont a PIX és a gDebugger örtvözetének tűnik.

Megemlítendő még az AMD CodeXL illetve az nVidia nSight, de utóbbit nem tudtam működésre bírni vulkánnal.

A könnyebb tájékozódás érdekében a fontosabb fogalmak, összefoglalva:

host aki a vulkánt használja, tipikusan a CPU oldal
instance a vulkán implementáció egy adott applikációhoz tartozó példánya
instance layer a vulkán implementáció fölé húzható globális rétegek (pl. validáció)
instance extension a vulkán implementáció globális kiterjesztései (pl. WSI, debug report)
physical device a számítógépben fizikailag létező hardver
logical device a fizikai hardver applikáció-oldali interfésze
device extension logical device-ra vonatkozó kiterjesztés
queue family adott művelethalmazokat (pl. graphics, compute) támogató command queue-k családja
queue egy konkrét command queue, ami akár több célra, illetve több szálból is használható
command buffer pool thread-enként egyedi command buffer allokátor
command buffer vulkán utasítások halmaza, ami lehet elsődleges vagy másodlagos (utóbbi szigorú feltételekhez kötött); újrafelhasználható
semaphore kétállapotú szemafor, queue parancsok közötti szinkronizációhoz
event utasítások közötti finom szinkronizáció, amit a host és device egyaránt használhat
barrier utasításhalmazok közötti szinkronizációs választóvonal (pl. memória olvasás/írás bevárásához)
fence device és host közötti magas szintű szinkronizáció (pl. command buffer bevárása)
memory heap adott memóriatípust támogató kupac
device memory egy memory heap-ből allokált adott tulajdonságú memóriatartomány
buffer egy konkrét memóriához tartozó tetszőleges formátumú adatot leíró objektum
buffer view a buffer mögötti adat más formátumban való értelmezése
image egy konkrét memóriához tartozó pixel adat; tárolása tipikusan nemlineáris
image view az image egy adott subresource intervallumának más formátumban értelmezése
image layout az image állapota; minden váltást egy image barrier végez el (akár implicit beszúrva egy render pass által)
shader module akár több belépési pontot tartalmazó shader halmaz
descriptor set layout a shader-be kívülről érkező adatok leírása
descriptor set pool descriptor set allokátor
descriptor set a shader-be kívülről érkező konkrét adatok
framebuffer image view-k halmaza, amiket a render pass használ (a.k.a attachment-ek)
render pass az attachment-ek (pl. color, depth-stencil) leírása
render subpass a render pass által megadott attachment-ek egy részhalmaza
subpass dependency végrehajtási és memóriaelérési függőség két subpass között
pipeline layout descriptor set layout-ok és push constant range-ek összefoglalása
pipeline rajzoló vagy dispatch utasítások végrehajtója ("szerelőszalag")
derivative pipeline létező pipeline-ból származtatott új pipeline
pipeline cache a pipeline-ok (kvázi) módosíthatatlan objektumok, de a létrehozásuk költséges, ezen segít a pipeline cache; merevlemezre is kiírható
push constant a command buffer-be közvetlenül írt uniform adat

És még sorolhatnám... de ezeket kell álmodból felébresztve kenni-vágni.


Nem mondok olyat, hogy "ezt is túléltük", mert épphogy csak nekikezdtünk. Mostantól fognak csak jönni az újabbnál újabb GPU trükkök, a szabvány fejlődéséről nem is beszélve. Maga a vulkán szabvány is annyi lehetőséget ad, amivel könyveket lehetne megtölteni (és akkor vegyük hozzá, hogy nem is mindenkinek világos, hogy mi miért van). Foglaljuk össze a vulkán előnyeit:

  • az alkalmazás magas szintű logikájára szabható
  • minimális driver overhead
  • párhuzamosítható rajzolás
  • több GPU egyidejű használata
A fejlesztés eredménye két lényegesebb program lett: az egyik a drawcall batching-ot próbálja kihasználni, a másik pedig egy komolyabb tile-based deferred renderer (mert olyat még nem csináltam).

A szabványt bújva vannak jó hírek, pl. visszakerült a triangle fan topológia és a user clip plane-ek (a CullDistance szemantika formájában) de persze ez is, mint minden hardverfüggő... Nem tudom mennyire jó hír, de a blend operation mellett meg lehet adni logical operation-t is (mint a stencil buffer-nél, de jóval több lehetőséggel).

Kód a szokásos helyen, a deferred renderer-ről pedig videó. Lehet kezdeni új engine-t írni...


Höfö:
  • Vizsgáld meg a spagetti entrópiájának változását a kavargatása által!
  • Merthogy a vulkánnal nem fogsz egyhamar dűlőre jutni...

Irodalomjegyzék

https://www.khronos.org/registry/vulkan/specs/1.0-wsi_extensions/xhtml/vkspec.html - Vulkan specification (Khronos, 2016)
https://www.khronos.org/registry/vulkan/specs/misc/GL_KHR_vulkan_glsl.txt - GL_KHR_Vulkan_glsl (Khronos, 2015)
https://www.khronos.org/assets/.../10-Porting-to-Vulkan.pdf - Porting to Vulkan (Khronos, 2016)
https://www.khronos.org/assets/.../4-Using-spir-v-with-spirv-cross.pdf - Using SPIR-V with SPIR-V Cross (Khronos, 2016)
https://www.khronos.org/assets/.../Khronos-OpenGL-Efficiency-GDC-Mar14.pdf - OpenGL Efficiency: AZDO (Khronos, 2014)
http://gpuopen.com/.../Most-common-mistakes-in-Vulkan-apps.pdf - Most Common Mistakes in Vulkan Apps (AMD, 2016)
http://gpuopen.com/.../VulkanFastPaths.pdf - Vulkan Fast Paths (AMD, 2016)
http://on-demand.gputechconf.com/siggraph/.../SIG1501-Piers-Daniell.pdf - Vulkan on nVidia GPUs (nVidia, 2015)
https://developer.nvidia.com/vulkan-memory-management - Vulkan Memory Management (nVidia, 2016)
https://developer.nvidia.com/vulkan-shader-resource-binding - Vulkan Shader Resource Binding (nVidia, 2016)
https://imgtec.com/blog/gnomes-per-second-in-vulkan-and-opengl-es/ - Gnomes per Second (Imagination, 2015)

back to homepage

Valid HTML 4.01 Transitional Valid CSS!