© DrHitch/Shutterstock.com
Das Vulkan-API Teil 3

2 Handhabung von Buffer- und Texturobjekten


Nachdem wir uns im letzten Kapitel damit auseinandergesetzt haben, wie sich die Geometrie- und Texturdaten einer Spielewelt von der Festplatte in den CPU-Speicher laden lassen, werden wir uns heute damit beschäftigen, wie sich die besagten Daten im Rahmen einer Vulkan-Anwendung handhaben lassen.

Heute wird es ernst, wir werden uns endlich die Hände schmutzig machen und mit der Implementierung unseres Vulkan-basierten Frameworks beginnen. Wie heißt es doch so schön: Grau ist alle Theorie. In der Vergangenheit sind wir zwar bereits auf die wichtigsten Vulkan-Objekte samt den zugehörigen Funktionen, Strukturen, Variablen und Flags zu sprechen gekommen, allerdings war ein Großteil unserer Betrachtungen bislang vorwiegend theoretischer Natur. Zweifelsohne verfügen wir nach nunmehr fünf Kapiteln in zwei shortcuts bereits über fundierte Kenntnisse über die grundlegenden Funktionsabläufe einer Vulkan-basierten Anwendung. Aber machen wir uns nichts vor: Auf die Schwierigkeiten und Fallstricke, die es bei der tagtäglichen Arbeit mit diesem neuen API zu umschiffen gilt, sind wir deshalb noch lange nicht vorbereitet.

Die Listings, mit denen wir uns im Verlauf dieses Kapitels auseinandersetzen werden, demonstrieren mehr als eindrucksvoll, dass es unter Vulkan den schnellen Weg nicht gibt. Unter Zuhilfenahme des OpenGL-API lassen sich viele alltägliche Aufgaben, wie die Erstellung und Aktualisierung von Buffer- und Texturobjekten, mit einigen wenigen Funktionsaufrufen bewerkstelligen. Im Rahmen einer Vulkan-Anwendung sind zur Bewältigung der gleichen Aufgaben hingegen viele Seiten Programmcode erforderlich.

Der eine oder andere wird sich in diesem Zusammenhang sicherlich die berechtigte Frage stellen, ob sich dieser ganze Mehraufwand überhaupt einmal an irgendeiner Stelle bezahlbar machen wird. Zweifelsohne gestattet uns ein Low-Level-API wie Vulkan eine sehr viel bessere Kontrolle des Ressourcen- und Speichermanagements. Auch die Möglichkeit, die anstehenden (Rendering-spezifischen) Aufgaben auf eine optimale Anzahl von Threads aufzuteilen, ist nicht zu unterschätzen. Und dass man aufgrund der hardwarenahen Programmierung im Laufe der Zeit ein sehr viel besseres Verständnis von der Funktionsweise eines Grafik-API samt den zugehörigen Programmabläufen entwickelt, steht ebenfalls außer Frage. Letzten Endes kommt es aber einzig und allein darauf an, welche Vorteile sich hierbei für den Endbenutzer ergeben.

An dieser Stelle kann nicht oft genug darauf hingewiesen werden, dass sich die Vorzüge des neuen Vulkan-API mitnichten nur beim Konsum von High-End-Computerspielen bemerkbar machen. Dank Multi-Thread-basiertem Rendering und einem im Vergleich zu OpenGL deutlich reduzierten Treiberoverhead lassen sich heutzutage endlich auch anspruchsvolle Grafikanwendungen für leistungsschwächere Endgeräte entwickeln. Die deutlich einfacher strukturierten Grafiktreiber eines Low-Level-API dürften zukünftig auch die ständigen Treiberupdates überflüssig machen, die heutzutage praktisch nach jedem Release eines neuen AAA-Spieletitels erfolgen. Allgemeine Treiberoptimierungen sind die eine Sache, aber die Notwendigkeit, einen Treiber im Nachhinein an einzelne Spieleprogramme anpassen zu müssen, deutet darauf hin, dass das Zusammenspiel zwischen der aktuellen Hardwaregeneration auf der einen und den älteren High(er)-Level-APIs (OpenGL, frühere DirectX-Versionen) auf der anderen Seite alles andere als perfekt ist.

