72. fejezet - Grafikus API kiegészítések


Az elmúlt hónapokban a példakódok refaktorálásával foglalkoztam, beleértve a korábbi hibák javítását, egy közös felületet a platformok könnyebb használatához, illetve néhány újdonságot. Amit viszont megtartottam, az a különféle API-k szétválasztása. Ez azt jelenti, hogy ha OpenGL példát nézel, akkor olyan kódot fogsz látni (sosem volt célom absztrakt felületet írni, arra ott van az engine-em).

Ez a cikk azért született, hogy a korábban nem egyértelmű dolgokat (pl. az azóta felokosított Vulkan validációs rétegek miatt) elmagyarázzam, illetve az elérhető új funkcionalitásokat bemutassam. Emellett néhány példaprogram drasztikusan megváltozott, például kihajítottam az Ogg-Vorbis-t és helyette a Windows Media Foundation segítségével töltök be hangokat. Ezekről, illetve néhány fontos tanulságról szólnak a bekezdések.



Rögtön ezzel kezdeném, a neten ugyanis nem írják le sehol, hogy mit kell csinálni a glslang-gal, hogy az új feature-öket ki tudja használni (a kódját kellett végignéznem, hogy rájöjjek). Maga a Vulkan 1.1 beállítása nem bonyolult, egyszerűen azt a verziót adod meg az app_info.apiVersion adattagnak. Egészen meglepődtem, hogy a benti R7 360-as kártyám tudja ezt, és ezáltal a SPIR-V 1.3-at is (ami ugyanis kötelező Vulkan 1.1-ben). A támogatás a vkEnumerateInstanceVersion() függvénnyel kérdezhető le. Vigyázat, ez a vulkán implementáció verzióját jelenti, szóval ugyanezt el kell végezni a kiválasztott physical device-ra is (deviceProps.apiVersion).

Mivel az említett függvény Vulkan 1.1 újdonság, könnyen előállhat az az eset, hogy a driver még régi és rögtön ebben a hívásban el is száll a program. A tisztességes megoldás erre az, hogy előbb lekérdezed a függvénypointert a vkGetInstanceProcAddr() függvénnyel.

A glslang persze minderre magasról tojik. Először is a TBuiltInResource struktúra megváltozott; napokig böngésztem a netet, még a fejlesztőknek is írtam, hogy mi a bánatért nem működik a shader fordítás. Hát ezért...szóval első dolgom volt kimásolni az alapértékeket a StandAlone/ResourceLimits.cpp fájlból (persze lehetne azt is használni, de az esetleges módosítások miatt inkább lemásoltam).

Ha ez megvan, akkor a shader-t a következőképpen kell SPIR-V 1.3 kompatibilsként fordítani:

CODE
glslang::TShader shader(stage); EShMessages messages = (EShMessages)(EShMsgSpvRules|EShMsgVulkanRules); char* source = ...; shader.setStrings(&source, 1); shader.setEnvTarget(glslang::EShTargetSpv, glslang::EShTargetSpv_1_3); shader.parse(&SPIRVResources, 110, false, messages);

Ahol a 110 azt akarja jelenteni, hogy desktopon fordítok (ha mobilra fordítasz, akkor a kommentek alapján 100-at kell megadni). Ha ezek közül bármelyiket elfelejted, akkor ugyanezt várhatod az újdonságoktól is. A többi dolog hasonlóan megy mint eddig, annyi különbséggel, hogy a glslang összes libjét hozzá kell linkelni a programhoz (még az opcionálisakat is). Ez nem lenne akkora probléma, ha nem foglalnának el közel 1 GB-ot...

(megj.: és pont emiatt nem tudtam betenni ezeket a repóba)


Ez elég mellbevágó volt, pontosabban az, hogy egy szót nem szólt eddig a validációs réteg. Szerencsére minden hibaüzenetben kiírja a linket a szabvány megfelelő bekezdésére, így könnyen javítható volt. A lényeg, hogy a memóriának nem csak a címe kell align-olt legyen (ahogy írtam attól függően, hogy lineáris vagy optimális, illetve, hogy milyen után foglalod), hanem a mérete is:

CODE
VkDeviceSize alignment = driverInfo.deviceProps.limits.nonCoherentAtomSize; size += (alignment - (size % alignment)); buffercreateinfo.size = size;

A memóriára akkor mondjuk, hogy koherens, ha a módosítások azonnal láthatóak annak, aki olvassa. Nem-koherens memória esetén a cache-elési logika érvényesül, és a szabvány megköveteli, hogy ekkor a nonCoherentAtomSize érték többszöröseivel legyen határolva.


Ahogy az eredeti cikkben írtam, akkoriban még a szabvány sem volt egyértelmű; ezen természetesen változtattak én meg csak téptem a hajamat, hogy mit jelent egyáltalán a hibaüzenet, amit kapok. Röviden a barrier-ek hat meghatározó eleme (src/dst pipeline stage, src/dst access mask, src/dst image layout) nagyon szigorúan összefügg, amit ez a táblázat rögzít. Értsük ez alatt a következőket:

  • adott művelet csak a hozzá tartozó stage-ben hajtható végre (pl. a vkCmdCopyBuffer() a TRANSFER-ben)
  • az access mask a táblázat szerinti stage-ekben érvényes csak
  • a source image layout szigorúan meg kell egyezzen az image aktuális image layout-jával
  • az image layout függ a felhasználási módtól (pl. storage image csak GENERAL layout-ban lehet)
  • az image layout a barrier-ben megadott teljes subresource range-en azonos kell legyen
