wgpu-matrix
Matrizen mit wgpu-matrix
Abschnitt betitelt „Matrizen mit wgpu-matrix“Bisher haben wir unsere Model-, View- und Projektionsmatrix direkt in WGSL definiert. Das war hilfreich, um die einzelnen Transformationen kennenzulernen.
Wenn wir später beispielsweise einen Würfel drehen oder eine Kamera bewegen, müssen sich diese Matrizen verändern können. Dafür berechnen wir sie in TypeScript und übergeben sie anschließend als Uniforms an den Shader.
Die Bibliothek wgpu-matrix stellt uns dafür Funktionen und TypeScript-Typen für Vektoren, Matrizen und Quaternionen zur Verfügung.
Wir installieren wgpu-matrix über npm:
npm install wgpu-matrixAnschließend können wir die benötigten Funktionen in TypeScript importieren:
import { mat3, mat4 } from "wgpu-matrix";Matrizen erstellen und Transformationen berechnen
Abschnitt betitelt „Matrizen erstellen und Transformationen berechnen“Eine Matrix erstellen
Abschnitt betitelt „Eine Matrix erstellen“Mit mat4.identity() erstellen wir eine 4×4-Einheitsmatrix:
const modelMatrix = mat4.identity();Sie entspricht der Matrix, die wir bereits kennengelernt haben:
| 1 0 0 0 || 0 1 0 0 || 0 0 1 0 || 0 0 0 1 |Die Einheitsmatrix verändert die Position eines Vertex nicht.
Die Bibliothek liefert die Matrixwerte standardmäßig als Float32Array. Dieses Format eignet sich für die Übergabe an einen WebGPU-Buffer.
Eine Verschiebung erstellen
Abschnitt betitelt „Eine Verschiebung erstellen“Mit mat4.translation() erstellen wir eine Matrix, die ein Objekt verschiebt:
const modelMatrix = mat4.translation([0.5, 0, 0]);Damit beschreiben wir dieselbe Verschiebung entlang der X-Achse, die wir bereits in unserem Würfelbeispiel verwenden.
Die erzeugte Matrix sieht mathematisch so aus:
| 1 0 0 0.5 || 0 1 0 0 || 0 0 1 0 || 0 0 0 1 |Die 0.5 in der letzten Spalte verschiebt unsere Vertices um 0.5 Einheiten entlang der X-Achse.
Eine Rotation erstellen
Abschnitt betitelt „Eine Rotation erstellen“Mit wgpu-matrix können wir Objekte um die X-, Y- und Z-Achse drehen.
Die Funktionen erwarten den Winkel im Bogenmaß (Radiant). Math.PI entspricht 180 Grad. Deshalb bedeutet Math.PI / 4 eine Drehung um 45 Grad.
const rotationX = mat4.rotationX(Math.PI / 4);const rotationY = mat4.rotationY(Math.PI / 4);const rotationZ = mat4.rotationZ(Math.PI / 4);Betrachten wir eine Rotation um die Y-Achse:
const modelMatrix = mat4.rotationY(Math.PI / 4);Die erzeugte Matrix entspricht ungefähr:
| 0.707 0 0.707 0 || 0 1 0 0 || -0.707 0 0.707 0 || 0 0 0 1 |Dabei bleiben die Y-Koordinaten unverändert. Die X- und Z-Koordinaten werden so verändert, dass sich das Objekt um die Y-Achse dreht.
Wir können auch einen beliebigen Winkel in Grad umrechnen:
const degrees = 45;const radians = (degrees * Math.PI) / 180;
const modelMatrix = mat4.rotationY(radians);Eine Skalierung erstellen
Abschnitt betitelt „Eine Skalierung erstellen“Mit mat4.scaling() verändern wir die Größe eines Objekts:
const modelMatrix = mat4.scaling([2, 1, 1]);Die drei Werte stehen für die Skalierung entlang der X-, Y- und Z-Achse.
In diesem Beispiel verdoppeln wir die Breite unseres Würfels. Seine Höhe und Tiefe bleiben unverändert.
Die Skalierungsmatrix lautet:
| 2 0 0 0 || 0 1 0 0 || 0 0 1 0 || 0 0 0 1 |Wenn wir alle drei Achsen mit demselben Faktor skalieren, bleibt die Form des Objekts erhalten:
const modelMatrix = mat4.scaling([2, 2, 2]);Der Würfel wird doppelt so groß.
Bei einer ungleichmäßigen Skalierung verwenden wir unterschiedliche Faktoren:
const modelMatrix = mat4.scaling([2, 0.5, 1]);Damit wird der Würfel doppelt so breit und halb so hoch. Seine Tiefe bleibt unverändert.
Transformationen kombinieren
Abschnitt betitelt „Transformationen kombinieren“Wir können Verschiebung, Rotation und Skalierung miteinander kombinieren.
Dafür verwenden wir mat4.multiply():
const translation = mat4.translation([0.5, 0, 0]);const rotation = mat4.rotationY(Math.PI / 4);const scaling = mat4.scaling([2, 1, 1]);
const modelMatrix = mat4.multiply( translation, mat4.multiply(rotation, scaling),);Die resultierende Model-Matrix enthält alle drei Transformationen.
Die Reihenfolge ist entscheidend.
Bei der Multiplikation mit einem Vertex werden die Transformationen von rechts nach links angewendet:
- Zuerst wird der Würfel skaliert.
- Anschließend wird er um die Y-Achse gedreht.
- Zuletzt wird er entlang der X-Achse verschoben.
Die Model-Matrix lässt sich mathematisch so darstellen:
Model = Translation * Rotation * SkalierungWenn wir die Reihenfolge ändern, kann ein anderes Ergebnis entstehen. Beispielsweise macht es einen Unterschied, ob wir ein Objekt zuerst verschieben und anschließend um den Ursprung drehen oder ob wir es zuerst drehen und danach verschieben.
Kamera und Projektion
Abschnitt betitelt „Kamera und Projektion“Die View-Matrix erstellen
Abschnitt betitelt „Die View-Matrix erstellen“Mit der View-Matrix legen wir fest, aus welcher Position und in welche Richtung wir unsere Szene betrachten.
Bisher haben wir die Kamera durch eine fest eingetragene View-Matrix in WGSL umgesetzt. Mit wgpu-matrix können wir diese Matrix nun in TypeScript berechnen.
Dafür verwenden wir mat4.lookAt(). Die Funktion benötigt drei Angaben:
- Kameraposition (
eye): Wo befindet sich die Kamera? - Zielpunkt (
target): Welchen Punkt betrachtet die Kamera? - Oben-Richtung (
up): Welche Richtung soll für die Kamera oben sein?
Für unseren Würfel könnte das so aussehen:
const cameraPosition = [0, 0, 2.5];const cameraTarget = [0, 0, 0];const cameraUp = [0, 1, 0];
const cameraMatrix = mat4.lookAt(cameraPosition, cameraTarget, cameraUp);mat4.lookAt() liefert eine Kameramatrix, die die Position und Ausrichtung der Kamera in der Welt beschreibt.
Für die Transformation unserer Vertices benötigen wir allerdings die umgekehrte Transformation: die View-Matrix. Sie überführt Positionen aus dem Weltkoordinatensystem in das Koordinatensystem der Kamera.
Deshalb invertieren wir die Kameramatrix:
const viewMatrix = mat4.inverse(cameraMatrix);Die Projektionsmatrix erstellen
Abschnitt betitelt „Die Projektionsmatrix erstellen“Die Projektionsmatrix bestimmt, wie unsere dreidimensionale Szene auf die zweidimensionale Zeichenfläche projiziert wird.
Für eine perspektivische Projektion verwenden wir mat4.perspective():
const fieldOfView = (60 * Math.PI) / 180;const aspect = canvas.width / canvas.height;const near = 0.1;const far = 100;
const projectionMatrix = mat4.perspective(fieldOfView, aspect, near, far);Die vier Parameter kennen wir bereits aus unserer perspektivischen Projektionsmatrix:
fieldOfView: Der vertikale Öffnungswinkel der Kamera. Hier verwenden wir 60 Grad, umgerechnet in Radiant.aspect: Das Seitenverhältnis unserer Zeichenfläche, also Breite geteilt durch Höhe.near: Der Abstand zur nahen Clipping-Ebene.far: Der Abstand zur fernen Clipping-Ebene.
mat4.perspective() berechnet daraus die Projektionsmatrix für das WebGPU-Koordinatensystem. Wir müssen die einzelnen Matrixwerte nicht mehr selbst bestimmen und eintragen.
Die Normalenmatrix
Abschnitt betitelt „Die Normalenmatrix“Neben den Vertex-Positionen besitzt unser Würfel auch Normalen. Diese Vektoren stehen senkrecht auf seinen Oberflächen.
Wenn wir den Würfel drehen, müssen sich auch seine Normalen drehen. Bei einer reinen Rotation können wir dafür den Rotationsteil unserer Model-Matrix verwenden.
Bei einer ungleichmäßigen Skalierung ist das anders: Die Normalen dürfen nicht einfach genauso skaliert werden wie die Vertex-Positionen. Sonst stehen sie möglicherweise nicht mehr senkrecht auf den Oberflächen.
Dafür benötigen wir die Normalenmatrix.
Wir können sie mit wgpu-matrix aus der Model-Matrix berechnen:
const normalMatrix = mat3.transpose(mat3.inverse(mat3.fromMat4(modelMatrix)));Dabei geschieht Folgendes:
mat3.fromMat4()entnimmt der Model-Matrix den 3×3-Teil ohne Verschiebung.mat3.inverse()berechnet die inverse Matrix.mat3.transpose()vertauscht die Zeilen und Spalten.
Das Ergebnis ist die inverse transponierte Matrix, die wir bereits bei der Normalentransformation kennengelernt haben.
Die Berechnung setzt voraus, dass die Model-Matrix invertierbar ist. Ein Skalierungsfaktor von 0 wäre beispielsweise problematisch.
Im WGSL-Shader können wir die Normalenmatrix später so verwenden:
let worldNormal = normalize(uniforms.normalMatrix * normals);Die transformierte Normale befindet sich damit im Weltkoordinatensystem.
ToDo: Die Normalenmatrix mathematisch herleiten und Schritt für Schritt zeigen, warum sie als inverse transponierte Transformationsmatrix berechnet wird.
Matrizen einmal anlegen und aktualisieren
Abschnitt betitelt „Matrizen einmal anlegen und aktualisieren“Bisher haben wir mit Funktionen wie mat4.translation() oder mat4.rotationY() jeweils eine neue Matrix erstellt.
Bei Animationen und interaktiven Transformationen ändern sich die Matrizen jedoch häufig. Wenn wir dabei jedes Mal neue Arrays erzeugen, müssen diese später vom Garbage-Collector freigegeben werden.
Mit wgpu-matrix können wir stattdessen bestehende Matrizen wiederverwenden.
Dafür übergeben wir als letzten Parameter die Matrix, in der das Ergebnis gespeichert werden soll.
Matrizen initialisieren
Abschnitt betitelt „Matrizen initialisieren“Wir legen die Matrizen einmal an:
const modelMatrix = mat4.identity();const cameraMatrix = mat4.identity();const viewMatrix = mat4.identity();const projectionMatrix = mat4.identity();const normalMatrix = mat3.identity();Alle Matrizen besitzen damit bereits ihren Speicher. Die folgenden Berechnungen schreiben ihre Ergebnisse in diese vorhandenen Arrays.
Die Model-Matrix aktualisieren
Abschnitt betitelt „Die Model-Matrix aktualisieren“Eine einzelne Verschiebung können wir direkt in die vorhandene Matrix schreiben:
mat4.translation([0.5, 0, 0], modelMatrix);Bei einer späteren Änderung überschreiben wir sie:
mat4.translation([0.7, 0, 0], modelMatrix);Jetzt enthält sie eine Verschiebung um 0.7 entlang der X-Achse. Die vorherige Verschiebung wird ersetzt, nicht addiert.
Auch Translation, Rotation und Skalierung können wir kombinieren, ohne zusätzliche Matrizen anzulegen:
function updateModelMatrix( position: [number, number, number], rotationY: number, scaling: [number, number, number],) { mat4.identity(modelMatrix);
mat4.translate(modelMatrix, position, modelMatrix); mat4.rotateY(modelMatrix, rotationY, modelMatrix); mat4.scale(modelMatrix, scaling, modelMatrix);}Zuerst setzen wir die Matrix auf die Einheitsmatrix zurück. Anschließend wenden wir die aktuellen Transformationen an.
Die Reihenfolge entspricht weiterhin:
Model = Translation * Rotation * SkalierungWir können die Funktion beispielsweise so aufrufen:
updateModelMatrix([0.5, 0, 0], Math.PI / 4, [2, 1, 1]);Bei jeder Änderung berechnen wir die Model-Matrix erneut. Ihr Speicher bleibt dabei derselbe.
Die View-Matrix aktualisieren
Abschnitt betitelt „Die View-Matrix aktualisieren“Für die View-Matrix verwenden wir dieselben Kameraangaben wie zuvor:
const cameraPosition = [0, 0, 2.5];const cameraTarget = [0, 0, 0];const cameraUp = [0, 1, 0];
function updateViewMatrix() { mat4.lookAt(cameraPosition, cameraTarget, cameraUp, cameraMatrix);
mat4.inverse(cameraMatrix, viewMatrix);}Wenn wir die Kamera bewegen oder ihren Zielpunkt verändern, rufen wir updateViewMatrix() erneut auf.
Sowohl die Kameramatrix als auch die View-Matrix behalten dabei ihren Speicher.
Die Projektionsmatrix aktualisieren
Abschnitt betitelt „Die Projektionsmatrix aktualisieren“Auch die Projektionsmatrix können wir neu berechnen, ohne ein weiteres Matrix-Array anzulegen:
let fieldOfView = 60;
function updateProjectionMatrix() { const radians = (fieldOfView * Math.PI) / 180; const aspect = canvas.width / canvas.height;
mat4.perspective(radians, aspect, 0.1, 100, projectionMatrix);}Das ist beispielsweise dann wichtig, wenn sich die Größe unseres Canvas oder der Öffnungswinkel der Kamera ändert.
Die Normalenmatrix aktualisieren
Abschnitt betitelt „Die Normalenmatrix aktualisieren“Nach einer Änderung der Model-Matrix berechnen wir auch die Normalenmatrix erneut:
function updateNormalMatrix() { mat3.fromMat4(modelMatrix, normalMatrix); mat3.inverse(normalMatrix, normalMatrix); mat3.transpose(normalMatrix, normalMatrix);}Auch hier verwenden wir durchgehend dasselbe Matrix-Array.
Die Berechnung setzt voraus, dass die Model-Matrix invertierbar ist. Eine Skalierung mit dem Faktor 0 auf einer Achse müssen wir deshalb vermeiden.
Die Matrizen an WebGPU übergeben
Abschnitt betitelt „Die Matrizen an WebGPU übergeben“Um unsere Matrizen im Shader verwenden zu können, benötigen wir einen Uniform-Buffer.
Eine mat4x4f besteht aus 16 Gleitkommazahlen mit jeweils 4 Byte. Deshalb benötigt sie 64 Byte.
Für die Model-Matrix erstellen wir beispielsweise diesen Buffer:
const matrixBuffer = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,});Anschließend übertragen wir unsere Matrix:
device.queue.writeBuffer(matrixBuffer, 0, modelMatrix);Wenn wir die Matrix später verändern, müssen wir die aktualisierten Werte erneut in den Buffer schreiben.
Im WGSL-Shader können wir die Model-Matrix über eine Uniform-Bindung verwenden:
struct MatrixUniforms { modelMatrix: mat4x4f,};
@group(0) @binding(0)var<uniform> matrices: MatrixUniforms;Die Transformation eines Vertex bleibt unverändert:
let worldPosition = matrices.modelMatrix * position;Die Berechnung der Matrix findet jetzt in TypeScript statt. WGSL übernimmt weiterhin die Transformation der einzelnen Vertices.
Wenn wir zusätzlich die View-, Projektions- und Normalenmatrix übertragen, erweitern wir unsere Uniform-Struktur entsprechend. Die Normalenmatrix benötigt dabei wegen der Uniform-Speicherregeln von WGSL ebenfalls eine passende Speicheranordnung.
Für alle drei Transformationsmatrizen bleibt im Vertex-Shader die bekannte Reihenfolge erhalten:
let worldPosition = uniforms.modelMatrix * position;let viewPosition = uniforms.viewMatrix * worldPosition;let clipPosition = uniforms.projectionMatrix * viewPosition;Das Verändern einer Matrix in TypeScript aktualisiert den Uniform-Buffer auf der GPU nicht automatisch. Wir müssen die geänderten Werte mit device.queue.writeBuffer() übertragen.
Die Matrizen und der Uniform-Buffer bleiben bestehen. Wir aktualisieren nur ihre Inhalte, statt bei jeder Änderung neue Objekte anzulegen.
Die vollständige Einbindung in unsere bestehende Render-Pipeline setzen wir im nächsten Beispiel um.