Maximale Performance in allen Lebenslagen

Vereinfacht ausgedrückt, befassen wir uns in diesem Kapitel lediglich mit der Frage, wie sich die Daten, die für die Animation und Darstellung einer virtuellen (Spiele-)Welt erforderlich sind, vom Haupt- in den Grafikspeicher und vom Grafik- zurück in den Hauptspeicher transferieren lassen. Zugegeben, auf den ersten Blick scheint dieses Thema ein wenig „unsexy“ zu sein, zumal uns die Implementierung der hierfür verantwortlichen Frameworkklassen eine Menge Geduld und Arbeit abverlangt. Verschaffen wir uns zunächst mal einen Überblick über die einzelnen Buffer- und Texturklassen, auf die wir im Rahmen unserer (zukünftigen) Vulkan-Demoprogramme immer wieder zurückgreifen werden:

  • Für die Handhabung der für die Darstellung der 3-D-Modelle erforderlichen Vertex- und Indexbufferobjekte ist die CVulkanGeometryDataPool-Klasse verantwortlich.
  • Bei der Handhabung von (dynamischen) Uniform-Buffer- bzw. Uniform-Buffer-Array-Objekten greifen wir auf die nachfolgend genannten Frameworkklassen zurück:
    • CVulkanUniformBufferObject
    • CVulkanUniformBufferObjectArray
    • CVulkanDynamicUniformBufferObject (als Alternative zur Verwendung eines Uniform-Buffer-Array-Objekts)

Hinweis: Innerhalb eines Shader-Programms sind nur lesende Speicherzugriffe möglich.

  • Bei der Handhabung von (dynamischen) Shader-Storage-Buffer- bzw. Shader-Storage-Buffer-Array-Objekten kommen die nachfolgenden Frameworkklassen zum Einsatz:
    • CVulkanStorageBufferObject
    • CVulkanStorageBufferObjectArray
    • CVulkanDynamicStorageBufferObject (als Alternative zur Verwendung eines Storage-Buffer-Array-Objekts)

Hinweis: Innerhalb eines Shader-Programms sind sowohl lesende als auch schreibende Speicherzugriffe möglich.

  • Bei der Handhabung von Texel-Buffer-Objekten greifen wir zum einen auf die CVulkanUniformTexelBufferObject-Klasse (innerhalb eines Shader-Programms sind nur lesende Speicherzugriffe möglich!) und zum anderen auf die CVulkanStorageTexelBufferObject-Klasse (innerhalb eines Shader-Programms sind sowohl lesende als auch schreibende Speicherzugriffe möglich) zurück.
  • Für die Handhabung von Texturen und Texturarrayobjekten sind die beiden Frameworkklassen CVulkanTextureObject sowie CVulkanTextureArrayObject verantwortlich.

Was nun den Umgang mit Texturen und 3-D-Modellen betrifft, kann man eigentlich nicht sonderlich viel verkehrt machen. Um die Anzahl rechenintensiver Texturwechsel so gering wie möglich zu halten, sollte man aus Performancegründen möglichst viele gleichartige Texturen zu einer überschaubaren Anzahl von Texturarrayobjekten zusammenfassen und auf die Verwendung von einzelnen Texturobjekten weitestgehend verzichten. Darüber hinaus sollten die Geometriedaten von sämtlichen 3-D-Modellen, bei denen das gleiche Vertexformat zum Einsatz kommt, wenn möglich in einem einzigen großen Vertexbuffer- sowie dem dazugehörigen Indexbufferobjekt gespeichert werden. Mit Blick auf die Performance wäre es alles andere als eine gute Idee, bei jedem Modell auf eine separate CVulkanGeometryDataPool-Instanz zurückzugreifen.

Bei der Auswahl der darüber hinaus zu verwendenden Bufferobjekte (VkBuffer) gilt hingegen die Maxime: Trial and Error. Wenn es mehrere scheinbar gleichwertige Alternativen gibt, dann sollte man, wenn möglich, auch sämtliche Varianten implementieren, da sich die jeweilige Performance durchaus von Rechner zu Rechner unterscheiden kann. Selbstverständlich gilt zunächst einmal auch an dieser Stelle, dass auf den Gebrauch einer großen Anzahl von kleinen Bufferobjekten aus Performancegründen weitestgehend verzichtet werden sollte. Die Verwendung von einigen wenigen, entsprechend großzügig dimensionierten Bufferobjekten, auf deren Teilbereiche man unabhängig voneinander zugreifen kann, stellt in allen Fällen die deutlich bessere Alternative dar. Die Entscheidung, ob man hierbei nun aber beispielsweise auf die CVulkanDynamicUniformBufferObject- oder die CVulkanUniformBufferObjectArray-Klasse zurückgreifen sollte, lässt sich letzten Endes nur durch Ausprobieren treffen. Ein ähnlich gelagertes Problem resultiert aus der Gegebenheit, dass man anstelle eines Uniform-Buffer-Objekts immer auch ein Shader-Storage-Buffer-Objekt verwenden könnte. Die umgekehrte Vorgehensweise ist hingegen nicht möglich, da bei einem Uniform-Buffer nur lesende Speicherzugriffe gestattet sind. Bis zur Einführung der Shader-Storage-Buffer stellte der Gebrauch eines Texel-Buffer-Objekts (hierbei handelt es sich um eine eindimensionale Textur, über die der Zugriff auf die in einem Bufferobjekt gespeicherten Daten erfolgt) unter OpenGL die einzige Möglichkeit dar, ein Shader-Programm aus dem Hauptprogramm heraus mit großen Datenmengen zu versorgen. Immer dann, wenn sich die Bufferdaten ähnlich wie die Farben einer Textur in einer Abfolge von jeweils vier Werten (Vierervektoren bzw. 4x4-Matrizen) speichern lassen, bietet sich die Verwendung eines Texel-Buffer-Objekts an.

Natürlich haben wir auch an dieser Stelle wiederum die Qual der Wahl, da lesende Speicherzugriffe sowohl bei Uniform- als auch bei Storage-Texel-Buffern möglich sind. Für den Fall, dass innerhalb eines Shader-Programms sowohl lesende als auch schreibende Speicherzugriffe auf ein Bufferobjekt notwendig sind, und das Format, in dem die gespeicherten Bufferdaten vorliegen müssen, zweitrangig ist, kann man sowohl auf ein Shader-Storage- als auch auf ein Storage-Texel-Buffer-Objekt zurückgreifen. Sind hingegen nur lesende Speicherzugriffe erforderlich, lassen sich im Prinzip sämtliche Buffertypen verwenden. Die Deklaration von auf den jeweiligen Verwendungszweck abgestimmten Datenstrukturen ist innerhalb eines Shader-Programms jedoch nur bei Gebrauch eines Uniform- bzw. Shader-Storage-Buffer-Objekts möglich.

Falls Sie nun der Meinung sind, dass ich bei der Auswahl der zu benutzenden Bufferobjekte ein klein wenig übertrieben habe, dann warten Sie mal die Zugabe ab. Nirgendwo steht geschrieben, dass sich Image-Objekte (VkImage) nur als Texturen oder Render Targets verwenden lassen. Nichts spricht dagegen, ein solches Objekt auch zum Speichern von beliebigen Datensätzen zu gebrauchen; es ist alles eine Frage des jeweils verwendeten Image-Layouts (VkImageLayout):

  • VK_IMAGE_LAYOUT_PREINITIALIZED: Image-Layout unmittelbar bei der Initialisierung.
  • VK_IMAGE_LAYOUT_GENERAL: Innerhalb eines Shader-Programms sind sowohl schreibende als auch lesende Speicherzugriffe möglich.
  • VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL: Das Image-Objekt ist für einen Lesezugriff optimiert (Verwendungszweck: klassische Textur).
  • VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL: Das Image-Objekt ist für den Schreibzugriff innerhalb eines Fragment-Shader-Programms optimiert (Verwendungszweck: Render Target).
  • VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL: Erforderliches Layout, damit sich die (Bild-)Daten vor ihrer Verwendung in einem Shader-Programm in den zum Image-Objekt zugehörigen GPU-Speicher transferieren lassen.

