Im Teil 1 dieser Reihe führte ich in die grundlegenden Werkzeuge für unsere Aufgabenstellung ein und zeigte das nun entstehende Anwendungsprogramm.
Teil 2 widmete sich den optischen Grundlagen unserer Modellbildung.
Im Teil 3 betrachteten wir die gesamte Transformations- und Projektionslinie von der Aufnahme bis zur Darstellung des entzerrten Bildausschnittes.
In diesem Teil betrachten wir die Einbindung der Shader in unsere Anwendung.
Hier bevorzuge ich Qt. Dieses Framework hat verschiedene Vorteile bei Anwendungen dieser Art, eine ist die sehr einfache Einbettung eigener Shader, die dabei in einen Qt-eigenen Szenengraphen eingebunden werden. Weiter lassen sich Anwendungen einschließlich Shader sehr einfach zwischen verschiedenen Plattformen portieren.
Dazu ein Blick hinter die Kulissen:
Qt 6 arbeitet intern über das Rendering Hardware Interface (RHI). Dahinter können als Backend laufen:
- Vulkan
- Direct3D
- Metal
- OpenGL / OpenGL ES

Qt verwendet als gemeinsame Beschreibung im Wesentlichen das Vulkan / SPIR-V-Ressourcenmodell. Die Bindings aus GLSL werden von Qt anschließend auf die jeweilige Ziel-API abgebildet.
Qt hat seine Shader-Pipeline daher bewusst auf modernes, Vulkan-kompatibles GLSL ausgerichtet. Qt selbst verwendet in der Dokumentation regelmäßig Version 4.40 (#version 440, https://doc.qt.io/qt-6/qml-qtquick-shadereffect.html). Die offizielle Spezifikation findet sich hier: https://registry.khronos.org/OpenGL/specs/gl/GLSLangSpec.4.40.pdf
WICHTIG: Auch wenn wir in GLSL 4.40 unsere Shader schreiben, so laufen diese nicht nativ als OpenGL-Shader. Auch hier hat Qt in die Trickkiste gegriffen und analog zur Behandlung von C++ Code wird hier der Shader-Source von dem Qt-eigenen Tool QSB verarbeitet. Dieses erzeugt aus dem Source die Shader der unterstützten Backends. Zur Laufzeit kann die Anwendung so je nach Backend den passenden Shader laden.

Es gibt verschiedene Shader-Typen:
| Shader-Typ | Endung | Aufgabe |
|---|---|---|
| Vertex Shader | .vert | Verarbeitet einzelne Eckpunkte (Vertices) eines Modells. Typischerweise werden Positionen aus dem Modellraum über View-/Projection-Matrizen in den Clip-/Bildraum transformiert. Auch Normalen, Texturkoordinaten oder andere Vertex-Daten können weitergegeben bzw. verändert werden. |
| Tessellation Control Shader | .tesc | Bestimmt, wie stark Flächen unterteilt werden sollen. Er legt also die Tessellationsgrade fest und kann zusätzliche Daten für die erzeugten Teilflächen vorbereiten. |
| Tessellation Evaluation Shader | .tese | Berechnet nach der Unterteilung die Positionen und Attribute der neu erzeugten Punkte auf der Oberfläche. Hier wird also festgelegt, wo die durch Tessellation entstandenen Vertices tatsächlich liegen. |
| Geometry Shader | .geom | Arbeitet auf ganzen Primitiven wie Punkten, Linien oder Dreiecken. Er kann zusätzliche Primitive erzeugen, vorhandene verändern oder verwerfen. |
| Fragment Shader | .frag | Berechnet für die durch Rasterisierung entstehenden Fragmente typischerweise Farbe und weitere Ausgabewerte. Hier passieren z. B. Texturierung, Beleuchtung, Farbkorrektur oder unsere Entzerrungsberechnungen. |
| Compute Shader | .comp | Ist nicht direkt an die klassische Grafik-Pipeline gebunden. Er führt allgemeine parallele Berechnungen auf der GPU aus, z. B. Bildverarbeitung, Simulationen, Filterung oder Datenaufbereitung. |
Vertex-, Tessellation-, Geometry- und Fragment-Shader gehören dabei zur Rendering-Pipeline. Compute-Shader dagegen sind allgemeine GPU-Rechenprogramme und laufen unabhängig von der klassischen Zeichenpipeline.
Nicht alle Shader-Typen werden auf allen Plattformen unterstützt:
Unterstützung des Shader-Typs durch QRhi / Backend:
| Shader-Typ | Endung | Vulkan | OpenGL / ES | Metal | Direct3D 11 / 12 |
|---|---|---|---|---|---|
| Vertex | .vert | ✅ | ✅ | ✅ | ✅ |
| Tessellation Control | .tesc | ✅ | ✅* | ✅** | ✅*** |
| Tessellation Evaluation | .tese | ✅ | ✅* | ✅** | ✅*** |
| Geometry | .geom | ✅ | ✅* | ❌ | ✅ |
| Fragment | .frag | ✅ | ✅ | ✅ | ✅ |
| Compute | .comp | ✅ | ✅* | ✅ | ✅ |
* abhängig von ausreichend neuer OpenGL-/OpenGL-ES-Version und den Fähigkeiten des Treibers. Compute braucht z. B. OpenGL 4.3 bzw. OpenGL ES 3.1; Geometry erwartet QRhi ab OpenGL 3.2 bzw. OpenGL ES 3.2.
** Tessellation funktioniert auf Metal, benötigt bei QSB aber zusätzliche Tessellationseinstellungen.
*** Direct3D kann Tessellation. QRhi implementiert die grundlegende Tessellation über alle Backends. Für D3D müssen jedoch HLSL Hull- und Domain-Shader bereitgestellt werden.
Und die automatische Übersetzung durch qsb hat je nach Backend Grenzen:
| Shader-Typ | Endung | SPIR-V / Vulkan | GLSL / OpenGL | MSL / Metal | HLSL / Direct3D |
|---|---|---|---|---|---|
| Vertex | .vert | ✅ | ✅ | ✅ | ✅ |
| Tessellation Control | .tesc | ✅ | ✅ | ✅* | ❌ |
| Tessellation Evaluation | .tese | ✅ | ✅ | ✅* | ❌ |
| Geometry | .geom | ✅ | ✅ | ❌ | ✅** |
| Fragment | .frag | ✅ | ✅ | ✅ | ✅ |
| Compute | .comp | ✅ | ✅*** | ✅ | ✅ |
* Metal/Tessellation: QSB kann Tessellation nach MSL übersetzen, benötigt dafür aber zusätzliche Angaben wie --msltess, --tess-vertex-count und --tess-mode. Isolines werden für Metal nicht unterstützt.
** HLSL/Geometry: Geometry-Shader können nach HLSL übersetzt werden; die Unterstützung hängt dabei vom verwendeten Qt-/QSB-Stand ab. Für aktuelle Qt-6-Versionen ist dieser Pfad vorhanden.
*** GLSL/Compute: Die erzeugte GLSL-Version muss Compute Shader unterstützen, also z.B. mindestens GLSL 430 bzw. GLSL ES 310.
Hier bezeichnet ❌ also wirklich nur die automatische QSB-Übersetzung. qsb kann Tessellation-Control/-Evaluation derzeit nicht automatisch nach HLSL übersetzen. Qt empfiehlt dafür handgeschriebene Hull-/Domain-Shader, die in das .qsb-Paket injiziert werden.
Bei Geometry ist es umgekehrt: Metal unterstützt Geometry Shader überhaupt nicht. Seit Qt 6.11 kann qsb Geometry Shader dagegen automatisch nach HLSL für D3D11/12 übersetzen. QRhi bezeichnet Geometry Shader allerdings weiterhin als experimentell.
Wie man den Übersichten entnehmen kann, werden zwei Shader-Typen durchgängig unterstützt, Vertex- und Fragment-Shader. Nun kommt noch eine Ebene hinzu. Die Shader arbeiten nicht losgelöst, sondern werden in den vorhandenen Qt-Scenengraphen eingefügt. Sie unterliegen damit auch dessen architektonischer Struktur.
Neben den eigentlichen Shader-Typen der Grafikpipeline kennt Qt unterschiedliche Arten, Shader in seine Rendering-Architektur einzubinden. Diese bezeichne ich im Folgenden zur Unterscheidung als Qt-Shader-Typen. Unser Shader wird als ShaderEffect in den Qt-Quick-Scene-Graph eingebunden. Dies ist einer der einfachsten Qt-Shader-Typen. Er wird als Vertex- und Fragment-Shader realisiert.
Der Scenengraph definiert dabei bestimmte Schnittstellen zum Shader vor. Der Shader muss natürlich auch in seiner Arbeitsweise an den Scenengraph angepasst sein. So übergibt der Scenengraph dem Shader eine komplette Transformationsmatrix für alle Transformationen von der Wurzel des Scenengraphen bis zum Shader als Node und seine geforderte Transparenz.
Die folgenden Eingaben (location in) sind vordefiniert:
| Position | Typ | Name | Bedeutung |
|---|---|---|---|
| 0 | vec4 | qt_Vertex | vertex position, der obere linke vertex hat die Position (0, 0), der untere rechte (width, height) |
| 1 | vec2 | qt_MultiTexCoord0 | Texturkoordinate, die obere linke Koordinate ist (0, 0), die untere rechte (1, 1). Wenn supportsAtlasTextures wahr ist, werden die Koordinaten stattdessen auf der Position im Atlas basieren |
Folgende Uniformen (binding = 0) sind vordefiniert:
| Typ | Name | Bedeutung |
|---|---|---|
| mat4 | qt_Matrix | kombinierte Transformationsmatrix: das Produkt der Matrizen aus dem Wurzelelement zu diesem ShaderEffect und eine orthogonale Projektion |
| float | qt_Opacity | kombinierte Deckkraft: das Produkt der Deckkräfte vom Root-Element zu diesem ShaderEffect |
Bei den Vertex-Eingängen sind die locations maßgeblich; die Namen sind konventionell. Bei qt_Matrix und qt_Opacity dagegen sind auch die Namen fest vorgegeben.
In unserem Fall hängen wir den ShaderEffect direkt in QML ein:
ShaderEffect
{
anchors.fill: parent
property variant source: sourceImage
property bool facadeMappingEnabled: root.rectificationEnabled
property vector2d targetToRectifiedScale: root.targetToRectifiedScale
property vector2d targetToRectifiedOffset: root.targetToRectifiedOffset
property vector2d allowedRectifiedMinimum: root.allowedRectifiedMinimum
property vector2d allowedRectifiedMaximum: root.allowedRectifiedMaximum
property var inverseRectification: root.inverseRectification
property real k1: root.parameters.k1
property real k2: root.parameters.k2
property real k3: root.parameters.k3
property real p1: root.parameters.p1
property real p2: root.parameters.p2
property vector2d distortedToSourceUvScale: root.distortedToSourceUvScale
property color backgroundColor: root.emptyPixelColor
vertexShader: "qrc:/shaders/distortion.vert.qsb"
fragmentShader: "qrc:/shaders/distortion.frag.qsb"
}
Die Properties werden dabei von Qt automatisiert dem Uniform Buffer hinzugefügt, nach qt_Matrix und qt_Opacity, welche der Scenengraph immer vorgibt. Vertex- und Fragment-Shader erhalten zur Laufzeit denselben Uniform Buffer an binding 0; Qt verlangt deshalb grundsätzlich identische Uniform-Block-Deklarationen in beiden Shadern.
Dementsprechend muss unsere Gegenseite gestaltet sein, die Shader. Ich beginne mit dem Vertex-Shader:
#version 440
layout(location = 0) in vec4 qt_Vertex;
layout(location = 1) in vec2 qt_MultiTexCoord0;
layout(location = 0) out vec2 qt_TexCoord0;
layout(std140, binding = 0) uniform buf
{
mat4 qt_Matrix;
float qt_Opacity;
// Entzerrtes Zielraster und Fassadenausschnitt.
bool facadeMappingEnabled;
vec2 targetToRectifiedScale;
vec2 targetToRectifiedOffset;
vec2 allowedRectifiedMinimum;
vec2 allowedRectifiedMaximum;
// Rückprojektion auf die unverzeichnete Kameraebene.
mat3 inverseRectification;
// Linsenverzeichnung und Abbildung auf den Bildsensor.
float k1;
float k2;
float k3;
float p1;
float p2;
vec2 distortedToSourceUvScale;
// Definierte Ausgabe für nicht abbildbare Zielpixel.
vec4 backgroundColor;
} ubuf;
Wie auch in der Qt-Dokumentation üblich, verwenden wir hier GLSL 4.40.
Es folgen die Schnittstellen. Der Vertex-Shader erhält seinen Vertex auf location 0 in und die zugehörige Texturkoordinate auf location 1 in. Weiter bindet er über binding 0 die Transformationsmatrix und Transparenz im Scenengraph und unsere Kalibrierungswerte für unsere Berechnungskette. Über location 0 out gehen die Texturkoordinaten weiter an den Fragment-Shader.
Nun schauen wir uns den Fragment-Shader an.
#version 440
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf
{
mat4 qt_Matrix;
float qt_Opacity;
// Entzerrtes Zielraster und Fassadenausschnitt.
bool facadeMappingEnabled;
vec2 targetToRectifiedScale;
vec2 targetToRectifiedOffset;
vec2 allowedRectifiedMinimum;
vec2 allowedRectifiedMaximum;
// Rückprojektion auf die unverzeichnete Kameraebene.
mat3 inverseRectification;
// Linsenverzeichnung und Abbildung auf den Bildsensor.
float k1;
float k2;
float k3;
float p1;
float p2;
vec2 distortedToSourceUvScale;
// Definierte Ausgabe für nicht abbildbare Zielpixel.
vec4 backgroundColor;
} ubuf;
layout(binding = 1) uniform sampler2D source;
Hier empfängt der Shader über location 0 in die Texturkoordinaten des Vertex-Shaders und gibt über location 0 out die berechnete Farbe unserer entzerrten Fassade zurück. Weiter bindet er wieder über binding 0 unseren schon bekannten Uniform Buffer mit Transformationsmatrix und Transparenz im Scenengraph und unseren Kalibrierungswerten für unsere Berechnungskette, zusätzlich über binding 1 die Aufnahme unserer Fassade eingehangen als Textur.
Der nächste Teil widmet sich dann den Berechnungen in den Shadern.