La tua prima app WebGPU

1. Introduzione

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

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 consentito di creare una nuova classe di contenuti web avanzati e gli sviluppatori hanno realizzato cose straordinarie con questa tecnologia. 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 che sembra naturale sul web ed è meno verbosa di alcune delle API native su cui è basata.

Rendering

Le GPU sono spesso associate al rendering di grafica veloce e dettagliata e WebGPU non fa eccezione. Dispone delle funzionalità necessarie per supportare molte delle tecniche di rendering più diffuse oggi sia su GPU desktop che mobile e offre 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 tua 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, crei Conway's Game of Life 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 configurare WebGPU e configurare una tela.
  • Come disegnare una geometria 2D semplice.
  • Come utilizzare gli shader vertex e fragment per modificare ciò che viene disegnato.
  • Scoprire come utilizzare i computing Shader per eseguire una semplice simulazione.

Questo codelab si concentra sull'introduzione dei concetti fondamentali alla base di WebGPU. Non è pensata per essere una revisione completa dell'API, né copre (o richiede) argomenti spesso correlati, come la matematica matriciale 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 abbastanza complessa con molte regole che ne garantiscono l'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, soprattutto se sei un principiante, e va bene così. Gli sviluppatori che si occupano dell'API sono consapevoli delle difficoltà legate allo sviluppo con GPU e hanno lavorato duramente per garantire che ogni volta che il codice WebGPU causa un errore, nella console dello sviluppatore vengano restituiti messaggi molto dettagliati e utili che ti aiutino 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, se vuoi eseguire il rendering di qualcosa, come faremo nel codelab, hai bisogno di una tela. Quindi è un buon punto di partenza!

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

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

indice.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 contatto 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 tornare la pagina a una modalità che non utilizza WebGPU. (Forse potrebbe usare WebGL?) Tuttavia, ai fini di questo codelab, devi solo generare 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 è una 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à. Ciò può accadere se il browser dell'utente supporta WebGPU, ma l'hardware della GPU non dispone di tutte le funzionalità necessarie per utilizzare WebGPU.

La maggior parte delle volte è sufficiente lasciare che sia il browser a scegliere un'opzione predefinita, come fai qui, ma per esigenze più avanzate esistono argomenti che possono essere passati a requestAdapter() per specificare se vuoi utilizzare hardware a basso consumo 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. Ottieni il dispositivo chiamando adapter.requestDevice(), che restituisce anche una promessa.

indice.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 utilizzeresti per inizializzare i contesti Canvas 2D o WebGL, utilizzando rispettivamente i tipi di contesto 2d e webgl. Il valore context restituito deve essere associato al dispositivo utilizzando il metodo configure(), come segue:

indice.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. I diversi tipi di dispositivi hanno un rendimento ottimale quando vengono utilizzati formati delle texture diversi e, se non utilizzi il formato preferito dal dispositivo, potrebbero verificarsi copie aggiuntive della memoria in background prima che l'immagine possa essere visualizzata all'interno della pagina.

Fortunatamente, non devi preoccuparti molto di tutto questo perché WebGPU ti dice quale formato utilizzare per la tua tela. Nella maggior parte dei casi, è consigliabile passare il valore restituito dalla chiamata a navigator.gpu.getPreferredCanvasFormat(), come mostrato sopra.

Cancellare la tela

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 per fare qualsiasi altra cosa in WebGPU, devi fornire alla GPU alcuni comandi che le indicano 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 relativi al rendering (in questo caso, allo svuotamento della tela), quindi il passaggio successivo consiste nell'utilizzare encoder per iniziare un passaggio di rendering.

Le pass di rendering sono il momento in cui avvengono tutte le operazioni di disegno in WebGPU. Ognuna inizia con una chiamata beginRenderPass(), che definisce le texture che ricevono l'output di eventuali comandi di disegno eseguiti. Usi più avanzati possono fornire diverse texture, chiamate allegati, con vari scopi, ad esempio memorizzare la profondità della geometria visualizzata o fornire l'antialiasing. Per questa app, però, ne serve solo una.

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

indice.html

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

La trama viene fornita 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 è importante solo per i casi d'uso più avanzati, quindi qui chiami createView() senza argomenti sulla texture, indicando 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 pari a "clear" indica che vuoi che la trama venga cancellata all'avvio del passaggio di 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 passaggio di rendering, non devi fare altro. Almeno per il momento. 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() nell'encoder dei comandi. Il buffer dei comandi è un handle opaco per i comandi registrati.

indice.html

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

indice.html

device.queue.submit([commandBuffer]);

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

