Post

3D Kugelbad mit Qt QRhi implementiert

3D Kugelbad mit Qt QRhi implementiert

Das 3D Kugelbad (instanced rendering) mit QRhi implementiert

Im Beitrag Icosphere (Ikosaeder-Kugel oder geodätische Kugel) in OpenGL hab ich ein kleines 3D-Bällebad gebaut: ein Haufen Kugeln, die in einem Gitterraum auf und ab hüpfen, gerendert mit Instanced Rendering über glDrawElementsInstanced(). Das läuft prima – aber eben nur mit OpenGL. Und OpenGL ist auf dem Rückzug: unter macOS seit Jahren deprecated, unter Windows liefern die Treiber eher lieblos, und mobil gibt es sowieso nur OpenGL ES.

Qt hat für dieses Problem eine Antwort: QRhi – das Qt Rendering Hardware Interface. Das ist die Abstraktionsschicht, auf der intern seit Qt 6 auch Qt Quick aufsetzt. Man schreibt seinen Rendering-Code genau einmal, und Qt schiebt ihn dann durch dasjenige Backend, das auf der jeweiligen Plattform am besten funktioniert:

  • Vulkan (Linux, Android, Windows)
  • Metal (macOS, iOS)
  • Direct3D 11 und 12 (Windows)
  • OpenGL / OpenGL ES (überall, als Rückfallebene)

Seit Qt 6.7 gibt es dazu die Klasse QRhiWidget, die im Wesentlichen das ist, was QOpenGLWidget für OpenGL war: ein Widget, in das man hineinrendert, eingebettet in eine ganz normale Widget-Anwendung.

Eine Warnung vorweg, damit man nicht später überrascht ist: QRhi ist keine vollständig öffentliche API. Der Header rhi/qrhi.h kommt über das CMake-Target Qt6::GuiPrivate, und Qt gibt für diese Klassen keine Quelltextkompatibilität zwischen Minor-Releases. In der Praxis hat sich seit 6.6 wenig geändert, aber man sollte damit rechnen, beim Sprung auf eine neue Qt-Version mal eine Zeile anfassen zu müssen. QRhiWidget selbst ist dagegen echte öffentliche API.

Ich hab also das Kugelbad-Beispiel 1:1 auf QRhi portiert – gleiche Szene, gleiche Kugeln, gleiches Phong-Shading, gleiches instanziertes Rendern. Damit lässt sich sehr schön Zeile für Zeile vergleichen, was aus welchem OpenGL-Konstrukt geworden ist.

Obwohl OpenGL nicht kurzfristig verschwinden wird, sind doch Features moderner GPUs, wie z.B. Raytracing, nur noch via Vulkan/Metal/DirectX zu programmieren, da der OpenGL Standard nicht mehr weiterentwickelt wird. Wenn man also sowas braucht, ist der Schwenk zu QRhi wohl durchaus sinnvoll.

Die für mich relevante Frage ist trotzdem: lohnt sich aktuell der Aufwand, bestehende OpenGL Programme nach QRhi zu portieren und neue Programme gleich damit zu schreiben, oder ist OpenGL doch noch Mittel der Wahl? Wohl gemerkt, ich plane nicht, modernste Spiele zu schreiben, sondern eher technische 3D Anwendungen zu programmieren. Und für mich steht immer Aufwand/Nutzen und Wartbarkeit des Quelltextes im Vordergrund.

Grundlegende Unterschiede/Vergleich zwischen QRhi und der OpenGL Variante

Fangen wir mit dem Widget an. Die drei Überschreibungen, die man von QOpenGLWidget kennt, haben direkte Entsprechungen – bis auf eine:

1
2
3
4
5
6
  QOpenGLWidget            QRhiWidget
  ----------------------   -----------------------------------------------
  initializeGL()           initialize(QRhiCommandBuffer *)
  paintGL()                render(QRhiCommandBuffer *)
  resizeGL(w, h)           — gibt es nicht; initialize() wird erneut aufgerufen
  ~GLWidget() + cleanup    releaseResources()

Das fehlende resizeGL() ist kein Versehen, sondern Absicht: QRhiWidget::initialize() wird jedes Mal aufgerufen, wenn sich am Rendertarget etwas ändert – Größe, Sample-Count, oder wenn das Grafikgerät verloren ging (Device Loss, z.B. nach einem Treiberneustart). initialize() ist damit keine einmalige Initialisierung mehr, sondern eine Funktion, die man mehrfach überstehen muss. Dazu unten mehr.

Interessanter ist die zweite Tabelle. Die betrifft nicht das Widget, sondern das Rendering-Modell selbst:

OpenGL QRhi
QOpenGLShaderProgram QRhiGraphicsPipeline + QRhiShaderResourceBindings
glUseProgram() QRhiCommandBuffer::setGraphicsPipeline()
glUniform*() / setUniformValue() Schreiben in einen QRhiBuffer(UniformBuffer)
glVertexAttribPointer() + VAO QRhiVertexInputLayout, in die Pipeline gebacken
glVertexAttribDivisor(loc, 1) QRhiVertexInputBinding::PerInstance
glEnable(GL_DEPTH_TEST) etc. Pipeline-State, in die Pipeline gebacken
glClearColor() + glClear() Parameter von beginPass()
glBufferData() / allocate() QRhiResourceUpdateBatch::uploadStaticBuffer()
glDrawElements() QRhiCommandBuffer::drawIndexed()
glDrawElementsInstanced() drawIndexed() mit instanceCount
GL-Calls wirken sofort alles wird in einen QRhiCommandBuffer aufgezeichnet

Der Preis: alles ist explizit

Zwei wichtige Dinge muss man verinnerlichen:

Erstens: die Pipeline ist unveränderlich. In OpenGL hat man ein Shaderprogramm gebunden, dann irgendein VAO, dann noch schnell glEnable(GL_DEPTH_TEST) oder andere Flags gesetzt und losgezeichnet – alles globaler, jederzeit umschaltbarer Zustand. Ein QRhiGraphicsPipeline-Objekt dagegen bekommt bei der Erzeugung alles mit: das Shaderpaar, das Vertexlayout, Tiefentest, Culling, Topologie, Sample-Count, den Render-Pass. Danach ist es festgezurrt. Will man eine andere Kombination, braucht man ein zweites Pipeline-Objekt. Deshalb hat das Beispiel drei Pipelines – eine für die Kugeln, eine für die Würfel, eine für das Gitter – wo die OpenGL-Variante mit vier ShaderProgram-Objekten und dazwischen umgestecktem globalen State auskam.

Das ist genau das Modell, das Vulkan, Metal und D3D12 vorgeben, und der Grund dafür ist Performance: der Treiber kann die komplette Zustandskombination einmal beim Erzeugen validieren und übersetzen, statt bei jedem Draw-Call zu prüfen, was sich gerade geändert hat. Impliziert steckt hier aber auch drin, dass bei Änderung auch nur eines Attributs einer Pipeline, die gesamte Pipeline neu erstellt werden muss.

Zweitens: nichts wird sofort hochgeladen. Ein batch->uploadStaticBuffer(...) kopiert nichts, es merkt sich nur den Auftrag. Ausgeführt wird der Stapel erst, wenn man ihn an beginPass() übergibt (oder explizit cb->resourceUpdate(batch) aufruft). Genauso zeichnet cb->drawIndexed() nichts – es schreibt einen Eintrag in den Kommandopuffer, den Qt am Ende des Frames an die GPU schickt. Das fühlt sich anfangs umständlich an, ist aber der Grund, warum QRhi Uploads bündeln und Dynamic-Buffer intern doppelt puffern kann, ohne dass die CPU auf die GPU warten muss.

Das Projekt-Setup: CMake und der Shader-Compiler

