1. Einführung
Was ist eine WebGPU?
WebGPU ist eine neue, moderne API für den Zugriff auf die Funktionen Ihrer GPU in Webanwendungen.
Moderne API
Vor WebGPU gab es WebGL, das nur einen Teil der Funktionen von WebGPU bot. Es ermöglichte eine neue Art von interaktiven Webinhalten und Entwickler haben damit erstaunliche Dinge geschaffen. Sie basierte jedoch auf der 2007 veröffentlichten OpenGL ES 2.0 API, die wiederum auf der noch älteren OpenGL API basierte. GPUs haben sich in dieser Zeit erheblich weiterentwickelt und die nativen APIs, die für die Schnittstelle verwendet werden, haben sich mit Direct3D 12, Metal und Vulkan ebenfalls weiterentwickelt.
WebGPU bringt die Vorteile dieser modernen APIs auf die Webplattform. Im Mittelpunkt steht die plattformübergreifende Aktivierung von GPU-Funktionen und die Präsentation einer API, die sich im Web natürlich anfühlt und weniger ausführlich ist als einige der nativen APIs, auf denen sie basiert.
Rendering
GPUs werden oft mit einem schnellen und detailgenauen Rendering in Verbindung gebracht. WebGPU ist da keine Ausnahme. Sie verfügt über die Funktionen, die zur Unterstützung vieler der gängigsten Rendering-Techniken von Desktop- und mobilen GPUs erforderlich sind, und bietet die Möglichkeit, in Zukunft neue Funktionen hinzuzufügen, wenn die Hardware-Funktionen weiterentwickelt werden.
Computing
Neben dem Rendering bietet WebGPU die Möglichkeit, die GPU für allgemeine, hoch parallele Arbeitslasten zu nutzen. Diese Compute Shader können eigenständig, ohne Rendering-Komponente oder als eng in die Rendering-Pipeline eingebundener Teil verwendet werden.
Im heutigen Codelab lernen Sie, wie Sie die Rendering- und Rechenfunktionen von WebGPU nutzen, um ein einfaches Einführungsprojekt zu erstellen.
Umfang
In diesem Codelab erstellen Sie Conways Spiel des Lebens mit WebGPU. Mit der Anwendung können Sie Folgendes tun:
- Mit den Rendering-Funktionen von WebGPU können Sie einfache 2D-Grafiken zeichnen.
- Verwenden Sie die Rechenfunktionen von WebGPU, um die Simulation durchzuführen.
Das Spiel des Lebens ist ein sogenannter zellulärer Automaten, bei dem ein Raster von Zellen den Status im Laufe der Zeit nach bestimmten Regeln ändert. Im Spiel des Lebens werden Zellen aktiv oder inaktiv, je nachdem, wie viele ihrer benachbarten Zellen aktiv sind. Dies führt zu interessanten Mustern, die sich beim Betrachten ändern.
Aufgaben in diesem Lab
- WebGPU einrichten und Canvas konfigurieren
- So zeichnen Sie einfache 2D-Geometrie.
- Wie Sie Vertex- und Fragment-Shader verwenden, um das Gezeichnete zu ändern.
- Compute Shader verwenden, um eine einfache Simulation durchzuführen
In diesem Codelab werden die grundlegenden Konzepte von WebGPU vorgestellt. Es ist nicht als umfassender Überblick über die API gedacht und deckt auch keine häufig damit zusammenhängenden Themen wie 3D-Matrixmathematik ab.
Voraussetzungen
- Eine aktuelle Version von Chrome (113 oder höher) unter ChromeOS, macOS oder Windows. WebGPU ist eine browser- und plattformübergreifende API, die jedoch noch nicht überall verfügbar ist.
- Kenntnisse in HTML, JavaScript und Chrome-Entwicklertools.
Kenntnisse mit anderen Grafik-APIs wie WebGL, Metal, Vulkan oder Direct3D sind nicht erforderlich. Wenn Sie jedoch Erfahrung mit diesen APIs haben, werden Sie wahrscheinlich viele Ähnlichkeiten mit WebGPU feststellen, die Ihnen den Einstieg erleichtern können.
2. Einrichten
Code abrufen
Dieses Codelab hat keine Abhängigkeiten und führt Sie durch jeden Schritt, der zum Erstellen der WebGPU-Anwendung erforderlich ist. Sie benötigen also keinen Code, um loszulegen. Unter https://glitch.com/edit/#!/your-first-webgpu-app finden Sie jedoch einige funktionierende Beispiele, die als Checkpoints dienen können. Sie können sich diese ansehen und bei Bedarf als Referenz verwenden.
Verwenden Sie die Entwicklerkonsole.
WebGPU ist eine ziemlich komplexe API mit vielen Regeln, die eine ordnungsgemäße Verwendung erzwingen. Schlimmer noch, aufgrund der Funktionsweise der API kann sie für viele Fehler keine typischen JavaScript-Ausnahmen auslösen, was es schwieriger macht, die genaue Ursache des Problems zu ermitteln.
Bei der Entwicklung mit WebGPU werden Sie auf Probleme stoßen, vor allem als Anfänger. Das ist ganz normal. Die Entwickler der API sind sich der Herausforderungen bei der GPU-Entwicklung bewusst und haben hart daran gearbeitet, dass Sie immer dann, wenn Ihr WebGPU-Code einen Fehler verursacht, sehr detaillierte und hilfreiche Meldungen in der Entwicklerkonsole erhalten, die Ihnen helfen, das Problem zu identifizieren und zu beheben.
Es ist immer hilfreich, die Konsole geöffnet zu lassen, wenn Sie an einer beliebigen Webanwendung arbeiten. Das gilt aber ganz besonders hier.
3. WebGPU initialisieren
Mit <canvas>
beginnen
WebGPU kann verwendet werden, ohne dass etwas auf dem Bildschirm angezeigt wird, wenn Sie sie nur für Berechnungen verwenden möchten. Wenn Sie jedoch etwas rendern möchten, wie Sie es im Codelab machen, benötigen Sie einen Canvas. Das ist ein guter Anfang.
Erstellen Sie ein neues HTML-Dokument mit einem einzelnen <canvas>
-Element und einem <script>
-Tag, in dem das Canvas-Element abgefragt wird. Sie können auch 00-starter-page.html von Glitch verwenden.
- Erstellen Sie eine
index.html
-Datei mit folgendem Code:
index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>WebGPU Life</title>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script type="module">
const canvas = document.querySelector("canvas");
// Your WebGPU code will begin here!
</script>
</body>
</html>
Adapter und Gerät anfordern
Jetzt kommen Sie zu den WebGPU-Bits. Zuerst sollten Sie bedenken, dass es einige Zeit dauern kann, bis APIs wie WebGPU im gesamten Web-Ökosystem verfügbar sind. Daher ist es ratsam, zuerst zu prüfen, ob der Browser des Nutzers WebGPU verwenden kann.
- Fügen Sie den folgenden Code hinzu, um zu prüfen, ob das Objekt
navigator.gpu
, das als Einstiegspunkt für WebGPU dient, vorhanden ist:
index.html
if (!navigator.gpu) {
throw new Error("WebGPU not supported on this browser.");
}
Idealerweise möchten Sie den Nutzer informieren, wenn WebGPU nicht verfügbar ist, indem Sie die Seite in einen Modus zurückversetzen, in dem WebGPU nicht verwendet wird. (Könnte stattdessen WebGL verwendet werden?) Für die Zwecke dieses Codelabs geben Sie jedoch einfach einen Fehler aus, um die weitere Ausführung des Codes zu stoppen.
Wenn Sie wissen, dass WebGPU vom Browser unterstützt wird, müssen Sie zuerst eine GPUAdapter
anfordern, um WebGPU für Ihre App zu initialisieren. Sie können sich einen Adapter als die WebGPU-Darstellung einer bestimmten GPU-Hardware in Ihrem Gerät vorstellen.
- Verwenden Sie zum Abrufen eines Adapters die Methode
navigator.gpu.requestAdapter()
. Sie gibt ein Versprechen zurück. Daher ist es am einfachsten, sie mitawait
aufzurufen.
index.html
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("No appropriate GPUAdapter found.");
}
Wenn keine geeigneten Adapter gefunden werden können, ist der zurückgegebene adapter
-Wert möglicherweise null
. Diese Möglichkeit solltest du berücksichtigen. Das kann passieren, wenn der Browser des Nutzers WebGPU unterstützt, die GPU-Hardware aber nicht alle Funktionen für die Verwendung von WebGPU bietet.
In den meisten Fällen ist es in Ordnung, den Browser einfach einen Standardadapter auswählen zu lassen, wie Sie es hier tun. Für erweiterte Anforderungen gibt es jedoch Argumente, die an requestAdapter()
übergeben werden können, um anzugeben, ob Sie auf Geräten mit mehreren GPUs (z. B. Laptops) energiesparende oder leistungsstarke Hardware verwenden möchten.
Nachdem Sie einen Adapter haben, müssen Sie als letzten Schritt vor der Arbeit mit der GPU ein GPUDevice anfordern. Das Gerät ist die Hauptoberfläche, über die die meisten Interaktionen mit der GPU stattfinden.
- Rufe
adapter.requestDevice()
auf, um das Gerät abzurufen. Dadurch wird auch ein Versprechen zurückgegeben.
index.html
const device = await adapter.requestDevice();
Wie bei requestAdapter()
gibt es auch Optionen, die hier für komplexere Verwendungszwecke übergeben werden können, z. B. um bestimmte Hardwarefunktionen zu aktivieren oder höhere Limits anzufordern. Für Ihre Zwecke funktionieren die Standardeinstellungen jedoch gut.
Canvas konfigurieren
Nachdem Sie ein Gerät erstellt haben, müssen Sie noch eine weitere Sache tun, wenn Sie damit etwas auf der Seite anzeigen möchten: Konfigurieren Sie den Canvas für die Verwendung mit dem Gerät, das Sie gerade erstellt haben.
- Rufen Sie dazu zuerst
canvas.getContext("webgpu")
auf, um einenGPUCanvasContext
vom Canvas anzufordern. Mit diesem Aufruf können Sie Canvas 2D- oder WebGL-Kontexte mit den Kontexttypen2d
bzw.webgl
initialisieren. Der zurückgegebene Wert fürcontext
muss dann mit dem Gerät über die Methodeconfigure()
verknüpft werden. Beispiel:
index.html
const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
Hier können mehrere Optionen übergeben werden. Die wichtigsten sind device
, mit dem Sie den Kontext verwenden, und format
, das Texturformat, das für den Kontext verwendet werden soll.
Texturen sind die Objekte, die WebGPU zum Speichern von Bilddaten verwendet. Jede Textur hat ein Format, das der GPU mitteilt, wie diese Daten im Arbeitsspeicher angeordnet sind. In diesem Codelab wird die Funktionsweise des Texturspeichers nicht im Detail beschrieben. Wichtig ist, dass der Canvas-Kontext Texturen für Ihren Code zum Zeichnen bereitstellt. Das verwendete Format kann sich darauf auswirken, wie effizient die Bilder auf dem Canvas dargestellt werden. Verschiedene Gerätetypen erzielen die beste Leistung, wenn unterschiedliche Texturformate verwendet werden. Wenn Sie nicht das bevorzugte Format des Geräts verwenden, können im Hintergrund zusätzliche Speicherkopien erstellt werden, bevor das Bild als Teil der Seite angezeigt werden kann.
Zum Glück müssen Sie sich darüber keine großen Gedanken machen, da Ihnen WebGPU vorgibt, welches Format Sie für Ihren Canvas verwenden sollen. In fast allen Fällen möchten Sie den zurückgegebenen Wert übergeben, indem Sie navigator.gpu.getPreferredCanvasFormat()
aufrufen, wie oben gezeigt.
Canvas löschen
Sie haben jetzt ein Gerät und der Canvas wurde damit konfiguriert. Sie können jetzt mit dem Gerät den Inhalt des Canvas ändern. Zuerst sollten Sie den Hintergrund mit einer durchgehenden Farbe füllen.
Dazu und für so ziemlich alles andere in WebGPU müssen Sie der GPU einige Befehle geben, die ihr vorgeben, was sie tun soll.
- Dazu muss das Gerät eine
GPUCommandEncoder
erstellen, die eine Schnittstelle zum Aufzeichnen von GPU-Befehlen bietet.
index.html
const encoder = device.createCommandEncoder();
Die Befehle, die Sie an die GPU senden möchten, beziehen sich auf das Rendering (in diesem Fall das Leeren des Canvas). Im nächsten Schritt beginnen Sie mit encoder
einen Rendering-Pass.
Bei Rendering-Pässen werden alle Zeichnvorgänge in WebGPU ausgeführt. Jedes beginnt mit einem beginRenderPass()
-Aufruf, der die Texturen definiert, die die Ausgabe aller ausgeführten Zeichenbefehle erhalten. Für erweiterte Anwendungen können mehrere Texturen, sogenannte Anhänge, mit verschiedenen Zwecken bereitgestellt werden, z. B. zum Speichern der Tiefe der gerenderten Geometrie oder zum Bereitstellen von Anti-Aliasing. Für diese App benötigen Sie jedoch nur eine.
- Rufen Sie die Textur aus dem Canvas-Kontext ab, den Sie zuvor erstellt haben. Rufen Sie dazu
context.getCurrentTexture()
auf. Daraufhin wird eine Textur mit einer Pixelbreite und -höhe zurückgegeben, die den Attributenwidth
undheight
des Canvas entspricht, sowie demformat
, das beim Aufrufen voncontext.configure()
angegeben wurde.
index.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
storeOp: "store",
}]
});
Die Textur wird als view
-Eigenschaft eines colorAttachment
angegeben. Für Renderingdurchläufe ist es erforderlich, dass Sie ein GPUTextureView
anstelle eines GPUTexture
angeben, das angibt, in welchen Teilen der Textur gerendert werden soll. Das ist nur bei erweiterten Anwendungsfällen von Bedeutung. Hier wird createView()
ohne Argumente auf die Textur aufgerufen, um anzugeben, dass der Rendering-Pass die gesamte Textur verwenden soll.
Außerdem müssen Sie angeben, was der Renderer mit der Textur beim Starten und Beenden tun soll:
- Ein Wert von
loadOp
für"clear"
gibt an, dass die Textur gelöscht werden soll, wenn der Rendering-Pass beginnt. - Ein
storeOp
-Wert von"store"
gibt an, dass die Ergebnisse aller während des Renderpasses erstellten Zeichnungen in der Textur gespeichert werden sollen, sobald der Renderpass abgeschlossen ist.
Sobald der Rendering-Pass gestartet wurde, müssen Sie nichts weiter tun. Zumindest vorerst. Das Starten des Renderingpasses mit loadOp: "clear"
reicht aus, um die Texturansicht und den Canvas zu löschen.
- Beenden Sie die Renderingübergabe, indem Sie direkt nach
beginRenderPass()
den folgenden Aufruf hinzufügen:
index.html
pass.end();
Es ist wichtig zu wissen, dass die GPU durch diese Aufrufe nicht automatisch etwas tut. Sie zeichnen nur Befehle auf, die die GPU später ausführen soll.
- Rufen Sie
finish()
auf dem Befehls-Encoder auf, um einenGPUCommandBuffer
zu erstellen. Der Befehlsbuffer ist ein undurchsichtiger Handle für die aufgezeichneten Befehle.
index.html
const commandBuffer = encoder.finish();
- Reichen Sie den Befehlsbuffer mithilfe des
queue
desGPUDevice
an die GPU ein. Die Warteschlange führt alle GPU-Befehle aus und sorgt dafür, dass sie geordnet und ordnungsgemäß synchronisiert ausgeführt werden. Diesubmit()
-Methode der Warteschlange nimmt ein Array von Befehlspuffern entgegen, in diesem Fall haben Sie jedoch nur einen.
index.html
device.queue.submit([commandBuffer]);
Nachdem Sie einen Befehlspuffer gesendet haben, kann er nicht mehr verwendet werden. Sie müssen ihn also nicht aufbewahren. Wenn Sie weitere Befehle senden möchten, müssen Sie einen weiteren Befehlsbuffer erstellen. Daher werden diese beiden Schritte häufig zu einem zusammengefasst, wie auf den Beispielseiten dieses Codelabs:
index.html
// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);
Nachdem Sie die Befehle an die GPU gesendet haben, sollte JavaScript die Steuerung an den Browser zurückgeben. An diesem Punkt erkennt der Browser, dass Sie die aktuelle Textur des Kontexts geändert haben, und aktualisiert das Canvas, um diese Textur als Bild anzuzeigen. Wenn Sie den Canvas-Inhalt danach noch einmal aktualisieren möchten, müssen Sie einen neuen Befehlsbuffer erfassen und einreichen und context.getCurrentTexture()
noch einmal aufrufen, um eine neue Textur für einen Renderpass zu erhalten.
- Lade die Seite neu. Beachten Sie, dass der Canvas mit Schwarz gefüllt ist. Glückwunsch! Sie haben also Ihre erste WebGPU-Anwendung erstellt.
Wählen Sie eine Farbe aus.
Ehrlich gesagt sind schwarze Quadrate aber ziemlich langweilig. Nehmen Sie sich einen Moment Zeit, bevor Sie mit dem nächsten Abschnitt fortfahren, um ihn ein wenig zu personalisieren.
- Fügen Sie im
encoder.beginRenderPass()
-Aufruf demcolorAttachment
eine neue Zeile mit einerclearValue
hinzu. Beispiel:
index.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
storeOp: "store",
}],
});
Die clearValue
weist dem Rendering-Pass an, welche Farbe er beim Ausführen des clear
-Vorgangs am Anfang des Passes verwenden soll. Das übergebene Dictionary enthält vier Werte: r
für rot, g
für grün, b
für blau und a
für Alpha (Transparenz). Jeder Wert kann zwischen 0
und 1
liegen. Zusammen beschreiben sie den Wert dieses Farbkanals. Beispiel:
{ r: 1, g: 0, b: 0, a: 1 }
ist knallrot.{ r: 1, g: 0, b: 1, a: 1 }
ist leuchtend lila.{ r: 0, g: 0.3, b: 0, a: 1 }
ist dunkelgrün.{ r: 0.5, g: 0.5, b: 0.5, a: 1 }
ist mittelgrau.{ r: 0, g: 0, b: 0, a: 0 }
ist die Standardeinstellung, transparent schwarz.
Im Beispielcode und in den Screenshots in diesem Codelab wird ein dunkles Blau verwendet. Sie können aber jede beliebige Farbe auswählen.
- Aktualisieren Sie die Seite, nachdem Sie die Farbe ausgewählt haben. Die ausgewählte Farbe sollte jetzt auf dem Canvas zu sehen sein.
4. Geometrien zeichnen
Am Ende dieses Abschnitts zeichnet Ihre App eine einfache Geometrie auf den Canvas: ein farbiges Quadrat. Seien Sie gewarnt, dass es bei einer so einfachen Ausgabe sehr aufwändig erscheinen mag, aber das liegt daran, dass WebGPU dafür entwickelt wurde, viele Geometrie sehr effizient zu rendern. Eine Nebenwirkung dieser Effizienz ist, dass relativ einfache Dinge ungewöhnlich schwierig erscheinen können. Das ist aber zu erwarten, wenn Sie sich für eine API wie WebGPU entscheiden – Sie möchten etwas etwas Komplexeres tun.
Darstellung von Grafiken mit GPUs
Bevor Sie weitere Codeänderungen vornehmen, sollten Sie sich einen kurzen, vereinfachten Überblick darüber verschaffen, wie GPUs die Formen erstellen, die Sie auf dem Bildschirm sehen. Wenn Sie mit den Grundlagen des GPU-Renderings vertraut sind, können Sie direkt zum Abschnitt „Definition von Vertices“ springen.
Im Gegensatz zu einer API wie Canvas 2D, die viele Formen und Optionen bietet, die Sie verwenden können, verarbeitet Ihre GPU nur wenige verschiedene Arten von Formen (oder Primitiven, wie sie in WebGPU bezeichnet werden): Punkte, Linien und Dreiecke. In diesem Codelab verwenden Sie nur Dreiecke.
GPUs arbeiten fast ausschließlich mit Dreiecken, da diese viele nützliche mathematische Eigenschaften haben, die eine einfache, vorhersehbare und effiziente Verarbeitung ermöglichen. Fast alles, was Sie mit der GPU zeichnen, muss in Dreiecke unterteilt werden, bevor die GPU es zeichnen kann. Diese Dreiecke müssen durch ihre Eckpunkte definiert werden.
Diese Punkte oder Eckpunkte werden in Form von X-, Y- und (bei 3D-Inhalten) Z-Werten angegeben, die einen Punkt in einem kartesischen Koordinatensystem definieren, das von WebGPU oder ähnlichen APIs definiert wird. Die Struktur des Koordinatensystems lässt sich am einfachsten in Bezug auf den Canvas auf Ihrer Seite betrachten. Unabhängig davon, wie breit oder hoch Ihr Canvas ist, befindet sich der linke Rand auf der X-Achse immer bei -1 und der rechte Rand immer bei +1 auf der X-Achse. Ebenso ist die untere Kante immer -1 auf der Y-Achse und die obere Kante +1 auf der Y-Achse. Das bedeutet, dass (0, 0) immer die Mitte des Canvas ist, (-1, -1) immer die untere linke Ecke und (1, 1) immer die rechte obere Ecke ist. Dies wird als Clipbereich bezeichnet.
Die Vertexe werden selten anfangs in diesem Koordinatensystem definiert. Daher nutzen GPUs kleine Programme, die sogenannten Vertex-Shader, um die erforderlichen mathematischen Berechnungen zur Transformation der Vertexe in den Clipbereich sowie alle anderen Berechnungen durchzuführen, die zum Zeichnen der Vertexe erforderlich sind. Der Shader kann beispielsweise eine Animation anwenden oder die Richtung vom Vertex zu einer Lichtquelle berechnen. Diese Shader werden von Ihnen, dem WebGPU-Entwickler, geschrieben und bieten eine erstaunliche Kontrolle über die Funktionsweise der GPU.
Die GPU nimmt dann alle Dreiecke aus diesen transformierten Eckpunkten und ermittelt, welche Pixel auf dem Bildschirm zum Zeichnen benötigt werden. Dann wird ein weiteres kleines Programm ausgeführt, das Sie schreiben und das als Fragment-Shader bezeichnet wird. Es berechnet, welche Farbe jedes Pixel haben soll. Diese Berechnung kann so einfach wie return green (grün zurückgeben) oder so komplex wie die Berechnung des Winkels der Oberfläche relativ zur Sonneneinstrahlung sein, die von anderen Oberflächen in der Nähe reflektiert, durch Nebel gefiltert und durch die Metallisierung der Oberfläche modifiziert wird. Sie haben die volle Kontrolle, was sowohl befähigend als auch überwältigend sein kann.
Die Ergebnisse dieser Pixelfarben werden dann in einer Textur zusammengefasst, die dann auf dem Bildschirm angezeigt werden kann.
Knoten definieren
Wie bereits erwähnt, wird die Simulation von „Das Leben“ als Raster aus Zellen dargestellt. Ihre App muss das Raster visualisieren und aktive Zellen von inaktiven Zellen unterscheiden können. Der Ansatz in diesem Codelab besteht darin, farbige Quadrate in die aktiven Zellen zu zeichnen und inaktive Zellen leer zu lassen.
Dies bedeutet, dass Sie der GPU vier verschiedene Punkte bereitstellen müssen, einen für jede der vier Ecken des Quadrats. Ein Quadrat, das beispielsweise in der Mitte des Canvas gezeichnet und von den Rändern etwas nach innen gezogen wurde, hat folgende Eckkoordinaten:
Damit diese Koordinaten an die GPU übergeben werden können, müssen Sie die Werte in einen TypedArray einfügen. TypedArrays sind eine Gruppe von JavaScript-Objekten, mit denen Sie zusammenhängende Speicherblöcke zuweisen und jedes Element der Reihe als einen bestimmten Datentyp interpretieren können. In einem Uint8Array
ist beispielsweise jedes Element im Array ein einzelnes, ungesigniertes Byte. TypedArrays eignen sich hervorragend zum Austauschen von Daten mit APIs, die empfindlich auf das Speicherlayout reagieren, wie WebAssembly, WebAudio und natürlich WebGPU.
Für das quadratische Beispiel ist ein Float32Array
angemessen, da es sich bei den Werten um Bruchzahlen handelt.
- Erstellen Sie ein Array, das alle Knotenpositionen im Diagramm enthält, indem Sie die folgende Arraydeklaration in Ihren Code einfügen. Ein guter Platz dafür ist oben, direkt unter dem
context.configure()
-Aufruf.
index.html
const vertices = new Float32Array([
// X, Y,
-0.8, -0.8,
0.8, -0.8,
0.8, 0.8,
-0.8, 0.8,
]);
Beachten Sie, dass die Leerzeichen und der Kommentar keinen Einfluss auf die Werte haben. Sie dienen nur zu Ihrer Übersichtlichkeit und der besseren Lesbarkeit. Sie sehen, dass jedes Wertepaar die X- und Y-Koordinaten für einen Eckpunkt bildet.
Aber es gibt ein Problem. GPUs arbeiten mit Dreiecken, erinnern Sie sich? Das bedeutet, dass Sie die Eckpunkte in Dreiergruppen angeben müssen. Sie haben eine Vierergruppe. Die Lösung besteht darin, zwei der Eckpunkte zu wiederholen, um zwei Dreiecke zu erstellen, die eine gemeinsame Kante durch die Mitte des Quadrats haben.
Um das Quadrat aus dem Diagramm zu bilden, müssen Sie die Eckpunkte (-0,8; -0,8) und (0,8; 0,8) zweimal angeben, einmal für das blaue Dreieck und einmal für das rote. Sie können das Quadrat auch mit den anderen beiden Ecken teilen. Das macht keinen Unterschied.
- Aktualisieren Sie das vorherige
vertices
-Array so, dass es in etwa so aussieht:
index.html
const vertices = new Float32Array([
// X, Y,
-0.8, -0.8, // Triangle 1 (Blue)
0.8, -0.8,
0.8, 0.8,
-0.8, -0.8, // Triangle 2 (Red)
0.8, 0.8,
-0.8, 0.8,
]);
Im Diagramm ist zwar eine Trennung zwischen den beiden Dreiecken zu sehen, die Spitzenpositionen sind jedoch genau gleich und die GPU rendert sie ohne Lücken. Es wird als einzelnes, ausgefülltes Quadrat gerendert.
Vertex-Buffer erstellen
Die GPU kann keine Eckpunkte mit Daten aus einem JavaScript-Array ziehen. GPUs haben häufig einen eigenen Arbeitsspeicher, der für das Rendering optimiert ist. Alle Daten, die die GPU beim Zeichnen verwenden soll, müssen daher in diesem Arbeitsspeicher abgelegt werden.
Für viele Werte, einschließlich Vertexdaten, wird der GPU-Speicher über GPUBuffer
-Objekte verwaltet. Ein Puffer ist ein Arbeitsspeicherblock, der für die GPU leicht zugänglich ist und für bestimmte Zwecke gekennzeichnet ist. Sie können sich das ein bisschen wie einen GPU-sichtbaren TypedArray vorstellen.
- Fügen Sie den folgenden Aufruf zu
device.createBuffer()
nach der Definition Ihresvertices
-Arrays hinzu, um einen Zwischenspeicher für Ihre Scheitelpunkte zu erstellen.
index.html
const vertexBuffer = device.createBuffer({
label: "Cell vertices",
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
Zuerst geben Sie dem Puffer ein Label. Jedem einzelnen von Ihnen erstellten WebGPU-Objekt kann ein optionales Label zugewiesen werden. Das Label kann beliebig sein, solange es Ihnen hilft, das Objekt zu identifizieren. Bei Problemen werden diese Labels in den Fehlermeldungen von WebGPU verwendet, um Ihnen zu helfen, das Problem zu verstehen.
Geben Sie als Nächstes die Größe des Buffers in Byte an. Sie benötigen einen Puffer mit 48 Byte. Dazu multiplizieren Sie die Größe einer 32‑Bit-Gleitkommazahl ( 4 Byte) mit der Anzahl der Gleitkommazahlen in Ihrem vertices
-Array (12). Glücklicherweise wird die byteLength von TypedArrays bereits für Sie berechnet. Sie können sie also beim Erstellen des Buffers verwenden.
Geben Sie abschließend die Nutzung des Buffers an. Dies ist ein oder mehrere der GPUBufferUsage
-Flags. Mehrere Flags werden mit dem Operator |
( bitweises OR) kombiniert. In diesem Fall geben Sie an, dass der Buffer für Vertexdaten (GPUBufferUsage.VERTEX
) verwendet werden soll und dass Sie auch Daten hineinkopieren können möchten (GPUBufferUsage.COPY_DST
).
Das zurückgegebene Pufferobjekt ist undurchsichtig. Sie können die darin enthaltenen Daten nicht (leicht) prüfen. Außerdem sind die meisten Attribute unveränderlich. Sie können die Größe einer GPUBuffer
nach der Erstellung nicht ändern und auch die Nutzungsflags nicht ändern. Sie können jedoch den Inhalt des Arbeitsspeichers ändern.
Wenn der Puffer zum ersten Mal erstellt wird, wird der darin enthaltene Speicher auf Null initialisiert. Es gibt mehrere Möglichkeiten, den Inhalt zu ändern. Am einfachsten ist es, device.queue.writeBuffer()
mit einem TypedArray aufzurufen, das Sie einfügen möchten.
- Fügen Sie den folgenden Code hinzu, um die Scheitelpunktdaten in den Zwischenspeicherspeicher zu kopieren:
index.html
device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);
Knotenlayout definieren
Sie haben jetzt einen Buffer mit Vertexdaten, aber für die GPU ist es nur ein Byte-Blob. Wenn Sie damit etwas zeichnen möchten, benötigen Sie weitere Informationen. Sie müssen WebGPU mehr über die Struktur der Vertexdaten mitteilen können.
- Definieren Sie die Vertex-Datenstruktur mit einem
GPUVertexBufferLayout
-Wörterbuch:
index.html
const vertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0, // Position, see vertex shader
}],
};
Das kann auf den ersten Blick etwas verwirrend sein, lässt sich aber relativ einfach erklären.
Geben Sie als Erstes die arrayStride
an. Dies ist die Anzahl der Byte, die die GPU im Zwischenspeicher überspringen muss, wenn sie nach dem nächsten Scheitelpunkt sucht. Jeder Scheitelpunkt des Quadrats besteht aus zwei 32-Bit-Gleitkommazahlen. Wie bereits erwähnt, beträgt eine 32-Bit-Gleitkommazahl 4 Byte, also sind zwei Gleitkommazahlen 8 Byte.
Als Nächstes folgt die Property attributes
, ein Array. Attribute sind die einzelnen Informationen, die in jedem Knoten codiert sind. Ihre Eckpunkte enthalten nur ein Attribut (die Position des Scheitelpunkts). Bei komplexeren Anwendungsfällen gibt es jedoch häufig Eckpunkte mit mehreren Attributen, z. B. die Farbe eines Scheitelpunkts oder die Richtung, in die die Geometrieoberfläche zeigt. Das ist in diesem Codelab jedoch nicht möglich.
In Ihrem einzelnen Attribut definieren Sie zuerst den format
der Daten. Dieser Wert stammt aus einer Liste von GPUVertexFormat
-Typen, die die einzelnen Arten von Vertex-Daten beschreiben, die die GPU verstehen kann. Ihre Eckpunkte haben jeweils zwei 32‑Bit-Floats, sodass Sie das Format float32x2
verwenden. Wenn Ihre Scheitelpunktdaten jeweils aus vier vorzeichenlosen 16-Bit-Ganzzahlen bestehen, sollten Sie stattdessen uint16x4
verwenden. Erkennen Sie das Muster?
Als Nächstes wird mit offset
angegeben, wie viele Byte nach Beginn des Vertex dieses Attribut beginnt. Sie müssen sich nur darüber Gedanken machen, wenn Ihr Puffer mehr als ein Attribut enthält, das bei diesem Codelab nicht auftauchen wird.
Schließlich haben Sie die shaderLocation
. Dies ist eine beliebige Zahl zwischen 0 und 15 und muss für jedes von Ihnen definierte Attribut eindeutig sein. Es verknüpft dieses Attribut mit einem bestimmten Eingang im Vertex-Shader, den Sie im nächsten Abschnitt kennenlernen.
Beachten Sie, dass Sie diese Werte jetzt zwar definieren, aber noch nirgendwo an die WebGPU API übergeben werden. Diese Werte kommen schon bald an, aber es ist am einfachsten, sich diese Werte an dem Punkt zu überlegen, an dem Sie Ihre Eckpunkte definieren, also richten Sie sie jetzt für die spätere Verwendung ein.
Mit Shadern beginnen
Sie haben jetzt die Daten, die Sie rendern möchten, müssen der GPU aber noch genau mitteilen, wie sie sie verarbeiten soll. Ein Großteil davon geschieht mit Shadern.
Shader sind kleine Programme, die Sie schreiben und die auf Ihrer GPU ausgeführt werden. Jeder Shader arbeitet auf einer anderen Ebene der Daten: Vertex-, Fragment- oder allgemeine Berechnung. Da sie sich auf der GPU befinden, sind sie starrer strukturiert als herkömmliches JavaScript. Aber diese Struktur ermöglicht es ihnen, sehr schnell und – entscheidend – parallel zu arbeiten!
Shader in WebGPU werden in einer Schattierungssprache namens WGSL (WebGPU Shading Language) geschrieben. WGSL ähnelt syntaktisch ein wenig wie Rust und hat Funktionen, die gängige GPU-Funktionen wie Vektor- und Matrixberechnungen einfacher und schneller machen sollen. Die gesamte Schattierungssprache zu vermitteln, würde den Rahmen dieses Codelabs sprengen. Aber hoffentlich können Sie sich einige der Grundlagen aneignen, während Sie sich einige einfache Beispiele ansehen.
Die Shader selbst werden als Strings an WebGPU übergeben.
- Erstellen Sie einen Platz zum Eingeben des Shadercodes. Kopieren Sie dazu den folgenden Code in den Code unter der
vertexBufferLayout
:
index.html
const cellShaderModule = device.createShaderModule({
label: "Cell shader",
code: `
// Your shader code will go here
`
});
Zum Erstellen der Shader rufen Sie device.createShaderModule()
auf, für das Sie optional label
und WGSL code
als String angeben. Hinweis: Hier werden Backticks verwendet, um mehrzeilige Strings zuzulassen. Nachdem du gültigen WGSL-Code hinzugefügt hast, gibt die Funktion ein GPUShaderModule
-Objekt mit den kompilierten Ergebnissen zurück.
Vertex-Shader definieren
Beginnen Sie mit dem Vertex-Shader, da auch die GPU dort beginnt.
Ein Vertex-Shader ist als Funktion definiert und die GPU ruft diese Funktion für jeden Scheitelpunkt in Ihrer vertexBuffer
einmal auf. Da Ihre vertexBuffer
sechs Positionen (Ecken) hat, wird die von Ihnen definierte Funktion sechsmal aufgerufen. Bei jedem Aufruf wird der Funktion als Argument eine andere Position aus dem vertexBuffer
übergeben. Die Aufgabe der Vertex-Shader-Funktion besteht darin, eine entsprechende Position im Clip-Raum zurückzugeben.
Außerdem werden sie nicht unbedingt in einer bestimmten Reihenfolge aufgerufen. Stattdessen eignen sich GPUs hervorragend für die parallele Ausführung solcher Shader und können potenziell Hunderte (oder sogar Tausende!) von Vertexes gleichzeitig verarbeiten. Das ist ein großer Teil der Faktoren, die für die hohe Geschwindigkeit von GPUs verantwortlich sind, aber es bringt auch Einschränkungen mit sich. Um eine extreme Parallelisierung zu ermöglichen, können Vertex-Shader nicht miteinander kommunizieren. Jeder Shader-Aufruf kann jeweils nur Daten für einen einzelnen Scheitelpunkt sehen und nur Werte für einen einzelnen Scheitelpunkt ausgeben.
In WGSL kann eine Vertex-Shader-Funktion einen beliebigen Namen haben, muss aber das @vertex
-Attribut vorangestellt haben, um anzugeben, welche Shader-Phase sie darstellt. In WGSL werden Funktionen mit dem Schlüsselwort fn
angegeben, Argumente werden in Klammern deklariert und der Gültigkeitsbereich wird mit geschweiften Klammern definiert.
- Erstellen Sie eine leere
@vertex
-Funktion:
index.html (Code für createShaderModule)
@vertex
fn vertexMain() {
}
Das ist jedoch nicht zulässig, da ein Vertex-Shader mindestens die endgültige Position des zu verarbeitenden Vertex im Clip-Raum zurückgeben muss. Dies wird immer als vierdimensionaler Vektor angegeben. Vektoren werden so häufig in Shadern verwendet, dass sie in der Sprache als erstklassige Primitive behandelt werden. Es gibt eigene Typen wie vec4f
für einen vierdimensionalen Vektor. Es gibt ähnliche Typen für 2D-Vektoren (vec2f
) und 3D-Vektoren (vec3f
).
- Wenn Sie angeben möchten, dass der zurückgegebene Wert die erforderliche Position ist, kennzeichnen Sie ihn mit dem
@builtin(position)
-Attribut. Mit dem Symbol->
wird angegeben, was die Funktion zurückgibt.
index.html (createShaderModule-Code)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
}
Wenn die Funktion einen Rückgabetyp hat, müssen Sie im Funktionskörper natürlich einen Wert zurückgeben. Mit der Syntax vec4f(x, y, z, w)
können Sie eine neue vec4f
zum Zurückgeben erstellen. Die Werte x
, y
und z
sind Gleitkommazahlen, die im Rückgabewert angeben, wo der Scheitelpunkt im Clipbereich liegt.
- Wenn Sie den statischen Wert
(0, 0, 0, 1)
zurückgeben, haben Sie eigentlich einen gültigen Vertex-Shader, obwohl dieser nie etwas anzeigt, da die GPU erkennt, dass die erzeugten Dreiecke nur ein einzelner Punkt sind, und verwirft ihn dann.
index.html (createShaderModule-Code)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}
Stattdessen möchten Sie die Daten aus dem von Ihnen erstellten Puffer verwenden. Dazu deklarieren Sie ein Argument für Ihre Funktion mit einem @location()
-Attribut und einem Typ, der den in der vertexBufferLayout
beschriebenen entspricht. Sie haben 0
als shaderLocation
angegeben. Markieren Sie das Argument in Ihrem WGSL-Code daher mit @location(0)
. Sie haben das Format auch als float32x2
definiert, also als 2D-Vektor. In WGSL ist Ihr Argument also ein vec2f
. Sie können ihm einen beliebigen Namen geben. Da es sich dabei um die Knotenpositionen handelt, ist ein Name wie pos naheliegend.
- Ändern Sie die Shaderfunktion in den folgenden Code:
index.html (createShaderModule-Code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(0, 0, 0, 1);
}
Und jetzt müssen Sie diese Position zurückgeben. Da die Position ein 2D-Vektor und der Rückgabetyp ein 4D-Vektor ist, müssen Sie ihn ein wenig ändern. Sie müssen die beiden Komponenten aus dem Positionierungsargument in die ersten beiden Komponenten des Rückgabevektors einfügen und die letzten beiden Komponenten jeweils als 0
und 1
belassen.
- Gibt die korrekte Position zurück, indem Sie explizit angeben, welche Positionskomponenten verwendet werden sollen:
index.html (createShaderModule-Code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos.x, pos.y, 0, 1);
}
Allerdings, da diese Art von Zuordnungen in Shadern so weit verbreitet ist, können Sie den Positionsvektor auch in einer praktischen Kurzschreibweise als erstes Argument übergeben. Das bedeutet dasselbe.
- Ersetzen Sie die
return
-Anweisung durch den folgenden Code:
index.html (createShaderModule-Code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
Das ist Ihr erster Vertex-Shader. Es ist sehr einfach, da die Position praktisch unverändert übergeben wird. Es ist aber ein guter Ausgangspunkt.
Fragment-Shader definieren
Als Nächstes folgt der Fragment-Shader. Fragment-Shader funktionieren sehr ähnlich wie Vertex-Shader, werden aber nicht für jeden Vertex, sondern für jedes gemalte Pixel aufgerufen.
Fragment-Shader werden immer nach Vertex-Shadern aufgerufen. Die GPU nimmt die Ausgabe der Vertex-Shader und trianguliert sie, indem Dreiecke aus drei Punkten erstellt werden. Anschließend werden alle diese Dreiecke rasterisiert, indem ermittelt wird, welche Pixel der Ausgabefarbanhänge in diesem Dreieck enthalten sind. Der Fragment-Shader wird dann einmal für jedes dieser Pixel aufgerufen. Der Fragment-Shader gibt eine Farbe zurück, die in der Regel aus Werten berechnet wird, die vom Vertex-Shader und Assets wie Texturen an ihn gesendet werden. Die GPU schreibt diese Farbe in den Farb-Anschluss.
Genau wie Vertex-Shader werden Fragment-Shader massiv parallel ausgeführt. Sie sind in Bezug auf ihre Eingaben und Ausgaben etwas flexibler als Vertex-Shader, geben aber einfach eine Farbe für jedes Pixel jedes Dreiecks zurück.
Eine WGSL-Fragment-Shader-Funktion wird mit dem @fragment
-Attribut gekennzeichnet und gibt auch einen vec4f
zurück. In diesem Fall steht der Vektor jedoch für eine Farbe, nicht für eine Position. Dem Rückgabewert muss ein @location
-Attribut zugewiesen werden, um anzugeben, in welches colorAttachment
aus dem beginRenderPass
-Aufruf die zurückgegebene Farbe geschrieben wird. Da Sie nur einen Anhang hatten, ist der Standort „0“.
- Erstellen Sie eine leere
@fragment
-Funktion:
index.html (createShaderModule-Code)
@fragment
fn fragmentMain() -> @location(0) vec4f {
}
Die vier Komponenten des zurückgegebenen Vektors sind die Farbwerte Rot, Grün, Blau und Alpha. Sie werden genau so interpretiert wie die clearValue
, die Sie zuvor in beginRenderPass
festgelegt haben. vec4f(1, 0, 0, 1)
ist also knallrot, was eine gute Farbe für Ihr Quadrat zu sein scheint. Du kannst aber auch eine andere Farbe wählen.
- Legen Sie den zurückgegebenen Farbvektor so fest:
index.html (createShaderModule-Code)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}
Das ist ein vollständiger Fragment-Shader. Sie ist nicht besonders interessant, da sie nur jedes Pixel jedes Dreiecks rot einfärbt. Das reicht aber fürs Erste.
Zur Wiederholung: Nachdem Sie den oben beschriebenen Shadercode hinzugefügt haben, sieht der createShaderModule
-Aufruf jetzt so aus:
index.html
const cellShaderModule = device.createShaderModule({
label: 'Cell shader',
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
`
});
Renderpipeline erstellen
Ein Shader-Modul kann nicht allein für das Rendering verwendet werden. Stattdessen müssen Sie sie als Teil einer GPURenderPipeline
verwenden, die Sie durch Aufrufen von device.createRenderPipeline() erstellen. Die Renderpipeline steuert, wie die Geometrie gezeichnet wird, einschließlich der Informationen dazu, welche Shader verwendet werden, wie Daten in Vertex-Buffers interpretiert werden und welche Art von Geometrie gerendert werden soll (Linien, Punkte, Dreiecke usw.).
Die Renderpipeline ist das komplexeste Objekt in der gesamten API. Aber keine Sorge! Die meisten der Werte, die Sie übergeben können, sind optional und Sie müssen zu Beginn nur wenige Werte angeben.
- Erstellen Sie wie folgt eine Rendering-Pipeline:
index.html
const cellPipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: "auto",
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
Jede Pipeline benötigt ein layout
, das beschreibt, welche Arten von Eingaben (mit Ausnahme von Vertex-Zwischenspeichern) die Pipeline benötigt. Sie haben jedoch nicht wirklich welche. Glücklicherweise können Sie "auto"
vorerst übergeben und die Pipeline erstellt dann ein eigenes Layout aus den Shadern.
Als Nächstes müssen Sie Details zur Phase vertex
angeben. module
ist das GPUShaderModule, das Ihren Vertex-Shader enthält. entryPoint
gibt den Namen der Funktion im Shader-Code an, der für jeden Vertex-Aufruf aufgerufen wird. (Sie können mehrere @vertex
- und @fragment
-Funktionen in einem einzigen Shadermodul haben.) buffers ist ein Array von GPUVertexBufferLayout
-Objekten, das beschreibt, wie Ihre Daten in den Vertex-Buffers verpackt sind, mit denen Sie diese Pipeline verwenden. Glücklicherweise haben Sie das bereits in Ihrer vertexBufferLayout
definiert. Hier geben Sie es ab.
Schließlich sehen Sie Details zur Phase fragment
. Dazu gehören auch ein Shader-Modul und ein Eingabepunkt, ähnlich wie bei der Vertex-Phase. Mit dem letzten Bit wird das targets
definiert, mit dem diese Pipeline verwendet wird. Dies ist ein Array von Wörterbüchern mit Details wie der Textur format
der Farbanhänge, die von der Pipeline ausgegeben werden. Diese Details müssen mit den Texturen in der colorAttachments
aller Renderpässe übereinstimmen, mit denen diese Pipeline verwendet wird. Ihr Renderer-Pass verwendet Texturen aus dem Canvas-Kontext und den Wert, den Sie in canvasFormat
gespeichert haben, als Format. Daher geben Sie hier dasselbe Format an.
Das sind bei weitem nicht alle Optionen, die Sie beim Erstellen einer Rendering-Pipeline angeben können, aber für die Anforderungen dieses Codelabs reicht es aus.
Quadrat zeichnen
Damit haben Sie jetzt alles, was Sie zum Zeichnen Ihres Quadrats benötigen.
- Um das Quadrat zu zeichnen, springen Sie wieder nach unten zum Aufrufpaar
encoder.beginRenderPass()
undpass.end()
und fügen Sie die folgenden neuen Befehle dazwischen ein:
index.html
// After encoder.beginRenderPass()
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices
// before pass.end()
Dadurch werden der WebGPU alle Informationen zur Verfügung gestellt, die zum Zeichnen des Quadrats erforderlich sind. Geben Sie zuerst mit setPipeline()
an, mit welcher Pipeline gezeichnet werden soll. Dazu gehören die verwendeten Shader, das Layout der Vertexdaten und andere relevante Statusdaten.
Als Nächstes rufen Sie setVertexBuffer()
mit dem Buffer auf, der die Eckpunkte für das Quadrat enthält. Sie rufen ihn mit 0
auf, da dieser Puffer dem Nullelement in der vertex.buffers
-Definition der aktuellen Pipeline entspricht.
Zum Schluss führen Sie den Aufruf draw()
aus, was nach all der vorherigen Einrichtung seltsam einfach erscheint. Das einzige, was Sie übergeben müssen, ist die Anzahl der Scheitelpunkte, die gerendert werden soll. Sie ruft diese aus den aktuell festgelegten Scheitelpunktzwischenspeichern ab und interpretiert sie mit der aktuell festgelegten Pipeline. Sie könnten es einfach in 6
hartcodieren. Wenn Sie es jedoch aus dem Array der Eckpunkte (12 Gleitkommazahlen / 2 Koordinaten pro Scheitelpunkt == 6 Eckpunkte) berechnen, müssen Sie weniger manuell aktualisieren, wenn Sie das Quadrat z. B. durch einen Kreis ersetzen müssen.
- Aktualisieren Sie das Display und sehen Sie sich (endlich) die Ergebnisse Ihrer harten Arbeit an: ein großes farbiges Quadrat.
5. Raster zeichnen
Zuerst möchten wir Ihnen gratulieren! Das Einblenden der ersten Geometrieelemente auf dem Bildschirm ist bei den meisten GPU-APIs oft einer der schwierigsten Schritte. Alles, was Sie ab jetzt tun, kann in kleineren Schritten erfolgen, sodass Sie Ihren Fortschritt leichter überprüfen können.
In diesem Abschnitt lernen Sie Folgendes:
- So übergeben Sie Variablen (Uniforms) aus JavaScript an den Shader.
- So ändern Sie das Rendering-Verhalten mithilfe von Uniforms.
- So zeichnen Sie mithilfe von Instanzen viele verschiedene Varianten derselben Geometrie.
Raster definieren
Um ein Raster zu rendern, müssen Sie eine sehr grundlegende Information dazu kennen. Wie viele Zellen enthält es, sowohl in der Breite als auch in der Höhe? Das liegt in Ihrem Ermessen als Entwickler. Zur Vereinfachung sollten Sie das Raster jedoch als Quadrat (mit gleicher Breite und Höhe) betrachten und eine Größe verwenden, die eine Potenz von 2 ist. (Das erleichtert später einige Berechnungen.) Sie werden es später vergrößern, aber für den Rest dieses Abschnitts legen Sie die Rastergröße auf 4 × 4 fest, da sich einige der in diesem Abschnitt verwendeten mathematischen Konzepte so leichter veranschaulichen lassen. Danach kannst du es vergrößern.
- Definieren Sie die Rastergröße, indem Sie oben in Ihrem JavaScript-Code eine Konstante hinzufügen.
index.html
const GRID_SIZE = 4;
Als Nächstes müssen Sie die Darstellung des Quadrats so anpassen, dass es GRID_SIZE
× GRID_SIZE
mal auf dem Canvas passt. Das bedeutet, dass das Quadrat viel kleiner sein muss und es viele davon geben muss.
Eine Möglichkeit, dies zu bewerkstelligen, besteht darin, den Vertex-Puffer deutlich zu vergrößern und darin GRID_SIZE
× GRID_SIZE
Quadrate mit der richtigen Größe und Position zu definieren. Der Code dafür wäre gar nicht so schlecht. Nur ein paar For-Schleifen und ein bisschen Mathematik. Allerdings wird dadurch auch die GPU nicht optimal genutzt und mehr Arbeitsspeicher verbraucht, als für den Effekt erforderlich ist. In diesem Abschnitt wird ein GPU-freundlicherer Ansatz beschrieben.
Einen einheitlichen Puffer erstellen
Zuerst müssen Sie dem Shader die von Ihnen ausgewählte Rastergröße mitteilen, da er damit die Darstellung ändert. Sie könnten die Größe einfach in den Shader hartcodieren, aber dann müssen Sie jedes Mal, wenn Sie die Rastergröße ändern möchten, den Shader und die Renderpipeline neu erstellen, was sehr aufwendig ist. Besser ist es, die Rastergröße dem Shader als Uniformen zur Verfügung zu stellen.
Sie haben bereits gelernt, dass bei jeder Aufrufung eines Vertex-Shaders ein anderer Wert aus dem Vertex-Buffer übergeben wird. Ein Uniform ist ein Wert aus einem Buffer, der bei jeder Aufrufung gleich ist. Sie sind nützlich, um Werte zu übermitteln, die für ein Element der Geometrie (wie seine Position), einen ganzen Frame einer Animation (z. B. die aktuelle Zeit) oder sogar die gesamte Lebensdauer der App (z. B. eine Nutzereinstellung) üblich sind.
- Fügen Sie den folgenden Code hinzu, um einen einheitlichen Buffer zu erstellen:
index.html
// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
label: "Grid Uniforms",
size: uniformArray.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);
Das sollte Ihnen sehr vertraut vorkommen, da es sich fast genau um denselben Code handelt, mit dem Sie zuvor den Vertex-Buffer erstellt haben. Das liegt daran, dass Uniforms über dieselben GPUBuffer-Objekte wie Vertex an die WebGPU API gesendet werden. Der Hauptunterschied besteht darin, dass usage
diesmal GPUBufferUsage.UNIFORM
anstelle von GPUBufferUsage.VERTEX
enthält.
Auf Uniforms in einem Shader zugreifen
- Definieren Sie eine Uniform, indem Sie den folgenden Code hinzufügen:
index.html (createShaderModule-Aufruf)
// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos / grid, 0, 1);
}
// ...fragmentMain is unchanged
Dadurch wird eine Uniform namens grid
in Ihrem Shader definiert. Dies ist ein 2D-Gleitkommavektor, der mit dem Array übereinstimmt, das Sie gerade in den Uniform-Puffer kopiert haben. Außerdem wird angegeben, dass die Uniform an @group(0)
und @binding(0)
gebunden ist. Was diese Werte bedeuten, erfahren Sie gleich.
An anderer Stelle im Shadercode können Sie den Rastervektor dann nach Bedarf verwenden. In diesem Code wird die Position des Knotens durch den Rastervektor geteilt. Da pos
ein 2D-Vektor und grid
ein 2D-Vektor ist, führt WGSL eine komponentenweise Division durch. Mit anderen Worten: Das Ergebnis ist dasselbe wie bei vec2f(pos.x / grid.x, pos.y / grid.y)
.
Diese Arten von Vektorvorgängen sind bei GPU-Shadern sehr üblich, da viele Rendering- und Computing-Techniken darauf basieren.
In Ihrem Fall bedeutet das, dass das gerenderte Quadrat (bei einer Rastergröße von 4) ein Viertel seiner ursprünglichen Größe hat. Das ist perfekt, wenn Sie vier davon in eine Zeile oder Spalte einfügen möchten.
Bindungsgruppe erstellen
Durch die Deklarierung der Uniform im Shader wird sie jedoch nicht mit dem von Ihnen erstellten Buffer verbunden. Dazu müssen Sie eine Bindungsgruppe erstellen und festlegen.
Eine Bindungsgruppe ist eine Sammlung von Ressourcen, die Sie für Ihren Shader gleichzeitig zugänglich machen möchten. Es kann mehrere Arten von Buffers enthalten, z. B. Ihren Uniform-Buffer, und andere Ressourcen wie Texturen und Sampler, die hier nicht behandelt werden, aber gängige Bestandteile von WebGPU-Rendering-Techniken sind.
- Erstellen Sie eine Bindungsgruppe mit dem Uniform-Buffer. Fügen Sie dazu nach dem Erstellen des Uniform-Buffers und der Renderpipeline den folgenden Code hinzu:
index.html
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}],
});
Zusätzlich zu der mittlerweile standardmäßigen label
benötigen Sie auch eine layout
, die beschreibt, welche Arten von Ressourcen diese Bindungsgruppe enthält. Darauf gehen Sie in einem späteren Schritt noch genauer ein. Im Moment können Sie aber ganz einfach die Bindungsgruppe aus Ihrer Pipeline abrufen, da Sie die Pipeline mit layout: "auto"
erstellt haben. Dadurch erstellt die Pipeline automatisch Bindungsgruppenlayouts aus den Bindungen, die Sie im Shader-Code selbst deklariert haben. In diesem Fall bitten Sie es, getBindGroupLayout(0)
zu tun, wobei 0
der @group(0)
entspricht, die Sie in den Shader eingegeben haben.
Nachdem Sie das Layout festgelegt haben, geben Sie ein Array von entries
an. Jeder Eintrag ist ein Wörterbuch mit mindestens den folgenden Werten:
binding
, entspricht dem@binding()
-Wert, den Sie im Shader eingegeben haben. In diesem Fall ist das0
.resource
, die tatsächliche Ressource, die der Variablen am angegebenen Bindungsindex zur Verfügung gestellt werden soll. In diesem Fall ist das der einheitliche Puffer.
Die Funktion gibt einen GPUBindGroup
zurück, also einen nicht transparenten, unveränderlichen Handle. Die Ressourcen, auf die eine Bindungsgruppe verweist, können nach der Erstellung nicht mehr geändert werden. Der Inhalt dieser Ressourcen kann jedoch geändert werden. Wenn Sie beispielsweise den Uniform-Puffer so ändern, dass er eine neue Rastergröße enthält, wird dies in zukünftigen Draw-Aufrufen mit dieser Bindungsgruppe berücksichtigt.
Bindegruppe binden
Nachdem die Bindungsgruppe erstellt wurde, müssen Sie WebGPU noch mitteilen, dass sie beim Zeichnen verwendet werden soll. Glücklicherweise ist das ziemlich einfach.
- Kehren Sie zum Renderdurchgang zurück und fügen Sie vor der
draw()
-Methode diese neue Zeile hinzu:
index.html
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setBindGroup(0, bindGroup); // New line!
pass.draw(vertices.length / 2);
Das als erstes Argument übergebene 0
entspricht dem @group(0)
im Shadercode. Sie geben an, dass jeder @binding
, der zu @group(0)
gehört, die Ressourcen in dieser Bindungsgruppe verwendet.
Jetzt ist der Uniform-Puffer für Ihren Shader verfügbar.
- Aktualisieren Sie die Seite. Es sollte dann in etwa so aussehen:
Super! Dein Square ist jetzt ein Viertel so groß wie vorher! Das ist nicht viel, aber es zeigt, dass die Uniform tatsächlich angewendet wird und dass der Shader jetzt auf die Größe des Rasters zugreifen kann.
Geometrie im Shader bearbeiten
Da Sie jetzt die Rastergröße im Shader referenzieren können, können Sie mit der Bearbeitung der zu rendernden Geometrie beginnen, um sie an das gewünschte Rastermuster anzupassen. Überlegen Sie dazu genau, was Sie erreichen möchten.
Sie müssen Ihren Canvas konzeptionell in einzelne Zellen unterteilen. Um der Konvention zu folgen, dass die X-Achse nach rechts hin ansteigt und die Y-Achse nach oben hin, nehmen wir an, dass sich die erste Zelle im linken unteren Eck des Canvas befindet. Das Ergebnis sieht so aus, mit Ihrer aktuellen quadratischen Geometrie in der Mitte:
Ihre Aufgabe besteht darin, eine Methode im Shader zu finden, mit der Sie die quadratische Geometrie in einer dieser Zellen anhand der Zellenkoordinaten positionieren können.
Zunächst sehen Sie, dass Ihr Quadrat nicht genau an einer der Zellen ausgerichtet ist, da es so definiert wurde, dass es die Mitte des Canvas umgibt. Sie sollten das Quadrat um eine halbe Zelle verschieben, damit es gut darin passt.
Sie können das Problem beispielsweise beheben, indem Sie den Vertex-Buffer des Quadrats aktualisieren. Indem Sie die Eckpunkte so verschieben, dass sich die linke untere Ecke beispielsweise bei (0.1, 0.1) statt bei (-0.8, -0.8) befindet, würden Sie dieses Quadrat so verschieben, dass es besser an den Zellengrenzen liegt. Da Sie jedoch die volle Kontrolle darüber haben, wie die Vertexe in Ihrem Shader verarbeitet werden, ist es genauso einfach, sie mit dem Shadercode an die richtige Stelle zu verschieben.
- Ändern Sie das Vertex-Shader-Modul mit dem folgenden Code:
index.html (createShaderModule-Aufruf)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
// Add 1 to the position before dividing by the grid size.
let gridPos = (pos + 1) / grid;
return vec4f(gridPos, 0, 1);
}
Dadurch wird jeder Punkt um eins nach oben und rechts verschoben (was, wie Sie sich erinnern, der Hälfte des Clipbereichs entspricht), bevor er durch die Rastergröße geteilt wird. Das Ergebnis ist ein gut am Raster ausgerichtetes Quadrat, das sich nur unwesentlich vom Ursprung entfernt befindet.
Da im Koordinatensystem Ihres Canvas (0, 0) in der Mitte und (-1, -1) links unten steht und Sie (0, 0) links unten haben möchten, müssen Sie die Position Ihrer Geometrie um (-1, -1) verschieben, nachdem Sie sie durch die Rastergröße geteilt haben, um sie in diese Ecke zu verschieben.
- Verschieben Sie die Position der Geometrie so:
index.html (createShaderModule-Aufruf)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
// Subtract 1 after dividing by the grid size.
let gridPos = (pos + 1) / grid - 1;
return vec4f(gridPos, 0, 1);
}
Jetzt ist das Quadrat schön in der Zelle (0, 0) positioniert.
Was ist, wenn Sie es in eine andere Zelle einfügen möchten? Ermitteln Sie das, indem Sie in Ihrem Shader einen cell
-Vektor deklarieren und mit einem statischen Wert wie let cell = vec2f(1, 1)
füllen.
Wenn Sie das zu gridPos
hinzufügen, wird die - 1
im Algorithmus rückgängig gemacht. Das ist nicht das, was Sie möchten. Stattdessen sollten Sie das Quadrat für jede Zelle nur um eine Rastereinheit (ein Viertel des Canvas) verschieben. Sie müssen also noch einmal durch grid
teilen.
- So ändern Sie die Rasterposition:
index.html (createShaderModule-Aufruf)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
let cell = vec2f(1, 1); // Cell(1,1) in the image above
let cellOffset = cell / grid; // Compute the offset to cell
let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!
return vec4f(gridPos, 0, 1);
}
Wenn Sie die Seite jetzt aktualisieren, sehen Sie Folgendes:
Hm. Nicht ganz das, was du wolltest.
Der Grund dafür ist, dass der Abstand der Canvas-Koordinaten von -1 zu +1 tatsächlich zwei Einheiten beträgt. Wenn Sie also einen Punkt um ein Viertel des Canvas verschieben möchten, müssen Sie ihn um 0,5 Einheiten verschieben. Das ist ein Fehler, der beim Umgang mit GPU-Koordinaten leicht passieren kann. Zum Glück ist die Lösung genauso einfach.
- Multiplizieren Sie Ihr Versatz wie folgt mit 2:
index.html (createShaderModule-Aufruf)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
let cell = vec2f(1, 1);
let cellOffset = cell / grid * 2; // Updated
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
So erhalten Sie genau das, was Sie sich wünschen.
Der Screenshot sieht so aus:
Außerdem können Sie jetzt cell
auf einen beliebigen Wert innerhalb der Rastergrenzen setzen und dann aktualisieren, damit das Quadrat an der gewünschten Stelle gerendert wird.
Instanzen zeichnen
Nachdem Sie das Quadrat mithilfe von etwas Mathematik an der gewünschten Stelle platziert haben, besteht der nächste Schritt darin, in jeder Zelle des Rasters ein Quadrat zu rendern.
Eine Möglichkeit besteht darin, Zellenkoordinaten in einen einheitlichen Puffer zu schreiben und dann draw einmal für jedes Quadrat im Raster aufzurufen und dabei jedes Mal die Uniform zu aktualisieren. Das wäre jedoch sehr langsam, da die GPU jedes Mal darauf warten muss, dass die neue Koordinate von JavaScript geschrieben wird. Einer der Schlüssel zu einer guten Leistung der GPU besteht darin, die Zeit zu minimieren, die sie auf andere Teile des Systems wartet.
Stattdessen können Sie eine Methode verwenden, die als Instanzerstellung bezeichnet wird. Mit einer Instanz können Sie die GPU anweisen, mehrere Kopien derselben Geometrie mit einem einzigen Aufruf von draw
zu erstellen, was viel schneller ist als das einmalige Aufrufen von draw
für jede Kopie. Jede Kopie der Geometrie wird als Instanz bezeichnet.
- Fügen Sie dem vorhandenen Zeichenaufruf ein Argument hinzu, um der GPU mitzuteilen, dass genügend Instanzen Ihres Quadrats zum Füllen des Rasters vorhanden sein sollen:
index.html
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
Damit wird dem System mitgeteilt, dass die sechs (vertices.length / 2
) Eckpunkte des Quadrats 16 (GRID_SIZE * GRID_SIZE
) Mal gezeichnet werden sollen. Wenn Sie die Seite aktualisieren, wird jedoch weiterhin Folgendes angezeigt:
Warum? Das liegt daran, dass Sie alle 16 Quadrate an derselben Stelle zeichnen. Sie benötigen zusätzliche Logik im Shader, die die Geometrie pro Instanz neu positioniert.
Im Shader können Sie nicht nur auf die Vertex-Attribute wie pos
zugreifen, die aus Ihrem Vertex-Buffer stammen, sondern auch auf die sogenannten eingebauten Werte von WGSL. Diese Werte werden von WebGPU berechnet. Dazu gehört auch instance_index
. instance_index
ist eine vorzeichenlose 32‑Bit-Zahl von 0
bis number of instances - 1
, die Sie als Teil Ihrer Shaderlogik verwenden können. Der Wert ist für jeden verarbeiteten Knoten, der zu derselben Instanz gehört, gleich. Das bedeutet, dass Ihr Vertex-Shader sechsmal mit einem instance_index
von 0
aufgerufen wird, einmal für jede Position in Ihrem Vertex-Buffer. Dann weitere sechs Mal mit einem instance_index
von 1
, sechs weitere Male mit einem instance_index
von 2
und so weiter.
Um dies zu sehen, müssen Sie das instance_index
-Builtin zu Ihren Shader-Eingängen hinzufügen. Gehe dabei genauso vor wie bei der Position, aber verwende statt eines @location
-Attributs @builtin(instance_index)
und benenne das Argument dann beliebig. Sie können sie instance
nennen, um dem Beispielcode zu entsprechen. Verwenden Sie es dann als Teil der Shaderlogik.
- Verwenden Sie
instance
anstelle der Zellenkoordinaten:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance); // Save the instance_index as a float
let cell = vec2f(i, i); // Updated
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
Wenn Sie jetzt aktualisieren, sehen Sie, dass tatsächlich mehr als ein Quadrat vorhanden ist. Sie können jedoch nicht alle 16 sehen.
Das liegt daran, dass die Zellenkoordinaten, die Sie generieren, wie folgt lauten: (0, 0), (1, 1), (2, 2)... bis (15, 15), aber nur die ersten vier davon passen auf den Canvas. Um das gewünschte Raster zu erstellen, müssen Sie den instance_index
so transformieren, dass jeder Index einer eindeutigen Zelle innerhalb des Rasters zugeordnet ist. Beispiel:
Die Berechnung ist relativ einfach. Für den X-Wert jeder Zelle benötigen Sie den modulo von instance_index
und die Rasterbreite. Diese können Sie in WGSL mit dem Operator %
durchführen. Für den Y-Wert jeder Zelle soll instance_index
durch die Rasterbreite geteilt werden, wobei alle Bruchteile verworfen werden. Dazu können Sie die Funktion floor()
von WGSL verwenden.
- Ändern Sie die Berechnungen so:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
// Compute the cell coordinate from the instance_index
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
Nachdem Sie diese Änderung am Code vorgenommen haben, sehen Sie endlich das lang erwartete Quadratraster.
- Und jetzt, da es funktioniert, können Sie zurückgehen und die Rastergröße erhöhen.
index.html
const GRID_SIZE = 32;
Tada! Sie können dieses Raster jetzt sehr groß machen und Ihre durchschnittliche GPU kommt damit problemlos zurecht. Die einzelnen Quadrate sind nicht mehr zu sehen, lange bevor es zu Engpässen bei der GPU-Leistung kommt.
6. Zusatzpunkte: Machen Sie es bunter!
Sie können jetzt einfach zum nächsten Abschnitt springen, da Sie die Grundlagen für den Rest des Codelabs gelegt haben. Das Raster aus Quadraten, die alle dieselbe Farbe haben, lässt sich gut bedienen, ist aber nicht gerade aufregend, oder? Glücklicherweise können Sie mit ein bisschen mehr Mathematik und Shadercode die Dinge etwas heller machen.
Strukturen in Shadern verwenden
Bisher haben Sie ein Datenelement aus dem Vertex-Shader übergeben: die transformierte Position. Sie können jedoch viel mehr Daten aus dem Vertex-Shader zurückgeben und dann im Fragment-Shader verwenden.
Daten können nur durch Rückgabe aus dem Vertex-Shader übergeben werden. Ein Vertex-Shader muss immer eine Position zurückgeben. Wenn Sie zusätzlich andere Daten zurückgeben möchten, müssen Sie diese in einem Struct platzieren. Strukturen in WGSL sind benannte Objekttypen, die eine oder mehrere benannte Properties enthalten. Die Attribute können auch mit Attributen wie @builtin
und @location
ausgezeichnet werden. Sie werden außerhalb von Funktionen deklariert und Sie können Instanzen davon bei Bedarf in Funktionen übergeben und aus Funktionen zurückgeben. Betrachten Sie Ihren aktuellen Scheitel-Shader:
index.html (createShaderModule-Aufruf)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
- Mithilfe von Strukturen für die Funktionsein- und ‑ausgabe lässt sich dasselbe ausdrücken:
index.html (createShaderModule-Aufruf)
struct VertexInput {
@location(0) pos: vec2f,
@builtin(instance_index) instance: u32,
};
struct VertexOutput {
@builtin(position) pos: vec4f,
};
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
let i = f32(input.instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
return output;
}
Beachten Sie, dass Sie hierfür mit input
auf die Eingabeposition und den Instanzindex verweisen müssen. Die Struktur, die Sie zuerst zurückgeben, muss als Variable deklariert werden und ihre einzelnen Attribute müssen festgelegt werden. In diesem Fall macht es keinen großen Unterschied und die Shaderfunktion wird sogar etwas länger. Wenn Ihre Shader jedoch komplexer werden, kann die Verwendung von Strukturen eine gute Möglichkeit sein, Ihre Daten zu organisieren.
Daten zwischen Vertex- und Fragmentfunktionen übergeben
Zur Erinnerung: Ihre @fragment
-Funktion ist so einfach wie möglich:
index.html (createShaderModule-Aufruf)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
Sie nehmen keine Eingaben entgegen und geben eine durchgehende Farbe (rot) als Ausgabe aus. Wenn der Shader jedoch mehr über die Geometrie wüsste, die er färbt, könnten Sie diese zusätzlichen Daten verwenden, um die Dinge etwas interessanter zu gestalten. Was ist beispielsweise, wenn Sie die Farbe jedes Quadrats basierend auf seiner Zellenkoordinate ändern möchten? Die @vertex
-Ebene weiß, welche Zelle gerendert wird. Sie müssen sie nur an die @fragment
-Ebene weitergeben.
Wenn Sie Daten zwischen der Vertex- und der Fragment-Phase übergeben möchten, müssen Sie sie in einem Ausgabe-Struct mit einem @location
Ihrer Wahl einfügen. Da Sie die Zellenkoordinate übergeben möchten, fügen Sie sie dem VertexOutput
-String aus dem vorherigen Abschnitt hinzu und legen Sie sie dann in der @vertex
-Funktion fest, bevor Sie zurückkehren.
- Ändern Sie den Rückgabewert Ihres Vertex-Shaders so:
index.html (createShaderModule-Aufruf)
struct VertexInput {
@location(0) pos: vec2f,
@builtin(instance_index) instance: u32,
};
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) cell: vec2f, // New line!
};
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
let i = f32(input.instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
output.cell = cell; // New line!
return output;
}
- In der Funktion
@fragment
können Sie den Wert abrufen, indem Sie ein Argument mit derselben@location
hinzufügen. Die Namen müssen nicht übereinstimmen, aber es ist einfacher, den Überblick zu behalten.
index.html (createShaderModule-Aufruf)
@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
// Remember, fragment return values are (Red, Green, Blue, Alpha)
// and since cell is a 2D vector, this is equivalent to:
// (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
return vec4f(cell, 0, 1);
}
- Alternativ können Sie auch ein Struct verwenden:
index.html (createShaderModule-Aufruf)
struct FragInput {
@location(0) cell: vec2f,
};
@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
- Da in Ihrem Code beide Funktionen im selben Shader-Modul definiert sind, können Sie auch die Ausgabestruktur der
@vertex
-Phase wiederverwenden. Das erleichtert das Übergeben von Werten, da die Namen und Speicherorte natürlich konsistent sind.
index.html (createShaderModule-Aufruf)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
Unabhängig davon, welches Muster Sie ausgewählt haben, haben Sie Zugriff auf die Zellennummer in der @fragment
-Funktion und können sie verwenden, um die Farbe zu beeinflussen. Bei jedem der obigen Codes sieht die Ausgabe so aus:
Es gibt jetzt definitiv mehr Farben, aber es sieht nicht gerade schön aus. Sie fragen sich vielleicht, warum nur die linke und die untere Zeile unterschiedlich sind. Das liegt daran, dass die Farbwerte, die Sie über die Funktion @fragment
zurückgeben, davon ausgehen, dass sich jeder Kanal im Bereich von 0 bis 1 befindet. Alle Werte außerhalb dieses Bereichs werden auf diesen Bereich begrenzt. Die Zellenwerte reichen dagegen entlang jeder Achse von 0 bis 32. Hier sehen Sie, dass die erste Zeile und Spalte sofort den vollen Wert 1 entweder im roten oder grünen Farbkanal erreichen und jede Zelle danach auf denselben Wert begrenzt wird.
Wenn Sie einen fließenderen Übergang zwischen den Farben wünschen, müssen Sie für jeden Farbkanal einen Bruchteilwert zurückgeben, der idealerweise entlang jeder Achse bei null beginnt und bei eins endet. Das bedeutet noch eine weitere Division durch grid
.
- Ändern Sie den Fragment-Shader so:
index.html (createShaderModule-Aufruf)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell/grid, 0, 1);
}
Wenn Sie die Seite aktualisieren, sehen Sie, dass der neue Code einen viel schöneren Farbverlauf im gesamten Raster ergibt.
Das ist zwar eine Verbesserung, aber links unten ist jetzt eine dunkle Ecke zu sehen, in der das Raster schwarz wird. Wenn Sie mit der Simulation von „Das Leben“ beginnen, wird das Geschehen durch einen schwer zu erkennenden Bereich des Rasters verdeckt. Es wäre schön, wenn Sie das ändern könnten.
Glücklicherweise haben Sie einen ganzen ungenutzten Farbkanal – blau –, den Sie verwenden können. Idealerweise sollte Blau am hellsten erscheinen, während die anderen Farben am dunkelsten sind, und dann ausgeblendet werden, wenn die Intensität der anderen Farben zunimmt. Am einfachsten ist es, wenn der Kanal bei 1 start und einen der Zellenwerte subtrahiert. Die Beziehung kann entweder c.x
oder c.y
sein. Probiere beide aus und entscheide dich dann für das, was dir am besten gefällt!
- Fügen Sie dem Fragment-Shader hellere Farben hinzu, z. B. so:
createShaderModule-Aufruf
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let c = input.cell / grid;
return vec4f(c, 1-c.x, 1);
}
Das Ergebnis sieht ziemlich gut aus.
Dieser Schritt ist nicht kritisch. Da es aber besser aussieht, ist es in der entsprechenden Checkpoint-Quelldatei enthalten. Die restlichen Screenshots in diesem Codelab spiegeln dieses farbenfrohere Raster wider.
7. Zellenstatus verwalten
Als Nächstes müssen Sie festlegen, welche Zellen im Raster basierend auf einem Status gerendert werden, der auf der GPU gespeichert ist. Das ist wichtig für die abschließende Simulation.
Sie benötigen lediglich ein Ein-/Aus-Signal für jede Zelle. Daher sind alle Optionen geeignet, mit denen Sie eine große Auswahl an Werttypen speichern können. Vielleicht denken Sie, dass dies ein weiterer Anwendungsfall für einheitliche Puffer ist! Das ist zwar möglich, aber schwieriger, da Uniform-Buffers eine begrenzte Größe haben, keine Arrays mit dynamischer Größe unterstützen (die Arraygröße muss im Shader angegeben werden) und nicht von Compute-Shadern beschrieben werden können. Letzteres ist am problematischsten, da Sie die Simulation von „Das Leben“ auf der GPU in einem Compute-Shader ausführen möchten.
Glücklicherweise gibt es eine andere Pufferoption, mit der sich all diese Einschränkungen vermeiden lassen.
Speicherpuffer erstellen
Speicher-Buffers sind allgemeine Buffers, die in Compute-Shadern gelesen und geschrieben und in Vertex-Shadern gelesen werden können. Sie können sehr groß sein und benötigen keine bestimmte Größe in einem Shader, was sie eher mit allgemeinem Arbeitsspeicher vergleichbar macht. Damit wird der Zellenstatus gespeichert.
- Um einen Speicherpuffer für den Zellenstatus zu erstellen, verwenden Sie ein Code-Snippet zur Zwischenspeichererstellung, das Ihnen mittlerweile wahrscheinlich vertraut ist:
index.html
// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);
// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
label: "Cell State",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
Rufen Sie device.createBuffer()
mit der entsprechenden Größe auf und geben Sie diesmal die Verwendung GPUBufferUsage.STORAGE
an.
Sie können den Puffer auf die gleiche Weise wie zuvor füllen, indem Sie das TypedArray derselben Größe mit Werten füllen und dann device.queue.writeBuffer()
aufrufen. Da Sie die Auswirkungen Ihres Puffers auf das Raster sehen möchten, füllen Sie es zunächst mit etwas Berechenbarem.
- Aktivieren Sie mit dem folgenden Code jede dritte Zelle:
index.html
// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);
Speicherpuffer im Shader lesen
Aktualisieren Sie als Nächstes Ihren Shader, damit er den Inhalt des Speicherpuffers ansieht, bevor das Raster gerendert wird. Das funktioniert ähnlich wie beim Hinzufügen von Uniformen.
- Aktualisieren Sie den Shader mit dem folgenden Code:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!
Zuerst fügen Sie den Bindungspunkt hinzu, der sich direkt unter der einheitlichen Rasteransicht befindet. Sie möchten dieselbe @group
wie die grid
beibehalten, aber die @binding
-Zahl muss sich unterscheiden. Der var
-Typ ist storage
, um den unterschiedlichen Zwischenspeichertyp widerzuspiegeln. Der Typ, den Sie für cellState
angeben, ist ein Array von u32
-Werten, um dem Uint32Array
in JavaScript zu entsprechen.
Rufen Sie als Nächstes im Text der @vertex
-Funktion den Status der Zelle ab. Da der Status in einem flachen Array im Speicherbuffer gespeichert wird, können Sie mit instance_index
den Wert für die aktuelle Zelle abrufen.
Wie schaltet man eine Zelle aus, wenn der Status anzeigt, dass sie inaktiv ist? Da die aktiven und inaktiven Status, die Sie aus dem Array erhalten, „1“ oder „0“ sind, können Sie die Geometrie anhand des aktiven Status skalieren. Wenn Sie die Geometrie mit 1 skalieren, bleibt sie unverändert. Wenn Sie sie mit 0 skalieren, wird sie in einen einzelnen Punkt zusammengezogen, der dann von der GPU verworfen wird.
- Aktualisieren Sie Ihren Shadercode, um die Position anhand des aktiven Status der Zelle zu skalieren. Der Statuswert muss in einen
f32
umgewandelt werden, um die Anforderungen an die Typsicherheit von WGSL zu erfüllen:
index.html
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) -> VertexOutput {
let i = f32(instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let state = f32(cellState[instance]); // New line!
let cellOffset = cell / grid * 2;
// New: Scale the position by the cell's active state.
let gridPos = (pos*state+1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
output.cell = cell;
return output;
}
Speicherbuffer der Bindungsgruppe hinzufügen
Bevor der Zellenstatus wirksam wird, müssen Sie den Speicherpuffer einer Bindungsgruppe hinzufügen. Da es Teil desselben @group
wie der einheitliche Buffer ist, fügen Sie es auch derselben Bindungsgruppe im JavaScript-Code hinzu.
- Fügen Sie den Speicherpuffer so hinzu:
index.html
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
},
// New entry!
{
binding: 1,
resource: { buffer: cellStateStorage }
}],
});
Achte darauf, dass die binding
des neuen Eintrags mit der @binding()
des entsprechenden Werts im Shader übereinstimmt.
Danach sollten Sie das Raster aktualisieren und das Muster sehen können.
Ping-Pong-Puffer-Muster verwenden
Die meisten Simulationen wie die, die Sie gerade erstellen, verwenden normalerweise mindestens zwei Kopien ihres Zustands. Bei jedem Schritt der Simulation lesen sie aus einer Kopie des Status und schreiben in die andere. Im nächsten Schritt wird die Richtung umgekehrt und der zuvor geschriebene Status wird gelesen. Dies wird allgemein als Ping-Pong-Muster bezeichnet, da die aktuelle Version des Status bei jedem Schritt zwischen den Statuskopien hin- und hergesendet wird.
Warum ist das notwendig? Sehen wir uns ein vereinfachtes Beispiel an: Angenommen, Sie schreiben eine sehr einfache Simulation, in der Sie alle aktiven Blöcke bei jedem Schritt um eine Zelle nach rechts verschieben. Zur besseren Verständlichkeit definieren Sie Ihre Daten und die Simulation in JavaScript:
// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];
function simulate() {
for (let i = 0; i < state.length-1; ++i) {
if (state[i] == 1) {
state[i] = 0;
state[i+1] = 1;
}
}
}
simulate(); // Run the simulation for one step.
Wenn Sie diesen Code jedoch ausführen, wird die aktive Zelle in einem Schritt bis zum Ende des Arrays verschoben. Warum? Weil Sie den Status ständig aktualisieren, also verschieben Sie die aktive Zelle nach rechts, sehen sich dann die nächste Zelle an und... hey! Es ist aktiv. Bewegen Sie es lieber wieder nach rechts. Wenn Sie die Daten gleichzeitig mit der Beobachtung ändern, werden die Ergebnisse verfälscht.
Mit dem Ping-Pong-Muster stellen Sie sicher, dass Sie für den nächsten Schritt der Simulation immer nur die Ergebnisse des letzten Schritts verwenden.
// Example simulation. Don't copy into the project!
const stateA = [1, 0, 0, 0, 0, 0, 0, 0];
const stateB = [0, 0, 0, 0, 0, 0, 0, 0];
function simulate(inArray, outArray) {
outArray[0] = 0;
for (let i = 1; i < inArray.length; ++i) {
outArray[i] = inArray[i-1];
}
}
// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA);
- Verwenden Sie dieses Muster in Ihrem eigenen Code, indem Sie Ihre Speicherpufferzuweisung aktualisieren, um zwei identische Zwischenspeicher zu erstellen:
index.html
// Create two storage buffers to hold the cell state.
const cellStateStorage = [
device.createBuffer({
label: "Cell State A",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
}),
device.createBuffer({
label: "Cell State B",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
})
];
- Um den Unterschied zwischen den beiden Puffern zu veranschaulichen, füllen Sie sie mit unterschiedlichen Daten:
index.html
// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);
// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
- Wenn Sie die verschiedenen Speicherbuffer im Rendering anzeigen möchten, aktualisieren Sie Ihre Bindungsgruppen ebenfalls auf zwei verschiedene Varianten:
index.html
const bindGroups = [
device.createBindGroup({
label: "Cell renderer bind group A",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[0] }
}],
}),
device.createBindGroup({
label: "Cell renderer bind group B",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[1] }
}],
})
];
Render-Loop einrichten
Bisher wurde nur eine Zeichnung pro Seitenaktualisierung durchgeführt. Jetzt möchten Sie aber, dass die Daten im Zeitverlauf aktualisiert werden. Dazu benötigen Sie einen einfachen Rendering-Loop.
Ein Rendering-Loop ist eine endlos wiederholte Schleife, die Ihre Inhalte in einem bestimmten Intervall auf dem Canvas zeichnet. In vielen Spielen und anderen Inhalten, die animiert werden sollen, wird die Funktion requestAnimationFrame()
verwendet, um Callbacks mit der Geschwindigkeit zu planen, mit der der Bildschirm aktualisiert wird (60-mal pro Sekunde).
Diese App kann auch diese Funktion verwenden. In diesem Fall sollten die Aktualisierungen jedoch in größeren Schritten erfolgen, damit Sie die Simulation leichter nachvollziehen können. Sie können die Schleife stattdessen selbst verwalten, um die Aktualisierungsrate der Simulation zu steuern.
- Wählen Sie zuerst eine Aktualisierungsrate für die Simulation aus (200 ms sind gut, Sie können aber auch langsamer oder schneller gehen). Behalten Sie dann im Blick, wie viele Simulationsschritte abgeschlossen wurden.
index.html
const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
- Verschieben Sie dann den gesamten Code, den Sie derzeit für das Rendering verwenden, in eine neue Funktion. Planen Sie mit
setInterval()
, dass diese Funktion im gewünschten Intervall wiederholt wird. Achten Sie darauf, dass die Funktion auch die Schrittzahl aktualisiert, und verwenden Sie diese, um auszuwählen, welche der beiden Bindungsgruppen gebunden werden soll.
index.html
// Move all of our rendering code into a function
function updateGrid() {
step++; // Increment the step count
// Start a render pass
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
storeOp: "store",
}]
});
// Draw the grid.
pass.setPipeline(cellPipeline);
pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
// End the render pass and submit the command buffer
pass.end();
device.queue.submit([encoder.finish()]);
}
// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);
Wenn Sie jetzt die App ausführen, sehen Sie, dass der Canvas zwischen den beiden Statuspuffern wechselt, die Sie erstellt haben.
Damit sind Sie im Wesentlichen mit dem Rendering fertig! Sie sind bereit, die Ausgabe der Game of Life-Simulation anzuzeigen, die Sie im nächsten Schritt erstellen. Dort verwenden Sie zum ersten Mal Compute Shader.
Natürlich gibt es bei den Rendering-Funktionen von WebGPU noch viel mehr zu entdecken, als Sie hier gesehen haben. Der Rest geht jedoch über den Rahmen dieses Codelabs hinaus. Hoffentlich haben Sie damit einen guten Eindruck davon, wie das Rendering mit WebGPU funktioniert, dass das Erlernen komplexerer Techniken wie 3D-Rendering leichter verständlich ist.
8. Simulation ausführen
Jetzt kommt das letzte wichtige Puzzleteil: die Simulation des Spiels „Das Leben“ in einem Compute Shader ausführen.
Verwenden Sie endlich Compute-Shader.
Sie haben in diesem Codelab abstrakt etwas über Compute-Shader gelernt. Aber was genau sind das?
Ein Compute-Shader ähnelt Vertex- und Fragment-Shadern, da er für die Ausführung mit extremer Parallelität auf der GPU entwickelt wurde. Im Gegensatz zu den anderen beiden Shader-Stufen haben sie jedoch keine bestimmten Eingaben und Ausgaben. Sie lesen und schreiben Daten ausschließlich aus von Ihnen ausgewählten Quellen, z. B. aus Speicherpuffern. Das bedeutet, dass Sie anstelle einer Ausführung für jeden Vertex, jede Instanz oder jedes Pixel angeben müssen, wie viele Aufrufe der Shaderfunktion Sie wünschen. Wenn Sie den Shader dann ausführen, wird angezeigt, welche Aufrufe verarbeitet werden. Sie können dann festlegen, auf welche Daten Sie zugreifen und welche Vorgänge Sie dort ausführen möchten.
Compute-Shader müssen wie Vertex- und Fragment-Shader in einem Shader-Modul erstellt werden. Fügen Sie das Modul also zu Ihrem Code hinzu. Wie Sie sich vorstellen können, muss die Hauptfunktion Ihres Compute-Shaders aufgrund der Struktur der anderen von Ihnen implementierten Shader mit dem Attribut @compute
gekennzeichnet werden.
- Erstellen Sie einen Compute-Shader mit dem folgenden Code:
index.html
// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
label: "Game of Life simulation shader",
code: `
@compute
fn computeMain() {
}`
});
Da GPUs häufig für 3D-Grafiken verwendet werden, sind Compute Shader so strukturiert, dass Sie anfordern können, dass der Shader eine bestimmte Anzahl von Malen entlang einer X-, Y- und Z-Achse aufgerufen wird. So können Sie ganz einfach Aufgaben anweisen, die einem 2D- oder 3D-Raster entsprechen. Das ist ideal für Ihren Anwendungsfall. Sie möchten diesen Shader GRID_SIZE
× GRID_SIZE
mal aufrufen, also einmal für jede Zelle Ihrer Simulation.
Aufgrund der GPU-Hardwarearchitektur ist dieses Raster in Arbeitsgruppen unterteilt. Eine Arbeitsgruppe hat eine X-, Y- und Z-Größe. Die Größen können jeweils 1 betragen. Es bietet sich jedoch oft an, die Arbeitsgruppen etwas größer zu gestalten, um die Leistung zu verbessern. Wählen Sie für Ihren Shader eine etwas willkürliche Arbeitsgruppengröße von 8 × 8 aus. Dies ist nützlich, damit Sie den Überblick über Ihren JavaScript-Code behalten.
- Definieren Sie eine Konstante für die Größe der Arbeitsgruppe, z. B. so:
index.html
const WORKGROUP_SIZE = 8;
Sie müssen die Größe der Arbeitsgruppe auch der Shaderfunktion selbst hinzufügen. Dazu verwenden Sie JavaScript-Template-Literals, damit Sie die gerade definierte Konstante problemlos verwenden können.
- Fügen Sie der Shaderfunktion die Größe der Arbeitsgruppe hinzu:
index.html (Compute createShaderModule call)
@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {
}
Damit wird dem Shader mitgeteilt, dass die mit dieser Funktion ausgeführte Arbeit in Gruppen von (8 × 8 × 1) erfolgt. Für nicht angegebene Achsen wird standardmäßig „1“ verwendet. Sie müssen jedoch mindestens die X-Achse angeben.
Wie bei den anderen Shader-Phasen gibt es eine Vielzahl von @builtin
-Werten, die Sie als Eingabe in Ihre Compute-Shader-Funktion annehmen können, um zu erfahren, bei welcher Aufrufinstanz Sie sich befinden und welche Arbeit Sie ausführen müssen.
- Fügen Sie einen
@builtin
-Wert hinzu, z. B. so:
index.html (Compute createShaderModule call)
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Sie übergeben das global_invocation_id
-Builtin, einen dreidimensionalen Vektor aus vorzeichenlosen Ganzzahlen, der angibt, wo Sie sich im Raster der Shaderaufrufe befinden. Sie führen diesen Shader einmal für jede Zelle in Ihrem Raster aus. Sie sehen Zahlen wie (0, 0, 0)
, (1, 0, 0)
, (1, 1, 0)
bis hin zu (31, 31, 0)
. Sie können diese als Zellindex verwenden, auf den Sie eine Operation anwenden möchten.
Compute-Shader können auch Uniforms verwenden, die Sie genauso wie in Vertex- und Fragment-Shadern verwenden.
- Verwenden Sie eine Uniform mit Ihrem Compute-Shader, um die Rastergröße anzugeben, z. B. so:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f; // New line
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Genau wie im Vertex-Shader stellen Sie auch den Zellenstatus als Speicherbuffer bereit. In diesem Fall benötigen Sie jedoch zwei davon. Da Compute Shader keine erforderliche Ausgabe wie eine Vertexposition oder Fragmentfarbe haben, ist das Schreiben von Werten in einen Speicherbuffer oder eine Textur die einzige Möglichkeit, Ergebnisse aus einem Compute Shader zu erhalten. Verwenden Sie die Ping-Pong-Methode, die Sie zuvor kennengelernt haben. Sie haben einen Speicherpuffer, der den aktuellen Status des Rasters speist, und einen, in den Sie den neuen Status des Rasters schreiben.
- Den Eingabe- und Ausgabestatus der Zelle als Speicherbuffer freigeben, z. B. so:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f;
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Der erste Speicherzwischenspeicher wird mit var<storage>
deklariert, was ihn schreibgeschützt macht. Der zweite Speicherzwischenspeicher wird mit var<storage, read_write>
deklariert. So können Sie sowohl in den Puffer lesen als auch in ihn schreiben und ihn als Ausgabe für Ihren Compute-Shader verwenden. (In WebGPU gibt es keinen reinen Schreibmodus.)
Als Nächstes müssen Sie eine Möglichkeit haben, den Zellindex in das lineare Speicherarray abzubilden. Das ist im Grunde das Gegenteil dessen, was Sie im Vertex-Shader getan haben, wo Sie die lineare instance_index
auf eine 2D-Rasterzelle abgebildet haben. Zur Erinnerung: Ihr Algorithmus dafür lautete vec2f(i % grid.x, floor(i / grid.x))
.
- Schreiben Sie eine Funktion, die in die andere Richtung geht. Dabei wird der Y-Wert der Zelle mit der Rasterbreite multipliziert und dann der X-Wert der Zelle addiert.
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
// New function
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Um schließlich zu überprüfen, ob sie funktioniert, müssen Sie einen einfachen Algorithmus implementieren: Wenn eine Zelle gerade eingeschaltet ist, schaltet sie sich aus und umgekehrt. Es ist noch nicht das Spiel des Lebens, aber es reicht aus, um zu zeigen, dass der Compute-Shader funktioniert.
- Fügen Sie den einfachen Algorithmus so hinzu:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines. Flip the cell state every step.
if (cellStateIn[cellIndex(cell.xy)] == 1) {
cellStateOut[cellIndex(cell.xy)] = 0;
} else {
cellStateOut[cellIndex(cell.xy)] = 1;
}
}
Das war es für heute mit dem Compute Shader. Bevor Sie die Ergebnisse sehen können, müssen Sie jedoch noch einige Änderungen vornehmen.
Bindegruppen- und Pipeline-Layouts verwenden
Sie werden feststellen, dass im obigen Shader weitgehend dieselben Eingaben (Uniforms und Speicher-Buffer) wie in der Renderpipeline verwendet werden. Vielleicht denken Sie, dass Sie einfach dieselben Bindungsgruppen verwenden und damit fertig sind. Die gute Nachricht ist: Das ist möglich. Die Einrichtung ist nur etwas aufwendiger.
Jedes Mal, wenn Sie eine Bindungsgruppe erstellen, müssen Sie einen GPUBindGroupLayout
angeben. Bisher wurde dieses Layout durch Aufrufen von getBindGroupLayout()
in der Renderpipeline abgerufen, die es wiederum automatisch erstellte, da Sie beim Erstellen layout: "auto"
angegeben haben. Dieser Ansatz funktioniert gut, wenn Sie nur eine einzige Pipeline verwenden. Wenn Sie jedoch mehrere Pipelines haben, die Ressourcen gemeinsam nutzen sollen, müssen Sie das Layout explizit erstellen und dann sowohl der Bindungsgruppe als auch den Pipelines zur Verfügung stellen.
Zur Verdeutlichung: In Ihren Renderpipelines verwenden Sie einen einzelnen Uniform- und einen einzelnen Speicherbuffer. Im Compute-Shader, den Sie gerade geschrieben haben, benötigen Sie jedoch einen zweiten Speicherbuffer. Da die beiden Shader dieselben @binding
-Werte für den Uniform- und den ersten Speicherbuffer verwenden, können Sie diese zwischen den Pipelines freigeben. Die Renderpipeline ignoriert den zweiten Speicherbuffer, da er nicht verwendet wird. Sie möchten ein Layout erstellen, das alle Ressourcen in der Bindungsgruppe beschreibt, nicht nur die, die von einer bestimmten Pipeline verwendet werden.
- Rufen Sie zum Erstellen dieses Layouts
device.createBindGroupLayout()
auf:
index.html
// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
label: "Cell Bind Group Layout",
entries: [{
binding: 0,
// Add GPUShaderStage.FRAGMENT here if you are using the `grid` uniform in the fragment shader.
visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
buffer: {} // Grid uniform buffer
}, {
binding: 1,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
buffer: { type: "read-only-storage"} // Cell state input buffer
}, {
binding: 2,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: "storage"} // Cell state output buffer
}]
});
Die Struktur ähnelt dem Erstellen der Bindungsgruppe selbst, da Sie eine Liste von entries
angeben. Der Unterschied besteht darin, dass Sie beschreiben, welcher Ressourcentyp der Eintrag sein muss und wie er verwendet wird, anstatt die Ressource selbst anzugeben.
Geben Sie in jedem Eintrag die binding
-Nummer für die Ressource an, die (wie Sie beim Erstellen der Bindungsgruppe erfahren haben) mit dem @binding
-Wert in den Shadern übereinstimmt. Sie geben auch visibility
an, das sind GPUShaderStage
-Flags, die angeben, welche Shader-Phasen die Ressource verwenden können. Sowohl der Uniform- als auch der erste Speicherbuffer sollen in den Vertex- und Compute-Shadern zugänglich sein, der zweite Speicherbuffer jedoch nur in Compute-Shadern.
Geben Sie abschließend an, welche Art von Ressource verwendet wird. Dies ist ein anderer Wörterbuchschlüssel, je nachdem, was freigegeben werden soll. Hier sind alle drei Ressourcen Puffer. Verwenden Sie daher den Schlüssel buffer
, um die Optionen für jede Ressource zu definieren. Andere Optionen sind beispielsweise texture
oder sampler
. Diese sind hier jedoch nicht erforderlich.
Im Puffer-Dictionary legen Sie Optionen fest, z. B. welcher type
des Puffers verwendet wird. Der Standardwert ist "uniform"
. Sie können das Wörterbuch also leer lassen, um 0 zu verknüpfen. Sie müssen jedoch mindestens buffer: {}
festlegen, damit der Eintrag als Puffer erkannt wird. Bindung 1 erhält den Typ "read-only-storage"
, da Sie sie nicht mit read_write
-Zugriff im Shader verwenden. Bindung 2 hat den Typ "storage"
, da Sie sie mit read_write
-Zugriff verwenden.
Nachdem die bindGroupLayout
erstellt wurde, können Sie sie beim Erstellen Ihrer Bindungsgruppen übergeben, anstatt die Bindungsgruppe aus der Pipeline abzufragen. Dazu müssen Sie jeder Bindungsgruppe einen neuen Speicherpuffereintrag hinzufügen, damit er dem gerade definierten Layout entspricht.
- Aktualisieren Sie die Bindungsgruppenerstellung so:
index.html
// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
device.createBindGroup({
label: "Cell renderer bind group A",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[0] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[1] }
}],
}),
device.createBindGroup({
label: "Cell renderer bind group B",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[1] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[0] }
}],
}),
];
Nachdem die Bindungsgruppe auf dieses explizite Bindungsgruppen-Layout aktualisiert wurde, müssen Sie die Renderpipeline entsprechend aktualisieren.
- Erstellen Sie eine
GPUPipelineLayout
.
index.html
const pipelineLayout = device.createPipelineLayout({
label: "Cell Pipeline Layout",
bindGroupLayouts: [ bindGroupLayout ],
});
Ein Pipeline-Layout ist eine Liste von Bindungsgruppen-Layouts (in diesem Fall eins), die von einer oder mehreren Pipelines verwendet werden. Die Reihenfolge der Bindungsgruppenlayouts im Array muss mit den @group
-Attributen in den Shadern übereinstimmen. Das bedeutet, dass bindGroupLayout
mit @group(0)
verknüpft ist.
- Sobald Sie das Pipeline-Layout haben, aktualisieren Sie die Rendering-Pipeline, um diese anstelle von
"auto"
zu verwenden.
index.html
const cellPipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: pipelineLayout, // Updated!
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
Berechnungspipeline erstellen
Genau wie Sie eine Rendering-Pipeline benötigen, um Ihre Vertex- und Fragment-Shader zu verwenden, benötigen Sie eine Compute-Pipeline, um Ihren Compute-Shader zu verwenden. Glücklicherweise sind Computing-Pipelines viel weniger kompliziert als Rendering-Pipelines, da sie keinen Zustand festlegen müssen, sondern nur den Shader und das Layout.
- Erstellen Sie eine Compute-Pipeline mit dem folgenden Code:
index.html
// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
label: "Simulation pipeline",
layout: pipelineLayout,
compute: {
module: simulationShaderModule,
entryPoint: "computeMain",
}
});
Beachten Sie, dass Sie wie bei der aktualisierten Renderingpipeline den neuen pipelineLayout
statt "auto"
übergeben. Dadurch wird sichergestellt, dass sowohl die Rendering-Pipeline als auch die Compute-Pipeline dieselben Bindungsgruppen verwenden können.
Karten/Tickets berechnen
Jetzt können Sie die Compute Pipeline tatsächlich nutzen. Da Sie das Rendering in einem Rendering-Pass ausführen, können Sie sich wahrscheinlich vorstellen, dass Sie die Berechnungen in einem Compute-Pass ausführen müssen. Die Berechnung und das Rendern können im selben Befehls-Encoder erfolgen. Sie sollten Ihre updateGrid
-Funktion daher etwas umstrukturieren.
- Verschieben Sie die Encoder-Erstellung an den Anfang der Funktion und beginnen Sie damit einen Compute-Pass (vor der
step++
).
index.html
// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();
const computePass = encoder.beginComputePass();
// Compute work will go here...
computePass.end();
// Existing lines
step++; // Increment the step count
// Start a render pass...
Wie bei Compute-Pipelines können Compute-Passes viel einfacher gestartet werden als ihre Rendering-Äquivalente, da Sie sich keine Gedanken über Anhänge machen müssen.
Sie sollten den Compute-Pass vor dem Render-Pass ausführen, da der Render-Pass so sofort die neuesten Ergebnisse aus dem Compute-Pass verwenden kann. Das ist auch der Grund, warum Sie die step
-Anzahl zwischen den Durchläufen erhöhen, sodass der Ausgabepuffer der Compute-Pipeline zum Eingabepuffer für die Rendering-Pipeline wird.
- Legen Sie als Nächstes die Pipeline und Bindungsgruppe im Compute-Pass fest. Verwenden Sie dabei dasselbe Muster für den Wechsel zwischen Bindungsgruppen wie für den Rendering-Pass.
index.html
const computePass = encoder.beginComputePass();
// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);
computePass.end();
- Schließlich übergeben Sie die Arbeit an den Compute-Shader und geben an, wie viele Arbeitsgruppen auf jeder Achse ausgeführt werden sollen, anstatt wie bei einem Rendering-Pass zu zeichnen.
index.html
const computePass = encoder.beginComputePass();
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);
// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);
computePass.end();
Wichtig: Die Zahl, die Sie an dispatchWorkgroups()
übergeben, ist nicht die Anzahl der Aufrufe. Stattdessen ist es die Anzahl der auszuführenden Arbeitsgruppen, wie durch @workgroup_size
in Ihrem Shader definiert.
Wenn der Shader 32 × 32 Mal ausgeführt werden soll, um das gesamte Raster abzudecken, und die Arbeitsgruppengröße 8 × 8 ist, müssen Sie 4 × 4 Arbeitsgruppen (4 × 8 = 32) entsenden. Deshalb teilen Sie die Rastergröße durch die Größe der Arbeitsgruppe und geben diesen Wert in dispatchWorkgroups()
ein.
Jetzt können Sie die Seite noch einmal aktualisieren. Das Raster sollte sich bei jedem Update umkehren.
Algorithmus für das Spiel „Das Leben“ implementieren
Bevor Sie den Compute-Shader aktualisieren, um den endgültigen Algorithmus zu implementieren, sollten Sie zum Code zurückkehren, mit dem der Inhalt des Speicherpuffers initialisiert wird, und ihn so aktualisieren, dass bei jedem Seitenladevorgang ein zufälliger Puffer erstellt wird. Regelmäßige Muster sind keine sehr interessanten Ausgangspunkt für das Game of Life. Sie können die Werte beliebig zufällig auswählen. Es gibt jedoch eine einfache Methode, mit der Sie gute Ergebnisse erzielen.
- Um jede Zelle in einem zufälligen Zustand zu starten, aktualisieren Sie die
cellStateArray
-Initialisierung in den folgenden Code:
index.html
// Set each cell to a random state, then copy the JavaScript array
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);
Jetzt können Sie endlich die Logik für die Simulation des Spiels des Lebens implementieren. Nach all dem, was wir bis hierher getan haben, ist der Shadercode vielleicht enttäuschend einfach.
Zuerst müssen Sie für jede Zelle wissen, wie viele ihrer Nachbarn aktiv sind. Sie interessiert nicht, welche davon aktiv sind, sondern nur die Anzahl.
- Um das Abrufen der Daten benachbarter Zellen zu erleichtern, fügen Sie eine
cellActive
-Funktion hinzu, die dencellStateIn
-Wert der angegebenen Koordinate zurückgibt.
index.html (Compute createShaderModule call)
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
Die cellActive
-Funktion gibt eins zurück, wenn die Zelle aktiv ist. Wenn Sie also den Rückgabewert des Aufrufs von cellActive
für alle acht umgebenden Zellen addieren, erhalten Sie, wie viele benachbarte Zellen aktiv sind.
- So ermitteln Sie die Anzahl der aktiven Nachbarn:
index.html (Compute createShaderModule call)
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines:
// Determine how many active neighbors this cell has.
let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
cellActive(cell.x+1, cell.y) +
cellActive(cell.x+1, cell.y-1) +
cellActive(cell.x, cell.y-1) +
cellActive(cell.x-1, cell.y-1) +
cellActive(cell.x-1, cell.y) +
cellActive(cell.x-1, cell.y+1) +
cellActive(cell.x, cell.y+1);
Dies führt jedoch zu einem kleinen Problem: Was passiert, wenn sich die zu prüfende Zelle außerhalb des Bretts befindet? Laut Ihrer aktuellen cellIndex()
-Logik wird der Wert entweder in die nächste oder vorherige Zeile überlaufen oder geht über den Rand des Buffers hinaus.
Für das Spiel des Lebens besteht eine gängige und einfache Methode zur Lösung dieses Problems darin, dass Zellen am Rand des Rasters die Zellen am gegenüberliegenden Rand des Rasters wie ihre Nachbarn behandeln, was eine Art Wrap-around-Effekt erzeugt.
- Unterstützung für den Zeilenumbruch durch eine kleine Änderung an der Funktion
cellIndex()
.
index.html (Compute createShaderModule-Aufruf)
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
Wenn Sie den Operator %
verwenden, um die Zellen X und Y zu umbrechen, wenn sie über die Rastergröße hinausgehen, wird sichergestellt, dass Sie nie außerhalb der Speicherbuffergrenzen zugreifen. So können Sie sich darauf verlassen, dass die Anzahl der activeNeighbors
vorhersehbar ist.
Anschließend wenden Sie eine der vier Regeln an:
- Alle Zellen mit weniger als zwei Nachbarn werden inaktiv.
- Jede aktive Zelle mit zwei oder drei Nachbarn bleibt aktiv.
- Alle inaktiven Zellen mit genau drei Nachbarn werden aktiv.
- Alle Zellen mit mehr als drei Nachbarn werden inaktiv.
Sie können dies mit einer Reihe von If-Anweisungen tun, aber WGSL unterstützt auch Switch-Anweisungen, die für diese Logik gut geeignet sind.
- Implementieren Sie die Logik des Game of Life so:
index.html (Compute createShaderModule call)
let i = cellIndex(cell.xy);
// Conway's game of life rules:
switch activeNeighbors {
case 2: { // Active cells with 2 neighbors stay active.
cellStateOut[i] = cellStateIn[i];
}
case 3: { // Cells with 3 neighbors become or stay active.
cellStateOut[i] = 1;
}
default: { // Cells with < 2 or > 3 neighbors become inactive.
cellStateOut[i] = 0;
}
}
Der endgültige Aufruf des Compute-Shader-Moduls sieht jetzt so aus:
index.html
const simulationShaderModule = device.createShaderModule({
label: "Life simulation shader",
code: `
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// Determine how many active neighbors this cell has.
let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
cellActive(cell.x+1, cell.y) +
cellActive(cell.x+1, cell.y-1) +
cellActive(cell.x, cell.y-1) +
cellActive(cell.x-1, cell.y-1) +
cellActive(cell.x-1, cell.y) +
cellActive(cell.x-1, cell.y+1) +
cellActive(cell.x, cell.y+1);
let i = cellIndex(cell.xy);
// Conway's game of life rules:
switch activeNeighbors {
case 2: {
cellStateOut[i] = cellStateIn[i];
}
case 3: {
cellStateOut[i] = 1;
}
default: {
cellStateOut[i] = 0;
}
}
}
`
});
Das war's auch schon. Fertig! Aktualisiere deine Seite und sieh dir an, wie dein neu erstellter Mobilfunk-Automat weiter wächst.
9. Glückwunsch!
Sie haben eine Version der klassischen Simulation „Conway's Game of Life“ erstellt, die mithilfe der WebGPU API vollständig auf Ihrer GPU ausgeführt wird.