indice.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 rileva che hai modificato la trama corrente del contesto e aggiorna la tela per visualizzarla 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 è riempita 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 passaggio di rendering il colore da utilizzare durante l'esecuzione dell'operazione clear all'inizio del passaggio. Il dizionario passato contiene quattro valori: r per rosso, g per verde, b per blu e a per alfa (trasparenza). Ogni valore può variare da 0 a 1 e, insieme, descrivono il valore del canale di colore. Ad esempio:

  • { r: 1, g: 0, b: 0, a: 1 } è di colore rosso vivo.
  • { 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. Una volta scelto il colore, ricarica la pagina. Dovresti vedere il colore scelto nella tela.

Una tela azzerata in blu scuro per dimostrare come modificare il colore di sfondo predefinito.

4. Disegna geometria

Entro la fine di questa sezione, l'app disegnerà sulla tela alcune semplici geometrie: un quadrato colorato. Tieni presente che potrebbe sembrare un sacco di lavoro per un output così semplice, ma questo perché WebGPU è progettato per eseguire il rendering di molte geometrie 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.

Informazioni su come le GPU disegnano

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 questi 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 relazione con la tela della pagina. Indipendentemente dalla larghezza o dall'altezza del canvas, il bordo sinistro è sempre pari a -1 sull'asse X e il bordo destro è sempre pari a +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. Questo spazio è noto come Clip Space.

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

Inizialmente i vertici vengono definiti 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 snapshot può applicare animazioni o calcolare la direzione dal vertice a una sorgente di luce. Questi shader sono scritti da te, lo sviluppatore WebGPU, e offrono un controllo sorprendente 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 hai scritto chiamato shader di frammento che calcola 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 di questi colori dei pixel vengono poi accumulati in una texture, che può essere visualizzata sullo schermo.

Definire i vertici

Come accennato in precedenza, la simulazione del gioco della vita viene mostrata come una griglia di celle. L'app ha bisogno di un modo per visualizzare la griglia, distinguendo le celle attive da quelle non attive. 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 Uint8Array, ogni elemento dell'array è un singolo byte senza segno. 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 per posizionarlo è in alto, appena sotto la chiamata context.configure().

indice.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 capire 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 persone. La soluzione è ripetere due dei vertici per creare due triangoli che condividono uno spigolo 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 per quello rosso. In alternativa, puoi scegliere di dividere il quadrato con gli altri due angoli; non fa 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. Verrà visualizzato come un singolo quadrato pieno.

Creare un buffer di vertici

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.

indice.html

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

La prima cosa da notare è che devi assegnare al buffer un'etichetta. A ogni singolo oggetto WebGPU che crei puoi assegnare un'etichetta facoltativa, ed è consigliabile farlo. L'etichetta può essere qualsiasi stringa, purché ti aiuti a identificare l'oggetto. In caso di problemi, queste etichette vengono utilizzate nei messaggi di errore generati da WebGPU per aiutarti a capire cosa è andato storto.

Dopodiché, specifica una dimensione per il buffer in byte. Devi avere un buffer di 48 byte, che puoi determinare moltiplicando le dimensioni di un numero in virgola mobile a 32 bit (4 byte) per il numero di numeri in virgola mobile nell'array vertices (12). Fortunatamente, gli array di tipi già calcolano il loro byteLength per te, quindi puoi utilizzarlo durante la creazione del 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 GPUBuffer dopo averlo creato né modificare i flag di utilizzo. Puoi modificare i contenuti della 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 dei vertici nella memoria del buffer, aggiungi il seguente codice:

index.html

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

Definire il layout del vertice

Ora hai un buffer con i dati dei vertici, ma per 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 ulteriori informazioni sulla struttura dei dati dei 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 che devi fornire è il 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 hanno spesso vertici con più attributi, come il colore di un vertice o la direzione in cui punta la superficie geometrica. Tuttavia, non rientra nell'ambito di questo codelab.

Nel singolo attributo, definisci innanzitutto il 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. Hai notato un particolare comportamento che si ripete?

Successivamente, offset descrive quanti byte dopo l'inizio del vertice inizia questo attributo specifico. Devi preoccuparti di questo solo se il buffer contiene più di un attributo, il che non accadrà 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 nello shader vertex, di cui parleremo nella sezione successiva.

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

Iniziare con gli shader

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

Gli shader sono piccoli programmi che scrivi ed esegui sulla GPU. Ogni shader opera su una fase diversa dei dati: elaborazione Vertex, elaborazione Fragment o Computing generale. Poiché si trovano sulla GPU, sono strutturati in modo più rigido rispetto al codice JavaScript medio. Tuttavia, questa struttura consente di eseguire le query molto rapidamente e, soprattutto, in parallelo.

Gli Shader in WebGPU sono scritti in un linguaggio di ombreggiatura denominato WGSL (WebGPU Shading Language). WGSL è, dal punto di vista sintattico, un po' come Rust, con funzionalità volte a semplificare e velocizzare i tipi comuni di operazioni GPU (come i calcoli vettoriali e matriciali). Insegnare l'intero linguaggio di ombreggiatura va ben oltre lo scopo di questo codelab, ma speriamo che tu possa acquisire alcune nozioni di base esaminando alcuni semplici esempi.

Gli shader stessi vengono passati a WebGPU come stringhe.

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

indice.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 qui utilizzi i backtick per consentire stringhe di più righe. Dopo aver aggiunto un codice WGSL valido, la funzione restituisce un oggetto GPUShaderModule con i risultati compilati.

Definisci Vertex Shar

Inizia con lo shader vertex perché è anche da qui che inizia la GPU.

Un vertex shader è definito come una funzione e la GPU la chiama una volta per ogni vertice del vertexBuffer. Poiché vertexBuffer ha sei posizioni (vertici), la funzione che definisci viene chiamata sei volte. Ogni volta che viene chiamata, alla funzione viene passata come argomento una posizione diversa da vertexBuffer ed è compito della funzione vertex shader restituire una posizione corrispondente nello spazio 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, ad esempio:

index.html (codice createShaderModule)

@vertex
fn vertexMain() {

}

Tuttavia, non è valido perché un vertex shader deve restituire almeno la posizione finale del vertice in fase di elaborazione nello spazio clip. Viene sempre fornito 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 di ritorno, devi restituire effettivamente 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 di (0, 0, 0, 1) e tecnicamente hai uno shader vertex valido, anche se non mostra mai nulla poiché la GPU riconosce che i triangoli che produce sono solo un singolo punto e li ignora.

index.html (codice createShaderModule)

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

Vuoi invece utilizzare i dati del buffer che hai creato e per farlo devi dichiarare un argomento per la funzione con un attributo e un tipo @location() corrispondenti a quanto descritto in vertexBufferLayout. Hai specificato un shaderLocation di 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 assegnare il nome che preferisci, ma poiché rappresentano le posizioni dei vertici, un nome come pos sembra naturale.

  1. Modifica la funzione shader con il seguente codice:

index.html (codice createShaderModule)

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

Ora devi tornare a quella posizione. Poiché la posizione è un vettore 2D e il tipo di ritorno è un vettore 4D, devi modificarlo un po'. Devi prendere i due componenti dell'argomento posizione e inserirli nei primi due componenti del vettore restituito, lasciando gli ultimi due componenti rispettivamente come 0 e 1.

  1. Restituire la posizione corretta specificando esplicitamente i componenti di posizione da 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);
}

