Hlavní navigace

Vulkan: představení a první jednoduchá aplikace

1. 7. 2021
Doba čtení: 8 minut

Sdílet

 Autor: Depositphotos, Vulkan
Vulkan je moderní grafické a výpočetní API. Ve svém oboru se stalo prakticky nejvýznamnější API, ke kterému se obrací jak herní, tak i profesionální počítačová grafika. Toto je první díl tutoriálu, který nás do Vulkan API uvede.

Vulkan 1.0 přišel na svět v roce 2016. Je tedy téměř o 25 let modernějším API v porovnání s OpenGL. Dvacet pět let před vydáním Vulkanu byly mainstreamové grafické karty, zjednodušeně řečeno, pouze zobrazovadlo: paměť, ke které byl připojen výstup na monitor. Co jste do paměti zapsali, to se objevilo na obrazovce. Dnes je grafická karta programovatelný vysoce paralelizovaný výpočetní stroj, který může svým hrubým výpočetním výkonem nahradit desítky i stovky moderních procesorů. Vulkan je navržen tak, aby poskytoval větší flexibilitu pro využití tohoto potenciálu, který dřímá v dnešních grafických kartách.

Další pohled: 25 let před vydáním Vulkanu měly téměř všechny počítače jen jeden procesor s jedním jádrem. Toto odpovídalo konceptu jednoho aktivního grafického kontextu v OpenGL, který ne úplně odpovídá tomu, co bychom dnes očekávali od moderního rozhraní podporujícího vícevláknové zpracování. Dnes je vícejádrový počítač standardem a není tedy divu, že Vulkanu je od svého počátku navržen tak, aby umožňoval přirozenou paralelní spolupráci mnoha jader procesoru, a to dokonce nejen s jednou, ale i s několika paralelně pracujícími grafickými kartami.

OpenGL má značnou režii pro mnohé operace a velmi složitou architekturu ovladače. Vulkan je oproti tomu navržen jako nízkoúrovňové API s minimální režií a relativně jednoduchým ovladačem, což přispívá, mimo jiné, k menšímu množství chyb, k menšímu množství problémů k tomu vztažených a celkově k lepší stabilitě ovladače i celého systému.

OpenGL je sice platformě nezávislé, ale na macOS bylo vždy několik verzí nazpět a mobilní zařízení podporovala většinou pouze OpenGL ES. Vulkan naproti tomu slibuje podporu jak na macOS, tak na většině moderních tabletů a mobilů.

Vulkan je sice úžasný a svým nízkoúrovňovým přístupem umožňuje perfektně a efektivně pracovat s výpočetními zdroji a hromadu věcí optimalizovat až do detailů. Avšak je zde i druhá stránka věci: Na co v OpenGL stačilo pár řádků kódu, na to jich ve Vulkan potřebujeme občas desítky, někdy i stovky. Ty sice přinášejí novou flexibilitu a možnosti, ale člověk by se jimi mohl cítit zaskočen. A zde přichází náš tutoriál, aby nám ulehčil práci a osvětlil základní přístupy a základní principy, na kterých je Vulkan postaven.

Ve všech našich tutoriálech budeme používat moderní C++. To se týká i Vulkan API. Nebudeme tedy používat standardní Vulkan include <vulkan/vulkan.h>, ale nadstavbu Vulkan-Hpp s include <vulkan/vulkan.hpp>, která je součástí Vulkan SDK. Vulkan-Hpp je navržena s důrazem na maximální efektivitu a pokud možno nulovou extra zátěž CPU. Vulkan.hpp se zrodil pod křídly Nvidie, ale velmi záhy v Khronosu „zdomácněl“ a už po půl roce existence Vulkan API se stal standardní součástí Vulkan SDK.

Druhá věc může být pro některé poněkud kontroverzní. Jsou jí C++ výjimky. Dle mého měření, ač nejsem expert, výjimky zvyšují výkon aplikace a zpřehledňují kód. A je to docela logické: Použití výjimek nám totiž z kódu odstraní velké množství podmínek, které vyhodnocují návratové chybové kódy a předávají je dále do nadřazených volajících funkcí, které opět vyhodnocují návratové chybové kódy, atd.

Použití výjimek odstraní tyto podmínky z našeho kódu a kód je tím pádem rychlejší. Kompilátor namísto těchto podmínek vytvoří tabulky, které řídí, co se stane, když vznikne výjimka. Ve svém důsledku je kód přehlednější. Máme důvod očekávat, že je i rychlejší, a že i vývoj je z pohledu obsluhy chyb podstatně zjednodušen. Proti případnému argumentu, že vyhození výjimky je velmi pomalé, odpovídám, že výjimka se tak jmenuje od toho, že se jedná o výjimečnou situaci, kterou typicky ošetřujeme chybovou hláškou nebo ukončením aplikace.

Jestli se chybová hláška objeví o pár mikrosekund později a následně bude několik sekund čekat, než uživatel klikne na tlačítko Ok – trápí těch pár mikrosekund někoho? Nebo k ukončení aplikace dojde o několik mikrosekund později. Koho to zajímá? Pro nás je důležitější, že aplikace běží rychleji a že máme přehlednější kód, který se lépe udržuje a ladí. Nehledě k tomu, že obsluha chyb je nyní podstatně jednodušší, což zrychluje vývoj aplikace a omezuje další potenciální zdroj chyb. Neříkám, že toto všechno platí za všech situací a za všech okolností, ale předkládám ke zvážení. Má-li někdo jiný názor, neváhejte sdílet v diskuzi pod článkem.

První aplikace: Inicializace instance a výpis fyzických zařízení

Každá vulkanní aplikace si musí nejprve vytvořit globální objekt VkInstance, než začne Vulkan používat. Jinými slovy, všechny vulkanní funkce, až na několik definovaných výjimek, bychom měli volat teprve až po vytvoření objektu VkInstance. Podobně při ukončování aplikace: objekt VkInstance bychom měli zrušit teprve, až se provedly všechny destruktory, které něco dělají s Vulkanem, a už se nebudou volat žádné další vulkanní funkce.

VkInstance je ale C-čkové rozhraní a my jsme si říkali, že budeme používat C++. Hlavička vulkan.hpp nám nabízí třídu vk::Instance, která zapouzdřuje veškerou standardizovanou funkcionalitu VkInstance. Kvůli správě paměti však samotnou třídu vk::Instance nepoužijeme. Protože používáme výjimky, měli bychom se držet programovacího idiomu RAII. Tato zkratka znamená Resource Acquisition Is Initialization, česky něco jako  „alokace musí být inicializace“. Inicializace je typicky prováděna v konstruktoru. Kde je konstruktor alokující paměť či jiné zdroje, je obyčejně také destruktor, který se stará o jejich uvolnění.

Tento programovací idiom v C++ typicky vede k objektově orientované správě paměti a ostatních zdrojů. A to je přesně to, co se nám hodí při výjimkách. Provedeme-li na začátku funkce obyčejné new a na konci delete, nebude delete zavoláno, pokud vznikne mezi new a delete výjimka. Zabalíme-li však new a delete do objektu, kde se v konstruktoru volá new a v destruktoru delete, postará se tento objekt sám o uvolnění paměti, a to jak v případě klasického opuštění funkce, tak v případě vzniku výjimky.

Kód starající se o dealokaci tedy píšeme společně s kódem alokace, a nemusíme procházet celou funkci, kdykoliv měníme strukturu funkce, přidáváme returny, a podobně, abychom se stále ujišťovali, že jsme před každým return zavolali všechna potřebná delete a free. O vše se nyní postarají destruktory objektů automaticky. A tak by to mělo být.

Nemusíme však psát vlastní objekty pro každou alokaci. Pro drtivou většinu případů nám stačí standardní std::unique_ptr a std::shared_ptr. Pro vulkanní objekty pak všechny vulkanní třídy začínající slovem Unique. Pro vk::Instance to tedy bude vk::UniqueInstance. Její deklarace by pak vypadala takto:

vk::UniqueInstance instance;

vk::UniqueInstance se chová podobně jako std::unique_ptr. Jejím vytvořením se vnitřní ukazatel nastaví na nullptr. Proměnnou instance tedy musíme naplnit. K tomu použijeme funkci vk::createInstanceUnique(). Existuje i klasická funkce vk::createInstance(). Ta ale vrací pouze vk::Instance, nikoliv vk::UniqueInstance, kterou potřebujeme my.

vk::createInstanceUnique() bere jako jediný povinný parametr ukazatel na strukturu vk::InstanceCreateInfo. A toto bude naše hlavní zkušenost s Vulkan funkcemi: místo toho, aby vulkanní funkce měly neskutečné množství parametrů, berou jako hlavní parametr ukazatel či referenci na strukturu, která obsahuje všechna potřebná data. Většina struktur pak obsahuje i pointer pNext, který může ukazovat na rozšiřující struktury s dalšími daty. Tímto způsobem Vulkan roste a místo nových API funkcí se často objevují pouze nové struktury, což je z pohledu API mnohem flexibilnější.

Konstrukce vk::UniqueInstance pak bude vypadat takto:

// Vulkan instance
vk::UniqueInstance instance(
   vk::createInstanceUnique(
      vk::InstanceCreateInfo{
         [...]
      }));

vk::InstanceCreateInfo nebudeme vytvářet jako proměnnou, ale zkonstruujeme ji při volání vk::createInstanceUnique(). Konstruktoru pouze předáme v parametrech požadovaný obsah struktury. Ne každý musí milovat tento styl. Avšak ve Vulkanu není neobvyklé volat i 50 funkcí v jedné metodě, například při statické konstrukci scény. Těchto 50 funkcí může vyžadovat deklaraci padesáti struktur na zásobníku, které na něm budou zbytečně strašit až do opuštění funkce – to teoreticky snižuje i efektivnost cache procesoru. Takto je struktura uvolněna hned po použití a nezabírá zbytečně místo na zásobníku.

Celý kód funkce main pak vypadá následovně:

// Vulkan instance
vk::UniqueInstance instance(
   vk::createInstanceUnique(
      vk::InstanceCreateInfo{
         vk::InstanceCreateFlags(),  // flags
         &(const vk::ApplicationInfo&)vk::ApplicationInfo{
            "01-deviceList",         // application name
            VK_MAKE_VERSION(0,0,0),  // application version
            nullptr,                 // engine name
            VK_MAKE_VERSION(0,0,0),  // engine version
            VK_API_VERSION_1_0,      // api version
         },
         0,        // enabled layer count
         nullptr,  // enabled layer names
         0,        // enabled extension count
         nullptr,  // enabled extension names
      }));

// print device list
vector<vk::PhysicalDevice> deviceList = instance->enumeratePhysicalDevices();
cout << "Physical devices:" << endl;
for(vk::PhysicalDevice pd : deviceList) {
   vk::PhysicalDeviceProperties p = pd.getProperties();
   cout << "   " << p.deviceName << endl;
}

Na prvních řádcích vytváříme vulkanní instanci, přičemž většina kódu připadá na obsah struktury vk::InstanceCreateInfo. Její obsah nebudu komentovat a odkážu na studium vulkanní dokumentace, což je typická denní strava programátorů Vulkanu, kdykoliv zkoušejí něco nového.

Dokumentaci najdeme na stránkách Khronosu, kde doporučuji hledat specifikaci poslední verze Vulkanu, což je v době psaní tohoto tutoriálu verze 1.2. Doporučuji verzi „Core API“ nebo s „Khronos-defined Extensions“. Verze s „all published Extensions“ člověka může zbytečně mást a odvádět od podstaty velkým množstvím různých rozšíření nejrůznějších firem.

V druhé části kódu se dotážeme na seznam fyzických zařízení (physical devices), které podporují Vulkan, a vypíšeme jejich názvy. Výpis na počítači se třemi grafickými kartami může vypadat takto:

Physical devices:
   GeForce GTX 1050
   Radeon(TM) RX 460 Graphics
   Intel(R) HD Graphics 530

Vidíme tři grafické karty, které podporují Vulkan API. Většina počítačů však má pouze jedinou grafickou kartu. Právě tu pravděpodobně uvidíte ve výpisu, pokud podporuje Vulkan API a má nainstalované grafické ovladače.

Pokud se vám podařilo dospět až sem, gratuluji. Pokud se nedaří aplikaci zkompilovat, příště si řekneme, jak nakonfigurovat vše potřebné pro vývoj vulkanních aplikací. Prozatím prozradím, že kromě zdrojáků k tomuto dílu je potřeba ještě cmake. Na Windows doporučuji Vulkan SDK. Na Linuxu totéž, či alespoň development balíčky Vulkanu. Na Windows budeme používat Visual C++ 2017/19 či novější, na Linuxu g++.

MIF temata

Odborníci mohou zkusit i cokoliv jiného, už ale bez asistence našeho tutoriálu. Nejaktuálnější zdrojáky tutoriálu jsou umístěny na GitHubu, takže pokud narazíte na nečekaný problém, je možné, že v repozitáři již bude vyřešen. Autor je velmi zaměstnaný člověk, proto nemusí být schopen řešit kompilační problémy všeho druhu na nejrůznějších platformách. Přesto pokud naleznete řešení určitého problému, snad pro mě nebude problém toto řešení přidat do zdrojáků na GitHubu k užitku nás všech ostatních.

Na závěr nám všem přeji hodně zdaru s Vulkanem do celého tutoriálu, který bude postupně vycházet snad nejen toto léto, ale i další roky s mnoha tipy, jak dělat to, či ono, a jak to nejlépe implementovat.