Command-Buffer-Objekte

Bevor wir uns gleich in aller Ausführlichkeit damit auseinandersetzen, wie sich Buffer- und Textur(array)objekte initialisieren und sich die in ihnen gespeicherten Daten aktualisieren lassen, müssen wir uns zuvor noch einmal kurz mit dem im Rahmen einer Vulkan-Anwendung zum Einsatz kommenden Command-Buffer-Modell befassen. Wie bereits im ersten Kapitel dieser Serie zur Sprache gekommen ist, besteht eine der wesentlichsten Verbesserungen des neuen Vulkan-API darin, dass man im Unterschied zu OpenGL auf eine beliebige Anzahl von Rendering-Threads zurückgreifen kann. Genau an dieser Stelle haben nun die so genannten Command-Buffer (VkCommandBuffer) ihren großen Auftritt. Zur Erinnerung: Bevor sich die von der GPU auszuführenden Arbeitsanweisungen an die Grafikkarte übermitteln lassen, müssen wir sie zunächst einmal in einem zuvor angeforderten VkCommandBuffer-Objekt (zwischen-)speichern.

Sofern man einen Command-Buffer für die Durchführung einer Datentransferoperation benötigt, bietet sich beispielsweise ein Aufruf der in Listing 2.1 gezeigten Create_DataTransferCommandBuffer()-Methode an. Damit sich ein VkCommandBuffer-Objekt mittels der hierfür verantwortlichen vkAllocateCommandBuffers()-Funktion threadsicher aus einem so genannten Command-Pool anfordern lässt, müssen wir uns allerdings zuvor im Verlauf der Initialisierung unserer Vulkan-Anwendung mithilfe der vkCreateCommandPool()-Funktion für jeden Rendering-Thread ein separates VkCommandPool-Objekt anlegen. Sobald die Erstellung eines Command-Buffers erst einmal abgeschlossen ist, lassen sich die in ihm gespeicherten Arbeitsanweisungen beispielsweise mithilfe der in Listing 2.2 skizzierten EndAndSubmit_CommandBufferToDataTransferQueue()-Methode zu jeder Zeit und so oft, wie es erforderlich ist, an die GPU weiterleiten.

VkCommandBuffer CBaseVulkanApp::Create_DataTransferCommandBuffer(
VkCommandBufferLevel level, bool beginRecording, uint32_t threadID)
{
VkCommandBuffer newCommandBuffer;
VkCommandBufferAllocateInfo cmdBufAllocateInfo = {};
cmdBufAllocateInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
cmdBufAllocateInfo.pNext = NULL;
cmdBufAllocateInfo.commandPool = DataTransferCommandPool_Thread[threadID];
cmdBufAllocateInfo.level = level;
cmdBufAllocateInfo.commandBufferCount = 1;

VkResult U_ASSERT_ONLY err;
err = vkAllocateCommandBuffers(LogicalDevice, &cmdBufAllocateInfo, &newCommandBuffer);
assert(!err);

if (beginRecording)
{
VkCommandBufferBeginInfo cmdBufBeginInfo = {};
cmdBufBeginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
cmdBufBeginInfo.pNext = NULL;

err = vkBeginCommandBuffer(newCommandBuffer, &cmdBufBeginInfo);
assert(!err);
}
return newCommandBuffer;
}

Listing 2.1: Command-Buffer für den CPU-GPU-Datentransfer bzw. für eine Image-Layoutänderung anfordern

Wie Sie anhand von Listing 2.2 erkennen können, erfolgt die Kommunikation zwischen dem Hauptprogramm und der Grafikkarte über ein bzw. über mehrere VkQueue-Objekte, für deren Initialisierung die vkGetDeviceQueue()-Funktion verantwortlich ist. Jedes von uns verwendete Queue-Objekt repräsentiert eine separate Warteschlange, in der eine mehr oder weniger große Anzahl von VkCommandBuffer-Objekten auf die Verarbeitung der in ihnen gespeicherten Arbeitsanweisungen wartet. Da sowohl die Anzahl als auch die Einsatzmöglichkeiten der uns zur Verfügung stehenden Queue-Objekte von der jeweils verbauten Grafikkarte abhängig sind, sollte die Verwendung von mehr als einem Queue-Objekt aus Kompatibilitätsgründen lediglich optional erfolgen. Unsere Demoprogramme unterstützen beispielsweise die separate Verwendung von bis zu drei VkQueue-Objekten (Graphics, Compute, Transfer), dank derer sich die Rendering-Operationen, die GPU-basierten Bewegungssimulationen sowie die erforderlichen Ressourcenupdates parallel zueinander durchführen lassen.

void CBaseVulkanApp::EndAndSubmit_CommandBufferToDataTransferQueue(
VkCommandBuffer commandBuffer, bool freeBuffer, uint32_t threadID)
{
if (commandBuffer == VK_NULL_HANDLE)
return;

VkResult U_ASSERT_ONLY err;
err = vkEndCommandBuffer(commandBuffer);
assert(!err);

VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.pNext = NULL;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;

err = vkQueueSubmit(DataTransferQueue, 1, &submitInfo, DataTransferQueueSubmitFence);
assert(!err);

err = vkWaitForFences(LogicalDevice, 1, &DataTransferQueueSubmitFence, VK_TRUE, FENCE_TIMEOUT);
assert(!err);

err = vkResetFences(LogicalDevice, 1, &DataTransferQueueSubmitFence);
assert(!err);

if (freeBuffer)
{
vkFreeCommandBuffers(LogicalDevice, DataTransferCommandPool_Thread[threadID], 1, &commandBuffer);
commandBuffer = VK_NULL_HANDLE;
}}

Listing 2.2: Command-Buffer finalisieren und an eine Datentransfer-Queue übermitteln

Uniform-Buffer-Array-Objekte

Uniform-Buffer-Objekte lassen sich immer dann verwenden, wenn Shader-Programme so effizient wie möglich mit verhältnismäßig kleinen Datenmengen versorgt werden müssen. Am Beispiel der CVulkanUniformBufferObjectArray-Klasse unseres Vulkan-Frameworks werden wir nun darauf zu sprechen kommen, wie sich ein solcher Uniform-Buffer bzw. ein solches Uniform-Buffer-Array initialisieren lässt und auf welchem Wege sich die in so einem Buffer gespeicherten Daten aus dem Hauptprogramm heraus (CPU-seitig) aktualisieren lassen. Im Zuge der Initialisierung einer neuen CVulkanUniformBufferObjectArray-Instanz ist zunächst einmal ein Aufruf der in Listing 2.3 gezeigten Initialize()-Methode erforderlich. Im zweiten Schritt lassen sich dann mithilfe der in Listing 2.4 skizzierten Init_UniformBufferObject()-Methode die jeweils benötigten Arrayelemente anfordern. Verschaffen wir uns doch gleich mal einen Überblick über die dazugehörigen Programmabläufe:

  • Schritt 1: Mithilfe des VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT Flags müssen wir zunächst einmal festlegen, dass es sich bei dem zu initialisierenden VkBuffer-Objekt um einen Uniform-Buffer (Array) handelt.
  • Schritt 2: Aufruf der vkCreateBuffer()-Funktion, um das gewünschte Bufferobjekt zu erzeugen.
  • Schritt 3: Bevor sich der Speicher für ein VkBuffer- bzw. VkImage-Objekt reservieren lässt, müssen wir die Anforderungen an den bereitzustellenden Arbeitsspeicher (Größe, Alignment, Speichertyp) zunächst einmal in einer VkMemoryR...

Neugierig geworden? Wir haben diese Angebote für dich:

Angebote für Gewinner-Teams

Wir bieten Lizenz-Lösungen für Teams jeder Größe: Finden Sie heraus, welche Lösung am besten zu Ihnen passt.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang