La tua prima app WebGPU

1. Introduzione

Il logo WebGPU è costituito da diversi triangoli blu che formano una "W" stilizzata

Ultimo aggiornamento: 28-08-2023

Che cos'è WebGPU?

WebGPU è una nuova e moderna API per accedere alle funzionalità della tua GPU nelle app web.

API moderna

Prima di WebGPU, esisteva WebGL, che offriva un sottoinsieme delle funzionalità di WebGPU. Ha attivato una nuova classe di contenuti web avanzati e gli sviluppatori hanno creato contenuti incredibili con questo strumento. Tuttavia, si basava sull'API OpenGL ES 2.0, rilasciata nel 2007, basata sull'API OpenGL ancora più vecchia. In questo periodo le GPU si sono evolute in modo significativo e anche le API native utilizzate per interfacciarsi con Direct3D 12, Metal e Vulkan si sono evolute.

WebGPU porta i progressi di queste API moderne sulla piattaforma web. Si concentra sull'abilitazione delle funzionalità GPU in modo multipiattaforma, presentando al contempo un'API naturale sul web e meno dettagliata rispetto ad alcune API native su cui è basata.

Rendering

Le GPU sono spesso associate a una grafica veloce e dettagliata e WebGPU non fa eccezione. Ha le funzionalità necessarie per supportare molte delle tecniche di rendering più popolari al giorno d'oggi per GPU sia desktop che mobile. Inoltre, fornisce un percorso per l'aggiunta di nuove funzionalità in futuro man mano che le funzionalità hardware continuano a evolversi.

Computing

Oltre al rendering, WebGPU sblocca il potenziale della GPU per l'esecuzione di carichi di lavoro generici e altamente paralleli. Questi shader di computing possono essere utilizzati in modo autonomo, senza componenti di rendering, o come parte strettamente integrata della pipeline di rendering.

Nel codelab di oggi imparerai a sfruttare le funzionalità di rendering e di calcolo di WebGPU per creare un semplice progetto introduttivo.

Cosa creerai

In questo codelab, creerai Il gioco della vita di Conway utilizzando WebGPU. La tua app sarà in grado di:

  • Utilizza le funzionalità di rendering di WebGPU per disegnare semplici grafiche 2D.
  • Utilizza le funzionalità di computing di WebGPU per eseguire la simulazione.

Uno screenshot del prodotto finale di questo codelab

Il gioco della vita è un automa cellulare, in cui una griglia di celle cambia stato nel tempo in base a un insieme di regole. Nel Gioco della vita le cellule diventano attive o inattive in base a quante delle cellule vicine sono attive, il che porta a schemi interessanti che fluttuano mentre guardi.

Cosa imparerai a fare

  • Come impostare WebGPU e un canvas.
  • Come disegnare semplici geometrie 2D.
  • Come utilizzare Shaper di vertici e frammenti per modificare ciò che è stato disegnato.
  • Scoprire come utilizzare i computing Shader per eseguire una semplice simulazione.

Questo codelab è incentrato sull'introduzione dei concetti fondamentali alla base di WebGPU. Non è pensato per essere una revisione completa dell'API, né copre (o richiede) argomenti spesso correlati, come la matematica a matrice 3D.

Che cosa ti serve

  • Una versione recente di Chrome (113 o versioni successive) su ChromeOS, macOS o Windows. WebGPU è un'API multipiattaforma e cross-browser, ma non è stata ancora distribuita ovunque.
  • Conoscenza di HTML, JavaScript e Chrome DevTools.

Non è necessaria avere familiarità con altre API grafiche, come WebGL, Metal, Vulkan o Direct3D, ma se hai esperienza con queste API noterai molte somiglianze con WebGPU che possono aiutarti a iniziare il tuo percorso di apprendimento.

2. Configurazione

Ottieni il codice

Questo codelab non ha dipendenze e ti guida in ogni passaggio necessario per creare l'app WebGPU, quindi non hai bisogno di codice per iniziare. Tuttavia, alcuni esempi operativi che possono fungere da punti di controllo sono disponibili all'indirizzo https://glitch.com/edit/#!/your-first-webgpu-app. Se non riesci a procedere, puoi dare un'occhiata e farvi riferimento mentre procedi.

Utilizza la Developer Console.

WebGPU è un'API piuttosto complessa con molte regole che ne impongono un utilizzo corretto. Peggio ancora, a causa del funzionamento dell'API, non è in grado di generare eccezioni JavaScript tipiche per molti errori, il che rende più difficile individuare con precisione la provenienza del problema.

Incontrerai problemi durante lo sviluppo con WebGPU, specialmente ai principianti, e va bene così. Gli sviluppatori responsabili dell'API sono consapevoli delle difficoltà legate all'utilizzo dello sviluppo delle GPU e si sono impegnati al massimo per garantire che ogni volta che il codice WebGPU genera un errore, riceverai nella Developer Console messaggi molto dettagliati e utili che ti aiuteranno a identificare e risolvere il problema.

Tenere aperta la console mentre si lavora su qualsiasi applicazione web è sempre utile, ma è particolarmente utile in questo caso.

3. Inizializza WebGPU

Inizia con un <canvas>

WebGPU può essere utilizzata senza mostrare nulla sullo schermo se si vuole solo utilizzarla per eseguire calcoli. Tuttavia, per eseguire il rendering di un elemento, come nel codelab, è necessaria una tela. Quindi è un buon punto di partenza!

Crea un nuovo documento HTML con un singolo elemento <canvas> e un tag <script> in cui eseguiamo una query sull'elemento canvas. In alternativa, utilizza 00-starter-page.html da glitch.

  • Crea un file index.html con il seguente codice:

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>

Richiedere un alimentatore e un dispositivo

Ora puoi entrare nelle bit di WebGPU! Innanzitutto, devi considerare che la propagazione di API come WebGPU in tutto l'ecosistema web può richiedere del tempo. Di conseguenza, un primo passaggio precauzionale valido consiste nel verificare se il browser dell'utente può utilizzare WebGPU.

  1. Per verificare se esiste l'oggetto navigator.gpu, che funge da punto di ingresso per WebGPU, aggiungi il seguente codice:

index.html

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

Idealmente, dovresti informare l'utente se WebGPU non è disponibile facendo in modo che la pagina ricorra a una modalità che non utilizza WebGPU. (Forse potrebbe usare WebGL?) Ai fini di questo codelab, tuttavia, viene generato un errore per interrompere l'esecuzione del codice.

Quando sai che WebGPU è supportata dal browser, il primo passaggio per inizializzare WebGPU per la tua app è richiedere una GPUAdapter. Un adattatore è la rappresentazione WebGPU di un componente specifico dell'hardware GPU del tuo dispositivo.

  1. Per acquistare un adattatore, usa il metodo navigator.gpu.requestAdapter(). Restituisce una promessa, quindi è più conveniente chiamarla con await.

index.html

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

Se non sono stati trovati adattatori appropriati, il valore adapter restituito potrebbe essere null, quindi vuoi gestire questa possibilità. Questo può accadere se il browser dell'utente supporta WebGPU, ma l'hardware GPU non dispone di tutte le funzionalità necessarie per utilizzare WebGPU.

La maggior parte delle volte è accettabile lasciare che il browser scelga semplicemente un adattatore predefinito, come fai qui, ma per esigenze più avanzate ci sono argomenti che possono essere trasmessi a requestAdapter() che specificano se desideri utilizzare hardware a bassa potenza o ad alte prestazioni su dispositivi con più GPU (come alcuni laptop).

Dopo aver ottenuto un adattatore, l'ultimo passaggio prima di poter iniziare a utilizzare la GPU è richiedere un GPUDevice. Il dispositivo è l'interfaccia principale attraverso la quale avviene la maggior parte delle interazioni con la GPU.

  1. Recupera il dispositivo chiamando il numero adapter.requestDevice(), anche questo restituisce una promessa.

index.html

const device = await adapter.requestDevice();

Come con requestAdapter(), qui sono opzioni che possono essere trasmesse per utilizzi più avanzati, come l'abilitazione di funzionalità hardware specifiche o la richiesta di limiti più elevati, ma per i tuoi scopi le impostazioni predefinite funzionano bene.

Configurare il canvas

Ora che hai un dispositivo, c'è un'altra cosa da fare se vuoi utilizzarlo per mostrare qualsiasi cosa sulla pagina: configurare la tela da utilizzare con il dispositivo appena creato.

  • Per farlo, devi prima richiedere un elemento GPUCanvasContext dalla tela chiamando il numero canvas.getContext("webgpu"). Si tratta della stessa chiamata che useresti per inizializzare i contesti Canvas 2D o WebGL, utilizzando rispettivamente i tipi di contesto 2d e webgl. L'oggetto context restituito deve quindi essere associato al dispositivo utilizzando il metodo configure(), ad esempio:

index.html

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

Ci sono alcune opzioni che possono essere passate qui, ma le più importanti sono la device con cui utilizzerai il contesto e la format, che è il formato di texture che il contesto dovrebbe usare.

Le texture sono gli oggetti utilizzati da WebGPU per archiviare i dati delle immagini e ogni texture ha un formato che consente alla GPU di sapere come sono disposti i dati in memoria. I dettagli di come funziona la memoria texture non rientrano nell'ambito di questo codelab. È importante sapere che il contesto del canvas fornisce le texture in cui disegnare il codice e che il formato che utilizzi può influire sull'efficacia con cui il canvas mostra queste immagini. Tipi di dispositivi diversi hanno un rendimento migliore quando si utilizzano formati di texture diversi e, se non utilizzi il formato preferito del dispositivo, in background potrebbero essere inserite copie in memoria aggiuntive prima che l'immagine possa essere visualizzata come parte della pagina.

Fortunatamente, non devi preoccuparti molto di tutto questo perché WebGPU ti dice quale formato utilizzare per la tua tela. In quasi tutti i casi, devi passare il valore restituito chiamando navigator.gpu.getPreferredCanvasFormat(), come mostrato sopra.

Cancellare i contenuti in canvas

Ora che hai un dispositivo e la tela è stata configurata con questo dispositivo, puoi iniziare a utilizzarlo per modificare i contenuti della tela. Per iniziare, cancellali con un colore a tinta unita.

Per farlo, o praticamente per qualsiasi altra cosa in WebGPU, devi fornire alcuni comandi alla GPU indicandogli cosa fare.

  1. Per farlo, chiedi al dispositivo di creare una GPUCommandEncoder, che fornisce un'interfaccia per la registrazione dei comandi GPU.

index.html

const encoder = device.createCommandEncoder();

I comandi che vuoi inviare alla GPU sono correlati al rendering (in questo caso, la cancellazione del canvas), quindi il passaggio successivo consiste nell'utilizzare encoder per avviare un Render Pass.

I passaggi di rendering si verificano quando vengono eseguite tutte le operazioni di disegno in WebGPU. Ognuna inizia con una chiamata a beginRenderPass(), che definisce le texture che ricevono l'output di qualsiasi comando di disegno eseguito. Utilizzi più avanzati possono fornire diverse texture, chiamate allegati, con vari scopi, come memorizzare la profondità della geometria di rendering o fornire antialiasing. Per questa app, tuttavia, è necessario solo uno.

  1. Ottieni la texture dal contesto del canvas creato in precedenza chiamando context.getCurrentTexture(), che restituisce una texture con larghezza e altezza in pixel corrispondenti agli attributi width e height del canvas e ai valori format specificati quando hai chiamato context.configure().

