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

2 Die GPU meldet sich zu Wort


Nachdem wir uns im vorigen Kapitel mit dem Entwurf eines Vulkan-basierten Frameworks, auseinandergesetzt und uns in diesem Zusammenhang mit den wichtigsten Aspekten des Vulkan-API vertraut gemacht haben, wird sich in diesem Kapitel alles um das Thema Shader-Programmierung drehen.

Mit jeder neuen Generation werden die auf dem Markt erhältlichen Grafikkarten immer leistungsfähiger, wodurch letzten Endes auch die Anforderungen an einen 3-D-Programmierer von Jahr zu Jahr steigen. Während man sich in der Anfangszeit der Shader-Programmierung noch mit solch altertümlich wirkenden Problemen wie etwa einer begrenzten Anzahl von verfügbaren GPU-Instruktionen herumschlagen musste, lassen sich mit heutigen Shader-Programmen immer komplexere Echtzeitberechnungen durchführen. Hierdurch wird nicht nur das Erscheinungsbild der virtuellen 3-D-Welten immer realistischer, Gleiches gilt zunehmend auch für die Interaktionsmöglichkeiten mit einer solchen Welt sowie für die in ihr herrschenden physikalischen Gesetzmäßigkeiten.

Bevor man dem neuen Vulkan-API seinen doch recht werbewirksamen Namen verliehen hat, wurde es zuvor in Programmiererkreisen lange Zeit schlicht und einfach als „OpenGL Next“ bezeichnet. Vergleicht man einmal den Sourcecode einer Vulkan-Anwendung mit dem eines OpenGL-Programms, dann zeigt sich jedoch, dass es sich bei der Namensänderung keinesfalls um eine reine PR-Maßnahme handelte. Das Vulkan-API ist mitnichten eine einfache Weiterentwicklung der mittlerweile etwas in die Jahre gekommenen OpenGL-Schnittstelle, sondern etwas grundlegend Neues. Für OpenGL-Programmierer gibt es jedoch auch eine gute Nachricht zu vermelden, da sich die für eine Vulkan-Anwendung benötigten Shader-Programme nach wie vor mithilfe der OpenGL Shading Language (GLSL bzw. GLslang) implementieren lassen.

Verschiedene Shader-Typen für verschiedene Aufgaben

Die Darstellung von komplexen 3-D-Szenen erfordert eine immens große Anzahl gleichartiger Berechnungsschritte, die sich jedoch im Regelfall allesamt parallel zueinander ausführen lassen. Sofern man aber auf die Verwendung von so genannten Compute-Shader-Programmen verzichten kann, muss man sich um das Thema Parallelisierung allerdings keine allzu großen Gedanken machen, da sie bei allen anderen Shader-Typen implizit erfolgt. Im Zug der Implementierung eines Vertex-Shaders müssen wir beispielsweise lediglich festlegen, auf welche Weise die einzelnen Vertices transformiert werden sollen; eine explizite Angabe, für welche Vertices die besagten Berechnungen durchzuführen sind, ist hingegen nicht erforderlich:

gl_Position = matProjection*matWorldView*vec4(inPos, 1.0f);

Für die Auswahl des betreffenden Vertex (bzw. Pixels) ist allein die GPU beim Aufruf (Invocation) der von uns implementierten Shader-Funktion verantwortlich:

// Parallel erfolgende Aufrufe einer von uns implementierten Shader-Funktion:
Run_Shader(outputData[i], inputData[i]);

In einer OpenGL-, DirectX- oder Vulkan-Anwendung können an der Darstellung eines 3-D-Objekts unter Umständen bis zu fünf verschiedene Shader-Typen beteiligt sein. Am Anfang einer jeden Grafikpipeline (Rendering-Pipeline) meldet sich zunächst einmal der Vertex Shader zu Wort. Im Verlauf der Vertex-Shader-Berechnungen werden die Bildschirmkoordinaten sämtlicher Vertices eines 3-D-Modells ermittelt, nachdem zuvor die zugehörigen Vertexdaten aus einem Vertex-Buffer ausgelesen wurden. Sobald der Vertex Shader seine Arbeit abgeschlossen hat, können wir bei Bedarf mithilfe eines Tessellation Control Shaders (DirectX: Hull Shader) für die detailarmen Modelloberflächen eine Reihe von Kontrollpunkten berechnen, mit denen der Hardware-Tessellator im Anschluss daran die besagten Oberflächen in feingliedrigere Dreiecksnetze unterteilen kann (Subdivision). Im nächsten Schritt steht schließlich mittels eines geeigneten Tessellation Evaluation Shaders (DirectX: Domain Shader) die Berechnung der Positionen der durch Tessellation neu erzeugten Vertices an, wodurch sich einem 3-D-Modell in Abhängigkeit von der jeweils verwendeten Displacement Map neue Oberflächendetails hinzufügen lassen.

Bevor die Hardware-Tessellation zu einem festen Bestandteil einer modernen Grafikpipeline avancierte, musste man nach Abschluss der Vertex-Shader-Berechnungen auf einen Geometry Shader zurückgreifen, um neue geometrische Primitiven (Punkte, Linien, Dreiecke) zu generieren oder um bereits vorhandene Primitiven aus der Pipeline zu entfernen. Auch wenn es theoretisch möglich wäre, sollte ein Geometry Shader aus Performancegründen niemals für großflächige Tessellation-Berechnungen eingesetzt werden. Geometry Shader eignen sich allenfalls für kleinere Modifikationen!

Nach Abschluss sämtlicher Vertex-basierter Berechnungen haben schließlich die Fragment Shader (DirectX: Pixel Shader) ihren großen Auftritt. Anfangs waren diese Programme lediglich für die pixelgenaue Beleuchtung und Texturierung eines 3-D-Modells verantwortlich, mittlerweile sind sie jedoch zu einer tragenden Säule im Rahmen der immer komplexer werdenden Post-Processing-Berechnungen geworden. Kein Bestandteil einer Grafikpipeline sind hingegen die so genannten Compute-Shader-Programme, mit deren Hilfe es möglich ist, eine Vielzahl von unterschiedlichen Berechnungen (Ray Tracing, Bildbearbeitung, Partikelsysteme, Physiksimulationen usw.) auf die GPU auszulagern und unabhängig von etwaigen parallel verlaufenden Rendering-Operationen durchzuführen.

GLSL und SPIR-V – zwei Seiten einer Medaille

Auch als bekennender OpenGL-Fan komme ich nicht umhin, einzugestehen, dass man zumindest in der Vergangenheit die Shader-Programmierung mit Fug und Recht als Achillesferse des OpenGL-API bezeichnen konnte. Ursprünglich hatte man mit der Designentscheidung, dass die Kompilierung der GLSL-Shader-Programme durch den jeweils installierten Grafiktreiber erfolgen soll, die Hoffnung verbunden, dass sich hierdurch die Ausführungsgeschwindigkeit der kompilierten Shader maximieren ließe. In der Praxis hat diese Entscheidung jedoch vornehmlich zu großen Kompatibilitätsproblemen geführt, da man sich als Programmierer nach der Implementierung eines komplexen Shaders niemals wirklich sicher sein konnte, ob denn das betreffende Programm auch wirklich auf sämtlichen Grafikkarten korrekt ausgeführt werden würde. Und selbst wenn das anfangs noch der Fall war, konnte sich dies jederzeit durch ein Treiberupdate nachträglich noch ändern. Die Notwendigkeit, den GLSL-Programmcode gemeinsam mit der zugehörigen Grafikanwendung veröffentlichen zu müssen, war in diesem Zusammenhang sicher noch das Geringste aller Übel.

Im Unterschied zu OpenGL ist der Vulkan-Treiber nicht mehr für die Kompilierung der jeweils benötigten Shader-Programme verantwortlich, weshalb die eingangs erwähnten Kompatibilitätsprobleme nun endgültig der Vergangenheit angehören sollten. Anders als noch unter OpenGL üblich, müssen die in einer Vulkan-Anwendung zum Einsatz kommenden GLSL Shader bereits im Verlauf der Programmentwicklung kompiliert werden. Hierbei können wir beispielsweise auf das Kommandozeilentool glsLangValidator zurückgreifen, das als Bestandteil des LunarG-Vulkan-SDKs vertrieben wird. Nach ihrer Kompilierung werden die betreffenden Shader-Programme als Binärcode im so genannten SPIR-V-Format (Standard Portable Intermediate Representation) abgespeichert und können dann später zur Laufzeit einer Grafikanwendung auf direktem Weg an die GPU übermittelt werden. Da der Umgang mit dem zuvor genannten Kommandozeilentool zugegebenerweise ein wenig umständlich ist, verwenden wir die in Listing 2.1 skizzierten Helper-Funktionen, um die jeweils benötigten Shader-Programme unmittelbar nach dem Start unserer Vulkan-Demoanwendungen – falls gewünscht – neu zu kompilieren. Zum Laden eines kompilierten Shader-Programms greifen wir hingegen auf die in Listing 2.2 gezeigte Load_(Vertex)Shader_SpirV_File()-Methode der uns bereits aus dem vorigen Kapitel bekannten CBaseVulkanApp-Klasse zurück.

INLINE void Generate_SpirV_VertexShaderFile(char* pFileNameOutput, char* pFileNameInput)
{
remove(pFileNameOutput);

STARTUPINFO si;
PROCESS_INFORMATION pi;

ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);

ZeroMemory(&pi, sizeof(pi));

char strBuffer[200];

sprintf(strBuffer, "glslangValidator.exe -V %s", pFileNameInput);

CreateProcess(NULL, strBuffer, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi);

WaitForSingleObject(pi.hProcess, INFINITE);

// Close process and thread handles:
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);

rename("vert.spv", pFileNameOutput);
}

INLINE void Generate_SpirV_FragmentShaderFile(char* pFileNameOutput, char* pFileNameInput)
{[...]}

INLINE void Generate_SpirV_ComputeShaderFile(char* pFileNameOutput, char* pFileNameInput)
{[...]}

Listing 2.1: Kompilierung eines Shader-Programms

VkShaderModule CBaseVulkanApp::Load_VertexShader_SpirV_File(char* pFileName)
{
if (tempVertexShaderModule != VK_NULL_HANDLE)
{
vkDestroyShaderModule(LogicalDevice, tempVertexShaderModule, NULL);tempVertexShaderModule = VK_NULL_HANDLE;
}

char *vertShaderCode = NULL;
size_t size;

vertShaderCode = Read_SpirV(pFileName, &size);

tempVertexShaderModule = Init_Shader_Module(vertShaderCode, size);

free(vertShaderCode);
return tempVertexShaderModule;
}

Listing 2.2: Laden eines SPIR-V-Vertex-Shader-Programms

Wie Sie in diesem Zusammenhang erkennen können, lässt sich der Ladevorgang eines Shaders in zwei Schritte unterteilen. Zunächst einmal muss der Binärcode der betreffenden SPIR-V-Shader-Datei ausgelesen werden, wobei die in Listing 2.3 gezeigte Read_SpirV()-Helper-Funktion ihren großen Auftritt hat. In einem zweiten Schritt können wir dann den kompilierten Shader-Code in Form einer einfachen Zeichenkette (ein Array vom Typ char) an die in Listing 2.4 skizzierte CBaseVulkanApp-Methode Init_Shader_Module()-Klasse übergeben, die ihrerseits für die Initialisierung eines VkShaderModule-Objekts (dieses repräsentiert das zu ladende Shader-Programm) verantwortlich zeichnet.

INLINE char* Read_SpirV(const char *pFilename, size_t *pSize)
{
long int size;
size_t U_ASSERT_ONLY retval;

void *shader_code = NULL;

FILE *fp = fopen(pFilename, "rb");

if(!fp)
return NULL;

fseek(fp, 0L, SEEK_END);
size = ftell(fp);
fseek(fp, 0L, SEEK_SET);

shader_code = malloc(size);
retval = fread(shader_code, size, 1, fp);
assert(retval == 1);

*pSize = size;

fclose(fp);
return (char*)shader_code;
}

Listing 2.3: Helper-Funktion zum Auslesen einer SPIR-V-Shader-Datei

VkShaderModule CBaseVulkanApp::Init_Shader_Module(const void *pCode, size_t size)
{
VkShaderModule module;
VkShaderModuleCreateInfo moduleCreateInfo;

moduleCreateInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
moduleCreateInfo.pNext = NULL;
moduleCreateInfo.codeSize = size;
moduleCreateInfo.pCode = (uint32_t*)pCode;
moduleCreateInfo.flags = 0;

VkResult U_ASSERT_ONLY err;

err = vkCreateShaderModule(LogicalDevice, &moduleCreateInfo, NULL, &module);
assert(!err);
return module;
}

Listing 2.4: Initialisierung eines neuen „VkShaderModule“-Objekts

Aufbau und Funktionsweise eines Vertex-Shader-Programms

Mit dem Thema Shader-Programmierung verhält es sich so wie mit vielen anderen Dingen im Leben. Oberflächlich betrachtet scheint alles zunächst noch ganz unkompliziert zu sein, was sich nicht zuletzt darin äußert, dass sich bereits nach einer relativ kurzen Einarbeitungsphase die ersten einfach gestrickten GLSL-Programme mühelos implementieren lassen. Die Komplexität von so manchen Dingen erkennt man aber oftmals erst auf den zweiten Blick. Ist man nämlich beispielsweise an den ganzen Details der GLSL-Programmierung interessiert, dann wird einem spätestens nach dem Download der 211-seitigen PDF-Datei [1] mit den Spezifikationen der OpenGL Shading Language (Version 4.50) bewusst, dass man für ein gründliches Studium dieses Dokuments vor allen Dingen eines benötigt – sehr viel Zeit. Da uns diese jedoch im Rahmen dieses Kapitels nur in einem begrenzten Maß zur Verfügung steht, müssen wir uns wohl oder übel auf eine Auswahl der wichtigsten Aspekte der OpenGL Shading Language beschränken. In diesem Zusammenhang riskieren wir zunächst einmal einen Blick auf Listing 2.5, um gemeinsam Schritt für Schritt den Aufbau und die Funktionsweise eines Vertex-Shader-Programms nachzuvollziehen.

// Shader-Version festlegen:
#version 450

// Genauigkeit der Fließkomma-Berechnungen festlegen:
precision highp float;

// Zu verwendende Shader-Programm-Erweiterungen festlegen:
#extension GL_ARB_separate_shader_objects : enable
#extension GL_ARB_shading_language_420pack : enable
#extension GL_ARB_enhanced_layouts : enable

// Der nachfolgende Uniform Block ermöglicht den Zugriff auf die in
// einem Uniform Buffer gespeicherten Transformationsmatrizen:
layout (set = 0, binding = 0) uniform UBO_TransformationMatrices
{
mat4 matProjection;
mat4 matWorldView;
};

// Variablen für den Zugriff auf die im Vertex Buffer gespeicherten Daten:
layout (location = 0) in vec3 inPos;
layout (location = 1) in vec3 inNormal;
layout (location = 2) in vec3 inPerpendicular1;
layout (location = 3) in vec3 inPerpendicular2;
layout (location = 4) in vec3 inTexCoord;

// Rückgabewerte (Output-Variablen) der Vertex-Shader-Funktion:

// Vom Shader berechnete Vertexposition im Bildraum:
out gl_PerVertex {vec4 gl_Position;};

// Vertex-Normalenvektor (zwecks Weiterleitung an den Fragment Shader):
layout (location = 0) out vec3 outNormal;

// Texturkoordinaten (zwecks Weiterleitung an den Fragment Shader):
layout (location = 1) out vec3 outTexCoord;

void main()
{
// Normalenvektor an den Fragment Shader weiterleiten:
outNormal = inNormal;
// Texturkoordinaten an den Fragment Shader weiterleiten:
outTexCoord = inTexCoord;
// Vertexposition im Bildraum berechnen:
gl_Position = matProjection*matWorldView*vec4(inPos, 1.0f);
}

Listing 2.5: Ein beispielhafter Vertex Shader

Im ersten Schritt müssen wir zunächst einmal die zu verwendende Shader-Version sowie die Genauigkeit der durchzuführenden Fließkommaberechnungen festlegen:

#version 450
precision highp float;

Im zweiten Schritt müssen wir dann die Entscheidung treffen, auf welche Programmerweiterungen wir im Rahmen der Shader-Implementierung zurückgreifen wollen:

#extension GL_ARB_separate_shader_objects : enable
#extension GL_ARB_shading_language_420pack : enable
#extension GL_ARB_enhanced_layouts : enable

Im dritten Schritt erfolgen, falls notwendig, die Deklaration eines Push-Constant-Blocks (Listing 2.6, Beispiel 1), die Deklaration der benötigten Texel-Buffer-Objekte (Listing 2.7) sowie die Deklaration der erforderlichen Uniform- (Listing 2.8) und/oder Shader-Storage-Blöcke (Listing 2.9), mit deren Hilfe man innerhalb eines Shader-Programms auf die zugehörigen Uniform-Buffer- bzw. Shader-Storage-Buffer-Objekte zugreifen kann. Im vierten Schritt kümmern wir uns um die Deklaration der benötigten Strukturen und Konstanten sowie um die Implementierung der benutzerdefinierten Funktionen (Listing 2.10).

// Beispiel 1 – ein im Vertex Shader verwendeter Push-Constant-Block:
//-------------------------------------------------------------------
layout(push_constant) uniform _PushConstantsListPe...

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