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

1 Entwurf eines einfachen Frameworks


Vulkan [1], [2], [3] bietet nie dagewesene Möglichkeiten und Freiheiten zum Preis einer im Vergleich zu OpenGL deutlich komplizierteren Handhabe. In Anbetracht dieser Tatsache dürfte es daher auch niemanden verwundern, dass die praktische Arbeit mit einem Low-Level-API wie Vulkan ohne Zuhilfenahme eines geeigneten Frameworks schnell zu einem mühevollen Unterfangen wird. Es gibt jedoch noch einen weiteren Grund, warum wir uns so früh wie möglich mit dem Entwurf eines eigenen Frameworks befassen sollten: In der Entwurfsphase können wir uns mit sämtlichen Aspekten des neuen Vulkan-API vertraut machen, ohne dass wir uns gleich in alle Details der zugrunde liegenden Programmabläufe einarbeiten müssen.

Im ersten Teil dieser shortcut-Reihe haben wir uns mit den Voraussetzungen befasst, unter denen die neue Vulkan-Schnittstelle ihre Vorteile gegenüber dem mittlerweile etwas in die Jahre gekommenen OpenGL-API voll ausspielen kann. Vorbei sind die Zeiten, in denen bei der Entwicklung einer Grafikanwendung sämtliche mit OpenGL in Verbindung stehende Programmabläufe innerhalb eines einzigen Threads implementiert werden mussten. Rendering-Operationen, Buffer- und Ressourcenupdates (Austausch der nicht mehr benötigten Texturen und 3-D-Modelle) sowie die mitunter erforderlichen Compute-Shader-basierten Berechnungen lassen sich im Verlauf der Vulkan-Programmentwicklung im Prinzip auf eine beliebig große Anzahl von Threads aufteilen. Damit die einzelnen Threads jedoch auch wirklich parallel zueinander ausgeführt werden können, muss man als Entwickler dafür Sorge tragen, dass sich die Anzahl der Threads an die jeweils zur Verfügung stehende Hardware anpassen lässt.

Doch auch an dieser Stelle ist Vorsicht geboten: In der Theorie ist es zwar korrekt, dass beispielsweise auf einer 8-Kern-CPU acht Threads parallel ausgeführt werden können, allerdings sollte man stets in Erinnerung behalten, dass sowohl das Betriebssystem als auch weitere Anwendungen ein gewisses Maß an Rechenzeit für sich selbst in Anspruch nehmen. Darüber hinaus ist es zwingend erforderlich, eine mehr oder weniger große Zahl von Threads für Programmabläufe zu reservieren, die zwar nichts mit der eigentlichen grafischen Darstellung zu tun haben, für deren Ausführung jedoch innerhalb des Hauptprogrammthreads schlicht zu wenig Zeit zur Verfügung steht. Hierzu zählen unter anderem KI-Berechnungen, Physiksimulationen, Kollisionsberechnungen, mögliche Interaktionen mit der Spielewelt, die Musik-, Sound- und Sprachausgabe oder die prozedurale Generierung der Spielewelt.

Das Vulkan-API bietet uns darüber hinaus auch die Möglichkeit, die Kommunikation zwischen der CPU und der GPU (der Grafikkarte) an die jeweiligen Anforderungen einer Grafikanwendung anzupassen. Im einfachsten Fall erfolgt die komplette Kommunikation (Rendering-Operationen, Buffer- und Ressourcenupdates sowie Compute-Shader-basierte Berechnungen) über ein einziges VkQueue-Objekt. Das hat jedoch den offenkundigen Nachteil, dass sich die anstehenden Arbeitsanweisungen nur sequenziell an die Grafikkarte übermitteln lassen. Nichtsdestotrotz sollte diese Variante in allen Vulkan-Anwendungen standardmäßig implementiert werden, weil sich hierdurch die Kompatibilität mit sämtlichen Vulkan-fähigen Grafikkarten gewährleisten lässt. Hinweis: Nvidia-Karten unterstützen beispielsweise im Gegensatz zu ihren AMD-Pendants die parallele Verwendung von mehreren Grafik- bzw. Rendering-Queues. Die Verwendung von drei unterschiedlichen Queue-Objekten stellt indes den bestmöglichen Kompromiss zwischen Performance (gleichbleibend hohe Frameraten) auf der einen und Kompatibilität auf der anderen Seite dar. Hierbei ist Queue Nr. 1 für die Durchführung der Rendering-Operationen, Queue Nr. 2 für die Buffer- und Ressourcenupdates und Queue Nr. 3 für die Ausführung der Compute-Shader-basierten Berechnungen zuständig.

