Ihre erste WebGPU-Anwendung

1. Einführung

Das WebGPU-Logo besteht aus mehreren blauen Dreiecken, die ein stilisiertes "W" bilden.

Zuletzt aktualisiert:28.08.2023

Was ist eine WebGPU?

WebGPU ist eine neue, moderne API für den Zugriff auf die Funktionen Ihrer GPU in Webanwendungen.

Moderne API

Vor WebGPU gab es WebGL, das einen Teil der Funktionen von WebGPU bot. Es ermöglichte eine neue Art von Rich-Web-Content, und Entwickler haben damit großartige Dinge geschaffen. Sie basierte jedoch auf der 2007 veröffentlichten OpenGL ES 2.0 API, die wiederum auf der noch älteren OpenGL API basierte. GPUs haben sich in dieser Zeit erheblich weiterentwickelt und die nativen APIs, die für die Schnittstelle verwendet werden, haben sich mit Direct3D 12, Metal und Vulkan ebenfalls weiterentwickelt.

WebGPU bringt die Fortschritte dieser modernen APIs auf die Webplattform. Im Mittelpunkt steht die plattformübergreifende Aktivierung von GPU-Funktionen und die Präsentation einer API, die sich im Web natürlich anfühlt und weniger ausführlich ist als einige der nativen APIs, auf denen sie basiert.

Rendering

GPUs werden oft mit einem schnellen und detailgenauen Rendering in Verbindung gebracht. WebGPU ist da keine Ausnahme. Sie verfügt über die Funktionen, die zur Unterstützung vieler der gängigsten Rendering-Techniken von Desktop- und mobilen GPUs erforderlich sind, und bietet die Möglichkeit, in Zukunft neue Funktionen hinzuzufügen, wenn die Hardware-Funktionen weiterentwickelt werden.

Computing

Neben dem Rendering schöpft WebGPU das Potenzial Ihrer GPU für die Ausführung allgemeiner, hoch paralleler Arbeitslasten aus. Diese Compute-Shader können eigenständig, ohne Rendering-Komponente, oder als eng integrierter Teil Ihrer Rendering-Pipeline verwendet werden.

Im heutigen Codelab lernen Sie, wie Sie die Rendering- und Rechenfunktionen von WebGPU nutzen, um ein einfaches Einführungsprojekt zu erstellen.

Inhalt

In diesem Codelab erstellen Sie Conways Spiel des Lebens mit WebGPU. Mit der Anwendung können Sie Folgendes tun:

  • Nutzen Sie die Rendering-Funktionen von WebGPU, um einfache 2D-Grafiken zu zeichnen.
  • Verwenden Sie die Rechenkapazitäten von WebGPU, um die Simulation durchzuführen.

Screenshot des Endprodukts dieses Codelabs

Das Spiel des Lebens ist ein sogenannter zellulärer Automaten, bei dem ein Raster von Zellen den Status im Laufe der Zeit nach bestimmten Regeln ändert. Im Spiel des Lebens werden Zellen aktiv oder inaktiv, je nachdem, wie viele ihrer Nachbarzellen aktiv sind. Dies führt zu interessanten Mustern, die sich beim Betrachten ändern.

Aufgaben in diesem Lab

  • Hier erfahren Sie, wie Sie eine WebGPU einrichten und einen Canvas konfigurieren.
  • So zeichnen Sie einfache 2D-Geometrie.
  • Hier erfahren Sie, wie Sie Scheitelpunkt- und Fragment-Shader zum Ändern der Darstellung verwenden.
  • Hier erfahren Sie, wie Sie Compute-Shader zum Ausführen einer einfachen Simulation verwenden.

In diesem Codelab werden die grundlegenden Konzepte hinter WebGPU vorgestellt. Sie ist nicht als umfassende Übersicht über die API gedacht und deckt (oder erfordert) häufig verwandte Themen wie 3D-Matrixrechnungen ab.

Voraussetzungen

  • Eine aktuelle Version von Chrome (113 oder höher) unter ChromeOS, macOS oder Windows. WebGPU ist eine browser- und plattformübergreifende API, die jedoch noch nicht überall verfügbar ist.
  • Kenntnisse in HTML, JavaScript und Chrome-Entwicklertools.

Sie sind nicht unbedingt mit anderen Grafik-APIs wie WebGL, Metal, Vulkan oder Direct3D vertraut, aber wenn Sie bereits Erfahrung mit diesen APIs haben, werden Ihnen wahrscheinlich viele Ähnlichkeiten mit WebGPU auffallen, die Ihnen den Einstieg erleichtern können.

2. Einrichten

Code abrufen

Dieses Codelab hat keine Abhängigkeiten und führt Sie durch jeden Schritt, der zum Erstellen der WebGPU-App erforderlich ist. Sie benötigen also keinen Code, um loszulegen. Einige Arbeitsbeispiele, die als Prüfpunkte dienen können, finden Sie jedoch unter https://glitch.com/edit/#!/your-first-webgpu-app. Sie können sie sich ansehen und auf sie zurückgreifen, wenn Sie nicht weiterkommen.

Entwicklerkonsole verwenden

WebGPU ist eine ziemlich komplexe API mit vielen Regeln, die eine ordnungsgemäße Verwendung erzwingen. Schlimmer noch: Aufgrund der Funktionsweise der API können für viele Fehler keine typischen JavaScript-Ausnahmen ausgelöst werden. Dadurch ist es schwieriger, die Ursache des Problems genau zu ermitteln.

Bei der Entwicklung mit WebGPU werden Sie vor allem als Einsteiger auf Probleme stoßen, und das ist in Ordnung! Die Entwickler hinter der API sind sich der Herausforderungen bei der GPU-Entwicklung bewusst und haben hart daran gearbeitet, dass Sie jedes Mal, wenn Ihr WebGPU-Code einen Fehler verursacht, sehr detaillierte und hilfreiche Meldungen in der Entwicklerkonsole erhalten, mit denen Sie das Problem identifizieren und beheben können.

Es ist immer hilfreich, die Konsole geöffnet zu lassen, während Sie an beliebigen Webanwendungen arbeiten, aber das gilt besonders in diesem Fall.

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, so wie Sie es hier im Codelab machen, benötigen Sie einen Canvas. Das ist ein guter Anfang!

Erstellen Sie ein neues HTML-Dokument mit einem einzelnen <canvas>-Element sowie einem <script>-Tag, in dem das Canvas-Element abgefragt wird. (Oder verwenden Sie 00-starter-page.html für eine Störung.)

  • Erstellen Sie eine index.html-Datei mit dem folgenden Code:

index.html

<!doctype html>

<html>
  <head>
    <meta charset="utf-8">
    <title>WebGPU Life</title>
  </head>
  <body>
    <canvas width="512" height="512"></canvas>
    <script type="module">
      const canvas = document.querySelector("canvas");

      // Your WebGPU code will begin here!
    </script>
  </body>
</html>

Adapter und Gerät anfordern

Jetzt kommen Sie zu den WebGPU-Bits. Zuerst sollten Sie bedenken, dass es eine Weile dauern kann, bis APIs wie WebGPU im gesamten Websystem verbreitet werden. Daher sollten Sie als Erstes prüfen, ob der Browser des Nutzers WebGPU verwenden kann.

  1. Fügen Sie den folgenden Code hinzu, um zu prüfen, ob das Objekt navigator.gpu, das als Einstiegspunkt für WebGPU dient, vorhanden ist:

index.html

if (!navigator.gpu) {
  throw new Error("WebGPU not supported on this browser.");
}

Idealerweise möchten Sie den Nutzer informieren, wenn WebGPU nicht verfügbar ist, indem Sie die Seite in einen Modus zurückversetzen, in dem WebGPU nicht verwendet wird. (Könnte stattdessen WebGL verwendet werden?) Für die Zwecke dieses Codelabs geben Sie jedoch einfach einen Fehler aus, um die weitere Ausführung des Codes zu stoppen.

Sobald Sie wissen, dass WebGPU vom Browser unterstützt wird, müssen Sie als Erstes bei der Initialisierung von WebGPU für Ihre App eine GPUAdapter anfordern. Sie können sich einen Adapter als die WebGPU-Darstellung einer bestimmten GPU-Hardware in Ihrem Gerät vorstellen.

  1. Verwenden Sie die Methode navigator.gpu.requestAdapter(), um einen Adapter abzurufen. Es gibt ein Promise zurück, daher ist es am besten, es mit await aufzurufen.

index.html

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
  throw new Error("No appropriate GPUAdapter found.");
}

Wenn keine geeigneten Adapter gefunden werden, kann der zurückgegebene adapter-Wert null sein. Das kann passieren, wenn der Browser des Nutzers WebGPU unterstützt, seine GPU-Hardware aber nicht über alle für die Verwendung von WebGPU erforderlichen Funktionen verfügt.

Meistens ist es in Ordnung, dem Browser einfach einen Standardadapter auswählen zu lassen, wie Sie es hier tun. Für komplexere Anforderungen können Sie jedoch Argumente an requestAdapter() übergeben, die angeben, ob Sie auf Geräten mit mehreren GPUs (z. B. einigen Laptops) Hardware mit geringem Stromverbrauch oder Hochleistungs-Hardware verwenden möchten.

Wenn Sie einen Adapter haben, müssen Sie im letzten Schritt vor der Arbeit mit der GPU ein GPUDevice anfordern. Das Gerät ist die Hauptschnittstelle, über die die meiste Interaktion mit der GPU erfolgt.

  1. Hol dir das Gerät, indem du adapter.requestDevice() aufrufst. Es wird ebenfalls ein Promise zurückgegeben.

index.html

const device = await adapter.requestDevice();

Wie bei requestAdapter() gibt es auch Optionen, die hier für komplexere Verwendungszwecke übergeben werden können, z. B. um bestimmte Hardwarefunktionen zu aktivieren oder höhere Limits anzufordern. Für Ihre Zwecke funktionieren die Standardeinstellungen jedoch gut.

Canvas konfigurieren

Jetzt, da Sie ein Gerät haben, müssen Sie noch eine 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.

  • Fordern Sie dazu zuerst ein GPUCanvasContext vom Canvas an, indem Sie canvas.getContext("webgpu") aufrufen. Dies ist derselbe Aufruf, mit dem Sie Canvas-2D- oder WebGL-Kontexte mit den Kontexttypen 2d und webgl initialisieren würden. Die zurückgegebene context muss dann mithilfe der Methode configure() mit dem Gerät verknüpft werden. Beispiel:

index.html

const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
  device: device,
  format: canvasFormat,
});

Es gibt einige Optionen, die hier übergeben werden können. Die wichtigsten sind jedoch das 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 sagt, wie diese Daten im Arbeitsspeicher angeordnet sind. In diesem Codelab wird die Funktionsweise des Texturspeichers nicht im Detail beschrieben. Wichtig zu wissen ist, dass der Canvas-Kontext Texturen bietet, in die Ihr Code einzeichnen kann. Das verwendete Format kann sich darauf auswirken, wie effizient diese Bilder auf dem Canvas dargestellt werden. Verschiedene Gerätetypen funktionieren am besten, wenn unterschiedliche Texturformate verwendet werden. Wenn Sie nicht das bevorzugte Format des Geräts verwenden, können im Hintergrund zusätzliche Speicherkopien erstellt werden, bevor das Bild als Teil der Seite angezeigt werden kann.

Zum Glück müssen Sie sich darüber keine großen Gedanken machen, da Ihnen WebGPU vorgibt, welches Format Sie für Ihren Canvas verwenden sollen. In fast allen Fällen möchten Sie den zurückgegebenen Wert übergeben, indem Sie navigator.gpu.getPreferredCanvasFormat() aufrufen, wie oben gezeigt.

Canvas leeren

Nachdem Sie nun ein Gerät mit dem Canvas konfiguriert haben, können Sie es verwenden, um den Inhalt des Canvas zu ändern. Löschen Sie sie zunächst mit einer Volltonfarbe.

Um das zu tun – oder so ziemlich alles andere mit einer WebGPU – müssen Sie der GPU einige Befehle zur Ausführung erteilen.

  1. 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 du an die GPU senden möchtest, beziehen sich auf das Rendering (in diesem Fall das Löschen des Canvas). Im nächsten Schritt musst du also encoder verwenden, um einen Render-Pass zu starten.

Bei Renderingdurchläufen finden alle Zeichenvorgänge in WebGPU statt. Jede beginnt mit einem beginRenderPass()-Aufruf, der die Texturen definiert, die die Ausgabe der ausgeführten Zeichenbefehle erhalten. Bei komplexeren Anwendungen können verschiedene Texturen, sogenannte Anhänge, zu verschiedenen Zwecken bereitgestellt werden, zum Beispiel zur Speicherung der Tiefe der gerenderten Geometrie oder zur Kantenglättung. Für diese App benötigen Sie jedoch nur eine.

  1. Rufen Sie die Textur aus dem Canvas-Kontext ab, den Sie zuvor erstellt haben. Rufen Sie dazu context.getCurrentTexture() auf. Daraufhin wird eine Textur mit einer Pixelbreite und -höhe zurückgegeben, die den Attributen width und height des Canvas entspricht, sowie dem format, das beim Aufrufen von context.configure() angegeben wurde.

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
     view: context.getCurrentTexture().createView(),
     loadOp: "clear",
     storeOp: "store",
  }]
});

Die Textur wird als view-Eigenschaft einer colorAttachmentangegeben. Für Renderingdurchläufe ist es erforderlich, dass Sie ein GPUTextureView anstelle eines GPUTexture angeben, das angibt, in welchen Teilen der Textur gerendert werden soll. Das ist nur für komplexere Anwendungsfälle wichtig. Hier rufen Sie createView() ohne Argumente für die Textur auf, was bedeutet, dass bei der Rendering-Übertragung die gesamte Textur verwendet werden soll.

