La tua prima app WebGPU

La tua prima app WebGPU

Informazioni su questo codelab

subjectUltimo aggiornamento: apr 15, 2025
account_circleScritto da: Brandon Jones, François Beaufort

1. Introduzione

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

Che cos'è WebGPU?

WebGPU è una nuova API moderna per accedere alle funzionalità della 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, era basata sull'API OpenGL ES 2.0, rilasciata nel 2007, che era basata sull'API OpenGL ancora precedente. Le GPU sono evolute notevolmente in questo periodo e anche le API native utilizzate per interfacciarsi con esse sono evolute con Direct3D 12, Metal e Vulkan.

WebGPU porta i vantaggi di queste API moderne alla 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 workload per uso generico e altamente paralleli. Questi shader di calcolo possono essere utilizzati in modo autonomo, senza alcun componente 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 Il gioco della vita di Conway utilizzando WebGPU. La tua app sarà in grado di:

  • Utilizza le funzionalità di rendering di WebGPU per disegnare semplici immagini 2D.
  • Utilizza le funzionalità di calcolo 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 celle diventano attive o non attive a seconda del numero di celle vicine attive, il che genera pattern 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.
  • Come utilizzare gli shader di calcolo per eseguire una simulazione semplice.

Questo codelab si concentra sull'introduzione dei concetti fondamentali alla base di WebGPU. Non è inteso come un riesame completo dell'API, né copre (o richiede) argomenti correlati di frequente, come la matematica delle matrici 3D.

Che cosa ti serve

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

La conoscenza di altre API di grafica, come WebGL, Metal, Vulkan o Direct3D, non è obbligatoria, ma se hai esperienza con queste API, probabilmente noterai molte somiglianze con WebGPU che potrebbero aiutarti a iniziare a imparare.

2. Configurazione

Ottieni il codice

Questo codelab non ha dipendenze e ti guida in ogni passaggio necessario per creare l'app WebGPU, quindi non è necessario alcun codice per iniziare. Tuttavia, alcuni esempi funzionanti che possono fungere da checkpoint sono disponibili all'indirizzo https://glitch.com/edit/#!/your-first-webgpu-app. Puoi consultarli e utilizzarli come riferimento man mano che procedi se non riesci a procedere.

Utilizza la console per sviluppatori.

WebGPU è un'API abbastanza complessa con molte regole che ne garantiscono l'utilizzo corretto. Inoltre, a causa del funzionamento dell'API, non può generare le eccezioni JavaScript tipiche per molti errori, il che rende più difficile individuare esattamente la causa 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.

Mantenere la console aperta mentre lavori su qualsiasi applicazione web è sempre utile, ma in questo caso è particolarmente importante.

3. Inizializzare WebGPU

Inizia con un <canvas>

WebGPU può essere utilizzato senza mostrare nulla sullo schermo se vuoi utilizzarlo solo per eseguire calcoli. Tuttavia, se vuoi eseguire il rendering di qualcosa, come faremo nel codelab, hai bisogno di una tela. È 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:

index.html

<!doctype html>

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

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

Richiedere un adattatore e un dispositivo

Ora puoi iniziare a utilizzare WebGPU. Innanzitutto, tieni presente che le API come WebGPU possono richiedere un po' di tempo per essere propagate nell'intero ecosistema web. Di conseguenza, un buon primo passaggio di precauzione è 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 in modo che la pagina torni a una modalità che non utilizza WebGPU. (Forse potrebbe utilizzare WebGL?) Tuttavia, ai fini di questo codelab, devi solo generare un errore per interrompere l'esecuzione del codice.

Una volta che sai che WebGPU è supportato dal browser, il primo passaggio per inizializzarlo per la tua app è richiedere un GPUAdapter. Puoi considerare un adattatore come la rappresentazione di WebGPU di un componente hardware GPU specifico nel tuo dispositivo.

  1. Per ottenere un adattatore, utilizza il metodo navigator.gpu.requestAdapter(). Restituisce una promessa, quindi è più pratico chiamarla con await.

index.html

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

Se non è possibile trovare adattatori appropriati, il valore adapter restituito potrebbe essere null, quindi devi 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).

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

  1. Ottieni il dispositivo chiamando adapter.requestDevice(), che restituisce anche una promessa.

index.html

const device = await adapter.requestDevice();

Come per requestAdapter(), qui sono disponibili opzioni che possono essere passate per utilizzi più avanzati, come l'attivazione di funzionalità hardware specifiche o la richiesta di limiti più elevati, ma per le tue esigenze le impostazioni predefinite vanno benissimo.

Configurare Canvas

Ora che hai un dispositivo, devi fare un'altra cosa se vuoi utilizzarlo per mostrare qualcosa nella pagina: configurare la tela da utilizzare con il dispositivo che hai appena creato.

  • Per farlo, devi prima richiedere un GPUCanvasContext dal canvas chiamando 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 quindi essere associato al dispositivo utilizzando il metodo configure(), come segue:

index.html

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

Qui è possibile passare alcune opzioni, ma le più importanti sono device, con cui utilizzerai il contesto, e format, ovvero il formato della trama che deve utilizzare il contesto.

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 sul funzionamento della memoria delle texture non rientrano nell'ambito di questo codelab. L'aspetto importante da sapere è che il contesto della tela fornisce le texture in cui il codice deve disegnare e il formato utilizzato può influire sull'efficienza con cui la tela mostra queste immagini. Diversi tipi di dispositivi hanno il rendimento migliore 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 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, puoi iniziare a utilizzarlo per modificare i contenuti della tela. Per iniziare, cancellalo 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 un 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. Recupera la trama dal contesto della tela che hai creato in precedenza chiamando context.getCurrentTexture(), che restituisce una trama con una larghezza e un'altezza in pixel corrispondenti agli attributi width e height della tela e al format specificato quando hai chiamato context.configure().

index.html

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

La trama viene fornita come proprietà view di un colorAttachment. Le pass di rendering richiedono un GPUTextureView anziché un GPUTexture, che indica le parti della texture da eseguire in 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 la pass 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 pari a "store" indica che, al termine del passaggio di rendering, vuoi che i risultati di qualsiasi disegno eseguito durante il passaggio di rendering vengano salvati nella texture.

Una volta avviata la pass 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 trama e la tela.

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

index.html

pass.end();

È importante sapere che il semplice fatto di effettuare queste chiamate non fa fare nulla alla GPU. Registrano semplicemente i comandi che la GPU dovrà eseguire 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.

index.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, garantendo 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.

index.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é è abbastanza comune vedere questi due passaggi raggruppati in uno, come avviene nelle pagine di esempio di questo codelab:

index.html

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

Dopo aver inviato i comandi alla GPU, lascia che JavaScript restituisca 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 vuoi aggiornare di nuovo i contenuti della tela, devi registrare e inviare un nuovo buffer di comandi, chiamando di nuovo context.getCurrentTexture() per ottenere una nuova texture per un passaggio 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.

A dire il vero, però, i quadrati neri sono piuttosto noiosi. Quindi, prima di passare alla sezione successiva, prenditi un momento per personalizzarla un po'.

  1. Nella chiamata encoder.beginRenderPass(), aggiungi una nuova riga con un clearValue a colorAttachment, come segue:

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 brillante.
  • { r: 1, g: 0, b: 1, a: 1 } è di colore viola brillante.
  • { r: 0, g: 0.3, b: 0, a: 1 } è verde scuro.
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 } è grigio medio.
  • { r: 0, g: 0, b: 0, a: 0 } è il nero trasparente predefinito.

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

  1. Dopo aver scelto il colore, ricarica la pagina. Dovresti vedere il colore scelto nel canvas.

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

4. Disegna la geometria

Al termine di questa sezione, l'app disegnerà una geometria semplice sulla tela: un quadrato colorato. Ti avverto che sembra molto 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 potrebbe sembrare insolitamente difficile, ma è quello che ti aspetti se ti rivolgi a un'API come WebGPU: vuoi fare qualcosa di un po' più complesso.

Informazioni su come le GPU disegnano

Prima di apportare altre modifiche al codice, vale la pena fare una panoramica generale molto rapida e semplificata di come le GPU creano le forme che vedi sullo schermo. Se conosci già le nozioni di base sul funzionamento del rendering GPU, puoi passare alla sezione Definizione dei vertici.

A differenza di un'API come Canvas 2D, che offre molte forme e opzioni pronte per l'uso, la GPU gestisce solo alcuni tipi diversi di forme (o primitive, come vengono chiamate da WebGPU): punti, linee e triangoli. Ai fini di questo codelab, utilizzerai solo triangoli.

Le GPU lavorano quasi esclusivamente con i triangoli perché 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 punti angolari.

Questi punti, o vertici, sono dati in termini di valori X, Y e (per i contenuti 3D) Z che definiscono un punto su un sistema di coordinate cartesiane definito da WebGPU o 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 della tela, 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 della tela, (-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 visualizza lo spazio delle coordinate del dispositivo normalizzate.

I vertici vengono raramente definiti inizialmente in questo sistema di coordinate, quindi le GPU si basano su piccoli programmi chiamati vertex shader per eseguire le operazioni matematiche necessarie per trasformare i vertici nello spazio clip, nonché tutti gli altri calcoli necessari per disegnare i vertici. Ad esempio, lo shader potrebbe applicare un'animazione 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 return green o complesso come il calcolo dell'angolo della superficie rispetto alla luce del sole che rimbalza su altre superfici vicine, filtrata dalla nebbia e modificata dal grado di metallicità della superficie. È completamente sotto il tuo controllo, il che può essere sia stimolante che 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 non attive.

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 del canvas, leggermente rientrato rispetto ai bordi, ha coordinate degli angoli come queste:

Un grafico Coordinate del dispositivo normalizzate che mostra le coordinate degli angoli di un quadrato

Per fornire queste coordinate alla GPU, devi inserire i valori in un TypedArray. Se non li conosci già, gli array di tipi sono un gruppo di oggetti JavaScript che ti consentono di allocare blocchi di memoria contigui e 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. Gli array di tipi sono ideali per l'invio di dati avanti e indietro con API sensibili al layout della memoria, come WebAssembly, WebAudio e (ovviamente) WebGPU.

Per l'esempio del quadrato, poiché i valori sono frazionari, è appropriato un Float32Array.

  1. Crea un array che contenga tutte le posizioni dei vertici nel diagramma inserendo la seguente dichiarazione di array nel codice. Un buon punto per posizionarlo è in alto, appena sotto la chiamata context.configure().

index.html

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

Tieni presente che lo spazio e il commento non influiscono sui valori; sono solo per comodità e per una maggiore leggibilità. 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,
]);

Sebbene il diagramma mostri una separazione tra i due triangoli per chiarezza, le posizioni dei vertici sono esattamente le stesse e la GPU le esegue senza spazi. Verrà visualizzato come un singolo quadrato pieno.

Creare un buffer di vertici

La GPU non può disegnare vertici con dati di un array JavaScript. Le GPU hanno spesso una propria memoria altamente ottimizzata per il rendering, pertanto tutti i dati che vuoi che la GPU utilizzi durante il disegno devono essere inseriti in questa 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 contrassegnato per determinati scopi. Puoi considerarlo un po' come un TypedArray visibile alla GPU.

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

index.html

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

La prima cosa da notare è che 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 GPUBufferUsage, con più flag combinati con l'operatore | ( OR bitwise). In questo caso, specifichi che vuoi che il buffer venga utilizzato per i dati dei vertici (GPUBufferUsage.VERTEX) e che vuoi anche potervi copiare i dati (GPUBufferUsage.COPY_DST).

L'oggetto buffer restituito è opaco: non puoi ispezionare (facilmente) i dati in esso contenuti. 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 a 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 dei vertici

Ora hai un buffer con i dati dei vertici, ma per la GPU è solo un blob di byte. Devi fornire qualche informazione in più se vuoi disegnare qualcosa. 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 può sembrare un po' complicato, ma è relativamente facile da analizzare.

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.

Segue la proprietà attributes, che è un array. Gli attributi sono le singole informazioni codificate in ogni 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. Questo proviene da un elenco di tipi GPUVertexFormat che descrivono ogni tipo di dati del vertice che la GPU può comprendere. I vertici hanno ciascuno due valori float a 32 bit, quindi utilizzi il formato float32x2. Se i dati dei vertici sono invece costituiti da quattro numeri interi non firmati a 16 bit ciascuno, ad esempio, devi utilizzare 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 shaderLocation. Si tratta di un numero arbitrario compreso tra 0 e 15 e deve essere univoco per ogni attributo definito. 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. Lo vedrai a breve, ma è più facile pensare a questi valori quando definisci i vertici, quindi li stai configurando ora per utilizzarli in un secondo momento.

Iniziare con gli shader

Ora hai i dati che vuoi visualizzare, ma devi comunque dire alla GPU esattamente 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 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 shading chiamato 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 punto in cui inserire il codice dello shader copiando quanto segue nel codice sotto vertexBufferLayout:

index.html

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

Per creare gli shader, chiama device.createShaderModule(), a cui fornisci un label facoltativo e WGSL code come stringa. Tieni presente che qui utilizzi i backtick per consentire stringhe di più righe. Dopo aver aggiunto del codice WGSL valido, la funzione restituisce un oggetto GPUShaderModule con i risultati compilati.

Definisci lo shader vertex

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

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

È importante capire che non verranno necessariamente chiamati in ordine sequenziale. Le GPU, invece, eccellono nell'eseguire shader come questi in parallelo, elaborando potenzialmente centinaia (o addirittura migliaia) di vertici contemporaneamente. Questo è un fattore fondamentale che contribuisce alla velocità incredibile delle GPU, ma presenta delle limitazioni. Per garantire una parallelizzazione estrema, gli shader dei vertici non possono comunicare tra loro. Ogni chiamata allo shader può vedere i dati di un solo vertice alla volta ed è in grado di generare valori solo per un singolo vertice.

In WGSL, una funzione dello shader vertex può essere chiamata come preferisci, ma deve avere l'attributo @vertex davanti per indicare lo stadio dello shader 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 così comuni negli shader che vengono trattati come primitive di primo livello nel linguaggio, con i propri tipi come vec4f per un vettore a 4 dimensioni. Esistono tipi simili anche per i vettori 2D (vec2f) e 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 è il valore restituito dalla 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 in virgola mobile che, nel valore restituito, indicano la posizione del vertice nello spazio clip.

  1. Restituire un valore statico di (0, 0, 0, 1), tecnicamente hai un vertex shader valido, anche se non mostra mai nulla poiché la GPU riconosce che i triangoli che produce sono solo un singolo punto e poi 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 il tuo argomento è un 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 molto comuni negli shader, puoi anche passare il vettore di posizione come primo argomento in una comoda scorciatoia e 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, basta passare la posizione in modo invariato, 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 determinando quali pixel degli attributi di colore di output sono inclusi nel triangolo e chiama lo shader di frammento una volta per ciascun pixel. Lo shader frammento restituisce un colore, in genere calcolato dai valori inviati dallo shader vertice e da risorse come le texture, che la GPU scrive nell'attributo colore.

Proprio come gli shader vertex, gli shader fragment vengono eseguiti in modo altamente parallelo. Sono un po' più flessibili degli shader vertex in termini di input e output, ma puoi considerarli come se restituissero 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, ad esempio:

index.html (codice createShaderModule)

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

}

I quattro componenti del vettore restituito sono i valori di colore rosso, verde, blu e alfa, che vengono interpretati esattamente nello stesso modo del clearValue impostato in beginRenderPass in precedenza. Quindi vec4f(1, 0, 0, 1) è rosso brillante, un colore adatto per la tua piazza. Puoi impostarlo 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)
}

Ed ecco un frammento shader completo. Non è molto interessante, imposta semplicemente ogni pixel di ogni triangolo su rosso, ma per il momento è sufficiente.

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

index.html

const cellShaderModule = device.createShaderModule({
  label: 'Cell shader',
  code: `
    @vertex
    fn vertexMain(@location(0) pos: vec2f) ->
      @builtin(position) vec4f {
      return vec4f(pos, 0, 1);
    }

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

Creare una pipeline di rendering

Un modulo shader non può essere utilizzato per il rendering da solo. Devi invece utilizzarlo all'interno di un GPURenderPipeline, creato chiamando device.createRenderPipeline(). La pipeline di rendering controlla come viene disegnata la geometria, inclusi elementi quali gli shader utilizzati, come interpretare i dati nei buffer dei vertici, quale tipo di geometria deve essere visualizzata (linee, punti, triangoli e così via) 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 layout che descriva i tipi di input (diversi dai buffer di vertici) di cui ha bisogno, ma non ne hai. Per fortuna, per il momento puoi passare "auto" e la pipeline creerà il proprio layout dagli shader.

Successivamente, devi fornire i dettagli sulla 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. buffers è un array di oggetti GPUVertexBufferLayout che descrivono il modo in cui i dati vengono pacchettizzati nei buffer dei vertici con cui utilizzi questa pipeline. Per fortuna, l'hai già definito in precedenza nel tuo vertexBufferLayout. Ecco dove devi inserirlo.

Infine, sono disponibili i dettagli sulla fase fragment. Sono inclusi anche un modulo e un entryPoint dello shader, come la fase del vertice. L'ultimo passaggio consiste nel definire il 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

Ora hai tutto ciò che ti serve per disegnare il 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 quale pipeline deve essere utilizzata per disegnare. Sono inclusi gli shader utilizzati, il layout dei dati del vertice e altri dati di stato pertinenti.

Poi chiami setVertexBuffer() con il buffer contenente i vertici del quadrato. Lo chiami con 0 perché questo buffer corrisponde all'elemento 0 nella definizione di vertex.buffers della pipeline corrente.

Infine, esegui la chiamata draw(), che sembra stranamente semplice dopo tutta la configurazione precedente. L'unica cosa che devi passare è il numero di vertici da visualizzare, che vengono estratti dai buffer dei vertici attualmente impostati e interpretati 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

Innanzitutto, congratulati 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 ti consente di verificare più facilmente i tuoi 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 l'instanziazione 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, sia in larghezza che in altezza? Spetta a te, in qualità di sviluppatore, ma per semplificare un po' le cose, tratta la griglia come un quadrato (stessa larghezza e altezza) e utilizza una dimensione che sia una potenza di 2. (Questo semplifica alcuni calcoli in un secondo momento). Alla fine vorrai aumentarla, ma per il resto di questa sezione imposta la dimensione della griglia su 4 x 4 perché è più facile dimostrare alcuni dei calcoli utilizzati in questa sezione. Scalala in un secondo momento.

  • Definisci le dimensioni della griglia aggiungendo una costante nella parte superiore del codice JavaScript.

index.html

const GRID_SIZE = 4;

Successivamente, devi aggiornare il modo in cui viene visualizzato il quadrato in modo da poterne inserire GRID_SIZE volte GRID_SIZE nella tela. Ciò significa che il quadrato deve essere molto più piccolo e devono essercene molti.

Ora, un modo per affrontare il problema è aumentare notevolmente il buffer dei vertici e definire al suo interno GRID_SIZE volte GRID_SIZE quadrati con le dimensioni e la posizione corrette. Il codice non sarebbe male, in effetti. 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ù adatto alle GPU.

Creare un buffer uniforme

Innanzitutto, devi comunicare le dimensioni della griglia che hai scelto allo shader, poiché le utilizza per modificare la modalità di visualizzazione. Potresti semplicemente codificare le dimensioni nello shader, ma questo significa che ogni volta che vuoi modificare le dimensioni della griglia devi ricreare lo shader e la pipeline di rendering, il che è costoso. Un modo migliore è fornire le dimensioni della griglia allo shader come uniformi.

In precedenza hai appreso che a ogni chiamata di un vertex shader viene passato un valore diverso dal buffer di vertici. Un valore uniforme è un valore di un buffer uguale 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:

index.html

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

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.

Accedere alle uniformi in uno shader

  • Definisci una uniforme aggiungendo il seguente codice:

index.html (chiamata 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 shader chiamata grid, che è un vettore di tipo float 2D che corrisponde all'array che hai appena copiato nell'area dati uniforme. Specifica inoltre che l'uniforme è vincolata a @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 dividi 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.

Ciò significa che, se hai utilizzato una dimensione della griglia pari a 4, il quadrato visualizzato sarà pari a un quarto delle dimensioni originali. È perfetto se vuoi adattarne quattro a una riga o una colonna.

Creare un gruppo di associazioni

Tuttavia, la dichiarazione dell'uniforme nello shader non la connette al buffer che hai creato. Per farlo, devi creare e impostare un gruppo di associazione.

Un gruppo di binding è una raccolta di risorse che vuoi rendere accessibili contemporaneamente 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 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, ormai standard, devi anche avere un elemento layout che descriva i tipi di risorse contenuti in questo gruppo di associazioni. 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 fa riferimento un gruppo di unione dopo averlo creato, ma puoi modificare i contenuti di queste risorse. Ad esempio, se modifichi l'area dati uniforme in modo che contenga una nuova dimensione della griglia, questa modifica viene applicata alle chiamate draw future che utilizzano questo gruppo di binding.

Eseguire il binding del gruppo di binding

Ora che il gruppo di binding è stato creato, devi comunque dire a WebGPU di utilizzarlo durante il disegno. Fortunatamente, è abbastanza semplice.

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

index.html

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

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

pass.draw(vertices.length / 2);

Il parametro 0 passato come primo argomento corrisponde a @group(0) nel codice shader. Stai dicendo che ogni @binding che fa parte di @group(0) utilizza le risorse di 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 delle dimensioni precedenti. Non è molto, ma dimostra che la uniforme è effettivamente applicata e che lo shader ora può accedere alle dimensioni della griglia.

Manipolare la geometria nello shader

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. Per farlo, valuta esattamente cosa vuoi ottenere.

Devi suddividere concettualmente 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. Il layout sarà simile a questo, con la geometria quadrata attuale 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 consiste nel trovare uno shader che ti consenta di posizionare la geometria quadrata in una qualsiasi di queste celle in base alle coordinate della cella.

Innanzitutto, puoi vedere che il quadrato non è ben allineato con nessuna delle celle perché è stato definito per circondare il centro del foglio. Ti consigliamo di spostare il quadrato di mezza cella in modo che sia allineato all'interno.

Un modo per risolvere il problema è aggiornare il buffer dei vertici del quadrato. Se sposti i vertici in modo che l'angolo in basso a sinistra sia, ad esempio, (0,1, 0,1) anziché (-0,8, -0,8), sposti questo quadrato in modo che sia allineato ai bordi della cella in modo più gradevole. Tuttavia, poiché hai il controllo completo sul modo in cui i vertici vengono elaborati nello shader, è altrettanto facile spostarli utilizzando il codice dello shader.

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

In questo modo, ogni vertice viene spostato verso l'alto e verso destra di uno (che, ricorda, corrisponde alla 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 della tela concettualmente divisa in una griglia 4x4 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 la dimensione della griglia per spostarla in quell'angolo.

  1. Trasforma la posizione della geometria, ad esempio:

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 scoprirlo, dichiara un vettore cell nello shader e compilalo 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. ma vuoi 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 è proprio 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 del canvas, 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, come segue:

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 sfondo blu scuro. Il quadrato rosso è disegnato nella stessa posizione descritta nel diagramma precedente, ma senza l&#39;overlay della griglia.

Inoltre, ora puoi impostare cell su qualsiasi valore all'interno dei limiti della griglia, quindi aggiornare per visualizzare il rendering quadrato nella posizione desiderata.

Disegna istanze

Ora che puoi posizionare il quadrato dove vuoi con un po' di matematica, il passaggio successivo consiste nel visualizzare 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. Tuttavia, sarebbe molto lento, poiché la GPU deve attendere ogni volta che la nuova coordinata viene scritta da JavaScript. Uno dei fattori chiave per ottenere un buon rendimento dalla GPU è ridurre al minimo il tempo di attesa per altre parti del sistema.

In alternativa, puoi utilizzare una tecnica chiamata instanziazione. 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 è indicata come istanza.

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

index.html

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

In questo modo, il sistema viene informato che vuoi che disegni i sei (vertices.length / 2) vertici del quadrato 16 (GRID_SIZE * GRID_SIZE) volte. Tuttavia, se aggiorni la pagina, visualizzi 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 che provengono dal buffer dei vertici, puoi anche accedere ai cosiddetti valori predefiniti di WGSL. Questi sono valori calcolati da WebGPU e uno di questi è instance_index. instance_index è un numero a 32 bit non firmato compreso tra 0 e number of instances - 1 che puoi utilizzare nella logica dello shader. Il valore è lo stesso 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 un instance_index di 1, poi altre sei volte con instance_index di 2 e così via.

Per vedere questo in azione, devi aggiungere la funzionalità integrata instance_index agli input shader. 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:

index.html

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

Questo accade perché le coordinate delle celle che generi sono (0, 0), (1, 1), (2, 2) e così via fino a (15, 15), ma solo le prime quattro sono visualizzate nel riquadro. 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, devi calcolare 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:

index.html

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

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

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

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

  return vec4f(gridPos, 0, 1);
}

Dopo aver apportato 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. Ora che funziona, torna indietro e aumenta le dimensioni della griglia.

index.html

const GRID_SIZE = 32;

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

Ecco fatto! Ora puoi creare una griglia davvero molto grande e la GPU media la gestisce perfettamente. Non vedrai più i singoli quadrati molto prima di riscontrare colli di bottiglia nel rendimento della GPU.

6. Un bel voto in più: aggiungi un po&#39; di colore.

A questo punto, puoi passare facilmente alla sezione successiva, poiché 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

Finora hai passato un dato dall'attributo vertex shader: la posizione trasformata. Tuttavia, puoi restituire molti più dati dallo shader vertex e utilizzarli nello shader fragment.

L'unico modo per passare i dati dall'shader vertex è restituirli. Un vertex shader è sempre necessario per restituire una posizione, quindi se vuoi restituire altri dati, devi inserirli in una 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);
}
  • Esprimere la stessa cosa utilizzando struct per l'input e l'output della funzione:

index.html (chiamata createShaderModule)

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

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

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

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

Tieni presente che devi fare riferimento alla posizione di input e all'indice di istanza con input e che la struct restituita deve prima essere dichiarata 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. Tuttavia, se lo shader conoscesse di più sulla geometria che sta colorando, potresti utilizzare questi dati aggiuntivi per rendere le cose un po' più interessanti. Ad esempio, cosa succede se vuoi cambiare il colore di ogni quadrato in base alla sua coordinata di cella? La fase @vertex sa quale cella viene visualizzata; devi solo passarla alla fase @fragment.

Per passare qualsiasi dato tra le fasi di vertice e frammento, devi includerlo in uno struct di output con un @location a nostra scelta. Poiché vuoi passare la coordinata della cella, aggiungila alla struttura VertexOutput di cui sopra e impostala nella funzione @vertex prima di tornare.

  1. Modifica il valore restituito dello shader vertex, ad esempio:

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 una 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. In questo modo, è facile passare i valori perché i nomi e le posizioni sono naturalmente coerenti.

index.html (chiamata createShaderModule)

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

Indipendentemente dal modello scelto, il risultato è che hai accesso al numero di cella nella funzione @fragment e puoi 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 sono proprio belli da vedere. Potresti chiederti perché solo le righe di sinistra e di fondo sono diverse. Questo perché i valori di colore restituiti dalla funzione @fragment prevedono che ogni canale rientri nell'intervallo da 0 a 1 e tutti i valori al di fuori di questo intervallo vengono vincolati. I valori delle celle, invece, vanno da 0 a 32 lungo ogni 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ù graduale tra i colori, devi restituire un valore frazionario per ogni canale di colore, idealmente a partire da zero e terminando con uno lungo 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 diversi angoli.

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. L'effetto ideale è che il blu sia più luminoso dove gli altri colori sono più scuri e che svanisca man mano che l'intensità degli altri colori aumenta. Il modo più semplice per farlo è iniziare il canale da 1 e sottrarre 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 a createShaderModule

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

Il risultato è molto bello.

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

Questo non è un passaggio fondamentale. Tuttavia, poiché ha un aspetto migliore, è stato incluso nel file sorgente del checkpoint corrispondente e il resto degli screenshot in questo codelab riflette questa griglia più colorata.

7. Gestire lo stato delle celle

Successivamente, 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. Potresti pensare che questo sia un altro caso d'uso per gli uniform buffer. Sebbene tu possa farlo funzionare, è più difficile perché gli uniform buffer hanno dimensioni limitate, non supportano gli array con dimensioni dinamiche (devi specificare le dimensioni dell'array nello shader) e non possono essere scritti dagli shader di calcolo. 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

Gli storage buffer sono buffer di uso generale che possono essere letti e scritti negli shader di calcolo e letti negli shader di vertice. Possono essere molto grandi e non richiedono una dimensione dichiarata specifica in uno shader, il che li rende molto più simili alla memoria generale. che utilizzi per memorizzare 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:

index.html

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

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

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 compilare il buffer come prima compilando l'array di oggetti di tipo TypedArray della stessa dimensione con i valori e chiamando device.queue.writeBuffer(). Poiché vuoi vedere l'effetto del buffer sulla griglia, inizia a riempirla con qualcosa di prevedibile.

  1. Attiva ogni terza cella con il seguente codice:

index.html

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

Leggi il buffer di archiviazione nello shader

Aggiorna lo shader per esaminare i contenuti del buffer di archiviazione prima di eseguire il rendering della griglia. Questa procedura è molto simile a quella utilizzata in precedenza per aggiungere le divise.

  1. Aggiorna lo shader 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 griglia uniforme. Vuoi mantenere lo stesso @group dell'uniforme grid, ma il numero @binding deve essere diverso. Il tipo var è storage, per riflettere il diverso tipo di buffer e, anziché un singolo vettore, il tipo specificato 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 è memorizzato in un array piatto nel buffer di archiviazione, puoi utilizzare instance_index per cercare il valore della cella corrente.

Come faccio a disattivare una cella se lo stato indica che è inattiva? 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 ignora.

  1. Aggiorna il codice dello shader per scalare 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 che lo stato della cella venga applicato, aggiungi il buffer di archiviazione a un gruppo di unione. 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 binding della nuova voce corrisponda al @binding() del valore corrispondente nello shader.

A questo punto, dovresti essere in grado di aggiornare e vedere il pattern nella griglia.

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

Utilizzare il pattern di buffer ping-pong

La maggior parte delle simulazioni come quella che stai creando utilizza in genere almeno due copie del proprio stato. In ogni passaggio della simulazione, leggono da una copia dello stato e scrivono nell'altra. Poi, nel passaggio successivo, capovolgilo e leggi dallo stato in cui ha scritto in precedenza. Questo è comunemente noto come pattern ping pong perché la versione più aggiornata dello stato passa da una copia all'altra in ogni passaggio.

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 semplicità, 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, poi guardi la cella successiva e… È attivo. Meglio spostarlo di nuovo verso destra. Il fatto che tu modifichi i dati nello stesso momento in cui li osservi corrompe i risultati.

Utilizzando il pattern 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 meglio la differenza tra i due buffer, compilali con dati diversi:

index.html

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

// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
  cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
  1. Per mostrare i diversi buffer di archiviazione nel rendering, aggiorna i gruppi di binding 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] }
    }],
  })
];

Configurare un loop di rendering

Finora hai eseguito un solo grafico per aggiornamento della pagina, ma ora vuoi mostrare l'aggiornamento dei dati 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 invece il loop in modo da poter controllare la frequenza di aggiornamento della simulazione.

  1. Innanzitutto, scegli una frequenza di aggiornamento della simulazione (200 ms sono buoni, ma puoi andare più lentamente o più velocemente se vuoi) e poi tieni traccia del numero di passaggi della simulazione completati.

index.html

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

index.html

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

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

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

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

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

Strisce diagonali di quadrati colorati che vanno da sinistra in basso a destra in alto 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 funzionalità di rendering di WebGPU sono molto più ampie della piccola parte che hai esplorato qui, ma il resto esula dallo scopo di questo codelab. Spero che ti dia un'idea sufficiente di come funziona il rendering di WebGPU, in modo da aiutarti a comprendere più facilmente tecniche più avanzate come il rendering 3D.

8. Esegui la simulazione

Ora passiamo all'ultimo pezzo 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 compute shader è simile agli shader vertex e fragment in quanto è progettato per funzionare con un parallelismo estremo sulla GPU, ma, a differenza degli altri due stadi shader, 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. Quando esegui lo shader, ti viene comunicato quale chiamata è in fase di elaborazione e puoi decidere a quali dati accedere e quali operazioni eseguire.

Gli shader di calcolo devono essere creati in un modulo shader, proprio come gli shader di vertici e frammenti, quindi aggiungilo al codice per iniziare. Come puoi immaginare, data la struttura degli altri shader che hai implementato, la funzione principale per l'shader di calcolo deve essere contrassegnata dall'attributo @compute.

  1. Crea uno shader di calcolo con il seguente codice:

index.html

// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
  label: "Game of Life simulation shader",
  code: `
    @compute
    fn computeMain() {

    }`
});

Poiché le GPU vengono utilizzate di frequente per la grafica 3D, gli shader di calcolo sono strutturati in modo da poter richiedere l'attivazione dello shader un numero specifico di volte lungo un asse 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 dimensioni X, Y e Z e, anche se ogni dimensione può essere 1, spesso è possibile ottenere vantaggi in termini di prestazioni aumentando leggermente le dimensioni dei gruppi di lavoro. Per lo shader, scegli una dimensione del gruppo di lavoro arbitraria di 8 x 8. È utile tenere traccia di questo nel codice JavaScript.

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

index.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 le dimensioni del gruppo di lavoro alla funzione shader, come segue:

index.html (chiamata createShaderModule di Compute)

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

}

Questo indica allo shader che il lavoro svolto con questa funzione viene eseguito 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 delle invocazioni degli shader. Esegui questo shader 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 di cella su cui eseguirai l'operazione.

Gli shader di calcolo possono anche utilizzare uniformi, che vengono utilizzate come negli shader vertex e fragment.

  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 ne devi avere due. Poiché gli shader di calcolo non hanno un output obbligatorio, come la posizione di un vertice o il colore di un frammento, scrivere valori in un buffer di archiviazione o in una texture è l'unico modo per ottenere risultati da uno shader di calcolo. Utilizza il metodo ping-pong che hai appreso in precedenza: hai un buffer di archiviazione che alimenta lo stato corrente della griglia e uno in cui scrivi il nuovo stato della griglia.

  1. Esponi lo stato di input e output della cella come buffer di archiviazione, ad esempio:

index.html (chiamata createShaderModule di Compute)

@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 puoi leggere e scrivere nel buffer, utilizzandolo come output per il tuo compute shader. (in WebGPU non è presente una modalità di archiviazione di sola scrittura).

Successivamente, devi avere un modo per mappare l'indice di 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 questa operazione era vec2f(i % grid.x, floor(i / grid.x)).

  1. Scrivi una funzione per andare nell'altra direzione. 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 verificare che funzioni, implementa un algoritmo molto semplice: se una cella è attiva, si disattiva e viceversa. Non è ancora il Gioco della vita, ma è sufficiente per dimostrare che lo shader di calcolo 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 ora è tutto per lo shader di calcolo. 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 dallo shader riportato sopra è che utilizza in gran parte gli stessi input (uniformi e buffer di archiviazione) della pipeline di rendering. Potresti pensare che sia sufficiente utilizzare gli stessi gruppi di binding e non fare altro, giusto? La buona notizia è che puoi. ma richiede una configurazione manuale un po' più complessa.

Ogni volta che crei un gruppo di associazioni, devi fornire un 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 poi fornirlo sia al gruppo di associazione sia alle pipeline.

Per capire il motivo, 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 shader utilizzano gli stessi valori @binding per l'uniforme e 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():

index.html

// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
  label: "Cell Bind Group Layout",
  entries: [{
    binding: 0,
    // Add GPUShaderStage.FRAGMENT here if you are using the `grid` uniform in the fragment shader.
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: {} // Grid uniform buffer
  }, {
    binding: 1,
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: { type: "read-only-storage"} // Cell state input buffer
  }, {
    binding: 2,
    visibility: GPUShaderStage.COMPUTE,
    buffer: { type: "storage"} // Cell state output buffer
  }]
});

La struttura è simile a quella della 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 l'uniforme sia il primo buffer di archiviazione siano accessibili negli shader vertex e compute, ma il secondo buffer di archiviazione deve essere accessibile solo negli shader compute.

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

Nel dizionario del buffer, puoi impostare opzioni come il tipo di type di buffer utilizzato. 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. Alla associazione 1 viene assegnato il tipo "read-only-storage" perché non la utilizzi con accesso read_write nello shader, mentre la associazione 2 ha il tipo "storage" perché la utilizzi con 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. In questo modo, devi aggiungere una nuova voce del buffer di archiviazione a ogni gruppo di unione 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 un GPUPipelineLayout.

index.html

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

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

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

index.html

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

Crea la pipeline di calcolo

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 calcolo con il seguente codice:

index.html

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

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

Ora puoi utilizzare la pipeline di calcolo. 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++).

index.html

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

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

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

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.

Ti consigliamo di eseguire il pass di calcolo prima del pass di rendering perché consente al pass di rendering di utilizzare immediatamente i risultati più recenti del pass di calcolo. 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. Successivamente, imposta la pipeline e il gruppo di binding all'interno del passaggio di calcolo, utilizzando lo stesso pattern per passare da un gruppo di binding all'altro come per il passaggio di rendering.

index.html

const computePass = encoder.beginComputePass();

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

computePass.end();
  1. Infine, 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 nello shader.

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 in basso a destra in alto su uno sfondo blu scuro. Strisce diagonali di quadrati colorati larghi due quadrati che vanno da sinistra in basso a destra in alto su sfondo blu scuro. L&#39;inversione dell&#39;immagine precedente.

Implementare l'algoritmo per il gioco della vita

Prima di aggiornare lo shader di calcolo per implementare l'algoritmo finale, devi tornare al codice che inizializza i contenuti dell'area di memoria e aggiornarlo in modo da produrre un buffer casuale a ogni caricamento della pagina. I motivi 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 con il 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 del gioco della vita. Dopo tutto quello che ci è voluto per arrivare a questo punto, il codice dello shader potrebbe risultare sorprendentemente semplice.

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 1 se la cella è attiva, quindi l'aggiunta del valore restituito dalla chiamata a cellActive per tutte le otto celle adiacenti indica quante celle adiacenti sono 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.

Per il Gioco della vita, un modo comune e semplice per risolvere il problema è fare in modo che le celle ai bordi della griglia trattino le celle sul bordo opposto della griglia come vicine, creando una sorta di effetto di a capo.

  1. Supporto del wrapping della griglia con una modifica minore 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 a capo la cella X e Y quando si estende oltre le dimensioni della griglia, ti assicuri di non accedere mai oltre i limiti del buffer di archiviazione. In questo modo, puoi avere la certezza che il conteggio di activeNeighbors sia prevedibile.

Poi 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 vicine diventa attiva.
  • Qualsiasi cella con più di tre celle adiacenti diventa inattiva.

Puoi farlo con 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 del gioco della vita, con celle colorate visualizzate su uno sfondo blu scuro.

9. Complimenti!

Hai creato una versione della simulazione del classico Gioco della vita di Conway che funziona interamente sulla tua GPU utilizzando l'API WebGPU.

Passaggi successivi

Letture aggiuntive

Documentazione di riferimento