Ihre erste WebGPU-Anwendung

Ihre erste WebGPU-App

Informationen zu diesem Codelab

subjectZuletzt aktualisiert: Juli 17, 2025
account_circleVerfasst von Brandon Jones, François Beaufort

1. Einführung

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

Was ist WebGPU?

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

Moderne API

Vor WebGPU gab es WebGL, das eine Teilmenge der Funktionen von WebGPU bot. Sie ermöglichte eine neue Klasse von umfangreichen Webinhalten und Entwickler haben damit erstaunliche Dinge geschaffen. Sie basierte jedoch auf der OpenGL ES 2.0-API, die 2007 veröffentlicht wurde und auf der noch älteren OpenGL-API basierte. GPUs haben sich in dieser Zeit erheblich weiterentwickelt und auch die nativen APIs, die für die Interaktion mit ihnen verwendet werden, haben sich mit Direct3D 12, Metal und Vulkan weiterentwickelt.

WebGPU bringt die Fortschritte dieser modernen APIs auf die Webplattform. Der Fokus liegt auf der plattformübergreifenden Aktivierung von GPU-Funktionen. Die API ist für das Web konzipiert und weniger ausführlich als einige der nativen APIs, auf denen sie basiert.

Rendering

GPUs werden oft mit dem schnellen Rendern detaillierter Grafiken in Verbindung gebracht, und WebGPU ist da keine Ausnahme. Sie bietet die erforderlichen Funktionen, um viele der heute beliebtesten Rendering-Techniken auf Desktop- und mobilen GPUs zu unterstützen. Außerdem können in Zukunft neue Funktionen hinzugefügt werden, wenn sich die Hardware weiterentwickelt.

Computing

Neben dem Rendern bietet WebGPU das Potenzial Ihrer GPU für die Ausführung von allgemeinen, hochparallelen Arbeitslasten. Diese Compute-Shader können eigenständig ohne Rendering-Komponente oder als eng integrierter Teil Ihrer Rendering-Pipeline verwendet werden.

In diesem Codelab erfahren Sie, wie Sie die Rendering- und Rechenfunktionen von WebGPU nutzen können, um ein einfaches Einführungsprojekt zu erstellen.

Umfang

In diesem Codelab erstellen Sie Conway's Game of Life mit WebGPU. Mit der Anwendung können Sie Folgendes tun:

  • Mit den Renderingfunktionen von WebGPU einfache 2D-Grafiken zeichnen
  • Verwenden Sie die Compute-Funktionen von WebGPU, um die Simulation durchzuführen.

Ein Screenshot des Endprodukts dieses Codelabs

Das Game of Life ist ein sogenannter zellulärer Automat, bei dem sich der Zustand eines Rasters von Zellen im Laufe der Zeit auf der Grundlage bestimmter Regeln ändert. Im Game of Life werden Zellen je nachdem, wie viele ihrer benachbarten Zellen aktiv sind, aktiviert oder deaktiviert. Das führt zu interessanten Mustern, die sich im Laufe der Zeit verändern.

Lerninhalte

  • WebGPU einrichten und ein Canvas konfigurieren
  • Einfache 2D-Geometrie zeichnen
  • Vertex- und Fragment-Shader verwenden, um zu ändern, was gezeichnet wird.
  • So verwenden Sie Compute-Shader, um eine einfache Simulation durchzuführen.

In diesem Codelab werden die grundlegenden Konzepte von WebGPU vorgestellt. Es handelt sich nicht um eine umfassende Übersicht der API und es werden auch keine häufig damit zusammenhängenden Themen wie 3D-Matrixmathematik behandelt (oder benötigt).

Voraussetzungen

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

Kenntnisse anderer Grafik-APIs wie WebGL, Metal, Vulkan oder Direct3D sind nicht erforderlich. Wenn Sie jedoch Erfahrung damit haben, werden Sie wahrscheinlich viele Ähnlichkeiten mit WebGPU feststellen, die Ihnen den Einstieg erleichtern können.

2. Einrichten

Code abrufen

Für dieses Codelab sind keine Abhängigkeiten erforderlich. Es führt Sie durch jeden Schritt, der zum Erstellen der WebGPU-App erforderlich ist. Sie benötigen also keinen Code, um loszulegen. Einige funktionierende Beispiele, die als Checkpoints dienen können, finden Sie unter https://github.com/GoogleChromeLabs/your-first-webgpu-app-codelab. Sie können sie sich ansehen und bei Bedarf darauf zurückgreifen.

Entwicklerkonsole verwenden

WebGPU ist eine ziemlich komplexe API mit vielen Regeln, die eine korrekte Verwendung erzwingen. Da die API keine typischen JavaScript-Ausnahmen für viele Fehler auslösen kann, ist es schwieriger, die genaue Fehlerquelle zu ermitteln.

Beim Entwickeln mit WebGPU werden Probleme auftreten, insbesondere als Anfänger. Das ist in Ordnung. Die Entwickler 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, die Ihnen helfen, das Problem zu identifizieren und zu beheben.

Es ist immer hilfreich, die Konsole geöffnet zu lassen, während Sie an einer beliebigen Webanwendung arbeiten. Das gilt hier aber ganz besonders.

3. WebGPU initialisieren

Beginnen Sie mit einem <canvas>

WebGPU kann auch ohne Anzeige auf dem Bildschirm verwendet werden, wenn Sie es nur für Berechnungen nutzen möchten. Wenn Sie jedoch etwas rendern möchten, wie wir es im Codelab tun werden, benötigen Sie ein Canvas. Das ist also ein guter Ausgangspunkt.

Erstellen Sie ein neues HTML-Dokument mit einem einzelnen <canvas>-Element und einem <script>-Tag, in dem wir das Canvas-Element abfragen. Alternativ können Sie 00-starter-page.html verwenden.

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

index.html

<!doctype html>

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

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

Adapter und Gerät anfordern

Jetzt können Sie sich mit WebGPU beschäftigen. Zuerst sollten Sie bedenken, dass es eine Weile dauern kann, bis sich APIs wie WebGPU im gesamten Webökosystem durchgesetzt haben. Ein guter erster Schritt ist daher, zu prüfen, ob der Browser des Nutzers WebGPU verwenden kann.

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

index.html

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

Im Idealfall informieren Sie den Nutzer, wenn WebGPU nicht verfügbar ist, indem Sie die Seite auf einen Modus zurücksetzen, in dem WebGPU nicht verwendet wird. (Vielleicht könnte stattdessen WebGL verwendet werden?) Für dieses Codelab wird jedoch nur ein Fehler ausgegeben, um die weitere Ausführung des Codes zu verhindern.

Wenn Sie wissen, dass WebGPU vom Browser unterstützt wird, besteht der erste Schritt beim Initialisieren von WebGPU für Ihre App darin, ein GPUAdapter anzufordern. Ein Adapter ist die WebGPU-Darstellung eines bestimmten Teils der GPU-Hardware auf Ihrem Gerät.

  1. Verwenden Sie zum Abrufen eines Adapters die Methode navigator.gpu.requestAdapter(). Die Funktion gibt ein Promise zurück. Daher ist es am besten, sie 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. Sie sollten diese Möglichkeit also berücksichtigen. Das kann passieren, wenn der Browser des Nutzers WebGPU unterstützt, die GPU-Hardware jedoch nicht alle Funktionen hat, die für die Verwendung von WebGPU erforderlich sind.

In den meisten Fällen ist es in Ordnung, wenn der Browser einen Standardadapter auswählt, wie hier. Für komplexere Anforderungen gibt es jedoch Argumente, die an requestAdapter() übergeben werden können, um anzugeben, ob Sie auf Geräten mit mehreren GPUs (z. B. einigen Laptops) Hardware mit geringem Stromverbrauch oder mit hoher Leistung verwenden möchten.

Sobald Sie einen Adapter haben, müssen Sie als letzten Schritt, bevor Sie mit der GPU arbeiten können, ein GPUDevice anfordern. Das Gerät ist die Hauptschnittstelle, über die die meisten Interaktionen mit der GPU erfolgen.

  1. Das Gerät wird durch Aufrufen von adapter.requestDevice() abgerufen, wodurch auch ein Promise zurückgegeben wird.

index.html

const device = await adapter.requestDevice();

Wie bei requestAdapter() gibt es auch hier Optionen, die übergeben werden können, um erweiterte Funktionen wie die Aktivierung bestimmter Hardwarefunktionen oder das Anfordern höherer Limits zu ermöglichen. Für Ihre Zwecke reichen jedoch die Standardeinstellungen aus.

Canvas konfigurieren

Nachdem Sie ein Gerät erstellt haben, müssen Sie noch den Canvas für die Verwendung mit dem gerade erstellten Gerät konfigurieren, wenn Sie damit etwas auf der Seite anzeigen möchten.

  • Fordern Sie dazu zuerst ein GPUCanvasContext aus dem Canvas an, indem Sie canvas.getContext("webgpu") aufrufen. (Dies ist derselbe Aufruf, den Sie zum Initialisieren von Canvas 2D- oder WebGL-Kontexten verwenden, wobei die Kontexttypen 2d bzw. webgl verwendet werden.) Die zurückgegebene context muss dann mit der Methode configure() mit dem Gerät verknüpft werden:

index.html

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

Hier können einige Optionen übergeben werden. Die wichtigsten sind jedoch das device, mit dem der Kontext verwendet werden soll, und das format, das das Texturformat angibt, das der Kontext verwenden soll.

Texturen sind die Objekte, die WebGPU zum Speichern von Bilddaten verwendet. Jede Textur hat ein Format, das der GPU mitteilt, wie die Daten im Arbeitsspeicher angeordnet sind. Die Funktionsweise des Texturspeichers wird in diesem Codelab nicht behandelt. Wichtig ist, dass der Canvas-Kontext Texturen für Ihren Code zum Zeichnen bereitstellt. Das verwendete Format kann sich darauf auswirken, wie effizient der Canvas diese Bilder anzeigt. Verschiedene Gerätetypen funktionieren am besten mit unterschiedlichen Texturformaten. Wenn Sie nicht das bevorzugte Format des Geräts verwenden, kann es sein, dass im Hintergrund zusätzliche Speicherkopien erstellt werden, bevor das Bild als Teil der Seite angezeigt werden kann.

Glücklicherweise müssen Sie sich darum nicht kümmern, da WebGPU Ihnen mitteilt, welches Format Sie für Ihren Canvas verwenden sollten. In den meisten Fällen sollten Sie den Wert übergeben, der durch den Aufruf von navigator.gpu.getPreferredCanvasFormat() zurückgegeben wird, wie oben gezeigt.

Canvas löschen

Nachdem Sie ein Gerät haben und das Canvas damit konfiguriert wurde, können Sie das Gerät verwenden, um den Inhalt des Canvas zu ändern. Beginnen Sie damit, den Hintergrund mit einer einheitlichen Farbe zu füllen.

Dazu oder für so ziemlich alles andere in WebGPU müssen Sie der GPU einige Befehle geben, die ihr sagen, was sie tun soll.

  1. Lassen Sie das Gerät dazu ein GPUCommandEncoder erstellen, das eine Schnittstelle zum Aufzeichnen von GPU-Befehlen bietet.

index.html

const encoder = device.createCommandEncoder();

Die Befehle, die Sie an die GPU senden möchten, beziehen sich auf das Rendern (in diesem Fall das Löschen des Canvas). Der nächste Schritt besteht also darin, mit encoder einen Render-Pass zu starten.

Render-Passes sind die Zeitpunkte, zu denen alle Zeichenvorgänge in WebGPU stattfinden. Jeder beginnt mit einem beginRenderPass()-Aufruf, der die Texturen definiert, die die Ausgabe aller ausgeführten Zeichenbefehle empfangen. Bei anspruchsvolleren Anwendungen können mehrere Texturen, sogenannte Anhänge, mit verschiedenen Zwecken bereitgestellt werden, z. B. zum Speichern der Tiefe gerenderter Geometrie oder zum Bereitstellen von Antialiasing. Für diese App benötigen Sie jedoch nur eine.

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