Ed ecco il tuo shader vertex iniziale. È molto semplice, si limita a distribuire la posizione sostanzialmente invariata, ma è sufficiente per iniziare.

Definire lo shader di frammento

Ora passiamo allo shader di frammenti. Gli shader di frammento funzionano in modo molto simile agli shader di vertice, ma invece di essere richiamati per ogni vertice, vengono richiamati per ogni pixel disegnato.

Gli shader di frammento vengono sempre chiamati dopo gli shader di vertice. La GPU prende l'output degli shader vertex e lo triangola, creando triangoli da insiemi 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 gli shader vertex, gli shader fragment vengono eseguiti in modo altamente 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 shader per frammenti WGSL è indicata con l'attributo @fragment e restituisce anche un vec4f. In questo caso, però, il vettore rappresenta un colore, non una posizione. Al valore restituito deve essere assegnato un attributo @location per indicare in quale colorAttachment della chiamata beginRenderPass viene scritto il colore restituito. Poiché hai un solo allegato, la posizione è 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) è rosso brillante, un colore adatto per la tua piazza. Puoi comunque impostarla sul colore che preferisci.

  1. Imposta il vettore di colore restituito, ad esempio:

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.

Per riepilogare, dopo aver aggiunto il codice shader descritto sopra, la chiamata a createShaderModule ora ha il seguente aspetto:

indice.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 shader 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 fornirne solo alcuni per iniziare.

  • Crea una pipeline di rendering, ad esempio:

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. Per fortuna, per il momento puoi passare "auto" e la pipeline creerà il proprio layout dagli shader.

Ora devi fornire i dettagli relativi alla fase vertex. module è il modulo GPUShader che contiene lo shader vertex e entryPoint indica il nome della funzione nel codice shader che viene chiamata per ogni chiamata del vertice. Puoi avere più funzioni @vertex e @fragment in un singolo modulo shader. I buffer sono un array di oggetti GPUVertexBufferLayout che descrive come i dati vengono compressi nei vertex buffer con cui utilizzi questa pipeline. Per fortuna, l'hai già definito in precedenza nel tuo vertexBufferLayout. Ecco dove devi inserirlo.

Infine, troverai i dettagli relativi alla fase fragment. Sono inclusi anche un modulo e un entryPoint dello shader, come la fase del vertice. 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 trama format, degli allegati a colori a cui la pipeline genera output. Questi dettagli devono corrispondere alle texture indicate nel colorAttachments di tutti i passaggi di rendering con cui viene utilizzata questa pipeline. Il passaggio di rendering utilizza le texture dal contesto della tela e il valore salvato in canvasFormat per il relativo formato, quindi devi passare lo stesso formato qui.

Non sono nemmeno lontanamente tutte le opzioni che puoi specificare quando crei una pipeline di rendering, ma sono sufficienti per le esigenze di questo codelab.

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

In questo modo, fornisci 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, esegui la chiamata 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. Potresti semplicemente impostarlo come valore fisso su 6, ma se lo calcoli dall'array di vertici (12 valori float/2 coordinate per vertice == 6 vertici), se decidi di sostituire il quadrato con, ad esempio, un cerchio, ci sono meno elementi da aggiornare manualmente.

  1. Aggiorna lo schermo e (finalmente) vedrai 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! Visualizzare i primi bit di geometria sullo schermo è spesso uno dei passaggi più difficili con la maggior parte delle API GPU. Tutto ciò che fai da qui può essere fatto in passaggi più piccoli, il che semplifica la verifica dei progressi.

In questa sezione scoprirai:

  • Come passare le variabili (chiamate uniformi) allo shader 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 un'informazione di base molto importante. 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 alcuni calcoli in un secondo momento). 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.

indice.html

const GRID_SIZE = 4;

Poi devi aggiornare la modalità di rendering del quadrato, in modo da poterlo adattare a GRID_SIZE volte GRID_SIZE sulla 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 giusta dimensione e posizione. Il codice non sarebbe troppo male, anzi. Solo un paio di cicli for e un po' di matematica. Tuttavia, non si sfrutta al meglio la GPU e si utilizza più memoria del necessario per ottenere l'effetto. Questa sezione esamina un approccio più compatibile con le GPU.

Creare 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 le dimensioni della griglia allo shader 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 valori comuni per un elemento geometrico (ad esempio la sua posizione), un frame completo di animazione (ad esempio l'ora corrente) o persino l'intera durata dell'app (ad esempio una preferenza dell'utente).

  • Crea un buffer uniforme aggiungendo il seguente codice:

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

Dovresti riconoscerlo perché è quasi esattamente lo stesso codice che hai utilizzato per creare il buffer di vertici in precedenza. Questo perché le uniformi vengono comunicate all'API WebGPU tramite gli stessi oggetti GPUBuffer dei vertici, con la differenza principale che questa volta usage 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). A breve scoprirai il significato di questi valori.

Poi, in un altro punto del codice dello shader, puoi utilizzare il vettore della griglia come preferisci. 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 componente per componente. 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 shader GPU, poiché molte tecniche di rendering e calcolo si basano su di essi.

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 binding è un insieme di risorse che vuoi rendere contemporaneamente accessibili allo shader. Può includere diversi tipi di buffer, come il buffer uniforme, e altre risorse come texture e sampler che non sono coperte qui, ma che sono componenti comuni delle tecniche di rendering WebGPU.

  • Crea un gruppo di binding con il buffer uniforme aggiungendo il seguente codice dopo la creazione del buffer uniforme e della 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 esaminerai più a fondo in un passaggio futuro, ma per il momento puoi tranquillamente chiedere alla pipeline il layout del gruppo di unione perché l'hai creata con layout: "auto". In questo modo, la pipeline crea automaticamente i layout dei gruppi di binding dalle associazioni dichiarate nel codice dello shader stesso. In questo caso, lo chiedi a getBindGroupLayout(0), dove 0 corrisponde a @group(0) che hai digitato nello shader.

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 shader. In questo caso, 0.
  • resource, ovvero la risorsa effettiva che vuoi esporre alla variabile nell'indice di binding specificato. In questo caso, il 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 modifica viene applicata alle chiamate draw future che utilizzano questo gruppo di binding.

Vincola il gruppo di associazione

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

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

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

Ora l'uniform buffer è esposto allo shader.

  1. Aggiorna la pagina e dovresti visualizzare qualcosa di simile al seguente:

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

Evviva! Ora la tua piazza è un quarto di quella 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 shader, puoi iniziare a manipolare la geometria che stai eseguendo il rendering in modo che si adatti al pattern della griglia che preferisci. A questo scopo, pensa esattamente a ciò che vuoi ottenere.

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

Un&#39;illustrazione della griglia concettuale in cui verrà suddiviso lo spazio delle coordinate del dispositivo normalizzate quando viene visualizzata 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 coordinate della cella.

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 il problema è aggiornare il buffer dei vertici del quadrato. Spostando i vertici in modo che l'angolo in basso a sinistra si trovi, ad esempio, a (0,1, 0,1) anziché (-0,8, -0,8), sposterai questo quadrato per allinearsi meglio con i confini delle celle. Dal momento 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 dello shader vertex 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 ben 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 del canvas posiziona (0, 0) al centro e (-1, -1) in basso a sinistra e vuoi che (0, 0) sia in basso a sinistra, devi traslare la posizione della geometria di (-1, -1) dopo averla divisa per le dimensioni 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); 
}

Ora il quadrato è posizionato correttamente nella cella (0, 0).

Una visualizzazione della tela concettualmente divisa in una griglia 4x4 con un quadrato rosso nella cella (0, 0)

E se vuoi 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 - 1 nell'algoritmo, quindi non è quello che vuoi. Vuoi invece spostare il quadrato solo di un'unità di griglia (un quarto del canvas) per ogni cella. Sembra che tu debba fare un'altra divisione per grid.

  1. Modificare il posizionamento della griglia, ad esempio:

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, viene visualizzato quanto segue:

Una visualizzazione della tela concettualmente divisa in una griglia 4x4 con un quadrato rosso centrato tra la cella (0, 0), la cella (0, 1), la cella (1, 0) e la cella (1, 1)

Mm. Non è esattamente quello che volevi.

Il motivo è che, poiché le coordinate del canvas vanno da -1 a +1, in realtà sono 2 unità in larghezza. Ciò significa che se vuoi spostare un vertice di un quarto della tela, devi spostarlo di 0,5 unità. Questo è un errore facile da fare quando si ragiona con le coordinate della GPU. Fortunatamente, la correzione è 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 avrai esattamente ciò che vuoi.

Una visualizzazione della tela concettualmente divisa in una griglia 4x4 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, quindi chiamare draw una volta per ogni quadrato della 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. L'instanziazione è un modo per dire alla GPU di disegnare più copie della stessa geometria con una singola chiamata a draw, che è molto più veloce di chiamare draw una volta per ogni copia. Ogni copia della geometria è chiamata istanza.

  1. Per indicare alla GPU che vuoi abbastanza istanze del quadrato per riempire la griglia, aggiungi un argomento alla chiamata draw esistente:

indice.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é? Perché disegni tutti e 16 i quadrati nello stesso punto. Devi avere una logica aggiuntiva nello shader che riposiziona la geometria in base all'istanza.

Nello shader, oltre agli attributi dei vertici come pos provenienti dal buffer di vertici, puoi anche accedere ai cosiddetti valori incorporati di WGSL. Questi sono valori calcolati da WebGPU e uno di questi è 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 lo shader vertex viene chiamato sei volte con un instance_index di 0, una volta per ogni posizione nel buffer di vertici. 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. Procedi nello stesso modo della posizione, ma anziché taggarla con un attributo @location, utilizza @builtin(instance_index) e poi assegna all'argomento il nome che preferisci. Puoi chiamarlo instance per abbinarlo al codice di esempio. e poi utilizzalo all'interno della logica dello shader.

  1. Utilizza instance al posto delle coordinate della cella:

indice.html

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

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

  return vec4f(gridPos, 0, 1);
}

Se aggiorni ora, vedrai che hai più di un quadrato. ma non puoi vederne 16 contemporaneamente.

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 che preferisci, devi trasformare instance_index in modo che ogni indice venga mappato a una cella univoca all'interno della griglia, ad esempio:

Una visualizzazione della tela concettualmente divisa in una griglia 4x4 con ogni cella che corrisponde anche a un indice di istanza lineare.

I calcoli sono abbastanza 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 %. Per il valore Y di ogni cella, vuoi che instance_index venga diviso per la larghezza della griglia, ignorando eventuali residui frazionari. Puoi farlo con la funzione floor() di WGSL.

  1. Modifica i calcoli, ad esempio:

indice.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 l'aggiornamento al codice, finalmente hai la griglia di quadrati tanto attesa.

Quattro righe di quattro colonne di quadrati rossi su 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 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. Anche se la griglia di quadrati che condividono tutti lo stesso colore è utilizzabile, non è esattamente entusiasmante, vero? Fortunatamente, puoi migliorare un po' la situazione con un po' di matematica e codice shader.

Utilizzare le strutture negli shader

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 passare i dati dall'shader vertex è restituirli. È sempre necessario che un vertex Shar restituisca una posizione, quindi se vuoi restituire altri dati insieme a questi, devi inserirlo in uno struct. Gli struct in WGSL sono tipi di oggetti denominati che contengono una o più proprietà denominate. Le proprietà possono essere contrassegnate anche con attributi come @builtin e @location. Le dichiari al di fuori di qualsiasi funzione e poi puoi passare le relative istanze all'interno e all'esterno delle funzioni, in base alle esigenze. Ad esempio, considera il tuo attuale shader vertex:

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 a 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 molta differenza e, di fatto, rende la funzione dello shader un po' più lunga, ma man mano che gli shader diventano più complessi, l'utilizzo delle strutture può essere un ottimo modo per organizzare i dati.

Trasferire dati tra le funzioni vertex e fragment

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 ricevendo input e stai passando 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 corrispondere, ma è più facile tenere traccia delle attività se lo fanno.

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, dato che nel codice entrambe le funzioni sono definite nello stesso modulo shader, è riutilizzare la struttura di output della fase @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 dei codici precedenti, l'output sarà 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 di colore restituiti dalla funzione @fragment si aspettano che ogni canale rientri 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 vediamo che la prima riga e la prima colonna raggiungono immediatamente il valore completo 1 nel canale di colore rosso o verde e che ogni cella successiva viene bloccata allo 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. Modifica lo shader di frammento, ad esempio:

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 un gradiente di colori molto più piacevole nell'intera griglia.

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

Sebbene si tratti di un miglioramento, ora c'è un angolo scuro in basso a sinistra, dove la griglia diventa nera. Quando inizi a eseguire la simulazione del gioco della vita, una sezione della griglia difficile da vedere oscura ciò che sta succedendo. Sarebbe bello renderlo più luminoso.

Fortunatamente, hai a disposizione un intero canale di colore inutilizzato, il blu. 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. Provali entrambi e scegli quello che preferisci.

  1. Aggiungi colori più brillanti allo shader di frammento, ad esempio:

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.

Questo non è 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.

Ti serve solo un indicatore di attivazione/disattivazione per ogni cella, quindi qualsiasi opzione che ti consenta di memorizzare un ampio array di quasi tutti i tipi di valori è valida. 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. L'ultimo elemento è il più problematico, poiché vuoi eseguire la simulazione del gioco della vita sulla GPU in un compute shader.

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 richiedono una dimensione dichiarata specifica in uno shader, 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 cella, utilizza quello che ormai probabilmente inizia a essere uno snippet di codice di creazione del buffer familiare:

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

Come per gli buffer vertex e uniform, chiama device.createBuffer() con le dimensioni appropriate e 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:

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

Aggiorna lo shader per esaminare 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!

Innanzitutto, aggiungi il punto di aggancio, che si trova proprio sotto la divisa 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 corrispondere a 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? Dato che gli stati attivi e inattivi che ottieni dall'array sono 1 o 0, puoi scalare la geometria in base allo stato attivo. Se la scala è 1, la geometria rimane invariata, mentre se è 0 la geometria si riduce a un singolo punto, che la GPU poi elimina.

  1. Aggiorna il codice dello shader per ridimensionare la posizione in base allo stato attivo della cella. Il valore dello stato deve essere eseguito il casting in un f32 per soddisfare i requisiti di sicurezza di tipo 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;
}

Aggiungere 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 binding nel codice JavaScript.

  • Aggiungi il buffer di archiviazione, ad esempio:

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 da sinistra a destra su 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? Esamina un esempio semplificato: immagina di scrivere una simulazione molto semplice in cui sposti tutti i blocchi attivi verso destra di una cella 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.

Tuttavia, se esegui questo codice, la cella attiva si sposta fino alla fine dell'array in un solo passaggio. Perché? Perché continui ad aggiornare lo stato in situ, quindi sposti la cella attiva verso destra, guardi la cella successiva e… È attivo! Meglio spostarlo di nuovo a destra. Il fatto che tu modifichi i dati nello stesso momento in cui li osservi corrompe 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(inArray, outArray) {
  outArray[0] = 0;
  for (let i = 1; i < inArray.length; ++i) {
     outArray[i] = inArray[i-1];
  }
}

// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA); 
  1. Utilizza questo pattern nel tuo codice aggiornando l'allocazione del buffer di archiviazione per 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:

indice.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. Per farlo, hai bisogno di un semplice loop di rendering.

Un ciclo di rendering è un ciclo che si ripete all'infinito e disegna i contenuti sulla tela a un determinato intervallo. Molti giochi e altri contenuti che vogliono essere animati senza interruzioni utilizzano la funzione requestAnimationFrame() per pianificare i richiami alla stessa frequenza con cui lo schermo si aggiorna (60 volte al secondo).

Questa app può utilizzarlo, ma in questo caso è consigliabile che gli aggiornamenti vengano eseguiti in passaggi più lunghi per poter seguire più facilmente l'andamento 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.

indice.html

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

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

Ora, quando esegui l'app, noti che la tela passa da un buffer dello stato all'altro.

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 sfondo blu scuro.

A questo punto, hai quasi completato la parte di rendering. Ora puoi visualizzare l'output della simulazione del gioco della vita che crei nel passaggio successivo, dove inizierai finalmente a utilizzare gli shader di calcolo.

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. Tuttavia, speriamo che ti dia un'idea sufficiente di come funziona il rendering di WebGPU per aiutarti a comprendere più facilmente tecniche più avanzate come il rendering 3D.

8. Esegui la simulazione

Ora passiamo all'ultimo elemento principale del puzzle: l'esecuzione della simulazione del gioco della vita in un compute shader.

Finalmente puoi utilizzare gli shader di calcolo.

In questo codelab hai appreso in modo astratto gli shader di calcolo, ma che cosa sono esattamente?

Un computing shaper è simile agli Shaper vertex e fragments 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 che scegli, ad esempio i buffer di archiviazione. Ciò significa che, anziché eseguire una volta per ogni vertice, istanza o pixel, devi indicare quante invocazioni della funzione shader 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:

indice.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 facilmente i lavori conformi a una griglia 2D o 3D, il che è perfetto 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 è suddivisa 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 lo shader, scegli una dimensione del gruppo di lavoro arbitraria di 8 x 8. È utile tenerne traccia nel codice JavaScript.

  1. Definisci una costante per le dimensioni del tuo gruppo di lavoro, ad esempio:

indice.html

const WORKGROUP_SIZE = 8;

Devi anche aggiungere le dimensioni del gruppo di lavoro alla funzione shader stessa, utilizzando i letterali di 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 createShaderModule di Compute)

@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). Per qualsiasi asse non specificato, il valore predefinito è 1, anche se devi specificare almeno l'asse X.

Come per gli altri stadi shader, esistono diversi valori @builtin che puoi accettare come input nella funzione di compute shader per indicare quale chiamata stai utilizzando e decidere quale lavoro devi svolgere.

  1. Aggiungi un valore @builtin, ad esempio:

index.html (chiamata createShaderModule di Compute)

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

}

Devi passare la funzione interna global_invocation_id, che è un vettore tridimensionale di numeri interi non firmati che ti indica dove ti trovi nella griglia di invocazioni degli shader. Esegui questo Shar una volta per ogni cella della griglia. Visualizzi numeri come (0, 0, 0), (1, 0, 0), (1, 1, 0)… fino a (31, 31, 0), il che significa che puoi trattarli come l'indice della cella su cui vuoi eseguire operazioni.

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

  1. Utilizza un'uniforme con lo shader di calcolo per indicare le dimensioni della griglia, ad esempio:

index.html (chiamata createShaderModule di Compute)

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

}

Come nello shader vertex, esponi anche 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 è dichiarato con var<storage>, il che lo rende di sola lettura, mentre il secondo buffer di archiviazione è 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).

Successivamente, devi avere un modo per mappare l'indice della cella nell'array di archiviazione lineare. Questo è fondamentalmente l'opposto di ciò che hai fatto nello shader vertex, dove hai preso il instance_index lineare e lo hai mappato a una cella della griglia 2D. Ti ricordiamo che l'algoritmo per questo 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 createShaderModule di Compute)

@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, ad esempio:

index.html (chiamata createShaderModule di Compute)

@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. Tuttavia, prima di poter vedere i risultati, devi apportare altre modifiche.

Utilizzare i layout di gruppi di collegamento 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. ma richiede un po' più di configurazione manuale.

Ogni volta che crei un gruppo di associazione, devi fornire un elemento GPUBindGroupLayout. In precedenza, ottenevi questo layout chiamando getBindGroupLayout() nella 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 perché, considera quanto segue: nelle pipeline di rendering utilizzi un singolo buffer uniforme e un singolo buffer di archiviazione, ma nello shader di calcolo 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 questo layout, chiama device.createBindGroupLayout():

indice.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 descrivi il tipo di risorsa che deve essere la voce e come viene utilizzata anziché fornire la risorsa stessa.

In ogni voce, specifica il numero binding della risorsa, che (come hai appreso quando hai creato il gruppo di binding) corrisponde al valore @binding negli shader. Fornisci anche visibility, ovvero flag GPUShaderStage che indicano quali fasi dello shader 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, indica il tipo di risorsa utilizzata. Si tratta di una chiave di dizionario diversa, a seconda di ciò che devi esporre. Qui, 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 qui.