index.html

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

La texture viene specificata come proprietà view di un colorAttachment. I passaggi di rendering richiedono che sia specificato un valore GPUTextureView anziché un valore GPUTexture, che gli indica le parti della texture in cui eseguire il rendering. Questo è davvero importante solo per i casi d'uso più avanzati, quindi in questo caso chiamiamo createView() senza argomenti sulla texture, a indicare che vuoi che il passaggio di rendering utilizzi l'intera texture.

Devi anche specificare cosa vuoi che il passaggio di rendering faccia con la texture all'inizio e alla fine:

  • Un valore loadOp di "clear" indica che vuoi che la texture venga cancellata all'avvio del rendering.
  • Un valore storeOp di "store" indica che, una volta terminato il rendering, vuoi che i risultati di qualsiasi disegno eseguito durante il rendering vengano salvati nella texture.

Una volta iniziato il rendering, non devi fare nulla. Almeno per ora. L'avvio del passaggio di rendering con loadOp: "clear" è sufficiente per cancellare la visualizzazione della texture e il canvas.

  1. Termina il passaggio di rendering aggiungendo la seguente chiamata subito dopo beginRenderPass():

index.html

pass.end();

È importante sapere che semplicemente effettuare queste chiamate non fa sì che la GPU faccia effettivamente nulla. Stanno solo registrando comandi che la GPU eseguirà in un secondo momento.

  1. Per creare un GPUCommandBuffer, chiama finish() sul codificatore di comando. Il buffer dei comandi è un handle opaco per i comandi registrati.

index.html

const commandBuffer = encoder.finish();
  1. Invia il buffer dei comandi alla GPU utilizzando queue di GPUDevice. La coda esegue tutti i comandi GPU, assicurando che l'esecuzione sia ben ordinata e sincronizzata. Il metodo submit() della coda accetta un array di buffer dei comandi, anche se in questo caso ne hai solo uno.

index.html

device.queue.submit([commandBuffer]);

Una volta inviato un buffer dei comandi, non può essere riutilizzato, quindi non è necessario conservarlo. Se vuoi inviare altri comandi, devi creare un altro buffer dei comandi. Ecco perché è piuttosto comune vedere questi due passaggi compressi in uno solo, come nelle pagine di esempio del codelab:

index.html

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

Dopo aver inviato i comandi alla GPU, consenti a JavaScript di restituire il controllo al browser. A quel punto, il browser vede che è stata modificata la texture corrente del contesto e aggiorna il canvas in modo che venga visualizzata come immagine. Se in seguito vuoi aggiornare di nuovo i contenuti del canvas, devi registrare e inviare un nuovo buffer dei comandi, chiamando di nuovo context.getCurrentTexture() per ottenere una nuova texture per un pass di rendering.

  1. Ricarica la pagina. Nota che la tela è piena di nero. Complimenti! Ciò significa che hai creato correttamente la tua prima app WebGPU.

Un canvas nero che indica che WebGPU è stato utilizzato correttamente per cancellare i contenuti del canvas.

Scegli un colore

In tutta sincerità, però, i quadrati neri sono piuttosto noiosi. Dedica qualche istante a passare alla sezione successiva per personalizzarla un po'.

  1. Nella chiamata encoder.beginRenderPass(), aggiungi una nuova riga con un clearValue a colorAttachment, in questo modo:

index.html

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

clearValue indica al pass di rendering il colore da utilizzare quando si esegue l'operazione clear all'inizio del pass. Il dizionario passato al suo interno contiene quattro valori: r per rosso, g per verde, b per blu e a per alpha (trasparenza). Ogni valore può essere compreso tra 0 e 1 e insieme descrivono il valore di quel canale. Ad esempio:

  • { r: 1, g: 0, b: 0, a: 1 } è di colore rosso acceso.
  • { r: 1, g: 0, b: 1, a: 1 } è di colore viola brillante.
  • { r: 0, g: 0.3, b: 0, a: 1 } è verde scuro.
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 } è grigio medio.
  • { r: 0, g: 0, b: 0, a: 0 } è il nero trasparente predefinito.

Il codice di esempio e gli screenshot in questo codelab utilizzano un blu scuro, ma puoi scegliere il colore che preferisci.

  1. Dopo aver scelto il colore, ricarica la pagina. Dovresti vedere il colore scelto nella tela.

Una tela diventa di colore blu scuro per dimostrare come modificare il colore trasparente predefinito.

4. Disegna geometria

Entro la fine di questa sezione, l'app disegnerà sulla tela alcune semplici geometrie: un quadrato colorato. Tieni presente che sembrerà molto lavoro per un output così semplice, ma questo è dovuto al fatto che WebGPU è progettato per eseguire il rendering di molti elementi geometrici in modo molto efficiente. Un effetto collaterale di questa efficienza è che fare cose relativamente semplici può sembrare insolitamente difficile, ma è questo l'aspettativa se si passa a un'API come WebGPU: si vuole fare qualcosa di più complesso.

Comprendere come vengono tracciate le GPU

Prima di apportare altre modifiche al codice, vale la pena fare una panoramica molto rapida, semplificata e di alto livello su come le GPU creano le forme che vedi sullo schermo. Se hai già familiarità con le nozioni di base relative al funzionamento del rendering GPU, non esitare a passare alla sezione Definizione dei vertici.

A differenza di un'API come Canvas 2D che ha molte forme e opzioni pronte per l'uso, la GPU in realtà gestisce solo pochi tipi diversi di forme (o primitivi come vengono chiamati da WebGPU): punti, linee e triangoli. Ai fini di questo codelab, utilizzerai solo i triangoli.

Le GPU funzionano quasi esclusivamente con i triangoli perché i triangoli hanno molte proprietà matematiche interessanti che li rendono facili da elaborare in modo prevedibile ed efficiente. Quasi tutto ciò che disegni con la GPU deve essere suddiviso in triangoli prima che la GPU possa disegnarlo e quei triangoli devono essere definiti dai loro punti d'angolo.

Questi punti, o vertici, sono indicati in termini di valori Z di X, Y e (per i contenuti 3D) Z che definiscono un punto su un sistema di coordinate cartesiano definito da WebGPU o da API simili. È più facile pensare alla struttura del sistema di coordinate in termini di come è correlata al canvas sulla pagina. Non importa quanto sia larga o alta la tela, il bordo sinistro è sempre a -1 sull'asse X, mentre il bordo destro è sempre +1 sull'asse X. Analogamente, il bordo inferiore è sempre -1 sull'asse Y e il bordo superiore è +1 sull'asse Y. Ciò significa che (0, 0) è sempre il centro del canvas, (-1, -1) è sempre l'angolo in basso a sinistra e (1, 1) è sempre l'angolo in alto a destra. Si tratta dello spazio clip.

Un semplice grafico che mostra lo spazio delle coordinate del dispositivo normalizzato.

Inizialmente i vertici vengono definiti inizialmente in questo sistema di coordinate, quindi le GPU si basano su piccoli programmi chiamati vertex shaker per eseguire qualsiasi calcolo matematico necessario per trasformare i vertici in uno spazio per i clip, così come qualsiasi altro calcolo necessario per disegnare i vertici. Ad esempio, lo shaker può applicare animazioni o calcolare la direzione dal vertice a una sorgente di luce. Questi componenti sono scritti da te, lo sviluppatore WebGPU, e offrono un controllo incredibile sul funzionamento della GPU.

Da qui, la GPU prende tutti i triangoli costituiti da questi vertici trasformati e determina quali pixel sullo schermo sono necessari per disegnarli. Poi esegue un altro piccolo programma che scrivi, chiamato fragment shaker per calcolare il colore di ogni pixel. Questo calcolo può essere semplice come il ritorno al verde o complesso come calcolare l'angolo della superficie rispetto alla luce del sole che rimbalza su altre superfici vicine, filtrato nella nebbia e modificato in base al grado di metallo della superficie. È sotto il tuo controllo, il che può essere stimolante e travolgente.

I risultati dei colori dei pixel vengono quindi accumulati in una texture che può essere visualizzata sullo schermo.

Definire i vertici

Come accennato in precedenza, la simulazione Gioco della vita viene mostrata come una griglia di celle. La tua app richiede un modo per visualizzare la griglia, per distinguere le celle attive da quelle inattive. L'approccio utilizzato da questo codelab consisterà nel disegnare quadrati colorati nelle celle attive e lasciare vuote le celle inattive.

Ciò significa che dovrai fornire alla GPU quattro punti diversi, uno per ciascuno dei quattro angoli del quadrato. Ad esempio, un quadrato disegnato al centro dell'area di lavoro, tirato dai bordi di un lato, ha le coordinate degli angoli come queste:

Grafico di coordinate del dispositivo normalizzato che mostra le coordinate per gli angoli di un quadrato

Per inviare queste coordinate alla GPU, devi inserire i valori in un campo TypedArray. Se non lo conosci già, i TypedArray sono un gruppo di oggetti JavaScript che ti consente di allocare blocchi di memoria contigui e di interpretare ogni elemento della serie come un tipo di dati specifico. Ad esempio, in un elemento Uint8Array, ogni elemento dell'array è un singolo byte non firmato. I TypedArray sono ottimi per l'invio di dati con API sensibili al layout della memoria, come WebAssembly, WebAudio e (ovviamente) WebGPU.

Nell'esempio quadrato, poiché i valori sono frazionari, Float32Array è appropriato.

  1. Crea un array che contenga tutte le posizioni dei vertici nel diagramma inserendo la seguente dichiarazione dell'array nel codice. Un buon punto di inserimento è vicino alla parte superiore, appena sotto la chiamata context.configure().

index.html

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

Tieni presente che la spaziatura e il commento non hanno effetto sui valori. è solo per tua comodità e per renderlo più leggibile. Ti aiuta a vedere che ogni coppia di valori costituisce le coordinate X e Y di un vertice.

Ma c'è un problema! Le GPU funzionano in termini di triangoli, ricordi? Ciò significa che devi fornire i vertici in gruppi di tre. Hai un gruppo di quattro. La soluzione è ripetere due vertici per creare due triangoli che condividono un bordo al centro del quadrato.

Un diagramma che mostra come i quattro vertici del quadrato verranno utilizzati per formare due triangoli.

Per formare il quadrato dal diagramma, devi elencare i vertici (-0,8, -0,8) e (0,8, 0,8) due volte, una per il triangolo blu e una volta per quello rosso. Puoi anche scegliere di dividere il quadrato con gli altri due angoli, senza fare alcuna differenza.

  1. Aggiorna l'array vertices precedente in modo che abbia il seguente aspetto:

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

Anche se il diagramma mostra una separazione tra i due triangoli per chiarezza, le posizioni dei vertici sono esattamente le stesse e la GPU esegue il rendering senza spazi vuoti. come un singolo quadrato in tinta unita.

Crea un vertex buffer

La GPU non può tracciare vertici con i dati di un array JavaScript. Le GPU spesso hanno una memoria altamente ottimizzata per il rendering, quindi tutti i dati che vuoi che la GPU utilizzi mentre attinge devono essere inseriti in quella memoria.

Per molti valori, inclusi i dati dei vertici, la memoria lato GPU viene gestita tramite oggetti GPUBuffer. Un buffer è un blocco di memoria facilmente accessibile alla GPU e segnalato per determinati scopi. È un po' come un TypedArray visibile su GPU.

  1. Per creare un buffer che contenga i vertici, aggiungi la seguente chiamata a device.createBuffer() dopo la definizione dell'array vertices.

index.html

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

La prima cosa da notare è che assegni un'etichetta al buffer. A ogni singolo oggetto WebGPU che crei può essere assegnata un'etichetta facoltativa e ti consigliamo di farlo. L'etichetta è qualsiasi stringa tu voglia, purché ti aiuti a identificare l'oggetto. Se riscontri problemi, queste etichette vengono utilizzate nei messaggi di errore generati da WebGPU per aiutarti a capire cosa non ha funzionato.

Successivamente, assegna una dimensione per il buffer in byte. Hai bisogno di un buffer di 48 byte, che viene determinato moltiplicando la dimensione di un numero in virgola mobile a 32 bit ( 4 byte) per il numero di numeri in virgola mobile nell'array vertices (12). Fortunatamente, TypedArrays calcola già il proprio byteLength automaticamente, quindi puoi usarlo quando crei il buffer.

Infine, devi specificare l'utilizzo del buffer. Si tratta di uno o più flag di GPUBufferUsage, con più flag combinati con l'operatore | ( OR bit per bit). In questo caso, specifichi che vuoi che il buffer venga utilizzato per i dati del vertice (GPUBufferUsage.VERTEX) e che vuoi anche poter copiare dati al suo interno (GPUBufferUsage.COPY_DST).

L'oggetto buffer che ti viene restituito è opaco: non puoi (facilmente) ispezionare i dati che contiene. Inoltre, la maggior parte dei suoi attributi è immutabile: non puoi ridimensionare un elemento GPUBuffer dopo averlo creato, né modificare i flag di utilizzo. Quello che puoi cambiare sono i contenuti della sua memoria.

Quando il buffer viene creato inizialmente, la memoria che contiene viene inizializzata su zero. Esistono diversi modi per modificarne i contenuti, ma il più semplice è chiamare device.queue.writeBuffer() con un TypedArray da copiare.

  1. Per copiare i dati del vertice nella memoria del buffer, aggiungi il codice seguente:

index.html

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

Definire il layout del vertice

Ora hai un buffer contenente dati sui vertici, ma per quanto riguarda la GPU è solo un blob di byte. Se hai intenzione di disegnare qualcosa, devi fornire qualche informazione in più. Devi essere in grado di fornire a WebGPU maggiori informazioni sulla struttura dei dati ai vertici.

index.html

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

A prima vista questo può creare un po' di confusione, ma è relativamente facile suddividerlo.

La prima cosa da fare è arrayStride. Si tratta del numero di byte che la GPU deve saltare in avanti nel buffer quando cerca il vertice successivo. Ogni vertice del quadrato è costituito da due numeri in virgola mobile a 32 bit. Come accennato in precedenza, un numero in virgola mobile a 32 bit è di 4 byte, quindi due numeri in virgola mobile sono di 8 byte.

Poi c'è la proprietà attributes, che è un array. Gli attributi sono le singole informazioni codificate in ciascun vertice. I vertici contengono un solo attributo (la posizione del vertice), ma i casi d'uso più avanzati spesso hanno vertici che contengono più attributi, come il colore di un vertice o la direzione della superficie geometrica. Questo, però, non rientra nell'ambito di questo codelab.

Nel tuo attributo singolo, devi prima definire il valore format dei dati. Deriva da un elenco di tipi di GPUVertexFormat che descrivono ogni tipo di dati dei vertici che la GPU è in grado di comprendere. I vertici hanno due numeri in virgola mobile a 32 bit ciascuno, quindi utilizzi il formato float32x2. Ad esempio, se i dati sui vertici sono costituiti da quattro numeri interi senza segno a 16 bit ciascuno, utilizzerai uint16x4. Vedi lo schema?

Successivamente, offset descrive quanti byte nel vertice avvia questo particolare attributo. Devi preoccuparti di questo problema solo se nel buffer sono presenti più attributi, che non verranno visualizzati durante questo codelab.

Infine, hai il shaderLocation. Si tratta di un numero arbitrario compreso tra 0 e 15 e deve essere univoco per ogni attributo che definisci. Collega questo attributo a un determinato input in Vertex Shader, di cui parleremo nella prossima sezione.

Nota che anche se definisci questi valori ora, non li trasmetti all'API WebGPU da nessuna parte. Il processo è in atto, ma è più semplice pensare a questi valori quando si definiscono i vertici, in modo da prepararli per utilizzarli in seguito.

Inizia con gli Shaper

Ora hai i dati di cui vuoi eseguire il rendering, ma devi comunque indicare esattamente alla GPU come elaborarli. In gran parte, questo avviene con gli Shaper.

Gli Shader sono piccoli programmi che scrivi ed esegui sulla tua GPU. Ogni Shader opera su una diversa fase dei dati: elaborazione di Vertex, elaborazione di frammenti o computing generale. Poiché si trovano sulla GPU, sono strutturati in modo più rigido rispetto al codice JavaScript medio. Questa struttura, però, consente loro di agire molto rapidamente e, soprattutto, in parallelo.

Gli Shader in WebGPU sono scritti in un linguaggio di ombreggiatura denominato WGSL (WebGPU Shading Language). WGSL è, sintatticamente, un po' come Rust, con funzionalità volte a rendere più semplici e veloci i tipi comuni di lavoro delle GPU (come la matematica vettoriale e matriciale). Insegnare tutto il linguaggio del linguaggio shading va ben oltre l'ambito di questo codelab, ma spero che apprenderai alcune delle nozioni di base mentre analizzerai alcuni semplici esempi.

Gli stessi Shaper vengono passati in WebGPU sotto forma di stringhe.

  • Crea un luogo in cui inserire il codice del tuo snapshot copiando il seguente codice nel tuo codice sotto vertexBufferLayout:

index.html

const cellShaderModule = device.createShaderModule({
  label: "Cell shader",
  code: `
    // Your shader code will go here
  `
});

Per creare gli shaker che chiami device.createShaderModule(), a cui fornisci un elemento facoltativo label e un WGSL code come stringa. Tieni presente che in questo caso vengono utilizzati apici inversi per consentire l'utilizzo di stringhe su più righe. Dopo aver aggiunto un codice WGSL valido, la funzione restituisce un oggetto GPUShaderModule con i risultati compilati.

Definisci Vertex Shar

Inizia con Vertex Streamr perché è da lì che inizia anche la GPU.

È definito come una funzione "vertex shaker" e la GPU chiama questa funzione una volta per ogni vertice nel tuo vertexBuffer. Poiché vertexBuffer ha sei posizioni (vertici), la funzione che definisci viene chiamata sei volte. Ogni volta che viene richiamata, una posizione diversa da vertexBuffer viene passata alla funzione come argomento ed è compito della funzione vertex shaker restituire una posizione corrispondente nello spazio del clip.

È importante capire che non verranno necessariamente chiamati in ordine sequenziale. Al contrario, le GPU eccellono nell'esecuzione di Shader come questi in parallelo, elaborando potenzialmente centinaia (o addirittura migliaia!) di vertici contemporaneamente. Questa è una parte enorme dell'incredibile velocità delle GPU, ma presenta alcune limitazioni. Per garantire un caricamento di parallelismo estremo, i Vertex Shader non possono comunicare tra loro. Ogni chiamata di Shar può vedere solo i dati per un singolo vertice alla volta ed è in grado di produrre valori per un solo vertice.

In WGSL, una funzione Vertex Shar può essere denominata come preferisci, ma deve avere l'attributo @vertex davanti a sé per indicare la fase dello shaker che rappresenta. WGSL indica le funzioni con la parola chiave fn, utilizza le parentesi per dichiarare eventuali argomenti e le parentesi graffe per definire l'ambito.

  1. Crea una funzione @vertex vuota, come questa:

index.html (codice createShaderModule)

@vertex
fn vertexMain() {

}

Tuttavia, non è valido, poiché uno streamr di vertex deve restituire almeno la posizione finale del vertice elaborato nello spazio dei clip. Viene sempre dato come vettore a 4 dimensioni. I vettori sono una cosa talmente comune da usare negli Shaper che vengono trattati come primitive di prima classe nel linguaggio, con tipi propri, come vec4f per un vettore a 4 dimensioni. Esistono anche tipi simili per i vettori 2D (vec2f) e i vettori 3D (vec3f).

  1. Per indicare che il valore restituito è la posizione richiesta, contrassegnalo con l'attributo @builtin(position). Viene utilizzato un simbolo -> per indicare che questo è ciò che restituisce la funzione.

index.html (codice createShaderModule)

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

}

Ovviamente, se la funzione ha un tipo restituito, devi effettivamente restituire un valore nel corpo della funzione. Puoi creare un nuovo vec4f da restituire, utilizzando la sintassi vec4f(x, y, z, w). I valori x, y e z sono tutti numeri a virgola mobile che, nel valore restituito, indicano la posizione del vertice nello spazio del clip.

  1. Restituisce un valore statico (0, 0, 0, 1) e tecnicamente hai un vertex Shar valido, anche se uno che non visualizza mai nulla poiché la GPU riconosce che i triangoli prodotti sono solo un singolo punto e poi lo scarta.

index.html (codice createShaderModule)

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

Quello che dovresti usare è utilizzare i dati del buffer che hai creato e devi farlo dichiarando un argomento per la tua funzione con un attributo e un tipo di tipo @location() che corrispondano a quanto hai descritto nel vertexBufferLayout. Hai specificato un valore shaderLocation pari a 0, quindi nel codice WGSL contrassegna l'argomento con @location(0). Hai anche definito il formato come float32x2, che è un vettore 2D, quindi in WGSL l'argomento è vec2f. Puoi assegnargli il nome che preferisci, ma poiché rappresentano le posizioni dei vertici, un nome come pos sembra naturale.

  1. Cambia la funzione Shar con il seguente codice:

index.html (codice createShaderModule)

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

E ora devi restituire quella posizione. Poiché la posizione è un vettore 2D e il tipo restituito è un vettore 4D, è necessario modificarlo leggermente. Devi semplicemente prendere i due componenti dall'argomento position e posizionarli nei primi due componenti del vettore di ritorno, lasciando gli ultimi due componenti rispettivamente come 0 e 1.

  1. Restituisci la posizione corretta indicando esplicitamente quali componenti di posizione utilizzare:

index.html (codice createShaderModule)

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

Tuttavia, poiché questi tipi di mappature sono così comuni negli streamr, puoi passare anche il vettore di posizione come primo argomento in una pratica breve forma di abbreviazione, il che significa la stessa cosa.

  1. Riscrivi l'istruzione return con il seguente codice:

index.html (codice createShaderModule)

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

E questo è il tuo vertex Shar iniziale! È molto semplice, si limita a distribuire la posizione sostanzialmente invariata, ma è sufficiente per iniziare.

Definisci lo shaker dei frammenti

Poi c'è lo shaker dei frammenti. I Fragment Shader operano in modo molto simile ai vertex Shader, ma invece di essere richiamati per ogni vertice, vengono richiamati per ogni pixel disegnato.

I Fragment Shader vengono sempre chiamati dopo Vertex Shader. La GPU prende l'output dei vertex shaker e lo triangola, creando triangoli da set di tre punti. Quindi rasterizza ciascuno di questi triangoli individuando quali pixel degli allegati dei colori di output sono inclusi nel triangolo, quindi chiama lo shaker dei frammenti una volta per ciascuno di quei pixel. Lo shaker per frammenti restituisce un colore, tipicamente calcolato in base ai valori inviati da Vertex Shader e ad asset come le texture, che la GPU scrive nell'allegato del colore.

Proprio come i vertex shaker, i frammenti di snippet vengono eseguiti in modo molto parallelo. Sono un po' più flessibili dei vertex Shader in termini di input e output, ma si può considerare che restituiscano semplicemente un colore per ogni pixel di ogni triangolo.

Una funzione shaker per frammenti WGSL è indicata con l'attributo @fragment e restituisce anche un vec4f. In questo caso, tuttavia, il vettore rappresenta un colore, non una posizione. Al valore restituito deve essere fornito un attributo @location per indicare a quale colorAttachment della chiamata beginRenderPass è scritto il colore restituito. Poiché avevi un solo allegato, la località è pari a 0.

  1. Crea una funzione @fragment vuota, come questa:

index.html (codice createShaderModule)

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

}

I quattro componenti del vettore restituito sono i valori dei colori rosso, verde, blu e alfa, che sono interpretati esattamente allo stesso modo del valore clearValue impostato in beginRenderPass in precedenza. Quindi vec4f(1, 0, 0, 1) è un rosso acceso, che sembra un buon colore per il tuo quadrato. Puoi comunque impostarla sul colore che preferisci.

  1. Imposta il vettore di colore restituito in questo modo:

index.html (codice createShaderModule)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}

E questo è uno streamr di frammenti completo! Non è molto interessante; imposta su rosso ogni pixel di ogni triangolo, ma per il momento è sufficiente.

Ricapitolando, dopo aver aggiunto il codice dello shaker descritto sopra, la chiamata a createShaderModule ora avrà il seguente aspetto:

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

Creare una pipeline di rendering

Un modulo Shar non può essere utilizzato per il rendering da solo. Devi invece utilizzarlo come parte di una GPURenderPipeline, creata chiamando device.createRenderPipeline(). La pipeline di rendering controlla come viene disegnata la geometria, ad esempio quali Shar vengono utilizzati, come interpretare i dati nei buffer di vertice, quale tipo di geometria deve essere visualizzata (linee, punti, triangoli...) e altro ancora.

La pipeline di rendering è l'oggetto più complesso dell'intera API, ma non preoccuparti. La maggior parte dei valori che puoi passare è facoltativa e devi solo specificarne alcuni per iniziare.

  • Crea una pipeline di rendering come la seguente:

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

Ogni pipeline ha bisogno di un elemento layout che descriva i tipi di input (diversi dai buffer di vertice) necessari per la pipeline, ma in realtà non ne hai. Fortunatamente, per il momento puoi passare "auto" e la pipeline crea il proprio layout dagli Shar.

A questo punto, devi fornire i dettagli relativi alla fase vertex. module è il GPUShaderModule che contiene il tuo vertex Shader, mentre entryPoint fornisce il nome della funzione nel codice dello shaker che viene chiamata per ogni chiamata a un vertex. (puoi avere più funzioni @vertex e @fragment in un singolo modulo Shar.) I buffer sono un array di oggetti GPUVertexBufferLayout che descrive come i dati vengono compressi nei vertex buffer con cui utilizzi questa pipeline. Fortunatamente, lo hai già definito in precedenza nel tuo vertexBufferLayout. Ecco dove lo passi.

Infine, troverai i dettagli relativi alla fase fragment. Sono inclusi anche un modulo di snapshot e un entryPoint, come lo stage vertex. L'ultimo bit è definire la targets con cui viene utilizzata questa pipeline. Si tratta di un array di dizionari che forniscono dettagli, ad esempio la texture format, dei colori allegati a cui la pipeline restituisce. Questi dettagli devono corrispondere alle texture fornite in colorAttachments di tutti i passaggi di rendering con cui viene utilizzata questa pipeline. La tua trasmissione di rendering utilizza le texture del contesto del canvas e usa il valore salvato in canvasFormat per il formato, quindi passi lo stesso formato qui.

Questo non è nemmeno vicino a tutte le opzioni che puoi specificare durante la creazione di una pipeline di rendering, ma è sufficiente per le esigenze di questo codelab.

Disegnare il quadrato

E con questo, ora hai tutto ciò che ti serve per disegnare il tuo quadrato.

  1. Per disegnare il quadrato, torna alla coppia di chiamate encoder.beginRenderPass() e pass.end() e aggiungi questi nuovi comandi tra di loro:

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

Questo fornisce a WebGPU tutte le informazioni necessarie per disegnare il quadrato. Innanzitutto, utilizza setPipeline() per indicare la pipeline con cui disegnare. Sono inclusi gli screenr utilizzati, il layout dei dati dei vertici e altri dati di stato pertinenti.

Successivamente, chiama setVertexBuffer() con il buffer contenente i vertici del quadrato. Lo chiami con 0 perché questo buffer corrisponde al 0° elemento nella definizione vertex.buffers della pipeline attuale.

Infine, effettui la chiamata a draw(), che sembra stranamente semplice dopo tutta la configurazione precedente. L'unica cosa che devi passare è il numero di vertici che dovrebbe visualizzare, che estrae dai buffer di vertice attualmente impostati e interpreta con la pipeline attualmente impostata. Puoi semplicemente codificarlo in modo hardcoded in 6, ma calcolarlo dalla matrice dei vertici (12 galleggianti / 2 coordinate per vertice == 6 vertici) significa che se dovessi decidere di sostituire il quadrato, ad esempio con un cerchio, c'è meno da aggiornare a mano.

  1. Aggiorna lo schermo e guarda (finalmente) i risultati di tutto il tuo duro lavoro: un grande quadrato colorato.

Un singolo quadrato rosso visualizzato con WebGPU

5. Disegna una griglia

Per prima cosa, prenditi un momento per congratularti con te stesso! Ottenere i primi pezzi di geometria sullo schermo è spesso uno dei passaggi più difficili per la maggior parte delle API GPU. Tutte le attività che fai qui possono essere svolte in passaggi più piccoli, così è più facile verificare i tuoi progressi man mano che procedi.

In questa sezione imparerai:

  • Come passare variabili (chiamate uniformi) allo Shar da JavaScript.
  • Come utilizzare le uniformi per modificare il comportamento di rendering.
  • Come utilizzare le istanze per disegnare molte varianti diverse della stessa geometria.

Definire la griglia

Per eseguire il rendering di una griglia, devi conoscere informazioni fondamentali a riguardo. Quante celle contiene, in larghezza e in altezza? Sei tu lo sviluppatore, ma per semplificare le cose, considera la griglia come un quadrato (stessa larghezza e altezza) e utilizza una dimensione con una potenza di due. (Questo semplifica parte dei calcoli matematici in seguito.) Alla fine vuoi ingrandire l'immagine, ma per il resto di questa sezione, imposta la dimensione della griglia su 4x4 perché semplifica la dimostrazione di alcuni dei calcoli matematici usati in questa sezione. Poi fai lo scale up.

  • Definisci la dimensione della griglia aggiungendo una costante all'inizio del codice JavaScript.

index.html

const GRID_SIZE = 4;

Poi devi aggiornare la modalità di rendering del quadrato, in modo che tu possa adattarlo per GRID_SIZE volte GRID_SIZE alla tela. Questo significa che il quadrato deve essere molto più piccolo e ce ne deve essere molti.

Un modo per poter affrontare questo problema è ingrandire significativamente il buffer di vertice e definire un valore GRID_SIZE volte GRID_SIZE di quadrati al suo interno con la dimensione e la posizione corrette. Il codice non sarebbe male, anzi. Solo un paio di loop for e un po' di calcolo. Tuttavia, ciò non consente di sfruttare al meglio la GPU e di utilizzare più memoria del necessario per ottenere l'effetto. Questa sezione esamina un approccio più compatibile con le GPU.

Crea un buffer uniforme

Innanzitutto, devi comunicare le dimensioni della griglia che hai scelto allo Shar, poiché la utilizza per modificare il modo in cui gli elementi vengono visualizzati. Potresti semplicemente codificare la dimensione nello Shar, ma poi questo significa che ogni volta che vuoi modificare la dimensione della griglia devi ricreare la pipeline di shaper e rendering, il che è costoso. Un modo migliore è fornire allo Shar le dimensioni della griglia come uniformi.

In precedenza hai imparato che un valore diverso del vertex buffer viene passato a ogni chiamata a un vertex shaker. Un'uniforme è un valore di un buffer che è lo stesso per ogni chiamata. Sono utili per comunicare i valori comuni per un elemento geometrico (come la sua posizione), un frame completo dell'animazione (come l'ora corrente) o persino l'intera durata dell'app (ad esempio una preferenza dell'utente).

  • Crea un buffer uniforme aggiungendo il seguente codice:

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

Dovrebbe esserti familiare perché è quasi esattamente lo stesso codice che hai usato in precedenza per creare il vertex buffer. Questo perché le uniformi vengono comunicate all'API WebGPU tramite gli stessi oggetti GPUBuffer che sono i vertici, con la differenza principale che usage questa volta include GPUBufferUsage.UNIFORM anziché GPUBufferUsage.VERTEX.

Accedi alle uniformi in uno Shar

  • Definisci un'uniforme aggiungendo il seguente codice:

index.html (chiamata a createShaderModule)

// 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 

Questo definisce un'uniforme nello streamr denominata grid, che è un vettore mobile 2D che corrisponde all'array appena copiato nel buffer uniforme. Inoltre, specifica che l'uniforme è vincolata ai punti @group(0) e @binding(0). Imparerai a breve cosa significano questi valori.

Quindi, altrove nel codice dello shaker, puoi usare il vettore della griglia come necessario. In questo codice devi dividere la posizione del vertice per il vettore della griglia. Poiché pos è un vettore 2D e grid è un vettore 2D, WGSL esegue una divisione in base ai componenti. In altre parole, il risultato è lo stesso di vec2f(pos.x / grid.x, pos.y / grid.y).

Questi tipi di operazioni vettoriali sono molto comuni negli streamr GPU, poiché su questi si basano molte tecniche di rendering e calcolo.

Nel tuo caso, questo significa che (se hai utilizzato una dimensione della griglia pari a 4) il quadrato che renderi sarà pari a un quarto della sua dimensione originale. Questa soluzione è perfetta se vuoi adattarne quattro a una riga o colonna.

Crea un gruppo di associazione

Tuttavia, la dichiarazione dell'uniforme nello shaker non la collega al buffer che hai creato. A questo scopo, devi creare e impostare un gruppo di associazione.

Un gruppo di associazione è una raccolta di risorse che vuoi rendere contemporaneamente accessibili allo streamr. Può includere diversi tipi di buffer, come il buffer uniforme, e altre risorse, come texture e campionatori, che non sono trattati qui, ma sono parti comuni delle tecniche di rendering WebGPU.

  • Crea un gruppo di associazione con il tuo buffer uniforme aggiungendo il codice seguente dopo la creazione del buffer uniforme e la pipeline di rendering:

index.html

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

Oltre all'elemento label ora standard, è necessaria anche una classe layout che descriva i tipi di risorse contenuti in questo gruppo di associazione. Questo è un aspetto che approfondirai più avanti in un passaggio futuro, ma per il momento puoi chiedere alla pipeline il layout del gruppo di associazione perché l'hai creata con layout: "auto". Ciò fa sì che la pipeline crei automaticamente i layout dei gruppi di associazione dalle associazioni dichiarate nel codice dello streamr. In questo caso, la chiedi a getBindGroupLayout(0), dove 0 corrisponde al @group(0) che hai digitato nello shaker.

Dopo aver specificato il layout, fornisci un array di entries. Ogni voce è un dizionario con almeno i seguenti valori:

  • binding, che corrisponde al valore @binding() inserito nello streamr. In questo caso, 0.
  • resource, ovvero la risorsa effettiva che vuoi esporre alla variabile in corrispondenza dell'indice di associazione specificato. In questo caso, si tratta del buffer uniforme.

La funzione restituisce un GPUBindGroup, ovvero un handle opaco e immutabile. Non puoi modificare le risorse a cui punta un gruppo di associazione dopo la creazione, ma puoi modificarne i contenuti. Ad esempio, se modifichi il buffer uniforme in modo che contenga una nuova dimensione della griglia, questa verrà applicata alle chiamate di disegno future utilizzando questo gruppo di associazione.

Vincola il gruppo di associazione

Ora che il gruppo di associazione è stato creato, devi ancora dire a WebGPU di utilizzarlo per disegnare. Per fortuna è molto semplice.

  1. Torna al passaggio di rendering e aggiungi questa nuova riga prima del metodo draw():

index.html

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

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

pass.draw(vertices.length / 2);

Il valore 0 passato come primo argomento corrisponde al valore @group(0) nel codice dello shaker. Stai dicendo che ogni @binding che fa parte di @group(0) utilizza le risorse in questo gruppo di associazione.

E ora il buffer uniforme è esposto al tuo ombrellone!

  1. Aggiorna la pagina. Il risultato dovrebbe essere simile al seguente:

Un piccolo quadrato rosso al centro di uno sfondo blu scuro.

Evviva! Ora il quadrato misura un quarto delle dimensioni di prima. Non è molto, ma mostra che la tua uniforme è stata effettivamente applicata e che ora lo shaker può accedere alle dimensioni della griglia.

Manipolare la geometria nello shaker

Ora che puoi fare riferimento alle dimensioni della griglia nello streamr, puoi iniziare a lavorare per modificare la geometria di rendering in modo che si adatti al pattern di griglia desiderato. A questo scopo, pensa esattamente a ciò che vuoi ottenere.

Devi concettualmente suddividere il canvas in singole celle. Per mantenere la convenzione secondo cui l'asse X aumenta man mano che ci si sposta a destra e l'asse Y aumenta con lo spostamento verso l'alto, supponiamo che la prima cella si trovi nell'angolo in basso a sinistra del canvas. Viene visualizzato un layout simile al seguente, con la geometria quadrata corrente al centro:

Un&#39;illustrazione della griglia concettuale lo spazio delle coordinate del dispositivo normalizzato verrà diviso durante la visualizzazione di ogni cella con la geometria quadrata attualmente visualizzata al centro.

La sfida è trovare un metodo nello Shar che ti consenta di posizionare la geometria quadrata in una di queste celle date le relative coordinate.

Innanzitutto, puoi vedere che il quadrato non è allineato bene con nessuna delle celle perché è stato definito per circondare il centro dell'area di lavoro. Ti consigliamo di spostare il quadrato di metà cella in modo che si allinea perfettamente al suo interno.

Un modo per risolvere questo problema è aggiornare il buffer di vertice del quadrato. Spostando i vertici in modo che l'angolo in basso a destra si trovi, ad esempio, a (0,1, 0,1) invece di (-0,8, -0,8), sposterai questo quadrato per allinearsi meglio con i confini delle celle. Dato che hai il pieno controllo sul modo in cui i vertici vengono elaborati nello streamr, è altrettanto facile posizionarli usando il codice dello shaker.

  1. Modifica il modulo Vertex Shar con il seguente codice:

index.html (chiamata createShaderModule)

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

Ogni vertice verso l'alto e verso destra viene spostato di uno (che, ricorda, è la metà dello spazio del clip) prima di dividerlo per la dimensione della griglia. Il risultato è un quadrato allineato alla griglia appena fuori dall'origine.

Una visualizzazione del canvas concettualmente suddiviso in una griglia 4 x 4 con un quadrato rosso nella cella (2, 2)

Poiché il sistema di coordinate della tela pone (0, 0) al centro e (-1, -1) in basso a sinistra e vuoi che (0, 0) si trovi in basso a sinistra, devi tradurre la posizione della geometria per (-1, -1) dopo la divisione per la dimensione della griglia per spostarla in quell'angolo.

  1. Traduci la posizione della geometria in questo modo:

index.html (chiamata createShaderModule)

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

E ora il quadrato è ben posizionato nella cella (0, 0).

Una visualizzazione del canvas concettualmente suddiviso in una griglia 4 x 4 con un quadrato rosso nella cella (0, 0)

E se volessi inserirlo in un'altra cella? Per capirlo, dichiara un vettore cell nello shaker e compila con un valore statico come let cell = vec2f(1, 1).

Se lo aggiungi a gridPos, viene annullato il - 1 nell'algoritmo, quindi non è ciò che vuoi. Dovrai invece spostare il quadrato solo di un'unità della griglia (un quarto del canvas) per ogni cella. Sembra che tu debba dividere un'altra parte per grid.

  1. Modifica il posizionamento della griglia, in questo modo:

index.html (chiamata createShaderModule)

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

Se aggiorni ora, vedrai quanto segue:

Una visualizzazione del canvas concettualmente suddiviso in una griglia 4x4 con un quadrato rosso al centro tra la cella (0, 0), la cella (0, 1), la cella (1, 0) e la cella (1, 1)

Mmm. Non è esattamente quello che volevi.

Il motivo è che, poiché le coordinate del canvas vanno da -1 a +1, il risultato è in realtà pari a 2 unità. Ciò significa che se vuoi spostare un vertice di un quarto della tela, devi spostarlo di 0,5 unità. È un facile errore da commettere quando si ragiona con le coordinate della GPU. Per fortuna, risolvere il problema è altrettanto semplice.

  1. Moltiplica l'offset per 2, in questo modo:

index.html (chiamata createShaderModule)

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

In questo modo ottieni esattamente ciò che vuoi.

Una visualizzazione del canvas concettualmente suddiviso in una griglia 4 x 4 con un quadrato rosso nella cella (1, 1)

Lo screenshot ha il seguente aspetto:

Screenshot di un quadrato rosso su uno sfondo blu scuro. Il quadrato rosso è stato disegnato nella stessa posizione come descritto nel diagramma precedente, ma senza l&#39;overlay della griglia.

Inoltre, ora puoi impostare cell su qualsiasi valore compreso nei limiti della griglia e poi aggiornare per visualizzare il rendering quadrato nella posizione che preferisci.

Disegna istanze

Ora che puoi posizionare il quadrato dove preferisci con un po' di calcolo, il passaggio successivo è eseguire il rendering di un quadrato in ogni cella della griglia.

Un modo per farlo è scrivere le coordinate delle celle in un buffer uniforme e chiamare draw una volta per ogni quadrato nella griglia, aggiornando l'uniforme ogni volta. Questo sarebbe molto lento, tuttavia, poiché la GPU deve attendere ogni volta che la nuova coordinata venga scritta da JavaScript. Uno dei fattori chiave per ottenere buone prestazioni dalla GPU è ridurre al minimo il tempo di attesa su altre parti del sistema.

Puoi invece utilizzare una tecnica chiamata istanza. Le istanze sono un modo per indicare alla GPU di disegnare più copie della stessa geometria con una singola chiamata a draw, metodo che è molto più veloce rispetto alla chiamata a draw una volta per ogni copia. Ogni copia della geometria è indicata come istanza.

  1. Per indicare alla GPU che vuoi un numero sufficiente di istanze del quadrato per riempire la griglia, aggiungi un argomento alla chiamata di disegno esistente:

index.html

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

Questo indica al sistema che vuoi che disegna i sei (vertices.length / 2) vertici del quadrato 16 (GRID_SIZE * GRID_SIZE) volte. Tuttavia, se aggiorni la pagina, vedrai ancora quanto segue:

Un&#39;immagine identica al diagramma precedente, per indicare che non è cambiato nulla.

Perché? Beh, perché si disegna tutti e 16 quei quadrati nello stesso punto. Devi avere una logica aggiuntiva nello shaker che riposiziona la geometria di volta in volta.

Nello Shader, oltre agli attributi del vertice come pos provenienti dal tuo vertex buffer, puoi anche accedere ai cosiddetti valori integrati di WGSL. Si tratta di valori calcolati da WebGPU e uno di questi valori è instance_index. instance_index è un numero a 32 bit non firmato da 0 a number of instances - 1 che puoi utilizzare come parte della logica del tuo shaker. Il suo valore è uguale per ogni vertice elaborato che fa parte della stessa istanza. Ciò significa che il tuo vertex shaker viene chiamato sei volte con un valore instance_index di 0, una per ogni posizione nel tuo vertex buffer. Poi altre sei volte con instance_index di 1, poi altre sei con instance_index di 2 e così via.

Per vedere questa funzionalità in azione, devi aggiungere l'instance_index integrata ai tuoi input dello shaker. Esegui questa operazione come per la posizione, ma invece di codificarla con un attributo @location, utilizza @builtin(instance_index) e assegna all'argomento il nome che preferisci. Puoi chiamarla instance in modo che corrisponda al codice di esempio. Poi usalo come parte della logica dello shaker.

  1. Usa instance al posto delle coordinate della cella:

index.html

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

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

  return vec4f(gridPos, 0, 1);
}

Se aggiorni la pagina, ora puoi vedere che è presente più di un quadrato. Ma non puoi vederli tutti e 16.

Quattro quadrati rossi in una linea diagonale dall&#39;angolo in basso a sinistra all&#39;angolo in alto a destra su uno sfondo blu scuro.

Questo perché le coordinate delle celle che generi sono (0, 0), (1, 1), (2, 2) fino a (15, 15), ma solo le prime quattro di queste rientrano nella tela. Per creare la griglia desiderata, devi trasformare instance_index in modo che ogni indice venga mappato a una cella unica all'interno della griglia, in questo modo:

Una visualizzazione del canvas concettualmente suddiviso in una griglia 4x4 con ogni cella corrispondente anche a un indice di istanza lineare.

I calcoli matematici sono ragionevolmente semplici. Per il valore X di ogni cella, desideri il modulo di instance_index e la larghezza della griglia, che puoi eseguire in WGSL con l'operatore %. E per il valore Y di ogni cella vuoi che instance_index venga diviso per la larghezza della griglia, ignorando il resto frazionario. Puoi farlo con la funzione floor() di WGSL.

  1. Modifica i calcoli in questo modo:

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

Dopo aver apportato quell'aggiornamento al codice, ecco finalmente la griglia dei quadrati tanto attesi!

Quattro righe di quattro colonne di quadrati rossi su uno sfondo blu scuro.

  1. E ora che funziona, torna indietro e aumenta la dimensione della griglia!

index.html

const GRID_SIZE = 32;

32 righe di 32 colonne di quadrati rossi su uno sfondo blu scuro.

Ehilà! Puoi rendere questa griglia davvero molto grande ora e la tua GPU media ce la fa a gestire bene. Non vedrai più i singoli quadrati molto prima di riscontrare colli di bottiglia delle prestazioni della GPU.

6. Riconoscimento extra: rendilo più colorato!

A questo punto, puoi facilmente passare alla sezione successiva, dal momento che hai gettato le basi per il resto del codelab. Ma mentre la griglia di quadrati tutti dello stesso colore è utilizzabile, non è esattamente eccitante, vero? Fortunatamente puoi rendere tutto un po' più bravo con un po' di calcolo più matematico e un po' di codice in uno shaker.

Usare gli struct negli Shaper

Fino ad ora, hai passato un dato di Vertex Shader: la posizione trasformata. In realtà, puoi restituire molti più dati da Vertex Shader e utilizzarli per il Snippet Shader.

L'unico modo per trasferire i dati da Vertex Shader è restituirli. Poiché è sempre necessario restituire una posizione, uno ombreggiatore vertex deve sempre restituire una posizione, quindi se vuoi restituire anche altri dati, devi inserirlo in uno struct. Gli struct in WGSL sono tipi di oggetti denominati che contengono una o più proprietà con nome. È possibile eseguire il markup delle proprietà anche con attributi come @builtin e @location. Le dichiari al di fuori di qualsiasi funzione e poi puoi trasferire le rispettive istanze all'interno e all'esterno delle funzioni, se necessario. Ad esempio, considera il tuo attuale Vertex Shader:

index.html (chiamata createShaderModule)

@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);
}
  • Esprimi la stessa cosa utilizzando gli struct per l'input e l'output della funzione:

index.html (chiamata createShaderModule)

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

Tieni presente che devi fare riferimento alla posizione di input e all'indice dell'istanza con input e lo struct restituito per primo deve essere dichiarato come variabile e avere le singole proprietà impostate. In questo caso non fa troppa differenza e, anzi, aumenta il suo funzionamento un po' più a lungo, ma man mano che i tuoi mapping diventano più complessi, l'utilizzo degli struct può essere un ottimo modo per organizzare i dati.

Trasmettere dati tra le funzioni vertice e di frammento

Ti ricordiamo che la funzione @fragment è il più semplice possibile:

index.html (chiamata createShaderModule)

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

Non stai prendendo input e stai trasmettendo un colore a tinta unita (rosso) come output. Se invece sapessi di più sulla geometria che sta colorando, potresti usare quei dati aggiuntivi per rendere le cose un po' più interessanti. Ad esempio, cosa succede se vuoi modificare il colore di ogni quadrato in base alla coordinata della cella? Lo stage @vertex sa quale cella viene visualizzata. devi solo passarlo alla fase @fragment.

Per trasferire dati tra le fasi di vertice e frammento, devi includerli in uno struct di output con un @location a tua scelta. Poiché vuoi passare la coordinata della cella, aggiungila allo struct VertexOutput di prima, quindi impostala nella funzione @vertex prima di tornare.

  1. Modifica il valore restituito del vertex shaker nel seguente modo:

index.html (chiamata createShaderModule)

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. Nella funzione @fragment, ricevi il valore aggiungendo un argomento con lo stesso @location. I nomi non devono necessariamente corrispondere, ma è più facile tenere traccia di determinate cose.

index.html (chiamata createShaderModule)

@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. In alternativa, puoi utilizzare uno struct:

index.html (chiamata createShaderModule)

struct FragInput {
  @location(0) cell: vec2f,
};

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. Un'altra alternativa**,** dal momento che entrambe queste funzioni sono definite nel codice nello stesso modulo shaper, è riutilizzare lo struct di output dello stage @vertex. Questo semplifica il passaggio dei valori perché i nomi e le località sono naturalmente coerenti.

index.html (chiamata createShaderModule)

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

A prescindere dal pattern scelto, il risultato è che avrai accesso al numero di cella nella funzione @fragment e potrai utilizzarlo per influenzare il colore. Con uno qualsiasi del codice riportato sopra, l'output è simile al seguente:

Una griglia di quadrati in cui la colonna più a sinistra è verde, la riga inferiore è rossa e tutti gli altri quadrati sono gialli.

Ora ci sono sicuramente più colori, ma non è proprio bello. Potresti chiederti perché solo la riga di sinistra e quella in basso sono diverse. Questo perché i valori del colore che restituisci dalla funzione @fragment si aspettano che ogni canale sia nell'intervallo da 0 a 1 e tutti i valori al di fuori di questo intervallo vengono bloccati. I valori delle celle, invece, sono compresi tra 0 e 32 su ciascun asse. Qui puoi vedere che la prima riga e la prima colonna hanno immediatamente raggiunto il valore 1 completo sul canale del colore rosso o verde e ogni cella successiva viene bloccata sullo stesso valore.

Se vuoi una transizione più fluida tra i colori, devi restituire un valore frazionario per ogni canale colore, idealmente partendo da zero e terminando con uno su ogni asse, il che significa un'altra divisione per grid.

  1. Cambia lo shaker dei frammenti in questo modo:

index.html (chiamata createShaderModule)

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

Aggiorna la pagina e vedrai che il nuovo codice offre una sfumatura di colori molto più bella in tutta la griglia.

Una griglia di quadrati che passano dal nero, al rosso, al verde e al giallo in angoli diversi.

Anche se questo è sicuramente un miglioramento, c'è ora uno sfortunato angolo buio in basso a sinistra, dove la griglia diventa nera. Quando inizi la simulazione Gioco della vita, una sezione della griglia difficile da vedere oscurerà ciò che sta succedendo. Sarebbe bello ravvivare l'attenzione.

Fortunatamente, disponi di un intero canale colore inutilizzato, il blu, che puoi utilizzare. Idealmente, l'effetto desiderato è fare in modo che il blu sia più luminoso dove gli altri colori sono più scuri per poi scomparire man mano che gli altri colori aumentano di intensità. Il modo più semplice per farlo è fare in modo che il canale inizi con 1 e sottragga uno dei valori della cella. Può essere c.x o c.y. Prova entrambi e poi scegli quello che preferisci.

  1. Aggiungi colori più accesi allo shaker per frammenti, in questo modo:

chiamata createShaderModule

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

Il risultato è davvero bello.

Una griglia di quadrati che passano dal rosso, al verde, al blu al giallo in diversi angoli.

Non si tratta di un passaggio fondamentale. Poiché ha un aspetto migliore, è incluso nel file sorgente dei checkpoint corrispondente e gli altri screenshot di questo codelab riflettono questa griglia più colorata.

7. Gestisci lo stato della cella

Poi devi controllare quali celle della griglia vengono visualizzate, in base a uno stato memorizzato nella GPU. Questo è importante per la simulazione finale.

Tutto ciò di cui hai bisogno è un segnale di attivazione e disattivazione per ogni cella, quindi tutte le opzioni che ti consentono di archiviare un ampio array di quasi tutti i tipi di valori funzionano. Si potrebbe pensare che questo sia un altro caso d'uso per i buffer uniformi. Anche se potresti risolvere questo problema, è più difficile perché i buffer uniformi hanno dimensioni limitate, non possono supportare array con dimensioni dinamiche (devi specificare la dimensione dell'array nello Shar) e non possono essere scritti da Compute Skillsr. Quest'ultimo elemento è l'aspetto più problematico, dato che vuoi eseguire la simulazione Game of Life sulla GPU in un computing shaker.

Fortunatamente, esiste un'altra opzione di buffer che evita tutte queste limitazioni.

Crea un buffer di archiviazione

I buffer di archiviazione sono buffer per uso generico che possono essere letti e scritti in Compute Skillsr e in Vertex Shader. Possono essere molto grandi e non hanno bisogno di una specifica dimensione dichiarata in uno streamr, il che li rende molto più simili alla memoria generale. È quello che utilizzi per archiviare lo stato della cella.

  1. Per creare un buffer di archiviazione per lo stato della tua cella, utilizza probabilmente uno snippet di codice per la creazione del buffer dall'aspetto familiare:

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

Proprio come per i vertici e i buffer uniformi, chiama device.createBuffer() con la dimensione appropriata, quindi assicurati di specificare un utilizzo di GPUBufferUsage.STORAGE questa volta.

Puoi completare il buffer nello stesso modo di prima riempiendo il campo TypedArray della stessa dimensione con valori e quindi chiamando device.queue.writeBuffer(). Poiché vuoi vedere l'effetto del buffer sulla griglia, inizia riempiendolo con qualcosa di prevedibile.

  1. Attiva ogni terza cella con il seguente codice:

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

Leggi il buffer di archiviazione nello shaker

Dopodiché aggiorna il tuo shaker in modo che esamini i contenuti del buffer di archiviazione prima di eseguire il rendering della griglia. Questo aspetto è molto simile all'aggiunta delle uniformi in precedenza.

  1. Aggiorna lo shaker con il seguente codice:

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!

In primo luogo, aggiungi il punto di associazione, che si infila sotto l'uniforme della griglia. Vuoi mantenere lo stesso @group dell'uniforme grid, ma il numero di @binding deve essere diverso. Il tipo var è storage, per riflettere il diverso tipo di buffer e, anziché un singolo vettore, il tipo che fornisci per cellState è un array di valori u32, in modo da abbinare Uint32Array in JavaScript.

Successivamente, nel corpo della funzione @vertex, esegui una query sullo stato della cella. Poiché lo stato è archiviato in un array flat nel buffer di archiviazione, puoi usare instance_index per cercare il valore della cella attuale.

Come si disattiva una cella se lo stato indica che non è attiva? Beh, poiché gli stati attivi e inattivi che ottieni dall'array sono 1 o 0, puoi scalare la geometria in base allo stato attivo! La scalabilità per 1 lascia invariata la geometria, mentre se la scala per 0 la geometria viene compressa in un singolo punto, che poi viene scartato dalla GPU.

  1. Aggiorna il codice del tuo shaker per scalare la posizione in base allo stato attivo della cella. Il valore dello stato deve essere trasmesso a f32 per soddisfare i requisiti di sicurezza del tipo di WGSL:

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

Aggiungi il buffer di archiviazione al gruppo di associazione

Prima di poter vedere che lo stato della cella diventa effettivo, aggiungi il buffer di archiviazione a un gruppo di associazione. Poiché fa parte dello stesso @group del buffer uniforme, aggiungilo anche allo stesso gruppo di associazione nel codice JavaScript.

  • Aggiungi il buffer di archiviazione, in questo modo:

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

Assicurati che il valore binding della nuova voce corrisponda al valore @binding() del valore corrispondente nello shaker.

Con questo comando, dovresti essere in grado di aggiornare e vedere il pattern visualizzato nella griglia.

Strisce diagonali di quadrati colorati che vanno dal basso a sinistra in alto a destra su uno sfondo blu scuro.

Usa il pattern di buffer del ping-pong

La maggior parte delle simulazioni come quella che stai creando in genere utilizza almeno due copie del proprio stato. In ogni fase della simulazione, leggono da una copia dello stato e scrivono nell'altra. Poi, nel passaggio successivo, capovolgi il documento e leggi lo stato in cui ha scritto in precedenza. Questo viene comunemente chiamato pattern ping pong perché la versione più aggiornata dello stato si sposta continuamente tra le copie di ogni passaggio e viceversa.

Perché è necessario? Guarda un esempio semplificato: immagina di scrivere una simulazione molto semplice in cui sposti tutti i blocchi attivi di una cella in ogni passaggio. Per semplificare la comprensione, definisci i dati e la simulazione 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.

Ma se esegui quel codice, la cella attiva si sposta fino alla fine dell'array in un solo passaggio! Perché? Perché continui ad aggiornare lo stato attuale, quindi sposti la cella attiva verso destra, poi guardi alla cella successiva e... ehi! È attivo! Meglio spostarlo di nuovo a destra. Il fatto che modifichi i dati nello stesso momento in cui noti compromette i risultati.

Utilizzando il pattern del ping pong, ti assicuri di eseguire sempre il passaggio successivo della simulazione utilizzando solo i risultati dell'ultimo passaggio.

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

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

// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA); 
  1. Utilizza questo pattern nel tuo codice aggiornando l'allocazione del buffer di archiviazione in modo da creare due buffer identici:

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. Per visualizzare la differenza tra i due buffer, riempili con dati diversi:

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. Per mostrare i diversi buffer di archiviazione nel rendering, aggiorna i gruppi di associazione in modo che abbiano anche due varianti diverse:

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

Impostare un loop di rendering

Finora, hai eseguito una sola estrazione per aggiornamento di pagina, ma ora vuoi mostrare i dati che vengono aggiornati nel tempo. A questo scopo, ti serve un semplice loop di rendering.

Un loop di rendering è un loop continuo ininterrotto che attira i contenuti sulla tela a un determinato intervallo. Molti giochi e altri contenuti che si animano senza problemi utilizzano la funzione requestAnimationFrame() per programmare callback alla stessa frequenza di aggiornamento dello schermo (60 volte al secondo).

Anche questa app può utilizzare questo comando, ma in questo caso probabilmente è opportuno che gli aggiornamenti vengano eseguiti in passaggi più lunghi, in modo da poter seguire più facilmente lo svolgimento della simulazione. Gestisci il loop autonomamente in modo da poter controllare la frequenza di aggiornamento della simulazione.

  1. Per prima cosa, scegli una velocità di aggiornamento della simulazione (200 ms è un buon risultato, ma puoi rallentare o accelerare se vuoi) e poi tenere traccia di quanti passaggi della simulazione sono stati completati.

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. Quindi, sposta tutto il codice attualmente utilizzato per il rendering in una nuova funzione. Programma la ripetizione della funzione all'intervallo che preferisci con setInterval(). Assicurati che la funzione aggiorni anche il conteggio dei passi e utilizzalo per scegliere quale dei due gruppi di associazione da associare.

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

E ora, quando esegui l'app, vedi che il canvas si alterna continuamente per mostrare i due buffer di stato che hai creato.

Strisce diagonali di quadrati colorati che vanno dal basso a sinistra in alto a destra su uno sfondo blu scuro. Strisce verticali di quadrati colorati su uno sfondo blu scuro.

Hai quasi finito per il rendering. È tutto pronto per visualizzare l'output della simulazione Gioco della vita creata nel passaggio successivo, in cui finalmente inizierai a utilizzare i computing Shader.

Ovviamente, le capacità di rendering di WebGPU sono molto di più della piccola sezione che hai esplorato qui, ma il resto non rientra nell'ambito di questo codelab. Speriamo, però, che abbiate abbastanza un'idea di come funziona il rendering di WebGPU, che aiuta a semplificare l'esplorazione di tecniche più avanzate come il rendering 3D.

8. Esegui la simulazione

Ora passiamo all'ultimo pezzo importante del puzzle: l'esecuzione della simulazione Gioco della vita in un computing.

Finalmente usa gli strumenti per la creazione di oggetti di computing.

In questo codelab hai appreso in modo astratto i componenti di Compute Shader, ma cosa sono esattamente?

Un computing shaker è simile a quello dei vertici e dei frammenti in quanto è progettato per essere eseguito con un parallelismo estremo sulla GPU, ma a differenza delle altre due fasi, non ha un insieme specifico di input e output. Leggi e scrivi dati esclusivamente da origini di tua scelta, come i buffer di archiviazione. Ciò significa che invece di eseguire una volta per ogni vertice, istanza o pixel, devi indicare quante chiamate alla funzione shaker vuoi. Quindi, quando esegui lo shaker, ti viene comunicato quale chiamata viene elaborata e puoi decidere a quali dati accedere e quali operazioni eseguirai da lì.

I Compute Shader devono essere creati in un modulo Shader, proprio come Vertex e Snippet Shader, quindi aggiungili al codice per iniziare. Come puoi intuire, data la struttura degli altri Shar che hai implementato, la funzione principale per il tuo staging di computing deve essere contrassegnata con l'attributo @compute.

  1. Crea uno shaker di computing con il codice seguente:

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

    }`
});

Poiché le GPU vengono utilizzate di frequente per la grafica 3D, i computing shaker sono strutturati in modo che sia possibile richiedere che venga richiamato un numero specifico di volte lungo gli assi X, Y e Z. In questo modo puoi inviare molto facilmente lavori conformi a una griglia 2D o 3D, il che è ottimo per il tuo caso d'uso. Vuoi chiamare questo Shader GRID_SIZE volte GRID_SIZE volte, una per ogni cella della simulazione.

A causa della natura dell'architettura hardware della GPU, questa griglia è divisa in gruppi di lavoro. Un gruppo di lavoro ha una dimensione X, Y e Z e, sebbene le dimensioni possano essere 1 ciascuna, l'aumento delle dimensioni dei gruppi di lavoro comporta spesso dei vantaggi in termini di prestazioni. Per il tuo shaker, scegli una dimensione del gruppo di lavoro un po' arbitraria di 8 per 8. È utile tenerne traccia nel codice JavaScript.

  1. Definisci una costante per la dimensione del gruppo di lavoro, in questo modo:

index.html

const WORKGROUP_SIZE = 8;

Devi anche aggiungere la dimensione del gruppo di lavoro alla funzione shaker stessa, operazione che puoi eseguire utilizzando i valori letterali modello di JavaScript in modo da poter utilizzare facilmente la costante appena definita.

  1. Aggiungi la dimensione del gruppo di lavoro alla funzione Shar, in questo modo:

index.html (chiamata Compute createShaderModule)

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

}

Questo indica allo shaker che il lavoro eseguito con questa funzione viene svolto in gruppi (8 x 8 x 1). (Qualsiasi asse non attivo è impostato sul valore predefinito 1, anche se devi specificare almeno l'asse X).

Come per le altre fasi dello shaker, sono disponibili diversi valori @builtin che puoi accettare come input nella funzione di builder di computing per indicarti quale chiamata stai utilizzando e decidere quale lavoro devi eseguire.

  1. Aggiungi un valore @builtin, in questo modo:

index.html (chiamata Compute createShaderModule)

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

}

Passi l'global_invocation_id integrato, che è un vettore tridimensionale di numeri interi senza segno che ti indica in che punto della griglia di chiamate dello shaker ti trovi. Esegui questo Shar una volta per ogni cella della griglia. Si ottengono numeri come (0, 0, 0), (1, 0, 0), (1, 1, 0)... fino a (31, 31, 0), il che significa che si può trattare come l'indice di cella su cui si sta lavorando!

I responsabili di computing possono usare anche le uniformi, che puoi usare proprio come negli Shader vertex e nei frammenti.

  1. Usa un'uniforme con il tuo builder di computing per indicarti le dimensioni della griglia, in questo modo:

index.html (chiamata Compute createShaderModule)

@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) {

}

Proprio come in Vertex Shader, puoi anche esporre lo stato della cella come buffer di archiviazione. Ma in questo caso, te ne servono due. Dal momento che i responsabili di computing non hanno un output richiesto, come la posizione del vertice o il colore dei frammenti, scrivere i valori in un buffer di archiviazione o una texture è l'unico modo per ottenere risultati da uno shaker di computing. Usa il metodo del ping-pong che hai imparato in precedenza: hai un buffer di archiviazione che alimenta lo stato attuale della griglia e uno in cui trascrivi il nuovo stato della griglia.

  1. Esponi lo stato di input e output della cella come buffer di archiviazione, in questo modo:

index.html (chiamata Compute createShaderModule)

@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) {

}

Tieni presente che il primo buffer di archiviazione viene dichiarato con var<storage>, il che lo rende di sola lettura, mentre il secondo buffer di archiviazione viene dichiarato con var<storage, read_write>. In questo modo è possibile leggere e scrivere nel buffer, utilizzandolo come output per il tuo computeshar. (Non è disponibile una modalità di archiviazione di sola scrittura in WebGPU).

Poi devi avere un modo per mappare l'indice di cella nell'array di archiviazione lineare. Fondamentalmente è l'opposto di quello che hai fatto nel vertex Shar, dove hai preso il instance_index lineare e l'hai mappato a una cella della griglia 2D. Ti ricordiamo che l'algoritmo utilizzato per questa richiesta era vec2f(i % grid.x, floor(i / grid.x)).

  1. Scrivi una funzione per andare nella direzione opposta. prende il valore Y della cella, lo moltiplica per la larghezza della griglia e poi aggiunge il valore X della cella.

index.html (chiamata Compute createShaderModule)

@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) {
  
}

Infine, per vedere se funziona, implementa un algoritmo molto semplice: se una cella è attualmente attiva, si disattiva e viceversa. Non si tratta ancora del Gioco della vita, ma è sufficiente per dimostrare che il computing shaker funziona.

  1. Aggiungi l'algoritmo semplice, come questo:

index.html (chiamata Compute createShaderModule)

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

Per il momento è tutto per il computing. Prima di vedere i risultati, però, è necessario apportare alcune altre modifiche.

Utilizzare i layout di gruppi di associazioni e pipeline

Una cosa che potresti notare con lo Shar precedente è che utilizza in gran parte gli stessi input (uniforme e buffer di archiviazione) della pipeline di rendering. Quindi potresti pensare di poter semplicemente utilizzare gli stessi gruppi di associazione e di finire qui, giusto? La buona notizia è che puoi farlo. Per farlo, occorre solo un po' più di configurazione manuale.

Ogni volta che crei un gruppo di associazione, devi fornire un elemento GPUBindGroupLayout. In precedenza, il layout veniva ottenuto chiamando getBindGroupLayout() sulla pipeline di rendering, che a sua volta lo creava automaticamente perché avevi fornito layout: "auto" al momento della creazione. Questo approccio funziona bene quando utilizzi una sola pipeline, ma se hai più pipeline che vogliono condividere risorse, devi creare il layout in modo esplicito e quindi fornirlo sia al gruppo di associazione che alle pipeline.

Per capire il perché, devi considerare questo: nelle tue pipeline di rendering utilizzi un singolo buffer uniforme e un singolo buffer di archiviazione, ma nello shaker di computing che hai appena scritto hai bisogno di un secondo buffer di archiviazione. Poiché i due Shar utilizzano gli stessi valori @binding per il buffer uniforme e per il primo buffer di archiviazione, puoi condividerli tra le pipeline e la pipeline di rendering ignora il secondo buffer di archiviazione, che non utilizza. Vuoi creare un layout che descriva tutte le risorse presenti nel gruppo di associazione, non solo quelle utilizzate da una pipeline specifica.

  1. Per creare il layout, chiama device.createBindGroupLayout():

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

La struttura è simile alla creazione del gruppo di associazione stesso, in quanto descrivi un elenco di entries. La differenza è che devi descrivere il tipo di risorsa che deve essere la voce e come viene utilizzata, anziché fornire la risorsa stessa.

In ogni voce, fornisci il numero binding per la risorsa, che (come hai appreso quando hai creato il gruppo di associazione) corrisponde al valore @binding negli Shaper. Devi fornire anche i visibility, che sono flag GPUShaderStage che indicano quali fasi dello snapshot possono utilizzare la risorsa. Vuoi che sia il primo buffer di archiviazione uniforme sia il primo buffer di archiviazione siano accessibili in Vertex e Compute Engine, ma il secondo buffer di archiviazione deve essere accessibile solo nei computing Shader.

Infine, devi indicare il tipo di risorsa utilizzata. Si tratta di una chiave del dizionario diversa, a seconda di ciò che devi esporre. In questo caso, tutte e tre le risorse sono buffer, quindi devi utilizzare la chiave buffer per definire le opzioni per ciascuna. Altre opzioni includono texture o sampler, ma non sono necessarie.

Nel dizionario del buffer, puoi impostare opzioni come type di buffer utilizzato. Il valore predefinito è "uniform", quindi puoi lasciare vuoto il dizionario per associare 0. Tuttavia, è necessario impostare almeno il valore buffer: {}, in modo che la voce venga identificata come buffer. All'associazione 1 viene assegnato un tipo "read-only-storage" perché non lo usi con accesso read_write nello shaker e l'associazione 2 ha un tipo "storage" perché lo usi con accesso read_write.

Una volta creato l'oggetto bindGroupLayout, puoi passarlo durante la creazione dei gruppi di associazione anziché eseguire una query sul gruppo di associazione dalla pipeline. Ciò significa che devi aggiungere una nuova voce del buffer di archiviazione a ciascun gruppo di associazione in modo che corrisponda al layout appena definito.

  1. Aggiorna la creazione del gruppo di associazione in questo modo:

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

Ora che il gruppo di associazione è stato aggiornato in modo da utilizzare questo layout esplicito del gruppo di associazione, devi aggiornare la pipeline di rendering per utilizzare la stessa cosa.

  1. Crea una GPUPipelineLayout.

index.html

const pipelineLayout = device.createPipelineLayout({
  label: "Cell Pipeline Layout",
  bindGroupLayouts: [ bindGroupLayout ],
});

Un layout di pipeline è un elenco di layout di gruppi di associazione (in questo caso, ne hai uno) utilizzati da una o più pipeline. L'ordine dei layout dei gruppi di associazione nell'array deve corrispondere agli attributi @group negli Shaper. Ciò significa che bindGroupLayout è associato a @group(0).

  1. Una volta ottenuto il layout della pipeline, aggiorna la pipeline di rendering in modo da utilizzarla al posto di "auto".

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

Crea la pipeline di computing

Proprio come hai bisogno di una pipeline di rendering per utilizzare i tuoi Shader vertex e fragments, hai bisogno di una pipeline di computing per usare il tuo shaker Compute. Fortunatamente, le pipeline di computing sono molto meno complicate delle pipeline di rendering, in quanto non hanno alcun stato da impostare, ma solo lo shaker e il layout.

  • Crea una pipeline di computing con il codice seguente:

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

Nota che passi il nuovo valore pipelineLayout anziché "auto", proprio come la pipeline di rendering aggiornata, in modo da garantire che sia la pipeline di rendering sia la pipeline di computing possano utilizzare gli stessi gruppi di associazione.

Tessere per il calcolo

Questo ti porta a utilizzare effettivamente la pipeline di computing. Dato che esegui il rendering in un passaggio di rendering, probabilmente puoi indovinare che devi svolgere il lavoro di computing in un pass di computing. Il lavoro di calcolo e rendering può avvenire entrambi nello stesso codificatore di comandi, quindi è opportuno eseguire un po' di shuffling della funzione updateGrid.

  1. Sposta la creazione dell'encoder nella parte superiore della funzione, quindi inizia una pass di computing (prima di 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...

Proprio come le pipeline di calcolo, le tessere di computing sono molto più semplici da avviare rispetto alle relative controparti di rendering, perché non devi preoccuparti degli allegati.

Vuoi eseguire il pass di computing prima del pass di rendering perché consente al pass di rendering di utilizzare immediatamente gli ultimi risultati della pass di computing. Questo è anche il motivo per cui aumenti il conteggio di step tra i passaggi, in modo che il buffer di output della pipeline di computing diventi il buffer di input per la pipeline di rendering.

  1. Quindi, imposta la pipeline e il gruppo di associazione all'interno del pass di computing, utilizzando lo stesso pattern per il passaggio da un gruppo di associazione all'altro come faresti per il passaggio di rendering.

index.html

const computePass = encoder.beginComputePass();

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

computePass.end();
  1. Infine, invece di disegnare come in un passaggio di rendering, invii il lavoro allo shaker di computing, indicando quanti gruppi di lavoro vuoi eseguire su ciascun asse.

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

Una cosa molto importante da notare è che il numero che passi in dispatchWorkgroups() non è il numero di chiamate. Si tratta invece del numero di gruppi di lavoro da eseguire, come definito da @workgroup_size nel tuo shaker.

Se vuoi che lo Shar venga eseguito 32x32 volte al fine di coprire l'intera griglia e la dimensione del tuo gruppo di lavoro è 8x8, devi inviare gruppi di lavoro 4x4 (4 * 8 = 32). Ecco perché dividi la dimensione della griglia per la dimensione del gruppo di lavoro e passi quel valore in dispatchWorkgroups().

Ora puoi aggiornare di nuovo la pagina. Dovresti vedere che la griglia si inverte a ogni aggiornamento.

Strisce diagonali di quadrati colorati che vanno dal basso a sinistra in alto a destra su uno sfondo blu scuro. Strisce diagonali di quadrati colorati due quadrati larghi dal basso a sinistra in alto a destra su uno sfondo blu scuro. L&#39;inversione dell&#39;immagine precedente.

Implementare l'algoritmo per il gioco della vita

Prima di aggiornare lo shaker Compute per implementare l'algoritmo finale, devi tornare al codice che inizializza i contenuti del buffer di archiviazione e aggiornarlo in modo da produrre un buffer casuale a ogni caricamento pagina. Gli schemi regolari non sono punti di partenza molto interessanti per Il gioco della vita. Puoi randomizzare i valori come preferisci, ma esiste un modo semplice per iniziare che fornisce risultati ragionevoli.

  1. Per avviare ogni cella in uno stato casuale, aggiorna l'inizializzazione di cellStateArray al seguente codice:

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

Ora puoi finalmente implementare la logica per la simulazione Gioco della vita. Dopo tutto ciò che ci è voluto per arrivare a questo punto, il codice dello shaker potrebbe essere semplice e deludente.

Innanzitutto, devi sapere, per ogni cella, quanti dei suoi vicini sono attivi. Non ti interessa sapere quali sono attive, ma solo il conteggio.

  1. Per semplificare il recupero dei dati delle celle vicine, aggiungi una funzione cellActive che restituisca il valore cellStateIn della coordinata specificata.

index.html (chiamata Compute createShaderModule)

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

La funzione cellActive restituisce uno se la cella è attiva, quindi se aggiungi il valore restituito della chiamata a cellActive per tutte le otto celle circostanti, ottieni il numero di celle vicine attive.

  1. Trova il numero di vicini attivi, in questo modo:

index.html (chiamata Compute createShaderModule)

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

Questo, però, crea un piccolo problema: cosa succede quando la cella che stai controllando è fuori dal bordo dello schema? In base alla tua logica cellIndex() in questo momento, esce dalla riga successiva o precedente oppure esce dal bordo del buffer.

Nel caso di Gioco della vita, un modo comune e semplice per risolvere questo problema è fare in modo che le celle sul bordo della griglia trattino le celle sul bordo opposto della griglia come vicine, creando una sorta di effetto avvolgente.

  1. Supporta il wrap-around della griglia con una piccola modifica alla funzione cellIndex().

index.html (chiamata Compute createShaderModule)

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

Se utilizzi l'operatore % per eseguire il wrapping delle celle X e Y quando si estende oltre la dimensione della griglia, avrai la certezza di non accedere mai al di fuori dei limiti del buffer di archiviazione. Detto questo, puoi avere la certezza che il conteggio di activeNeighbors è prevedibile.

Quindi applica una delle quattro regole:

  • Qualsiasi cella con meno di due vicini diventa inattiva.
  • Qualsiasi cella attiva con due o tre vicini rimane attiva.
  • Qualsiasi cella inattiva con esattamente tre vicini diventa attiva.
  • Qualsiasi cella con più di tre vicini diventa inattiva.

Puoi eseguire questa operazione utilizzando una serie di istruzioni if, ma WGSL supporta anche le istruzioni switch, che sono adatte a questa logica.

  1. Implementa la logica "Game of Life" in questo modo:

index.html (chiamata Compute createShaderModule)

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

Come riferimento, la chiamata finale al modulo Compute Shar ora ha il seguente aspetto:

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

E... questo è tutto! È tutto. Aggiorna la pagina e osserva la crescita dell'automa cellulare che hai appena creato.

Screenshot di uno stato di esempio della simulazione Game of Life, con celle colorate visualizzate su sfondo blu scuro.

9. Complimenti!

Hai creato una versione della classica simulazione Il gioco della vita di Conway, che viene eseguita interamente sulla tua GPU usando l'API WebGPU.

Passaggi successivi

Per approfondire

Documenti di riferimento