index.html

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

Die Textur wird als view-Eigenschaft eines colorAttachment angegeben. Für Render-Passes müssen Sie einen GPUTextureView anstelle eines GPUTexture angeben, um festzulegen, welche Teile der Textur gerendert werden sollen. Das ist nur bei komplexeren Anwendungsfällen wirklich wichtig. Hier rufen Sie createView() ohne Argumente für die Textur auf, um anzugeben, dass der Render-Pass die gesamte Textur verwenden soll.

Außerdem müssen Sie angeben, was mit der Textur geschehen soll, wenn der Render-Pass beginnt und endet:

  • Ein loadOp-Wert von "clear" gibt an, dass die Textur beim Start des Render-Passes gelöscht werden soll.
  • Ein storeOp-Wert von "store" gibt an, dass nach Abschluss des Renderings die Ergebnisse aller während des Renderingvorgangs ausgeführten Zeichenvorgänge in der Textur gespeichert werden sollen.

Sobald der Rendering-Vorgang begonnen hat, müssen Sie nichts weiter tun. Zumindest vorerst. Wenn Sie den Renderdurchlauf mit loadOp: "clear" starten, wird die Texturansicht und die Arbeitsfläche gelöscht.

  1. Beenden Sie den Render-Pass, indem Sie den folgenden Aufruf direkt nach beginRenderPass() hinzufügen:

index.html

pass.end();

Es ist wichtig zu wissen, dass die GPU durch diese Aufrufe nicht automatisch etwas tut. Sie zeichnen nur Befehle auf, die die GPU später ausführen soll.

  1. Rufen Sie finish() für den Befehlscoder auf, um einen GPUCommandBuffer zu erstellen. Der Befehlspuffer ist ein undurchsichtiges Handle für die aufgezeichneten Befehle.

index.html

const commandBuffer = encoder.finish();
  1. Senden Sie den Befehlspuffer mit dem queue des GPUDevice an die GPU. In der Warteschlange werden alle GPU-Befehle ausgeführt, sodass ihre Ausführung gut geordnet und richtig synchronisiert ist. Die submit()-Methode der Warteschlange akzeptiert ein Array von Befehlspuffern. In diesem Fall haben Sie jedoch nur einen.

index.html

device.queue.submit([commandBuffer]);

Nachdem Sie einen Befehlspuffer gesendet haben, kann er nicht mehr verwendet werden. Sie müssen ihn also nicht aufbewahren. Wenn Sie weitere Befehle senden möchten, müssen Sie einen weiteren Befehlspuffer erstellen. Daher werden diese beiden Schritte häufig in einem zusammengefasst, wie auf den Beispielseiten für dieses Codelab:

index.html

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

Nachdem Sie die Befehle an die GPU gesendet haben, sollte JavaScript die Steuerung an den Browser zurückgeben. Der Browser erkennt, dass Sie die aktuelle Textur des Kontextes geändert haben, und aktualisiert das Canvas, um diese Textur als Bild anzuzeigen. Wenn Sie die Inhalte des Canvas danach noch einmal aktualisieren möchten, müssen Sie einen neuen Befehlspuffer aufzeichnen und senden und context.getCurrentTexture() noch einmal aufrufen, um eine neue Textur für einen Rendering-Pass zu erhalten.

  1. Lade die Seite neu. Die Arbeitsfläche ist schwarz gefüllt. Glückwunsch! Das bedeutet, dass Sie Ihre erste WebGPU-App erfolgreich erstellt haben.

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

Wähle eine Farbe aus.

Ehrlich gesagt sind schwarze Quadrate aber ziemlich langweilig. Nehmen Sie sich also einen Moment Zeit, bevor Sie mit dem nächsten Abschnitt fortfahren, um ihn ein wenig zu personalisieren.

  1. Fügen Sie dem encoder.beginRenderPass()-Aufruf eine neue Zeile mit einem clearValue zum colorAttachment hinzu, wie hier:

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

Mit clearValue wird dem Render-Pass mitgeteilt, welche Farbe er verwenden soll, wenn er den Vorgang clear am Anfang des Passes ausführt. Das übergebene Dictionary enthält vier Werte: r für Rot, g für Grün, b für Blau und a für Alpha (Transparenz). Jeder Wert kann zwischen 0 und 1 liegen. Zusammen beschreiben sie den Wert des jeweiligen Farbkanals. Beispiel:

  • { r: 1, g: 0, b: 0, a: 1 } ist hellrot.
  • { r: 1, g: 0, b: 1, a: 1 } ist hellviolett.
  • { 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 der Standardwert: transparentes Schwarz.

Im Beispielcode und auf den Screenshots in diesem Codelab wird ein Dunkelblau verwendet. Sie können jedoch eine beliebige Farbe auswählen.

  1. Nachdem Sie eine Farbe ausgewählt haben, aktualisieren Sie die Seite. Die ausgewählte Farbe sollte auf dem Canvas angezeigt werden.

Eine Leinwand, die in Dunkelblau gerendert wurde, um zu zeigen, wie die Standardfarbe für das Löschen geändert wird.

4. Geometrie zeichnen

Am Ende dieses Abschnitts wird in Ihrer App eine einfache geometrische Form auf dem Canvas gezeichnet: ein farbiges Quadrat. Es mag jetzt so aussehen, als wäre das viel Arbeit für eine so einfache Ausgabe, aber das liegt daran, dass WebGPU darauf ausgelegt ist, viele geometrische Formen sehr effizient zu rendern. Ein Nebeneffekt dieser Effizienz ist, dass relativ einfache Dinge ungewöhnlich schwierig erscheinen können. Das ist jedoch zu erwarten, wenn Sie eine API wie WebGPU verwenden – Sie möchten etwas Komplexeres tun.

So werden GPUs gezeichnet

Bevor wir weitere Codeänderungen vornehmen, ist es sinnvoll, einen sehr kurzen, vereinfachten Überblick darüber zu geben, wie GPUs die Formen erzeugen, die auf dem Bildschirm angezeigt werden. Wenn Sie bereits mit den Grundlagen der GPU-Wiedergabe vertraut sind, können Sie direkt zum Abschnitt „Eckpunkte definieren“ springen.

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

GPUs arbeiten fast ausschließlich mit Dreiecken, da diese viele gute 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 es zeichnen kann. Diese Dreiecke müssen durch ihre Eckpunkte definiert werden.

Diese Punkte oder Eckpunkte werden durch X-, Y- und (bei 3D-Inhalten) Z-Werte angegeben, die einen Punkt in einem kartesischen Koordinatensystem definieren, das von WebGPU oder ähnlichen APIs definiert wird. Die Struktur des Koordinatensystems lässt sich am besten im Hinblick auf die Beziehung zum Canvas auf Ihrer Seite nachvollziehen. Unabhängig davon, wie breit oder hoch Ihr Arbeitsbereich ist, befindet sich der linke Rand immer bei -1 auf der X-Achse und der rechte Rand immer bei +1 auf der X-Achse. Die Unterkante ist immer -1 auf der Y-Achse und die Oberkante ist +1 auf der Y-Achse. Das bedeutet, dass (0, 0) immer die Mitte des Arbeitsbereichs, (-1, -1) immer die untere linke Ecke und (1, 1) immer die obere rechte Ecke ist. Dies wird als Clip Space bezeichnet.

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

Die Eckpunkte werden in diesem Koordinatensystem selten von Anfang an definiert. Daher sind GPUs auf kleine Programme namens Vertex-Shader angewiesen, um die erforderlichen Berechnungen durchzuführen, um die Eckpunkte in den Clip-Space zu transformieren, sowie alle anderen Berechnungen, die zum Zeichnen der Eckpunkte erforderlich sind. Der Shader kann beispielsweise eine Animation anwenden oder die Richtung vom Vertex zu einer Lichtquelle berechnen. Diese Shader werden von Ihnen, dem WebGPU-Entwickler, geschrieben und bieten eine erstaunliche Kontrolle über die Funktionsweise der GPU.

Die GPU nimmt dann alle Dreiecke, die aus diesen transformierten Eckpunkten bestehen, und bestimmt, welche Pixel auf dem Bildschirm zum Zeichnen benötigt werden. Anschließend wird ein weiteres kleines Programm ausgeführt, das Sie schreiben und das als Fragment-Shader bezeichnet wird. Es berechnet, welche Farbe jedes Pixel haben soll. Diese Berechnung kann so einfach wie Rückgabe von Grün oder so komplex sein wie die Berechnung des Winkels der Oberfläche relativ zum Sonnenlicht, das von anderen Oberflächen in der Nähe reflektiert wird, durch Nebel gefiltert und durch die Metallisierung der Oberfläche modifiziert wird. Sie haben die volle Kontrolle, was sowohl ermächtigend als auch überwältigend sein kann.

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

Eckpunkte definieren

Wie bereits erwähnt, wird die Simulation „The Game of Life“ als Raster von Zellen dargestellt. Ihre App muss das Raster visualisieren und aktive Zellen von inaktiven Zellen unterscheiden können. In diesem Codelab werden farbige Quadrate in die aktiven Zellen gezeichnet und inaktive Zellen bleiben leer.

Das bedeutet, dass Sie der GPU vier verschiedene Punkte zur Verfügung stellen müssen, einen für jede der vier Ecken des Quadrats. Ein Quadrat, das in der Mitte des Arbeitsbereichs gezeichnet und von den Rändern aus ein Stück nach innen gezogen wird, hat beispielsweise die folgenden Eckkoordinaten:

Ein Diagramm mit normalisierten Gerätekoordinaten, in dem die Koordinaten für die Ecken eines Quadrats dargestellt sind

Damit die Koordinaten an die GPU übergeben werden können, müssen Sie die Werte in einem TypedArray platzieren. Wenn Sie noch nicht damit vertraut sind: TypedArrays sind eine Gruppe von JavaScript-Objekten, mit denen Sie zusammenhängende Speicherblöcke zuweisen und jedes Element in der Reihe als bestimmten Datentyp interpretieren können. In einem Uint8Array ist beispielsweise jedes Element im Array ein einzelnes, nicht signiertes Byte. TypedArrays eignen sich hervorragend für das Senden von Daten an und von APIs, die empfindlich auf das Speicherlayout reagieren, z. B. WebAssembly, WebAudio und (natürlich) WebGPU.

Da die Werte im Beispiel mit dem Quadrat Bruchzahlen sind, ist Float32Array angemessen.

  1. Erstellen Sie ein Array, das alle Eckpunktpositionen im Diagramm enthält, indem Sie die folgende Array-Deklaration in Ihren Code einfügen. Am besten platzieren Sie ihn oben, direkt unter dem context.configure()-Aufruf.

index.html

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

Hinweis: Die Abstände und Kommentare haben keine Auswirkungen auf die Werte. Sie dienen lediglich dazu, die Lesbarkeit zu verbessern. So können Sie sehen, dass jedes Wertepaar die X- und Y-Koordinaten für einen Eckpunkt bildet.

Aber es gibt ein Problem. GPUs arbeiten mit Dreiecken. Das bedeutet, dass Sie die Eckpunkte in Dreiergruppen angeben müssen. Sie haben eine Gruppe mit vier Personen. Die Lösung besteht darin, zwei der Eckpunkte zu wiederholen, um zwei Dreiecke zu erstellen, die eine Kante in der Mitte des Quadrats gemeinsam haben.

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 und einmal für das rote Dreieck. Sie könnten das Quadrat auch mit den anderen beiden Ecken aufteilen. Das macht keinen Unterschied.

  1. Aktualisieren Sie Ihr bisheriges vertices-Array so:

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 im Diagramm zur besseren Übersicht eine Trennung zwischen den beiden Dreiecken dargestellt ist, sind die Eckpositionen genau gleich und die GPU rendert sie ohne Lücken. Es wird als einzelnes, durchgehendes Quadrat gerendert.

Vertex-Puffer erstellen

Die GPU kann keine Eckpunkte mit Daten aus einem JavaScript-Array zeichnen. GPUs haben häufig einen eigenen Arbeitsspeicher, der für das Rendern optimiert ist. Alle Daten, die die GPU beim Zeichnen verwenden soll, müssen in diesem Arbeitsspeicher abgelegt werden.

Bei vielen Werten, einschließlich Vertex-Daten, wird der GPU-seitige Speicher über GPUBuffer-Objekte verwaltet. Ein Puffer ist ein Speicherblock, auf den die GPU leicht zugreifen kann und der für bestimmte Zwecke gekennzeichnet ist. Sie können sich das ein wenig wie ein GPU-sichtbares TypedArray vorstellen.

  1. Fügen Sie nach der Definition des vertices-Arrays den folgenden Aufruf zu device.createBuffer() hinzu, um einen Puffer für die Eckpunkte zu erstellen.

index.html

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

Zuerst geben Sie dem Puffer ein Label. Jedes WebGPU-Objekt, das Sie erstellen, kann ein optionales Label erhalten. Das sollten Sie auch tun. Das Label kann ein beliebiger String sein, solange es Ihnen hilft, das Objekt zu identifizieren. Wenn Probleme auftreten, werden diese Labels in den Fehlermeldungen verwendet, die WebGPU generiert, damit Sie nachvollziehen können, was schiefgelaufen ist.

Geben Sie als Nächstes eine Größe für den Puffer in Byte an. Sie benötigen einen Puffer mit 48 Byte. Diesen Wert erhalten Sie, indem Sie die Größe einer 32-Bit-Gleitkommazahl ( 4 Byte) mit der Anzahl der Gleitkommazahlen in Ihrem vertices-Array (12) multiplizieren. Glücklicherweise berechnen TypedArrays ihre byteLength bereits für Sie. Sie können sie also beim Erstellen des Puffers verwenden.

Schließlich müssen Sie die Verwendung des Puffers angeben. Dies ist eines oder mehrere der Flags GPUBufferUsage. Mehrere Flags werden mit dem Operator | ( bitweises ODER) kombiniert. In diesem Fall geben Sie an, dass der Puffer für Vertex-Daten (GPUBufferUsage.VERTEX) verwendet werden soll und dass Sie auch Daten hineinkopieren möchten (GPUBufferUsage.COPY_DST).

Das Pufferobjekt, das an Sie zurückgegeben wird, ist undurchsichtig. Sie können die darin enthaltenen Daten nicht (einfach) prüfen. Außerdem sind die meisten Attribute unveränderlich. Sie können ein GPUBuffer nach der Erstellung nicht mehr in der Größe ändern und auch die Nutzungsflags nicht mehr ändern. Sie können jedoch den Inhalt des Speichers ändern.

Wenn der Puffer zum ersten Mal erstellt wird, wird der darin enthaltene Speicher mit Nullen initialisiert. Es gibt mehrere Möglichkeiten, den Inhalt zu ändern. Am einfachsten ist es jedoch, device.queue.writeBuffer() mit einem TypedArray aufzurufen, das Sie kopieren möchten.

  1. Fügen Sie den folgenden Code hinzu, um die Vertex-Daten in den Arbeitsspeicher des Puffers zu kopieren:

index.html

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

Vertex-Layout definieren

Sie haben jetzt einen Puffer mit Vertexdaten, aber für die GPU ist das nur ein Blob mit Bytes. Wenn Sie etwas zeichnen möchten, müssen Sie ein paar weitere Informationen angeben. Sie müssen WebGPU mehr über die Struktur der Vertex-Daten mitteilen.

index.html

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

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

Als Erstes geben Sie die arrayStride an. Dies ist die Anzahl der Byte, die die GPU im Puffer überspringen muss, wenn sie nach dem nächsten Vertex sucht. Jeder Eckpunkt Ihres Quadrats besteht aus zwei 32-Bit-Gleitkommazahlen. Wie bereits erwähnt, ist ein 32-Bit-Gleitkommawert 4 Byte groß. Zwei Gleitkommawerte entsprechen also 8 Byte.

Als Nächstes folgt das Attribut attributes, das ein Array ist. Attribute sind die einzelnen Informationen, die in jedem Knoten codiert sind. Ihre Knoten enthalten nur ein Attribut (die Knotenposition). In komplexeren Anwendungsfällen haben Knoten jedoch häufig mehrere Attribute, z. B. die Farbe eines Knotens oder die Richtung, in die die geometrische Oberfläche zeigt. Das wird in diesem Codelab jedoch nicht behandelt.

Im einzelnen Attribut definieren Sie zuerst die format der Daten. Diese stammt aus einer Liste von GPUVertexFormat-Typen, die jeden Typ von Vertex-Daten beschreiben, die die GPU verstehen kann. Ihre Eckpunkte haben jeweils zwei 32‑Bit-Gleitkommazahlen, daher verwenden Sie das Format float32x2. Wenn Ihre Vertex-Daten stattdessen aus vier 16-Bit-Ganzzahlen ohne Vorzeichen bestehen, verwenden Sie stattdessen uint16x4. Erkennen Sie das Muster?

Als Nächstes wird mit offset angegeben, wie viele Byte in den Vertex dieses Attribut umfasst. Das ist nur dann ein Problem, wenn Ihr Puffer mehrere Attribute enthält. Das ist in diesem Codelab jedoch nicht der Fall.

Schließlich haben Sie noch die shaderLocation. Dies ist eine beliebige Zahl zwischen 0 und 15, die für jedes Attribut, das Sie definieren, eindeutig sein muss. Damit wird dieses Attribut mit einer bestimmten Eingabe im Vertex-Shader verknüpft, die im nächsten Abschnitt beschrieben wird.

Beachten Sie, dass Sie diese Werte zwar jetzt definieren, sie aber noch nicht an die WebGPU API übergeben. Das kommt noch, aber es ist am einfachsten, sich diese Werte dann anzusehen, wenn Sie die Eckpunkte definieren. Sie richten sie also 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. Ein Großteil davon geschieht mit Shadern.

Shader sind kleine Programme, die Sie schreiben und die auf Ihrer GPU ausgeführt werden. Jeder Shader wird in einer anderen Phase der Daten ausgeführt: Vertex-Verarbeitung, Fragment-Verarbeitung oder allgemeine Berechnung. Da sie auf der GPU ausgeführt werden, sind sie starrer strukturiert als durchschnittlicher JavaScript-Code. Diese Struktur ermöglicht es ihnen jedoch, sehr schnell und vor allem parallel zu arbeiten.

Shader in WebGPU werden in einer Shading-Sprache namens WGSL (WebGPU Shading Language) geschrieben. WGSL ähnelt syntaktisch Rust und bietet Funktionen, die die Arbeit mit gängigen GPU-Typen (z. B. Vektor- und Matrixmathematik) einfacher und schneller machen. Die gesamte Shading-Sprache zu vermitteln, geht weit über den Rahmen dieses Codelabs hinaus. Wir hoffen jedoch, dass Sie einige Grundlagen lernen, wenn Sie sich einige einfache Beispiele ansehen.

Die Shader selbst werden als Strings an WebGPU übergeben.

  • Erstellen Sie einen Bereich, in den 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 und geben optional label und WGSL code als String an. Hinweis: Hier verwenden Sie Backticks, um mehrzeilige Strings zu ermöglichen. Sobald 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 die GPU auch dort beginnt.

Ein Vertex-Shader wird als Funktion definiert und die GPU ruft diese Funktion einmal für jeden Vertex in Ihrem vertexBuffer auf. Da Ihr vertexBuffer sechs Positionen (Eckpunkte) enthält, wird die von Ihnen definierte Funktion sechsmal aufgerufen. Bei jedem Aufruf wird eine andere Position aus vertexBuffer als Argument an die Funktion übergeben. Die Vertex-Shader-Funktion muss eine entsprechende Position im Clip-Space zurückgeben.

Es ist wichtig zu wissen, dass sie auch nicht unbedingt in sequenzieller Reihenfolge aufgerufen werden. GPUs eignen sich jedoch hervorragend, um solche Shader parallel auszuführen und potenziell Hunderte oder sogar Tausende von Knoten gleichzeitig zu verarbeiten. Das ist ein wichtiger Grund für die unglaubliche Geschwindigkeit von GPUs, aber es gibt auch Einschränkungen. 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 Vertex sehen und nur Werte für einen einzelnen Vertex ausgeben.

In WGSL kann eine Vertex-Shader-Funktion einen beliebigen Namen haben. Sie muss jedoch das @vertex-Attribut vorangestellt haben, um anzugeben, welche Shader-Phase sie darstellt. In WGSL werden Funktionen mit dem Keyword fn gekennzeichnet, Argumente werden in Klammern deklariert und der Bereich wird mit geschweiften Klammern definiert.

  1. So erstellen Sie eine leere @vertex-Funktion:

index.html (createShaderModule-Code)

@vertex
fn vertexMain() {

}

Das ist jedoch nicht gültig, da ein Vertex-Shader mindestens die endgültige Position des verarbeiteten Vertex im Clip-Space zurückgeben muss. Dieser Wert wird immer als vierdimensionaler Vektor angegeben. Vektoren werden so häufig in Shadern verwendet, dass sie in der Sprache als erstklassige Primitiven behandelt werden und eigene Typen wie vec4f für einen vierdimensionalen Vektor haben. Es gibt ä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 ihn mit dem Attribut @builtin(position). Ein ->-Symbol wird verwendet, um anzugeben, was die Funktion zurückgibt.

index.html (createShaderModule-Code)

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

}

Wenn die Funktion einen Rückgabetyp hat, müssen Sie natürlich auch einen Wert im Funktionskörper zurückgeben. Sie können ein neues vec4f erstellen, das zurückgegeben werden soll, indem Sie die Syntax vec4f(x, y, z, w) verwenden. Die Werte x, y und z sind alle Gleitkommazahlen, die im Rückgabewert angeben, wo sich der Vertex im Clip-Space befindet.

  1. Wenn Sie einen statischen Wert von (0, 0, 0, 1) zurückgeben, haben Sie technisch gesehen einen gültigen Vertex-Shader, der jedoch nie etwas anzeigt, da die GPU erkennt, dass die erzeugten Dreiecke nur ein einzelner Punkt sind, und sie dann verwirft.

index.html (createShaderModule-Code)

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

Stattdessen möchten Sie die Daten aus dem von Ihnen erstellten Puffer verwenden. Dazu deklarieren Sie ein Argument für Ihre Funktion mit einem @location()-Attribut und einem Typ, die mit dem übereinstimmen, was Sie in vertexBufferLayout beschrieben haben. Sie haben shaderLocation als 0 angegeben. Markieren Sie das Argument in Ihrem WGSL-Code also mit @location(0). Sie haben das Format auch als float32x2 definiert, einen 2D-Vektor. In WGSL ist Ihr Argument also ein vec2f. Sie können den Namen beliebig wählen. Da diese Werte jedoch die Positionen der Eckpunkte darstellen, ist ein Name wie pos sinnvoll.

  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 sie etwas ändern. Sie müssen die beiden Komponenten aus dem Positionsargument übernehmen und in die ersten beiden Komponenten des Rückgabevektors einfügen. Die letzten beiden Komponenten bleiben 0 bzw. 1.

  1. Geben Sie die richtige 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);
}

Da diese Art von Zuordnungen in Shadern jedoch so häufig vorkommen, können Sie den Positionsvektor auch als erstes Argument in einer praktischen Kurzform übergeben. Das Ergebnis ist dasselbe.

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

index.html (createShaderModule-Code)

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

Das ist Ihr erster Vertex-Shader. Das ist ganz einfach: Die Position wird im Grunde unverändert weitergegeben. Das reicht aber für den Anfang.

Fragment-Shader definieren

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

Fragment-Shader werden immer nach Vertex-Shadern aufgerufen. Die GPU nimmt die Ausgabe der Vertex-Shader entgegen und trianguliert sie. Dabei werden aus Gruppen von drei Punkten Dreiecke erstellt. Anschließend wird jedes dieser Dreiecke gerastert. Dazu wird ermittelt, welche Pixel der Ausgabefarb-Attachments in diesem Dreieck enthalten sind. Der Fragment-Shader wird dann einmal für jedes dieser Pixel aufgerufen. Der Fragment-Shader gibt eine Farbe zurück, die in der Regel aus Werten berechnet wird, die vom Vertex-Shader und Assets wie Texturen an ihn gesendet werden. Die GPU schreibt diese Farbe in das Farbattachment.

Wie Vertex-Shader werden auch Fragment-Shader massiv parallel ausgeführt. Sie sind in Bezug auf ihre Ein- und Ausgaben etwas flexibler als Vertex-Shader, aber Sie können davon ausgehen, dass sie einfach eine Farbe für jedes Pixel jedes Dreiecks zurückgeben.

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

  1. So erstellen Sie eine leere @fragment-Funktion:

index.html (createShaderModule-Code)

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

}

Die vier Komponenten des zurückgegebenen Vektors sind die Farbwerte für Rot, Grün, Blau und Alpha, die genau wie die clearValue interpretiert werden, die Sie zuvor in beginRenderPass festgelegt haben. vec4f(1, 0, 0, 1) ist also hellrot, was eine gute Farbe für Ihr Quadrat zu sein scheint. Sie können die Farbe aber auch kostenlos 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)
}

Das ist ein vollständiger Fragment-Shader. Das ist nicht besonders interessant. Es wird einfach jeder Pixel jedes Dreiecks auf Rot gesetzt. Das reicht aber für den Moment.

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

Render-Pipeline erstellen

Ein Shader-Modul kann nicht allein für das Rendern verwendet werden. Stattdessen müssen Sie sie als Teil einer GPURenderPipeline verwenden, die durch Aufrufen von device.createRenderPipeline() erstellt wird. Die Renderpipeline steuert, wie die Geometrie gezeichnet wird, einschließlich der verwendeten Shader, der Interpretation von Daten in Vertex-Puffern und der Art der zu rendernden Geometrie (Linien, Punkte, Dreiecke usw.).

Die Render-Pipeline ist das komplexeste Objekt in der gesamten API, aber keine Sorge. Die meisten Werte, die Sie an die Funktion übergeben können, sind optional. Sie müssen nur wenige angeben, um loszulegen.

  • So erstellen Sie 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 (außer Vertex-Puffern) die Pipeline benötigt. Sie haben aber keine. Glücklicherweise können Sie "auto" vorerst übergeben. Das Layout der Pipeline wird dann aus den Shadern erstellt.

Als Nächstes müssen Sie Details zur Phase vertex angeben. module ist das GPUShaderModule, das Ihren Vertex-Shader enthält, und entryPoint gibt den Namen der Funktion im Shader-Code an, die für jeden Vertex-Aufruf aufgerufen wird. Ein Shader-Modul kann mehrere @vertex- und @fragment-Funktionen enthalten. buffers ist ein Array von GPUVertexBufferLayout-Objekten, die beschreiben, wie Ihre Daten in den Vertex-Puffern gepackt werden, mit denen Sie diese Pipeline verwenden. Glücklicherweise haben Sie das bereits in Ihrem vertexBufferLayout definiert. Hier wird der Pass gespielt.

Schließlich finden Sie Details zur fragment-Phase. Dazu gehören auch ein Shader-Modul und ein entryPoint, wie bei der Vertex-Phase. Im letzten Schritt müssen Sie die targets definieren, die für diese Pipeline verwendet werden. Dies ist ein Array von Dictionaries mit Details wie der Textur format der Farbanhänge, die von der Pipeline ausgegeben werden. Diese Details müssen mit den Texturen im colorAttachments aller Renderdurchgänge übereinstimmen, mit denen diese Pipeline verwendet wird. Ihr Render-Pass verwendet Texturen aus dem Canvas-Kontext und den Wert, den Sie in canvasFormat für das Format gespeichert haben. Sie müssen also dasselbe Format übergeben.

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

Quadrat zeichnen

Damit haben Sie jetzt alles, was Sie zum Zeichnen des Quadrats benötigen.

  1. Um das Quadrat zu zeichnen, kehren Sie zum Aufrufpaar encoder.beginRenderPass() und pass.end() zurück 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, welche Pipeline für das Zeichnen verwendet werden soll. Dazu gehören die verwendeten Shader, das Layout der Vertex-Daten und andere relevante Statusdaten.

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

Zuletzt rufen Sie draw() auf, was nach all den vorherigen Einrichtungsschritten seltsam einfach erscheint. Sie müssen nur die Anzahl der zu rendernden Eckpunkte übergeben. Diese werden aus den aktuell festgelegten Vertex-Puffern abgerufen und mit der aktuell festgelegten Pipeline interpretiert. Sie könnten den Wert einfach auf 6 hartzucodieren. Wenn Sie ihn jedoch aus dem Array „vertices“ berechnen (12 Gleitkommazahlen / 2 Koordinaten pro Eckpunkt = 6 Eckpunkte), müssen Sie weniger manuell aktualisieren, wenn Sie das Quadrat beispielsweise durch einen Kreis ersetzen.

  1. Aktualisieren Sie den Bildschirm und sehen Sie sich das Ergebnis Ihrer harten Arbeit an: ein großes farbiges Quadrat.

Ein einzelnes rotes Quadrat, das mit WebGPU gerendert wurde

5. Raster zeichnen

Zuerst einmal: Herzlichen Glückwunsch! Die ersten Geometriedaten auf dem Bildschirm darzustellen, ist oft einer der schwierigsten Schritte bei den meisten GPU-APIs. Alles, was Sie ab hier tun, kann in kleineren Schritten erfolgen, sodass Sie Ihren Fortschritt leichter überprüfen können.

In diesem Abschnitt erfahren Sie:

  • Wie Variablen (sogenannte Uniforms) aus JavaScript an den Shader übergeben werden.
  • So verwenden Sie Uniforms, um das Rendering-Verhalten zu ändern.
  • So verwenden Sie Instancing, um viele verschiedene Varianten derselben Geometrie zu zeichnen.

Raster definieren

Damit ein Raster gerendert werden kann, benötigen Sie eine sehr grundlegende Information dazu. Wie viele Zellen enthält es in Breite und Höhe? Das liegt an Ihnen als Entwickler. Um die Sache etwas einfacher zu gestalten, sollten Sie das Raster jedoch als Quadrat (gleiche Breite und Höhe) behandeln und eine Größe verwenden, die eine Zweierpotenz ist. Das macht einige Berechnungen später einfacher. Sie möchten es später vergrößern, aber für den Rest dieses Abschnitts legen Sie die Rastergröße auf 4 × 4 fest, da dies die Veranschaulichung einiger der in diesem Abschnitt verwendeten Berechnungen erleichtert. Später kannst du es noch skalieren.

  • Definieren Sie die Rastergröße, indem Sie oben in Ihrem JavaScript-Code eine Konstante hinzufügen.

index.html

const GRID_SIZE = 4;

Als Nächstes müssen Sie die Darstellung des Quadrats so aktualisieren, dass GRID_SIZE × GRID_SIZE Quadrate auf dem Canvas Platz finden. Das bedeutet, dass die Quadrate viel kleiner sein müssen und es viele davon geben muss.

Eine Möglichkeit, dies zu erreichen, wäre, den Vertex-Puffer deutlich zu vergrößern und darin GRID_SIZE × GRID_SIZE Quadrate mit der richtigen Größe und Position zu definieren. Der Code dafür ist eigentlich gar nicht so kompliziert. Dazu sind nur ein paar For-Schleifen und etwas Mathematik erforderlich. Dadurch wird die GPU aber auch nicht optimal genutzt und es wird mehr Arbeitsspeicher als nötig verwendet, um den Effekt zu erzielen. In diesem Abschnitt wird ein GPU-freundlicherer Ansatz vorgestellt.

Uniform-Puffer erstellen

Zuerst müssen Sie dem Shader die von Ihnen gewählte Rastergröße mitteilen, da er diese verwendet, um die Darstellung zu ändern. Sie könnten die Größe einfach im Shader fest codieren. Das bedeutet aber, dass Sie jedes Mal, wenn Sie die Rastergröße ändern möchten, den Shader und die Rendering-Pipeline neu erstellen müssen, was aufwendig ist. Eine bessere Möglichkeit besteht darin, die Rastergröße als Uniforms an den Shader zu übergeben.

Wie Sie bereits wissen, wird bei jedem Aufruf eines Vertex-Shaders ein anderer Wert aus dem Vertex-Puffer übergeben. Ein Uniform ist ein Wert aus einem Puffer, der für jeden Aufruf gleich ist. Sie sind nützlich, um Werte zu kommunizieren, die für ein geometrisches Objekt (z. B. seine Position), einen vollständigen Animationsframe (z. B. die aktuelle Zeit) oder sogar die gesamte Lebensdauer der App (z. B. eine Nutzereinstellung) gelten.

  • Erstellen Sie einen einheitlichen Puffer, 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);

Dieser Code sollte Ihnen sehr vertraut sein, da er fast genau dem Code entspricht, den Sie zuvor zum Erstellen des Vertex-Puffers verwendet haben. Das liegt daran, dass Uniformen über dieselben GPUBuffer-Objekte an die WebGPU API übergeben werden wie Eckpunkte. Der Hauptunterschied besteht darin, dass usage diesmal GPUBufferUsage.UNIFORM anstelle von GPUBufferUsage.VERTEX enthält.

Auf Uniforms in einem Shader zugreifen

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

index.html (createShaderModule-Aufruf)

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

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

// ...fragmentMain is unchanged

Dadurch wird eine Uniform in Ihrem Shader mit dem Namen grid definiert. Das ist ein 2D-Gleitkommavektor, der dem Array entspricht, das Sie gerade in den Uniform-Puffer kopiert haben. Außerdem wird angegeben, dass die Uniform an @group(0) und @binding(0) gebunden ist. Was diese Werte bedeuten, erfahren Sie gleich.

An anderer Stelle im Shader-Code können Sie den Grid-Vektor dann nach Bedarf verwenden. In diesem Code wird die Position des Vertex durch den Rastervektor geteilt. Da pos ein 2D-Vektor und grid ein 2D-Vektor ist, führt WGSL eine komponentenweise Division durch. Das Ergebnis ist also dasselbe wie bei vec2f(pos.x / grid.x, pos.y / grid.y).

Diese Arten von Vektoroperationen sind in GPU-Shadern sehr häufig, da viele Rendering- und Berechnungstechniken auf ihnen basieren.

Wenn Sie eine Rastergröße von 4 verwendet haben, wäre das gerenderte Quadrat nur ein Viertel seiner ursprünglichen Größe. Das ist ideal, wenn Sie vier Bilder in einer Zeile oder Spalte anordnen möchten.

Bindungsgruppe erstellen

Durch das Deklarieren der Uniform im Shader wird sie jedoch nicht mit dem von Ihnen erstellten Puffer 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. Sie kann verschiedene Arten von Puffern enthalten, z. B. Ihren einheitlichen Puffer, und andere Ressourcen wie Texturen und Sampler, die hier nicht behandelt werden, aber häufige Bestandteile von WebGPU-Rendering-Techniken sind.

  • Erstellen Sie eine Bindungsgruppe mit Ihrem Uniform-Puffer, indem Sie den folgenden Code nach dem Erstellen des Uniform-Puffers und der Rendering-Pipeline hinzufügen:

index.html

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

Zusätzlich zum jetzt standardmäßigen label benötigen Sie auch ein layout, das beschreibt, welche Arten von Ressourcen diese Bindungsgruppe enthält. Das ist etwas, das Sie in einem späteren Schritt genauer untersuchen. Im Moment können Sie Ihre Pipeline jedoch problemlos nach dem Layout der Bindungsgruppe fragen, da Sie die Pipeline mit layout: "auto" erstellt haben. Dadurch werden Bindungsgruppenlayouts automatisch aus den Bindungen erstellt, die Sie im Shader-Code deklariert haben. In diesem Fall bitten Sie das Modell, getBindGroupLayout(0), wobei 0 dem @group(0) entspricht, das Sie im Shader eingegeben haben.

Nachdem Sie das Layout angegeben haben, geben Sie ein Array von entries an. Jeder Eintrag ist ein Dictionary mit mindestens den folgenden Werten:

  • binding, was dem @binding()-Wert entspricht, den Sie im Shader eingegeben haben. In diesem Fall ist das 0.
  • resource ist die tatsächliche Ressource, die Sie der Variablen am angegebenen Bindungsindex zur Verfügung stellen möchten. In diesem Fall Ihr Uniform-Puffer.

Die Funktion gibt ein GPUBindGroup zurück, das ein undurchsichtiges, unveränderliches Handle ist. Sie können die Ressourcen, auf die eine Bindungsgruppe verweist, nach der Erstellung nicht mehr ändern. Sie können jedoch den Inhalt dieser Ressourcen ändern. Wenn Sie beispielsweise den einheitlichen Puffer so ändern, dass er eine neue Rastergröße enthält, wird dies in zukünftigen Zeichenaufrufen mit dieser Bindungsgruppe berücksichtigt.

Bindungsgruppe binden

Nachdem die Bindungsgruppe erstellt wurde, müssen Sie WebGPU noch mitteilen, dass sie beim Zeichnen verwendet werden soll. Glücklicherweise ist das ganz einfach.

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

index.html

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

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

pass.draw(vertices.length / 2);

Die als erstes Argument übergebene 0 entspricht der @group(0) im Shader-Code. Sie geben an, dass jede @binding, die Teil von @group(0) ist, die Ressourcen in dieser Bindungsgruppe verwendet.

Der einheitliche Puffer wird jetzt für Ihren Shader verfügbar gemacht.

  1. Aktualisieren Sie die Seite. Sie sollte dann so aussehen:

Ein kleines rotes Quadrat in der Mitte eines dunkelblauen Hintergrunds.

Super! Dein Quadrat ist jetzt nur noch ein Viertel so groß wie zuvor. Das ist nicht viel, aber es zeigt, dass die Uniform tatsächlich angewendet wird und der Shader jetzt auf die Größe des Rasters zugreifen kann.

Geometrie im Shader bearbeiten

Da Sie jetzt die Rastergröße im Shader referenzieren können, können Sie die gerenderte Geometrie so bearbeiten, dass sie dem gewünschten Rastermuster entspricht. Überlegen Sie sich dazu genau, was Sie erreichen möchten.

Sie müssen die Arbeitsfläche konzeptionell in einzelne Zellen unterteilen. Damit die Konvention beibehalten wird, dass die X-Achse nach rechts und die Y-Achse nach oben zunimmt, gehen wir davon aus, dass sich die erste Zelle in der unteren linken Ecke des Arbeitsbereichs befindet. So sieht das Layout dann aus: Die aktuelle quadratische Geometrie befindet sich in der Mitte.

Abbildung des konzeptionellen Rasters, in das der normalisierte Gerätekoordinatenraum unterteilt wird, wenn jede Zelle mit der aktuell gerenderten quadratischen Geometrie in der Mitte visualisiert wird.

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

Zuerst sehen Sie, dass das Quadrat nicht richtig an einer der Zellen ausgerichtet ist, da es so definiert wurde, dass es das Zentrum des Arbeitsbereichs umgibt. Das Quadrat sollte um eine halbe Zelle verschoben werden, damit es genau in die Zellen passt.

Eine Möglichkeit, das Problem zu beheben, besteht darin, den Vertex-Puffer des Quadrats zu aktualisieren. Wenn Sie die Eckpunkte so verschieben, dass sich die untere linke Ecke beispielsweise an der Position (0,1, 0,1) anstatt an (-0,8, -0,8) befindet, wird das Quadrat besser an den Zellgrenzen ausgerichtet. Da Sie jedoch die volle Kontrolle darüber haben, wie die Eckpunkte in Ihrem Shader verarbeitet werden, können Sie sie mit dem Shader-Code ganz einfach an die richtige Position bringen.

  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 wird jeder Eckpunkt um eins nach oben und rechts verschoben (was, wie gesagt, die Hälfte des Clip-Space ist), bevor er durch die Rastergröße geteilt wird. Das Ergebnis ist ein quadratisches Rechteck, das sich direkt neben dem Ursprung befindet und gut am Raster ausgerichtet ist.

Eine Visualisierung der Arbeitsfläche, die konzeptionell in ein 4‑×‑4-Raster unterteilt ist, mit einem roten Quadrat in Zelle (2, 2)

Da sich der Ursprung (0, 0) des Koordinatensystems Ihres Canvas in der Mitte und die untere linke Ecke bei (-1, -1) befindet, Sie aber möchten, dass sich der Ursprung in der unteren linken Ecke befindet, müssen Sie die Position Ihrer Geometrie um (-1, -1) verschieben, nachdem Sie sie durch die Rastergröße geteilt haben.

  1. So übersetzen Sie die Position Ihrer Geometrie:

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

Ihr Quadrat befindet sich jetzt in Zelle (0, 0).

Eine Visualisierung der Leinwand, die konzeptionell in ein 4×4-Raster unterteilt ist, mit einem roten Quadrat in Zelle (0, 0)

Was ist, wenn Sie es in einer anderen Zelle platzieren möchten? Dazu deklarieren Sie in Ihrem Shader einen cell-Vektor und füllen ihn mit einem statischen Wert wie let cell = vec2f(1, 1).

Wenn Sie das der gridPos hinzufügen, wird die - 1 im Algorithmus rückgängig gemacht. Das ist also nicht das, was Sie möchten. Stattdessen soll das Quadrat für jede Zelle nur um eine Rastereinheit (ein Viertel der Arbeitsfläche) verschoben werden. Es sieht so aus, als müsstest du noch einmal durch grid teilen.

  1. So ändern Sie die Positionierung des Rasters:

index.html (createShaderModule-Aufruf)

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

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

  let cell = vec2f(1, 1); // Cell(1,1) in the image above
  let cellOffset = cell / grid; // Compute the offset to cell
  let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!

  return vec4f(gridPos, 0, 1);
}

Wenn Sie die Seite jetzt aktualisieren, sehen Sie Folgendes:

Eine Visualisierung des Canvas, der konzeptionell in ein 4 × 4-Raster unterteilt ist. Ein rotes Quadrat ist in der Mitte zwischen Zelle (0, 0), Zelle (0, 1), Zelle (1, 0) und Zelle (1, 1) zentriert.

Hm. Das ist nicht ganz das, was Sie wollten.

Das liegt daran, dass die Canvas-Koordinaten von -1 bis +1 reichen, also tatsächlich 2 Einheiten breit sind. Wenn Sie einen Knoten also um ein Viertel des Canvas verschieben möchten, müssen Sie ihn um 0,5 Einheiten verschieben. Das ist ein häufiger Fehler, wenn man mit GPU-Koordinaten arbeitet. Glücklicherweise ist die Lösung genauso einfach.

  1. Multiplizieren Sie den Offset mit 2:

index.html (createShaderModule-Aufruf)

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

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

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

  return vec4f(gridPos, 0, 1);
}

So erhalten Sie genau das, was Sie möchten.

Visualisierung des Arbeitsbereichs, der konzeptionell in ein 4 × 4-Raster unterteilt ist, mit einem roten Quadrat in Zelle (1, 1)

Der Screenshot sieht so aus:

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

Außerdem können Sie cell jetzt auf einen beliebigen Wert innerhalb der Rastergrenzen festlegen und dann die Seite aktualisieren, um das Quadrat an der gewünschten Stelle zu rendern.

Zeichnung erstellen

Nachdem Sie das Quadrat mit etwas 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 besteht darin, Zellkoordinaten in einen einheitlichen Puffer zu schreiben und dann draw einmal für jedes Quadrat im Raster aufzurufen und den Uniform jedes Mal zu aktualisieren. Das wäre jedoch sehr langsam, da die GPU jedes Mal auf das Schreiben der neuen Koordinate durch JavaScript warten muss. Einer der wichtigsten Faktoren für eine gute GPU-Leistung ist, dass die GPU möglichst wenig Zeit mit Warten auf andere Teile des Systems verbringt.

Stattdessen können Sie die sogenannte Instanziierung verwenden. Durch Instanziierung wird die GPU angewiesen, mehrere Kopien derselben Geometrie mit einem einzigen Aufruf von draw zu zeichnen. Das ist viel schneller, als draw für jede Kopie einmal aufzurufen. Jede Kopie der Geometrie wird als Instanz bezeichnet.

  1. Damit die GPU weiß, dass Sie genügend Instanzen Ihres Quadrats benötigen, um das Raster zu füllen, fügen Sie Ihrem vorhandenen Zeichenaufruf ein Argument hinzu:

index.html

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

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

Ein identisches Bild wie im vorherigen Diagramm, um zu zeigen, dass sich nichts geändert hat.

Warum? Das liegt daran, dass du alle 16 Quadrate an derselben Stelle zeichnest. Sie benötigen zusätzliche Logik im Shader, mit der die Geometrie pro Instanz neu positioniert wird.

Im Shader können Sie neben den Vertex-Attributen wie pos, die aus Ihrem Vertex-Puffer stammen, auch auf die integrierten Werte von WGSL zugreifen. Diese Werte werden von WebGPU berechnet. Ein solcher Wert ist instance_index. instance_index ist eine vorzeichenlose 32-Bit-Zahl zwischen 0 und number of instances - 1, die Sie als Teil Ihrer Shader-Logik verwenden können. Der Wert ist für jeden verarbeiteten Knoten, der zur selben Instanz gehört, gleich. Das bedeutet, dass Ihr Vertex-Shader sechsmal mit einem instance_index von 0 aufgerufen wird, einmal für jede Position in Ihrem Vertex-Puffer. Wiederholen Sie den Vorgang dann sechsmal mit einem instance_index von 1, sechsmal mit einem instance_index von 2 usw.

Um das in Aktion zu sehen, müssen Sie das instance_index-Built-in zu Ihren Shader-Eingaben hinzufügen. Gehen Sie dabei genauso vor wie bei der Position. Verwenden Sie aber anstelle des Attributs @location das Attribut @builtin(instance_index) und benennen Sie das Argument beliebig. Sie können sie instance nennen, damit sie mit dem Beispielcode übereinstimmt. Verwenden Sie es dann als Teil der Shader-Logik.

  1. Verwenden Sie instance anstelle der Zellkoordinaten:

index.html

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

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {
 
  let i = f32(instance); // Save the instance_index as a float
  let cell = vec2f(i, i); // Updated
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

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

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

Das liegt daran, dass die von Ihnen generierten Zellkoordinaten (0, 0), (1, 1), (2, 2) … bis (15, 15) lauten, aber nur die ersten vier davon auf die Arbeitsfläche passen. Um das gewünschte Raster zu erstellen, müssen Sie die instance_index so transformieren, dass jeder Index einer eindeutigen Zelle im Raster zugeordnet wird:

Eine Visualisierung des Arbeitsbereichs, der konzeptionell in ein 4×4-Raster unterteilt ist, wobei jede Zelle auch einem linearen Instanzindex entspricht.

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

  1. Ändern Sie die Berechnungen so:

index.html

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

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

  let i = f32(instance);
  // Compute the cell coordinate from the instance_index
  let cell = vec2f(i % grid.x, floor(i / grid.x));

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

  return vec4f(gridPos, 0, 1);
}

Nachdem Sie diese Änderung am Code vorgenommen haben, sehen Sie endlich das lang ersehnte Raster aus Quadraten.

Vier Zeilen mit vier Spalten mit roten Quadraten auf einem dunkelblauen Hintergrund.

  1. Jetzt, da es funktioniert, können Sie die Rastergröße wieder erhöhen.

index.html

const GRID_SIZE = 32;

32 Zeilen mit 32 Spalten mit roten Quadraten auf einem dunkelblauen Hintergrund.

Tada! Sie können dieses Raster jetzt wirklich groß machen und Ihre durchschnittliche GPU kommt damit gut zurecht. Die einzelnen Quadrate verschwinden, lange bevor es zu Engpässen bei der GPU-Leistung kommt.

6. Zusatzaufgabe: Gestalte das Bild noch farbenfroher.

An dieser Stelle können Sie einfach zum nächsten Abschnitt springen, da Sie die Grundlagen für den Rest des Codelabs gelegt haben. Das Raster aus Quadraten mit derselben Farbe ist zwar brauchbar, aber nicht gerade aufregend. Glücklicherweise können Sie die Dinge mit etwas mehr Mathematik und Shader-Code etwas heller gestalten.

Strukturen in Shadern verwenden

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

Die einzige Möglichkeit, Daten aus dem Vertex-Shader zu übergeben, besteht darin, sie zurückzugeben. Ein Vertex-Shader muss immer eine Position zurückgeben. Wenn Sie also andere Daten zusammen mit der Position zurückgeben möchten, müssen Sie sie in ein Struct einfügen. Structs in WGSL sind benannte Objekttypen, die ein oder mehrere benannte Attribute enthalten. Die Eigenschaften können auch mit Attributen wie @builtin und @location gekennzeichnet werden. Sie deklarieren sie außerhalb von Funktionen und können dann Instanzen davon nach Bedarf in Funktionen übergeben und aus Funktionen abrufen. Sehen Sie sich beispielsweise Ihren aktuellen Vertex-Shader an:

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);
}
  • Dasselbe mit Strukturen für die Funktions-Ein- und Ausgabe ausdrücken:

index.html (createShaderModule-Aufruf)

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

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

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

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

Dazu müssen Sie mit input auf die Eingabeposition und den Instanzindex verweisen. Die Struktur, die Sie zurückgeben, muss zuerst als Variable deklariert und ihre einzelnen Eigenschaften festgelegt werden. In diesem Fall macht es keinen großen Unterschied und die Shader-Funktion wird sogar etwas länger. Wenn Ihre Shader jedoch komplexer werden, kann die Verwendung von Strukturen eine gute Möglichkeit sein, Ihre Daten zu organisieren.

Daten zwischen den Vertex- und Fragmentfunktionen übergeben

Zur Erinnerung: Ihre @fragment-Funktion ist so einfach wie möglich:

index.html (createShaderModule-Aufruf)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1);
}

Sie nehmen keine Eingaben entgegen und geben eine einfarbige (rote) Ausgabe aus. Wenn der Shader jedoch mehr über die Geometrie wüsste, die er einfärbt, könnten Sie diese zusätzlichen Daten verwenden, um die Dinge etwas interessanter zu gestalten. Was ist beispielsweise, wenn Sie die Farbe jedes Quadrats basierend auf seiner Zellkoordinate ändern möchten? In der Phase @vertex wird ermittelt, welche Zelle gerendert wird. Sie müssen sie nur an die Phase @fragment übergeben.

Wenn Sie Daten zwischen den Vertex- und Fragmentphasen übergeben möchten, müssen Sie sie in eine Ausgabestruktur mit einem @location Ihrer Wahl aufnehmen. Da Sie die Zellkoordinate übergeben möchten, fügen Sie sie dem VertexOutput-Struct aus dem vorherigen Schritt hinzu und legen Sie sie dann in der Funktion @vertex fest, bevor Sie sie zurückgeben.

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

index.html (createShaderModule-Aufruf)

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

struct VertexOutput {
  @builtin(position) pos: vec4f,
  @location(0) cell: vec2f, // New line!
};

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

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
 
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell; // New line!
  return output;
}
  1. In der Funktion @fragment wird der Wert empfangen, indem ein Argument mit demselben @location hinzugefügt wird. Die Namen müssen nicht übereinstimmen, aber es ist einfacher, den Überblick zu behalten, wenn sie es tun.

index.html (createShaderModule-Aufruf)

@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
  // Remember, fragment return values are (Red, Green, Blue, Alpha)
  // and since cell is a 2D vector, this is equivalent to:
  // (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
  return vec4f(cell, 0, 1);
}
  1. Alternativ können Sie stattdessen 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 besteht darin, die Ausgabestruktur der @vertex-Phase wiederzuverwenden, da beide Funktionen in Ihrem Code im selben Shader-Modul definiert sind. Das Übergeben von Werten ist so ganz einfach, da die Namen und Speicherorte natürlich konsistent sind.

index.html (createShaderModule-Aufruf)

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

Unabhängig davon, welches Muster Sie ausgewählt haben, haben Sie Zugriff auf die Zellennummer in der Funktion @fragment und können sie verwenden, um die Farbe zu beeinflussen. Bei jedem der oben genannten Codes sieht die Ausgabe so aus:

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

Es gibt jetzt definitiv mehr Farben, aber es sieht nicht gerade schön aus. Sie fragen sich vielleicht, warum sich nur die linke und die untere Zeile unterscheiden. Das liegt daran, dass die Farbwerte, die Sie von der Funktion @fragment zurückgeben, für jeden Channel im Bereich von 0 bis 1 liegen müssen. Alle Werte außerhalb dieses Bereichs werden auf diesen Bereich begrenzt. Ihre Zellenwerte reichen dagegen entlang jeder Achse von 0 bis 32. In der ersten Zeile und Spalte wird also sofort der volle Wert 1 für den roten oder grünen Farbkanal erreicht und jede Zelle danach wird auf denselben Wert begrenzt.

Wenn Sie einen sanfteren Übergang zwischen den Farben wünschen, müssen Sie für jeden Farbkanal einen Bruchwert zurückgeben, der idealerweise entlang jeder Achse bei null beginnt und bei eins endet. Das bedeutet, dass Sie noch einmal durch grid teilen müssen.

  1. Ändern Sie den Fragment-Shader so:

index.html (createShaderModule-Aufruf)

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

Wenn Sie die Seite aktualisieren, sehen Sie, dass der neue Code tatsächlich einen viel schöneren Farbverlauf über das gesamte Raster hinweg erzeugt.

Ein Raster aus Quadraten, deren Farbe sich in verschiedenen Ecken von Schwarz über Rot und Grün zu Gelb ändert.

Das ist zwar eine Verbesserung, aber jetzt gibt es unten links eine unschöne dunkle Ecke, in der das Raster schwarz wird. Wenn Sie mit der Simulation des Game of Life beginnen, wird ein schwer zu erkennender Bereich des Rasters verdeckt. Es wäre schön, wenn wir das ändern könnten.

Glücklicherweise haben Sie einen völlig ungenutzten Farbkanal – Blau –, den Sie verwenden können. Im Idealfall soll das Blau dort am hellsten sein, wo die anderen Farben am dunkelsten sind, und dann mit zunehmender Intensität der anderen Farben verblassen. Am einfachsten geht das, wenn Sie den Channel start auf 1 setzen und einen der Zellenwerte subtrahieren. Er kann entweder c.x oder c.y sein. Probieren Sie beide aus und wählen Sie dann die gewünschte Option aus.

  1. Fügen Sie dem Fragment-Shader hellere Farben hinzu, z. B. so:

createShaderModule-Aufruf

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  let c = input.cell / grid;
  return vec4f(c, 1-c.x, 1);
}

Das Ergebnis sieht gut aus.

Ein Raster aus Quadraten, die in verschiedenen Ecken von Rot über Grün und Blau zu Gelb übergehen.

Das ist kein kritischer Schritt. Da es aber besser aussieht, wurde es in die entsprechende Checkpoint-Quelldatei aufgenommen. Die restlichen Screenshots in diesem Codelab zeigen dieses farbenfrohe Raster.

7. Zellenstatus verwalten

Als Nächstes müssen Sie festlegen, welche Zellen im Raster gerendert werden sollen. Das hängt von einem Status ab, der auf der GPU gespeichert ist. Das ist wichtig für die endgültige Simulation.

Sie benötigen lediglich ein Ein/Aus-Signal für jede Zelle. Daher sind alle Optionen geeignet, mit denen Sie ein großes Array mit fast jedem Werttyp speichern können. Vielleicht denken Sie, dass dies ein weiterer Anwendungsfall für einheitliche Puffer ist. Das ist zwar möglich, aber schwieriger, da Uniform-Puffer in der Größe begrenzt sind, keine dynamisch dimensionierten Arrays unterstützen (Sie müssen die Arraygröße im Shader angeben) und nicht von Compute-Shadern geschrieben werden können. Der 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 sich all diese Einschränkungen vermeiden lassen.

Speicherpuffer erstellen

Speicherpuffer sind Puffer für den allgemeinen Gebrauch, in die in Compute-Shadern gelesen und geschrieben werden kann und die in Vertex-Shadern gelesen werden können. Sie können sehr groß sein und benötigen keine bestimmte deklarierte Größe in einem Shader, wodurch sie eher wie allgemeiner Speicher funktionieren. Damit speichern Sie den Zellstatus.

  1. Um einen Speicherpuffer für den Zellstatus zu erstellen, verwenden Sie den folgenden Code, der Ihnen inzwischen wahrscheinlich vertraut ist:

index.html

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

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

Rufen Sie wie bei Ihren Vertex- und Uniform-Puffern device.createBuffer() mit der entsprechenden Größe auf und geben Sie dann dieses Mal die Verwendung 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, sollten Sie ihn zuerst mit etwas Vorhersehbarem füllen.

  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 den Shader, damit er sich den Inhalt des Speicherpuffers ansieht, bevor das Raster gerendert wird. Das ähnelt sehr dem bisherigen Vorgehen beim Hinzufügen von Uniformen.

  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 Bindungspunkt hinzu, der direkt unter dem Raster platziert wird. Sie möchten die @group beibehalten, damit die grid einheitlich sind, aber die @binding-Nummer muss sich unterscheiden. Der Typ var ist storage, um den unterschiedlichen Puffertyp widerzuspiegeln. Anstelle eines einzelnen Vektors ist der Typ, den Sie für cellState angeben, ein Array von u32-Werten, um mit Uint32Array in JavaScript übereinzustimmen.

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

Wie schalte ich eine Zelle aus, wenn sie inaktiv ist? Da die aktiven und inaktiven Zustände, die Sie aus dem Array erhalten, 1 oder 0 sind, können Sie die Geometrie anhand des aktiven Zustands skalieren. Wenn Sie sie mit 1 skalieren, bleibt die Geometrie unverändert. Wenn Sie sie mit 0 skalieren, wird die Geometrie auf einen einzelnen Punkt reduziert, den die GPU dann verwirft.

  1. Aktualisieren Sie den Shader-Code, um die Position anhand des aktiven Status der Zelle zu skalieren. Der Statuswert muss in ein f32 umgewandelt werden, um die Typsicherheitsanforderungen von WGSL zu erfüllen:

index.html

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> VertexOutput {
  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let state = f32(cellState[instance]); // New line!

  let cellOffset = cell / grid * 2;
  // New: Scale the position by the cell's active state.
  let gridPos = (pos*state+1) / grid - 1 + cellOffset;

  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell;
  return output;
}

Speicherpuffer der Bindungsgruppe hinzufügen

Bevor Sie sehen können, dass der Zellstatus wirksam wird, müssen Sie den Speicherpuffer einer Bindungsgruppe hinzufügen. Da sie Teil desselben @group wie der einheitliche Puffer ist, fügen Sie sie auch im JavaScript-Code in dieselbe Bindungsgruppe ein.

  • Fügen Sie den Speicherpuffer so hinzu:

index.html

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

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

Danach sollten Sie die Seite aktualisieren und das Muster im Raster sehen können.

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

Ping-Pong-Puffer verwenden

Die meisten Simulationen wie die, die Sie erstellen, verwenden in der Regel mindestens zwei Kopien ihres Status. Bei jedem Schritt der Simulation lesen sie aus einer Kopie des Status und schreiben in die andere. Im nächsten Schritt wird die Rolle dann umgedreht und die KI liest aus dem Zustand, in den sie zuvor geschrieben hat. Dies wird häufig als Ping-Pong-Muster bezeichnet, da die aktuelle Version des Status bei jedem Schritt zwischen den Statuskopien hin- und herwechselt.

Warum ist das notwendig? Sehen wir uns ein vereinfachtes Beispiel an: Stellen Sie sich vor, Sie schreiben eine sehr einfache Simulation, in der Sie alle aktiven Blöcke in jedem Schritt um eine Zelle nach rechts verschieben. Um die Dinge leicht verständlich zu halten, definieren Sie Ihre Daten und Simulation in JavaScript:

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

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

simulate(); // Run the simulation for one step.

Wenn Sie diesen Code ausführen, wird die aktive Zelle jedoch in einem Schritt bis zum Ende des Arrays verschoben. Warum? Da Sie den Status direkt aktualisieren, verschieben Sie die aktive Zelle nach rechts und sehen sich dann die nächste Zelle an – und siehe da! Es ist aktiv. Beweg es lieber wieder nach rechts. Wenn Sie die Daten gleichzeitig mit der Beobachtung ändern, werden die Ergebnisse verfälscht.

Durch die Verwendung des Ping-Pong-Musters wird sichergestellt, dass Sie den nächsten Schritt der Simulation nur mit den Ergebnissen des letzten Schritts ausführen.

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

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

// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA);
  1. Verwenden Sie dieses Muster in Ihrem eigenen Code, indem Sie die Zuweisung des Speicherpuffers aktualisieren, um zwei identische Puffer 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 veranschaulichen, füllen Sie sie mit unterschiedlichen Daten:

index.html

// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
  cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
  1. Wenn Sie die verschiedenen Speicherpuffer in Ihrem Rendering anzeigen möchten, müssen Sie Ihre Bindungsgruppen so aktualisieren, dass sie ebenfalls 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] }
    }],
  })
];

Render-Loop einrichten

Bisher haben Sie nur einen Draw pro Seitenaktualisierung durchgeführt. Jetzt möchten Sie aber sehen, wie sich die Daten im Zeitverlauf aktualisieren. Dazu benötigen Sie eine einfache Rendering-Schleife.

Ein Render-Loop ist eine Endlosschleife, die Ihre Inhalte in einem bestimmten Intervall auf die Arbeitsfläche zeichnet. Viele Spiele und andere Inhalte, die flüssig animiert werden sollen, verwenden die Funktion requestAnimationFrame(), um Rückrufe mit derselben Rate zu planen, mit der der Bildschirm aktualisiert wird (60 Mal pro Sekunde).

Diese App kann das auch, aber in diesem Fall möchten Sie wahrscheinlich, dass Updates in längeren Schritten erfolgen, damit Sie leichter nachvollziehen können, was in der Simulation passiert. Verwalten Sie die Schleife stattdessen selbst, damit Sie die Geschwindigkeit steuern können, mit der Ihre Simulation aktualisiert wird.

  1. Wählen Sie zuerst eine Rate für die Aktualisierung unserer Simulation aus (200 ms sind gut, aber Sie können auch langsamer oder schneller vorgehen). Behalten Sie dann im Blick, wie viele Simulationsschritte abgeschlossen wurden.

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. Verschieben Sie dann den gesamten Code, den Sie derzeit für das Rendern verwenden, in eine neue Funktion. Planen Sie mit setInterval(), dass die Funktion in dem von Ihnen gewünschten Intervall wiederholt wird. Achte darauf, dass die Funktion auch die Schrittzahl aktualisiert, und verwende sie, 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 die App jetzt ausführen, sehen Sie, dass die Leinwand zwischen den beiden von Ihnen erstellten Statuspuffern hin- und herwechselt.

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

Damit sind Sie mit dem Rendern so gut wie fertig. Jetzt können Sie die Ausgabe der Game of Life-Simulation anzeigen, die Sie im nächsten Schritt erstellen. Dort verwenden Sie dann auch Compute-Shader.

Die Rendering-Funktionen von WebGPU sind natürlich viel umfangreicher als das, was Sie hier gesehen haben. Der Rest geht jedoch über den Rahmen dieses Codelabs hinaus. Wir hoffen, dass Sie dadurch einen guten Eindruck davon bekommen haben, wie das Rendern mit WebGPU funktioniert, und dass es Ihnen hilft, komplexere Techniken wie das 3D-Rendern besser zu verstehen.

8. Simulation ausführen

Nun zum letzten wichtigen Puzzleteil: der Game of Life-Simulation in einem Compute Shader.

Endlich Compute-Shader verwenden

In diesem Codelab haben Sie abstrakt etwas über Compute-Shader gelernt. Aber was genau sind sie?

Ein Compute-Shader ähnelt Vertex- und Fragment-Shadern insofern, als er für die Ausführung mit extremem Parallelismus auf der GPU konzipiert ist. Im Gegensatz zu den anderen beiden Shader-Phasen hat er jedoch keine bestimmte Menge an Ein- und Ausgaben. Sie lesen und schreiben Daten ausschließlich aus von Ihnen ausgewählten Quellen wie Speicherpuffern. Das bedeutet, dass Sie nicht für jeden Vertex, jede Instanz oder jedes Pixel eine Ausführung durchführen, sondern angeben müssen, wie viele Aufrufe der Shader-Funktion Sie wünschen. Wenn Sie den Shader dann ausführen, wird Ihnen mitgeteilt, welche Aufrufung verarbeitet wird. Sie können dann entscheiden, auf welche Daten Sie zugreifen und welche Vorgänge Sie von dort aus ausführen möchten.

Compute-Shader müssen in einem Shader-Modul erstellt werden, genau wie Vertex- und Fragment-Shader. Fügen Sie das also zu Ihrem Code hinzu, um loszulegen. Wie Sie vielleicht schon vermuten, muss die Hauptfunktion für Ihren Compute-Shader mit dem Attribut @compute gekennzeichnet werden, wenn Sie die Struktur der anderen Shader berücksichtigen, die Sie implementiert haben.

  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. So können Sie ganz einfach Arbeit zuweisen, die einem 2D- oder 3D-Raster entspricht. Das ist ideal für Ihren Anwendungsfall. Sie möchten diesen Shader GRID_SIZE-mal GRID_SIZE-mal aufrufen, einmal für jede Zelle Ihrer Simulation.

Aufgrund der GPU-Hardwarearchitektur ist dieses Raster in Arbeitsgruppen unterteilt. Eine Arbeitsgruppe hat die Größe X, Y und Z. Obwohl die Größen jeweils 1 sein können, bietet es oft Leistungsvorteile, die Arbeitsgruppen etwas größer zu machen. Wählen Sie für Ihren Shader eine etwas beliebige Arbeitsgruppengröße von 8 × 8 aus. Das ist nützlich, um den Überblick in Ihrem JavaScript-Code zu behalten.

  1. Definieren Sie eine Konstante für die Größe Ihrer Arbeitsgruppe, z. B. so:

index.html

const WORKGROUP_SIZE = 8;

Außerdem müssen Sie die Größe der Arbeitsgruppe in die Shader-Funktion selbst einfügen. Dazu verwenden Sie JavaScript-Vorlagenliterale, damit Sie die gerade definierte Konstante einfach verwenden können.

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

index.html (Compute createShaderModule call)

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

}

Damit wird dem Shader mitgeteilt, dass die mit dieser Funktion ausgeführten Vorgänge in Gruppen von (8 × 8 × 1) erfolgen. Für jede Achse, die Sie nicht angeben, wird standardmäßig 1 verwendet. Sie müssen jedoch mindestens die X-Achse angeben.

Wie bei den anderen Shader-Phasen gibt es eine Vielzahl von @builtin-Werten, die Sie als Eingabe für Ihre Compute-Shader-Funktion akzeptieren können, um zu erfahren, welcher Aufruf gerade ausgeführt wird, und zu entscheiden, welche Arbeit Sie ausführen müssen.

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

index.html (Compute createShaderModule call)

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

}

Sie übergeben die integrierte Variable global_invocation_id, einen dreidimensionalen Vektor aus vorzeichenlosen Ganzzahlen, der angibt, wo Sie sich im Raster der Shader-Aufrufe befinden. Sie führen diesen Shader einmal für jede Zelle im Raster aus. Sie erhalten Zahlen wie (0, 0, 0), (1, 0, 0), (1, 1, 0) … bis hin zu (31, 31, 0). Das bedeutet, dass Sie sie als den Zellindex behandeln können, mit dem Sie arbeiten werden.

In Compute-Shadern können auch Uniforms verwendet werden, die Sie genau wie in den Vertex- und Fragment-Shadern verwenden.

  1. Verwenden Sie eine Uniform mit Ihrem Compute-Shader, um die Rastergröße anzugeben, wie hier:

index.html (Compute createShaderModule call)

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

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

}

Genau wie im Vertex-Shader stellen Sie den Zellstatus auch als Speicherpuffer bereit. In diesem Fall benötigen Sie jedoch zwei davon. Da für Compute-Shader keine erforderliche Ausgabe wie eine Vertex-Position oder Fragmentfarbe vorhanden ist, ist das Schreiben von Werten in einen Speicherpuffer oder eine Textur die einzige Möglichkeit, Ergebnisse aus einem Compute-Shader zu erhalten. Verwenden Sie die Ping-Pong-Methode, die Sie bereits kennengelernt haben. Sie haben einen Speicherpuffer, in den der aktuelle Zustand des Rasters eingespeist wird, und einen, in den Sie den neuen Zustand des Rasters schreiben.

  1. Zelleneingabe und ‑ausgabe als Speicherpuffer verfügbar machen:

index.html (Compute createShaderModule call)

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

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

}

Der erste Speicherpuffer wird mit var<storage> deklariert, wodurch er schreibgeschützt wird. Der zweite Speicherpuffer wird jedoch mit var<storage, read_write> deklariert. So können Sie sowohl in den Puffer schreiben als auch daraus lesen und ihn als Ausgabe für Ihren Compute-Shader verwenden. (In WebGPU gibt es keinen Nur-Schreib-Speichermodus.)

Als Nächstes benötigen Sie eine Möglichkeit, den Zellenindex dem linearen Speicher-Array zuzuordnen. Das ist im Grunde das Gegenteil von dem, was Sie im Vertex-Shader gemacht haben, wo Sie den linearen instance_index auf eine 2D-Rasterzelle abgebildet haben. Zur Erinnerung: Ihr Algorithmus dafür war vec2f(i % grid.x, floor(i / grid.x)).

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

index.html (Compute createShaderModule call)

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

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

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

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

Um zu sehen, ob es funktioniert, implementieren Sie einen ganz einfachen Algorithmus: Wenn eine Zelle gerade aktiviert ist, wird sie deaktiviert und umgekehrt. Es ist noch nicht das Game of Life, aber es reicht, um zu zeigen, dass der Compute Shader funktioniert.

  1. Fügen Sie den einfachen Algorithmus so hinzu:

index.html (Compute createShaderModule call)

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

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
   
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines. Flip the cell state every step.
  if (cellStateIn[cellIndex(cell.xy)] == 1) {
    cellStateOut[cellIndex(cell.xy)] = 0;
  } else {
    cellStateOut[cellIndex(cell.xy)] = 1;
  }
}

Das war es erst einmal mit dem Compute Shader. Bevor Sie die Ergebnisse sehen können, müssen Sie jedoch noch einige Änderungen vornehmen.

Bind-Gruppen und Pipeline-Layouts verwenden

Ein wichtiger Punkt ist, dass für den oben genannten Shader größtenteils dieselben Eingaben (Uniforms und Speicherpuffer) wie für Ihre Rendering-Pipeline verwendet werden. Sie könnten also denken, dass Sie einfach dieselben Bindungsgruppen verwenden können, oder? Die gute Nachricht ist, dass Sie das können. Dazu ist nur etwas mehr manuelle Einrichtung erforderlich.

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

Zur besseren Verständlichkeit: In Ihren Render-Pipelines verwenden Sie einen einzelnen Uniform-Puffer und einen einzelnen Speicherpuffer, aber im Compute-Shader, den Sie gerade geschrieben haben, benötigen Sie einen zweiten Speicherpuffer. Da die beiden Shader dieselben @binding-Werte für den Uniform- und den ersten Speicherpuffer verwenden, können Sie diese zwischen Pipelines freigeben. Die Render-Pipeline ignoriert den zweiten Speicherpuffer, da sie ihn nicht verwendet. Sie möchten ein Layout erstellen, in dem alle Ressourcen beschrieben werden, die in der Bindungsgruppe vorhanden sind, 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 dem Erstellen der Bindungsgruppe selbst, da Sie eine Liste von entries beschreiben. Der Unterschied besteht darin, dass Sie beschreiben, welche Art von Ressource 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) dem @binding-Wert in den Shadern entspricht. Sie geben auch die visibility an, die GPUShaderStage-Flags sind, die angeben, welche Shader-Phasen die Ressource verwenden können. Sie möchten, dass sowohl der einheitliche als auch der erste Speicherpuffer in den Vertex- und Compute-Shadern zugänglich sind, der zweite Speicherpuffer jedoch nur in Compute-Shadern.

Geben Sie zum Schluss an, welche Art von Ressource verwendet wird. Dies ist ein anderer Wörterbuchschlüssel, je nachdem, was Sie verfügbar machen müssen. Hier sind alle drei Ressourcen Puffer. Sie verwenden also den Schlüssel buffer, um die Optionen für die einzelnen Ressourcen zu definieren. Andere Optionen sind z. B. texture oder sampler, aber diese sind hier nicht erforderlich.

Im Puffer-Dictionary legen Sie Optionen fest, z. B. welcher type des Puffers verwendet wird. Der Standardwert ist "uniform". Sie können das Dictionary für die Bindung 0 also leer lassen. Sie müssen jedoch mindestens buffer: {} festlegen, damit der Eintrag als Puffer identifiziert wird. Bindung 1 hat den Typ "read-only-storage", da sie im Shader nicht mit read_write-Zugriff verwendet wird. Bindung 2 hat den Typ "storage", da sie mit read_write-Zugriff verwendet wird.

Sobald die bindGroupLayout erstellt wurde, können Sie sie beim Erstellen Ihrer Bindungsgruppen übergeben, anstatt die Bindungsgruppe aus der Pipeline abzufragen. Dazu müssen Sie jeder Bindungsgruppe einen neuen Speicherpuffereintrag hinzufügen, damit das von Ihnen definierte Layout berücksichtigt wird.

  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 so aktualisiert wurde, dass dieses explizite Bindungsgruppenlayout verwendet wird, müssen Sie die Rendering-Pipeline entsprechend aktualisieren.

  1. Erstellen Sie einen 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, damit sie anstelle von "auto" verwendet wird.

index.html

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

Compute-Pipeline erstellen

So wie Sie eine Rendering-Pipeline benötigen, um Ihre Vertex- und Fragment-Shader zu verwenden, benötigen Sie eine Compute-Pipeline, um Ihren Compute-Shader zu verwenden. Glücklicherweise sind Compute-Pipelines viel weniger kompliziert als Render-Pipelines, da kein Status festgelegt werden muss, sondern nur der Shader und das Layout.

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

index.html

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

Beachten Sie, dass Sie das neue pipelineLayout anstelle von "auto" übergeben, genau wie in der aktualisierten Rendering-Pipeline. So können sowohl Ihre Rendering-Pipeline als auch Ihre Compute-Pipeline dieselben Bindungsgruppen verwenden.

Compute-Guthaben

Jetzt können Sie die Compute-Pipeline nutzen. Da Sie das Rendern in einem Render-Pass durchführen, können Sie wahrscheinlich erraten, dass Sie Berechnungen in einem Compute-Pass durchführen müssen. Berechnungs- und Rendering-Vorgänge können beide im selben Befehlscoder erfolgen. Daher sollten Sie die updateGrid-Funktion etwas umstellen.

  1. Verschieben Sie die Encodererstellung an den Anfang der Funktion und starten Sie dann einen Compute-Pass damit (vor dem step++).

index.html

// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

// Existing lines
step++; // Increment the step count
 
// Start a render pass...

Wie Compute-Pipelines sind Compute-Passes viel einfacher zu starten als ihre Rendering-Pendants, da Sie sich keine Gedanken über Anhänge machen müssen.

Sie möchten den Compute-Pass vor dem Render-Pass ausführen, da der Render-Pass so sofort die neuesten Ergebnisse des Compute-Pass verwenden kann. Das ist auch der Grund, warum Sie die step-Anzahl zwischen den Durchläufen erhöhen, damit der Ausgabepuffer der Compute-Pipeline zum Eingabepuffer für die Render-Pipeline wird.

  1. Als Nächstes legen Sie die Pipeline und die Bindungsgruppe im Compute-Pass fest. Verwenden Sie dabei dasselbe Muster für den Wechsel zwischen Bindungsgruppen wie für den Rendering-Pass.

index.html

const computePass = encoder.beginComputePass();

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

computePass.end();
  1. Anstatt wie in einem Render-Pass zu zeichnen, wird die Arbeit an den Compute-Shader gesendet und es wird angegeben, wie viele Arbeitsgruppen auf jeder Achse ausgeführt werden sollen.

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

Ganz wichtig: Die Zahl, die Sie an dispatchWorkgroups() übergeben, ist nicht die Anzahl der Aufrufe. Stattdessen ist es die Anzahl der auszuführenden Arbeitsgruppen, wie durch @workgroup_size in Ihrem Shader definiert.

Wenn der Shader 32 × 32-mal ausgeführt werden soll, um das gesamte Raster abzudecken, und die Arbeitsgruppengröße 8 × 8 beträgt, müssen Sie 4 × 4 Arbeitsgruppen senden (4 × 8 = 32). Deshalb teilen Sie die Rastergröße durch die Workgroup-Größe und übergeben diesen Wert an dispatchWorkgroups().

Wenn Sie die Seite jetzt noch einmal aktualisieren, sollte sich das Raster bei jeder Aktualisierung umkehren.

Diagonale Streifen aus bunten Quadraten, die vor einem dunkelblauen Hintergrund von unten links nach oben rechts verlaufen. Diagonale Streifen aus bunten Quadraten, die zwei Quadrate breit sind und von links unten nach rechts oben verlaufen, vor einem dunkelblauen Hintergrund. Die Inversion des vorherigen Bildes.

Algorithmus für das Game of Life implementieren

Bevor Sie den Compute-Shader aktualisieren, um den endgültigen Algorithmus zu implementieren, sollten Sie zum Code zurückkehren, mit dem der Inhalt des Speicherpuffers initialisiert wird, und ihn so aktualisieren, dass bei jedem Seitenaufruf ein zufälliger Puffer erstellt wird. Regelmäßige Muster sind keine sehr interessanten Ausgangspunkte für das Game of Life. Sie können die Werte beliebig randomisieren. Es gibt jedoch eine einfache Methode, mit der Sie gute Ergebnisse erzielen.

  1. Wenn jede Zelle in einem zufälligen Zustand beginnen soll, aktualisieren Sie die cellStateArray-Initialisierung mit dem folgenden Code:

index.html

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

Jetzt können Sie endlich die Logik für die Game of Life-Simulation implementieren. Nach all dem Aufwand, der nötig war, um hierher zu gelangen, ist der Shader-Code möglicherweise enttäuschend einfach.

Zuerst müssen Sie für jede Zelle wissen, wie viele ihrer Nachbarn aktiv sind. Sie möchten nicht wissen, welche Konten aktiv sind, sondern nur die Anzahl.

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

index.html (Compute createShaderModule call)

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

Die Funktion cellActive gibt „1“ 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 die Anzahl der aktiven Nachbarzellen.

  1. So ermitteln Sie die Anzahl der aktiven Nachbarn:

index.html (Compute createShaderModule call)

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

Das führt jedoch zu einem kleinen Problem: Was passiert, wenn sich die Zelle, die Sie prüfen, am Rand des Spielfelds befindet? Gemäß Ihrer aktuellen cellIndex()-Logik wird entweder in die nächste oder vorherige Zeile überlaufen oder der Puffer wird überschritten.

Beim Game of Life ist eine gängige und einfache Lösung, dass Zellen am Rand des Rasters Zellen am gegenüberliegenden Rand des Rasters als Nachbarn betrachten, wodurch eine Art Wrap-around-Effekt entsteht.

  1. Unterstützung für das Umbrechen von Rastern durch eine geringfügige Änderung der Funktion cellIndex().

index.html (Compute createShaderModule call)

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

Wenn Sie den Operator % verwenden, um die Zellen X und Y zu umschließen, wenn sie über die Rastergröße hinausgehen, stellen Sie sicher, dass Sie nie außerhalb der Grenzen des Speicherpuffers zugreifen. So können Sie sicher sein, dass die Anzahl der 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.

Das lässt sich mit einer Reihe von if-Anweisungen erreichen. WGSL unterstützt aber auch switch-Anweisungen, die sich gut für diese Logik eignen.

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

index.html (Compute createShaderModule call)

let i = cellIndex(cell.xy);

// Conway's game of life rules:
switch activeNeighbors {
  case 2: { // Active cells with 2 neighbors stay active.
    cellStateOut[i] = cellStateIn[i];
  }
  case 3: { // Cells with 3 neighbors become or stay active.
    cellStateOut[i] = 1;
  }
  default: { // Cells with < 2 or > 3 neighbors become inactive.
    cellStateOut[i] = 0;
  }
}

Der endgültige Aufruf des Compute-Shader-Moduls sieht jetzt so aus:

index.html

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

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

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

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

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

      let i = cellIndex(cell.xy);

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

Das war's auch schon. Fertig! Aktualisieren Sie die Seite und sehen Sie zu, wie Ihr neu erstellter zellulärer Automat wächst.

Screenshot eines Beispielzustands aus der Game of Life-Simulation mit farbigen Zellen vor einem dunkelblauen Hintergrund.

9. Glückwunsch!

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

Nächste Schritte

Weitere Informationen

Referenzdokumente