Informazioni su questo codelab
1. Introduzione
Che cos'è WebGPU?
WebGPU è una nuova API moderna per accedere alle funzionalità della GPU nelle app web.
API moderna
Prima di WebGPU, c'era WebGL, che offriva un sottoinsieme delle funzionalità di WebGPU. Ha reso possibile una nuova classe di contenuti web avanzati e gli sviluppatori hanno creato cose straordinarie con questa tecnologia. Tuttavia, era basata sull'API OpenGL ES 2.0, rilasciata nel 2007, che a sua volta si basava sull'API OpenGL ancora più vecchia. Le GPU si sono evolute in modo significativo in questo periodo e anche le API native utilizzate per interfacciarsi con loro si sono evolute con Direct3D 12, Metal e Vulkan.
WebGPU porta i progressi di queste API moderne sulla piattaforma web. Si concentra sull'abilitazione delle funzionalità della GPU in modo multipiattaforma, presentando un'API che risulta naturale sul web ed è meno dettagliata di alcune delle API native su cui si basa.
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ù popolari di oggi sia sulle GPU desktop che su quelle 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 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, oppure come parte integrante della pipeline di rendering.
In questo codelab imparerai a sfruttare le funzionalità di rendering e di calcolo di WebGPU per creare un semplice progetto introduttivo.
Cosa creerai
In questo codelab, creerai il Gioco della vita di Conway utilizzando WebGPU. La tua app sarà in grado di:
- Utilizza le funzionalità di rendering di WebGPU per disegnare semplici grafici 2D.
- Utilizza le funzionalità di calcolo di WebGPU per eseguire la simulazione.
Il gioco della vita è un cosiddetto automa cellulare, in cui una griglia di celle cambia stato nel tempo in base a una serie di regole. Nel Gioco della vita le celle diventano attive o inattive a seconda del numero di celle adiacenti attive, il che porta a schemi interessanti che cambiano man mano che li osservi.
Cosa imparerai a fare
- Come configurare WebGPU e un canvas.
- Come disegnare una semplice geometria 2D.
- Come utilizzare gli shader di vertici e di frammenti per modificare ciò che viene disegnato.
- Come utilizzare gli shader di calcolo per eseguire una semplice simulazione.
Questo codelab si concentra sull'introduzione dei concetti fondamentali alla base di WebGPU. Non è inteso come una revisione completa dell'API e non copre (né 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 cross-browser, ma non è ancora disponibile ovunque.
- Conoscenza di HTML, JavaScript e Chrome DevTools.
La familiarità con altre API grafiche, come WebGL, Metal, Vulkan o Direct3D, non è richiesta, ma se hai esperienza con queste API, noterai molte somiglianze con WebGPU che potrebbero aiutarti ad accelerare l'apprendimento.
2. Configurazione
Ottieni il codice
Questo codelab non ha dipendenze e ti guida in ogni passaggio necessario per creare l'app WebGPU, quindi non hai bisogno di alcun codice per iniziare. Tuttavia, alcuni esempi funzionanti che possono fungere da punti di controllo sono disponibili all'indirizzo https://github.com/GoogleChromeLabs/your-first-webgpu-app-codelab. Puoi consultarle e farvi riferimento man mano che procedi, se ti blocchi.
Utilizzare la console per gli sviluppatori.
WebGPU è un'API piuttosto complessa con molte regole che impongono un utilizzo corretto. Ancora peggio, a causa del funzionamento dell'API, non è possibile generare le tipiche eccezioni JavaScript per molti errori, il che rende più difficile individuare l'origine esatta del problema.
Incontrerai problemi durante lo sviluppo con WebGPU, soprattutto se sei alle prime armi, ma non preoccuparti. Gli sviluppatori dell'API sono consapevoli delle difficoltà di lavorare con lo sviluppo della GPU e si sono impegnati a fondo per garantire che ogni volta che il codice WebGPU causa un errore, nella console per gli sviluppatori vengano visualizzati messaggi molto dettagliati e utili che ti aiutano a identificare e risolvere il problema.
Tenere aperta la console mentre lavori su qualsiasi applicazione web è sempre utile, ma in questo caso lo è ancora di più.
3. Inizializza WebGPU
Inizia con un <canvas>
WebGPU può essere utilizzato senza mostrare nulla sullo schermo se vuoi solo usarlo per eseguire calcoli. Ma se vuoi eseguire il rendering di qualcosa, come faremo nel codelab, hai bisogno di un canvas. Quindi, è un buon punto di partenza.
Crea un nuovo documento HTML con un singolo elemento <canvas>
e un tag <script>
in cui eseguiamo una query sull'elemento canvas. (o utilizza 00-starter-page.html).
- 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 API come WebGPU possono richiedere un po' di tempo per propagarsi in tutto l'ecosistema web. Di conseguenza, un buon primo passo precauzionale è verificare se il browser dell'utente può utilizzare WebGPU.
- Per verificare se esiste l'oggetto
navigator.gpu
, che funge da punto di accesso 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? Ai fini di questo codelab, tuttavia, devi solo generare un errore per impedire l'ulteriore esecuzione del codice.
Una volta verificato che WebGPU è supportato dal browser, il primo passaggio per inizializzare WebGPU per la tua app è richiedere un GPUAdapter
. Puoi considerare un adattatore come la rappresentazione di WebGPU di un componente specifico dell'hardware della GPU nel tuo dispositivo.
- Per ottenere un adattatore, utilizza il metodo
navigator.gpu.requestAdapter()
. Restituisce una promessa, quindi è più comodo chiamarla conawait
.
index.html
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("No appropriate GPUAdapter found.");
}
Se non vengono trovati 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 il browser scelga un adattatore predefinito, come in questo caso, ma per esigenze più avanzate esistono argomenti che possono essere passati a requestAdapter()
che specificano 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 lavorare con la GPU è richiedere un GPUDevice. Il dispositivo è l'interfaccia principale attraverso cui avviene la maggior parte delle interazioni con la GPU.
- 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 trasmesse per utilizzi più avanzati, come l'attivazione di funzionalità hardware specifiche o la richiesta di limiti più elevati, ma per i tuoi scopi le impostazioni predefinite vanno benissimo.
Configurare Canvas
Ora che hai un dispositivo, c'è un'altra cosa da fare se vuoi utilizzarlo per mostrare qualcosa sulla pagina: configura il canvas da utilizzare con il dispositivo che hai appena creato.
- Per farlo, devi prima richiedere un
GPUCanvasContext
dalla tela chiamandocanvas.getContext("webgpu")
. Si tratta della stessa chiamata che utilizzeresti per inizializzare i contesti Canvas 2D o WebGL, utilizzando rispettivamente i tipi di contesto2d
ewebgl
. Ilcontext
restituito deve essere associato al dispositivo utilizzando il metodoconfigure()
, come segue:
index.html
const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
Qui possono essere passate alcune opzioni, ma le più importanti sono device
, che indica il contesto da utilizzare, e format
, che indica il formato della texture che il contesto deve utilizzare.
Le texture sono gli oggetti che WebGPU utilizza 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 del funzionamento della memoria delle texture non rientrano nell'ambito di questo codelab. La cosa importante da sapere è che il contesto del canvas fornisce le texture in cui il codice può disegnare e il formato utilizzato può influire sull'efficienza con cui il canvas mostra queste immagini. I diversi tipi di dispositivi funzionano meglio quando utilizzano formati di texture diversi e, se non utilizzi il formato preferito del dispositivo, potrebbero verificarsi copie aggiuntive della memoria in background prima che l'immagine possa essere visualizzata come parte della pagina.
Fortunatamente, non devi preoccuparti di tutto questo perché WebGPU ti indica il formato da utilizzare per la tela. Quasi sempre, devi passare il valore restituito chiamando navigator.gpu.getPreferredCanvasFormat()
, come mostrato sopra.
Cancella il canvas
Ora che hai un dispositivo e la tela è stata configurata, puoi iniziare a utilizzarlo per modificare i contenuti della tela. Per iniziare, cancellalo con un colore uniforme.
Per farlo, o per fare praticamente qualsiasi altra cosa in WebGPU, devi fornire alcuni comandi alla GPU che le indichino cosa fare.
- Per farlo, il dispositivo crea un
GPUCommandEncoder
, che fornisce un'interfaccia per la registrazione dei comandi della GPU.
index.html
const encoder = device.createCommandEncoder();
I comandi che vuoi inviare alla GPU sono correlati al rendering (in questo caso, alla cancellazione del canvas), quindi il passaggio successivo consiste nell'utilizzare encoder
per iniziare un Render Pass.
I render pass si verificano quando vengono eseguite tutte le operazioni di disegno in WebGPU. Ognuno inizia con una chiamata beginRenderPass()
, che definisce le texture che ricevono l'output di tutti i comandi di disegno eseguiti. Gli utilizzi più avanzati possono fornire diverse texture, chiamate allegati, con vari scopi, ad esempio memorizzare la profondità della geometria sottoposta a rendering o fornire l'antialiasing. Per questa app, tuttavia, ne serve solo uno.
- Ottieni la texture dal contesto del canvas che hai creato in precedenza chiamando
context.getCurrentTexture()
, che restituisce una texture con una larghezza e un'altezza in pixel corrispondenti agli attributiwidth
eheight
del canvas e all'attributoformat
specificato quando hai chiamatocontext.configure()
.
index.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
storeOp: "store",
}]
});
La texture viene fornita come proprietà view
di un colorAttachment
. I pass di rendering richiedono di fornire un GPUTextureView
anziché un GPUTexture
, che indica a quali parti della texture eseguire il rendering. Questo è importante solo per i casi d'uso più avanzati, quindi qui chiami createView()
senza argomenti sulla texture, indicando che vuoi che la pass di rendering utilizzi l'intera texture.
Devi anche specificare cosa vuoi che faccia il render pass con la texture all'inizio e alla fine:
- Un valore
loadOp
di"clear"
indica che vuoi che la texture venga cancellata all'inizio della pass di rendering. - Un valore
storeOp
di"store"
indica che, una volta terminato il passaggio di rendering, vuoi che i risultati di qualsiasi disegno eseguito durante il passaggio di rendering vengano salvati nella texture.
Una volta avviato il passaggio di rendering, non devi fare nulla. Almeno per ora. L'azione di avviare la pass di rendering con loadOp: "clear"
è sufficiente per cancellare la visualizzazione della texture e il canvas.
- Termina la pass di rendering aggiungendo la seguente chiamata subito dopo
beginRenderPass()
:
index.html
pass.end();
È importante sapere che la semplice esecuzione di queste chiamate non fa sì che la GPU faccia effettivamente qualcosa. Registrano solo i comandi che la GPU dovrà eseguire in un secondo momento.
- Per creare un
GPUCommandBuffer
, chiamafinish()
sul codificatore di comandi. Il buffer dei comandi è un handle opaco per i comandi registrati.
index.html
const commandBuffer = encoder.finish();
- Invia il buffer dei comandi alla GPU utilizzando
queue
diGPUDevice
. La coda esegue tutti i comandi della GPU, assicurandosi che la loro esecuzione sia ben ordinata e sincronizzata correttamente. Il metodosubmit()
della coda accetta un array di buffer dei comandi, anche se in questo caso ne hai solo uno.
index.html
device.queue.submit([commandBuffer]);
Una volta inviato, un buffer di comandi non può essere riutilizzato, quindi non è necessario conservarlo. Se vuoi inviare altri comandi, devi creare un altro buffer di comandi. Per questo motivo, è abbastanza comune vedere questi due passaggi raggruppati in uno solo, 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 questo punto, il browser rileva che hai modificato la texture corrente del contesto e aggiorna il canvas per visualizzare la texture come immagine. Se vuoi aggiornare di nuovo i contenuti del canvas, devi registrare e inviare un nuovo buffer di comandi, chiamando di nuovo context.getCurrentTexture()
per ottenere una nuova texture per una passata di rendering.
- Ricarica la pagina. Nota che il canvas è riempito di nero. Complimenti! Ciò significa che hai creato correttamente la tua prima app WebGPU.
Scegli un colore.
A dire il vero, però, i quadrati neri sono piuttosto noiosi. Quindi, prenditi un momento prima di passare alla sezione successiva per personalizzarla un po'.
- Nella chiamata
encoder.beginRenderPass()
, aggiungi una nuova riga con unclearValue
acolorAttachment
, in questo modo:
index.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
storeOp: "store",
}],
});
clearValue
indica al render pass quale colore deve utilizzare durante l'esecuzione dell'operazione clear
all'inizio del pass. 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 colore. Ad esempio:
{ r: 1, g: 0, b: 0, a: 1 }
è di colore rosso vivo.{ r: 1, g: 0, b: 1, a: 1 }
è di colore viola brillante.{ r: 0, g: 0.3, b: 0, a: 1 }
è verde scuro.{ r: 0.5, g: 0.5, b: 0.5, a: 1 }
è grigio medio.{ r: 0, g: 0, b: 0, a: 0 }
è il nero trasparente predefinito.
Il codice di esempio e gli screenshot di questo codelab utilizzano un blu scuro, ma puoi scegliere il colore che preferisci.
- Una volta scelto il colore, ricarica la pagina. Dovresti vedere il colore scelto nel canvas.
4. Disegna geometria
Al termine di questa sezione, la tua app disegnerà una semplice geometria sulla tela: un quadrato colorato. Ti avviso che sembrerà molto lavoro per un output così semplice, ma è perché WebGPU è progettato per eseguire il rendering di molta geometria in modo molto efficiente. Un effetto collaterale di questa efficienza è che fare cose relativamente semplici potrebbe sembrare insolitamente difficile, ma questa è l'aspettativa se ti rivolgi a un'API come WebGPU: vuoi fare qualcosa di un po' più complesso.
Come funzionano le GPU
Prima di apportare altre modifiche al codice, è utile fare una panoramica molto rapida, semplificata e di alto livello di come le GPU creano le forme che vedi sullo schermo. Se hai già familiarità con le nozioni di base sul funzionamento del rendering della GPU, puoi passare direttamente alla sezione Definizione dei vertici.
A differenza di un'API come Canvas 2D che ha molte forme e opzioni pronte all'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 funzionano quasi esclusivamente con i triangoli perché hanno molte proprietà matematiche che li rendono facili da elaborare in modo prevedibile ed efficiente. Quasi tutto ciò che disegni con la GPU deve essere suddiviso in triangoli prima che la GPU possa disegnarlo e questi triangoli devono essere definiti dai loro punti angolari.
Questi punti, o vertici, sono espressi 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. La struttura del sistema di coordinate è più facile da comprendere in relazione al canvas della pagina. Indipendentemente dalla larghezza o dall'altezza del canvas, il bordo sinistro si trova sempre a -1 sull'asse X e il bordo destro si trova sempre a +1 sull'asse X. Allo stesso modo, il bordo inferiore è sempre -1 sull'asse Y e il bordo superiore è +1 sull'asse Y. Ciò significa che (0, 0) è sempre il centro del canvas, (-1, -1) è sempre l'angolo in basso a sinistra e (1, 1) è sempre l'angolo in alto a destra. Questo spazio è noto come spazio di ritaglio.
I vertici vengono raramente definiti inizialmente in questo sistema di coordinate, quindi le GPU si basano su piccoli programmi chiamati shader dei vertici per eseguire i calcoli necessari per trasformare i vertici nello spazio di ritaglio, nonché qualsiasi altro calcolo necessario per disegnare i vertici. Ad esempio, lo shader potrebbe applicare un'animazione o calcolare la direzione dal vertice a una sorgente luminosa. Questi shader sono scritti da te, lo sviluppatore WebGPU, e offrono un controllo incredibile sul funzionamento della GPU.
Da qui, la GPU prende tutti i triangoli costituiti da questi vertici trasformati e determina quali pixel sullo schermo sono necessari per disegnarli. Poi esegue un altro piccolo programma che scrivi chiamato fragment shader, che calcola il colore di ogni pixel. Il calcolo può essere semplice come restituire il colore verde o complesso come calcolare l'angolo della superficie rispetto alla luce solare che rimbalza su altre superfici vicine, filtrata dalla nebbia e modificata dalla metallicità della superficie. È tutto sotto il tuo controllo, il che può essere sia stimolante che opprimente.
I risultati di questi colori dei pixel vengono quindi accumulati in una texture, che può essere mostrata sullo schermo.
Definisci i vertici
Come accennato in precedenza, la simulazione del Gioco della Vita viene mostrata come una griglia di celle. La tua app ha bisogno di un modo per visualizzare la griglia, distinguendo le celle attive da quelle inattive. L'approccio utilizzato in questo codelab consiste nel disegnare quadrati colorati nelle celle attive e lasciare vuote le celle inattive.
Ciò significa che dovrai fornire alla GPU quattro punti diversi, uno per ciascuno dei quattro angoli del quadrato. Ad esempio, un quadrato disegnato al centro del canvas, tirato dai bordi, ha coordinate degli angoli come queste:
Per inviare queste coordinate alla GPU, devi inserire i valori in un TypedArray. Se non li conosci già, i TypedArray 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 non firmato. I TypedArray sono ideali per inviare 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
.
- Crea un array che contenga tutte le posizioni dei vertici nel diagramma inserendo la seguente dichiarazione di array nel codice. Un buon posto per inserirlo è vicino alla parte superiore, appena sotto la chiamata
context.configure()
.
index.html
const vertices = new Float32Array([
// X, Y,
-0.8, -0.8,
0.8, -0.8,
0.8, 0.8,
-0.8, 0.8,
]);
Tieni presente che la spaziatura e il commento non influiscono sui valori, ma servono solo per comodità e per rendere il file più leggibile. Ti aiuta a capire che ogni coppia di valori costituisce le coordinate X e Y di un vertice.
Ma c'è un problema. Le GPU funzionano in termini di triangoli, ricordi? Ciò significa che devi fornire i vertici in gruppi di tre. Hai un gruppo di quattro persone. La soluzione consiste nel ripetere due dei vertici per creare due triangoli che condividono un lato al centro del quadrato.
Per formare il quadrato dal diagramma, devi elencare due volte i vertici (-0,8, -0,8) e (0,8, 0,8), una volta per il triangolo blu e una volta per quello rosso. Puoi anche scegliere di dividere il quadrato con gli altri due angoli, non fa differenza.
- Aggiorna l'array
vertices
precedente in modo che sia simile a questo:
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 li esegue il rendering senza spazi vuoti. Verrà visualizzato come un unico quadrato pieno.
Crea un buffer dei vertici
La GPU non può disegnare vertici con dati provenienti da un array JavaScript. Le GPU hanno spesso una propria memoria altamente ottimizzata per il rendering, quindi 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.
- Per creare un buffer in cui memorizzare i vertici, aggiungi la seguente chiamata a
device.createBuffer()
dopo la definizione dell'arrayvertices
.
index.html
const vertexBuffer = device.createBuffer({
label: "Cell vertices",
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
La prima cosa da notare è che al buffer viene assegnata un'etichetta. A ogni oggetto WebGPU che crei può essere assegnata un'etichetta facoltativa, e ti consigliamo vivamente di farlo. L'etichetta è una stringa a tua scelta, 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 le dimensioni del buffer in byte. Hai bisogno di un buffer di 48 byte, che ottieni 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, TypedArray calcola già la byteLength per te, quindi puoi utilizzarla 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 bit a bit). In questo caso, specifichi che vuoi che il buffer venga utilizzato per i dati dei vertici (GPUBufferUsage.VERTEX
) e che vuoi anche poter copiarvi i dati (GPUBufferUsage.COPY_DST
).
L'oggetto buffer che ti viene restituito è opaco: non puoi (facilmente) ispezionare i dati che contiene. Inoltre, la maggior parte dei suoi attributi è immutabile: non puoi ridimensionare un GPUBuffer
dopo la sua creazione, né puoi modificare i flag di utilizzo. Ciò che puoi modificare sono i contenuti della sua 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 che vuoi copiare.
- 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. Se vuoi disegnare qualcosa, devi fornire qualche informazione in più. Devi essere in grado di fornire a WebGPU maggiori informazioni sulla struttura dei dati dei vertici.
- Definisci la struttura dei dati dei vertici con un dizionario
GPUVertexBufferLayout
:
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 capire.
La prima cosa che fornisci è il arrayStride
. Questo è il 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 float a 32 bit è di 4 byte, quindi due float sono di 8 byte.
Poi c'è 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 spesso hanno vertici con più attributi, come il colore di un vertice o la direzione in cui è rivolta la superficie della geometria. Tuttavia, non rientra nell'ambito di questo codelab.
Nel singolo attributo, definisci innanzitutto il format
dei dati. Queste informazioni provengono da un elenco di tipi di GPUVertexFormat
che descrivono ogni tipo di dati dei vertici che la GPU può comprendere. I tuoi vertici hanno due valori float a 32 bit ciascuno, quindi utilizzi il formato float32x2
. Se invece i tuoi dati dei vertici sono costituiti da quattro interi senza segno a 16 bit ciascuno, ad esempio, utilizzerai uint16x4
. Vedi il pattern?
Successivamente, offset
descrive a quanti byte nel vertice inizia questo particolare attributo. Devi preoccuparti di questo solo se il buffer contiene più di un attributo, cosa che non accadrà durante questo codelab.
Infine, c'è il shaderLocation
. Si tratta di un numero arbitrario compreso tra 0 e 15 e deve essere univoco per ogni attributo che definisci. Collega questo attributo a un input specifico nello shader dei vertici, che vedremo nella sezione successiva.
Tieni presente che, anche se definisci questi valori ora, non li passi ancora all'API WebGPU. Ne parleremo più avanti, ma è più facile pensare a questi valori nel momento in cui definisci i vertici, in modo da configurarli ora per utilizzarli in un secondo momento.
Inizia con gli shader
Ora hai i dati che vuoi visualizzare, ma devi ancora indicare alla GPU esattamente come elaborarli. Gran parte di questo lavoro viene svolto con gli shader.
Gli shader sono piccoli programmi che scrivi ed esegui sulla GPU. Ogni shader opera su una fase diversa dei dati: elaborazione dei vertici, elaborazione dei frammenti o calcolo generale. Poiché si trovano sulla GPU, sono strutturati in modo più rigido rispetto al JavaScript medio. ma questa struttura consente loro di eseguire le operazioni molto rapidamente e, soprattutto, in parallelo.
Gli shader in WebGPU sono scritti in un linguaggio di shading chiamato WGSL (WebGPU Shading Language). Dal punto di vista sintattico, WGSL è simile a Rust, con funzionalità pensate per semplificare e velocizzare i tipi comuni di lavoro della GPU (come la matematica vettoriale e matriciale). L'insegnamento dell'intero linguaggio di ombreggiatura va ben oltre lo scopo di questo codelab, ma spero che tu possa apprendere alcune nozioni di base mentre esamini alcuni semplici esempi.
Gli shader stessi vengono passati a WebGPU come stringhe.
- Crea uno spazio in cui inserire il codice dello shader copiando il seguente codice sotto
vertexBufferLayout
:
index.html
const cellShaderModule = device.createShaderModule({
label: "Cell shader",
code: `
// Your shader code will go here
`
});
Per creare gli shader che chiami device.createShaderModule()
, a cui fornisci un label
e un code
WGSL facoltativi come stringa. Tieni presente che qui utilizzi gli apici inversi per consentire stringhe su più righe. Una volta aggiunto un codice WGSL valido, la funzione restituisce un oggetto GPUShaderModule
con i risultati compilati.
Definisci lo shader dei vertici
Inizia con lo shader dei vertici, perché è da lì che inizia anche la GPU.
Uno shader dei vertici è definito come una funzione e la GPU chiama questa funzione una volta per ogni vertice del vertexBuffer
. Poiché il tuo vertexBuffer
ha sei posizioni (vertici), la funzione che definisci viene chiamata sei volte. Ogni volta che viene chiamata, alla funzione viene passata una posizione diversa da vertexBuffer
come argomento. Il compito della funzione dello shader dei vertici è restituire una posizione corrispondente nello spazio clip.
È importante capire che non verranno necessariamente chiamati in ordine sequenziale. Le GPU, invece, eccellono nell'esecuzione parallela di shader come questi, elaborando potenzialmente centinaia (o anche migliaia) di vertici contemporaneamente. Questo è un aspetto fondamentale della velocità incredibile delle GPU, ma presenta dei limiti. Per garantire un parallelismo estremo, gli shader dei vertici non possono comunicare tra loro. Ogni invocazione dello shader può vedere i dati di un solo vertice alla volta ed è in grado di restituire valori per un solo vertice.
In WGSL, una funzione vertex shader può essere denominata come preferisci, ma deve avere l'@vertex
attributo davanti per indicare lo stadio dello shader che rappresenta. WGSL indica le funzioni con la parola chiave fn
, utilizza le parentesi per dichiarare gli argomenti e le parentesi graffe per definire l'ambito.
- Crea una funzione
@vertex
vuota, come questa:
index.html (createShaderModule code)
@vertex
fn vertexMain() {
}
Tuttavia, non è valido, in quanto uno shader dei vertici deve restituire almeno la posizione finale del vertice in fase di elaborazione nello spazio clip. Viene sempre fornito come vettore quadridimensionale. I vettori sono così comuni negli shader che vengono trattati come primitive di prima classe nel linguaggio, con i propri tipi come vec4f
per un vettore quadridimensionale. Esistono tipi simili anche per i vettori 2D (vec2f
) e 3D (vec3f
).
- Per indicare che il valore restituito è la posizione richiesta, contrassegnalo con l'attributo
@builtin(position)
. Il simbolo->
viene utilizzato per indicare che questo è il valore restituito dalla funzione.
index.html (createShaderModule code)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
}
Naturalmente, se la funzione ha un tipo restituito, devi restituire un valore nel corpo della funzione. Puoi creare un nuovo vec4f
da restituire utilizzando la sintassi vec4f(x, y, z, w)
. I valori x
, y
e z
sono tutti numeri in virgola mobile che, nel valore restituito, indicano dove si trova il vertice nello spazio di ritaglio.
- Restituisci un valore statico di
(0, 0, 0, 1)
e avrai tecnicamente uno shader di vertici valido, anche se non visualizzerà mai nulla perché la GPU riconosce che i triangoli che produce sono un solo punto e lo scarta.
index.html (createShaderModule code)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}
Quello che vuoi fare, invece, è utilizzare i dati del buffer che hai creato e lo fai dichiarando un argomento per la tua funzione con un attributo e un tipo @location()
che corrispondono a quelli descritti in vertexBufferLayout
. Hai specificato un shaderLocation
di 0
, quindi nel codice WGSL contrassegna l'argomento con @location(0)
. Hai anche definito il formato come float32x2
, che è un vettore 2D, quindi in WGSL l'argomento è un vec2f
. Puoi assegnare il nome che preferisci, ma dato che rappresentano le posizioni dei vertici, un nome come pos sembra naturale.
- Modifica la funzione shader con il seguente codice:
index.html (createShaderModule code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(0, 0, 0, 1);
}
Ora devi restituire la posizione. Poiché la posizione è un vettore 2D e il tipo restituito è un vettore 4D, devi modificarlo leggermente. Quello che devi fare è prendere i due componenti dall'argomento position e inserirli nei primi due componenti del vettore restituito, lasciando gli ultimi due componenti rispettivamente come 0
e 1
.
- Restituisci la posizione corretta indicando esplicitamente quali componenti della posizione utilizzare:
index.html (createShaderModule code)
@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 abbreviazione, che ha lo stesso significato.
- Riscrivi l'istruzione
return
con il seguente codice:
index.html (createShaderModule code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
E questo è il tuo shader dei vertici iniziale. È molto semplice, basta passare la posizione in modo efficace e invariato, ma è sufficiente per iniziare.
Definisci lo shader di frammenti
Il prossimo è lo shader di framenti. Gli shader di frammenti funzionano in modo molto simile agli shader di vertici, ma anziché essere richiamati per ogni vertice, vengono richiamati per ogni pixel disegnato.
Gli shader di framenti vengono sempre chiamati dopo gli shader di vertici. La GPU prende l'output degli shader dei vertici e lo triangola, creando triangoli da insiemi di tre punti. Poi rasterizza ciascuno di questi triangoli determinando quali pixel degli allegati di colore di output sono inclusi nel triangolo e chiama lo shader di framenti una volta per ciascuno di questi pixel. Lo shader di frammenti restituisce un colore, in genere calcolato a partire dai valori inviati dallo shader di vertici e da asset come le texture, che la GPU scrive nell'allegato di colore.
Come gli shader dei vertici, gli shader dei frammenti vengono eseguiti in modo massicciamente parallelo. Sono un po' più flessibili degli shader dei vertici in termini di input e output, ma puoi considerarli come se restituissero un solo colore per ogni pixel di ogni triangolo.
Una funzione shader di 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 a quale colorAttachment
della chiamata beginRenderPass
viene scritto il colore restituito. Poiché avevi un solo allegato, la posizione è 0.
- Crea una funzione
@fragment
vuota, come questa:
index.html (createShaderModule code)
@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 di clearValue
impostato in beginRenderPass
in precedenza. Quindi vec4f(1, 0, 0, 1)
è di un rosso vivo, che sembra un colore decente per il tuo quadrato. Puoi impostare il colore che preferisci.
- Imposta il vettore di colori restituito nel seguente modo:
index.html (createShaderModule code)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}
E questo è uno shader di frammenti completo. Non è particolarmente interessante, ma imposta ogni pixel di ogni triangolo su rosso, il che è sufficiente per ora.
Per ricapitolare, dopo aver aggiunto il codice dello shader descritto sopra, la chiamata createShaderModule
ora ha questo 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);
}
`
});
Crea una pipeline di rendering
Un modulo shader non può essere utilizzato per il rendering da solo. Devi invece utilizzarlo come parte di una GPURenderPipeline
, creata chiamando device.createRenderPipeline(). La pipeline di rendering controlla come viene disegnata la geometria, incluse informazioni come gli shader utilizzati, come interpretare i dati nei buffer dei vertici, quale tipo di geometria deve essere sottoposto a rendering (linee, punti, triangoli e così via).
La pipeline di rendering è l'oggetto più complesso dell'intera API, ma non preoccuparti. La maggior parte dei valori che puoi trasmettere sono facoltativi e per iniziare ne devi fornire solo alcuni.
- Crea una pipeline di rendering, come questa:
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 dei vertici) necessari alla pipeline, ma tu non ne hai. Fortunatamente, per ora puoi passare "auto"
e la pipeline crea il proprio layout dagli shader.
Dopodiché, devi fornire i dettagli della fase vertex
. module
è il GPUShaderModule che contiene lo shader dei vertici, mentre entryPoint
indica il nome della funzione nel codice dello shader che viene chiamata per ogni invocazione 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 compressi nei buffer dei vertici che utilizzi con questa pipeline. Fortunatamente, l'hai già definita in precedenza nel tuo vertexBufferLayout
. Ecco dove devi consegnarlo.
Infine, sono riportati i dettagli della fase fragment
. Sono inclusi anche un modulo shader e un entryPoint, come lo stage dei vertici. L'ultimo passaggio consiste nel definire la targets
con cui viene utilizzata questa pipeline. Si tratta di un array di dizionari che forniscono dettagli, ad esempio la trama format
, degli allegati di colore a cui viene inviato l'output della pipeline. Questi dettagli devono corrispondere alle texture fornite in colorAttachments
di tutti i pass di rendering con cui viene utilizzata questa pipeline. Il render pass utilizza le texture del contesto del canvas e il valore salvato in canvasFormat
per il formato, quindi devi passare lo stesso formato qui.
Queste non sono nemmeno tutte le opzioni che puoi specificare quando crei una pipeline di rendering, ma sono sufficienti per le esigenze di questo codelab.
Disegna il quadrato
A questo punto, hai tutto ciò che ti serve per disegnare il quadrato.
- Per disegnare il quadrato, torna alla coppia di chiamate
encoder.beginRenderPass()
epass.end()
, quindi aggiungi questi nuovi comandi tra le due:
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, WebGPU riceve tutte le informazioni necessarie per disegnare il quadrato. Innanzitutto, utilizza setPipeline()
per indicare la pipeline da utilizzare per disegnare. Sono inclusi gli shader utilizzati, il layout dei dati dei vertici e altri dati di stato pertinenti.
Successivamente, chiami setVertexBuffer()
con il buffer contenente i vertici del quadrato. Lo chiami con 0
perché questo buffer corrisponde al primo elemento nella definizione vertex.buffers
della pipeline corrente.
Infine, effettui la chiamata draw()
, che sembra stranamente semplice dopo tutta la configurazione precedente. L'unica cosa che devi passare è il numero di vertici da eseguire il rendering, che viene estratto dai buffer dei vertici attualmente impostati e interpretato con la pipeline attualmente impostata. Potresti codificarlo in modo permanente in 6
, ma calcolarlo dall'array di vertici (12 float / 2 coordinate per vertice = 6 vertici) significa che se decidessi di sostituire il quadrato con, ad esempio, un cerchio, ci sarebbe meno da aggiornare manualmente.
- Aggiorna lo schermo e (finalmente) vedrai i risultati di tutto il tuo duro lavoro: un grande quadrato colorato.
5. Disegnare una griglia
Innanzitutto, congratulati con te stesso. Ottenere 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 suddiviso in passaggi più piccoli, il che rende più facile verificare i tuoi progressi man mano che procedi.
In questa sezione imparerai:
- Come passare le variabili (chiamate uniform) allo shader da JavaScript.
- Come utilizzare le uniformi per modificare il comportamento di rendering.
- Come utilizzare l'istanziamento per disegnare molte varianti diverse della stessa geometria.
Definisci la griglia
Per eseguire il rendering di una griglia, devi conoscere un'informazione fondamentale. Quante celle contiene, sia in larghezza che in altezza? La scelta spetta a te in qualità di sviluppatore, ma per semplificare un po' le cose, considera la griglia come un quadrato (stessa larghezza e altezza) e utilizza una dimensione che sia una potenza di due. In questo modo, alcuni calcoli saranno più semplici in seguito. Alla fine vuoi ingrandirla, ma per il resto di questa sezione imposta le dimensioni della griglia su 4x4, perché è più facile dimostrare alcuni dei calcoli utilizzati in questa sezione. Aumenta la scala in un secondo momento.
- Definisci le dimensioni della griglia aggiungendo una costante all'inizio del codice JavaScript.
index.html
const GRID_SIZE = 4;
Successivamente, devi aggiornare il rendering del quadrato in modo da poter inserire GRID_SIZE
volte GRID_SIZE
sul canvas. Ciò significa che il quadrato deve essere molto più piccolo e che ce ne devono essere molti.
Un modo per affrontare questo problema è rendere il buffer dei vertici molto più grande e definire GRID_SIZE
volte GRID_SIZE
quadrati al suo interno con le dimensioni e la posizione giuste. Il codice per farlo non sarebbe troppo difficile, anzi. Solo un paio di cicli for e un po' di matematica. Tuttavia, in questo modo 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 alla GPU.
Crea un buffer uniforme
Innanzitutto, devi comunicare le dimensioni della griglia che hai scelto allo shader, poiché le utilizza per modificare la modalità di visualizzazione degli elementi. Potresti codificare le dimensioni nello shader, ma ciò 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 uniform.
In precedenza hai appreso che a ogni chiamata di uno shader dei vertici viene passato un valore diverso dal buffer dei vertici. Un uniform è un valore di un buffer uguale per ogni invocazione. Sono utili per comunicare valori comuni per una geometria (come la sua posizione), un fotogramma completo di animazione (come l'ora corrente) o persino l'intero ciclo di vita dell'app (come una preferenza utente).
- Crea un buffer uniforme aggiungendo il seguente codice:
index.html
// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
label: "Grid Uniforms",
size: uniformArray.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);
Dovrebbe sembrarti molto familiare, perché è quasi esattamente lo stesso codice che hai utilizzato in precedenza per creare il buffer dei vertici. Questo perché le uniformi vengono comunicate all'API WebGPU tramite gli stessi oggetti GPUBuffer dei vertici, con la differenza principale che usage
questa volta include GPUBufferUsage.UNIFORM
anziché GPUBufferUsage.VERTEX
.
Accedere alle uniformi in uno shader
- Definisci un'uniforme aggiungendo il seguente codice:
index.html (chiamata a createShaderModule)
// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos / grid, 0, 1);
}
// ...fragmentMain is unchanged
Definisce una variabile uniforme nello shader chiamata grid
, che è un vettore float 2D che corrisponde all'array che hai appena copiato nel buffer uniforme. Specifica inoltre che l'uniforme è delimitata da @group(0)
e @binding(0)
. Scoprirai il significato di questi valori tra poco.
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 per componenti. In altre parole, il risultato è lo stesso di vec2f(pos.x / grid.x, pos.y / grid.y)
.
Questi tipi di operazioni vettoriali sono molto comuni negli shader GPU, poiché molte tecniche di rendering e di calcolo si basano su di essi.
Nel tuo caso, se hai utilizzato una dimensione della griglia di 4, il quadrato che renderizzi sarà un quarto delle dimensioni originali. È la soluzione ideale se vuoi inserirne quattro in una riga o colonna.
Creare un gruppo di binding
La dichiarazione dell'uniform nello shader non lo connette al buffer che hai creato. Per farlo, devi creare e impostare un gruppo di binding.
Un gruppo di binding è una raccolta di risorse che vuoi rendere accessibili al tuo shader contemporaneamente. Può includere diversi tipi di buffer, come il buffer uniforme, e altre risorse come texture e campionatori che non sono trattati qui, ma sono parti comuni delle tecniche di rendering WebGPU.
- Crea un gruppo di 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 al label
ora standard, devi anche avere un layout
che descriva i tipi di risorse contenuti in questo gruppo di binding. Approfondirai questo aspetto in un passaggio successivo, ma per il momento puoi chiedere alla pipeline il layout del gruppo di binding perché l'hai creata con layout: "auto"
. In questo modo, la pipeline crea automaticamente i layout dei gruppi di binding dai binding dichiarati nel codice dello shader. In questo caso, chiedi di 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()
che hai inserito nello shader. In questo caso,0
.resource
, ovvero la risorsa effettiva che vuoi esporre alla variabile all'indice di binding specificato. In questo caso, il buffer uniforme.
La funzione restituisce un GPUBindGroup
, ovvero un handle opaco e immutabile. Una volta creato, non puoi modificare le risorse a cui fa riferimento un gruppo di binding, anche se puoi modificarne i contenuti. Ad esempio, se modifichi il buffer uniforme in modo che contenga una nuova dimensione della griglia, questa modifica viene riflessa dalle future chiamate di disegno che utilizzano questo gruppo di binding.
Associa il gruppo di binding
Ora che il gruppo di binding è stato creato, devi comunque indicare a WebGPU di utilizzarlo durante il disegno. Fortunatamente, è piuttosto semplice.
- 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);
0
passato come primo argomento corrisponde a @group(0)
nel codice dello shader. Stai dicendo che ogni @binding
che fa parte di @group(0)
utilizza le risorse di questo gruppo di binding.
Ora il buffer uniforme è esposto allo shader.
- Aggiorna la pagina e dovresti visualizzare un messaggio simile a questo:
Evviva! Il quadrato ora è un quarto delle dimensioni precedenti. Non è molto, ma mostra che l'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 per adattarla al pattern della griglia che preferisci. Per farlo, pensa esattamente a cosa vuoi ottenere.
Devi dividere concettualmente il canvas in singole celle. Per mantenere la convenzione secondo cui 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 canvas. In questo modo, il layout sarà simile a questo, con la geometria quadrata corrente al centro:
La tua sfida consiste nel trovare un metodo nello shader che ti consenta di posizionare la geometria quadrata in una qualsiasi di queste celle in base alle coordinate della cella.
Innanzitutto, puoi notare che il quadrato non è allineato correttamente a nessuna delle celle perché è stato definito in modo da circondare il centro del canvas. Ti consigliamo di spostare il quadrato di mezza cella in modo che si allinei perfettamente all'interno.
Un modo per risolvere il problema è aggiornare il buffer dei vertici del quadrato. Spostando i vertici in modo che l'angolo in basso a sinistra si trovi, ad esempio, in (0.1, 0.1) anziché in (-0.8, -0.8), questo quadrato si allineerebbe meglio ai limiti della cella. Tuttavia, poiché hai il controllo completo su come vengono elaborati i vertici nello shader, è altrettanto facile spostarli semplicemente in posizione utilizzando il codice dello shader.
- Modifica il modulo dello shader dei vertici con il seguente codice:
index.html (chiamata a 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 in alto e a destra di un'unità (che, ricordiamo, è la metà dello spazio clip) prima di dividerlo per la dimensione della griglia. Il risultato è un quadrato ben allineato alla griglia appena fuori dall'origine.
Successivamente, poiché il sistema di coordinate del canvas posiziona (0, 0) al centro e (-1, -1) in basso a sinistra e vuoi che (0, 0) si trovi in basso a sinistra, devi traslare la posizione della geometria di (-1, -1) dopo aver diviso per la dimensione della griglia per spostarla in quell'angolo.
- Traduci la posizione della geometria, in questo modo:
index.html (chiamata a 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).
Cosa succede se vuoi inserirlo in una cella diversa? Per farlo, dichiara un vettore cell
nello shader e completalo con un valore statico come let cell = vec2f(1, 1)
.
Se lo aggiungi a gridPos
, annulli - 1
nell'algoritmo, quindi non è quello che vuoi. Invece, vuoi spostare il quadrato di una sola unità della griglia (un quarto del canvas) per ogni cella. Sembra che tu debba fare un'altra divisione per grid
.
- Modifica il posizionamento della griglia, ad esempio:
index.html (chiamata a createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
let cell = vec2f(1, 1); // Cell(1,1) in the image above
let cellOffset = cell / grid; // Compute the offset to cell
let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!
return vec4f(gridPos, 0, 1);
}
Se aggiorni ora, vedrai quanto segue:
Mm. Non è proprio quello che volevi.
Il motivo è che, poiché le coordinate del canvas vanno da -1 a +1, in realtà sono 2 unità. Ciò significa che se vuoi spostare un vertice di un quarto della tela, devi spostarlo di 0,5 unità. Questo è un errore facile da commettere quando si ragiona con le coordinate della GPU. Fortunatamente, la soluzione è altrettanto semplice.
- Moltiplica l'offset per 2, in questo modo:
index.html (chiamata a 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, otterrai esattamente ciò che desideri.
Lo screenshot ha il seguente aspetto:
Inoltre, ora puoi impostare cell
su qualsiasi valore all'interno dei limiti della griglia e poi aggiornare per visualizzare il quadrato nel punto desiderato.
Disegnare le istanze
Ora che puoi posizionare il quadrato dove vuoi con un po' di matematica, il passaggio successivo è 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, questo processo sarebbe molto lento, poiché la GPU deve attendere ogni volta che JavaScript scrive la nuova coordinata. Uno dei fattori chiave per ottenere buone prestazioni dalla GPU è ridurre al minimo il tempo di attesa per altre parti del sistema.
In alternativa, puoi utilizzare una tecnica chiamata instancing. L'istanziamento è un modo per indicare alla GPU di disegnare più copie della stessa geometria con una singola chiamata a draw
, il che è molto più veloce rispetto a chiamare draw
una volta per ogni copia. Ogni copia della geometria è chiamata istanza.
- Per indicare alla GPU che vuoi un numero sufficiente di istanze del quadrato per riempire la griglia, aggiungi un argomento alla chiamata di disegno esistente:
index.html
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
In questo modo, il sistema disegnerà i sei (vertices.length / 2
) vertici del quadrato 16 (GRID_SIZE * GRID_SIZE
) volte. Tuttavia, se aggiorni la pagina, visualizzi ancora quanto segue:
Perché? Perché disegni tutti e 16 i quadrati nello stesso punto. Devi avere una logica aggiuntiva nello shader che riposizioni la geometria in base all'istanza.
Nello shader, oltre agli attributi dei vertici come pos
che provengono dal buffer dei vertici, puoi anche accedere a quelli che sono noti come valori incorporati di WGSL. Questi sono valori calcolati da WebGPU e uno di questi è instance_index
. instance_index
è un numero a 32 bit senza segno compreso tra 0
e number of instances - 1
che puoi utilizzare come parte della logica dello shader. Il valore è lo stesso per ogni vertice elaborato che fa parte della stessa istanza. Ciò significa che lo shader dei vertici viene chiamato sei volte con un instance_index
di 0
, una volta per ogni posizione nel buffer dei vertici. Poi altre sei volte con un instance_index
di 1
, poi altre sei con instance_index
di 2
e così via.
Per vederlo in azione, devi aggiungere instance_index
integrato agli input dello shader. Esegui questa operazione 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 farlo corrispondere al codice di esempio. Poi usalo come parte della logica dello shader.
- 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 aggiorni la pagina, vedrai che hai più di un quadrato. ma non puoi vederli tutti e 16.
Questo perché le coordinate delle celle che generi sono (0, 0), (1, 1), (2, 2)... fino a (15, 15), ma solo le prime quattro rientrano nel canvas. Per creare la griglia che ti interessa, devi trasformare instance_index
in modo che ogni indice venga mappato a una cella univoca all'interno della griglia, come segue:
I calcoli sono piuttosto semplici. Per ogni valore X della cella, vuoi il modulo di instance_index
e la larghezza della griglia, che puoi eseguire in WGSL con l'operatore %
. Per ogni valore Y della cella, dividi instance_index
per la larghezza della griglia, scartando qualsiasi resto frazionario. Puoi farlo con la funzione floor()
di WGSL.
- Modifica i calcoli nel seguente modo:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
// Compute the cell coordinate from the instance_index
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
Dopo aver apportato questo aggiornamento al codice, finalmente avrai la tanto attesa griglia di quadrati.
- Ora che funziona, torna indietro e aumenta le dimensioni della griglia.
index.html
const GRID_SIZE = 32;
Tada! Ora puoi creare una griglia molto grande e la tua GPU media la gestisce senza problemi. Non vedrai più i singoli quadrati molto prima di riscontrare colli di bottiglia nelle prestazioni della GPU.
6. Un tocco in più: rendi il tuo messaggio più colorato.
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 dello stesso colore è funzionale, non è esattamente entusiasmante, non credi? Fortunatamente, puoi rendere le cose un po' più luminose con un po' più di matematica e codice shader.
Utilizzare le struct negli shader
Finora hai passato un dato dallo shader dei vertici: la posizione trasformata. Tuttavia, puoi restituire molti più dati dallo shader dei vertici e poi utilizzarli nello shader dei frammenti.
L'unico modo per passare i dati fuori dallo shader dei vertici è restituirli. Uno shader dei vertici è sempre necessario per restituire una posizione, quindi se vuoi restituire altri dati insieme a questa, 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 lo shader dei vertici attuale:
index.html (chiamata a createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
- Esprimi la stessa cosa utilizzando le struct per l'input e l'output della funzione:
index.html (chiamata a createShaderModule)
struct VertexInput {
@location(0) pos: vec2f,
@builtin(instance_index) instance: u32,
};
struct VertexOutput {
@builtin(position) pos: vec4f,
};
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
let i = f32(input.instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
return output;
}
Tieni presente che per fare riferimento alla posizione di input e all'indice dell'istanza è necessario utilizzare input
e che lo struct restituito deve essere dichiarato come variabile e le sue singole proprietà devono essere impostate. In questo caso, non fa molta differenza e, anzi, la funzione shader diventa un po' più lunga, ma man mano che gli shader diventano più complessi, l'utilizzo di struct può essere un ottimo modo per organizzare i dati.
Trasferire dati tra le funzioni del vertice e del frammento
Ti ricordiamo che la funzione @fragment
è il più semplice possibile:
index.html (chiamata a createShaderModule)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
Non stai prendendo alcun input e stai passando un colore solido (rosso) come output. Se lo shader conoscesse meglio la 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 alle coordinate della cella? La fase @vertex
sa quale cella viene visualizzata. Devi solo passarla alla fase @fragment
.
Per trasferire dati tra le fasi di vertice e frammento, devi includerli in uno struct di output con un @location
a tua scelta. Poiché vuoi passare la coordinata della cella, aggiungila alla struct VertexOutput
precedente, quindi impostala nella funzione @vertex
prima di restituirla.
- Modifica il valore restituito dello shader dei vertici nel seguente modo:
index.html (chiamata a 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;
}
- Nella funzione
@fragment
, ricevi il valore aggiungendo un argomento con lo stesso@location
. I nomi non devono corrispondere, ma è più facile tenere traccia delle cose se lo fanno.
index.html (chiamata a 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);
}
- In alternativa, puoi utilizzare una struct:
index.html (chiamata a createShaderModule)
struct FragInput {
@location(0) cell: vec2f,
};
@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
- Un'altra alternativa, dato che nel tuo codice entrambe queste funzioni sono definite nello stesso modulo shader, è riutilizzare lo struct di output della fase
@vertex
. In questo modo, il passaggio dei valori è semplice perché i nomi e le posizioni sono naturalmente coerenti.
index.html (chiamata a createShaderModule)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
Indipendentemente dal pattern scelto, il risultato è che hai accesso al numero di cella nella funzione @fragment
e puoi utilizzarlo per influenzare il colore. Con uno qualsiasi dei codici precedenti, l'output è simile al seguente:
Ora ci sono sicuramente più colori, ma non è esattamente bello. Ti starai chiedendo perché solo le righe a sinistra e in basso sono diverse. Questo perché i valori di colore restituiti dalla funzione @fragment
prevedono che ogni canale sia compreso tra 0 e 1 e tutti i valori al di fuori di questo intervallo vengono bloccati. I valori delle celle, invece, vanno da 0 a 32 lungo ogni asse. Quindi, come puoi vedere, la prima riga e la prima colonna raggiungono immediatamente il valore 1 completo sul canale di colore rosso o verde e ogni cella successiva viene bloccata sullo stesso valore.
Se vuoi una transizione più fluida tra i colori, devi restituire un valore frazionario per ogni canale di colore, idealmente a partire da zero e terminando a uno lungo ogni asse, il che significa un'altra divisione per grid
.
- Modifica lo shader dei frammenti nel seguente modo:
index.html (chiamata a 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 crea un gradiente di colori molto più piacevole su tutta la griglia.
Anche se si tratta sicuramente 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 oscurerà ciò che sta succedendo. Sarebbe bello illuminare un po' la situazione.
Fortunatamente, hai a disposizione un intero canale di colore inutilizzato, il blu, che puoi utilizzare. L'effetto ideale è che il blu sia più luminoso dove gli altri colori sono più scuri e poi sbiadisca man mano che gli altri colori aumentano di intensità. Il modo più semplice per farlo è impostare il canale start su 1 e sottrarre uno dei valori delle celle. Può essere c.x
o c.y
. Prova entrambi e scegli quello che preferisci.
- Aggiungi colori più luminosi allo shader dei frammenti, 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 è piuttosto buono.
Questo non è un passaggio fondamentale. Tuttavia, poiché ha un aspetto migliore, è stata inclusa nel file di origine del checkpoint corrispondente e il resto degli screenshot di questo codelab riflettono questa griglia più colorata.
7. Gestire lo stato della cella
Successivamente, devi controllare il rendering delle celle della griglia in base a uno stato memorizzato sulla GPU. Questo è importante per la simulazione finale.
Ti serve solo un segnale di attivazione/disattivazione per ogni cella, quindi qualsiasi opzione che ti consenta di memorizzare un ampio array di quasi tutti i tipi di valori funziona. Potresti pensare che questo sia un altro caso d'uso per i buffer uniformi. Anche se potresti far funzionare questa soluzione, è più difficile perché i buffer uniformi hanno dimensioni limitate, non supportano array di 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, in quanto vuoi eseguire la simulazione del Gioco della vita sulla GPU in uno shader di calcolo.
Fortunatamente, esiste un'altra opzione di buffer che evita tutte queste limitazioni.
Crea un buffer di archiviazione
I buffer di archiviazione sono buffer di uso generale che possono essere letti e scritti negli shader di calcolo e letti negli shader dei vertici. Possono essere molto grandi e non richiedono una dimensione specifica dichiarata in uno shader, il che li rende molto più simili alla memoria generale. È ciò che utilizzi per memorizzare lo stato della cella.
- Per creare un buffer di archiviazione per lo stato della cella, utilizza uno snippet di codice di creazione del buffer che ormai probabilmente ti sembrerà 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 i buffer dei vertici e uniformi, chiama device.createBuffer()
con la dimensione appropriata, quindi assicurati di specificare un utilizzo di GPUBufferUsage.STORAGE
questa volta.
Puoi riempire il buffer allo stesso modo di prima riempiendo TypedArray della stessa dimensione con i valori e poi chiamando device.queue.writeBuffer()
. Poiché vuoi vedere l'effetto del buffer sulla griglia, inizia a riempirlo con qualcosa di prevedibile.
- Attiva ogni terza cella con questo 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);
Leggere il buffer di archiviazione nello shader
Poi, aggiorna lo shader in modo che esamini i contenuti del buffer di archiviazione prima di eseguire il rendering della griglia. La procedura è molto simile a quella per l'aggiunta delle divise in passato.
- 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 rilegatura, che si trova proprio sotto l'uniforme della griglia. Vuoi mantenere lo stesso @group
della divisa 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 che fornisci per cellState
è un array di valori u32
, in modo che corrisponda 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 si disattiva una cella se lo stato indica che è inattiva? Poiché gli stati attivo e non attivo che ottieni dall'array sono 1 o 0, puoi scalare la geometria in base allo stato attivo. Se lo metti in scala di 1, la geometria rimane invariata, mentre se lo metti in scala di 0, la geometria si riduce a un singolo punto, che la GPU scarta.
- Aggiorna il codice dello shader per scalare la posizione in base allo stato attivo della cella. Il valore dello stato deve essere convertito in un
f32
per soddisfare i requisiti di sicurezza dei tipi di WGSL:
index.html
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) -> VertexOutput {
let i = f32(instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let state = f32(cellState[instance]); // New line!
let cellOffset = cell / grid * 2;
// New: Scale the position by the cell's active state.
let gridPos = (pos*state+1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
output.cell = cell;
return output;
}
Aggiungere il buffer di archiviazione al gruppo di binding
Prima di poter visualizzare l'effetto dello stato della cella, aggiungi il buffer di archiviazione a un gruppo di binding. Poiché fa parte dello stesso @group
del buffer uniforme, aggiungilo anche allo stesso gruppo di binding nel codice JavaScript.
- Aggiungi il buffer di archiviazione, in questo modo:
index.html
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
},
// New entry!
{
binding: 1,
resource: { buffer: cellStateStorage }
}],
});
Assicurati che il binding
della nuova voce corrisponda al @binding()
del valore corrispondente nello shader.
A questo punto, dovresti essere in grado di aggiornare la pagina e vedere il pattern nella griglia.
Utilizzare il pattern del 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 lo stato a cui hanno scritto in precedenza. Questo pattern è comunemente chiamato ping pong perché la versione più aggiornata dello stato viene trasferita avanti e indietro tra le copie dello stato a ogni passaggio.
Perché è necessario? Considera un esempio semplificato: immagina di scrivere una simulazione molto semplice in cui sposti tutti i blocchi attivi di una cella verso destra a ogni passaggio. Per semplificare la comprensione, definisci i dati e la simulazione in JavaScript:
// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];
function simulate() {
for (let i = 0; i < state.length-1; ++i) {
if (state[i] == 1) {
state[i] = 0;
state[i+1] = 1;
}
}
}
simulate(); // Run the simulation for one step.
Ma se esegui questo codice, la cella attiva si sposta fino alla fine dell'array in un solo passaggio. Perché? Perché aggiorni lo stato sul posto, quindi sposti la cella attiva a destra, poi guardi la cella successiva e… hey! È attivo. Meglio spostarlo di nuovo a destra. Il fatto che tu modifichi i dati nello stesso momento in cui li osservi altera 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);
- 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,
})
];
- Per visualizzare meglio la differenza tra i due buffer, riempili con dati diversi:
index.html
// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);
// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
- Per mostrare i diversi buffer di archiviazione nel rendering, aggiorna anche i gruppi di binding in modo che abbiano 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 ciclo di rendering
Finora hai eseguito un solo disegno per ogni aggiornamento della pagina, ma ora vuoi mostrare l'aggiornamento dei dati nel tempo. Per farlo, devi creare un semplice ciclo di rendering.
Un ciclo di rendering è un ciclo che si ripete all'infinito e disegna i contenuti sul canvas a un determinato intervallo. Molti giochi e altri contenuti che vogliono animarsi in modo fluido utilizzano la funzione requestAnimationFrame()
per pianificare i callback alla stessa velocità di aggiornamento dello schermo (60 volte al secondo).
Anche questa app può utilizzare questa funzionalità, ma in questo caso probabilmente vuoi che gli aggiornamenti avvengano in passaggi più lunghi in modo da poter seguire più facilmente cosa sta facendo la simulazione. Gestisci tu stesso il ciclo in modo da controllare la frequenza di aggiornamento della simulazione.
- Innanzitutto, scegli una velocità di aggiornamento per la simulazione (200 ms è un buon valore, ma puoi scegliere una velocità inferiore o superiore se preferisci), quindi 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
- Poi sposta tutto il codice che utilizzi attualmente per il rendering in una nuova funzione. Pianifica la ripetizione della funzione all'intervallo che preferisci con
setInterval()
. Assicurati che la funzione aggiorni anche il conteggio dei passi e utilizzalo per scegliere quale dei due gruppi di binding associare.
index.html
// Move all of our rendering code into a function
function updateGrid() {
step++; // Increment the step count
// Start a render pass
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
storeOp: "store",
}]
});
// Draw the grid.
pass.setPipeline(cellPipeline);
pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
// End the render pass and submit the command buffer
pass.end();
device.queue.submit([encoder.finish()]);
}
// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);
Ora, quando esegui l'app, vedi che il canvas si sposta avanti e indietro tra i due buffer di stato che hai creato.
A questo punto, la parte di rendering è quasi completata. Ora puoi visualizzare l'output della simulazione del Gioco della vita che creerai nel passaggio successivo, in cui 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 questo ti dia un'idea sufficiente di come funziona il rendering di WebGPU, in modo da rendere più facile la comprensione di tecniche più avanzate come il rendering 3D.
8. Eseguire la simulazione
Ora, passiamo all'ultimo pezzo importante del puzzle: l'esecuzione della simulazione del Gioco della Vita in uno shader di calcolo.
Utilizzare finalmente gli shader di calcolo.
In questo codelab hai appreso in modo astratto i concetti relativi agli shader di calcolo, ma cosa sono esattamente?
Uno shader di calcolo è simile agli shader di vertici e di frammenti in quanto è progettato per essere eseguito con un parallelismo estremo sulla GPU, ma a differenza delle altre due fasi dello shader, non ha un insieme specifico di input e output. Leggi e scrivi dati esclusivamente dalle origini che scegli, come i buffer di archiviazione. Ciò significa che, anziché eseguire una volta per ogni vertice, istanza o pixel, devi indicare il numero di chiamate della funzione shader che vuoi. Poi, quando esegui lo shader, ti viene comunicato quale invocazione viene elaborata e puoi decidere a quali dati accedere e quali operazioni eseguire da lì.
Gli shader di calcolo devono essere creati in un modulo shader, proprio come gli shader di vertici e frammenti, quindi aggiungili al codice per iniziare. Come puoi immaginare, data la struttura degli altri shader che hai implementato, la funzione principale dello shader di calcolo deve essere contrassegnata con l'attributo @compute
.
- 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 che lo shader venga richiamato un numero specifico di volte lungo gli assi X, Y e Z. In questo modo puoi distribuire molto facilmente il lavoro in base 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 workgroup. Un gruppo di lavoro ha dimensioni X, Y e Z e, sebbene le dimensioni possano essere pari a 1 ciascuna, spesso è più vantaggioso aumentare leggermente le dimensioni dei gruppi di lavoro. Per lo shader, scegli una dimensione del gruppo di lavoro un po' arbitraria di 8 x 8. È utile tenerne traccia nel codice JavaScript.
- Definisci una costante per la dimensione del 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 template letterali di JavaScript in modo da poter utilizzare facilmente la costante appena definita.
- Aggiungi le dimensioni del gruppo di lavoro alla funzione shader, nel seguente modo:
index.html (Compute createShaderModule call)
@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {
}
Indica allo shader che il lavoro svolto con questa funzione viene eseguito in gruppi (8 x 8 x 1). Qualsiasi asse che non specifichi viene impostato su 1 per impostazione predefinita, anche se devi specificare almeno l'asse X.
Come per le altre fasi dello shader, esiste una serie di valori @builtin
che puoi accettare come input nella funzione dello shader di calcolo per sapere a quale invocazione ti trovi e decidere quale lavoro devi svolgere.
- Aggiungi un valore
@builtin
, ad esempio:
index.html (Compute createShaderModule call)
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Passi il valore integrato global_invocation_id
, che è un vettore tridimensionale di numeri interi senza segno che indica la posizione nella griglia delle invocazioni dello shader. Esegui questo shader una volta per ogni cella della griglia. Ottieni numeri come (0, 0, 0)
, (1, 0, 0)
, (1, 1, 0)
... fino a (31, 31, 0)
, il che significa che puoi trattarlo come l'indice della cella su cui operare.
Gli shader di calcolo possono utilizzare anche le uniformi, che vengono utilizzate proprio come negli shader di vertici e di frammenti.
- Utilizza una variabile uniforme con lo shader di calcolo per indicare le dimensioni della griglia, ad esempio:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f; // New line
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Come nello shader dei vertici, esponi anche lo stato della cella come buffer di archiviazione. Ma in questo caso ne hai bisogno di due. Poiché gli shader di calcolo non hanno un output obbligatorio, come una posizione del vertice o un colore del frammento, la scrittura di 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 imparato in precedenza: hai un buffer di archiviazione che inserisce lo stato attuale della griglia e uno in cui scrivi il nuovo stato della griglia.
- Esporre lo stato di input e output della cella come buffer di archiviazione, in questo modo:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f;
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
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 lo shader di calcolo. Non esiste una modalità di archiviazione di sola scrittura in WebGPU.
A questo punto, devi avere un modo per mappare l'indice della cella nell'array di archiviazione lineare. Si tratta sostanzialmente dell'opposto di quanto hai fatto nello shader dei vertici, dove hai preso l'instance_index
lineare e l'hai mappato a una cella della griglia 2D. (Ti ricordiamo che l'algoritmo era vec2f(i % grid.x, floor(i / grid.x))
.)
- Scrivi una funzione per andare nella direzione opposta. Prende il valore Y della cella, lo moltiplica per la larghezza della griglia e poi aggiunge il valore X della cella.
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
// New function
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Infine, per verificare che funzioni, implementa un algoritmo molto semplice: se una cella è attualmente accesa, si spegne e viceversa. Non è ancora il Gioco della Vita, ma è sufficiente per dimostrare che lo shader di calcolo funziona.
- Aggiungi l'algoritmo semplice, in questo modo:
index.html (Compute createShaderModule call)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines. Flip the cell state every step.
if (cellStateIn[cellIndex(cell.xy)] == 1) {
cellStateOut[cellIndex(cell.xy)] = 0;
} else {
cellStateOut[cellIndex(cell.xy)] = 1;
}
}
Per ora è tutto per lo shader di calcolo. Ma prima di poter visualizzare i risultati, devi apportare altre modifiche.
Utilizzare i layout di pipeline e gruppo di binding
Una cosa che potresti notare dallo shader precedente è che utilizza in gran parte gli stessi input (uniform e buffer di archiviazione) della pipeline di rendering. Quindi potresti pensare di poter semplicemente utilizzare gli stessi gruppi di binding e il gioco è fatto, giusto? La buona notizia è che puoi farlo. Per farlo, è necessaria una configurazione manuale un po' più complessa.
Ogni volta che crei un gruppo di binding, 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 binding sia alle pipeline.
Per capire il motivo, considera che 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 binding, non solo quelle utilizzate da una pipeline specifica.
- 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 binding 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, fornisci il numero binding
per la risorsa, che (come hai imparato quando hai creato il gruppo di binding) corrisponde al valore @binding
negli shader. Fornisci anche visibility
, ovvero flag GPUShaderStage
che indicano quali fasi dello shader possono utilizzare la risorsa. Vuoi che sia il buffer di archiviazione uniforme sia il primo buffer di archiviazione siano accessibili negli shader di vertici e di calcolo, ma il secondo buffer di archiviazione deve essere accessibile solo negli shader di calcolo.
Infine, indica il tipo di risorsa utilizzata. Si tratta di una chiave del dizionario diversa, a seconda di ciò che devi esporre. In questo caso, tutte e tre le risorse sono buffer, quindi utilizzi il tasto buffer
per definire le opzioni per ciascuna. Altre opzioni includono texture
o sampler
, ma non sono necessarie qui.
Nel dizionario del buffer, imposti opzioni come il tipo di buffer utilizzato.type
Il valore predefinito è "uniform"
, quindi puoi lasciare vuoto il dizionario per l'associazione 0. Tuttavia, devi impostare almeno buffer: {}
, in modo che la voce venga identificata come buffer. Il binding 1 ha un tipo "read-only-storage"
perché non lo utilizzi con l'accesso read_write
nello shader, mentre il binding 2 ha un tipo "storage"
perché lo utilizzi con l'accesso read_write
.
Una volta creato il bindGroupLayout
, puoi passarlo durante la creazione dei gruppi di binding anziché eseguire query sul gruppo di binding dalla pipeline. In questo modo, devi aggiungere una nuova voce del buffer di archiviazione a ogni gruppo di binding per corrispondere al layout appena definito.
- Aggiorna la creazione del gruppo di binding nel seguente modo:
index.html
// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
device.createBindGroup({
label: "Cell renderer bind group A",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[0] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[1] }
}],
}),
device.createBindGroup({
label: "Cell renderer bind group B",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[1] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[0] }
}],
}),
];
Ora che il gruppo di binding è stato aggiornato per utilizzare questo layout esplicito, devi aggiornare la pipeline di rendering in modo che utilizzi lo stesso layout.
- Crea un
GPUPipelineLayout
.
index.html
const pipelineLayout = device.createPipelineLayout({
label: "Cell Pipeline Layout",
bindGroupLayouts: [ bindGroupLayout ],
});
Un layout della pipeline è un elenco di layout di gruppi di binding (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)
.
- Una volta ottenuto il layout della pipeline, aggiorna la pipeline di rendering in modo che lo utilizzi 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 di vertici e frammenti, 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 alcuno 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 passi il nuovo pipelineLayout
anziché "auto"
, proprio come nella pipeline di rendering aggiornata, il che garantisce che sia la pipeline di rendering sia la pipeline di calcolo possano utilizzare gli stessi gruppi di binding.
Pass di calcolo
A questo punto, puoi utilizzare la pipeline di calcolo. Dato che il rendering viene eseguito in una passata di rendering, probabilmente avrai intuito che devi eseguire il lavoro di calcolo in una passata di calcolo. Il lavoro di calcolo e rendering può avvenire nello stesso codificatore di comandi, quindi devi modificare leggermente la funzione updateGrid
.
- Sposta la creazione del codificatore all'inizio della funzione e poi inizia un passaggio di calcolo con esso (prima di
step++
).
index.html
// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();
const computePass = encoder.beginComputePass();
// Compute work will go here...
computePass.end();
// Existing lines
step++; // Increment the step count
// Start a render pass...
Proprio come le pipeline di calcolo, i passaggi di calcolo sono molto più semplici da avviare rispetto alle loro controparti di rendering perché non devi preoccuparti di eventuali allegati.
Vuoi eseguire il passaggio di calcolo prima del passaggio di rendering perché consente al passaggio di rendering di utilizzare immediatamente i risultati più recenti del passaggio di calcolo. Questo è anche il motivo per cui incrementi il conteggio di step
tra le passate, in modo che il buffer di output della pipeline di calcolo diventi il buffer di input per la pipeline di rendering.
- Successivamente, imposta la pipeline e il gruppo di binding all'interno della pass di calcolo, utilizzando lo stesso pattern per passare da un gruppo di binding all'altro come per la pass di rendering.
index.html
const computePass = encoder.beginComputePass();
// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);
computePass.end();
- Infine, anziché disegnare come in una pass di rendering, distribuisci il lavoro allo shader di calcolo, indicando il numero di gruppi di lavoro che vuoi eseguire su ogni asse.
index.html
const computePass = encoder.beginComputePass();
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);
// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);
computePass.end();
Una cosa molto importante da notare qui è che il numero che passi a dispatchWorkgroups()
non è il numero di chiamate. 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 32x32 volte per coprire l'intera griglia e le dimensioni del workgroup sono 8x8, devi inviare 4x4 workgroup (4 * 8 = 32). Per questo motivo, dividi le dimensioni della griglia per le dimensioni del gruppo di lavoro e passa questo valore a dispatchWorkgroups()
.
Ora puoi aggiornare di nuovo la pagina e dovresti notare che la griglia si inverte a ogni aggiornamento.
Implementa 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 il contenuto del buffer di archiviazione e aggiornarlo in modo che produca un buffer casuale a ogni caricamento della pagina. (I pattern regolari non sono punti di partenza molto interessanti per il gioco della vita.) Puoi randomizzare i valori come preferisci, ma esiste un modo semplice per iniziare che produce risultati ragionevoli.
- Per iniziare ogni cella in uno stato casuale, aggiorna l'inizializzazione
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 è stato necessario per arrivare fin qui, il codice dello shader potrebbe essere deludente per la sua semplicità.
Innanzitutto, devi sapere quante celle vicine sono attive per una determinata cella. Non ti interessa quali sono attivi, ma solo il conteggio.
- Per semplificare l'ottenimento dei dati delle celle adiacenti, aggiungi una funzione
cellActive
che restituisce il valorecellStateIn
della coordinata specificata.
index.html (Compute createShaderModule call)
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
La funzione cellActive
restituisce 1 se la cella è attiva, quindi sommando il valore restituito dalla chiamata di cellActive
per tutte le otto celle circostanti si ottiene il numero di celle adiacenti attive.
- Trova il numero di vicini attivi, ad esempio:
index.html (Compute createShaderModule call)
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines:
// Determine how many active neighbors this cell has.
let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
cellActive(cell.x+1, cell.y) +
cellActive(cell.x+1, cell.y-1) +
cellActive(cell.x, cell.y-1) +
cellActive(cell.x-1, cell.y-1) +
cellActive(cell.x-1, cell.y) +
cellActive(cell.x-1, cell.y+1) +
cellActive(cell.x, cell.y+1);
Questo porta a un piccolo problema: cosa succede quando la cella che stai controllando si trova fuori dal bordo della scacchiera? In base alla logica di cellIndex()
attuale, il testo viene spostato nella riga successiva o precedente oppure esce dal buffer.
Per il Gioco della Vita, un modo comune e semplice per risolvere questo problema è fare in modo che le celle sul bordo della griglia considerino le celle sul bordo opposto della griglia come vicine, creando una sorta di effetto di avvolgimento.
- Supporta il wrapping della griglia con una piccola modifica alla funzione
cellIndex()
.
index.html (Compute createShaderModule call)
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
Utilizzando l'operatore %
per eseguire il wrapping delle celle X e Y quando si estendono oltre le dimensioni della griglia, ti assicuri di non accedere mai al di fuori dei limiti del buffer di archiviazione. In questo modo, puoi avere la certezza che il conteggio di activeNeighbors
sia prevedibile.
Poi applichi una delle quattro regole:
- Qualsiasi cella con meno di due vicini diventa inattiva.
- Qualsiasi cella attiva con due o tre vicini rimane attiva.
- Qualsiasi cella inattiva con esattamente tre vicini diventa attiva.
- Qualsiasi cella con più di tre vicini diventa inattiva.
Puoi farlo con una serie di istruzioni if, ma WGSL supporta anche le istruzioni switch, che sono adatte a questa logica.
- Implementa la logica del Gioco della Vita, in questo modo:
index.html (Compute createShaderModule call)
let i = cellIndex(cell.xy);
// Conway's game of life rules:
switch activeNeighbors {
case 2: { // Active cells with 2 neighbors stay active.
cellStateOut[i] = cellStateIn[i];
}
case 3: { // Cells with 3 neighbors become or stay active.
cellStateOut[i] = 1;
}
default: { // Cells with < 2 or > 3 neighbors become inactive.
cellStateOut[i] = 0;
}
}
Per riferimento, la chiamata finale al modulo dello shader di calcolo ora ha questo 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.
9. Complimenti!
Hai creato una versione della classica simulazione del Gioco della vita di Conway che viene eseguita interamente sulla GPU utilizzando l'API WebGPU.
Passaggi successivi
- Consulta gli esempi di WebGPU.
Further reading
- WebGPU: tutti i core, nessuna tela
- WebGPU non elaborato
- Nozioni di base su WebGPU
- Best practice per WebGPU