Informationen zu diesem Codelab
1. Einführung
Was ist WebGPU?
WebGPU ist eine neue, moderne API, mit der Sie in Web-Apps auf die Funktionen Ihrer GPU zugreifen können.
Modern API
Vor WebGPU gab es WebGL, das 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 auf der noch älteren OpenGL API basiert. GPUs haben sich in dieser Zeit erheblich weiterentwickelt und die nativen APIs, die für die Kommunikation mit ihnen verwendet werden, haben sich ebenfalls weiterentwickelt, z. B. Direct3D 12, Metal und Vulkan.
WebGPU bringt die Vorteile dieser modernen APIs auf die Webplattform. Der Schwerpunkt liegt darauf, GPU-Funktionen plattformübergreifend zu ermöglichen und gleichzeitig eine API zu präsentieren, die sich im Web natürlich anfühlt und weniger umfangreich ist als einige der nativen APIs, auf denen sie basiert.
Rendering
GPUs werden oft mit dem Rendern schneller, detaillierter Grafiken in Verbindung gebracht. WebGPU ist keine Ausnahme. Sie bietet die erforderlichen Funktionen, um viele der heute beliebtesten Rendering-Techniken sowohl auf Desktop- als auch auf mobilen GPUs zu unterstützen. Außerdem können in Zukunft neue Funktionen hinzugefügt werden, wenn sich die Hardwarefunktionen weiterentwickeln.
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.
In diesem Codelab erfahren Sie, wie Sie die Rendering- und Rechenfunktionen von WebGPU nutzen, um ein einfaches Einführungsprojekt zu erstellen.
Umfang
In diesem Codelab erstellen Sie Conways Game of Life 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 sogenanntes Zellautomat, bei dem ein Raster aus Zellen den Zustand im Laufe der Zeit basierend auf einer Reihe von Regeln ändert. Im Game of Life werden Zellen je nachdem, wie viele ihrer benachbarten Zellen aktiv sind, aktiv oder inaktiv. Das führt zu interessanten Mustern, die sich im Laufe der Zeit ä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 Shaders für eine einfache Simulation verwenden
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 aber 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 alle Schritte zum Erstellen der WebGPU-Anwendung. 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 die ordnungsgemäße Verwendung erzwingen. Schlimmer noch: Aufgrund der Funktionsweise der API können für viele Fehler keine typischen JavaScript-Ausnahmen ausgelöst werden. Das erschwert es, die Ursache des Problems genau 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 wir es im Codelab tun werden, benötigen Sie ein 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 können Sie sich mit WebGPU vertraut machen. 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.
- Wenn Sie prüfen möchten, ob das Objekt
navigator.gpu
vorhanden ist, das als Einstiegspunkt für WebGPU dient, fügen Sie den folgenden Code hinzu:
index.html
if (!navigator.gpu) {
throw new Error("WebGPU not supported on this browser.");
}
Idealerweise informieren Sie den Nutzer, wenn WebGPU nicht verfügbar ist, indem die Seite zu einem Modus ohne WebGPU umschaltet. (Vielleicht könnte stattdessen WebGL verwendet werden?) Für dieses Codelab werfen Sie jedoch einfach einen Fehler, um die weitere Ausführung des Codes zu beenden.
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 WebGPU-Darstellung einer bestimmten GPU-Hardware auf Ihrem Gerät vorstellen.
- Verwenden Sie die Methode
navigator.gpu.requestAdapter()
, um einen Adapter abzurufen. 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 Wert für adapter
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, mit denen Sie angeben, 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 vor dem Arbeiten mit der GPU noch einen 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 hier Optionen, die übergeben werden können, um erweiterte Funktionen zu nutzen, z. B. bestimmte Hardwarefunktionen zu aktivieren oder höhere Limits anzufordern. Für Ihre Zwecke reichen jedoch die Standardeinstellungen aus.
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. Die Details zur Funktionsweise von Texturspeichern gehen über den Rahmen dieses Codelabs hinaus. 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. Für verschiedene Gerätetypen eignen sich unterschiedliche Texturformate am besten. Wenn Sie nicht das bevorzugte Format des Geräts verwenden, kann es zu zusätzlichen Speicherkopien kommen, bevor das Bild als Teil der Seite angezeigt werden kann.
Glücklicherweise müssen Sie sich darüber keine großen Gedanken machen, da WebGPU Ihnen mitteilt, welches Format Sie für Ihr Canvas verwenden müssen. In fast allen Fällen sollten Sie den Wert übergeben, der durch den Aufruf von navigator.gpu.getPreferredCanvasFormat()
zurückgegeben wird, 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. Jede 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
context.getCurrentTexture()
auf, um die Textur aus dem zuvor erstellten Canvas-Kontext abzurufen. Dadurch wird eine Textur mit einer Pixelbreite und -höhe zurückgegeben, die denwidth
- undheight
-Attributen des Canvas und demformat
entsprechen, das Sie beim Aufrufen voncontext.configure()
angegeben haben.
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 Renderpässe müssen Sie anstelle einer GPUTexture
eine GPUTextureView
angeben, die angibt, welche Teile der Textur gerendert werden sollen. 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 tun soll, wenn er beginnt und endet:
- Ein Wert von
"clear"
fürloadOp
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. Wenn Sie den Rendering-Pass mit loadOp: "clear"
starten, werden die Textur- und Canvas-Ansicht gelöscht.
- Beenden Sie den Renderdurchlauf, 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 lediglich Befehle auf, die die GPU später ausführen soll.
- Rufen Sie zum Erstellen eines
GPUCommandBuffer
finish()
auf dem Befehls-Encoder auf. 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 in einen 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. Der Browser erkennt dann, dass Sie die aktuelle Textur des Kontexts geändert haben, und aktualisiert den 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, um das System ein wenig zu personalisieren, bevor Sie mit dem nächsten Abschnitt fortfahren.
- Fügen Sie im
encoder.beginRenderPass()
-Aufruf dercolorAttachment
eine neue Zeile mit einemclearValue
hinzu, z. B. so:
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. Geometrie zeichnen
Am Ende dieses Abschnitts zeichnet Ihre App eine einfache Geometrie auf den Canvas: ein farbiges Quadrat. Für eine so einfache Ausgabe mag das viel Arbeit erscheinen. Das liegt daran, dass WebGPU darauf ausgelegt ist, viele Geometrien 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 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, hat der linke Rand immer den Wert -1 auf der X-Achse und der rechte Rand immer den Wert +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 zum Zeichnen der Vertexe durchzuführen. 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. In diesem Codelab werden farbige Quadrate in den aktiven Zellen gezeichnet und inaktive Zellen leer gelassen.
Sie müssen der GPU also vier verschiedene Punkte angeben, 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 in der Reihe als bestimmten Datentyp interpretieren können. In einem Uint8Array
ist beispielsweise jedes Element im Array ein einzelnes, ungesigniertes Byte. TypedArrays eignen sich hervorragend, um Daten mit APIs auszutauschen, die speicherlayoutabhängig sind, z. B. WebAssembly, WebAudio und (natürlich) WebGPU.
Da die Werte im Beispiel für das Quadrat Bruchteile sind, ist Float32Array
geeignet.
- 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,
]);
Die Abstände und Kommentare haben keine Auswirkungen auf die Werte. Sie dienen nur 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? Sie müssen die Eckpunkte also in Dreiergruppen angeben. Sie haben eine Gruppe mit vier Personen. 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. Er wird als einzelnes, durchgehendes Quadrat gerendert.
Vertex-Buffer erstellen
Die GPU kann keine Eckpunkte mit Daten aus einem JavaScript-Array zeichnen. 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.
- Um einen Puffer zum Speichern Ihrer Eckpunkte zu erstellen, fügen Sie nach der Definition des
vertices
-Arrays den folgenden Aufruf zudevice.createBuffer()
hinzu.
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. Wenn Probleme auftreten, werden diese Labels in den Fehlermeldungen von WebGPU verwendet, um Ihnen zu helfen, herauszufinden, was schiefgelaufen ist.
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 Puffer 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 Vertexdaten in den Speicher des Buffers 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, müssen Sie noch ein paar weitere Informationen angeben. Sie müssen WebGPU mehr über die Struktur der Vertexdaten mitteilen können.
- Definieren Sie die Datenstruktur des Knotens mit einem
GPUVertexBufferLayout
-Dictionary:
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 nachvollziehen.
Geben Sie als Erstes die arrayStride
an. Das ist die Anzahl der Bytes, die die GPU im Buffer überspringen muss, wenn sie nach dem nächsten Vertex sucht. Jeder Eckpunkt des Quadrats besteht aus zwei 32‑Bit-Gleitkommazahlen. Wie bereits erwähnt, hat ein 32‑Bit-Float 4 Byte. Zwei Floats haben also 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 Eckpunktposition). Bei erweiterten Anwendungsfällen haben Eckpunkte häufig mehrere Attribute, z. B. die Farbe eines Eckpunkts oder die Richtung, in die die geometrische Oberfläche zeigt. Das ist jedoch nicht Teil dieses Codelabs.
In Ihrem einzelnen Attribut definieren Sie zuerst die 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 Vertexdaten stattdessen aus jeweils vier 16‑Bit-unsignierten Ganzzahlen bestehen, verwenden Sie stattdessen uint16x4
. 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 darüber nur Gedanken machen, wenn Ihr Buffer mehr als ein Attribut enthält. Das ist in diesem Codelab nicht der Fall.
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 zwar jetzt definieren, sie aber noch nicht an die WebGPU API übergeben. Das kommt noch, aber es ist am einfachsten, wenn Sie diese Werte festlegen, während Sie die Eckpunkte definieren. Sie richten sie also 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 auf der GPU ausgeführt werden, sind sie starrer strukturiert als herkömmliches JavaScript. Diese Struktur ermöglicht jedoch eine sehr schnelle und vor allem parallele Ausführung.
Shader in WebGPU werden in einer Schattierungssprache namens WGSL (WebGPU Shading Language) geschrieben. WGSL ähnelt syntaktisch ein wenig Rust und bietet Funktionen, die gängige Arten von GPU-Arbeiten (z. B. Vektor- und Matrixmathematik) einfacher und schneller machen. 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 wird als Funktion definiert und die GPU ruft diese Funktion einmal für jeden Vertex in Ihrer vertexBuffer
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. Dies ist ein wichtiger Faktor für die unglaubliche Geschwindigkeit von GPUs, hat aber auch Einschränkungen. Um eine extreme Parallelisierung zu ermöglichen, können Vertex-Shader nicht miteinander kommunizieren. Bei jeder Shaderausführung können nur Daten für einen einzelnen Vertex gesehen und nur Werte für einen einzelnen Vertex ausgegeben werden.
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. Dieser wird immer als vierdimensionaler Vektor angegeben. Vektoren werden in Shadern so häufig verwendet, dass sie in der Sprache als erstklassige Primitive behandelt werden, mit eigenen 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 sich der Vertex im Clipbereich befindet.
- Wenn Sie einen statischen Wert von
(0, 0, 0, 1)
zurückgeben, haben Sie technisch gesehen einen gültigen Vertex-Shader, der aber nie etwas anzeigt, da die GPU erkennt, dass die von ihm erzeugten Dreiecke nur einen einzigen Punkt enthalten, und sie dann verwirft.
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 Buffer verwenden. Dazu deklarieren Sie ein Argument für Ihre Funktion mit einem @location()
-Attribut und einem Typ, der mit den in der vertexBufferLayout
beschriebenen übereinstimmt. 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.
- Gib die richtige Position an, indem du explizit angibst, 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);
}
Da diese Art von Zuordnungen in Shadern jedoch sehr häufig vorkommen, können Sie den Positionvektor auch als erstes Argument in einer praktischen Kurzschreibweise ü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. Das 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. Sie können sie aber auch in eine beliebige Farbe ändern.
- 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 färbt. Das reicht aber fürs Erste.
Zur Wiederholung: Nachdem Sie den oben beschriebenen Shadercode hinzugefügt haben, sieht Ihr 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 Werte, die Sie übergeben können, sind optional. Sie müssen nur einige angeben, um loszulegen.
- Erstellen Sie eine Renderpipeline, z. B. so:
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 eine layout
, die beschreibt, welche Arten von Eingaben (außer Vertex-Buffern) die Pipeline benötigt. Sie haben aber keine. 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, und entryPoint
ist der Name der Funktion im Shadercode, die bei jeder 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 werden, 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 in der Vertex-Phase. Als Nächstes definieren Sie die targets
, mit der 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 Renderpass 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 zurück zu den Aufrufen
encoder.beginRenderPass()
undpass.end()
und fügen Sie zwischen ihnen die folgenden neuen Befehle 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 Vertex-Daten 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 der gesamten vorherigen Einrichtung seltsam einfach erscheint. Sie müssen nur die Anzahl der zu rendernden Vertexe übergeben. Diese werden aus den aktuell festgelegten Vertex-Buffers abgerufen und mit der aktuell festgelegten Pipeline interpretiert. Sie könnten es einfach in 6
hartcodieren. Wenn Sie es jedoch aus dem Array „vertices“ berechnen (12 Floats ÷ 2 Koordinaten pro Knoten = 6 Knoten), müssen Sie weniger manuell aktualisieren, wenn Sie das Quadrat beispielsweise durch einen Kreis ersetzen möchten.
- 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 erfahren 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. Für eine einfachere Handhabung 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 erreichen, wäre, 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. Dadurch wird die GPU jedoch nicht optimal genutzt und es wird mehr Arbeitsspeicher als nötig für den Effekt verwendet. In diesem Abschnitt wird ein GPU-freundlicherer Ansatz betrachtet.
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 Uniforms 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 für jede Aufrufe gleich ist. Sie eignen sich gut, um Werte zu kommunizieren, die für ein geometrisches Objekt (z. B. seine Position), einen vollständigen Animationsframe (z. B. die aktuelle Uhrzeit) oder sogar für die gesamte Lebensdauer der App (z. B. eine Nutzereinstellung) üblich sind.
- Fügen Sie den folgenden Code hinzu, um einen einheitlichen Puffer 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 bekannt 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
- Fügen Sie den folgenden Code hinzu, um eine Uniform zu definieren:
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 in Ihrem Shader namens grid
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 Vektoroperationen sind in GPU-Shadern sehr häufig, da viele Rendering- und Berechnungstechniken 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 Bindegruppe erstellen und festlegen.
Eine Bindungsgruppe ist eine Sammlung von Ressourcen, die Sie gleichzeitig für Ihren Shader 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 näher ein. Im Moment können Sie aber ganz einfach die Pipeline nach dem Bindungsgruppenlayout fragen, da Sie sie mit layout: "auto"
erstellt haben. Dadurch werden in der Pipeline automatisch Bindungsgruppenlayouts aus den Bindungen erstellt, die Sie im Shadercode 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 angegeben 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 ganz einfach.
- Kehren Sie zum Renderpass zurück und fügen Sie diese neue Zeile vor der
draw()
-Methode 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 sagen, dass jede @binding
, die 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! Ihr Quadrat ist jetzt nur noch ein Viertel so groß wie vorher. Das ist nicht viel, aber es zeigt, dass die Uniform tatsächlich angewendet wird und 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 Layout sieht dann so aus, wobei die aktuelle quadratische Geometrie in der Mitte zu sehen ist:
Ihre Herausforderung besteht darin, im Shader eine Methode zu finden, mit der Sie die quadratische Geometrie anhand der Zellenkoordinaten in einer beliebigen dieser Zellen positionieren können.
Zuerst sehen Sie, dass das Quadrat nicht richtig mit einer der Zellen ausgerichtet ist, da es so definiert wurde, dass es den Mittelpunkt 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. Wenn Sie die Eckpunkte so verschieben, dass die linke untere Ecke beispielsweise (0,1, 0,1) statt (-0,8, -0,8) ist, können Sie dieses Quadrat so verschieben, dass es besser an den Zellengrenzen ausgerichtet ist. 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 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. Das ist nicht ganz das, was Sie wollten.
Da die Canvas-Koordinaten von -1 bis +1 gehen, ist der Balken tatsächlich 2 Einheiten breit. 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 den Versatz mit 2, z. B. so:
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 cell
jetzt auf einen beliebigen Wert innerhalb des Rasters festlegen und dann aktualisieren, um das Quadrat an der gewünschten Stelle zu sehen.
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 warten muss, bis die neue Koordinate von JavaScript geschrieben wurde. Einer der Schlüssel zu einer guten Leistung der GPU besteht darin, die Wartezeit auf andere Teile des Systems zu minimieren.
Stattdessen können Sie die sogenannte Instanzierung verwenden. Mit Instancing können Sie der GPU mit einem einzigen Aufruf von draw
mitteilen, dass mehrere Kopien derselben Geometrie gezeichnet werden sollen. Das ist viel schneller, als draw
einmal für jede Kopie aufzurufen. Jede Kopie der Geometrie wird als Instanz bezeichnet.
- Um der GPU mitzuteilen, dass Sie genügend Instanzen Ihres Quadrats benötigen, um das Raster zu füllen, fügen Sie Ihrem vorhandenen Draw-Aufruf ein Argument hinzu:
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 noch sechsmal mit einer instance_index
von 1
, dann noch sechsmal mit einer instance_index
von 2
usw.
Wenn Sie sich das ansehen möchten, 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 die Seite jetzt aktualisieren, sehen Sie, dass Sie tatsächlich mehr als ein Quadrat haben. Sie können jedoch nicht alle 16 sehen.
Das liegt daran, dass Sie die Zellenkoordinaten (0, 0), (1, 1), (2, 2) usw. bis hin zu (15, 15) generieren, aber nur die ersten vier davon auf dem Canvas passen. Um das gewünschte Raster zu erstellen, müssen Sie die instance_index
so transformieren, dass jeder Index einer eindeutigen Zelle im Raster zugeordnet wird. So gehts:
Die Berechnung ist relativ einfach. Für den X-Wert jeder Zelle benötigen Sie den Modulo von instance_index
und der Rasterbreite. Dies können Sie in WGSL mit dem Operator %
berechnen. Für den Y-Wert jeder Zelle soll instance_index
durch die Rasterbreite geteilt werden, wobei alle Bruchteile verworfen werden. Das geht mit der Funktion floor()
von WGSL.
- Ä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 werden schon lange nicht mehr angezeigt, 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. Aber auch wenn das Raster aus Quadraten, die alle dieselbe Farbe haben, funktioniert, ist es 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 nur 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 Shader-Objekt platzieren. Strukturen in WGSL sind benannte Objekttypen, die eine oder mehrere benannte Properties enthalten. Die Properties können auch mit Attributen wie @builtin
und @location
markiert 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 beispielsweise Ihren aktuellen Vertex-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 dabei die Eingabeposition und den Instanzindex mit input
angeben müssen. Außerdem muss die Struktur, die Sie zurückgeben, zuerst als Variable deklariert und ihre einzelnen Eigenschaften 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 den 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, wenn sie es tun.
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 Funktion @fragment
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, für jeden Kanal im Bereich von 0 bis 1 liegen müssen. 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 Rot- oder Grünkanal 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 das Blau dort am hellsten sein, wo die anderen Farben am dunkelsten sind, und dann verblassen, wenn die anderen Farben an Intensität zunehmen. Am einfachsten ist es, den Kanal bei 1 anzufangen und einen der Zellenwerte abzuziehen. Er kann c.x
oder c.y
sein. Probiere beide aus und wähle dann diejenige aus, die 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. Sie könnten denken, dass dies ein weiterer Anwendungsfall für einheitliche Buffers 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 das größte Problem, 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 all diese Einschränkungen vermieden werden.
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.
- Verwenden Sie zum Erstellen eines Speicherpuffers für den Zellenstatus den Code zum Erstellen von Puffern, der Ihnen inzwischen 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 von GPUBufferUsage.STORAGE
an.
Sie können den Puffer auf die gleiche Weise wie zuvor füllen, indem Sie den TypedArray derselben Größe mit Werten füllen und dann device.queue.writeBuffer()
aufrufen. Da Sie die Auswirkungen des Buffers auf das Raster sehen möchten, füllen Sie ihn zuerst mit etwas Vorhersehbarem.
- 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 bei der bisherigen Hinzufügung 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 Typ von var
ist storage
, um den unterschiedlichen Buffertyp widerzuspiegeln. Anstatt eines einzelnen Vektors ist der für cellState
angegebene Typ ein Array von u32
-Werten, um mit dem Uint32Array
in JavaScript übereinzustimmen.
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 deaktiviere ich eine Zelle, wenn der Status „Inaktiv“ lautet? 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. Bei einer Skalierung von 1 bleibt die Geometrie unverändert. Bei einer Skalierung von 0 wird die Geometrie 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 }
}],
});
Achten Sie 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
Bei den meisten Simulationen wie der, die Sie gerade erstellen, werden in der Regel mindestens zwei Kopien des Zustands verwendet. 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 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? Da Sie den Status immer an derselben Stelle aktualisieren, verschieben Sie die aktive Zelle nach rechts und sehen sich dann die nächste Zelle an. 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 sorgen Sie dafür, dass Sie den nächsten Schritt der Simulation immer nur mit den Ergebnissen des letzten Schritts ausführen.
// 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 die Speicherbufferzuweisung aktualisieren, um zwei identische Buffer 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 haben Sie nur eine Ziehung pro Seitenaktualisierung durchgeführt, aber jetzt möchten Sie Daten anzeigen, die im Laufe der Zeit 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. Viele Spiele und andere Inhalte, die flüssig animiert werden sollen, verwenden die Funktion requestAnimationFrame()
, um Callbacks mit der gleichen Rate 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 wählen Sie anhand dieser Zahl aus, welche der beiden Bindungsgruppen verbunden 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 die App jetzt ausführen, sehen Sie, dass der Canvas zwischen den beiden von Ihnen erstellten Status-Buffers hin- und herwechselt.
Damit sind Sie mit dem Rendern so gut wie 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. Ich hoffe, dass Sie einen Eindruck davon bekommen haben, wie das Rendering von WebGPU funktioniert, und dass es Ihnen leichter fällt, erweiterte Techniken wie 3D-Rendering zu verstehen.
8. Simulation ausführen
Jetzt kommt das letzte wichtige Puzzleteil: die Simulation des Spiels „Das Leben“ in einem Compute Shader ausführen.
Compute Shader endlich verwenden
Sie haben in diesem Codelab abstrakt über Compute Shader gelernt, aber was sind das genau?
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 Ihnen 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 also zuerst ein solches Modul in Ihren Code ein. 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. Das ist nützlich, um den Überblick über Ihren JavaScript-Code zu 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 von nicht signierten 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 bereits bekannte Ping-Pong-Methode: Sie haben einen Speicherpuffer, in den der aktuelle Zustand des Rasters eingefügt wird, und einen, in den Sie den neuen Zustand 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 Speicherpuffer wird mit var<storage>
deklariert, was ihn schreibgeschützt macht. Der zweite Speicherpuffer wird jedoch 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) {
}
Und schließlich: Um zu sehen, ob es funktioniert, implementieren Sie einen ganz einfachen Algorithmus: Wenn eine Zelle gerade aktiviert ist, wird sie deaktiviert und umgekehrt. Es ist noch nicht das Game of Life, 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. Sie könnten also denken, dass Sie einfach dieselben Bindungsgruppen verwenden können. Ist das aber wirklich so? 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 angeben, welche Art von Ressource der Eintrag haben 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 Sie freigeben möchten. 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
, die Sie hier jedoch nicht benötigen.
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 hat den Typ "read-only-storage"
, da sie im Shader nicht mit read_write
-Zugriff verwendet wird. Bindung 2 hat den Typ "storage"
, da sie mit read_write
-Zugriff verwendet wird.
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.
- Nachdem Sie das Pipeline-Layout erstellt haben, aktualisieren Sie die Renderpipeline, damit sie anstelle von
"auto"
verwendet wird.
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 Compute-Pipelines weitaus weniger kompliziert als Render-Pipelines, da für sie kein Status festgelegt werden muss, sondern nur der 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",
}
});
Wie in der aktualisierten Renderpipeline geben Sie auch hier die neue pipelineLayout
anstelle von "auto"
an. So können sowohl die Render- als auch die Compute-Pipeline dieselben Bindungsgruppen verwenden.
Karten/Tickets berechnen
Jetzt können Sie die Rechenpipeline tatsächlich nutzen. Da Sie das Rendering in einem Rendering-Pass ausführen, können Sie wahrscheinlich erraten, dass Sie die Rechenarbeit 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 lassen sich auch Compute-Passes viel einfacher starten 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. Aus diesem Grund erhöhen Sie die step
-Anzahl zwischen den Durchläufen, damit der Ausgabepuffer der Compute-Pipeline zum Eingabepuffer für die Renderpipeline 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();
- Anstatt wie bei einem Rendering-Pass zu zeichnen, leiten Sie die Arbeit an den Compute-Shader weiter und geben an, wie viele Arbeitsgruppen Sie auf jeder Achse ausführen möchten.
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.
- Wenn jede Zelle in einem zufälligen Zustand gestartet werden soll, aktualisieren Sie die
cellStateArray
-Initialisierung mit dem 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 Sie bis hierher gebracht hat, ist der Shadercode möglicherweise enttäuschend einfach.
Zuerst müssen Sie für jede Zelle wissen, wie viele ihrer Nachbarn aktiv sind. Sie interessieren sich nicht dafür, welche davon aktiv sind, sondern nur für die Anzahl.
- Um das Abrufen von Daten benachbarter Zellen zu vereinfachen, 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 Funktion cellActive
gibt den Wert 1 zurück, wenn die Zelle aktiv ist. Wenn Sie also den Rückgabewert des Aufrufs von cellActive
für alle acht umliegenden Zellen addieren, sehen 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);
Das führt jedoch zu einem kleinen Problem: Was passiert, wenn sich die Zelle, die Sie prüfen, außerhalb des Bretts befindet? Laut Ihrer aktuellen cellIndex()
-Logik wird entweder in die nächste oder vorherige Zeile übergelaufen oder der Puffer wird überschritten.
Für das Game of Life ist eine gängige und einfache Lösung, dass Zellen am Rand des Rasters Zellen am gegenüberliegenden Rand des Rasters als Nachbarn behandeln, was einen Art Wrap-around-Effekt erzeugt.
- Unterstützung für den Zeilenumbruch im Raster durch eine kleine Änderung an der Funktion
cellIndex()
.
index.html (Compute createShaderModule call)
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 folgenden 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! Aktualisieren Sie die Seite und sehen Sie zu, wie sich Ihr neu erstellter Zellautomat entwickelt.
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.