Nel dizionario del buffer, puoi impostare opzioni come la type di buffer utilizzata. Il valore predefinito è "uniform", quindi puoi lasciare il dizionario vuoto per l'associazione 0. Tuttavia, devi impostare almeno buffer: {} affinché 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 streamr e l'associazione 2 ha un tipo "storage" perché lo lo usi con l'accesso read_write.

Una volta creato bindGroupLayout, puoi passarlo durante la creazione dei gruppi di associazione anziché eseguire query sul gruppo di associazione dalla pipeline. Ciò significa che devi aggiungere una nuova voce del buffer di archiviazione a ciascun gruppo di associazione per far corrispondere il layout appena definito.

  1. Aggiorna la creazione del gruppo di associazioni, ad esempio:

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 unione è stato aggiornato per utilizzare questo layout esplicito del gruppo di unione, devi aggiornare la pipeline di rendering in modo che utilizzi la stessa cosa.

  1. Crea una GPUPipelineLayout.

indice.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".

indice.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 gli shader vertex e fragment, hai bisogno di una pipeline di calcolo per utilizzare lo shader di calcolo. Fortunatamente, le pipeline di calcolo sono molto meno complicate delle pipeline di rendering, in quanto non hanno alcun stato da impostare, solo lo shader e il layout.

  • Crea una pipeline di computing con il codice seguente:

indice.html

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

Tieni presente che devi passare il nuovo pipelineLayout anziché "auto", proprio come nella pipeline di rendering aggiornata, in modo che sia la pipeline di rendering sia la pipeline di calcolo possano utilizzare gli stessi gruppi di binding.

Tessere Compute

Questo ti porta a utilizzare effettivamente la pipeline di computing. Dato che esegui il rendering in un passaggio di rendering, probabilmente puoi intuire che devi eseguire operazioni di calcolo in un passaggio di calcolo. Il calcolo e il rendering possono avvenire entrambi nello stesso encoder dei comandi, quindi ti consigliamo di mescolare un po' la funzione updateGrid.

  1. Sposta la creazione dell'encoder nella parte superiore della funzione e poi avvia un passaggio di calcolo con questo (prima di step++).

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

Come le pipeline di calcolo, i passaggi di calcolo sono molto più semplici da avviare rispetto alle relative controparti di rendering perché non devi preoccuparti di eventuali 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 step tra i passaggi, in modo che il buffer di output della pipeline di calcolo 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.

indice.html

const computePass = encoder.beginComputePass();

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

computePass.end();
  1. Infine, anziché disegnare come in un passaggio di rendering, invii il lavoro allo shader di calcolo, indicando il numero di gruppi di lavoro da 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();

Un aspetto molto importante da notare è che il numero che passi a dispatchWorkgroups() non è il numero di invocazioni. Si tratta invece del numero di gruppi di lavoro da eseguire, come definito da @workgroup_size nel tuo shaker.

Se vuoi che lo shader venga eseguito 32 volte per coprire l'intera griglia e le dimensioni del tuo gruppo di lavoro sono 8 x 8, devi inviare gruppi di lavoro 4 x 4 (4 * 8 = 32). Ecco perché devi dividere le dimensioni della griglia per le dimensioni del gruppo di lavoro e passare questo valore a dispatchWorkgroups().

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

Strisce diagonali di quadrati colorati che vanno da sinistra a destra su 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 generare valori casuali come preferisci, ma esiste un modo semplice per iniziare che offre 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 interessano quelli attivi, ma solo il conteggio.

  1. Per semplificare l'acquisizione dei dati delle celle adiacenti, aggiungi una funzione cellActive che restituisce il valore cellStateIn della coordinata specificata.

index.html (chiamata createShaderModule di Compute)

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, ad esempio:

index.html (chiamata createShaderModule di Compute)

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

Tuttavia, si verifica un piccolo problema: cosa succede quando la cella che stai controllando è fuori dal bordo della scheda? In base alla logica attuale di cellIndex(), il valore tracima nella 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 createShaderModule di Compute)

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. In questo modo, puoi avere la certezza che il conteggio di activeNeighbors sia prevedibile.

Quindi applica una delle quattro regole:

  • Qualsiasi cella con meno di due celle adiacenti diventa inattiva.
  • Qualsiasi cella attiva con due o tre celle adiacenti rimane attiva.
  • Qualsiasi cella inattiva con esattamente tre celle adiacenti 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 del gioco della vita, ad esempio:

index.html (chiamata createShaderModule di Compute)

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 del modulo shader di calcolo finale 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 guarda crescere il tuo nuovo automa cellulare.

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

Documentazione di riferimento