Außerdem musst du angeben, was der Rendering-Pass mit der Textur zu Beginn und Ende tun soll:

  • Der loadOp-Wert "clear" gibt an, dass die Textur gelöscht werden soll, wenn der Renderingdurchlauf beginnt.
  • Der storeOp-Wert "store" gibt an, dass nach Abschluss des Renderingdurchlaufs die Ergebnisse aller Zeichnungen, die während des Renderingdurchlaufs erstellt wurden, in der Textur gespeichert werden sollen.

Sobald der Rendering-Pass begonnen hat, müssen Sie nichts weiter tun. Zumindest vorerst. Das Starten des Renderingpasses mit loadOp: "clear" reicht aus, um die Texturansicht und den Canvas zu löschen.

  1. Beenden Sie die Renderingübergabe, indem Sie direkt nach beginRenderPass() den folgenden Aufruf hinzufügen:

index.html

pass.end();

Es ist wichtig zu wissen, dass die GPU allein durch diese Aufrufe nicht zu einer Aktion führt. Sie zeichnen nur Befehle auf, die die GPU später ausführen soll.

  1. Um ein GPUCommandBuffer zu erstellen, rufe finish() im Befehls-Encoder auf. Der Befehlspuffer ist ein intransparentes Handle für die aufgezeichneten Befehle.

index.html

const commandBuffer = encoder.finish();
  1. Senden Sie den Befehlspuffer mit dem queue von GPUDevice an die GPU. Die Warteschlange führt alle GPU-Befehle aus und stellt so sicher, dass ihre Ausführung gut organisiert und richtig synchronisiert ist. Die Methode submit() der Warteschlange verwendet ein Array von Befehlspuffern, obwohl Sie in diesem Fall nur einen haben.

index.html

device.queue.submit([commandBuffer]);

Nachdem Sie einen Befehlspuffer gesendet haben, kann er nicht noch einmal verwendet werden. Sie müssen ihn also nicht speichern. Wenn Sie weitere Befehle senden möchten, müssen Sie einen weiteren Befehlspuffer erstellen. Aus diesem Grund werden diese beiden Schritte häufig zu einem zusammengefasst, wie es auf den Beispielseiten für dieses Codelab der Fall ist:

index.html

// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);

Nachdem Sie die Befehle an die GPU gesendet haben, kann JavaScript die Steuerung an den Browser zurückgeben. An diesem Punkt erkennt der Browser, dass Sie die aktuelle Textur des Kontexts geändert haben, und aktualisiert das Canvas, um diese Textur als Bild anzuzeigen. Wenn Sie die Canvas-Inhalte danach noch einmal aktualisieren möchten, müssen Sie einen neuen Befehlspuffer aufzeichnen und senden. Rufen Sie dazu noch einmal context.getCurrentTexture() auf, um eine neue Textur für einen Renderingdurchlauf abzurufen.

  1. Lade die Seite neu. Der Canvas ist schwarz dargestellt. Glückwunsch! Das bedeutet, dass Sie Ihre erste WebGPU-Anwendung erfolgreich erstellt haben.

Ein schwarzer Canvas, der angibt, dass WebGPU erfolgreich zum Löschen des Canvas-Inhalts verwendet wurde.

Wähle eine Farbe!

Aber ehrlich gesagt sind schwarze Quadrate ziemlich langweilig. Nehmen Sie sich also einen Moment Zeit, bevor Sie mit dem nächsten Abschnitt fortfahren, um den Abschnitt weiter zu personalisieren.

  1. Fügen Sie im encoder.beginRenderPass()-Aufruf dem colorAttachment eine neue Zeile mit clearValue hinzu. Beispiel:

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
    view: context.getCurrentTexture().createView(),
    loadOp: "clear",
    clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
    storeOp: "store",
  }],
});

clearValue weist den Rendering-Pass an, welche Farbe beim Ausführen des clear-Vorgangs zu Beginn der Karte / des Tickets verwendet werden soll. Das an ihn übergebene Wörterbuch enthält vier Werte: r für red, g für green, b für blue 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 leuchtend rot.
  • { 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 das transparente Standardschwarz.

Im Beispielcode und den Screenshots in diesem Codelab ist Dunkelblau dargestellt. Sie können aber eine beliebige Farbe auswählen.

  1. Aktualisieren Sie die Seite, nachdem Sie eine Farbe ausgewählt haben. Die ausgewählte Farbe sollte auf dem Canvas zu sehen sein.

Ein Canvas, der eine dunkelblaue Farbe annimmt, zeigt, wie die standardmäßige Klarfarbe geändert wird.

4. Geometrie zeichnen

Am Ende dieses Abschnitts zeichnet Ihre App eine einfache Geometrie auf dem Canvas, ein farbiges Quadrat. Seien Sie gewarnt, dass es bei einer so einfachen Ausgabe sehr aufwändig erscheinen mag, aber das liegt daran, dass WebGPU dafür entwickelt wurde, viele Geometrie sehr effizient zu rendern. Ein Nebeneffekt dieser Effizienz besteht darin, dass sich relativ einfache Dinge ungewöhnlich schwierig anfühlen, aber das ist die Erwartung, wenn Sie sich für eine API wie WebGPU entscheiden und etwas komplexer arbeiten möchten.

Wie GPUs zeichnen

Bevor Sie weitere Änderungen am Code vornehmen, sollten Sie sich kurz ansehen, wie GPUs die Formen erstellen, die Sie auf dem Bildschirm sehen. (Sie können gerne mit dem Abschnitt Definieren von Eckpunkten fortfahren, wenn Sie bereits mit den Grundlagen der GPU-Renderings vertraut sind.)

Im Gegensatz zu einer API wie Canvas 2D, die viele Formen und Optionen bietet, verarbeitet Ihre GPU tatsächlich nur einige verschiedene Arten von Formen (oder Primitiven, wie sie von WebGPU bezeichnet werden): Punkte, Linien und Dreiecke. In diesem Codelab verwenden Sie nur Dreiecke.

GPUs funktionieren fast ausschließlich mit Dreiecken, da Dreiecke viele tolle mathematische Eigenschaften haben, die eine vorhersehbare und effiziente Verarbeitung ermöglichen. Fast alles, was Sie mit der GPU zeichnen, muss in Dreiecke unterteilt werden, bevor die GPU sie zeichnen kann. Diese Dreiecke müssen durch ihre Eckpunkte definiert werden.

Diese Punkte oder Eckpunkte werden in Form von X-, Y- und (bei 3D-Inhalten) Z-Werten angegeben, die einen Punkt in einem kartesischen Koordinatensystem definieren, das von WebGPU oder ähnlichen APIs definiert wird. Am einfachsten lässt sich die Struktur des Koordinatensystems im Hinblick auf die Beziehung zum Canvas auf Ihrer Seite bedenken. Unabhängig davon, wie breit oder hoch Ihr Canvas ist, befindet sich der linke Rand auf der X-Achse immer bei -1 und der rechte Rand immer bei +1 auf der X-Achse. Ebenso ist der untere Rand auf der Y-Achse immer -1 und der obere Rand auf der Y-Achse +1. Das heißt, (0, 0) ist immer die Mitte des Canvas, (-1, -1) ist immer die linke untere Ecke und (1, 1) ist immer die obere rechte Ecke. Das wird als Clip Space bezeichnet.

Ein einfaches Diagramm, das den normalisierten Gerätekoordinatenraum darstellt.

Die Eckpunkte werden anfänglich nur selten in diesem Koordinatensystem definiert. Daher sind GPUs auf kleine Programme angewiesen, die als Vertex-Shader bezeichnet werden. Sie führen alle Berechnungen durch, die zur Umwandlung der Eckpunkte in Clipbereich erforderlich sind, sowie alle anderen Berechnungen, die zum Zeichnen der Eckpunkte erforderlich sind. Beispielsweise kann der Shader eine Animation anwenden oder die Richtung vom Scheitelpunkt zu einer Lichtquelle berechnen. Diese Shader werden von Ihnen, dem WebGPU-Entwickler, geschrieben und bieten ein hervorragendes Maß an Kontrolle über die Funktionsweise der GPU.

Von dort aus ermittelt die GPU alle Dreiecke, die aus diesen transformierten Eckpunkten bestehen, und bestimmt, welche Pixel auf dem Bildschirm zum Zeichnen benötigt werden. Dann führt es ein weiteres kleines Programm aus, das Sie geschrieben haben, einen Fragment-Shader, der berechnet, welche Farbe jedes Pixel haben soll. Diese Berechnung kann ganz einfach sein: Return Grün oder aber so komplex wie die Berechnung des Winkels der Oberfläche im Verhältnis zu dem, der von anderen Oberflächen in der Nähe reflektiert wird, durch Nebel gefiltert und durch die metallische Oberfläche der Oberfläche verändert wird. Du hast die Kontrolle – das kann sowohl ermutigend als auch überwältigend sein.

Die Ergebnisse dieser Pixelfarben werden dann zu einer Textur zusammengefasst, die dann auf dem Bildschirm dargestellt werden kann.

Eckpunkte definieren

Wie bereits erwähnt, wird die „Game of Life“-Simulation in Form eines Rasters aus Zellen dargestellt. Ihre App benötigt eine Möglichkeit, das Raster zu visualisieren, um aktive von inaktiven Zellen zu unterscheiden. Der Ansatz in diesem Codelab besteht darin, farbige Quadrate in die aktiven Zellen zu zeichnen und inaktive Zellen leer zu lassen.

Dies bedeutet, dass Sie der GPU vier verschiedene Punkte bereitstellen müssen, einen für jede der vier Ecken des Quadrats. Ein Quadrat, das in der Mitte des Canvas gezeichnet wird und von den Kanten weggezogen wird, hat beispielsweise die folgenden Eckkoordinaten:

Ein Diagramm mit normalisierten Gerätekoordinaten, das die Koordinaten der Ecken eines Quadrats zeigt

Um diese Koordinaten an die GPU zu übergeben, müssen Sie die Werte in ein TypedArray einfügen. TypedArrays sind eine Gruppe von JavaScript-Objekten, mit denen Sie zusammenhängende Speicherblöcke zuweisen und jedes Element der Reihe als einen bestimmten Datentyp interpretieren können. In einem Uint8Array ist jedes Element im Array beispielsweise ein einzelnes, vorzeichenloses Byte. TypedArrays eignen sich hervorragend zum Austauschen von Daten mit APIs, die empfindlich auf Speicherlayouts reagieren, wie WebAssembly, WebAudio und natürlich WebGPU.

Für das quadratische Beispiel ist ein Float32Array angemessen, da es sich bei den Werten um Bruchzahlen handelt.

  1. Erstellen Sie ein Array, das alle Scheitelpunktpositionen im Diagramm enthält, indem Sie die folgende Array-Deklaration in Ihren Code einfügen. Am besten platzieren Sie es oben unterhalb des context.configure()-Aufrufs.

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8,
   0.8, -0.8,
   0.8,  0.8,
  -0.8,  0.8,
]);

Beachten Sie, dass die Abstände und Kommentare keine Auswirkungen auf die Werte haben. damit Sie sie besser lesen können. Sie können sehen, dass jedes Wertepaar die X- und Y-Koordinaten eines Scheitelpunkts darstellt.

Aber es gibt ein Problem! GPUs funktionieren auch in Form von Dreiecken. Erinnern Sie sich? Das bedeutet, dass Sie die Eckpunkte in Dreiergruppen angeben müssen. Sie haben eine Vierergruppe. Die Lösung besteht darin, zwei der Eckpunkte zu wiederholen und so zwei Dreiecke zu bilden, die eine gemeinsame Kante durch die Mitte des Quadrats teilen.

Ein Diagramm, das zeigt, wie die vier Eckpunkte des Quadrats verwendet werden, um zwei Dreiecke zu bilden.

Um das Quadrat aus dem Diagramm zu bilden, müssen Sie die Eckpunkte (-0,8, -0,8) und (0,8, 0,8) zweimal auflisten, einmal für das blaue Dreieck und einmal für das rote Dreieck. Sie können das Quadrat auch mit den anderen beiden Ecken teilen. Das macht keinen Unterschied.

  1. Aktualisieren Sie das vorherige vertices-Array so, dass es ungefähr 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,
]);

Obwohl das Diagramm zur Verdeutlichung einen Abstand zwischen den beiden Dreiecken zeigt, sind die Scheitelpunktpositionen genau gleich und die GPU rendert sie ohne Lücken. Es wird als einzelnes, ausgefülltes Quadrat gerendert.

Vertex-Zwischenspeicher erstellen

Die GPU kann keine Eckpunkte mit Daten aus einem JavaScript-Array ziehen. GPUs haben häufig ihren eigenen Arbeitsspeicher, der für das Rendering optimiert ist. Daher müssen alle Daten, die die GPU beim Zeichnen verwenden soll, in diesem Arbeitsspeicher platziert werden.

Bei vielen Werten, einschließlich Scheitelpunktdaten, wird der GPU-Arbeitsspeicher über GPUBuffer-Objekte verwaltet. Ein Zwischenspeicher ist ein Arbeitsspeicherblock, auf den die GPU problemlos zugreifen kann und der für bestimmte Zwecke gekennzeichnet ist. Sie können sich dies ein bisschen wie ein GPU-sichtbares TypedArray vorstellen.

  1. Fügen Sie den folgenden Aufruf zu device.createBuffer() nach der Definition Ihres vertices-Arrays hinzu, um einen Zwischenspeicher für Ihre Scheitelpunkte zu erstellen.

index.html

const vertexBuffer = device.createBuffer({
  label: "Cell vertices",
  size: vertices.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

Zu beachten ist, dass Sie dem Puffer ein Label zuweisen. Sie können jedes einzelne WebGPU-Objekt, das Sie erstellen, ein optionales Label erhalten. Das sollten Sie auf jeden Fall tun. Das Label ist eine beliebige Zeichenfolge, solange Sie damit das Objekt leichter identifizieren können. Wenn Probleme auftreten, werden diese Labels in den Fehlermeldungen von WebGPU verwendet, damit Sie besser nachvollziehen können, was schiefgelaufen ist.

Als Nächstes geben Sie eine Größe für den Zwischenspeicher in Byte an. Sie benötigen einen Zwischenspeicher mit 48 Byte, den Sie durch Multiplizieren der Größe einer 32-Bit-Gleitkommazahl ( 4 Byte) mit der Anzahl der Gleitkommazahlen in Ihrem vertices-Array (12) ermitteln. Erfreulicherweise berechnen TypedArrays ihre byteLength bereits für Sie, sodass Sie diese beim Erstellen des Zwischenspeichers verwenden können.

Abschließend müssen Sie die usage des Zwischenspeichers angeben. Dies ist eines oder mehrere Flags vom Typ GPUBufferUsage, wobei mehrere Flags mit dem Operator | ( bitweises ODER) kombiniert werden. In diesem Fall geben Sie an, dass der Zwischenspeicher für Scheitelpunktdaten (GPUBufferUsage.VERTEX) verwendet werden soll und dass auch Daten in ihn kopiert werden können (GPUBufferUsage.COPY_DST).

Das an Sie zurückgegebene Pufferobjekt ist undurchsichtig. Sie können die darin enthaltenen Daten nicht (einfach) überprüfen. Außerdem sind die meisten Attribute unveränderlich. Sie können die Größe eines GPUBuffers nach seiner Erstellung nicht ändern und auch die Nutzungs-Flags nicht mehr ändern. Sie können den Inhalt des Arbeitsspeichers ändern.

Wenn der Zwischenspeicher zum ersten Mal erstellt wird, wird der darin enthaltene Arbeitsspeicher 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 kopieren möchten.

  1. Fügen Sie den folgenden Code hinzu, um die Scheitelpunktdaten in den Zwischenspeicherspeicher zu kopieren:

index.html

device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);

Vertex-Layout definieren

Jetzt haben Sie einen Puffer mit Scheitelpunktdaten, aber was die GPU betrifft, ist es nur ein Blob aus Byte. Wenn Sie damit etwas zeichnen möchten, benötigen Sie weitere Informationen. Sie müssen WebGPU mehr über die Struktur der Scheitelpunktdaten mitteilen können.

index.html

const vertexBufferLayout = {
  arrayStride: 8,
  attributes: [{
    format: "float32x2",
    offset: 0,
    shaderLocation: 0, // Position, see vertex shader
  }],
};

Dies kann auf den ersten Blick etwas verwirrend sein, lässt sich aber relativ leicht aufschlüsseln.

Als Erstes geben Sie den arrayStride an. Dies ist die Anzahl der Byte, die die GPU im Zwischenspeicher überspringen muss, wenn sie nach dem nächsten Scheitelpunkt sucht. Jeder Scheitelpunkt des Quadrats besteht aus zwei 32-Bit-Gleitkommazahlen. Wie bereits erwähnt, beträgt eine 32-Bit-Gleitkommazahl 4 Byte, also sind zwei Gleitkommazahlen 8 Byte.

Als Nächstes kommt die Eigenschaft attributes, bei der es sich um ein Array handelt. Attribute sind die einzelnen Informationen, die in jedem Scheitelpunkt codiert sind. Ihre Eckpunkte enthalten nur ein Attribut (die Position des Scheitelpunkts). Bei komplexeren Anwendungsfällen gibt es jedoch häufig Eckpunkte mit mehreren Attributen, z. B. die Farbe eines Scheitelpunkts oder die Richtung, in die die Geometrieoberfläche zeigt. Das ist in diesem Codelab jedoch nicht möglich.

In Ihrem einzelnen Attribut definieren Sie zuerst den format der Daten. Diese stammt aus einer Liste von GPUVertexFormat-Typen, die jeden Typ von Vertexdaten beschreiben, die die GPU verstehen kann. Ihre Eckpunkte haben jeweils zwei 32-Bit-Gleitkommazahlen, daher verwenden Sie das Format float32x2. Wenn Ihre Scheitelpunktdaten jeweils aus vier vorzeichenlosen 16-Bit-Ganzzahlen bestehen, sollten Sie stattdessen uint16x4 verwenden. Siehst du das Muster?

Als Nächstes beschreibt offset, wie viele Byte im Scheitelpunkt dieses bestimmten Attributs beginnen. Sie müssen sich nur darüber Gedanken machen, wenn Ihr Puffer mehr als ein Attribut enthält, das bei diesem Codelab nicht auftauchen wird.

Jetzt 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 einer bestimmten Eingabe im Vertex-Shader. Darüber erfahren Sie im nächsten Abschnitt.

Beachten Sie, dass Sie diese Werte jetzt zwar definieren, aber noch nirgendwo an die WebGPU API übergeben werden. Diese Werte kommen schon bald an, aber es ist am einfachsten, sich diese Werte an dem Punkt zu überlegen, an dem Sie Ihre Eckpunkte definieren, also richten Sie sie jetzt für die spätere Verwendung ein.

Mit Shadern beginnen

Jetzt haben Sie die Daten, die Sie rendern möchten, müssen der GPU aber noch genau mitteilen, wie sie verarbeitet werden sollen. Das geschieht zu einem großen Teil bei Shadern.

Shader sind kleine Programme, die Sie schreiben und die auf Ihrer GPU ausführen. Jeder Shader wird in einer anderen Phase der Daten ausgeführt: Vertex-Verarbeitung, Fragment-Verarbeitung oder allgemeine Compute. Da sie sich auf der GPU befinden, sind sie starrer strukturiert als Ihr durchschnittliches JavaScript. Aber diese Struktur ermöglicht es ihnen, sehr schnell und – entscheidend – parallel zu arbeiten!

Shader in WebGPU sind in einer Shading Language (WGSL) (WebGPU Shading Language) geschrieben. WGSL ähnelt syntaktisch ein wenig wie Rust und hat Funktionen, die gängige GPU-Funktionen wie Vektor- und Matrixberechnungen einfacher und schneller machen sollen. Die Schattierungssprache vollständig zu vermitteln, geht über den Rahmen dieses Codelab hinaus. Wir hoffen aber, dass Sie einige der Grundlagen lernen, während Sie einige einfache Beispiele durchgehen.

Die Shader selbst werden als Strings an die WebGPU übergeben.

  • Erstellen Sie einen Ort, an dem Sie Ihren Shader-Code eingeben können, indem Sie Folgendes in Ihren Code unter vertexBufferLayout kopieren:

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. Dabei geben Sie optional ein label und eine WGSL-code als String an. (Beachten Sie, dass Sie hier Graviszeichen verwenden, um mehrzeilige Strings zuzulassen.) Nachdem Sie gültigen WGSL-Code hinzugefügt haben, gibt die Funktion ein GPUShaderModule-Objekt mit den kompilierten Ergebnissen zurück.

Vertex-Shader definieren

Beginnen Sie mit dem Vertex-Shader, da dort auch die GPU beginnt.

Ein Vertex-Shader ist als Funktion definiert und die GPU ruft diese Funktion für jeden Scheitelpunkt in Ihrer vertexBuffer einmal auf. Da Ihre vertexBuffer sechs Positionen (Scheitelpunkte) enthält, wird die von Ihnen definierte Funktion sechsmal aufgerufen. Bei jedem Aufruf wird eine andere Position vom vertexBuffer als Argument an die Funktion übergeben. Die Vertex-Shader-Funktion hat die Aufgabe, eine entsprechende Position im Clipbereich zurückzugeben.

Es ist wichtig zu verstehen, dass sie auch nicht unbedingt in sequenzieller Reihenfolge aufgerufen werden. Stattdessen sind GPUs besonders gut geeignet, um Shader wie diese parallel auszuführen und damit möglicherweise Hunderte (oder sogar Tausende!) von Scheitelpunkten gleichzeitig zu verarbeiten. Das ist ein großer Teil der Faktoren, die für die hohe Geschwindigkeit von GPUs verantwortlich sind, aber es bringt auch Einschränkungen mit sich. Um eine extreme Parallelisierung zu gewährleisten, können Vertex-Shader nicht miteinander kommunizieren. Jeder Shader-Aufruf kann jeweils nur Daten für einen einzelnen Scheitelpunkt sehen und nur Werte für einen einzelnen Scheitelpunkt ausgeben.

In WGSL kann eine Vertex-Shader-Funktion beliebig benannt werden. Allerdings muss das @vertex-Attribut davor stehen, um anzugeben, welche Shader-Phase sie darstellt. WGSL kennzeichnet Funktionen mit dem Schlüsselwort fn, verwendet Klammern, um Argumente anzugeben, und verwendet geschweifte Klammern, um den Bereich zu definieren.

  1. Erstellen Sie so eine leere @vertex-Funktion:

index.html (createShaderModule-Code)

@vertex
fn vertexMain() {

}

Das ist jedoch ungültig, da ein Vertex-Shader mindestens die endgültige Position des Scheitelpunkts zurückgeben muss, der im Clipbereich verarbeitet wird. Dies wird immer als vierdimensionaler Vektor angegeben. Vektoren werden so häufig in Shadern verwendet, dass sie in der Sprache als erstklassige Primitive behandelt werden. Es gibt eigene Typen wie vec4f für einen vierdimensionalen Vektor. Es gibt auch ähnliche Typen für 2D-Vektoren (vec2f) und 3D-Vektoren (vec3f).

  1. Um anzugeben, dass der zurückgegebene Wert die erforderliche Position ist, markieren Sie sie mit dem Attribut @builtin(position). Das Symbol -> gibt an, dass die Funktion dies zurückgibt.

index.html (createShaderModule-Code)

@vertex
fn vertexMain() -> @builtin(position) vec4f {

}

Wenn die Funktion einen Rückgabetyp hat, muss natürlich ein Wert im Funktionstext zurückgegeben werden. Sie können mit der Syntax vec4f(x, y, z, w) eine neue vec4f erstellen, die zurückgegeben werden soll. Die Werte x, y und z sind Gleitkommazahlen, die im Rückgabewert angeben, wo der Scheitelpunkt im Clipbereich liegt.

  1. Wenn Sie den statischen Wert (0, 0, 0, 1) zurückgeben, haben Sie prinzipiell einen gültigen Vertex-Shader, obwohl dieser nie etwas anzeigt, da die GPU erkennt, dass die erzeugten Dreiecke nur ein einzelner Punkt sind, und verwirft ihn dann.

index.html (createShaderModule-Code)

@vertex
fn vertexMain() -> @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}

Stattdessen möchten Sie die Daten aus dem von Ihnen erstellten Zwischenspeicher verwenden. Dazu deklarieren Sie ein Argument für Ihre Funktion mit einem @location()-Attribut und einem Typ, der Ihrer Beschreibung in vertexBufferLayout entspricht. Du hast als shaderLocation den Wert 0 angegeben, deshalb musst du das Argument in deinem WGSL-Code mit @location(0) markieren. Sie haben das Format auch als float32x2 definiert. Dies ist ein 2D-Vektor, sodass Ihr Argument in WGSL ein vec2f-Argument ist. Sie können ihm einen beliebigen Namen geben, aber da diese Ihre Scheitelpunktpositionen darstellen, erscheint ein Name wie pos natürlich.

  1. Ändern Sie Ihre Shader-Funktion in den folgenden Code:

index.html (createShaderModule-Code)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1);
}

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. Nehmen Sie die beiden Komponenten aus dem Positionsargument und platzieren Sie sie in den ersten beiden Komponenten des Rückgabevektors, wobei Sie die letzten beiden Komponenten als 0 bzw. 1 belassen.

  1. Gibt die korrekte Position zurück, indem Sie explizit angeben, welche Positionskomponenten verwendet werden sollen:

index.html (createShaderModule-Code)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos.x, pos.y, 0, 1);
}

Allerdings, da diese Art von Zuordnungen in Shadern so weit verbreitet ist, können Sie den Positionsvektor auch in einer praktischen Kurzschreibweise als erstes Argument übergeben. Das bedeutet dasselbe.

  1. Schreiben Sie die return-Anweisung mit dem folgenden Code um:

index.html (createShaderModule-Code)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos, 0, 1);
}

Und das ist Ihr erster Scheitel-Shader! Es ist ganz einfach, die Position wird praktisch unverändert ausgegeben, aber es reicht für die ersten Schritte aus.

Fragment-Shader definieren

Als Nächstes kommt der Fragment-Shader. Fragment-Shader funktionieren ähnlich wie Vertex-Shader. Sie werden jedoch nicht für jeden Scheitelpunkt aufgerufen, sondern für jedes gezeichnete Pixel aufgerufen.

Fragment-Shader werden immer nach Scheitelpunkt-Shadern aufgerufen. Die GPU nimmt die Ausgabe der Scheitelpunkt-Shader und trianguliert sie und erstellt Dreiecke aus Gruppen von drei Punkten. Anschließend gerastert es jedes dieser Dreiecke, indem es ermittelt, welche Pixel der Ausgabefarbanhänge in diesem Dreieck enthalten sind, und ruft dann den Fragment-Shader einmal für jedes dieser Pixel auf. Der Fragment-Shader gibt eine Farbe zurück, die in der Regel aus Werten berechnet wird, die vom Scheitelpunkt-Shader und Assets wie Texturen an ihn gesendet wurden, die die GPU in den Farbanhang schreibt.

Genau wie Vertex-Shader werden Fragment-Shader in einer massiv parallelen Ausführung ausgeführt. Sie sind etwas flexibler als Vertex-Shader in Bezug auf ihre Ein- und Ausgaben, aber Sie können sie so betrachten, dass sie einfach eine Farbe für jedes Pixel jedes Dreiecks zurückgeben.

Eine WGSL-Fragment-Shader-Funktion wird mit dem Attribut @fragment angegeben und gibt auch einen vec4f-Wert zurück. In diesem Fall stellt der Vektor jedoch eine Farbe und keine Position dar. Der Rückgabewert muss ein @location-Attribut erhalten, um anzugeben, in welchen colorAttachment aus dem beginRenderPass-Aufruf die zurückgegebene Farbe geschrieben wird. Da Sie nur einen Anhang hatten, ist der Standort 0.

  1. Erstellen Sie so eine leere @fragment-Funktion:

index.html (createShaderModule-Code)

@fragment
fn fragmentMain() -> @location(0) vec4f {

}

Die vier Komponenten des zurückgegebenen Vektors sind die Rot-, Grün-, Blau- und Alpha-Farbwerte, die genauso interpretiert werden wie die clearValue, die Sie zuvor in beginRenderPass festgelegt haben. vec4f(1, 0, 0, 1) ist also leuchtend Rot, was eine anständige Farbe für Ihr Square zu sein scheint. Du kannst aber auch eine andere Farbe wählen.

  1. 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)
}

Und das ist ein vollständiger Fragment-Shader! Das ist nicht besonders interessant. wird einfach jedes Pixel jedes Dreiecks auf Rot gesetzt, aber das reicht vorerst aus.

Nur zur Erinnerung: Nachdem Sie den oben beschriebenen Shader-Code 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);
    }
  `
});

Renderingpipeline erstellen

Ein Shader-Modul kann nicht selbst zum Rendern verwendet werden. Stattdessen müssen Sie es als Teil einer GPURenderPipeline verwenden, die durch Aufrufen von device.createRenderPipeline() erstellt wird. Die Rendering-Pipeline steuert, wie Geometrie gezeichnet wird, einschließlich Informationen darüber, welche Shader verwendet werden, wie Daten in Scheitelpunktzwischenspeichern interpretiert werden und welche Art von Geometrie gerendert werden soll (Linien, Punkte, Dreiecke usw.).

Die Rendering-Pipeline ist das komplexeste Objekt in der gesamten API, aber keine Sorge! Die meisten Werte, die Sie übergeben können, sind optional und Sie müssen zu Beginn nur wenige Werte angeben.

  • Erstellen Sie wie folgt eine Rendering-Pipeline:

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: "auto",
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

Jede Pipeline benötigt eine layout, die beschreibt, welche Arten von Eingaben (mit Ausnahme von Vertex-Zwischenspeichern) die Pipeline benötigt. Sie haben jedoch nicht wirklich welche. Glücklicherweise können Sie vorerst "auto" übergeben. Die Pipeline erstellt dann aus den Shadern ein eigenes Layout.

Als Nächstes müssen Sie Details zur Phase vertex angeben. module ist das GPUShaderModule, das Ihren Vertex-Shader enthält. entryPoint gibt den Namen der Funktion im Shader-Code an, der für jeden Vertex-Aufruf aufgerufen wird. Sie können mehrere @vertex- und @fragment-Funktionen in einem einzelnen Shader-Modul haben. Die Zwischenspeicher sind ein Array von GPUVertexBufferLayout-Objekten, die beschreiben, wie Ihre Daten in den Vertex-Zwischenspeichern, mit denen Sie diese Pipeline verwenden, verpackt werden. Zum Glück hast du das bereits zuvor in deinem vertexBufferLayout definiert. Hier geben Sie sie ein.

Sie haben noch weitere Informationen zur Phase fragment. Dazu gehören auch ein Shader-modul und einen entryPoint wie die Vertex-Phase. Mit dem letzten Bit wird das targets definiert, mit dem diese Pipeline verwendet wird. Dies ist ein Array von Wörterbüchern, die Details wie die Textur format der Farbanhänge enthalten, an die die Pipeline ausgibt. Diese Details müssen mit den Texturen übereinstimmen, die in den colorAttachments aller Renderingkarten angegeben sind, mit denen diese Pipeline verwendet wird. Deine Rendering-Karte verwendet Texturen aus dem Canvas-Kontext und nutzt den in canvasFormat gespeicherten Wert als Format, sodass du hier das gleiche Format übergibst.

Das sind nicht einmal alle Optionen, die Sie beim Erstellen einer Rendering-Pipeline angeben können, aber das reicht für die Anforderungen dieses Codelabs aus.

Quadrat zeichnen

Damit haben Sie jetzt alles, was Sie zum Zeichnen Ihres Quadrats benötigen!

  1. Um das Quadrat zu zeichnen, springen Sie wieder nach unten zum Aufrufpaar encoder.beginRenderPass() und pass.end() und fügen Sie die folgenden neuen Befehle dazwischen ein:

index.html

// After encoder.beginRenderPass()

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices

// before pass.end()

Dadurch erhält WebGPU alle Informationen, die zum Zeichnen des Quadrats erforderlich sind. Zuerst geben Sie mit setPipeline() an, mit welcher Pipeline gezeichnet werden soll. Dazu gehören die verwendeten Shader, das Layout der Scheitelpunktdaten und andere relevante Statusdaten.

Als Nächstes rufen Sie setVertexBuffer() mit dem Zwischenspeicher auf, der die Eckpunkte für Ihr Quadrat enthält. Sie rufen ihn mit 0 auf, da dieser Zwischenspeicher dem 0. Element in der vertex.buffers-Definition der aktuellen Pipeline entspricht.

Und zu guter Letzt starten Sie den draw()-Anruf, der nach der gesamten vorherigen Einrichtung seltsam einfach erscheint. Das einzige, was Sie übergeben müssen, ist die Anzahl der Scheitelpunkte, die gerendert werden soll. Sie ruft diese aus den aktuell festgelegten Scheitelpunktzwischenspeichern ab und interpretiert sie mit der aktuell festgelegten Pipeline. Sie könnten es einfach als 6 hartcodieren. Wenn Sie es jedoch aus dem Array der Eckpunkte (12 Gleitkommazahlen / 2 Koordinaten pro Scheitelpunkt == 6 Eckpunkte) berechnen, müssen Sie weniger manuell aktualisieren, wenn Sie das Quadrat z. B. durch einen Kreis ersetzen müssen.

  1. Aktualisieren Sie Ihren Bildschirm und sehen Sie sich endlich die Ergebnisse Ihrer harten Arbeit an: ein großes farbiges Quadrat.

Ein einzelnes rotes Quadrat, das mit WebGPU gerendert wird

5. Raster zeichnen

Zuerst solltest du dir einen Moment Zeit nehmen, um dir selbst zu gratulieren! Die ersten geometrischen Formen auf dem Bildschirm zu präsentieren, ist bei den meisten GPU-APIs oft einer der schwierigsten Schritte. Alles, was Sie von hier aus tun, kann in kleineren Schritten ausgeführt werden, sodass Sie Ihren Fortschritt einfacher überprüfen können.

In diesem Abschnitt lernen Sie Folgendes:

  • Wie Variablen (Uniformen) von JavaScript an den Shader übergeben werden.
  • Hier erfährst du, wie du das Renderingverhalten mithilfe von Uniformen änderst.
  • Hier erfahren Sie, wie Sie mithilfe der Instant-Funktion viele verschiedene Varianten derselben Geometrie zeichnen.

Raster definieren

Um ein Raster zu rendern, müssen Sie eine sehr grundlegende Information darüber kennen. Wie viele Zellen enthält sie (Breite und Höhe)? Das liegt ganz bei Ihnen als Entwickler. Aus Gründen der Einfachheit sollten Sie das Raster jedoch als Quadrat behandeln (gleiche Breite und Höhe) und eine Potenz von zwei verwenden. Das erleichtert später einige Berechnungen. Sie möchten es später vergrößern, aber legen Sie für den Rest dieses Abschnitts die Rastergröße auf 4 x 4 fest, da es so einfacher ist, einige der in diesem Abschnitt verwendeten Berechnungen zu demonstrieren. Skalieren Sie danach hoch!

  • 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 das Rendering des Squares anpassen, damit GRID_SIZE-mal GRID_SIZE davon auf den Canvas passen. Das bedeutet, dass das Quadrat viel kleiner sein muss und es viele davon geben müssen.

Eine Möglichkeit, dies zu könnten, besteht darin, den Scheitelpunktzwischenspeicher deutlich größer zu machen und darin Quadrate im Wert von GRID_SIZE mal GRID_SIZE in der richtigen Größe und Position zu definieren. Der Code dafür wäre gar nicht so schlecht! Nur ein paar Schleifen und ein bisschen Mathematik. Allerdings wird dadurch auch die GPU nicht optimal genutzt und mehr Arbeitsspeicher verbraucht, als für den Effekt erforderlich ist. In diesem Abschnitt wird ein GPU-freundlicherer Ansatz beschrieben.

Einheitlichen Zwischenspeicher erstellen

Zunächst müssen Sie die ausgewählte Rastergröße an den Shader kommunizieren, da er diese verwendet, um die Anzeige der Dinge zu ändern. Sie könnten die Größe einfach im Shader hartcodieren. Das bedeutet jedoch, dass Sie jedes Mal, wenn Sie die Rastergröße ändern möchten, den Shader und die Rendering-Pipeline neu erstellen müssen, was teuer ist. Besser ist es, dem Shader die Rastergröße als Uniformen zur Verfügung zu stellen.

Sie haben bereits gelernt, dass an jeden Aufruf eines Vertex-Shaders ein anderer Wert aus dem Vertex-Zwischenspeicher übergeben wird. Eine Einheit ist ein Wert aus einem Zwischenspeicher, der für jeden Aufruf gleich ist. Sie sind nützlich, um Werte zu übermitteln, die für ein Element der Geometrie (wie seine Position), einen ganzen Frame einer Animation (z. B. die aktuelle Zeit) oder sogar die gesamte Lebensdauer der App (wie eine Nutzereinstellung) üblich sind.

  • Erstellen Sie einen einheitlichen Zwischenspeicher, indem Sie den folgenden Code hinzufügen:

index.html

// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
  label: "Grid Uniforms",
  size: uniformArray.byteLength,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);

Das sollte Ihnen sehr vertraut vorkommen, da es sich um fast denselben Code handelt, den Sie zuvor zum Erstellen des Scheitelpunktzwischenspeichers verwendet haben. Das liegt daran, dass Uniformen über dieselben GPUBuffer-Objekte wie Eckpunkte an die WebGPU API kommuniziert werden. Der Hauptunterschied besteht darin, dass usage dieses Mal GPUBufferUsage.UNIFORM anstelle von GPUBufferUsage.VERTEX enthält.

Auf Uniformen in einem Shader zugreifen

  • Definieren Sie eine Uniform, indem Sie den folgenden Code hinzufügen:

index.html (createShaderModule-Aufruf)

// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos / grid, 0, 1);
}

// ...fragmentMain is unchanged 

Damit wird eine Einheit in Ihrem Shader definiert, die grid genannt wird. Dabei handelt es sich um einen 2D-Gleitkommavektor, der mit dem Array übereinstimmt, das Sie gerade in den Puffer für einheitliche Daten kopiert haben. Außerdem wird angegeben, dass die Uniform an @group(0) und @binding(0) gebunden ist. Sie erfahren gleich, was diese Werte bedeuten.

Anschließend können Sie an anderer Stelle im Shader-Code den Rastervektor beliebig verwenden. In diesem Code dividieren Sie die Position des Scheitelpunkts durch den Rastervektor. Da pos ein 2D-Vektor und grid ein 2D-Vektor ist, führt WGSL eine komponentenbasierte Division durch. Mit anderen Worten, das Ergebnis ist das gleiche wie vec2f(pos.x / grid.x, pos.y / grid.y).

Diese Arten von Vektorvorgängen sind bei GPU-Shadern sehr üblich, da viele Rendering- und Computing-Techniken darauf basieren.

Das bedeutet in Ihrem Fall, dass das Quadrat, das Sie rendern, ein Viertel seiner ursprünglichen Größe (bei Verwendung einer Rastergröße von 4) wäre. Das ist ideal, wenn Sie vier von ihnen in eine Zeile oder Spalte passen möchten!

Bindungsgruppe erstellen

Durch die Deklaration der Uniform im Shader wird sie jedoch nicht mit dem von Ihnen erstellten Zwischenspeicher verbunden. Dazu müssen Sie eine Bindungsgruppe erstellen und festlegen.

Eine Bindungsgruppe ist eine Sammlung von Ressourcen, die Sie gleichzeitig für Ihren Shader zugänglich machen möchten. Es kann verschiedene Arten von Puffern enthalten, wie Ihren einheitlichen Puffer, und andere Ressourcen wie Texturen und Sampler, die hier nicht behandelt werden, aber gängige Bestandteile der WebGPU-Rendering-Techniken sind.

  • Erstellen Sie eine Bindungsgruppe mit Ihrem einheitlichen Zwischenspeicher. Fügen Sie dazu nach der Erstellung des einheitlichen Zwischenspeichers und der Renderingpipeline 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 }
  }],
});

Neben dem jetzt standardmäßigen label benötigen Sie auch eine layout, in der beschrieben wird, welche Arten von Ressourcen diese Bindungsgruppe enthält. Darauf gehen Sie in einem späteren Schritt näher ein, aber im Moment können Sie Ihre Pipeline gerne nach dem Bindungsgruppen-Layout fragen, da Sie die Pipeline mit layout: "auto" erstellt haben. Dadurch erstellt die Pipeline automatisch Bindungsgruppenlayouts aus den Bindungen, die Sie im Shader-Code selbst deklariert haben. In diesem Fall stellen Sie den Befehl an getBindGroupLayout(0), wobei 0 der @group(0) entspricht, die Sie in den Shader eingegeben haben.

Nachdem Sie das Layout festgelegt haben, geben Sie ein Array von entries an. Jeder Eintrag ist ein Wörterbuch mit mindestens den folgenden Werten:

  • binding, entspricht dem @binding()-Wert, den du im Shader eingegeben hast. In diesem Fall 0.
  • resource, die eigentliche Ressource, die Sie der Variablen am angegebenen Bindungsindex zur Verfügung stellen möchten. In diesem Fall ist es Ihr Uniformpuffer.

Die Funktion gibt ein GPUBindGroup zurück. Dies ist ein opaker, unveränderlicher Handle. Sie können die Ressourcen, auf die eine Bindungsgruppe verweist, nicht mehr ändern, nachdem sie erstellt wurde. Sie können jedoch den Inhalt dieser Ressourcen ändern. Wenn Sie beispielsweise den einheitlichen Zwischenspeicher so ändern, dass er eine neue Rastergröße enthält, spiegelt sich dies in zukünftigen Zeichenaufrufen mithilfe dieser Bindungsgruppe wider.

Bindungsgruppe binden

Nachdem die Bindungsgruppe erstellt wurde, müssen Sie noch WebGPU anweisen, sie beim Zeichnen zu verwenden. Zum Glück ist das ziemlich einfach.

  1. Wechseln Sie zurück zum Renderingpass und fügen Sie diese neue Zeile vor der Methode draw() ein:

index.html

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);

pass.setBindGroup(0, bindGroup); // New line!

pass.draw(vertices.length / 2);

Das als erste Argument übergebene 0-Argument entspricht dem @group(0) im Shader-Code. Sie geben an, dass jeder @binding, der zu @group(0) gehört, die Ressourcen in dieser Bindungsgruppe verwendet.

Jetzt ist der einheitliche Zwischenspeicher dem Shader ausgesetzt.

  1. Aktualisieren Sie Ihre Seite. Anschließend sollte in etwa Folgendes angezeigt werden:

Ein kleines rotes Quadrat in der Mitte eines dunkelblauen Hintergrunds.

Super! Dein Square ist jetzt ein Viertel so groß wie vorher! Das ist nicht viel, aber es wird angezeigt, dass Ihre Uniform angewendet wurde und der Shader jetzt auf die Größe Ihres Rasters zugreifen kann.

Geometrie im Shader bearbeiten

Da Sie nun auf die Rastergröße im Shader verweisen können, können Sie damit beginnen, die dargestellte Geometrie so zu bearbeiten, dass sie in das gewünschte Rastermuster passt. Überlegen Sie sich dabei genau, was Sie erreichen möchten.

Sie müssen Ihren Canvas konzeptionell in einzelne Zellen aufteilen. Um die Konvention beizubehalten, dass sich die X-Achse bei einer Bewegung nach rechts und die Y-Achse mit dem Aufwärtsbewegen zunimmt, nehmen wir an, dass sich die erste Zelle in der linken unteren Ecke des Canvas befindet. Das Ergebnis sieht so aus, mit Ihrer aktuellen quadratischen Geometrie in der Mitte:

Eine Darstellung des konzeptionellen Rasters des normalisierten Gerätekoordinatenraums wird bei der Visualisierung jeder Zelle mit der aktuell gerenderten quadratischen Geometrie im Mittelpunkt geteilt.

Ihre Aufgabe besteht darin, eine Methode im Shader zu finden, mit der Sie die quadratische Geometrie in einer dieser Zellen anhand der Zellenkoordinaten positionieren können.

Zunächst sehen Sie, dass Ihr Quadrat nicht genau an einer der Zellen ausgerichtet ist, da es so definiert wurde, dass es die Mitte des Canvas umgibt. Das Quadrat sollte um eine halbe Zelle verschoben werden, damit es gut innerhalb der Zelle ausgerichtet ist.

Eine Möglichkeit, dieses Problem zu beheben, besteht darin, den Zwischenspeicher des Scheitelpunkts des Quadrats zu aktualisieren. Indem Sie die Eckpunkte so verschieben, dass sich die untere rechte Ecke beispielsweise bei (0.1, 0.1) statt bei (-0.8, -0.8) befindet, würden Sie dieses Quadrat so verschieben, dass es besser an den Zellgrenzen liegt. Da Sie jedoch die volle Kontrolle darüber haben, wie die Scheitelpunkte in Ihrem Shader verarbeitet werden, ist es genauso einfach, sie einfach mit dem Shader-Code zu verschieben.

  1. Ä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 werden die Scheitelpunkte um eins nach oben und rechts nach rechts verschoben, bevor sie durch die Rastergröße geteilt wird. Das ist die Hälfte der Abschneidefläche. Das Ergebnis ist ein schön rasterbasiertes Quadrat nahe dem Ursprung.

Eine Visualisierung des Canvas, das konzeptionell in ein 4x4-Raster mit einem roten Quadrat in Zelle (2, 2) unterteilt ist

Da das Koordinatensystem Ihres Canvas (0, 0) in der Mitte und (-1, -1) unten links platziert wird und Sie (0, 0) links unten haben möchten, müssen Sie die Position Ihrer Geometrie durch (-1, -1) nach Teilung durch die Rastergröße umwandeln, um sie in diese Ecke zu verschieben.

  1. Übersetzen Sie die Position Ihrer Geometrie wie folgt:

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); 
}

Und jetzt ist Ihr Quadrat an der richtigen Stelle in Zelle (0, 0) platziert!

Eine Visualisierung des Canvas, das konzeptionell in ein 4x4-Raster mit einem roten Quadrat in Zelle (0, 0) unterteilt ist

Wie gehen Sie vor, wenn Sie es in einer anderen Zelle platzieren möchten? Um dies herauszufinden, deklariere einen cell-Vektor in deinem Shader und fülle ihn mit einem statischen Wert wie let cell = vec2f(1, 1).

Wenn du das dem gridPos hinzufügst, wird die - 1 im Algorithmus rückgängig gemacht. Das ist also nicht das, was du möchtest. Stattdessen möchten Sie das Quadrat nur um eine Rastereinheit (ein Viertel des Canvas) pro Zelle verschieben. Klingt so, als müsstest du noch einmal durch grid dividieren.

  1. So ändern Sie die Rasterpositionierung:

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 jetzt aktualisieren, sehen Sie Folgendes:

Eine Visualisierung des Canvas, die konzeptionell in ein 4x4-Raster mit einem roten Quadrat unterteilt ist zwischen Zelle (0, 0), Zelle (0, 1), Zelle (1, 0) und Zelle (1, 1)

Hm. Nicht ganz das, was du wolltest.

Der Grund dafür ist, dass der Abstand der Canvas-Koordinaten von -1 zu +1 tatsächlich zwei Einheiten beträgt. Wenn Sie also einen Scheitelpunkt ein Viertel des Canvas verschieben möchten, müssen Sie ihn um 0,5 Einheiten verschieben. Dieser Fehler kann bei Schlussfolgerungen mit GPU-Koordinaten leicht gemacht werden. Zum Glück ist die Fehlerbehebung genauso einfach.

  1. Multiplizieren Sie Ihr Versatz wie folgt mit 2:

index.html (createShaderModule-Aufruf)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

So haben Sie genau das, was Sie sich wünschen.

Eine Visualisierung des Canvas, das konzeptionell in ein 4x4-Raster mit einem roten Quadrat in Zelle (1, 1) unterteilt ist

Der Screenshot sieht so aus:

Screenshot eines roten Quadrats auf dunkelblauem Hintergrund Das rote Quadrat wird an derselben Position wie im vorherigen Diagramm gezeichnet, jedoch ohne Raster-Overlay.

Außerdem können Sie jetzt cell auf einen beliebigen Wert innerhalb der Rastergrenzen setzen und dann aktualisieren, damit das Quadrat an der gewünschten Stelle gerendert wird.

Draw-Instanzen

Jetzt, da Sie das Quadrat mit ein wenig Mathematik an der gewünschten Stelle platzieren können, besteht der nächste Schritt darin, ein Quadrat in jeder Zelle des Rasters zu rendern.

Eine Möglichkeit, diese Funktion zu nutzen, besteht darin, Zellenkoordinaten in einen einheitlichen Zwischenspeicher zu schreiben, dann draw für jedes Quadrat im Raster einmal aufzurufen und die Einheit jedes Mal zu aktualisieren. Das wäre jedoch sehr langsam, da die GPU jedes Mal darauf warten muss, dass die neue Koordinate von JavaScript geschrieben wird. Einer der Schlüssel zu einer guten Leistung der GPU liegt darin, die Wartezeit für andere Teile des Systems so gering wie möglich zu halten.

Stattdessen können Sie eine Technik namens Instanzing verwenden. Mit einer Instanz können Sie die GPU anweisen, mehrere Kopien derselben Geometrie mit einem einzigen Aufruf von draw zu erstellen, was viel schneller ist als das einmalige Aufrufen von draw für jede Kopie. Jede Kopie der Geometrie wird als Instanz bezeichnet.

  1. Fügen Sie dem vorhandenen Zeichenaufruf ein Argument hinzu, um der GPU mitzuteilen, dass genügend Instanzen Ihres Quadrats zum Füllen des Rasters vorhanden sein sollen:

index.html

pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

Dadurch wird dem System mitgeteilt, dass die sechs (vertices.length / 2) Eckpunkte Ihres Quadrats 16 (GRID_SIZE * GRID_SIZE) gezeichnet werden sollen. Wenn Sie die Seite jedoch aktualisieren, sehen Sie immer noch Folgendes:

Ein mit dem vorherigen Diagramm identisches Bild, das darauf hinweist, dass sich nichts geändert hat.

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 zusätzlich zu den Vertex-Attributen wie pos, die aus Ihrem Vertex-Zwischenspeicher stammen, auch auf integrierte Werte von WGSL zugreifen. Dies sind Werte, die von WebGPU berechnet werden. Einer dieser Werte ist instance_index. instance_index ist eine 32-Bit-Zahl ohne Vorzeichen zwischen 0 und number of instances - 1, die Sie als Teil Ihrer Shader-Logik verwenden können. Sein Wert ist für jeden verarbeiteten Scheitelpunkt, der Teil derselben Instanz ist, derselbe. Das bedeutet, dass Ihr Vertex-Shader sechsmal mit einem instance_index von 0 aufgerufen wird, einmal für jede Position im Scheitelpunktzwischenspeicher. Dann weitere sechs Mal mit einem instance_index von 1, sechs weitere Male mit einem instance_index von 2 und so weiter.

Um dies in Aktion zu sehen, musst du deinen Shader-Eingaben das integrierte instance_index hinzufügen. Tun Sie dies auf die gleiche Weise wie die Position, aber statt sie mit einem @location-Attribut zu taggen, verwenden Sie @builtin(instance_index) und geben dem Argument einen beliebigen Namen. Sie können sie instance nennen, damit sie dem Beispielcode entspricht. Verwenden Sie es dann als Teil der Shader-Logik!

  1. 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);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

Wenn Sie jetzt aktualisieren, sehen Sie, dass tatsächlich mehr als ein Quadrat vorhanden ist. Sie können jedoch nicht alle 16 sehen.

Vier rote Quadrate in einer diagonalen Linie von der linken unteren Ecke zur oberen rechten Ecke vor einem dunkelblauen Hintergrund.

Das liegt daran, dass die Zellenkoordinaten, die Sie generieren, wie folgt lauten: (0, 0), (1, 1), (2, 2)... bis (15, 15), aber nur die ersten vier davon passen auf den Canvas. Um das gewünschte Raster zu erstellen, müssen Sie den instance_index so transformieren, dass jeder Index einer eindeutigen Zelle innerhalb des Rasters zugeordnet ist. Beispiel:

Eine Visualisierung des Canvas, die konzeptionell in ein 4x4-Raster unterteilt ist, wobei jede Zelle auch einem linearen Instanzindex entspricht.

Die Berechnung ist relativ einfach. Für den X-Wert jeder Zelle benötigen Sie den Modulo von instance_index und die Rasterbreite. Diese können Sie in WGSL mit dem Operator % durchführen. Für den Y-Wert jeder Zelle soll instance_index durch die Rasterbreite geteilt werden, wobei alle Bruchteile verworfen werden. Dazu kannst du die WGSL-Funktion floor() verwenden.

  1. Ändern Sie die Berechnungen wie folgt:

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 den Code aktualisiert haben, haben Sie endlich das lang erwartete Quadratraster!

Vier Zeilen mit vier Spalten mit roten Quadraten auf dunkelblauem Hintergrund.

  1. Und jetzt, da es funktioniert, gehen Sie zurück und erhöhen Sie die Rastergröße!

index.html

const GRID_SIZE = 32;

32 Zeilen mit 32 Spalten mit roten Quadraten auf dunkelblauem Hintergrund.

Tada! Sie können dieses Raster jetzt wirklich wirklich groß machen und Ihre durchschnittliche GPU verarbeitet das problemlos. Die einzelnen Quadrate sind nicht mehr zu sehen, lange bevor es zu Engpässen bei der GPU-Leistung kommt.

6. Zusatzaufgabe: Mach es noch bunter!

An dieser Stelle können Sie ganz einfach zum nächsten Abschnitt springen, da Sie den Grundstein für den Rest des Codelabs gelegt haben. Das Raster aus Quadraten, die alle dieselbe Farbe haben, lässt sich gut bedienen, ist aber nicht gerade aufregend, oder? Zum Glück können Sie die Dinge mit ein wenig mehr Rechen- und Shader-Code ein wenig heller machen.

Strukturen in Shadern verwenden

Bisher haben Sie ein Datenelement aus dem Vertex-Shader übergeben: die transformierte Position. Sie können jedoch viel mehr Daten aus dem Vertex-Shader zurückgeben und diese dann im Fragment-Shader verwenden.

Die einzige Möglichkeit, Daten aus dem Vertex-Shader zu übergeben, besteht darin, sie zurückzugeben. Ein Vertex-Shader ist immer erforderlich, um eine Position zurückzugeben. Wenn Sie also andere Daten mit dieser Position zurückgeben möchten, müssen Sie sie in eine Struktur einfügen. Strukturen in WGSL sind benannte Objekttypen, die eine oder mehrere benannte Eigenschaften enthalten. Die Attribute können auch mit Attributen wie @builtin und @location ausgezeichnet werden. Sie deklarieren sie außerhalb von Funktionen und können dann bei Bedarf Instanzen davon in und aus Funktionen übergeben. Betrachten Sie Ihren aktuellen Scheitel-Shader:

index.html (createShaderModule-Aufruf)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> 
  @builtin(position) vec4f {

  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;
  
  return  vec4f(gridPos, 0, 1);
}
  • Drücken Sie dasselbe Ergebnis mithilfe von Structs für die Ein- und Ausgabe der Funktion aus:

index.html (createShaderModule-Aufruf)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
};

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
  
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  return output;
}

Beachten Sie, dass Sie hierfür mit input auf die Eingabeposition und den Instanzindex verweisen müssen. Die Struktur, die Sie zuerst zurückgeben, muss als Variable deklariert werden und ihre einzelnen Attribute müssen festgelegt werden. In diesem Fall macht das keinen großen Unterschied und die Shader-Funktion wird sogar etwas länger, aber wenn Ihre Shader immer komplexer werden, kann die Verwendung von Strukturen eine großartige Möglichkeit sein, Ihre Daten zu organisieren.

Daten zwischen der Scheitelpunkt- und der Fragmentfunktion übertragen

Die @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 vor und übergeben eine Volltonfarbe (Rot) als Ausgabe. Wenn der Shader jedoch mehr über die Geometrie wüsste, die er färbt, könnten Sie diese zusätzlichen Daten verwenden, um die Sache ein wenig interessanter zu machen. Was ist beispielsweise, wenn Sie die Farbe jedes Quadrats basierend auf seiner Zellkoordinate ändern möchten? In der Phase @vertex wird erkannt, welche Zelle gerendert wird. Sie müssen sie nur an die Phase @fragment übergeben.

Um Daten zwischen den Phasen des Scheitelpunkts und des Fragments zu übergeben, müssen Sie sie mit einer @location unserer Wahl in eine Ausgabestruktur einfügen. Da Sie die Zellenkoordinate übergeben möchten, fügen Sie sie der Struktur VertexOutput von zuvor hinzu und legen sie dann in der Funktion @vertex fest, bevor Sie zurückkehren.

  1. Ändern Sie den Rückgabewert Ihres Vertex-Shaders wie folgt:

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;
}
  1. In der Funktion @fragment erhalten Sie den Wert, indem Sie ein Argument mit demselben @location hinzufügen. (Die Namen müssen nicht übereinstimmen, aber falls sie funktionieren, ist es einfacher, den Überblick zu behalten.)

index.html (createShaderModule-Aufruf)

@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
  // Remember, fragment return values are (Red, Green, Blue, Alpha)
  // and since cell is a 2D vector, this is equivalent to:
  // (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
  return vec4f(cell, 0, 1);
}
  1. Alternativ können Sie auch eine Struktur 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);
}
  1. Eine weitere Alternative** ist die Wiederverwendung der Ausgabestruktur der @vertex-Phase, da diese Funktionen in Ihrem Code im selben Shader-Modul definiert sind. Dies erleichtert die Übergabe von Werten, da die Namen und Standorte von Natur aus einheitlich sind.

index.html (createShaderModule-Aufruf)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}

Unabhängig vom gewählten Muster haben Sie in der Funktion @fragment Zugriff auf die Zellennummer und können diese verwenden, um die Farbe zu beeinflussen. Mit jedem der obigen Codes sieht die Ausgabe wie folgt aus:

Ein Raster aus Quadraten, wobei die Spalte ganz links grün, die untere Zeile rot und alle anderen Quadrate gelb sind.

Es sind auf jeden Fall mehr Farben verfügbar, aber es sieht nicht gerade gut aus. Sie fragen sich vielleicht, warum nur die linke und die untere Zeile unterschiedlich sind. Das liegt daran, dass die Farbwerte, die Sie von der Funktion @fragment zurückgeben, erwarten, dass jeder Kanal im Bereich von 0 bis 1 liegt, und alle Werte außerhalb dieses Bereichs an ihn angepasst werden. Die Zellenwerte dagegen reichen entlang jeder Achse von 0 bis 32. Hier sehen Sie, dass die erste Zeile und Spalte sofort den vollen 1-Wert im roten oder grünen Farbkanal erreicht und jede Zelle danach auf denselben Wert fixiert.

Wenn Sie einen gleichmäßigeren Übergang zwischen den Farben wünschen, müssen Sie für jeden Farbkanal einen Bruchwert zurückgeben, idealerweise bei null beginnen und bei 1 entlang jeder Achse enden. Das bedeutet, dass noch einmal durch grid geteilt wird.

  1. Ändern Sie den Fragment-Shader wie folgt:

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 bietet.

Ein Raster mit Quadraten, die an verschiedenen Ecken von Schwarz zu Rot, zu Grün und dann zu Gelb übergehen.

Das ist zwar auf jeden Fall eine Verbesserung, aber jetzt gibt es leider eine dunkle Ecke unten links, wo das Raster schwarz wird. Wenn du die „Game of Life“-Simulation startest, wird die Matrix durch einen schwer zu erkennenden Abschnitt verdeckt. Es wäre schön, das etwas aufzuhellen.

Zum Glück gibt es einen nicht verwendeten Farbkanal – Blau –, den Sie verwenden können. Idealerweise sollte Blau am hellsten erscheinen, während die anderen Farben am dunkelsten sind, und dann ausgeblendet werden, wenn die Intensität der anderen Farben zunimmt. Am einfachsten ist es, wenn der Kanal bei 1 beginnt und einen der Zellenwerte subtrahiert. Das kann c.x oder c.y sein. Probiere beide aus und entscheide dich dann für das, was dir am besten gefällt!

  1. Fügen Sie dem Fragment-Shader hellere Farben hinzu:

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!

Ein Raster mit Quadraten, die an verschiedenen Ecken von Rot zu Grün und dann zu Blau zu Gelb übergehen.

Dies ist kein entscheidender Schritt. Da es aber besser aussieht, ist es in der entsprechenden Checkpoint-Quelldatei enthalten. Die übrigen Screenshots in diesem Codelab spiegeln dieses farbenfrohe Raster wider.

7. Zellenstatus verwalten

Als Nächstes müssen Sie festlegen, welche Zellen im Raster basierend auf einem in der GPU gespeicherten Status gerendert werden sollen. Das ist wichtig für die endgültige Simulation!

Sie benötigen nur ein Ein-Aus-Signal für jede Zelle, sodass alle Optionen, mit denen Sie ein großes Array von fast jedem Werttyp speichern können, funktionieren. Vielleicht denken Sie, dass dies ein weiterer Anwendungsfall für einheitliche Puffer ist! Dies könntest zwar funktionieren, es ist jedoch schwieriger, da einheitliche Puffer in ihrer Größe begrenzt sind, keine Arrays mit dynamischer Größe unterstützt werden (die Arraygröße muss im Shader angegeben werden) und in diese nicht von Compute-Shadern geschrieben werden kann. Dieser letzte Punkt ist am problematischsten, da Sie die Game of Life-Simulation auf der GPU in einem Compute-Shader ausführen möchten.

Glücklicherweise gibt es eine weitere Pufferoption, mit der all diese Einschränkungen vermieden werden.

Speicherpuffer erstellen

Speicherpuffer sind allgemein verwendete Zwischenspeicher, die in Compute-Shadern gelesen und geschrieben werden können und in Vertex-Shaders gelesen werden können. Sie können sehr groß sein und benötigen keine bestimmte deklarierte Größe in einem Shader, was sie eher dem allgemeinen Speicher ähnelt. So speichern Sie den Zellenstatus.

  1. Um einen Speicherpuffer für den Zellenstatus zu erstellen, verwenden Sie ein Code-Snippet zur Zwischenspeichererstellung, das Ihnen mittlerweile wahrscheinlich vertraut ist:

index.html

// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);

// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
  label: "Cell State",
  size: cellStateArray.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});

Rufen Sie wie bei den Scheitelpunkt- und einheitlichen Zwischenspeichern device.createBuffer() mit der entsprechenden Größe auf und geben Sie dann die Verwendung von GPUBufferUsage.STORAGE an.

Sie können den Puffer wie zuvor füllen, indem Sie das TypedArray derselben Größe mit Werten füllen und dann device.queue.writeBuffer() aufrufen. Da Sie die Auswirkungen Ihres Puffers auf das Raster sehen möchten, füllen Sie es zunächst mit etwas Berechenbarem.

  1. Aktivieren Sie jede dritte Zelle mit dem folgenden Code:

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, um sich den Inhalt des Speicherpuffers anzusehen, bevor Sie das Raster rendern. Das sieht ganz ähnlich aus, wie zuvor Uniformen hinzugefügt wurden.

  1. Aktualisieren Sie Ihren 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 Bindepunkt hinzu, der direkt unter der Rasteruniform platziert wird. Du möchtest dasselbe @group wie die grid-Uniform beibehalten, aber die @binding-Zahl muss unterschiedlich sein. Der var-Typ ist storage, um den unterschiedlichen Zwischenspeichertyp widerzuspiegeln. Der Typ, den Sie für cellState angeben, ist ein Array von u32-Werten, um dem Uint32Array in JavaScript zu entsprechen.

Fragen Sie als Nächstes im Text der @vertex-Funktion den Status der Zelle ab. Da der Status in einem flachen Array im Speicherpuffer gespeichert ist, können Sie instance_index verwenden, um den Wert für die aktuelle Zelle zu ermitteln.

Wie schaltet man eine Zelle aus, wenn der Status anzeigt, dass sie inaktiv ist? Nun, da die aktiven und inaktiven Status, die Sie aus dem Array erhalten, 1 oder 0 sind, können Sie die Geometrie nach dem aktiven Zustand skalieren! Bei der Skalierung um 1 bleibt die Geometrie unverändert, während sie um 0 skaliert wird, um sie zu einem einzigen Punkt zu minimieren, den die GPU dann verwirft.

  1. Aktualisieren Sie Ihren Shader-Code, um die Position anhand des aktiven Status der Zelle zu skalieren. Der Statuswert muss in einen f32 umgewandelt werden, um die Sicherheitsanforderungen der 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;
}

Speicherzwischenspeicher zur Bindungsgruppe hinzufügen

Bevor Sie sehen können, dass der Zellenstatus wirksam wird, fügen Sie den Speicherpuffer einer Bindungsgruppe hinzu. Da sie zum selben @group wie der Uniform Zwischenspeicher gehört, fügen Sie ihn auch derselben Bindungsgruppe im JavaScript-Code hinzu.

  • Fügen Sie den Speicherpuffer hinzu:

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  },
  // New entry!
  {
    binding: 1,
    resource: { buffer: cellStateStorage }
  }],
});

Achte darauf, dass die binding des neuen Eintrags mit der @binding() des entsprechenden Werts im Shader übereinstimmt.

Anschließend sollten Sie die Seite aktualisieren und das Muster im Raster sehen können.

Diagonale Streifen mit farbigen Quadraten, die von links unten nach rechts oben vor einem dunkelblauen Hintergrund verlaufen.

Ping-Pong-Puffermuster verwenden

Die meisten Simulationen wie die, die Sie gerade erstellen, verwenden normalerweise mindestens zwei Kopien ihres Zustands. Bei jedem Schritt der Simulation lesen sie aus einer Kopie des Status und schreiben in die andere. Im nächsten Schritt wenden Sie es dann um und lesen den Status aus, in den er zuvor geschrieben hat. Dies wird allgemein als Ping-Pong-Muster bezeichnet, da die aktuellste Version des Status zwischen den Bundesstaaten hin und her prallt, die einzelnen Schritte kopiert.

Warum ist das notwendig? Sehen Sie sich ein vereinfachtes Beispiel an: Stellen Sie sich vor, 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 jedoch diesen Code ausführen, wird die aktive Zelle in einem Schritt bis ans Ende des Arrays verschoben. Warum? Weil Sie den Status ständig aktualisieren, also verschieben Sie die aktive Zelle nach rechts, sehen sich dann die nächste Zelle an und... hey! Es ist aktiv! Verschiebe sie am besten wieder nach rechts. Die Tatsache, dass Sie die Daten gleichzeitig ändern, während Sie feststellen, dass dies die Ergebnisse verschlechtert.

Durch die Verwendung des Ping-Pong-Musters stellen Sie sicher, dass Sie für den nächsten Schritt der Simulation immer nur die Ergebnisse des letzten Schritts verwenden.

// Example simulation. Don't copy into the project!
const stateA = [1, 0, 0, 0, 0, 0, 0, 0];
const stateB = [0, 0, 0, 0, 0, 0, 0, 0];

function simulate(in, out) {
  out[0] = 0;
  for (let i = 1; i < in.length; ++i) {
     out[i] = in[i-1];
  }
}

// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA); 
  1. Verwenden Sie dieses Muster in Ihrem eigenen Code, indem Sie Ihre Speicherpufferzuweisung aktualisieren, um zwei identische Zwischenspeicher zu erstellen:

index.html

// Create two storage buffers to hold the cell state.
const cellStateStorage = [
  device.createBuffer({
    label: "Cell State A",
    size: cellStateArray.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  }),
  device.createBuffer({
    label: "Cell State B",
     size: cellStateArray.byteLength,
     usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  })
];
  1. Um den Unterschied zwischen den beiden Puffern zu visualisieren, 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);
  1. Wenn Sie die verschiedenen Speicherpuffer im Rendering darstellen möchten, aktualisieren Sie Ihre Bindungsgruppen so, dass sie zwei verschiedene Varianten haben:

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] }
    }],
  })
];

Renderingschleife einrichten

Bisher wurde nur eine Zeichnung pro Seitenaktualisierung durchgeführt. Jetzt möchten Sie aber, dass die Daten im Zeitverlauf aktualisiert werden. Dazu benötigen Sie eine einfache Rendering-Schleife.

Eine Renderingschleife ist eine sich endlos wiederholende Schleife, die Ihren Inhalt in einem bestimmten Intervall auf dem Canvas zieht. In vielen Spielen und anderen Inhalten, die animiert werden sollen, wird die Funktion requestAnimationFrame() verwendet, um Callbacks mit der Häufigkeit zu planen, mit der der Bildschirm aktualisiert wird (60-mal pro Sekunde).

Auch diese App kann diese Funktion verwenden. In diesem Fall möchten Sie jedoch wahrscheinlich, dass Updates in längeren Schritten erfolgen, damit Sie die Simulation besser nachvollziehen können. Verwalten Sie die Schleife stattdessen selbst, sodass Sie die Rate steuern können, mit der Ihre Simulation aktualisiert wird.

  1. Wählen Sie zunächst eine Rate für die Aktualisierung unserer Simulation aus (200 ms ist gut, Sie können aber langsamer oder schneller laufen), und verfolgen Sie dann, wie viele Schritte der Simulation bereits abgeschlossen sind.

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. Verschieben Sie dann den gesamten Code, den Sie derzeit für das Rendering verwenden, in eine neue Funktion. Planen Sie diese Funktion mit setInterval() so, dass sie im gewünschten Intervall wiederholt wird. Achten Sie darauf, dass die Funktion auch die Schrittzahl aktualisiert, und verwenden Sie diese, um auszuwählen, welche der beiden Bindungsgruppen gebunden werden soll.

index.html

// Move all of our rendering code into a function
function updateGrid() {
  step++; // Increment the step count
  
  // Start a render pass 
  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      loadOp: "clear",
      clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
      storeOp: "store",
    }]
  });

  // Draw the grid.
  pass.setPipeline(cellPipeline);
  pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
  pass.setVertexBuffer(0, vertexBuffer);
  pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

  // End the render pass and submit the command buffer
  pass.end();
  device.queue.submit([encoder.finish()]);
}

// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);

Wenn Sie jetzt die App ausführen, sehen Sie, dass der Canvas zwischen den beiden Statuspuffern wechselt, die Sie erstellt haben.

Diagonale Streifen mit farbigen Quadraten, die von links unten nach rechts oben vor einem dunkelblauen Hintergrund verlaufen. Vertikale Streifen mit bunten Quadraten vor einem dunkelblauen Hintergrund.

Damit sind Sie im Wesentlichen mit dem Rendering fertig! Sie können nun die Ausgabe der Game of Life-Simulation anzeigen, die Sie im nächsten Schritt erstellen, in dem Sie schließlich mit der Verwendung von Compute-Shadern beginnen.

Offensichtlich haben die Renderingfunktionen von WebGPU so viel mehr zu bieten als der winzige Teil, den Sie hier erkundet haben, aber der Rest wird in diesem Codelab nicht behandelt. Hoffentlich haben Sie damit einen guten Eindruck davon, wie das Rendering mit WebGPU funktioniert, dass das Erlernen komplexerer Techniken wie 3D-Rendering leichter verständlich ist.

8. Simulation ausführen

Nun kommen wir zum letzten großen Teil des Rätsels: die Game of Life-Simulation in einem Compute-Shader durchführen.

Verwenden Sie endlich Compute-Shader.

Sie haben in diesem Codelab abstrakt Compute-Shader kennengelernt, aber was genau sind das?

Ein Compute-Shader ähnelt Vertex- und Fragment-Shadern insofern, als er mit extremer Parallelität auf der GPU ausgeführt werden soll. Im Gegensatz zu den anderen beiden Shader-Phasen haben sie jedoch keine bestimmte Gruppe von Ein- und Ausgaben. Sie lesen und schreiben Daten ausschließlich aus Quellen, die Sie auswählen, wie Speicherpuffern. Dies bedeutet, dass Sie nicht für jeden Scheitelpunkt, jede Instanz oder jedes Pixel ein Mal ausführen müssen, wie viele Aufrufe der Shader-Funktion Sie möchten. Wenn Sie dann den Shader ausführen, wird Ihnen mitgeteilt, welcher Aufruf verarbeitet wird, und Sie können entscheiden, auf welche Daten Sie zugreifen und welche Vorgänge Sie von dort aus ausführen.

Compute-Shader müssen in einem Shader-Modul erstellt werden, genau wie Eckpunkt- und Fragment-Shader. Fügen Sie dies also Ihrem Code hinzu, um zu beginnen. Wie Sie sich vielleicht vorstellen können, muss die Hauptfunktion für Ihren Compute-Shader angesichts der Struktur der anderen Shader, die Sie implementiert haben, mit dem Attribut @compute gekennzeichnet werden.

  1. 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. Auf diese Weise können Sie Aufgaben, die einem 2D- oder 3D-Raster entsprechen, ganz einfach verteilen, was für Ihren Anwendungsfall sehr gut geeignet ist. Sie möchten diesen Shader GRID_SIZE-mal GRID_SIZE-mal aufrufen, einmal pro Zelle der Simulation.

Aufgrund der Beschaffenheit der GPU-Hardwarearchitektur ist dieses Raster in Arbeitsgruppen unterteilt. Eine Arbeitsgruppe hat eine X-, Y- und Z-Größe, und obwohl die Größe jeweils 1 sein kann, bringt die Vergrößerung Ihrer Arbeitsgruppen oft Leistungsvorteile mit sich. Wählen Sie für Ihren Shader eine beliebige Arbeitsgruppengröße von 8 × 8 aus. Dies ist nützlich, damit Sie den Überblick über Ihren JavaScript-Code behalten.

  1. Definieren Sie wie folgt eine Konstante für die Größe Ihrer Arbeitsgruppe:

index.html

const WORKGROUP_SIZE = 8;

Sie müssen die Arbeitsgruppengröße auch der Shader-Funktion selbst hinzufügen. Dazu verwenden Sie die JavaScript-Vorlagenliterale, damit Sie die soeben definierte Konstante problemlos verwenden können.

  1. Fügen Sie der Shader-Funktion die Arbeitsgruppengröße hinzu:

index.html (Compute createShaderModule-Aufruf)

@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {

}

Dies teilt dem Shader mit, dass die mit dieser Funktion ausgeführte Arbeit in Gruppen (8 x 8 x 1) erledigt wird. Für jede Achse, die Sie weglassen, ist der Standardwert 1, Sie müssen jedoch zumindest 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 akzeptieren können, um Ihnen mitzuteilen, in welchem Aufruf Sie sich befinden, und um zu entscheiden, welche Arbeit Sie ausführen müssen.

  1. Fügen Sie einen @builtin-Wert hinzu. Beispiel:

index.html (Compute createShaderModule-Aufruf)

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

Sie übergeben das integrierte global_invocation_id. Dies ist ein dreidimensionaler Vektor aus vorzeichenlosen Ganzzahlen, der Ihnen mitteilt, wo Sie sich im Raster der Shader-Aufrufe befinden. Sie führen diesen Shader einmal für jede Zelle in Ihrem Raster aus. Sie erhalten Zahlen wie (0, 0, 0), (1, 0, 0), (1, 1, 0)... bis hin zu (31, 31, 0), was bedeutet, dass Sie sie als den Zellindex behandeln können, den Sie bearbeiten.

Compute-Shader können auch Uniformen verwenden, die Sie genau wie bei Scheitelpunkt- und Fragment-Shadern verwenden.

  1. Verwenden Sie eine Einheit mit Ihrem Compute-Shader, um Ihnen die Rastergröße mitzuteilen:

index.html (Compute createShaderModule-Aufruf)

@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 beim Vertex-Shader wird der Zellenstatus als Speicherpuffer angezeigt. Aber in diesem Fall brauchen Sie zwei! Da Compute-Shader keine erforderliche Ausgabe wie eine Scheitelpunktposition oder eine Fragmentfarbe haben, ist das Schreiben von Werten in einen Speicherpuffer oder eine Textur die einzige Möglichkeit, Ergebnisse aus einem Compute-Shader zu erhalten. Wenden Sie die Ping-Pong-Methode an, die Sie zuvor kennengelernt haben. Sie haben einen Speicherpuffer, der den aktuellen Zustand des Rasters speist, und einen, in den Sie den neuen Zustand des Rasters schreiben.

  1. Stellen Sie den Ein- und Ausgabestatus der Zelle als Speicherpuffer bereit:

index.html (Compute createShaderModule-Aufruf)

@group(0) @binding(0) var<uniform> grid: vec2f;
    
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

Der erste Speicherzwischenspeicher wird mit var<storage> deklariert, was ihn schreibgeschützt macht. Der zweite Speicherzwischenspeicher wird mit var<storage, read_write> deklariert. Auf diese Weise können Sie sowohl Lese- als auch Schreibvorgänge in den Zwischenspeicher ausführen, wobei dieser Zwischenspeicher als Ausgabe für Ihren Compute-Shader verwendet wird. (In WebGPU gibt es keinen Schreibmodus.)

Als Nächstes müssen Sie Ihren Zellenindex dem linearen Speicherarray zuordnen. Das ist im Grunde das Gegenteil von dem, was Sie im Scheitelpunkt-Shader getan haben, bei dem Sie die lineare instance_index einer 2D-Rasterzelle zugeordnet haben. (Zur Erinnerung: Dein Algorithmus dafür war vec2f(i % grid.x, floor(i / grid.x)).)

  1. Schreiben Sie eine Funktion, um in die andere Richtung zu gehen. Multipliziert den Y-Wert der Zelle mit der Rasterbreite und addiert dann den X-Wert der Zelle.

index.html (Compute createShaderModule-Aufruf)

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

// New function   
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  
}

Um schließlich zu überprüfen, ob sie funktioniert, müssen Sie einen einfachen Algorithmus implementieren: Wenn eine Zelle gerade eingeschaltet ist, schaltet sie sich aus und umgekehrt. Es ist noch nicht das Spiel des Lebens, aber es reicht aus, um zu zeigen, dass der Compute-Shader funktioniert.

  1. Fügen Sie den einfachen Algorithmus hinzu:

index.html (Compute createShaderModule-Aufruf)

@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‘s für Ihren Compute-Shader – fürs Erste! Bevor Sie jedoch die Ergebnisse sehen können, müssen Sie noch einige Änderungen vornehmen.

Bindungsgruppen- und Pipeline-Layouts verwenden

Anhand des obigen Shaders fällt Ihnen möglicherweise auf, dass er weitgehend dieselben Eingaben (Uniformen und Speicherpuffer) wie Ihre Rendering-Pipeline verwendet. Vielleicht denken Sie, dass Sie einfach dieselben Bindungsgruppen verwenden und damit fertig sind. Die gute Nachricht ist, dass das möglich ist. Dazu ist nur eine manuelle Einrichtung erforderlich.

Jedes Mal, wenn Sie eine Bindungsgruppe erstellen, müssen Sie eine GPUBindGroupLayout angeben. Früher haben Sie dieses Layout durch Aufrufen von getBindGroupLayout() für die Rendering-Pipeline erhalten, die sie automatisch erstellt hat, weil Sie layout: "auto" beim Erstellen angegeben haben. Dieser Ansatz funktioniert gut, wenn Sie nur eine einzelne Pipeline verwenden. Wenn Sie jedoch mehrere Pipelines haben, die Ressourcen freigeben möchten, müssen Sie das Layout explizit erstellen und dann sowohl der Bindungsgruppe als auch den Pipelines zur Verfügung stellen.

Um den Grund dafür zu verstehen, sollten Sie Folgendes beachten: In Ihren Renderingpipelines verwenden Sie einen einzelnen einheitlichen Zwischenspeicher und einen einzelnen Speicherpuffer. Für den Compute-Shader, den Sie gerade geschrieben haben, benötigen Sie jedoch einen zweiten Speicherpuffer. Da die beiden Shader dieselben @binding-Werte für den einheitlichen und den ersten Speicherpuffer verwenden, können Sie diese zwischen Pipelines teilen. Die Renderingpipeline ignoriert dann den zweiten Speicherpuffer, der nicht verwendet wird. Sie möchten ein Layout erstellen, in dem alle Ressourcen in der Bindungsgruppe beschrieben werden, nicht nur die, die von einer bestimmten Pipeline verwendet werden.

  1. 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 der Erstellung der Bindungsgruppe selbst, da Sie eine Liste von entries beschreiben. Der Unterschied besteht darin, dass Sie beschreiben, welcher Ressourcentyp der Eintrag sein muss und wie er verwendet wird, anstatt die Ressource selbst anzugeben.

In jedem Eintrag geben Sie die binding-Nummer für die Ressource an, die (wie Sie beim Erstellen der Bindungsgruppe gelernt haben) mit dem @binding-Wert in den Shadern übereinstimmt. Sie geben auch die visibility-Flags an. Dies sind GPUShaderStage-Flags, die angeben, welche Shader-Phasen die Ressource verwenden können. Sowohl der einheitliche als auch der erste Speicherpuffer sollen in den Vertex- und Compute-Shadern zugänglich sein. Auf den zweiten Speicherpuffer muss nur in Compute-Shadern zugegriffen werden können.

Schließlich geben Sie an, welcher Ressourcentyp verwendet wird. Dies ist ein anderer Wörterbuchschlüssel, je nachdem, was freigegeben werden soll. Hier sind alle drei Ressourcen Puffer, also verwenden Sie den Schlüssel buffer, um die Optionen für jede Ressource zu definieren. Andere Optionen sind beispielsweise texture oder sampler. Diese sind hier jedoch nicht erforderlich.

Im Zwischenspeicherwörterbuch legen Sie fest, welche type des Zwischenspeichers verwendet werden sollen. Der Standardwert ist "uniform". Sie können also das Wörterbuch für die Bindung 0 leer lassen. Sie müssen jedoch mindestens buffer: {} festlegen, damit der Eintrag als Puffer identifiziert wird. Bindung 1 erhält den Typ "read-only-storage", da Sie sie nicht mit read_write-Zugriff im Shader verwenden, und Bindung 2 hat den Typ "storage", da Sie sie mit read_write-Zugriff verwenden.

Nachdem die bindGroupLayout erstellt wurde, können Sie sie beim Erstellen Ihrer Bindungsgruppen übergeben, anstatt die Bindungsgruppe über die Pipeline abzufragen. Dies bedeutet, dass Sie jeder Bindungsgruppe einen neuen Speicherpuffereintrag hinzufügen müssen, damit er dem Layout entspricht, das Sie gerade definiert haben.

  1. Aktualisieren Sie die Erstellung der Bindungsgruppe 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] }
    }],
  }),
];

Da die Bindungsgruppe jetzt aktualisiert wurde, um dieses explizite Bind-Gruppen-Layout zu verwenden, müssen Sie die Rendering-Pipeline aktualisieren, um dasselbe zu verwenden.

  1. 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 haben Sie eines), die von einer oder mehreren Pipelines verwendet werden. Die Reihenfolge der Bindungsgruppenlayouts im Array muss den @group-Attributen in den Shadern entsprechen. Das bedeutet, dass bindGroupLayout mit @group(0) verknüpft ist.

  1. Sobald Sie das Pipeline-Layout haben, aktualisieren Sie die Rendering-Pipeline, um diese anstelle von "auto" zu verwenden.

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: pipelineLayout, // Updated!
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

Computing-Pipeline erstellen

So wie Sie eine Rendering-Pipeline benötigen, um Ihre Scheitelpunkt- und Fragment-Shader zu verwenden, benötigen Sie eine Computing-Pipeline, um Ihren Compute-Shader zu verwenden. Glücklicherweise sind Computing-Pipelines viel weniger kompliziert als Rendering-Pipelines, da sie keinen Zustand festlegen müssen, sondern nur den Shader und das Layout.

  • Erstellen Sie eine Compute-Pipeline mit dem folgenden Code:

index.html

// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
  label: "Simulation pipeline",
  layout: pipelineLayout,
  compute: {
    module: simulationShaderModule,
    entryPoint: "computeMain",
  }
});

Beachten Sie, dass Sie wie bei der aktualisierten Renderingpipeline den neuen pipelineLayout statt "auto" übergeben. Dadurch wird sichergestellt, dass sowohl die Rendering-Pipeline als auch die Compute-Pipeline dieselben Bindungsgruppen verwenden können.

Karten/Tickets berechnen

Damit sind Sie so weit gekommen, dass Sie die Computing-Pipeline tatsächlich nutzen können. Wenn Sie das Rendering in einem Renderingdurchlauf ausführen, können Sie wahrscheinlich davon ausgehen, dass Sie Rechenaufgaben in einem Computing-Durchlauf ausführen müssen. Da sowohl Computing- als auch Renderingaufgaben im selben Befehls-Encoder ausgeführt werden können, sollten Sie die updateGrid-Funktion nach dem Zufallsprinzip umverteilen.

  1. Verschiebe die Encoder-Erstellung an den Anfang der Funktion und beginne dann mit einem Compute-Pass (vor 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...

Genau wie Rechenpipelines sind auch Computing-Pässe viel einfacher zu starten als Rendering-Vergleiche, da Sie sich keine Gedanken über Anhänge machen müssen.

Sie möchten den Computing-Durchlauf vor dem Renderingdurchlauf ausführen, da er dem Renderingdurchlauf ermöglicht, sofort die neuesten Ergebnisse aus dem Computedurchlauf zu verwenden. Das ist auch der Grund, warum Sie die step-Anzahl zwischen den Durchläufen erhöhen, sodass der Ausgabepuffer der Compute-Pipeline zum Eingabepuffer für die Rendering-Pipeline wird.

  1. Legen Sie als Nächstes die Pipeline und die Bindungsgruppe innerhalb des Compute-Durchlaufs fest. Verwenden Sie dabei das gleiche Muster zum Wechseln 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();
  1. Schließlich übergeben Sie die Arbeit an den Compute-Shader und geben an, wie viele Arbeitsgruppen auf jeder Achse ausgeführt werden sollen, anstatt wie bei einem Rendering-Pass zu zeichnen.

index.html

const computePass = encoder.beginComputePass();

computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);

// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);

computePass.end();

Etwas wichtiges ist hier zu beachten, dass die Zahl, die Sie an dispatchWorkgroups() übergeben, nicht die Anzahl der Aufrufe ist! Stattdessen ist es die Anzahl der auszuführenden Arbeitsgruppen, wie durch die @workgroup_size in Ihrem Shader definiert.

Wenn Sie möchten, dass der Shader 32 x 32-mal ausgeführt wird, um Ihr gesamtes Raster abzudecken, und Ihre Arbeitsgruppe 8 x 8 groß ist, müssen Sie 4 x 4-Arbeitsgruppen (4 x 8 = 32) senden. Deshalb teilen Sie die Rastergröße durch die Größe der Arbeitsgruppe und übergeben diesen Wert in dispatchWorkgroups().

Jetzt können Sie die Seite wieder aktualisieren. Sie sollten sehen, dass sich das Raster bei jeder Aktualisierung von selbst umkehrt.

Diagonale Streifen mit farbigen Quadraten, die von links unten nach rechts oben vor einem dunkelblauen Hintergrund verlaufen. Diagonale Streifen mit bunten Quadraten, zwei breite Quadrate, die von links unten nach rechts oben vor einem dunkelblauen Hintergrund verlaufen. Die Inversion des vorherigen Bildes.

Den Algorithmus für „Game of Life“ implementieren

Bevor Sie den Compute-Shader aktualisieren, um den endgültigen Algorithmus zu implementieren, sollten Sie zu dem Code zurückkehren, mit dem der Zwischenspeicherinhalt initialisiert wird, und diesen aktualisieren, um bei jedem Seitenaufbau einen zufälligen Zwischenspeicher zu erzeugen. Regelmäßige Muster sind kein besonders interessanter Ausgangspunkt in „Game of Life“. Sie können die Werte beliebig zufällig festlegen, aber es gibt einen einfachen Weg, um damit zu beginnen und vernünftige Ergebnisse zu erzielen.

  1. Um jede Zelle in einem zufälligen Zustand zu starten, aktualisieren Sie die cellStateArray-Initialisierung in den folgenden Code:

index.html

// Set each cell to a random state, then copy the JavaScript array 
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
  cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

Jetzt können Sie die Logik für die „Game of Life“-Simulation implementieren. Nach allem, was es brauchte, um hierher zu kommen, ist der Shader-Code möglicherweise enttäuschend einfach!

Zunächst müssen Sie für jede Zelle wissen, wie viele ihrer Nachbarn aktiv sind. Es spielt keine Rolle, welche davon aktiv sind, nur die Anzahl.

  1. Um das Abrufen der Daten benachbarter Zellen zu vereinfachen, fügen Sie eine cellActive-Funktion hinzu, die den cellStateIn-Wert der angegebenen Koordinate zurückgibt.

index.html (Compute createShaderModule-Aufruf)

fn cellActive(x: u32, y: u32) -> u32 {
  return cellStateIn[cellIndex(vec2(x, y))];
}

Die cellActive-Funktion gibt eins zurück, wenn die Zelle aktiv ist. Wenn Sie also den Rückgabewert des Aufrufs von cellActive für alle acht umgebenden Zellen addieren, erhalten Sie, wie viele benachbarte Zellen aktiv sind.

  1. So ermitteln Sie die Anzahl der aktiven Nachbarn:

index.html (Compute createShaderModule-Aufruf)

fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines:
  // Determine how many active neighbors this cell has.
  let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                        cellActive(cell.x+1, cell.y) +
                        cellActive(cell.x+1, cell.y-1) +
                        cellActive(cell.x, cell.y-1) +
                        cellActive(cell.x-1, cell.y-1) +
                        cellActive(cell.x-1, cell.y) +
                        cellActive(cell.x-1, cell.y+1) +
                        cellActive(cell.x, cell.y+1);

Dies führt jedoch zu einem kleinen Problem: Was passiert, wenn sich die Zelle, die Sie überprüfen, nicht am Rand der Tafel befindet? Nach Ihrer aktuellen cellIndex()-Logik geht sie entweder in die nächste oder vorherige Zeile über oder läuft über den Rand des Zwischenspeichers hinaus.

Für das Spiel des Lebens besteht eine gängige und einfache Methode zur Lösung dieses Problems darin, dass Zellen am Rand des Rasters die Zellen am gegenüberliegenden Rand des Rasters wie ihre Nachbarn behandeln, was eine Art Wrap-around-Effekt erzeugt.

  1. Unterstützungsrasterumbruch mit einer geringfügigen Änderung an der Funktion cellIndex().

index.html (Compute createShaderModule-Aufruf)

fn cellIndex(cell: vec2u) -> u32 {
  return (cell.y % u32(grid.y)) * u32(grid.x) +
         (cell.x % u32(grid.x));
}

Wenn Sie den Operator % verwenden, um die Zellen X und Y zu umschließen, wenn sie über die Rastergröße hinaus gehen, stellen Sie sicher, dass Sie nie außerhalb der Speicherzwischenspeichergrenzen zugreifen. So können Sie sicher sein, dass die Anzahl von activeNeighbors vorhersehbar ist.

Anschließend wenden Sie eine von vier Regeln an:

  • Jede Zelle mit weniger als zwei Nachbarn wird inaktiv.
  • Jede aktive Zelle mit zwei oder drei Nachbarn bleibt aktiv.
  • Jede inaktive Zelle mit genau drei Nachbarn wird aktiv.
  • Jede Zelle mit mehr als drei Nachbarn wird inaktiv.

Dazu können Sie eine Reihe von if-Anweisungen verwenden. WGSL unterstützt jedoch auch Switch-Anweisungen, die gut zu dieser Logik passen.

  1. Implementieren Sie die „Game of Life“-Logik so:

index.html (Compute createShaderModule-Aufruf)

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 nun so aus:

index.html

const simulationShaderModule = device.createShaderModule({
  label: "Life simulation shader",
  code: `
    @group(0) @binding(0) var<uniform> grid: vec2f;

    @group(0) @binding(1) var<storage> cellStateIn: array<u32>;
    @group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

    fn cellIndex(cell: vec2u) -> u32 {
      return (cell.y % u32(grid.y)) * u32(grid.x) +
              (cell.x % u32(grid.x));
    }

    fn cellActive(x: u32, y: u32) -> u32 {
      return cellStateIn[cellIndex(vec2(x, y))];
    }

    @compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
    fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
      // Determine how many active neighbors this cell has.
      let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                            cellActive(cell.x+1, cell.y) +
                            cellActive(cell.x+1, cell.y-1) +
                            cellActive(cell.x, cell.y-1) +
                            cellActive(cell.x-1, cell.y-1) +
                            cellActive(cell.x-1, cell.y) +
                            cellActive(cell.x-1, cell.y+1) +
                            cellActive(cell.x, cell.y+1);

      let i = cellIndex(cell.xy);

      // Conway's game of life rules:
      switch activeNeighbors {
        case 2: {
          cellStateOut[i] = cellStateIn[i];
        }
        case 3: {
          cellStateOut[i] = 1;
        }
        default: {
          cellStateOut[i] = 0;
        }
      }
    }
  `
});

Das war's auch schon! Fertig! Aktualisiere deine Seite und sieh dir an, wie dein neu entwickelter Mobilfunk-Automat wächst.

Screenshot eines Beispielstatus aus der Game of Life-Simulation mit farbenfrohen Zellen vor einem dunkelblauen Hintergrund

9. Glückwunsch!

Sie haben mit der WebGPU API eine Version von Conway's Game of Life-Simulation erstellt, die vollständig auf Ihrer GPU ausgeführt wird.

Was liegt als Nächstes an?

Weitere Informationen

Referenzdokumente