Die CMakeLists.txt ist die erste Datei, in der man den Unterschied sieht. Die OpenGL-Variante brauchte Qt6::OpenGL und Qt6::OpenGLWidgets; hier taucht OpenGL überhaupt nicht mehr auf:

1
2
3
4
5
# QRhiWidget lives in Qt6::Widgets and is public API since Qt 6.7.
# QRhi itself (rhi/qrhi.h) ships with Qt6::Gui but is only reachable through the
# GuiPrivate target — hence no OpenGL/OpenGLWidgets dependency at all here.
# ShaderTools provides qt_add_shaders(), which runs the qsb shader baker.
find_package(Qt6 6.7 REQUIRED COMPONENTS Core Gui Widgets GuiPrivate ShaderTools)

Qt6::GuiPrivate ist die schon erwähnte Halb-öffentlichkeit von qrhi.h. CMake wird beim Konfigurieren übrigens eine Warnung ausspucken, dass man da ein privates Modul benutzt – die kann man mit QT_NO_PRIVATE_MODULE_WARNING=ON abschalten, wenn sie nervt.

GLSL 440 und der qsb-Compiler

Qt6::ShaderTools ist das eigentlich Spannende. In der OpenGL-Variante lagen die Shader als reiner GLSL-Text in einer shaders.qrc und wurden zur Laufzeit von QOpenGLShaderProgram::addShaderFromSourceFile() kompiliert. Das geht bei QRhi nicht mehr – Vulkan will SPIR-V, D3D will DXBC/HLSL, Metal will MSL. Also werden die Shader zur Bauzeit übersetzt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
qt_add_shaders(${PROJECT_NAME} "shaders"
	BATCHABLE
	PRECOMPILE
	OPTIMIZED
	PREFIX "/shaders"
	BASE "${PROJECT_SOURCE_DIR}/shaders"
	GLSL "150,330"
	FILES
		shaders/phong.vert
		shaders/phong.frag
		shaders/instanced.vert
		shaders/grid.vert
		shaders/grid.frag
)

Was hier passiert: Das Werkzeug qsb (Qt Shader Baker) nimmt jede Quelldatei – geschrieben in Vulkan-flavoured GLSL, also #version 440 – und übersetzt sie zunächst nach SPIR-V. Aus dem SPIR-V generiert es dann per Rückübersetzung die HLSL-, MSL- und (wegen GLSL "150,330") auch die klassischen GLSL-Varianten. Alle Fassungen landen zusammen in einer Datei <name>.qsb, die als Qt-Ressource unter :/shaders/<name>.qsb eingebettet wird.

Zur Laufzeit lädt man dann einfach dieses Paket, und QRhi greift sich beim Erzeugen der Pipeline die Variante, die sein aktives Backend braucht. Beim Bauen sieht man das schön im CMake-Log:

1
2
3
shaders/phong.vert -> phong.vert.qsb exposed as :/shaders/phong.vert.qsb
shaders/instanced.vert -> instanced.vert.qsb exposed as :/shaders/instanced.vert.qsb
...

Die drei Optionen bedeuten: BATCHABLE erzeugt zusätzlich eine für Qt Quicks Batching umgeschriebene Variante (für dieses Beispiel eigentlich unnötig, schadet aber nicht), OPTIMIZED lässt den SPIR-V-Optimizer laufen, und PRECOMPILE legt für D3D und Metal auch gleich vorkompilierte Binärformen ins Paket, damit beim ersten Frame nicht noch der Shadercompiler des Treibers anspringt.

Ein angenehmer Nebeneffekt: Shaderfehler fallen beim Bauen auf und nicht erst, wenn der Anwender das Programm startet.

Schritt-für-Schritt erklärt

Die Verzeichnisstruktur ist bewusst parallel zur OpenGL-Variante gehalten:

1
2
3
4
5
6
7
8
9
10
11
src/main.cpp                 Programmstart
src/RhiWidget.h/.cpp         ersetzt GLWidget
src/Pipeline.h/.cpp          ersetzt ShaderProgram
src/Uniforms.h               neu: CPU-Abbild der Uniform-Blöcke
src/Vertex.h                 VertexVNC, unverändert
src/CubeMesh.h/.cpp          Würfelgeometrie, unverändert
src/CubeObject.h/.cpp        GPU-Puffer der Würfel
src/SphereMeshInstanced.*    Ikosphere-Generator, unverändert
src/SphereObject.h/.cpp      GPU-Puffer der Kugeln (instanziert)
src/GridObject.h/.cpp        Bodengitter
shaders/*.vert, *.frag       GLSL 440

main.cpp — kein QSurfaceFormat mehr

Die main() ist fast identisch zur OpenGL-Variante, mit einer bemerkenswerten Auslassung:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main(int argc, char *argv[]) {
	QApplication app(argc, argv);

	// No QSurfaceFormat setup here — unlike QOpenGLWidget, QRhiWidget does not need
	// (or want) a hand-picked GL version or depth buffer size: the backend and the
	// depth-stencil buffer are QRhi's business. Set an explicit backend with
	// QRhiWidget::setApi() if you want to pin one; by default Qt picks the platform
	// default (OpenGL on Linux, Metal on macOS, D3D11 on Windows), and the
	// QSG_RHI_BACKEND environment variable overrides it.

	unsigned int meshLevel = 3;
	SphereMeshInstanced::setSubdivisionLevel(meshLevel);

	QMainWindow window;
	window.setWindowTitle(QString("Qt + QRhi: Kugeln + Mesh Level %1 ").arg(meshLevel));
	window.resize(1400, 1000);

	RhiWidget *rhiWidget = new RhiWidget(&window);
	window.setCentralWidget(rhiWidget);
	window.show();

	return app.exec();
}

Der ganze Block

1
2
3
4
5
QSurfaceFormat format;
format.setVersion(3, 3);
format.setProfile(QSurfaceFormat::CoreProfile);
format.setDepthBufferSize(24);
QSurfaceFormat::setDefaultFormat(format);

fällt ersatzlos weg. Es gibt keine GL-Version mehr auszuhandeln, und den Tiefenpuffer legt QRhiWidget selbst an. Will man ein bestimmtes Backend erzwingen, ruft man rhiWidget->setApi(QRhiWidget::Api::Vulkan) auf – oder man setzt zum Ausprobieren einfach die Umgebungsvariable QSG_RHI_BACKEND=vulkan (bzw. opengl, d3d11, d3d12, metal). Sehr praktisch, um zu testen, ob die eigene Szene wirklich überall gleich aussieht.

Wichtig ist noch die Reihenfolge: SphereMeshInstanced::setSubdivisionLevel() muss vor der ersten Kugel aufgerufen werden, denn die Einheitskugel wird beim ersten Zugriff gebaut und danach in statischen Vektoren gecacht.

RhiWidget — der Ersatz für QOpenGLWidget

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class RhiWidget : public QRhiWidget {
	Q_OBJECT
public:
	explicit RhiWidget(QWidget *parent = nullptr);

protected:
	void initialize(QRhiCommandBuffer *cb) override;
	void render(QRhiCommandBuffer *cb) override;
	void releaseResources() override;

private slots:
	void animate();

private:
	void createPipelines();
	void reportFrameTime(QRhiCommandBuffer *cb);

	QRhi                     *m_rhi              = nullptr;
	QRhiRenderPassDescriptor *m_rp               = nullptr;
	int                       m_sampleCount      = 0;
	bool                      m_sceneInitialized = false;

	Pipeline     m_spherePipeline;
	Pipeline     m_cubePipeline;
	Pipeline     m_gridPipeline;
	CubeObject   m_cube;
	SphereObject m_spheres;
	GridObject   m_grid;

	QMatrix4x4 m_projection;
	float      m_cameraAngle = 0.0f;
	QTimer     m_timer;
};

Statt protected QOpenGLFunctions_3_3_Core – der Vererbung, über die man in der OpenGL-Variante an die gl*-Funktionen kam – gibt es hier gar keine Basisklasse mit Funktionen mehr. Der Zugriff auf die Hardware läuft ausschließlich über den QRhi*, den man sich mit rhi() holt, und über den QRhiCommandBuffer*, den initialize() und render() übergeben bekommen.

Neu sind die drei gemerkten Zustände m_rhi, m_rp und m_sampleCount (=1 beim normalen Rendertarget, > 1 bei Multisampling Antialiasing). Die braucht man, um in initialize() zu erkennen, was sich geändert hat.

initialize() — dreistufig statt einmalig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void RhiWidget::initialize(QRhiCommandBuffer *cb) {
	// initialize() runs on every resize and after a device loss, so everything that
	// depends on the QRhi or the render pass has to be rebuilt when either changed.
	if (m_rhi != rhi()) {
		m_spherePipeline.destroy();
		m_cubePipeline.destroy();
		m_gridPipeline.destroy();
		m_cube.releaseResources();
		m_spheres.releaseResources();
		m_grid.releaseResources();
		m_rhi = rhi();
		m_rp  = nullptr;
		m_sceneInitialized = false;
		qDebug("QRhi backend: %s", m_rhi->backendName());
	}

	if (m_rp != renderTarget()->renderPassDescriptor() || m_sampleCount != renderTarget()->sampleCount()) {
		m_rp          = renderTarget()->renderPassDescriptor();
		m_sampleCount = renderTarget()->sampleCount();
		createPipelines();
	}

	if (!m_sceneInitialized) {
		// One batch for all static uploads, submitted right away via resourceUpdate().
		QRhiResourceUpdateBatch *batch = m_rhi->nextResourceUpdateBatch();
		if (!m_cube.initialize(m_rhi, batch) || !m_spheres.initialize(m_rhi, batch)
			|| !m_grid.initialize(m_rhi, batch))
			qFatal("Could not create the scene GPU buffers");
		cb->resourceUpdate(batch);
		m_sceneInitialized = true;
	}

	// There is no resizeGL(); the projection is recomputed here instead.
	// clipSpaceCorrMatrix() adapts the OpenGL-style projection to the depth range and
	// Y direction of whichever backend is in use — without it the scene renders upside
	// down and z-clipped on Vulkan, Metal and D3D.
	const QSize sz = renderTarget()->pixelSize();
	m_projection = m_rhi->clipSpaceCorrMatrix();
	m_projection.perspective(45.0f, float(sz.width()) / float(sz.height() ? sz.height() : 1), 0.1f, 500.0f);
}

Das ist die zentrale Stelle, an der man sich beim Portieren am ehesten in den Fuß schießt. Deshalb die drei Blöcke einzeln:

Block 1 – hat sich der QRhi geändert? Das passiert beim allerersten Aufruf (m_rhi ist nullptr) und nach einem Device Loss. In dem Fall sind sämtliche GPU-Objekte, die man je erzeugt hat, ungültig; sie müssen weg, und alles wird von vorn gebaut. Das explizite m_rp = nullptr sorgt dafür, dass auch Block 2 danach zwingend anschlägt.

Block 2 – hat sich der Render-Pass oder der Sample-Count geändert? Eine QRhiGraphicsPipeline ist an einen konkreten QRhiRenderPassDescriptor gebunden – also an die Beschreibung von Format und Anzahl der Rendertarget-Anhänge. Ändert sich der (etwa weil das Widget Multisampling bekommt), sind alle Pipelines ungültig und müssen neu gebaut werden. Beim reinen Ändern der Fenstergröße bleibt der Descriptor übrigens gleich, sodass dieser Block dann nicht anschlägt – man baut also nicht bei jedem Resize alle Shader neu.

Block 3 – sind die Geometriepuffer schon da? Die Vertex- und Indexpuffer der Szene hängen nur am QRhi, nicht am Render-Pass. Sie werden deswegen mit einem eigenen Flag verwaltet. Bemerkenswert ist der Umgang mit dem QRhiResourceUpdateBatch: Alle drei Objekte tragen ihre Uploads in einen gemeinsamen Stapel ein, der dann mit cb->resourceUpdate(batch) sofort abgeschickt wird. In render() machen wir es gleich anders herum und übergeben den Stapel an beginPass().

Und zum Schluss die Projektion. Da es kein resizeGL() gibt, wird sie hier berechnet – initialize() wird ja bei jeder Größenänderung erneut gerufen, das passt also genau. Die eigentliche Pointe ist die Zeile

1
m_projection = m_rhi->clipSpaceCorrMatrix();

Die verschiedenen Grafik-APIs sind sich nämlich nicht einig, wie der Clip-Space aussieht: OpenGL hat den Tiefenbereich -1…+1 und die Y-Achse zeigt nach oben, Vulkan hat 0…1 und die Y-Achse zeigt nach unten, D3D und Metal haben 0…1 mit Y nach oben. clipSpaceCorrMatrix() liefert genau die Korrekturmatrix des aktiven Backends; multipliziert man die vor die klassische OpenGL-Projektionsmatrix, stimmt das Ergebnis überall. Lässt man sie weg, sieht die Szene unter OpenGL richtig aus – und steht unter Vulkan auf dem Kopf, mit halb weggeclipptem Tiefenbereich. Das ist ein Fehler, den man garantiert genau einmal macht :-)

createPipelines()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void RhiWidget::createPipelines() {
	// Sphere pipeline — instanced Phong; the vertex layout (shared unit sphere at
	// binding 0, per-instance data at binding 1) comes from SphereObject.
	m_spherePipeline.setShaders(":/shaders/instanced.vert.qsb", ":/shaders/phong.frag.qsb");
	m_spherePipeline.setVertexInputLayout(SphereObject::vertexInputLayout());
	m_spherePipeline.setUniformBlockSize(sizeof(PhongUniforms));
	if (!m_spherePipeline.create(m_rhi, m_rp, m_sampleCount))
		qFatal("Could not create the sphere pipeline");

	// Cube pipeline — always Phong, VertexVNC layout.
	m_cubePipeline.setShaders(":/shaders/phong.vert.qsb", ":/shaders/phong.frag.qsb");
	m_cubePipeline.setVertexInputLayout(CubeObject::vertexInputLayout());
	m_cubePipeline.setUniformBlockSize(sizeof(PhongUniforms));
	if (!m_cubePipeline.create(m_rhi, m_rp, m_sampleCount))
		qFatal("Could not create the cube pipeline");

	// Grid pipeline — line topology instead of triangles.
	m_gridPipeline.setShaders(":/shaders/grid.vert.qsb", ":/shaders/grid.frag.qsb");
	m_gridPipeline.setVertexInputLayout(GridObject::vertexInputLayout());
	m_gridPipeline.setUniformBlockSize(sizeof(GridUniforms));
	m_gridPipeline.setTopology(QRhiGraphicsPipeline::Lines);
	if (!m_gridPipeline.create(m_rhi, m_rp, m_sampleCount))
		qFatal("Could not create the grid pipeline");
}

Hier sieht man die „Pipeline = Kombination”-Regel in Aktion. Kugeln und Würfel benutzen denselben Fragmentshader phong.frag, aber unterschiedliche Vertexshader und unterschiedliche Vertexlayouts – also zwei Pipelines. Das Gitter hat einen anderen Uniform-Block (GridUniforms statt PhongUniforms) und eine andere Topologie (Lines statt Triangles) – also eine dritte.

In der OpenGL-Variante stand an dieser Stelle nur das Laden der Shaderprogramme; die Topologie war ein Argument von glDrawArrays(), und das Vertexlayout steckte im VAO, das man erst später beim Zeichnen band.

render() — Resource-Update-Batch und Render-Pass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
void RhiWidget::render(QRhiCommandBuffer *cb) {
	reportFrameTime(cb);

	// All buffer writes for this frame are collected in one batch and handed to
	// beginPass(), which is where QRhi actually performs them.
	QRhiResourceUpdateBatch *batch = m_rhi->nextResourceUpdateBatch();

	// schräg von vorne oben auf die x-y-Ebene
	// (positives y zeigt in den Bildschirm, positives x nach rechts)
	const float eyeRad = qDegreesToRadians(m_cameraAngle);
	const QVector3D eye(25.0f*qCos(eyeRad), 25*qSin(eyeRad), 15.0f);

	QMatrix4x4 view;
	view.lookAt(eye, {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 1.0f});

	// light leicht von oben links
	const QVector3D lightPos(-2.f, -20.f, 34.0f);

	// --- Spheres --------------------------------------------------------------
	QMatrix4x4 sphereModel;
	PhongUniforms sphereUbo;
	writeMat4(sphereUbo.mvp, m_projection * view * sphereModel);
	writeMat4(sphereUbo.model, sphereModel);
	writeNormalMat4(sphereUbo.normalMatrix, sphereModel.normalMatrix());
	writeVec3(sphereUbo.lightPos, lightPos);
	writeVec3(sphereUbo.viewPos, eye);
	m_spherePipeline.updateUniforms(batch, &sphereUbo, sizeof(sphereUbo));
	m_spheres.updateBuffers(batch);

	// ... (Würfel und Gitter analog)

	// --- Record the pass ------------------------------------------------------
	// beginPass() rolls the clear color and the depth clear into the pass itself,
	// replacing glClearColor()/glClear().
	const QSize outputSize = renderTarget()->pixelSize();
	cb->beginPass(renderTarget(), BackgroundColor, { 1.0f, 0 }, batch);
	cb->setViewport({ 0, 0, float(outputSize.width()), float(outputSize.height()) });

	m_cubePipeline.bind(cb);
	m_cube.draw(cb);

	m_spherePipeline.bind(cb);
	m_spheres.draw(cb);

	m_gridPipeline.bind(cb);
	m_grid.draw(cb);

	cb->endPass();
}

Die Struktur eines QRhi-Frames ist immer dieselbe und man sollte sie nicht durcheinanderbringen:

  1. Einen QRhiResourceUpdateBatch holen.
  2. Außerhalb des Render-Passes alles eintragen, was in diesem Frame hochgeladen werden soll: die Uniform-Blöcke der drei Pipelines und den Instanzpuffer der Kugeln.
  3. beginPass() – hier wird der Stapel abgearbeitet, und gleichzeitig werden Farb- und Tiefenpuffer geleert.
  4. Für jedes Objekt: Pipeline binden, Puffer binden, Draw-Call aufzeichnen.
  5. endPass().

Punkt 2 ist die harte Regel: Innerhalb eines Passes darf man keine Puffer beschreiben. Wer aus alter GL-Gewohnheit mitten zwischen zwei Draw-Calls noch schnell eine Uniform ändern will, muss stattdessen entweder alles vorher in getrennte Puffer schreiben (so wie hier – jede Pipeline hat ihren eigenen Uniform-Buffer, deswegen können Würfel und Kugeln unterschiedliche Modellmatrizen haben) oder mit dynamischen Offsets in einem größeren Puffer arbeiten.

Der Vergleich zu GLWidget::paintGL() ist lehrreich: dort stand für jede Uniform ein eigener setUniformValue()-Aufruf, der sofort einen GL-Call absetzte, und das Leeren des Bildschirms war ein separates glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT). Hier ist die Clear-Farbe schlicht ein Argument von beginPass(), und der Tiefen-Clear-Wert { 1.0f, 0 } (Tiefe 1.0, Stencil 0) gleich mit.

Frame-Messung und Aufräumen

Die OpenGL-Variante hat die Frame-Zeit mit dreifach gepufferten GL_TIME_ELAPSED-Queries gemessen. In QRhi gibt es dafür QRhiCommandBuffer::lastCompletedGpuTime() – allerdings mit einem Haken:

1
2
3
4
5
6
7
8
9
void RhiWidget::reportFrameTime(QRhiCommandBuffer *cb) {
	// The OpenGL version wrapped each frame in a triple-buffered GL_TIME_ELAPSED
	// query. QRhi offers the same thing through lastCompletedGpuTime(), but only if
	// the QRhi was created with QRhi::EnableTimestamps — and QRhiWidget creates it
	// internally without that flag. So take the GPU number when it is there and
	// measure CPU frame time regardless.
	const double gpuMs = cb->lastCompletedGpuTime() * 1000.0;
	...
}

Da QRhiWidget den QRhi selbst und ohne QRhi::EnableTimestamps anlegt, kommt hier meist 0 zurück; deshalb der Rückfall auf eine schlichte QElapsedTimer-Messung zwischen den render()-Aufrufen. Wer echte GPU-Zeiten braucht, muss sich den QRhi selbst bauen (also ohne QRhiWidget arbeiten) oder zu einem externen Profiler wie RenderDoc greifen.

releaseResources() ist schließlich das Spiegelbild von Block 1 aus initialize(): alle Pipelines zerstören, alle Objektpuffer freigeben, Zeiger auf nullptr. Qt ruft das auf, bevor der QRhi verschwindet. Das ist der Punkt, an dem sich QRhi angenehm von OpenGL unterscheidet – man muss nicht mehr händisch dafür sorgen, dass beim Aufräumen der richtige Kontext aktuell ist (makeCurrent()/doneCurrent()), es reicht, die Objekte in releaseResources() fallenzulassen.

Pipeline — was aus QOpenGLShaderProgram wurde

Die OpenGL-Variante hatte eine schmale Hüllklasse ShaderProgram um QOpenGLShaderProgram. Das Gegenstück heißt hier Pipeline und bündelt die drei Objekte, die zusammengehören: den Uniform-Buffer, die Shader-Resource-Bindings und die Pipeline selbst.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Pipeline {
public:
	void setShaders(const QString &vertexQsb, const QString &fragmentQsb);
	void setVertexInputLayout(const QRhiVertexInputLayout &layout) { m_inputLayout = layout; }
	void setUniformBlockSize(quint32 bytes) { m_ubufSize = bytes; }
	void setTopology(QRhiGraphicsPipeline::Topology t) { m_topology = t; }

	bool create(QRhi *rhi, QRhiRenderPassDescriptor *rp, int sampleCount);
	void destroy();

	void updateUniforms(QRhiResourceUpdateBatch *batch, const void *data, quint32 size);
	void bind(QRhiCommandBuffer *cb);

private:
	QString m_vertexQsb;
	QString m_fragmentQsb;

	QRhiVertexInputLayout          m_inputLayout;
	quint32                        m_ubufSize = 0;
	QRhiGraphicsPipeline::Topology m_topology = QRhiGraphicsPipeline::Triangles;

	std::unique_ptr<QRhiBuffer>                 m_ubuf;
	std::unique_ptr<QRhiShaderResourceBindings> m_srb;
	std::unique_ptr<QRhiGraphicsPipeline>       m_pipeline;
};

Das Laden der Shader ist erfreulich kurz, weil das .qsb-Paket ja schon alles Nötige enthält:

1
2
3
4
5
6
7
8
9
10
11
12
13
/*! Loads a shader pack produced by the qsb tool. This is the getShader() helper
	from the Qt "RHI Window" example: a .qsb file holds the same shader in several
	flavours (SPIR-V, GLSL, HLSL, MSL) and QRhi picks the one its backend needs.
*/
static QShader loadShader(const QString &path) {
	QFile f(path);
	if (!f.open(QIODevice::ReadOnly))
		qFatal("Could not open shader file: %s", qPrintable(path));
	const QShader s = QShader::fromSerialized(f.readAll());
	if (!s.isValid())
		qFatal("Invalid shader file: %s", qPrintable(path));
	return s;
}

Und hier das Herzstück, Pipeline::create():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
bool Pipeline::create(QRhi *rhi, QRhiRenderPassDescriptor *rp, int sampleCount) {
	Q_ASSERT(m_ubufSize > 0);

	// Dynamic: rewritten every frame, which is exactly what uniform buffers are for.
	// QRhi transparently double-buffers these so writing does not stall the pipeline.
	m_ubuf.reset(rhi->newBuffer(QRhiBuffer::Dynamic, QRhiBuffer::UniformBuffer, m_ubufSize));
	if (!m_ubuf->create())
		return false;

	// All shaders in use declare the uniform block in both stages, so it is bound
	// to both — a stage that does not declare the block must not have it bound.
	m_srb.reset(rhi->newShaderResourceBindings());
	m_srb->setBindings({
		QRhiShaderResourceBinding::uniformBuffer(0,
			QRhiShaderResourceBinding::VertexStage | QRhiShaderResourceBinding::FragmentStage,
			m_ubuf.get())
	});
	if (!m_srb->create())
		return false;

	m_pipeline.reset(rhi->newGraphicsPipeline());
	m_pipeline->setShaderStages({
		{ QRhiShaderStage::Vertex,   loadShader(m_vertexQsb)   },
		{ QRhiShaderStage::Fragment, loadShader(m_fragmentQsb) }
	});
	m_pipeline->setVertexInputLayout(m_inputLayout);
	m_pipeline->setShaderResourceBindings(m_srb.get());
	m_pipeline->setRenderPassDescriptor(rp);
	m_pipeline->setSampleCount(sampleCount);
	m_pipeline->setTopology(m_topology);

	// glEnable(GL_DEPTH_TEST) + the default glDepthFunc(GL_LESS) of the OpenGL version.
	m_pipeline->setDepthTest(true);
	m_pipeline->setDepthWrite(true);

	// The OpenGL version never calls glEnable(GL_CULL_FACE), so no culling here either.
	m_pipeline->setCullMode(QRhiGraphicsPipeline::None);

	return m_pipeline->create();
}

Die Reihenfolge ist zwingend: erst der Uniform-Buffer, dann die QRhiShaderResourceBindings (die auf den Buffer zeigen), dann die Pipeline (die auf die Bindings zeigt). Jedes dieser Objekte wird mit newXxx() erzeugt und muss anschließend mit create() scharfgeschaltet werden – ein Muster, das sich durch ganz QRhi zieht.

Der Puffertyp QRhiBuffer::Dynamic bedeutet: Der Inhalt ändert sich jeden Frame. QRhi legt solche Puffer intern mehrfach an und rotiert durch, sodass das Schreiben für Frame N+1 nicht darauf warten muss, dass die GPU mit Frame N fertig ist. Das ist genau die Doppelpufferung, die man in OpenGL mit GL_DYNAMIC_DRAW und viel Hoffnung dem Treiber überlassen hat.

Bemerkenswert klein sind dagegen die beiden Funktionen, die pro Frame laufen:

1
2
3
4
5
6
7
8
9
10
11
void Pipeline::updateUniforms(QRhiResourceUpdateBatch *batch, const void *data, quint32 size) {
	Q_ASSERT(size == m_ubufSize);
	batch->updateDynamicBuffer(m_ubuf.get(), 0, size, data);
}


void Pipeline::bind(QRhiCommandBuffer *cb) {
	cb->setGraphicsPipeline(m_pipeline.get());
	// Without arguments this rebinds the pipeline's own QRhiShaderResourceBindings.
	cb->setShaderResources();
}

bind() ist das Gegenstück zu glUseProgram() – und es ist tatsächlich alles, was nötig ist, weil Tiefentest, Culling, Topologie und Vertexlayout ja schon in der Pipeline stecken.

Uniforms.h — es gibt kein glUniform3f() mehr

Das ist der Punkt, an dem OpenGL-Umsteiger am ehesten stolpern. In der OpenGL-Variante schrieb man einfach:

1
2
m_sphereShaderProgram.setUniformValue("mvp", m_projection * view * model);
m_sphereShaderProgram.setUniformValue("lightPos", lightPos);

Qt hat den Namen im Shaderprogramm nachgeschlagen und den passenden glUniform*()-Aufruf abgesetzt. In QRhi gibt es das schlicht nicht. Alle Uniforms leben in einem Uniform-Buffer, der als ein einziger Speicherblock hochgeladen wird – und man muss ihn auf C++-Seite Byte für Byte so nachbauen, wie der Shader ihn erwartet.

Deshalb gibt es die neue Datei Uniforms.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*! Uniform block shared by phong.vert/frag and instanced.vert. */
struct PhongUniforms {
	float mvp[16];          // offset   0
	float model[16];        // offset  64
	float normalMatrix[16]; // offset 128
	float lightPos[4];      // offset 192
	float viewPos[4];       // offset 208
};                          // total  224
static_assert(sizeof(PhongUniforms) == 224, "PhongUniforms must match the std140 block");


/*! Uniform block used by grid.vert/frag. */
struct GridUniforms {
	float mvp[16];      // offset  0
	float gridColor[4]; // offset 64
	float backColor[4]; // offset 80
};                      // total  96
static_assert(sizeof(GridUniforms) == 96, "GridUniforms must match the std140 block");

Das Speicherlayout folgt der GLSL-Regel std140, und davon muss man drei Dinge kennen:

  • Eine mat4 belegt 64 Byte, spaltenweise – das ist exakt das, was QMatrix4x4::constData() liefert. Deshalb ist writeMat4() nur ein memcpy.
  • Ein vec3 ist auf 16 Byte ausgerichtet und wird auf 16 Byte aufgefüllt. Es belegt also genauso viel wie ein vec4. Deswegen sind alle vec3-Uniforms in den Shadern gleich als vec4 deklariert und auf C++-Seite als float[4] – dann ist das Padding sichtbar statt implizit, und niemand rätselt später, warum die Offsets nicht aufgehen.
  • Die Normalmatrix wird als mat4 übertragen, obwohl sie eine mat3 ist. Grund: eine std140-mat3 besteht aus drei je 16 Byte ausgerichteten Spalten, also 48 Byte mit Löchern – da kann man QMatrix3x3 (neun dicht gepackte Floats) nicht hineinkopieren. Als mat4 mit Identitäts-Rand ist es einfacher und im Shader steht dann mat3(u.normalMatrix).

Die Hilfsfunktionen dazu sind trivial, aber sie sind der einzige Ort, an dem man sich vertun kann:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*! Fills a float[16] from a QMatrix4x4 (column-major, matches std140 mat4). */
inline void writeMat4(float dst[16], const QMatrix4x4 &m) {
	std::memcpy(dst, m.constData(), 16 * sizeof(float));
}

/*! Expands a 3x3 normal matrix into the upper-left block of a mat4. */
inline void writeNormalMat4(float dst[16], const QMatrix3x3 &n) {
	QMatrix4x4 m; // identity
	for (int row = 0; row < 3; ++row)
		for (int col = 0; col < 3; ++col)
			m(row, col) = n(row, col);
	writeMat4(dst, m);
}

/*! Fills a float[4] from a QVector3D, w is set to 1. */
inline void writeVec3(float dst[4], const QVector3D &v) {
	dst[0] = v.x();
	dst[1] = v.y();
	dst[2] = v.z();
	dst[3] = 1.0f;
}

Die static_asserts in den Strukturen sind kein Zierat. Wenn man später ein Feld einfügt und den Shader anpasst, aber die Größe nicht mehr stimmt, fällt das beim Kompilieren auf – andernfalls bekäme man einen stillen Versatz aller nachfolgenden Werte, und die Suche danach kann einen ganzen Abend kosten.

Und noch eine Regel, die dazugehört: Der Uniform-Block muss in Vertex- und Fragmentshader identisch deklariert sein. Auch dann, wenn eine Stufe nur die Hälfte der Felder benutzt. phong.frag braucht die mvp-Matrix überhaupt nicht, deklariert sie aber trotzdem, weil sonst die Blocklayouts nicht mehr zusammenpassen.

Vertex.h und CubeMesh — fast unverändert übernommen

Erfreulich unspektakulär: VertexVNC mit seinen 36 Byte (Position, Normale, Farbe als je drei Floats) bleibt genau so, wie es war. Der einzige Unterschied ist, dass es keinen GLuint mehr gibt:

1
2
3
4
5
6
7
8
9
10
11
12
/*! Index type used by all element buffers — the QRhi counterpart of GL_UNSIGNED_INT
	is QRhiCommandBuffer::IndexUInt32.
*/
using Index = quint32;

struct VertexVNC {
	float v[3];    // position — offset 0
	float m[3];    // normal   — offsetof(VertexVNC, m) = 12
	float r, g, b; // color    — offsetof(VertexVNC, r) = 24
	...
};
static_assert(sizeof(VertexVNC) == 36, "VertexVNC must be tightly packed, the vertex input layout assumes stride 36");

Auch CubeMesh ist unverändert. Die Klasse füllt mit copy2Buffer() vom Aufrufer bereitgestellte Puffer, wobei die Zeiger per Referenz übergeben und weitergeschoben werden – so schreiben mehrere Meshes hintereinander in einen einzigen großen Speicherblock, und elementStartIndex verschiebt jeweils die Indexbasis. Dieses Muster ist unabhängig von der Grafik-API und funktioniert unter QRhi genauso.

CubeObject — Immutable-Puffer statt glBufferData()

Interessant wird es bei der Frage, wie die Geometrie auf die GPU kommt. Zuerst das Vertexlayout:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
QRhiVertexInputLayout CubeObject::vertexInputLayout() {
	// One interleaved buffer at binding 0, advancing once per vertex.
	// location 0 = position, 1 = color, 2 = normal — same as the OpenGL version.
	QRhiVertexInputLayout layout;
	layout.setBindings({
		{ sizeof(VertexVNC) }
	});
	layout.setAttributes({
		{ 0, 0, QRhiVertexInputAttribute::Float3, offsetof(VertexVNC, v) },
		{ 0, 1, QRhiVertexInputAttribute::Float3, offsetof(VertexVNC, r) },
		{ 0, 2, QRhiVertexInputAttribute::Float3, offsetof(VertexVNC, m) }
	});
	return layout;
}

Das ist die deklarative Fassung dessen, was in der OpenGL-Variante als Folge von Aufrufen im VAO stand:

1
2
3
4
5
6
7
// OpenGL-Variante:
program.enableAttributeArray(0);
program.setAttributeBuffer(0, GL_FLOAT, 0,                       3, sizeof(VertexVNC));
program.enableAttributeArray(1);
program.setAttributeBuffer(1, GL_FLOAT, offsetof(VertexVNC, r),  3, sizeof(VertexVNC));
program.enableAttributeArray(2);
program.setAttributeBuffer(2, GL_FLOAT, offsetof(VertexVNC, m),  3, sizeof(VertexVNC));

Die Aufteilung ist dabei sauberer als in OpenGL: Ein Binding beschreibt einen Puffer und trägt den stride; ein Attribut trägt (Binding-Index, Location, Datentyp, offset). Und – das ist der eigentliche Unterschied – dieses Layout ist reine Beschreibung. Es gibt kein VAO, das man erzeugen, binden und wieder freigeben müsste; das Layout wandert in die Pipeline und wird dort einmalig validiert. Deswegen ist vertexInputLayout() auch eine statische Funktion: sie gehört logisch zur Pipeline, nicht zum Objekt.

Das Anlegen und Befüllen der Puffer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bool CubeObject::initialize(QRhi *rhi, QRhiResourceUpdateBatch *batch) {
	const quint32 vbufSize = quint32(m_vertexBufferData.size() * sizeof(VertexVNC));
	const quint32 ibufSize = quint32(m_elementBufferData.size() * sizeof(Index));

	m_vbuf.reset(rhi->newBuffer(QRhiBuffer::Immutable, QRhiBuffer::VertexBuffer, vbufSize));
	if (!m_vbuf->create())
		return false;

	m_ibuf.reset(rhi->newBuffer(QRhiBuffer::Immutable, QRhiBuffer::IndexBuffer, ibufSize));
	if (!m_ibuf->create())
		return false;

	batch->uploadStaticBuffer(m_vbuf.get(), m_vertexBufferData.data());
	batch->uploadStaticBuffer(m_ibuf.get(), m_elementBufferData.data());
	return true;
}

QRhi kennt drei Puffertypen, und die Wahl ist keine Kosmetik:

Typ Bedeutung im Beispiel
Immutable einmal befüllt, danach unverändert Würfel, Gitter, Einheitskugel, Indexpuffer
Static selten geändert hier nicht benutzt
Dynamic jeden Frame beschrieben Uniform-Buffer, Kugel-Instanzpuffer

Immutable erlaubt es dem Backend, den Puffer in schnellen, CPU-unzugänglichen Grafikspeicher zu legen; Dynamic wird dagegen intern mehrfach vorgehalten. In OpenGL entsprach das ungefähr dem Unterschied zwischen GL_STATIC_DRAW und GL_DYNAMIC_DRAW – nur dass der Treiber diese Hinweise gerne mal ignoriert hat.

Das Zeichnen schließlich:

1
2
3
4
5
void CubeObject::draw(QRhiCommandBuffer *cb) {
	const QRhiCommandBuffer::VertexInput vbufBinding(m_vbuf.get(), 0);
	cb->setVertexInput(0, 1, &vbufBinding, m_ibuf.get(), 0, QRhiCommandBuffer::IndexUInt32);
	cb->drawIndexed(quint32(m_elementBufferData.size()));
}

setVertexInput() bindet Vertex- und Indexpuffer in einem Aufruf – das ersetzt m_vao.bind() samt der Puffer-Bindings darin. drawIndexed() ist das Gegenstück zu glDrawElements().

SphereMeshInstanced — die Einheitskugel

Die Erzeugung der Ikosphere ist vollständig unverändert aus der OpenGL-Variante übernommen und im Vorgängerbeitrag ausführlich beschrieben. Kurz zusammengefasst: Ein Ikosaeder mit 12 Ecken und 20 Dreiecken wird iterativ verfeinert, indem jedes Dreieck durch vier ersetzt wird; die neuen Kantenmittelpunkte werden normalisiert und landen damit auf der Kugeloberfläche. Bei Verfeinerungsstufe 3 ergibt das 642 Vertexe und 3840 Indexe.

Wichtig für alles Weitere ist nur eine Eigenschaft: Auf der Einheitskugel gilt Normale = Position. Ein einziger vec3 pro Vertex reicht deshalb aus, um beides zu beschreiben. Und die Daten pro Kugel sind winzig:

1
2
3
4
5
6
7
/*! Per-instance payload written into the instance VBO. */
struct InstanceData {
	float center[3]; // 12 bytes
	float radius;    //  4 bytes
	float color[3];  // 12 bytes
					 // total 28 bytes
};

28 Byte pro Kugel. Das ist die Zahl, um die es beim instanzierten Rendern geht – zum Vergleich: die „gebackene” Variante brauchte pro Kugel 37,6 KB, die bei jeder Bewegung neu hochgeladen werden mussten.

SphereObject — instanziertes Rendern ohne glVertexAttribDivisor()

Und damit zum eigentlichen Grund, warum ich das Beispiel überhaupt portiert habe. In OpenGL musste man die Attribute nach dem Anlegen explizit als „pro Instanz” markieren:

1
2
3
4
// OpenGL-Variante:
glVertexAttribDivisor(1, 1);
glVertexAttribDivisor(2, 1);
glVertexAttribDivisor(3, 1);

In QRhi ist das keine Anweisung mehr, sondern eine Eigenschaft des Bindings:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
QRhiVertexInputLayout SphereObject::vertexInputLayout() {
	QRhiVertexInputLayout layout;
	// binding 0: the shared unit sphere, one vec3 per vertex (position == smooth normal)
	// binding 1: the instance data, advanced once per instance instead of per vertex —
	//            this is the QRhi replacement for glVertexAttribDivisor(loc, 1)
	layout.setBindings({
		{ sizeof(QVector3D) },
		{ sizeof(SphereMeshInstanced::InstanceData), QRhiVertexInputBinding::PerInstance }
	});
	layout.setAttributes({
		{ 0, 0, QRhiVertexInputAttribute::Float3,  0 }, // aUnitPos (vec3,  offset  0)
		{ 1, 1, QRhiVertexInputAttribute::Float3,  0 }, // iCenter  (vec3,  offset  0)
		{ 1, 2, QRhiVertexInputAttribute::Float,  12 }, // iRadius  (float, offset 12)
		{ 1, 3, QRhiVertexInputAttribute::Float3, 16 }  // iColor   (vec3,  offset 16)
	});
	return layout;
}

Das gefällt mir deutlich besser als die OpenGL-Fassung. Es gibt jetzt zwei Bindings, also zwei Vertexpuffer: Binding 0 mit der gemeinsamen Einheitskugel (12 Byte pro Vertex, wird pro Vertex weitergezählt) und Binding 1 mit den Instanzdaten (28 Byte pro Kugel, wird nur einmal pro Instanz weitergezählt). Die Klassifikation PerInstance steht direkt am Binding, wo sie hingehört, statt als nachgereichter Zustandsaufruf pro Attribut. Und weil das Ganze Teil der Pipeline ist, kann man es zur Laufzeit nicht mehr versehentlich verstellen.

Die drei Puffer werden entsprechend ihrer Lebensdauer angelegt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
bool SphereObject::initialize(QRhi *rhi, QRhiResourceUpdateBatch *batch) {
	// --- Shared unit sphere vertex buffer (binding 0: aUnitPos) ----------------
	const auto &unitPos = SphereMeshInstanced::unitPositions();
	m_unitVbuf.reset(rhi->newBuffer(QRhiBuffer::Immutable, QRhiBuffer::VertexBuffer,
									quint32(unitPos.size() * sizeof(QVector3D))));
	if (!m_unitVbuf->create())
		return false;
	batch->uploadStaticBuffer(m_unitVbuf.get(), unitPos.data());

	// --- Shared index buffer with unit sphere indices --------------------------
	const auto &indices = SphereMeshInstanced::faceIndices();
	m_ibuf.reset(rhi->newBuffer(QRhiBuffer::Immutable, QRhiBuffer::IndexBuffer,
								quint32(indices.size() * sizeof(Index))));
	if (!m_ibuf->create())
		return false;
	batch->uploadStaticBuffer(m_ibuf.get(), indices.data());

	// --- Per-instance vertex buffer (binding 1) -------------------------------
	// Rewritten every frame, so Dynamic; at 28 bytes per sphere this stays small.
	m_instanceVbuf.reset(rhi->newBuffer(QRhiBuffer::Dynamic, QRhiBuffer::VertexBuffer,
										quint32(m_instanceData.size() * sizeof(SphereMeshInstanced::InstanceData))));
	if (!m_instanceVbuf->create())
		return false;

	return true;
}

Einheitskugel und Indexpuffer sind Immutable und werden genau einmal hochgeladen. Nur der Instanzpuffer ist Dynamic – und der wird pro Frame beschrieben:

1
2
3
4
5
6
7
8
9
void SphereObject::updateBuffers(QRhiResourceUpdateBatch *batch) {
	// transfer updated geometry — 28 bytes per sphere
	for (int i = 0; i < m_sphereCount; ++i)
		m_spheres[i].copyInstance(m_instanceData[i]);

	batch->updateDynamicBuffer(m_instanceVbuf.get(), 0,
							   quint32(m_instanceData.size() * sizeof(SphereMeshInstanced::InstanceData)),
							   m_instanceData.data());
}

Und der Draw-Call:

1
2
3
4
5
6
7
8
void SphereObject::draw(QRhiCommandBuffer *cb) {
	const QRhiCommandBuffer::VertexInput vbufBindings[] = {
		{ m_unitVbuf.get(),     0 },
		{ m_instanceVbuf.get(), 0 }
	};
	cb->setVertexInput(0, 2, vbufBindings, m_ibuf.get(), 0, QRhiCommandBuffer::IndexUInt32);
	cb->drawIndexed(SphereMeshInstanced::IndexCount, quint32(m_sphereCount));
}

Hier werden zwei Vertexpuffer auf einmal gebunden (ab Binding 0, zwei Stück), dazu der gemeinsame Indexpuffer. Und dann genügt ein einziger drawIndexed()-Aufruf mit instanceCount als zweitem Argument, um alle Kugeln zu zeichnen. Das ist – bis auf die Schreibweise – exakt das glDrawElementsInstanced() der OpenGL-Variante:

1
2
3
4
5
OpenGL:   glDrawElements(GL_TRIANGLES, n, GL_UNSIGNED_INT, nullptr)
QRhi:     cb->drawIndexed(n)

OpenGL:   glDrawElementsInstanced(GL_TRIANGLES, n, GL_UNSIGNED_INT, nullptr, count)
QRhi:     cb->drawIndexed(n, count)

Der Speichervorteil ist derselbe wie in der OpenGL-Variante und dort in Tabellen aufgeschlüsselt: statt N × 37,6 KB liegen dauerhaft 22,5 KB gemeinsame Geometrie plus N × 28 Byte auf der GPU, und pro Frame werden auch nur diese N × 28 Byte neu übertragen.

GridObject — Linien ohne Indexpuffer

Das Bodengitter ist der kleinste der drei Zeichner und trotzdem lehrreich, weil es die beiden anderen „Achsen” der Pipeline zeigt. Es braucht eine eigene Pipeline, und zwar aus zwei unabhängigen Gründen: sein Uniform-Block ist GridUniforms (MVP plus zwei Farben) statt PhongUniforms, und seine Topologie ist Lines statt Triangles. Beides ist in die Pipeline eingebacken, also ist keine Wiederverwendung möglich.

Sein Vertexlayout ist so schlicht, wie es nur geht – ein vec2 pro Vertex, z wird im Shader als 0 ergänzt:

1
2
3
4
5
6
7
8
9
10
QRhiVertexInputLayout GridObject::vertexInputLayout() {
	QRhiVertexInputLayout layout;
	layout.setBindings({
		{ 2 * sizeof(float) } // vertex after vertex, no interleaving
	});
	layout.setAttributes({
		{ 0, 0, QRhiVertexInputAttribute::Float2, 0 } // layout(location = 0) = vec2 position
	});
	return layout;
}

Und gezeichnet wird ohne Indexpuffer, weil jede Linie aus genau zwei Vertexen besteht, die jeweils nur einmal vorkommen:

1
2
3
4
5
6
void GridObject::draw(QRhiCommandBuffer *cb) {
	const QRhiCommandBuffer::VertexInput vbufBinding(m_vbuf.get(), 0);
	cb->setVertexInput(0, 1, &vbufBinding);
	// draw the grid lines; the pipeline topology is QRhiGraphicsPipeline::Lines
	cb->draw(m_vertexCount);
}

Der Fragmentshader des Gitters hat noch einen netten kleinen Trick, damit die Linien zum Horizont hin ausblenden statt in einer harten, flimmernden Kante zu enden:

1
2
3
4
5
6
7
const float FARPLANE = 200.0;

void main() {
	float distanceFromCamera = (gl_FragCoord.z / gl_FragCoord.w) / FARPLANE;
	distanceFromCamera = clamp(distanceFromCamera, 0.0, 1.0);
	finalColor = vec4( mix(u.gridColor.xyz, u.backColor.xyz, distanceFromCamera), 1.0 );
}

gl_FragCoord.w ist 1/w_clip, also rekonstruiert gl_FragCoord.z / gl_FragCoord.w die Tiefe des Fragments in Weltkoordinaten. Geteilt durch FARPLANE ergibt das einen Mischfaktor zwischen Gitterfarbe und Hintergrundfarbe. Schön daran: das funktioniert auf allen Backends unverändert, obwohl deren Clip-Space-Tiefenbereiche verschieden sind – die Division hebt die Projektion ja wieder auf, und clipSpaceCorrMatrix() hat den Rest schon vereinheitlicht.

Die Shader

Zum Schluss der Blick auf GLSL. Hier ist der vollständige instanzierte Vertexshader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#version 440

layout(location = 0) in vec3  aUnitPos;
layout(location = 1) in vec3  iCenter;
layout(location = 2) in float iRadius;
layout(location = 3) in vec3  iColor;

layout(location = 0) out vec3 fragColor;
layout(location = 1) out vec3 fragPos;
layout(location = 2) out vec3 fragNormal;

layout(std140, binding = 0) uniform PhongUniforms {
	mat4 mvp;
	mat4 model;
	mat4 normalMatrix;
	vec4 lightPos;
	vec4 viewPos;
} u;

void main() {
	vec4 localPos = vec4(iCenter + iRadius * aUnitPos, 1.0);
	gl_Position   = u.mvp * localPos;
	fragPos       = vec3(u.model * localPos);
	// Uniform per-instance scaling does not change normal direction;
	// normalMatrix handles the overall model transform.
	fragNormal    = mat3(u.normalMatrix) * aUnitPos;
	fragColor     = iColor;
}

Die eigentliche Rechenarbeit – iCenter + iRadius * aUnitPos, eine einzige MAD-Operation – ist identisch zur OpenGL-Fassung. Die Unterschiede sind alle formaler Natur, aber man muss sie kennen, sonst übersetzt qsb gar nicht erst:

  • #version 440 statt #version 330 core. Das ist Vulkan-GLSL, nicht dasjenige, das der OpenGL-Treiber sieht. Aus dem SPIR-V generiert qsb die GL-Variante ja selbst zurück.
  • layout(location = ...) auch für die Outputs. In OpenGL wurden Varyings zwischen Vertex- und Fragmentshader über ihren Namen zugeordnet. In SPIR-V gibt es kein Name-Matching mehr – die Locations müssen auf beiden Seiten explizit übereinstimmen. Wer im Vertexshader out vec3 fragColor an Location 0 schreibt, muss im Fragmentshader in vec3 fragColor ebenfalls an Location 0 deklarieren.
  • Keine freistehenden Uniforms mehr. uniform mat4 mvp; auf oberster Ebene ist in Vulkan-GLSL schlicht verboten. Alles muss in einen layout(std140, binding = N) uniform Block { ... } u; – deswegen der Präfix u. an jeder Verwendung.
  • Auch der Ausgabewert des Fragmentshaders braucht eine Location: layout(location = 0) out vec4 outColor;.

Der Fragmentshader phong.frag ist ansonsten Zeile für Zeile derselbe wie in der OpenGL-Variante – ambient, diffus, spekular:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void main() {
	vec3 norm     = normalize(fragNormal);
	vec3 lightDir = normalize(u.lightPos.xyz - fragPos);
	vec3 viewDir  = normalize(u.viewPos.xyz - fragPos);

	vec3 ambient = 0.15 * fragColor;

	float diff   = max(dot(norm, lightDir), 0.0);
	vec3 diffuse = diff * fragColor;

	vec3 reflectDir = reflect(-lightDir, norm);
	float spec      = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
	vec3 specular   = 0.5 * spec * vec3(1.0);

	outColor = vec4(ambient + diffuse + specular, 1.0);
}

Und weil phong.vert und instanced.vert beide dieselben drei Outputs an denselben Locations liefern, lässt sich derselbe Fragmentshader unverändert von zwei Pipelines benutzen – einmal für die Würfel, einmal für die Kugeln.

Fazit

Was gewinnt man?

  • Eine Codebasis für alle Plattformen. Vulkan unter Linux, Metal unter macOS, D3D unter Windows – ohne eine einzige #ifdef-Zeile. Und mit QSG_RHI_BACKEND lässt sich das im Sekundentakt gegenprüfen.
  • Expliziter, überprüfbarer Zustand. Es gibt keinen globalen Grafikzustand mehr, den irgendein anderer Codeteil hinter dem Rücken umgestellt haben könnte. Was gezeichnet wird, steht vollständig in der Pipeline.
  • Kein Kontext-Gefummel. Kein makeCurrent(), kein Sorgentragen beim Aufräumen, keine QSurfaceFormat-Verhandlung beim Start.
  • Fehler fallen früher auf. Shader werden beim Bauen übersetzt, Layout-Größen von static_asserts geprüft, und mit den Vulkan Validation Layers findet man Unstimmigkeiten, die OpenGL-Treiber gnädig verschluckt hätten.

Was kostet es?

  • Mehr Ausgangsaufwand. Der Weg vom leeren Fenster zum ersten Dreieck ist bei QRhi spürbar länger als bei QOpenGLWidget.
  • std140 von Hand. Kein setUniformValue("name", wert) mehr; man baut Strukturen und passt selbst auf die Ausrichtungsregeln auf.
  • Halb-öffentliche API. qrhi.h kommt über Qt6::GuiPrivate und kann sich zwischen Minor-Releases ändern.
  • Shader müssen vorkompiliert werden, also braucht der Build Qt6::ShaderTools.

Für ein Wochenendprojekt, das sowieso nur auf dem eigenen Linux-Rechner läuft, ist OpenGL weiter völlig in Ordnung. Für alles, was ausgeliefert werden soll und länger leben muss als der nächste macOS-Release, würde ich heute QRhi nehmen, zumal die KI beim erstellen/prüfen der Shader hilft. Mehraufwand entsteht jedenfalls bei Anpassungen der Layouts - hier muss man an mehreren Stellen im Quelltext höllisch aufpassen, dass man beim Mapping nichts zerschießt (aber wenigstens wird das weitgehend zu Compilezeiten geprüft).

Die Implementierung ist als komplettes Beispiel herunterladbar:

Zum Vergleich lohnt sich der Blick in die OpenGL-Variante desselben Programms, die im Beitrag Icosphere (Ikosaeder-Kugel oder geodätische Kugel) in OpenGL beschrieben ist.

This post is licensed under CC BY 4.0 by the author.