Was das Vulkan-API betrifft, so zeichnet es sich leider nicht durch besondere Einsteigerfreundlichkeit aus, was nicht zuletzt an der Vielzahl der unterschiedlichen Datentypen (Strukturen und Enums) liegt, die bei praktisch allen Funktionsaufrufen als Parameter zu berücksichtigen sind. Erschwerend kommt hinzu, dass man sich im Unterschied zu OpenGL bzw. zu früheren DirectX-Versionen nun auch um die Synchronisierung sämtlicher Programmabläufe – z. B. um die korrekte Abfolge der Compute-Shader-basierten Berechnungen und Rendering-Schritte – sowie um das komplette Speichermanagement zu kümmern hat. Dazu gehören etwa das Anfordern, Aufteilen und wieder Freigeben von Speicherplatz, das Festlegen des jeweiligen Speicherverwendungszwecks und das Durchführen des Datentransfers zwischen CPU- und GPU-Speicher mithilfe von temporär erzeugten Staging-Buffer-Objekten. Aufgrund der großen Zahl der damit einhergehenden API-Funktionsaufrufe tendiert der Sourcecode daher auch bei vermeintlich einfachen Anwendungen und Demoprogrammen dazu, mit fortschreitender Entwicklungsdauer zunehmend unübersichtlich zu werden. Selbst als Vulkan-Novize überlegt man daher bereits nach relativ kurzer Zeit, wie sich die ganzen Vulkan-API-Calls hinter möglichst wenigen unkomplizierten Funktionen und Klassenmethoden verbergen lassen.

Handhabung der grundlegenden Funktionsabläufe einer Vulkan-Anwendung

Der erste Schritt bei der Entwicklung des in unseren Programmbeispielen zum Einsatz kommenden Frameworks bestand zunächst im Entwurf der CBaseVulkanApp-Klasse, in deren Verantwortungsbereich die Handhabung von sämtlichen elementaren, mit dem Vulkan-API in Verbindung stehenden Funktionsabläufen liegt. Im Rahmen der Programminitialisierung kommen zunächst einmal die beiden nachfolgend gezeigten Klassenmethoden zum Einsatz:

  • Init_ApplicationWindow()
  • Init_Vulkan()

Während sich mithilfe der ersten Methode die Fenster- bzw. Vollbildeigenschaften der Anwendung festlegen lassen, ist die zweite Methode für die Initialisierung der eigentlichen Vulkan-Instanz verantwortlich. Soll eine laufende Anwendung schließlich wieder beendet werden, ist für die Durchführung der anstehenden Aufräumarbeiten ein Aufruf der CleanUp()-Methode erforderlich.

Die vielfältigen Shader-Programme, die für die Animation, Beleuchtung und Darstellung der virtuellen (Spiele-)Welten verantwortlich sind, können als Herzstück einer jeden Grafikanwendung gesehen werden. Im Unterschied zu OpenGL müssen diese Programme unter Vulkan jedoch zunächst kompiliert und im SPIR-(Standard-Portable-Intermediate-Representation-)V-Format abgespeichert werden. Zum Laden eines zuvor kompilierten Shader-Programms können wir dann in Abhängigkeit vom jeweiligen Shader-Typ auf eine der nachfolgenden CBaseVulkanApp-Klassenmethoden zurückgreifen:

  • Load_VertexShader_SPV_File()
  • Load_FragmentShader_SPV_File()
  • Load_ComputeShader_SPV_File()

Bevor wir nun auf die weiteren Aufgaben der CBaseVulkanApp-Klasse zu sprechen kommen, sollten wir uns zuvor noch einmal die Funktionsweise des Vulkan-APIs in Erinnerung rufen. Sämtliche Arbeitsanweisungen, die für die Durchführung der Rendering-Operationen, Shader-basierten Berechnungen und Ressourcenupdates erforderlich sind, werden in so genannten VkCommandBuffer-Objekten zwischengespeichert und unter Verwendung eines oder mehrerer VkQueue-Objekte an die Grafikkarte (GPU) weitergeleitet. In Abhängigkeit von ihrem jeweiligen Verwendungszweck können wir bei der Initialisierung der hierfür benötigten Buffer auf eine der drei nachfolgend gezeigten CBaseVulkanApp-Methoden zurückgreifen:

  • Create_GraphicsCommandBuffer()
  • Create_DataTransferCommandBuffer()
  • Create_ComputeCommandBuffer()

Sind die Arbeitsanweisungen erst einmal in einem Command Buffer gespeichert, lässt sich dieser mithilfe einer der nachfolgend aufgeführten Klassenmethoden beliebig oft an die Grafikkarte weiterleiten. Welche Methode in diesem Zusammenhang zu verwenden ist, hängt von der Art der zu übermittelnden Anweisungen ab:

  • Submit_CommandBufferToGraphicsQueue()
  • EndAndSubmit_CommandBufferToGraphicsQueue()
  • Submit_CommandBufferToDataTransferQueue()
  • EndAndSubmit_CommandBufferToDataTransferQueue()
  • Submit_CommandBufferToComputeQueue()
  • EndAndSubmit_CommandBufferToComputeQueue()
  • Submit_RenderingStepCommandBuffer_To_GraphicsQueue()

Für die Übermittlung der für die Szenendarstellung zuständigen Command-Buffer-Objekte müssen wir auf die Submit_RenderingStepCommandBuffer_To_GraphicsQueue()-Methode zurückgreifen. Damit es im Verlauf des Renderings zu keinerlei Darstellungsfehlern kommt, gilt es, mithilfe der nachfolgend gezeigten Klassenmethoden sicherzustellen, dass die einzelnen Rendering-Schritte auch wirklich in der von uns vorgesehenen Reihenfolge ausgeführt werden:

  • Begin_RenderingStepSynchronisation()
  • End_RenderingStepSynchronisation()
  • Synchronise_RenderingSteps()

Uniform-Buffer-Objekte

Uniform-Buffer-Objekte (Kasten: „Uniform-Buffer-Objekte“) kommen immer dann zum Einsatz, wenn Shader-Programme aus dem Hauptprogramm heraus (CPU-seitig) so effizient wie möglich mit verhältnismäßig kleinen Datenmengen versorgt werden müssen. In sämtlichen Anwendungsfällen, in denen die Verwendung eines einzelnes Uniform-Buffer-Objekts vollkommen ausreichend ist, bietet sich der Einsatz der CVulkanUniformBufferObject-Klasse an. Wird jedoch eine größere Anzahl von Uniform-Buffern benötigt, sollte man aus Performancegründen wenn möglich auf ein Uniform-Buffer-Array-Objekt zurückgreifen, das durch eine Instanz der CVulkanUniformBufferObjectArray-Klasse verkörpert wird. Um Missverständnissen vorzubeugen, sei gesagt, dass sich hinter einem Uniform-Buffer-Array-Objekt kein Array von kleinen Bufferobjekten verbirgt, vielmehr handelt es sich um einen großen Buffer (Speicherblock), der in eine Vielzahl von Teilbereichen unterteilt ist. Per Definition besteht ein Array aus einem großen Speicherblock, auf dessen Teilbereiche man unabhängig voneinander zugreifen kann. Falls gewünscht, lässt sich anstelle eines Uniform-Buffer-Arrays auch ein so genanntes dynamisches Uniform-Buffer-Objekt verwenden, das durch eine Instanz der CVulkanDynamicUniformBufferObject-Klasse repräsentiert wird.

Uniform-Buffer-Objekte (Frameworkklassen)

Member-Funktionen der CVulkanUniformBufferObject-Klasse:

  • Initialize()
  • CleanUp()
  • Update_BufferData()

Member-Funktionen der CVulkanUniformBufferObjectArray-Klasse:

  • Initialize()
  • CleanUp()
  • Init_UniformBufferObject()
  • Update_BufferData()

Member-Funktionen der CVulkanDynamicUniformBufferObject-Klasse:

  • Initialize()
  • CleanUp()
  • Update_BufferData()

Shader-Storage-Buffer-Objekte

Shader-Storage-Buffer-Objekte (Kasten: „Shader-Storage-Buffer-Objekte“) stellen in vielerlei Hinsicht eine Weiterentwicklung der zuvor betrachteten Uniform-Buffer-Objekte dar, weshalb wir anstelle eines Uniform-Buffers immer auch einen Storage-Buffer verwenden könnten. Der große Vorteil eines Storage-Buffer-Objekts gegenüber einem Uniform-Buffer besteht jedoch darin, dass sich die im Buffer gespeicherten Daten sowohl aus dem Hauptprogramm (CPU-seitig) als auch aus einem Shader-Programm heraus (GPU-seitig) aktualisieren bzw. auslesen lassen. In einfachen Fällen, in denen die Verwendung eines einzelnes Storage-Buffer-Objekts vollkommen ausreichend ist, können wir auf eine Instanz der CVulkanStorageBufferObject-Klasse zurückgreifen. Benötigt man hingegen eine größere Anzahl von Storage-Buffern, bietet sich aus Performancegründen die Verwendung der CVulkanStorageBufferObjectArray-Klasse an. Als Alternative zu einem Storage-Buffer-Array können wir darüber hinaus auch auf ein dynamisches Storage-Buffer-Objekt zurückgreifen, das durch eine Instanz der CVulkanDynamicStorageBufferObject-Klasse repräsentiert wird. Wichtig ist, dass wir uns bereits im Vorfeld der Initialisierung eines Storage-Buffers über dessen genauen Verwendungszweck im Klaren sind, da die Art und der Umfang des vom Buffer belegten Speichers einen nicht zu unterschätzenden Einfluss auf die Performance hat:

  • Anwendungsfall 1: Sofern der Datenaustausch hauptsächlich zwischen dem Hauptprogramm und einem Shader erfolgen soll, muss der onlyDirectShaderAccess-Parameter der Initialisierungsfunktion auf false gesetzt werden. Da bei der Speicheranforderung eine Kombination der beiden Flags VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT und VK_MEMORY_PROPERTY_HOST_COHERENT_BIT verwendet wird, erfolgt der CPU-seitige Zugriff auf die im Buffer gespeicherten Daten via Memory Mapping (Speichereinblendung). Behilflich sind dabei die drei Funktionen vkMapMemory(), memcpy() und vkUnmapMemory().
  • Anwendungsfall 2: Sofern der Datenaustausch hauptsächlich zwischen unterschiedlichen Shader-Programmen erfolgen soll, muss der onlyDirectShaderAccess-Parameter der Initialisierungsfunktion auf true gesetzt werden. Da bei der Speicheranforderung das VK_MEMORY-Flag VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT verwendet wird, ist ein CPU-seitiger Zugriff auf die im Buffer gespeicherten Daten nur noch au...

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

Angebote für Teams

Für Firmen haben wir individuelle Teamlizenzen. Wir erstellen Ihnen gerne ein passendes Angebot.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang