Vulkan: korektně vyrenderovaný obrázek

12. 8. 2021
Doba čtení: 13 minut

Sdílet

 Autor: Depositphotos, Vulkan
Minule jsme vyrenderovali první obrázek snad tím nejjednodušším způsobem. Avšak aplikace nefungovala na každém hardware. Dnes si ukážeme, jak renderovat obrázky řádným způsobem.

Minule jsme vyrenderovali obrázek do vk::Image, který byl v paměti uložen linearním způsobem (vk::ImageTiling::eLinear). Toto není zrovna nejefektivnější uložení z pohledu výkonu, a proto jej ne každý hardware podporuje. Problémy jsou tedy dva: obyčejně nižší výkon a absence podpory na některých kartách.

Lepší řešení je renderovat do vk::Image s optimálním uložením (vk::ImageTiling::eOptimal). Jak již název napovídá, optimální uložení bude z pohledu výkonu to nejlepší, co hardware podporuje. Každý hardware může používat jiné řešení pro optimální uložení. Pro lepší lokalitu přístupů do paměti může být použita například Mortonova Z-křivka, jak již bylo zmíněno v minulém díle. Dále můžou být data třeba bezztrátově komprimovány a tak dále. To vše pak může znamenat méně přístupů do paměti a také možná i vyšší efektivitu cache pamětí. Optimální uložení tedy teoreticky odstraňuje obě předchozí nevýhody: Nabízí jak optimální výkon, tak je také garantována jeho podpora na každém hardware, který podporuje renderování přes Vulkan API.

Avšak je zde něco za něco. Optimální uložení obecně znamená, že hardware uloží data do paměti ve svém formátu, o kterém my nic nevíme. Čtení těchto dat by nám tedy pravděpodobně bylo k ničemu. Pokud chceme data výsledného obrázku získat, používá se často následující řešení. Obrázky vk::Image alokujeme dva. Jeden v lokální paměti zařízení (vk::MemoryPropertyFlagBit­s::eDeviceLocal) a druhý v paměti přístupné procesoru (vk::MemoryPropertyFlagBit­s::eHostVisible). První paměť bude mít tiling vk::ImageTiling::eOptimal, tedy optimální pro renderování, a druhá vk::ImageTiling::eLinear, tedy ten správný pro zpracování procesorem. Obrázek vyrendrujeme do prvního vk::Image a jakmile renderování skončí, vezmeme jeho obsah a překopírujeme jej do druhého vk::Image. Ten následně zpřístupníme procesoru pro čtení. Pak již bezpečně přečteme výsledný obrázek a uložíme jej do souboru.

Aplikaci si můžeme stáhnout a otevřít si zdroják main.cpp. Mezi globálními proměnnými nám přibyly hostVisibleImage a hostVisibleImageMemory. Jdeme-li dále do funkce main(), kód aplikace zůstává stejný a to až do vytvoření render pasu. Render pass si však necháme na později a náš výklad začneme od vytvoření dvou vk::Image.

Tiling a usage u vk::Image

Kód pro vytvoření vk::Image jsme si už uvedli a vysvětlili v minulém díle. Ten kód zůstává v podstatě stejný pro oba vk::Image, tedy až na parametry tiling a usage. Kód pak vypadá takto (tiling a usage zvýrazněny):

// images
framebufferImage =
   device->createImageUnique(
      vk::ImageCreateInfo(
         vk::ImageCreateFlags(),       // flags
         vk::ImageType::e2D,           // imageType
         vk::Format::eR8G8B8A8Unorm,   // format
         vk::Extent3D(imageExtent.width, imageExtent.height, 1),  // extent
         1,                            // mipLevels
         1,                            // arrayLayers
         vk::SampleCountFlagBits::e1,  // samples
         vk::ImageTiling::eOptimal,    // tiling
         vk::ImageUsageFlagBits::eColorAttachment | vk::ImageUsageFlagBits::eTransferSrc,  // usage
         vk::SharingMode::eExclusive,  // sharingMode
         0,                            // queueFamilyIndexCount
         nullptr,                      // pQueueFamilyIndices
         vk::ImageLayout::eUndefined   // initialLayout
      )
   );
hostVisibleImage =
   device->createImageUnique(
      vk::ImageCreateInfo(
         vk::ImageCreateFlags(),       // flags
         vk::ImageType::e2D,           // imageType
         vk::Format::eR8G8B8A8Unorm,   // format
         vk::Extent3D(imageExtent.width, imageExtent.height, 1),  // extent
         1,                            // mipLevels
         1,                            // arrayLayers
         vk::SampleCountFlagBits::e1,  // samples
         vk::ImageTiling::eLinear,     // tiling
         vk::ImageUsageFlagBits::eTransferDst,  // usage
         vk::SharingMode::eExclusive,  // sharingMode
         0,                            // queueFamilyIndexCount
         nullptr,                      // pQueueFamilyIndices
         vk::ImageLayout::eUndefined   // initialLayout
      )
   );

Nyní tedy máme dva vk::Image. Do jednoho rendrujeme a nazvali jsme jej framebufferImage, ze druhého budeme číst procesorem a ten je nazván hostVisibleImage. Prvnímu nastavíme optimal tiling, protože ten je pro renderování podporován vždy a navíc je dobrý pro výkon. Druhému nastavíme linearní tiling, abychom jej mohli přečíst z procesoru.

Zajímavou položkou je pak parametr usage. V prvním případě jej nastavíme na vk::ImageUsageFlagBits::e­ColorAttachment a eTransferSrc. To znamená, že framebufferImage může být použit jako color attachment, jinými slovy, může se do něj renderovat. Druhým podporovaným použitím je eTransferSrc, tedy z framebufferImage může být použit jako zdrojový obrázek při kopírování dat. Naopak náš druhý vk::Image, tedy hostVisibleImage, má usage nastaven na eTransferDst a data do něj mohou být kopírována.

Alokace paměti pro vk::Image

Kód pro alokaci paměti a její nabindování zůstal velmi podobný. Řekli bychom, že se pouze zdvojil: jednou pro framebufferImageMemory a po druhé pro hostVisibleImageMemory. Jediný podstatný rozdíl je ve druhém parametru funkce allocateMemory, který je v kódu níže zdůrazněn tučně, a kam předáváme požadované vlastnosti alokované paměti.

framebufferImageMemory = allocateMemory(framebufferImage.get(), vk::MemoryPropertyFlagBits::eDeviceLocal);
hostVisibleImageMemory = allocateMemory(hostVisibleImage.get(), vk::MemoryPropertyFlagBits::eHostVisible |
                                                                vk::MemoryPropertyFlagBits::eHostCached);
device->bindImageMemory(
   framebufferImage.get(),        // image
   framebufferImageMemory.get(),  // memory
   0                              // memoryOffset
);
device->bindImageMemory(
   hostVisibleImage.get(),        // image
   hostVisibleImageMemory.get(),  // memory
   0                              // memoryOffset
);

A o jaké vlastnosti alokované paměti žádáme při volání funkce allocateMemory? V případě framebufferImageMemory žádáme o eDeviceLocal paměť, tedy lokální paměť vulkanního zařízení, která by měla být nejrychlejší. Pro hostVisibleImageMemory naopak žádáme o eHostVisible, která je přístupná z procesoru. Další vlastností, o kterou žádáme, je také eHostCached. To znamená, že procesor může využívat cache při práci s touto pamětí. To může přinést podstatné zvýšení výkonu při práci s touto pamětí, nicméně paměť pak vyžaduje dodatečnou synchronizaci. Detaily nechme v tomto díle tutoriálu stranou, neboť se vztahují ke kódu uložení obrázku do souboru, který jsme nestudovali. Pro zájemce jen poznamenejme, že se jedná o synchronizaci funkcemi vkFlushMappedMemoryRanges() a vkInvalidateMappedMemoryRanges() a více o nich je možné najít v dokumentaci.

Dále následuje kód shodný s minulým dílem tutoriálu, kde se tvoří další objekty a kde je nakonec nahrán seznam příkazů do command bufferu. Toto vše zůstává stejné až do okamžiku, kdy do command bufferu uložíme příkaz ke kopírování z framebufferImage do hostVisibleImage.

Kopírování dat vk::Image

V minulém díle jsme uložili výsledný obrázek do framebufferImage a odtud si jej také procesor přečetl. Bylo to jednoduché řešení, avšak ne ideální. V tomto díle na závěr renderování vložíme do command bufferu nejprve barieru, a pak příkaz ke kopírování z framebufferImage do hostVisibleImage. K bariéře se vrátíme vzápětí. Nejprve projdeme kód kopírovacího příkazu:

// copy framebufferImage to hostVisibleImage
commandBuffer->copyImage(
   framebufferImage.get(), vk::ImageLayout::eTransferSrcOptimal,  // srcImage,srcImageLayout
   hostVisibleImage.get(), vk::ImageLayout::eGeneral,  // dstImage,dstImageLayout
   vk::ImageCopy(  // regions
      vk::ImageSubresourceLayers(  // srcSubresource
         vk::ImageAspectFlagBits::eColor,  // aspectMask
         0,  // mipLevel
         0,  // baseArrayLayer
         1   // layerCount
      ),
      vk::Offset3D(0,0,0),         // srcOffset
      vk::ImageSubresourceLayers(  // dstSubresource
         vk::ImageAspectFlagBits::eColor,  // aspectMask
         0,  // mipLevel
         0,  // baseArrayLayer
         1   // layerCount
      ),
      vk::Offset3D(0,0,0),         // dstOffset
      vk::Extent3D(imageExtent.width, imageExtent.height, 1)  // extent
   )
);

Jak vidíme, do command bufferu zaznamenáme příkaz copyImage, který bere pět parametrů: zdrojový vk::Image a jeho layout, cílový vk::Image a jeho layout a pole vk::ImageCopy popisující detaily kopírovaných oblastí. Zdrojový a cílový image budou framebufferImage a hostVisibleImage. Layouty nyní necháme bokem a vrátíme se k nim později v průběhu tohoto dílu. Co se týká kopírovaných oblastí, my kopírujeme pouze jednu oblast, která zahrnuje celý obrázek. Proto místo pole dáváme jedinou strukturu vk::ImageCopy. Tuto strukturu pak vyplníme tak, aby se zkopíroval celý obsah obrázku. Zájemci o detaily najdou více opět v dokumentaci.

Synchronizace s použitím SubpassDependency

Vulkan je API navržené pro masivní paralelní zpracování. Pošleme-li tedy dva příkazy na zpracování, mohou být oba provedeny paralelně, nebo mohou být provedeny jeden po druhém v libovolném pořadí. To se nám nemusí hodit, pokud prvním příkazem rendrujeme scénu a druhým kopírujeme výsledek. Pokud by Vulkan provedl nejprve kopírování, a pak až renderoval, nedostali bychom správný výsledný obrázek. Proto máme ve Vulkan bariéry, SubpassDependencies a další synchronizační prvky.

Z možných řešení se v našem případě zdá nejefektivnější použít SubpassDependency. Proto zmodifikujeme kód vytváření render pasu tak, že v něm vytvoříme SubpassDependency s externí závislostí. Kód pro SubpassDependency je zvýrazněn tučně:

// render pass
renderPass =
   device->createRenderPassUnique(
      vk::RenderPassCreateInfo(
         vk::RenderPassCreateFlags(),  // flags
         1,                            // attachmentCount
         array{  // pAttachments
            vk::AttachmentDescription(
               vk::AttachmentDescriptionFlags(),  // flags
               vk::Format::eR8G8B8A8Unorm,        // format
               vk::SampleCountFlagBits::e1,       // samples
               vk::AttachmentLoadOp::eClear,      // loadOp
               vk::AttachmentStoreOp::eStore,     // storeOp
               vk::AttachmentLoadOp::eDontCare,   // stencilLoadOp
               vk::AttachmentStoreOp::eDontCare,  // stencilStoreOp
               vk::ImageLayout::eUndefined,       // initialLayout
               vk::ImageLayout::eTransferSrcOptimal  // finalLayout
            ),
         }.data(),
         1,  // subpassCount
         array{  // pSubpasses
            vk::SubpassDescription(
               vk::SubpassDescriptionFlags(),     // flags
               vk::PipelineBindPoint::eGraphics,  // pipelineBindPoint
               0,        // inputAttachmentCount
               nullptr,  // pInputAttachments
               1,        // colorAttachmentCount
               array{    // pColorAttachments
                  vk::AttachmentReference(
                     0,  // attachment
                     vk::ImageLayout::eColorAttachmentOptimal  // layout
                  ),
               }.data(),
               nullptr,  // pResolveAttachments
               nullptr,  // pDepthStencilAttachment
               0,        // preserveAttachmentCount
               nullptr   // pPreserveAttachments
            ),
         }.data(),
         1,  // dependencyCount
         array{  // pDependencies
            vk::SubpassDependency(
               0,  // srcSubpass
               VK_SUBPASS_EXTERNAL,  // dstSubpass
               vk::PipelineStageFlagBits::eColorAttachmentOutput,  // srcStageMask
               vk::PipelineStageFlagBits::eTransfer,  // dstStageMask
               vk::AccessFlagBits::eColorAttachmentWrite,  // srcAccessMask
               vk::AccessFlagBits::eTransferRead,  // dstAccessMask
               vk::DependencyFlags()  // dependencyFlags
            ),
         }.data()
      )
   );

Jak vidíme, je zde jedna struktura SubpassDependency. Uvnitř struktury vidíme tři dvojice parametrů: zdrojový a cílový subpass, zdrojovou a cílovou stage masku, a zdrojovou a cílovou access masku. Na závěr ještě flagy, které necháme na defaultní hodnotě.

Nyní k jednotlivým dvojicím: Jako zdrojový subpass uvádíme nulu, tedy index na jediný subpass, který máme a který je definován o pár řádků výše v parametru pSubpasses. Jako cílový subpass pak dáváme VK_SUBPASS_EXTERNAL, což znamená, že vytváříme externí závislost ven z render pasu. Jinými slovy vzniká závislost mezi naším renderováním a něčím, co následuje po render pasu. V našem případě to něco bude transfér výsledného obrázku.

Ve druhé a třetí dvojici parametrů nám jde o toto: Nechceme začít transfér vyrenderovaného obrázku dříve než je vyrenderován a bezpečně uložen v paměti. Slova transfér a uložení do paměti nám naznačují, že výpočty ve Vulkan probíhají v určitých fázích, anglicky stages. Jako příklady stages si uveďme například vertex shader, fragment shader, compute shader a transfér. Celkově je jich momentálně kolem patnácti a najdeme je v dokumentaci VkPipelineStageFlagBits. Druhá dvojice parametrů nám udává právě tyto stages. Zdrojová stage bude eColorAttachmentOutput, což je stage za fragment shaderem, kdy se zapisuje výsledná barva do paměti. Jako cílovou masku pak uvedeme eTransfer, tedy přenos dat. Tato kombinace zajišťuje, že nezačne transfér, dokud se neskončí zápis do color attachmentu. Tuto specifikaci ještě upřesňuje třetí dvojice parametrů, která říká, že do color attachmentu budeme pouze zapisovat a že náš transfér bude pouze číst. Můžeme tedy upřesnit naši synchronizační podmínku, že transfér nezačne číst z paměti, dokud neskončí zápis do color attachmentu.

Image layouts

Image layouty se proplétají přes několik míst v našem kódu, proto jsme si je nechali až skoro na konec, kdy už rozumíme většině kódu. Image layout bychom přeložili jako způsob či formát uložení dat vk::Image v paměti. Příklady layoutů jsou třeba vk::ImageLayout::eColorAt­tachmentOptimal, eDepthStencilAttachmentOptimal a eTransferSrcOptimal. Jak už názvy napovídají, různý layout je pro různé použití. Proto je potřeba občas obrázky mezi různými layouty konvertovat. Podporované layouty a další detaily najdeme ve Vulkan dokumentaci k VkImageLayout.

Layout u framebufferImage a hostVisibleImage v naší aplikaci není něco statického, ale mění se za běhu jak je potřeba. Oba obrázky vytváříme s ImageLayout::eUndefined, tedy nedefinovaným layoutem, neboť Vulkan povoluje jen dvě hodnoty při vytváření obrázku, a to eUndefined a ePreinitialized. A protože žádná předinicializovaná data nemáme, volíme eUndefined.

Prvně se podívejme na framebufferImage. Objekt framebufferImage je připojen k framebufferu, a framebuffer je aktivován při volání metody beginRenderPass(). Render pass pak používá nultý attachment objektu framebuffer, tedy právě náš framebufferImage, o jehož layout nám jde. A co se děje s layoutem framebufferImage v render pasu? Odpověď najdeme v kódu konstrukce render pasu.

Náš render pass v kódu jeho konstrukce specifikuje layout třikrát. Prvně v AttachmentDescription::initialLayout. Tam jej definujeme jako eUndefined, tedy ve shodě s tím, jak jsme framebufferImage vytvořili, tedy také eUndefined. Undefined je ideální layout pro vk::Image s neplatným obsahem, neboť neprovádí žádné konverze dat a je tedy velmi levný. My sami z framebufferImage vůbec nečteme, protože AttachmentDescription::loadOp je eClear. Jinými slovy, místo čtení se použije barva pozadí. Undefined layout tedy přesně sedí s tím, že data obrázku jsou neplatná a že z obrázku vůbec nečteme.

Nicméně při renderování v našem jediném subpasu generujeme výsledný obrázek a ten je potřeba uložit. Proto o pár řádků níže v SubpassDescription::pColorAttachments vidíme, že náš jediný subpass používá obrázek v layoutu eColorAttachmentOptimal. V tomto layoutu jsou tedy výsledky renderování subpasu ukládány. Nicméně jedna věc je layout, který používá jeden, druhý či třetí subpass, a něco jiného je finální layout render pasu. Finální layout render pasu je definován v AttachmentDescription::finalLayout jako eTransferSrcOptimal. Toto je tedy finální layout, který dostaneme na konci render pasu a ve kterém jej najdou operace, které následují po skončení render pasu. A proč jsme zvolili eTransferSrcOptimal? Protože budeme obrázek kopírovat příkazem copyImage() a – jak zjistíme v dokumentaci – tento příkaz vyžaduje layout obrázku buď eGeneral nebo eTransferSrcOptimal. Abychom eliminovali příkaz na změnu layoutu, necháme si obrázek render pasem uložit přímo v layoutu, ve kterém jej budeme potřebovat. Tím končí příběh framebufferImage v layoutu optimálním pro kopírování.

Druhý vk::Image s názvem hostVisibleImage vytváříme také s layoutem eUndefined. Na konci aplikace pak do něj chceme za pomoci příkazu copyImage() překopírovat obsah framebufferImage. Dokumentace k copyImage nám říká, že hostVisibleImage musí být v layoutu buď eTransferDstOptimal nebo eGeneral. My si vybereme eGeneral, protože obrázek následně budeme číst z procesoru, který vyžaduje layout eGeneral. Jak ale formát eUndefined zkonvertovat na eGeneral? Ke konverzi layoutů vk::Image slouží mimo render pasy také pipeline bariéry.

Bariéry

Pipeline bariéry slouží k synchronizaci. Zjednodušeně řečeno, bariéra odděluje něco, co platilo před ní od toho, co platí po ní. Bariéra se může týkat různých paměťových operací, ať již na globální úrovni, či na úrovni bufferů a vk::Images. My ji použijeme ke změně layoutu vk::Image z eUndefined na eGeneral. Bariéru vložíme do command bufferu po veškerém renderování těsně před příkaz copyImage:

// hostVisibleImage layout to eGeneral
commandBuffer->pipelineBarrier(
   vk::PipelineStageFlagBits::eTopOfPipe,  // srcStageMask
   vk::PipelineStageFlagBits::eTransfer,   // dstStageMask
   vk::DependencyFlags(),  // dependencyFlags
   nullptr,  // memoryBarriers
   nullptr,  // bufferMemoryBarriers
   vk::ImageMemoryBarrier{  // imageMemoryBarriers
      vk::AccessFlags(),                   // srcAccessMask
      vk::AccessFlagBits::eTransferWrite,  // dstAccessMask
      vk::ImageLayout::eUndefined,         // oldLayout
      vk::ImageLayout::eGeneral,           // newLayout
      0,                          // srcQueueFamilyIndex
      0,                          // dstQueueFamilyIndex
      hostVisibleImage.get(),     // image
      vk::ImageSubresourceRange{  // subresourceRange
         vk::ImageAspectFlagBits::eColor,  // aspectMask
         0,  // baseMipLevel
         1,  // levelCount
         0,  // baseArrayLayer
         1   // layerCount
      }
   }
);

Jak vídíme v kódu, bariéra je specifikována šesti parametry: zdrojová a cílová stage maska, dependency flags a třemi poli. Pole memoryBarriers je prázdné, protože globální paměťové bariery v tuto chvíli nepotřebujeme. Pole bufferMemoryBarriers je také prázdné, neboť nepotřebujeme žádnou barieru řešící paměťové přístupy k bufferům. Poslední pole imageMemoryBarriers obsahuje jednu položku typu vk::ImageMemoryBarriers, jejíž parametr image je nastaven na hostVisibleImage. Tato struktura tedy synchronizuje přístup k hostVisibleImage.

Mezi parametry se soustředíme jen na ty nejdůležitější, které jsou zvýrazněny tučně. Význam ostatních parametrů je možné najít v dokumentaci. Nás tedy bude zajímat především zdrojová a cílová stage maska, zdrojová a cílová access maska a starý a nový layout. Začněme od layoutů, které říkají, že starý layout je eUndefined a nový bude eGeneral. Přístupová maska srcAccessMask je prázdná, neboť k hostVisibleImage před bariérou nepřistupujeme. Naopak dstAccessMask obsahuje eTransferWrite, který indikuje, že za bariérou do obrázku zapisuje transférová jednotka. Zdrojová a cílová stage maska pak udává, co vše musí být hotovo a dokončeno před bariérou, abychom mohli překročit bariéru, a co se nesmí začít zpracovávat, dokud bariéru nepřekročíme. V předbariérové práci neděláme s hostVisibleImage nic, takže hodnota eTopOfPipe signalizuje, že nemusíme na nic čekat. Opačnou hodnotou by bylo eBotomOfPipe, což by znamenalo čekat, až specifikovaná práce před bariérou bude kompletně dokončena. Cílová stage maska je nastavena na eTransfer, tedy dokud není bariéra překonána a není převeden layout na eGeneral, nesmí začít transférová jednotka zapisovat do tohoto obrázku.

ict ve školství 24

A jsme hotovi. Příkaz copyImage() nyní může bezpečně provést kopii obrázku z framebufferImage do hostVisibleImage. Ten následně snadno namapujeme do paměťového prostoru procesoru. Procesor jej přečte a uloží do souboru. Po spuštění vidíme v image.bmp obrázek vyplněný zeleným pozadím tentokrát s jistotou, že aplikace bude fungovat na každém funkčním Vulkan zařízení, které podporuje rendering:


Shrnutí

V tomto díle jsme si ukázali, jak korektně renderovat obrázek a jak jej následně přečíst procesorem. K tomu jsme potřebovali dva objekty vk::Image. Jeden byl naalokován jako eDeviceLocal a s optimálním tilingem a druhý jako eHostVisible s linearním tilingem. Dále jsme si ukázali, jak překopírovat obsah obrázku, jak provádět synchronizaci za pomoci SubpassDependency a popsali jsme si tématiku layouty obrázků. Příště si již vyrendrujeme náš první trojúhelník. Tím uzavřeme naši první sérii našeho tutoriálu a někdy v budoucnu se můžeme těšit na další velké téma: otevření okna a renderování do něj.

Autor článku