Mutatok egy példát, hogy ez mit jelent a gyakorlatban. Az implementációmat először is átalakítottam úgy, hogy minden subresource legutolsó access mask-ját és image layout-ját eltárolom, így megspórolva az source paraméterek megadását. A példa egy textúra feltöltése és rajzolásra előkészítése:

CODE
VulkanTemporaryCommandBuffer(true, true, [&](VkCommandBuffer transfercmd) -> bool { VulkanPipelineBarrierBatch barrier(VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); { // az utolsó paraméter mondja meg, hogy generáljon-e mip szinteket; erről majd külön írok texture->UploadToVRAM(barrier, transfercmd, true); barrier.Reset(VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); barrier.ImageLayoutTransition( texture, VK_ACCESS_SHADER_READ_BIT, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, 0, 0, 0, 0); } barrier.Enlist(transfercmd); return true; });

Látható, hogy kénytelen voltam az src/dst pipeline stage-eket módosíthatóvá tenni, ilyenkor az addig megadott barrier-eket kitörli (bár nem feltétlenül lenne rá szükség, de akkor az Enlist() pillanatában mindegyikre tudnom kellene, hogy melyik pipeline stage-ben volt éppen).

Egy másik fontos dolog, ami konkrétan hibát is okozott, hogy a layout transition nem vonja magával az access mask megváltozását is. Hivatkozva az eredeti cikkben írt tile based deferred renderer-re, a compute shader elé az alábbi kódot kellett bebiggyesztenem:

CODE
// <g-buffer pass vége, az image-ek access mask-ja VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT> barrier.Reset(VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); { barrier.ImageLayoutTransition( gbuffernormals->GetImage(), VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL, 0, 1, 0, 1); } barrier.Enlist(primarycmdbuff); vkCmdBindPipeline(primarycmdbuff, VK_PIPELINE_BIND_POINT_COMPUTE, accumpasspipeline->GetPipeline()); vkCmdDispatch(primarycmdbuff, workgroupsx, workgroupsy, 1); // <forward pass kezdete>

Amíg ez nem volt a kódban, a képernyő tetején egy jól látható szemetelés történt; annak idején azért nem vettem észre, mert a modell csak egy adott részén jött elő (valószínűleg ott, ahol a legtöbb fényt kivágta). Ebből is látszik, hogy mennyire oda kell figyelni vulkán programozáskor, és ez tipikusan olyan hiba, amit a validációs réteg nem tud észrevenni.


Na ettől tényleg megőszültem...összehasonlítva mondjuk a Metal egy, azaz egy darab hívásával, vulkánban ez egy teljes káosz. Először is, ahogy említettem az image minden subresource-a (mip szintek, array layer-ek) ugyanabban a layout-ban kell legyenek. Oké, ez még lenyelhető... Ahhoz viszont, hogy egy adott mip szintből másoljak egy másikba az egyik TRANSFER_SRC, a másik TRANSFER_DST layout-ban kell legyen (azaz minden másolás előtt be kell biggyeszteni egy barrier-t). Beszéljen inkább a kód:

CODE
VkBufferImageCopy region = ...; VkImageBlit blit = ...; // feltöltöm az alap (0.) mip szintet; minden subresource-ot TRANSFER_DST-be rakva barrier.ImageLayoutTransition( this, VK_ACCESS_TRANSFER_WRITE_BIT, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, 0, 0, 0); barrier.Enlist(commandbuffer); vkCmdCopyBufferToImage( commandbuffer, stagingbuffer->GetBuffer(), image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &region); // mip-chain generálás for (uint32_t i = 1; i <= mipmapcount; ++i) { // az előző mip szintet TRANSFER_SRC-be kell rakni barrier.ImageLayoutTransition( this, VK_ACCESS_TRANSFER_WRITE_BIT, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, i - 1, 1, 0, 0); barrier.Enlist(commandbuffer); // de az utolsót is, különben később nem lesz konzisztens if (i < mipmapcount) { blit.dstOffsets[1].x = Math::Max<uint32_t>(1, extents.width >> i); blit.dstOffsets[1].y = Math::Max<uint32_t>(1, extents.height >> i); blit.dstOffsets[1].z = 1; blit.dstSubresource.mipLevel = i; vkCmdBlitImage( commandbuffer, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR); } }

Ehhez képest ugyanez metálban:

CODE
MetalTemporaryCommandBuffer(queue, true, [=](id<MTLCommandBuffer> transferdmc) -> bool { id<MTLBlitCommandEncoder> blitencoder = [transferdmc blitCommandEncoder]; [blitencoder generateMipmapsForTexture:ret]; [blitencoder endEncoding]; return true; });

Tényleg olyan nehéz lett volna a Khronos-nak egy hasonló egysoros függvényt bevennie a szabványba? Persze, hogy nem, de akkor elveszíted az olyan perverziókat, mint pl. hogy csak egy adott array layer-nek generálsz mip szinteket (ami mint tudjuk nagyon reális use-case).


Ha már Metal, akkor áttérek most egy kicsit arra. Mivel csak egy vacak Intel Iris Pro kártyám van, egetrengető dolgokat nem tudtam kipróbálni, viszont írtam egy forward+ renderer-t, ami még ezen a diavetítőn is meglepően jól fut (kis felbontáson...4K-ra kirakva Einsten-t meghazudtoló sebességre lassulnak a fények).

Na de mire jó a MetalKit? Röviden arra, hogy még kevesebbet kelljen kódolnod. Leginkább a néhai D3DX-hez tudnám hasonlítani. Előre implementált osztályok, matek függvények, stb. Természetesen az Igazi Programozó™ ódzkodik az ilyen dolgoktól; elkényelmesedik tőle az ember. Az egyetlen osztály, amit használok belőle az MTKView, ami megsprórolja a drawable-ökkel és a displaylink-kel való hercehurcát, illetve előre legyárt egy alap render pass-t (és tulajdonképpen azt az egyet használhatod akár a teljes programban).

CODE
- (void)viewDidLoad { [super viewDidLoad]; _view = (MTKView*)self.view; _view.device = MTLCreateSystemDefaultDevice(); if (!_view.device) { // nincs Metal támogatás self.view = [[NSView alloc] initWithFrame:self.view.frame]; return; } _view.delegate = self; _view.depthStencilPixelFormat = MTLPixelFormatDepth32Float_Stencil8; _view.colorPixelFormat = MTLPixelFormatBGRA8Unorm_sRGB; _view.sampleCount = 1; }

CODE
- (void)drawInMTKView:(nonnull MTKView*)view { // rajzolás; a view-tól elkérhető minden, amire szükség lehet }

A felsoroltak mellett további előnye, hogy beállítható kézi rajzolásra is, ekkor neked kell meghívni rá a draw üzenetet. Annyival egészítettem ki, hogy reagáljon a billentyűzet/egér üzenetekre, de ezt értelemszerűen nem fogom itt bemutatni.


A kérdésem itt az volt, hogy a három lehetséges tárolási mód közül (private, shared, managed) mikor melyiket érdemes használni. A negyedikkel (memoryless) nem foglalkoztam. Először is mit jelentenek ezek a tárolási módok:

  • private: az adat a GPU-n van lefoglalva, a CPU oldal nem látja
  • shared: a GPU és a CPU oldal is látja az adatot. Integrált kártya esetén ez egy darab memóriaterület, diszkrét kártya esetén az AGP/PCIe porton keresztül mozog
  • managed: külön példány van az adatról a GPU és a CPU oldalon is (így mindkettő gyorsan eléri, viszont másolni kell)
Az eredeti kérdésre (mikor melyiket) a válasz kissé megosztó:
  • ha az adat nem módosul gyakran, akkor private, az adatot egy staging buffer-en (shared) keresztül másolni fel
  • ha a textúra rajzolásra/számításra van használva, akkor private, egyébként managed
  • ha külső GPU-t használ a gép, akkor managed (az alacsony sávszél miatt)
  • belső GPU-n, ha a CPU oldal gyakran módosítja az adatot, akkor shared (közepes/nagy sávszél)
A gyakorlatban tehát a geometria adat tipikusan private, a textúra adat managed, a gyakran módosuló uniform adatok pedig shared. Felhívnám a figyelmet arra, hogy ha a program a fentiek értelmében nem támogatja a külső GPU-kat, akkor hiába veszel bikaerőset, az USB-C port alacsony sávszélessége miatt lassabb lesz a program, mint mondjuk a belső integrált kártyán.

Ezt is említettem már ugyan, de ide tartozik a CPU cache mode is:
  • default cache: az írások/olvasások az elvárt sorrendben történnek
  • write-combined: a CPU oldal csak írja az adatot (tudja ugyan olvasni is, de eszeveszett lassan)
Ezeket figyelembe véve a MetalMesh osztályomat a következőképpen írtam meg (csak belső GPU-ra):

CODE
MetalMesh::MetalMesh(...) { vertexbuffer = [device newBufferWithLength:numvertices * vertexstride options:MTLResourceStorageModePrivate]; indexbuffer = [device newBufferWithLength:numindices * istride options:MTLResourceStorageModePrivate]; // megj.: a cache mode nem fért ki (default) stagingvertexbuffer = [device newBufferWithLength:numvertices * vertexstride options:MTLResourceStorageModeShared]; stagingindexbuffer = [device newBufferWithLength:numindices * istride options:MTLResourceStorageModeShared]; mappedvdata = (uint8_t*)[stagingvertexbuffer contents]; mappedidata = (uint8_t*)[stagingindexbuffer contents]; }

CODE
void MetalMesh::UploadToVRAM(id<MTLCommandBuffer> commandbuffer) { id<MTLBlitCommandEncoder> encoder = [commandbuffer blitCommandEncoder]; [encoder copyFromBuffer:stagingvertexbuffer sourceOffset:0 toBuffer:vertexbuffer destinationOffset:0 size:stagingvertexbuffer.length]; [encoder copyFromBuffer:stagingindexbuffer sourceOffset:0 toBuffer:indexbuffer destinationOffset:0 size:stagingindexbuffer.length]; [encoder endEncoding]; }

Ha valamilyen okból szükség van gyakori módosításra, akkor ki lehet egészíteni, de ahogy korábbi cikkekben említettem, a compute shader a legtöbb esetben meg tudja ezt oldani a CPU beavatkozása nélkül.


...és hogy miért fontos. Az egyszerű válasz az, hogy ha nem lenne fontos, akkor nem vették volna be a szabványba (vö.: a geometry shader-t és a tessellation control shader-t kihajították). A nemtriviális válasz az, hogy különbség van egy sima storage buffer és az előre megjelölt vertex adat olvasása között.

A GPU-val kapcsolatban ugyanis van egy fogalom, amit pre-transform vertex cache-nek hívnak (röviden vcache), ami a rajzolandó objektum vertexeit egy kis méretű, de gyors elérésű memóriába teszi. Kapcsolódik ehhez a vcache-re való optimalizálás, de azzal most nem foglalkozok.

A lényeg az, hogy ha megadod az input layout-ot, akkor a hardver ki fogja használni, szemben az általános célú (storage) buffer-ekkel. Metálban ezt vertex descripor-nak hívják, és kétféleképpen is ki lehet tölteni. Az egyik a "klasszikus" módszer, amikor kézzel töltöd fel (mint pl. OpenGL-ben a VAO-t), de mivel úgyis szorosan kapcsolódik a render pipeline state-hez, érdemesebb a shader reflection alapján kitölteni:

CODE
pipelinedesc.vertexFunction = [defaultlibrary newFunctionWithName:@"vs_main"]; NSArray* signature = pipelinedesc.vertexFunction.vertexAttributes; MTLVertexDescriptor* descriptor = [[MTLVertexDescriptor alloc] init]; NSUInteger offset = 0; for (int i = 0; i < [signature count]; ++i) { MTLVertexAttribute* attrib = [signature objectAtIndex:i]; auto formatandsize = MapDataType(attrib.attributeType); descriptor.attributes[i].bufferIndex = 0; descriptor.attributes[i].format = formatandsize.first; descriptor.attributes[i].offset = offset; offset += formatandsize.second; } descriptor.layouts[0].stride = offset; descriptor.layouts[0].stepFunction = MTLVertexStepFunctionPerVertex; pipelinedesc.vertexDescriptor = descriptor;

Az egyes elemeket az [[attribute(n)]] minősítővel kell összeszedni egy struktúrába, amit a [[stage_in]] minősítővel kap meg a vertex shader.


Azért említem meg, mert a látszólagos egyszerűsége ellenére sikerült elrontanom. A legegyszerűbb megoldás persze az MTKTextureLoader használata lenne, de keveset lehet tudni a belső működéséről (pl. sRGB vagy lineárisként tölti-e be). Az Igazi Programozó™ emiatt bizalmatlanul áll hozzá és inkább néhány sorral többet kódol:

CODE
id<MTLTexture> MetalCreateTextureFromFile(..., const char* file, bool srgb) { NSURL* path = [NSURL fileURLWithPath:[NSString stringWithUTF8String:file]]; NSImage* img = [[NSImage alloc] initByReferencingURL:path]; CGColorSpaceRef colorspace = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); CGContextRef bitmap = CGBitmapContextCreate( NULL, img.size.width, img.size.height, 8, 0, colorspace, kCGImageAlphaPremultipliedLast); NSGraphicsContext* context = [NSGraphicsContext graphicsContextWithCGContext:bitmap flipped:NO]; [NSGraphicsContext setCurrentContext:context]; [img drawInRect:rect]; MTLPixelFormat format = (srgb ? MTLPixelFormatRGBA8Unorm_sRGB : MTLPixelFormatRGBA8Unorm); MTLTextureDescriptor* texdesc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:format width:img.size.width height:img.size.height mipmapped:YES]; ret = [device newTextureWithDescriptor:texdesc]; [ret replaceRegion:region mipmapLevel:0 withBytes:CGBitmapContextGetData(bitmap) bytesPerRow:bytesperrow]; // ... }

A mip szintek generálását fent leírtam. Szóval ja, nem mindegy hogy lineáris vagy nemlineáris adatot akarok betölteni: a normal map-ek például lineárisan vannak tárolva, és napokig nem jöttem rá, hogy miért volt rossz a megvilágítás. Azért, mert a Core Graphics oldaláról mindent sRGB-nek kell tekinteni (még ha nem is az).

Egyéb formátumok (pl. .dds) betöltése ugyanúgy történik, mint máshol.


Ezeket mindhárom API támogatja, a vulkán 1.1-től (subgroup operations), a metál 2-től (SIMD operations), a Direct3D 12 pedig amennyire tudom alapból (wave operations). Mivel mindegyik máshogy nevezi, én maradok inkább az nVidia terminológiájánál (warp).

A warp azoknak a szálaknak a halmazát jelenti, amik egy adott thread block-ban (work group) egyszerre futnak (de nem feltétlenül konkurensen). nVidia kártyákon ennek a mérete 32, AMD kártyákon 64. Egy adott szálra innentől invokációként fogok hivatkozni.

Amit fontos tudni, hogy az invokációk lehetnek aktívak vagy inaktívak, attól függően, hogy hogyan írtad meg a compute shader-t. Ha például a work group mérete kisebb a warp méretnél, akkor a különbözetben az összes invokáció inaktív lesz, azaz szuboptimálisan használod ki a hardvert. Hasonló eset áll elő akkor is, amikor dinamikus elágazásba kerül a program: a hamisra kiértékelődött invokációk inaktívak lesznek.

A támogatás lekérdezése vulkán oldalon a következőképpen fest (és végre látunk példát a pNext pointer használatára):

CODE
// megj.: a driverInfo struktúrában VkPhysicalDeviceSubgroupProperties subgroupProps;

CODE
// megj.: physical device kiválasztása után VkPhysicalDeviceProperties2 props2; driverInfo.subgroupProps.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SUBGROUP_PROPERTIES; driverInfo.subgroupProps.pNext = NULL; props2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2; props2.pNext = &driverInfo.subgroupProps; vkGetPhysicalDeviceProperties2(driverInfo.selectedDevice, &props2);

Amennyiben a subgroupSize legalább 1, akkor a műveletek támogatva vannak (de 32 alatt nincs benne köszönet). Fontos még a supportedStages változó, ami azt mondja meg, hogy milyen shader stage-ekben használhatóak ezek. A szabvány annyit követel meg, hogy ha van olyan queue, ami támogat compute shader-t, akkor ott használhatóak. Egyéb shader stage-ek nem feltétlenül támogatottak, szóval érdemes erre is figyelni.

Magukat a műveleteket is vulkán oldalról mutatom be (zanzásítva), de a többi API-ban is hasonlóak értendőek:
  • subgroup_basic: beépített változók (pl. gl_SubgroupSize) és szinkronizációs utasítások (pl. subgroupBarrier)
  • subgroup_vote: az invokációk "szavaznak" egy feltétel igazságtartalmára (pl. subgroupAll)
  • subgroup_ballot: adatmegosztás az invokációk között (pl. subgroupBroadcast), illetve bitmező alapú szavazás
  • subgroup_arithmetic: aktív invokációkra elvégezhető műveletek (pl. subgroupMax)
  • subgroup_shuffle: dinamikus adatmegosztás, elkérhető pl. egy adott invokáció eredménye (subgroupShuffle)
  • subgroup_shuffle_relative: eltolt adatmegosztás, pl. csak a páratlan invokációkon végigmenni
  • subgroup_clustered: műveletek elvégzése az aktív invokációk alcsoportjain (pl. subgroupClusteredMax)
  • subgroup_quad: fragment shader specifikus műveletek (pl. subgroupQuadSwapHorizontal)
Mire jók ezek általánosságban? Attól függ, hogy ki tudod-e használni őket vagy nem. Amennyiben igen, akkor a számításigényes compute shader-eket a végtelenségig ki lehet velük optimalizálni. Példának nézzük meg a korábbi sakktáblarajzolós compute shader régi és új változatát:

CODE
// régi #version 450 layout (binding = 0) uniform writeonly image2D img; layout (push_constant) uniform ConstantData { float time; } uniforms; layout (local_size_x = 16, local_size_y = 16) in; void main() { ivec2 loc = ivec2(gl_GlobalInvocationID.xy); vec4 color = vec4(0.0, 0.0, 0.0, 1.0); // minden aktív invokáció végrehajtja if (((loc.x + loc.y) / 16) % 2 == 1) { color.r = sin(uniforms.time) * 0.5 + 0.5; color.g = cos(uniforms.time) * 0.5 + 0.5; color.b = ...; } imageStore(img, loc, color); }
CODE
// új #version 450 #extension GL_KHR_shader_subgroup_basic : enable #extension GL_KHR_shader_subgroup_ballot : enable layout (binding = 0) uniform writeonly image2D img; layout (push_constant) uniform ConstantData { float time; } uniforms; layout (local_size_x = 16, local_size_y = 16) in; void main() { ivec2 loc = ivec2(gl_GlobalInvocationID.xy); vec4 color = vec4(0.0, 0.0, 0.0, 1.0); if (subgroupElect()) { // a megválasztott invokáció hajtja végre if (((loc.x + loc.y) / 16) % 2 == 1) { color.r = sin(uniforms.time) * 0.5 + 0.5; color.g = cos(uniforms.time) * 0.5 + 0.5; color.b = ...; } } // megosztás a többi invokációval color = subgroupBroadcastFirst(color); imageStore(img, loc, color); }

Ez talán a legtriviálisabb példa a használatra: minden subgroup-ból megválasztok egy invokációt a subgroupElect függvénnyel, ami kiszámolja a színt, majd a subgroupBroadcastFirst függvénnyel közvetítem az eredményt az összes többi invokációnak.

Persze meg lehetne ezt máshogy is oldani, például az első invokációval kiszámoltatom a színt egy shared változóba, utána barrier() és egyszerűen kiolvasom. Világos azonban, hogy ez a megoldás egyrészt a memóriába nyúl (még ha gyors elérésű is), másrészt megakasztja a végrehajtást. A subgroup memory lényegesen gyorsabb elérésű!

Természetesen kísérleteztem a többi függvénnyel is (pl. subgroupBallot), hogy valami kevésbé triviális feltétel alapján kiválogassam az aktív és inaktív invokációkat, de ezt vizuálisan nehéz prezentálni, mert ígyis-úgyis kiszámolja; legfeljebb az egyik megoldás gyorsabb, a másik lassabb. A subgroup műveletek tehát kimondottan alacsony szintű optimalizációra lettek kitalálva, értelmes példákat nehéz konstruálni hozzájuk. A javaslatom ezért az, hogy összetett compute/fragment shader optimalizálásakor vegyétek figyelembe.


Minden API-ban közös, hogy storage image formátuma nem lehet sRGB, ami ha belegondolunk logikus is, hiszen rajzoláskor úgyis lineáris színtérben kell számolni, akkor miért állítanál elő nemlineáris adatot? Hacsaknem egy tisztán compute shader alapú raytracer-ben, ami csak a végleges képet adja ki.

Megoldás persze mindig van (HDR textúrába rajzolni és a tone mapping/gamma korrekciót fragment shader-ben elvégezni), de ha nagyon lusta vagy, akkor erre valóak az image view-k, illetve GL 4.3-tól a glTextureView (amivel lehet sRGB-ként kezelni egy egyébként lineáris formátumú textúrát). Én nem érzek késztetést erre, úgyhogy ezt a bekezdést be is feje...

(megj.: szerintem az sRGB a "sárga bögre, görbe bögre" rövidítése lehet, mert az amcsik nem tudták kimondani)

Merthogy van sokkal izgalmasabb kérdés is, ugyanis Metal-ban nincs geometry shader (és nem is lesz). Szóval például a vastag vonalakat vagy CPU-n állítod elő, vagy compute shader-t használsz. A neten nincs egyértelmű konszenzus arról, hogy melyik megoldás a nyerő, de a tapasztalataim alapján azt javaslom, hogy ne helyben csináld (4 vertex, 6 index, a vonalat pedig mindig visszafejted).

Azért nem jó helyben számolni, mert az ide-oda transzformálgatások miatt eszméletlen precíziós hibák tudnak akkumulálódni (és ez akkor látszik leginkább, amikor nem vágod meg a vonalat a közeli vágósíkhoz). Az én megoldásom ezért az, hogy 2 vertex, 6 index, illetve egy extra buffer, amibe kiszámolom az adatot (mindig az eredetiből kiindulva).

CODE
// shader oldal #version 430 layout (std430, binding = 0) readonly buffer VertexData { vec4 data[]; } vertexdata; layout (std430, binding = 1) writeonly buffer GeometryData { vec4 data[]; } geomdata; layout (local_size_x = 64) in; void main() { uint start = gl_GlobalInvocationID.x; if (start >= numVertices) return; if (gl_LocalInvocationIndex % 2 == 0) { vec4 v1 = vertexdata.data[start + 0]; vec4 v2 = vertexdata.data[start + 1]; // TODO: mint a geometry shader-ben // TODO: de vissza kell trafózni! geomdata.data[start * 2 + 0] = wpos1; geomdata.data[start * 2 + 1] = wpos3; geomdata.data[start * 2 + 2] = wpos2; geomdata.data[start * 2 + 3] = wpos4; } }
CODE
// OpenGL oldal Math::Vector4* vdata = ...; uint32_t* idata = ...; // 2 vertex, 6 index for (uint32_t i = 0; i < segments; ++i) { vdata[i * 2 + 0] = ...; vdata[i * 2 + 1] = ...; idata[i * 6 + 0] = i * 4 + 0; idata[i * 6 + 1] = i * 4 + 1; idata[i * 6 + 2] = i * 4 + 2; idata[i * 6 + 3] = i * 4 + 3; idata[i * 6 + 4] = i * 4 + 2; idata[i * 6 + 5] = i * 4 + 1; } // 4 vertex glGenBuffers(1, &geometrybuffer); glBindBuffer(GL_ARRAY_BUFFER, geometrybuffer); glBufferData(GL_ARRAY_BUFFER, numvertices * 2 * 16, ...); // vastagítás glUseProgram(thicklineprogram); glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, vertexbuffer); glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, geometrybuffer); glDispatchCompute((numvertices + 63) / 64, 1, 1); glMemoryBarrier(GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT); // TODO: rajzolás

(megj.: nem írtam el, tényleg megcseréltem a sorrendet; ez attól függ, hogy hogyan indexeled meg a geometry buffer-t)

Magát a számolást nem írom le, mert elég intuitív, helyette néhány fontos dologra hívnám fel a figyelmet. Az std430-ra igaz, hogy a struktúrán belül nem paddel 16 bájtra, de attól még az egymás után következő példányok 16 bájtra vannak igazítva! Napokig nem értettem, hogy miért egy katyvaszt kapok eredményül (eredetileg vec3-ban tároltam a pozíciókat), amíg egy fórumbejegyzés lábjegyzetében meg nem találtam ezt az "apróságot". Szerintem ezt piros betűkkel kellene kiemelni, hogy az alignment 16 bájt (kivéve uniform-oknál, ahol 256). A gyakorlatban ez azt jelenti, hogy a pozíció vec4 kell legyen CPU oldalon is!

A másik fontos dolog a workgroup mérete. A fentiek értelmében legalább 64 kell legyen, de szinte kötelező minél nagyobbra venni (1024 maximum, ld. GL_MAX_COMPUTE_WORK_GROUP_SIZE), ugyanis a workgroup-ok száma 65535-ben van maximalizálva (ld. GL_MAX_COMPUTE_WORK_GROUP_COUNT). Ezek az értékek Radeon R7 360-ra igazak, más kártyákon eltérő lehet, szóval egy éles implementációban először kérdezd le ezeket, és úgy fordítsd a shader-t (illetve vulkánban használható a specialization constant, ahogy az eredeti cikkben leírtam).

Na de számoljunk akkor: a workgroup méret maximum 1024, ezekből lehet maximum 65535, tehát egy mai alsó kategóriás kártyán 33.5 millió vonalat át lehet(ne) így konvertálni egyetlen glDispatchCompute hívással. Na jó, de memória oldalról is meg kell ezt vizsgálni: az SSBO legalább 128 MB-ban kell legyen maximalizálva a szabvány szerint, négy darab pozíció elfoglal 64 bájtot, azaz máris leestünk 2 millió vonalra.

Most azt mondjátok, hogy "dehát az még mindig baromi sok..."; hát egy játékban valóban elég, viszont egy CAD programban abszolút nem. Tegyük azért hozzá, hogy ilyenkor már a framerate-ben sincs bocsánat, szóval nem nagy bűn egyszerűen lekorlátozni a buffer méretet és az extra vonalakat nem rajzolni (mellesleg simán előfordulhat, hogy pont az ilyen gigantikus méretek miatt hal meg a geometry shader, amit én első kézből tapasztaltam nem egyszer).

Harmadik fontos dolog: a kódról egyértelműen látszik, hogy szuboptimális: minden második invokáció aktív csak. Höfö átírni a példakódot vulkánra (a refaktorálásnak köszönhetően ez nem okozhat különösebb gondot) és subgroup műveletekkel kioptimalizálni (de az még jobb, ha azok nélkül is meg tudod csinálni, de akkor küldd el nekem is).

Amit még szeretnék megemlíteni, az a vonalak vágása a közeli vágósíkhoz (ami egyébként geometry shader-ben is szükséges). Az történik ugyanis, hogy ha a vonal egyik pontja a kamera mögött van, akkor a reverse projection miatt (pongyolán mondva) trapézzá alakul (nem tartja meg a konstans vastagságot), illetve ha be van kapcsolva a backface culling, akkor legalább egy háromszög eltűnik. A megoldás erre az, hogy még view space-ben meg kell határozni a közeli vágósíkkal való metszetet:

CODE
uniform vec4 nearPlane; // view space-ben bool ClipToNearPlane(inout vec4 vpos1, inout vec4 vpos2) { // sík egyenlete: (a, b, c, d) · (x, y, z, 1) = 0 // (parametrikus) vonal egyenlet: vpos1 + t * (vpos2 - vpos1) // utóbbi behelyettesítendő az elsőbe, meghatározandó t vec3 dir = vpos2.xyz - vpos1.xyz; float t = -(nearPlane.w + dot(nearPlane.xyz, vpos1.xyz)) / dot(nearPlane.xyz, dir); // hol vannak a pontok a síkhoz képest float dist1 = dot(vpos1, nearPlane); float dist2 = dot(vpos2, nearPlane); bool clipped = false; if (dist1 < 0) { // vpos1 a sík mögött van vpos1.xyz = vpos1.xyz + t * dir; clipped = true; } else if (dist2 < 0) { // vpos2 a sík mögött van vpos2.xyz = vpos1.xyz + t * dir; clipped = true; } return clipped; }

Az algoritmus tehát ráhelyezi a pontot a vágósíkra, amennyiben úgy látja, hogy mögötte van. Első kérdés, hogy mi történik akkor, ha mindkét pontra igaz ez? A válasz az, hogy nem érdekes, mert úgysem fog rajzolni semmit.

Második kérdés, hogy hogyan kell kiszámolni a közeli vágósíkot view space-ben. Ez szerencsére könnyű, mivel tudjuk róla, hogy kovariáns vektor: nearPlane = Mproj * { 0, 0, 1, 1 } (vigyázat, ez OpenGL-t feltételez!)

Az eredményt pedig úgy kell felhasználni, hogy az eredeti pontok helyett ezekkel végzed el a számolást.


Azért volt szükségem erre, mert az irradiance map számolóm furcsa eredményt adott egy bizonyos cubemap-re. Megkérdeztem egy külső fejlesztőt is, aki szintén az Unreal 4 algoritmusát használja, és megerősítette, hogy az ő implementációja is ugyanazt a rossz eredményt produkálja.

Helyes megoldást ugyan találtam, de nem adaptáltam eddig, ugyanis szeretném megérteni, hogy az UE4 megoldása miért rossz. Magyarázat nélkül a gyanúm az, hogy a Hammersley mintavételezés a hunyó. A teszteléshez viszont szükségem volt egy olyan eszközre, ami még a rajzolás megkezdése előtt el tudja kapni az API hívásokat.

CODE
#pragma comment(lib, "dxgi.lib") #include <DXGIType.h> #include <dxgi1_2.h> #include <dxgi1_3.h> #include <DXProgrammableCapture.h>

CODE
IDXGraphicsAnalysis* analysis = nullptr; DXGIGetDebugInterface1(0, __uuidof(analysis), (void**)&analysis); analysis->BeginCapture(); { // a kód, amit debugolni szeretnél } analysis->EndCapture(); analysis->Release();

A programot ilyenkor a Debug -> Graphics -> Start Graphics Debugging menüponttal kell indítani (Alt+F5). A Visual Studio ilyenkor egy külön tabot hoz fel, ahol a PIX-hez hasonlóan meg lehet nézni, hogy mi történt.

Vigyázat, ha nem így indítod a programot, akkor nullptr-t fogsz visszakapni, szóval normál esetben #ifdef-eld ki az egészet!


Az XAudio2 cikkem mostantól elavult, ugyanis sem az Ogg-Vorbis-t, se a korábban írt osztályaimat nem használom. A multithreading-es dolgokhoz a C++ Standard Library-t használom, a hang betöltéséhez pedig a WMF-et (így tudok .mp3-at betölteni, ami lényegesen leegyszerűsíti az életet).

A konkrét új megvalósítást most nem írom le, majd az eredeti cikket fogom megfelelően átírni (és olyan érdekességeket is megmelítek majd, mint a passkey idiom, illetve egyszerű villámlás effekt készítése). Most csak röviden megmutatom, hogy hogyan kell a WMF-et használni:

CODE
#pragma comment(lib, "mfplat.lib") #pragma comment(lib, "mfreadwrite.lib") #pragma comment(lib, "mfuuid.lib") // számít a sorrend! #include <Windows.h> #include <mfapi.h> #include <mfidl.h> #include <mfreadwrite.h> #include <mferror.h> IMFSourceReader* reader; // ez olvassa a hangfájlt IMFSample* sample; // egy beolvasott minta WAVEFORMATEX format; // ez írja le a hangformátumot (ahogy eddig is)

CODE
MFCreateSourceReaderFromURL(file.c_str(), nullptr, &reader); // egy fájlban több hang vagy videó stream is lehet DWORD stream = (DWORD)MF_SOURCE_READER_FIRST_AUDIO_STREAM; reader->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE); reader->SetStreamSelection(stream, TRUE); // ellenőrizni kell, hogy a média típusa megfelelő-e IMFMediaType* mediatype = nullptr; GUID subtype = {}; reader->GetNativeMediaType(stream, 0, &mediatype); mediatype->GetGUID(MF_MT_SUBTYPE, &subtype); if (subtype != MFAudioFormat_Float && subtype != MFAudioFormat_PCM) { // jelzem az olvasónak, hogy dekódolást kérek IMFMediaType* decodertype = nullptr; MFCreateMediaType(&decodertype); decodertype->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio); decodertype->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_PCM); reader->SetCurrentMediaType(stream, nullptr, decodertype); decodertype->Release(); } mediatype->Release();

A WMF belül tudni fogja, hogy melyik dekódert kell használnia. Az XAudio2 felé elő kell állítani egy megfelelő WAVEFORMATEX struktúrát, ez a következőképpen tehető meg:

CODE
UINT32 waveformatsize = 0; WAVEFORMATEX* waveformat = nullptr; reader->GetCurrentMediaType(stream, &mediatype); MFCreateWaveFormatExFromMFMediaType(mediatype, &waveformat, &waveformatsize); memcpy_s(&format, sizeof(WAVEFORMATEX), waveformat, waveformatsize); CoTaskMemFree(waveformat); mediatype->Release();

Ezen a ponton már létre lehet hozni a megfelelő XAudio2 objektumokat. A következő kérdés, hogy hogyan lehet megszerezni az IMFSourceReader-től az adatot (zanzásítva):

CODE
DWORD streamflags = 0; DWORD samplelength = 0; reader->ReadSample(stream, 0, nullptr, &streamflags, nullptr, &sample); if (streamflags & MF_SOURCE_READERF_ENDOFSTREAM) { endofstream = true; sample = nullptr; } IMFMediaBuffer* mediabuffer = nullptr; BYTE* audiodata = nullptr; if (sample != nullptr) { sample->ConvertToContiguousBuffer(&mediabuffer); mediabuffer->Lock(&audiodata, nullptr, &samplelength); { // hozzáadás a bufferhez, ami majd lemegy az Xaudio2-nek } mediabuffer->Unlock(); mediabuffer->Release(); }

Amennyiben betelt a buffer, mehet le az XAudio2SourceVoice::SubmitSourceBuffer() metódussal, és lehet kezdeni feltölteni a következőt.

Most jön a kérdés, hogy "jó, de mi köze ennek a gépéúhoz?". Az egyik maga a megvalósítás, az ugyanis echte megegyezik a vulkános frame queueing implementációmmal. A másik pedig höfö: ennek segítségével videót lejátszani vulkánban egy kocka valamelyik oldalán.


A refaktorálás egészen pontosan nyolc hónapig tartott, de a nehézség csak a vulkános példaprogramok átalakításakor jelentkezett (többek között a fent említett dolgok miatt). A Metal példakódok macOS-en futnak, mivel sejtésem szerint nincs mindenkinek iKütyüje (meg szerettem volna látni a különbségeket, amikből egyébként alig van). Ahogy említettem írtam egy forward+ renderer-t, szóval lehet mélyebben tanulmányozni a Metal lelki világát is.

Az új kód elérhető a szokásos helyen, a régi kód pedig a README-beli linken.


Höfö:

  • A tanultak alapján javítsd ki a saját kódjaidat!
  • Ha még nincs vulkán/metál kódod, akkor gyakorolj a fentivel!

Irodalomjegyzék

https://vulkan.lunarg.com/doc/.../16-vulkan_1_1_changes.html - Vulkan 1.1 Changes (LunarG, 2017)
https://www.khronos.org/blog/vulkan-subgroup-tutorial - Vulkan Subgroup Tutorial (Khronos, 2018)
https://www.khronos.org/assets/uploads/.../06-subgroups.pdf - Vulkan Subgroup Explained (nVidia, 2018)

back to homepage

Valid HTML 4.01 Transitional Valid CSS!