1. Introduzione
Che cos'è WebGPU?
WebGPU è una nuova API moderna per accedere alle funzionalità della GPU nelle app web.
API moderna
Prima di WebGPU, esisteva WebGL, che offriva un sottoinsieme delle funzionalità di WebGPU. Ha consentito di creare una nuova classe di contenuti web avanzati e gli sviluppatori hanno realizzato cose straordinarie con questa tecnologia. Tuttavia, si basava sull'API OpenGL ES 2.0, rilasciata nel 2007, basata sull'API OpenGL ancora più vecchia. Le GPU sono evolute notevolmente in questo periodo e anche le API native utilizzate per interfacciarsi con esse sono evolute con Direct3D 12, Metal e Vulkan.
WebGPU porta i vantaggi di queste API moderne alla piattaforma web. Si concentra sull'abilitazione delle funzionalità GPU in modo multipiattaforma, presentando al contempo un'API che sembra naturale sul web ed è meno verbosa di alcune delle API native su cui è basata.
Rendering
Le GPU sono spesso associate al rendering di grafica veloce e dettagliata e WebGPU non fa eccezione. Dispone delle funzionalità necessarie per supportare molte delle tecniche di rendering più diffuse oggi sia su GPU desktop che mobile e offre un percorso per l'aggiunta di nuove funzionalità in futuro, man mano che le funzionalità hardware continuano a evolversi.
Computing
Oltre al rendering, WebGPU sblocca il potenziale della GPU per l'esecuzione di carichi di lavoro generici e altamente paralleli. Questi shader di calcolo possono essere utilizzati in modo autonomo, senza alcun componente di rendering, o come parte strettamente integrata della pipeline di rendering.
Nel codelab di oggi imparerai a sfruttare le funzionalità di rendering e di calcolo di WebGPU per creare un semplice progetto introduttivo.
Cosa creerai
In questo codelab, crei Il gioco della vita di Conway utilizzando WebGPU. La tua app sarà in grado di:
- Utilizza le funzionalità di rendering di WebGPU per disegnare semplici immagini 2D.
- Utilizza le funzionalità di computing di WebGPU per eseguire la simulazione.
Il gioco della vita è un automa cellulare, in cui una griglia di celle cambia stato nel tempo in base a un insieme di regole. Nel Gioco della vita le cellule diventano attive o inattive a seconda di quante delle celle vicine sono attive, il che porta a schemi interessanti che fluttuano mentre guardi.
Cosa imparerai a fare
- Come configurare WebGPU e configurare una tela.
- Come disegnare una geometria 2D semplice.
- Come utilizzare gli shader vertex e fragment per modificare ciò che viene disegnato.
- Come utilizzare gli shader di calcolo per eseguire una simulazione semplice.
Questo codelab si concentra sull'introduzione dei concetti fondamentali alla base di WebGPU. Non è pensata per essere una revisione completa dell'API, né copre (o richiede) argomenti spesso correlati, come la matematica matriciale 3D.
Che cosa ti serve
- Una versione recente di Chrome (113 o versioni successive) su ChromeOS, macOS o Windows. WebGPU è un'API multipiattaforma e multibrowser, ma non è ancora stata rilasciata ovunque.
- Conoscenza di HTML, JavaScript e Chrome DevTools.
La conoscenza di altre API di grafica, come WebGL, Metal, Vulkan o Direct3D, non è obbligatoria, ma se hai esperienza con queste API, probabilmente noterai molte somiglianze con WebGPU che potrebbero aiutarti a iniziare a imparare.
2. Configurazione
Ottieni il codice
Questo codelab non ha dipendenze e ti guida in ogni passaggio necessario per creare l'app WebGPU, quindi non è necessario alcun codice per iniziare. Tuttavia, alcuni esempi pratici che possono essere utilizzati come punti di controllo sono disponibili all'indirizzo https://glitch.com/edit/#!/your-first-webgpu-app. In caso di problemi, puoi dare un'occhiata e farvi riferimento man mano che procedi.
Utilizza la console dello sviluppatore.
WebGPU è un'API abbastanza complessa con molte regole che ne garantiscono l'utilizzo corretto. Inoltre, a causa del funzionamento dell'API, non può generare le eccezioni JavaScript tipiche per molti errori, il che rende più difficile individuare esattamente la causa del problema.
Incontrerai problemi durante lo sviluppo con WebGPU, soprattutto se sei un principiante, e va bene così. Gli sviluppatori che si occupano dell'API sono consapevoli delle difficoltà legate allo sviluppo con GPU e hanno lavorato duramente per garantire che ogni volta che il codice WebGPU causa un errore, nella console dello sviluppatore vengano restituiti messaggi molto dettagliati e utili che ti aiutino a identificare e risolvere il problema.
Mantenere la console aperta mentre lavori su qualsiasi applicazione web è sempre utile, ma in questo caso è particolarmente importante.
3. Inizializzare WebGPU
Inizia con un <canvas>
WebGPU può essere utilizzato senza mostrare nulla sullo schermo se vuoi utilizzarlo solo per eseguire calcoli. Tuttavia, se vuoi eseguire il rendering di qualcosa, come faremo nel codelab, hai bisogno di una tela. È un buon punto di partenza.
Crea un nuovo documento HTML con un singolo elemento <canvas>
e un tag <script>
in cui eseguiamo una query sull'elemento canvas. In alternativa, utilizza 00-starter-page.html da Glitch.
- Crea un file
index.html
con il seguente codice:
index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>WebGPU Life</title>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script type="module">
const canvas = document.querySelector("canvas");
// Your WebGPU code will begin here!
</script>
</body>
</html>
Richiedere un adattatore e un dispositivo
Ora puoi iniziare a utilizzare WebGPU. Innanzitutto, tieni presente che le API come WebGPU possono richiedere un po' di tempo per essere propagate nell'intero ecosistema web. Di conseguenza, un buon primo passaggio di precauzione è verificare se il browser dell'utente può utilizzare WebGPU.
- Per verificare se esiste l'oggetto
navigator.gpu
, che funge da punto di contatto per WebGPU, aggiungi il seguente codice:
indice.html
if (!navigator.gpu) {
throw new Error("WebGPU not supported on this browser.");
}
Idealmente, dovresti informare l'utente se WebGPU non è disponibile facendo in modo che la pagina torni a una modalità che non utilizza WebGPU. (Forse potrebbe utilizzare WebGL?) Tuttavia, ai fini di questo codelab, devi solo generare un errore per interrompere l'esecuzione del codice.
Una volta che sai che WebGPU è supportato dal browser, il primo passaggio per inizializzarlo per la tua app è richiedere un GPUAdapter
. Un adattatore è una rappresentazione WebGPU di un componente specifico dell'hardware GPU del tuo dispositivo.
- Per ottenere un adattatore, utilizza il metodo
navigator.gpu.requestAdapter()
. Restituisce una promessa, quindi è più conveniente chiamarla conawait
.
indice.html
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("No appropriate GPUAdapter found.");
}
Se non è possibile trovare adattatori appropriati, il valore adapter
restituito potrebbe essere null
, quindi devi gestire questa possibilità. Questo può accadere se il browser dell'utente supporta WebGPU, ma l'hardware GPU non dispone di tutte le funzionalità necessarie per utilizzare WebGPU.
La maggior parte delle volte è sufficiente lasciare che sia il browser a scegliere un'opzione predefinita, come fai qui, ma per esigenze più avanzate esistono argomenti che possono essere passati a requestAdapter()
per specificare se vuoi utilizzare hardware a basso consumo o ad alte prestazioni su dispositivi con più GPU (come alcuni laptop).
Una volta ottenuto un'unità di aggiornamento, l'ultimo passaggio prima di poter iniziare a utilizzare la GPU è richiedere un GPUDevice. Il dispositivo è l'interfaccia principale tramite la quale avviene la maggior parte delle interazioni con la GPU.
- Ottieni il dispositivo chiamando
adapter.requestDevice()
, che restituisce anche una promessa.
index.html
const device = await adapter.requestDevice();
Come per requestAdapter()
, qui sono disponibili opzioni che possono essere passate per utilizzi più avanzati, come l'attivazione di funzionalità hardware specifiche o la richiesta di limiti più elevati, ma per le tue esigenze le impostazioni predefinite vanno benissimo.
Configurare Canvas
Ora che hai un dispositivo, devi fare un'altra cosa se vuoi utilizzarlo per mostrare qualcosa nella pagina: configurare la tela da utilizzare con il dispositivo che hai appena creato.
- Per farlo, devi prima richiedere un elemento
GPUCanvasContext
dalla tela chiamando il numerocanvas.getContext("webgpu")
. Si tratta della stessa chiamata che utilizzeresti per inizializzare i contesti Canvas 2D o WebGL, utilizzando rispettivamente i tipi di contesto2d
ewebgl
. Il valorecontext
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 è possibile passare alcune opzioni, ma le più importanti sono device
, con cui utilizzerai il contesto, e format
, ovvero il formato della trama che deve essere utilizzato dal contesto.
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 sul funzionamento della memoria delle texture non rientrano nell'ambito di questo codelab. L'aspetto importante da sapere è che il contesto della tela fornisce le texture in cui il codice deve disegnare e il formato utilizzato può influire sull'efficienza con cui la tela mostra queste immagini. Diversi tipi di dispositivi hanno il rendimento migliore quando vengono utilizzati formati delle texture diversi e, se non utilizzi il formato preferito dal dispositivo, potrebbero verificarsi copie aggiuntive della memoria in background prima che l'immagine possa essere visualizzata all'interno della pagina.
Fortunatamente, non devi preoccuparti di tutto questo perché WebGPU ti dice quale formato utilizzare per la tua tela. Nella maggior parte dei casi, è consigliabile passare il valore restituito dalla chiamata a navigator.gpu.getPreferredCanvasFormat()
, come mostrato sopra.
Cancellare i contenuti in canvas
Ora che hai un dispositivo e la tela è stata configurata con questo, puoi iniziare a utilizzarlo per modificare i contenuti della tela. Per iniziare, cancellala con un colore a tinta unita.
Per farlo, o praticamente per qualsiasi altra cosa in WebGPU, devi fornire alcuni comandi alla GPU indicandogli cosa fare.
- Per farlo, chiedi al dispositivo di creare un
GPUCommandEncoder
, che fornisce un'interfaccia per la registrazione dei comandi GPU.
index.html
const encoder = device.createCommandEncoder();
I comandi che vuoi inviare alla GPU sono relativi al rendering (in questo caso, allo svuotamento della tela), quindi il passaggio successivo consiste nell'utilizzare encoder
per iniziare un passaggio di rendering.
Le pass di rendering sono il momento in cui avvengono tutte le operazioni di disegno in WebGPU. Ognuna inizia con una chiamata beginRenderPass()
, che definisce le texture che ricevono l'output di eventuali comandi di disegno eseguiti. Usi più avanzati possono fornire diverse texture, chiamate allegati, con vari scopi, ad esempio memorizzare la profondità della geometria visualizzata o fornire l'antialiasing. Per questa app, tuttavia, è necessario solo uno.
- Recupera la trama dal contesto della tela che hai creato in precedenza chiamando
context.getCurrentTexture()
, che restituisce una trama con una larghezza e un'altezza in pixel corrispondenti agli attributiwidth
eheight
della tela e alformat
specificato quando hai chiamatocontext.configure()
.
indice.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
storeOp: "store",
}]
});
La trama viene fornita come proprietà view
di un colorAttachment
. Le pass di rendering richiedono un GPUTextureView
anziché un GPUTexture
, che indica le parti della texture da eseguire in rendering. Questo è davvero importante solo per i casi d'uso più avanzati, quindi in questo caso chiamiamo createView()
senza argomenti sulla texture, a indicare che vuoi che il passaggio di rendering utilizzi l'intera texture.
Devi anche specificare cosa vuoi che la pass di rendering faccia con la texture all'inizio e alla fine:
- Un valore
loadOp
pari a"clear"
indica che vuoi che la trama venga cancellata all'avvio del passaggio di rendering. - Un valore
storeOp
di"store"
indica che, una volta terminato il rendering, vuoi che i risultati di qualsiasi disegno eseguito durante il rendering vengano salvati nella texture.
Una volta iniziato il rendering, non devi fare nulla. Almeno per il momento. L'avvio del passaggio di rendering con loadOp: "clear"
è sufficiente per cancellare la visualizzazione della trama e la tela.
- Termina il passaggio di rendering aggiungendo la seguente chiamata immediatamente dopo
beginRenderPass()
:
indice.html
pass.end();
È importante sapere che semplicemente effettuare queste chiamate non fa sì che la GPU faccia effettivamente nulla. Registrano semplicemente i comandi che la GPU dovrà eseguire in un secondo momento.
- Per creare un
GPUCommandBuffer
, chiamafinish()
nell'encoder dei comandi. Il buffer dei comandi è un handle opaco per i comandi registrati.
index.html
const commandBuffer = encoder.finish();
- Invia il buffer di comandi alla GPU utilizzando
queue
delGPUDevice
. La coda esegue tutti i comandi della GPU, garantendo che la loro esecuzione sia ben ordinata e sincronizzata correttamente. Il metodosubmit()
della coda accetta un array di buffer di comando, anche se in questo caso ne hai solo uno.
index.html
device.queue.submit([commandBuffer]);
Una volta inviato, un buffer di comandi non può essere riutilizzato, quindi non è necessario conservarlo. Se vuoi inviare altri comandi, devi creare un altro buffer dei comandi. Ecco perché è piuttosto comune vedere questi due passaggi compressi in uno solo, come nelle pagine di esempio del codelab:
indice.html
// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);
Dopo aver inviato i comandi alla GPU, lascia che JavaScript restituisca il controllo al browser. A quel punto, il browser vede che è stata modificata la texture corrente del contesto e aggiorna il canvas per visualizzare la texture come immagine. Se vuoi aggiornare di nuovo i contenuti della tela, devi registrare e inviare un nuovo buffer di comandi, chiamando di nuovo context.getCurrentTexture()
per ottenere una nuova texture per un passaggio di rendering.
- Ricarica la pagina. Nota che la tela è piena 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, prima di passare alla sezione successiva, prenditi un momento per personalizzarla un po'.
- Nella chiamata
encoder.beginRenderPass()
, aggiungi una nuova riga con unclearValue
acolorAttachment
, come segue:
index.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
storeOp: "store",
}],
});
clearValue
indica al passaggio di rendering il colore da utilizzare durante l'esecuzione dell'operazione clear
all'inizio del passaggio. Il dizionario passato contiene quattro valori: r
per rosso, g
per verde, b
per blu e a
per alfa (trasparenza). Ogni valore può variare da 0
a 1
e, insieme, descrivono il valore del canale di colore. Ad esempio:
{ r: 1, g: 0, b: 0, a: 1 }
è di colore rosso brillante.{ r: 1, g: 0, b: 1, a: 1 }
è di colore viola brillante.{ r: 0, g: 0.3, b: 0, a: 1 }
è verde scuro.{ r: 0.5, g: 0.5, b: 0.5, a: 1 }
è grigio medio.{ r: 0, g: 0, b: 0, a: 0 }
è il nero trasparente predefinito.
Il codice di esempio e gli screenshot in questo codelab utilizzano un blu scuro, ma puoi scegliere il colore che preferisci.
- 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 geometria semplice sulla tela: un quadrato colorato. Tieni presente che sembrerà molto lavoro per un output così semplice, ma questo è dovuto al fatto che WebGPU è progettato per eseguire il rendering di molti elementi geometrici in modo molto efficiente. Un effetto collaterale di questa efficienza è che fare cose relativamente semplici potrebbe sembrare insolitamente difficile, ma è quello che ti aspetti se ti rivolgi a un'API come WebGPU: vuoi fare qualcosa di un po' più complesso.
Informazioni su come le GPU disegnano
Prima di apportare altre modifiche al codice, vale la pena fare una panoramica generale molto rapida e semplificata di come le GPU creano le forme che vedi sullo schermo. Se conosci già le nozioni di base sul funzionamento del rendering GPU, puoi passare alla sezione Definizione dei vertici.
A differenza di un'API come Canvas 2D, che offre molte forme e opzioni pronte per l'uso, la GPU gestisce solo alcuni tipi diversi di forme (o primitive, come vengono chiamate da WebGPU): punti, linee e triangoli. Ai fini di questo codelab, utilizzerai solo triangoli.
Le GPU lavorano quasi esclusivamente con i triangoli perché hanno molte proprietà matematiche interessanti che li rendono facili da elaborare in modo prevedibile ed efficiente. Quasi tutto ciò che disegni con la GPU deve essere suddiviso in triangoli prima che la GPU possa disegnarlo e questi triangoli devono essere definiti dai punti angolari.
Questi punti, o vertici, sono dati in termini di valori X, Y e (per i contenuti 3D) Z che definiscono un punto su un sistema di coordinate cartesiane definito da WebGPU o API simili. È più facile pensare alla struttura del sistema di coordinate in termini di relazione con la tela della pagina. Indipendentemente dalla larghezza o dall'altezza del canvas, il bordo sinistro è sempre pari a -1 sull'asse X e il bordo destro è sempre pari a +1 sull'asse X. Analogamente, il bordo inferiore è sempre -1 sull'asse Y e il bordo superiore è +1 sull'asse Y. Ciò significa che (0, 0) è sempre il centro della tela, (-1, -1) è sempre l'angolo in basso a sinistra e (1, 1) è sempre l'angolo in alto a destra. Questo spazio è noto come Clip Space.
I vertici vengono raramente definiti inizialmente in questo sistema di coordinate, quindi le GPU si basano su piccoli programmi chiamati vertex shader per eseguire le operazioni matematiche necessarie per trasformare i vertici nello spazio clip, nonché tutti gli altri calcoli necessari per disegnare i vertici. Ad esempio, lo shader potrebbe applicare un'animazione o calcolare la direzione dal vertice a una sorgente di luce. Questi shader sono scritti da te, lo sviluppatore WebGPU, e offrono un controllo sorprendente sul funzionamento della GPU.
Da qui, la GPU prende tutti i triangoli costituiti da questi vertici trasformati e determina quali pixel sullo schermo sono necessari per disegnarli. Poi esegue un altro piccolo programma che hai scritto, chiamato shader di frammento, che calcola il colore di ogni pixel. Questo calcolo può essere semplice come return green o complesso come il calcolo dell'angolo della superficie rispetto alla luce del sole che rimbalza su altre superfici vicine, filtrata dalla nebbia e modificata dal grado di metallicità della superficie. È completamente sotto il tuo controllo, il che può essere sia stimolante che travolgente.
I risultati di questi colori dei pixel vengono poi accumulati in una texture, che può essere visualizzata sullo schermo.
Definire i vertici
Come accennato in precedenza, la simulazione del gioco della vita viene mostrata come una griglia di celle. L'app ha bisogno di un modo per visualizzare la griglia, distinguendo le celle attive da quelle non attive. L'approccio utilizzato da questo codelab consisterà nel disegnare quadrati colorati nelle celle attive e lasciare vuote le celle non attive.
Ciò significa che dovrai fornire alla GPU quattro punti diversi, uno per ciascuno dei quattro angoli del quadrato. Ad esempio, un quadrato disegnato al centro dell'area di lavoro, tirato dai bordi di un lato, ha le coordinate degli angoli come queste:
Per fornire queste coordinate alla GPU, devi inserire i valori in un TypedArray. Se non lo conosci già, i TypedArray sono un gruppo di oggetti JavaScript che ti consente di allocare blocchi di memoria contigui e di interpretare ogni elemento della serie come un tipo di dati specifico. Ad esempio, in un elemento Uint8Array
, ogni elemento dell'array è un singolo byte non firmato. Gli array di tipi 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 punto per posizionarlo è in alto, appena sotto la chiamata
context.configure()
.
index.html
const vertices = new Float32Array([
// X, Y,
-0.8, -0.8,
0.8, -0.8,
0.8, 0.8,
-0.8, 0.8,
]);
Tieni presente che la spaziatura e il commento non influiscono sui valori; sono solo per comodità e per una maggiore leggibilità. Ti aiuta a capire che ogni coppia di valori costituisce le coordinate X e Y di un vertice.
Ma c'è un problema! Le GPU funzionano in termini di triangoli, ricordi? Ciò significa che devi fornire i vertici in gruppi di tre. Hai un gruppo di quattro. La soluzione è ripetere due dei vertici per creare due triangoli che condividono uno spigolo al centro del quadrato.
Per formare il quadrato dal diagramma, devi elencare i vertici (-0,8, -0,8) e (0,8, 0,8) due volte, una per il triangolo blu e una per quello rosso. In alternativa, puoi scegliere di dividere il quadrato con gli altri due angoli; non fa alcuna differenza.
- Aggiorna l'array
vertices
precedente in modo che abbia il seguente aspetto:
index.html
const vertices = new Float32Array([
// X, Y,
-0.8, -0.8, // Triangle 1 (Blue)
0.8, -0.8,
0.8, 0.8,
-0.8, -0.8, // Triangle 2 (Red)
0.8, 0.8,
-0.8, 0.8,
]);
Sebbene il diagramma mostri una separazione tra i due triangoli per chiarezza, le posizioni dei vertici sono esattamente le stesse e la GPU le esegue senza spazi. Verrà visualizzato come un singolo quadrato pieno.
Creare un buffer di vertici
La GPU non può disegnare vertici con dati di un array JavaScript. Le GPU hanno spesso una propria memoria altamente ottimizzata per il rendering, pertanto tutti i dati che vuoi che la GPU utilizzi durante il disegno devono essere inseriti in questa memoria.
Per molti valori, inclusi i dati dei vertici, la memoria lato GPU viene gestita tramite oggetti GPUBuffer
. Un buffer è un blocco di memoria facilmente accessibile alla GPU e segnalato per determinati scopi. Puoi considerarlo un po' come un TypedArray visibile alla GPU.
- Per creare un buffer per contenere 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 assegni un'etichetta al buffer. A ogni singolo oggetto WebGPU che crei può essere assegnata un'etichetta facoltativa e ti consigliamo di farlo. L'etichetta può essere qualsiasi stringa, purché ti aiuti a identificare l'oggetto. In caso di problemi, queste etichette vengono utilizzate nei messaggi di errore generati da WebGPU per aiutarti a capire cosa è andato storto.
Dopodiché, specifica una dimensione per il buffer in byte. Devi avere un buffer di 48 byte, che puoi determinare moltiplicando le dimensioni di un numero in virgola mobile a 32 bit ( 4 byte) per il numero di numeri in virgola mobile nell'array vertices
(12). Fortunatamente, gli array di tipi già calcolano il loro byteLength per te, quindi puoi utilizzarlo per creare il buffer.
Infine, devi specificare l'utilizzo del buffer. Si tratta di uno o più flag GPUBufferUsage
, con più flag combinati con l'operatore |
( OR a livello di bit). In questo caso, specifichi che vuoi che il buffer venga utilizzato per i dati del vertice (GPUBufferUsage.VERTEX
) e che vuoi anche poter copiare dati al suo interno (GPUBufferUsage.COPY_DST
).
L'oggetto buffer restituito è opaco: non puoi ispezionare (facilmente) i dati in esso contenuti. Inoltre, la maggior parte dei suoi attributi è immutabile: non puoi ridimensionare un elemento GPUBuffer
dopo averlo creato, né modificare i flag di utilizzo. Puoi modificare i contenuti della memoria.
Quando il buffer viene creato inizialmente, la memoria che contiene viene inizializzata a zero. Esistono diversi modi per modificarne i contenuti, ma il più semplice è chiamare device.queue.writeBuffer()
con un TypedArray da copiare.
- Per copiare i dati dei vertici nella memoria del buffer, aggiungi il seguente codice:
index.html
device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);
Definire il layout dei vertici
Ora hai un buffer con i dati dei vertici, ma per la GPU è solo un blob di byte. Devi fornire qualche altra informazione se vuoi disegnare qualcosa. Devi essere in grado di fornire a WebGPU ulteriori informazioni sulla struttura dei dati dei vertici.
- Definisci la struttura dei dati del vertice 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 analizzare.
La prima cosa da fare è arrayStride
. Si tratta del numero di byte che la GPU deve saltare in avanti nel buffer quando cerca il vertice successivo. Ogni vertice del quadrato è costituito da due numeri in virgola mobile a 32 bit. Come detto in precedenza, un numero in virgola mobile a 32 bit corrisponde a 4 byte, quindi due numeri in virgola mobile sono pari a 8 byte.
Segue la proprietà attributes
, che è un array. Gli attributi sono le singole informazioni codificate in ogni vertice. I vertici contengono un solo attributo (la posizione del vertice), ma i casi d'uso più avanzati hanno spesso vertici con più attributi, come il colore di un vertice o la direzione in cui punta la superficie geometrica. Tuttavia, non rientra nell'ambito di questo codelab.
Nel singolo attributo, definisci innanzitutto il format
dei dati. Questo proviene da un elenco di tipi GPUVertexFormat
che descrivono ogni tipo di dati del vertice che la GPU può comprendere. I vertici hanno ciascuno due numeri in virgola mobile a 32 bit, quindi utilizzi il formato float32x2
. Se i dati dei vertici sono invece costituiti da quattro numeri interi non firmati a 16 bit ciascuno, ad esempio, devi utilizzare uint16x4
. Hai notato un particolare comportamento che si ripete?
Successivamente, offset
descrive quanti byte nel vertice avvia questo particolare attributo. Devi preoccuparti di questo problema solo se nel buffer sono presenti più attributi, che non verranno visualizzati durante questo codelab.
Infine, hai il shaderLocation
. Si tratta di un numero arbitrario compreso tra 0 e 15 e deve essere univoco per ogni attributo che definisci. Collega questo attributo a un determinato input nello shader vertex, di cui parleremo nella sezione successiva.
Tieni presente che, anche se li definisci ora, non li stai ancora passando all'API WebGPU. Lo vedrai a breve, ma è più facile pensare a questi valori quando definisci i vertici, quindi li stai configurando ora per utilizzarli in un secondo momento.
Iniziare con gli shader
Ora hai i dati che vuoi visualizzare, ma devi comunque dire alla GPU esattamente come elaborarli. Gran parte di questo avviene con gli shader.
Gli shader sono piccoli programmi che scrivi ed esegui sulla GPU. Ogni shader opera su una fase diversa dei dati: elaborazione Vertex, elaborazione Fragment o Computing generale. Poiché si trovano sulla GPU, sono strutturati in modo più rigido rispetto al JavaScript medio. Tuttavia, questa struttura consente di eseguire le query molto rapidamente e, soprattutto, in parallelo.
Gli Shader in WebGPU sono scritti in un linguaggio di ombreggiatura denominato WGSL (WebGPU Shading Language). WGSL è, dal punto di vista sintattico, un po' come Rust, con funzionalità volte a semplificare e velocizzare i tipi comuni di lavoro con GPU (come i calcoli vettoriali e matriciali). Insegnare l'intero linguaggio di ombreggiatura va ben oltre lo scopo di questo codelab, ma speriamo che tu possa acquisire alcune nozioni di base esaminando alcuni semplici esempi.
Gli streamer stessi vengono passati in WebGPU come stringhe.
- Crea un punto in cui inserire il codice dello shader copiando quanto segue nel codice sotto
vertexBufferLayout
:
index.html
const cellShaderModule = device.createShaderModule({
label: "Cell shader",
code: `
// Your shader code will go here
`
});
Per creare gli shader, chiama device.createShaderModule()
, a cui fornisci un label
facoltativo e WGSL code
come stringa. Tieni presente che qui utilizzi i backtick per consentire stringhe di più righe. Dopo aver aggiunto un codice WGSL valido, la funzione restituisce un oggetto GPUShaderModule
con i risultati compilati.
Definisci lo shader vertex
Inizia con lo shader vertex perché è da lì che inizia anche la GPU.
Un vertex shader è definito come una funzione e la GPU la chiama una volta per ogni vertice del vertexBuffer
. Poiché vertexBuffer
ha sei posizioni (vertici), la funzione che definisci viene chiamata sei volte. Ogni volta che viene chiamata, alla funzione viene passata come argomento una posizione diversa da vertexBuffer
ed è compito della funzione vertex shaker restituire una posizione corrispondente nello spazio del clip.
È importante capire che non verranno necessariamente chiamati in ordine sequenziale. Le GPU, invece, sono eccellenti nell'eseguire shader come questi in parallelo, con la possibilità di elaborare contemporaneamente centinaia (o addirittura migliaia) di vertici. Questo è un fattore fondamentale che contribuisce alla velocità incredibile delle GPU, ma presenta delle limitazioni. Per garantire una parallelizzazione estrema, gli shader dei vertici non possono comunicare tra loro. Ogni chiamata allo shader può vedere i dati di un solo vertice alla volta ed è in grado di generare valori solo per un singolo vertice.
In WGSL, una funzione Vertex Shar può essere denominata come preferisci, ma deve avere l'attributo @vertex
davanti a sé per indicare la fase dello shaker che rappresenta. WGSL indica le funzioni con la parola chiave fn
, utilizza le parentesi per dichiarare eventuali argomenti e le parentesi graffe per definire l'ambito.
- Crea una funzione
@vertex
vuota, ad esempio:
index.html (codice createShaderModule)
@vertex
fn vertexMain() {
}
Tuttavia, non è valido perché un vertex shader deve restituire almeno la posizione finale del vertice in fase di elaborazione nello spazio clip. Viene sempre fornito come vettore a 4 dimensioni. I vettori sono così comuni negli shader che vengono trattati come primitive di primo livello nel linguaggio, con i propri tipi come vec4f
per un vettore a 4 dimensioni. Esistono tipi simili anche per i vettori 2D (vec2f
) e 3D (vec3f
).
- Per indicare che il valore restituito è la posizione richiesta, contrassegnalo con l'attributo
@builtin(position)
. Viene utilizzato un simbolo->
per indicare che questo è il valore restituito dalla funzione.
index.html (codice createShaderModule)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
}
Ovviamente, se la funzione ha un tipo di ritorno, devi restituire effettivamente un valore nel corpo della funzione. Puoi creare un nuovo vec4f
da restituire, utilizzando la sintassi vec4f(x, y, z, w)
. I valori x
, y
e z
sono tutti numeri in virgola mobile che, nel valore restituito, indicano la posizione del vertice nello spazio clip.
- Restituisce un valore statico
(0, 0, 0, 1)
e tecnicamente hai un vertex Shar valido, anche se uno che non visualizza mai nulla poiché la GPU riconosce che i triangoli prodotti sono solo un singolo punto e poi lo scarta.
index.html (codice createShaderModule)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}
Quello che vogliamo, invece, è utilizzare i dati del buffer che hai creato e devi farlo dichiarando un argomento per la tua funzione con un attributo e un tipo di tipo @location()
che corrispondano a quanto descritto nel vertexBufferLayout
. Hai specificato un shaderLocation
di 0
, quindi nel codice WGSL, contrassegna l'argomento con @location(0)
. Hai anche definito il formato come float32x2
, che è un vettore 2D, quindi in WGSL il tuo argomento è un vec2f
. Puoi assegnare il nome che preferisci, ma poiché rappresentano le posizioni dei vertici, un nome come pos sembra naturale.
- Modifica la funzione shader con il seguente codice:
index.html (codice createShaderModule)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(0, 0, 0, 1);
}
Ora devi tornare a quella posizione. Poiché la posizione è un vettore 2D e il tipo di ritorno è un vettore 4D, devi modificarlo un po'. Devi semplicemente prendere i due componenti dall'argomento position e posizionarli nei primi due componenti del vettore di ritorno, lasciando gli ultimi due componenti rispettivamente come 0
e 1
.
- Restituire la posizione corretta specificando esplicitamente i componenti di posizione da utilizzare:
index.html (codice createShaderModule)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos.x, pos.y, 0, 1);
}
Tuttavia, poiché questi tipi di mappature sono così comuni negli streamr, puoi passare anche il vettore di posizione come primo argomento in una pratica breve forma di abbreviazione, il che significa la stessa cosa.
- Riscrivi l'istruzione
return
con il seguente codice:
index.html (codice createShaderModule)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
E questo è il tuo vertex Shar iniziale! È molto semplice, basta passare la posizione in modo invariato, ma è sufficiente per iniziare.
Definire lo shader di frammento
Ora passiamo allo shader di frammenti. Gli shader di frammento funzionano in modo molto simile agli shader di vertice, ma invece di essere richiamati per ogni vertice, vengono richiamati per ogni pixel disegnato.
I Fragment Shaper sono sempre chiamati dopo Vertex Shader. La GPU prende l'output degli shader vertex e lo triangola, creando triangoli da insiemi di tre punti. Quindi rasterizza ciascuno di questi triangoli individuando quali pixel degli allegati del colore di output sono inclusi nel triangolo, quindi chiama lo shaker dei frammenti una volta per ciascuno di quei pixel. Lo shader frammento restituisce un colore, in genere calcolato dai valori inviati dallo shader vertice e da risorse come le texture, che la GPU scrive nell'attributo colore.
Proprio come gli shader vertex, gli shader fragment vengono eseguiti in modo altamente parallelo. Sono un po' più flessibili degli shader vertex in termini di input e output, ma puoi considerarli come se restituissero semplicemente un colore per ogni pixel di ogni triangolo.
Una funzione shader per frammenti WGSL è indicata con l'attributo @fragment
e restituisce anche un vec4f
. In questo caso, però, il vettore rappresenta un colore, non una posizione. Al valore restituito deve essere fornito un attributo @location
per indicare a quale colorAttachment
della chiamata beginRenderPass
è scritto il colore restituito. Poiché hai un solo allegato, la posizione è 0.
- Crea una funzione
@fragment
vuota, ad esempio:
index.html (codice createShaderModule)
@fragment
fn fragmentMain() -> @location(0) vec4f {
}
I quattro componenti del vettore restituito sono i valori dei colori rosso, verde, blu e alfa, che sono interpretati esattamente allo stesso modo del valore clearValue
impostato in beginRenderPass
in precedenza. Quindi vec4f(1, 0, 0, 1)
è rosso brillante, un colore adatto per la tua piazza. Puoi impostarlo sul colore che preferisci.
- Imposta il vettore di colore restituito, ad esempio:
index.html (codice createShaderModule)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}
Ed ecco un frammento shader completo. Non è molto interessante, imposta semplicemente ogni pixel di ogni triangolo su rosso, ma per il momento è sufficiente.
Ricapitolando, dopo aver aggiunto il codice dello shaker descritto sopra, la chiamata a createShaderModule
ora avrà il seguente aspetto:
index.html
const cellShaderModule = device.createShaderModule({
label: 'Cell shader',
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
`
});
Creare una pipeline di rendering
Un modulo shader non può essere utilizzato per il rendering da solo. Devi invece utilizzarlo all'interno di un GPURenderPipeline
, creato chiamando device.createRenderPipeline(). La pipeline di rendering controlla come viene disegnata la geometria, inclusi elementi quali gli shader utilizzati, come interpretare i dati nei buffer dei vertici, quale tipo di geometria deve essere visualizzata (linee, punti, triangoli e così via) e altro ancora.
La pipeline di rendering è l'oggetto più complesso dell'intera API, ma non preoccuparti. La maggior parte dei valori che puoi passare è facoltativa e devi fornirne solo alcuni per iniziare.
- Crea una pipeline di rendering, ad esempio:
index.html
const cellPipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: "auto",
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
Ogni pipeline ha bisogno di un layout
che descriva i tipi di input (diversi dai buffer di vertici) di cui ha bisogno, ma non ne hai. Per fortuna, per il momento puoi passare "auto"
e la pipeline creerà il proprio layout dagli shader.
Successivamente, devi fornire i dettagli della fase vertex
. module
è il modulo GPUShader che contiene lo shader vertex e entryPoint
indica il nome della funzione nel codice shader che viene chiamata per ogni chiamata del vertice. Puoi avere più funzioni @vertex
e @fragment
in un singolo modulo shader. buffers è un array di oggetti GPUVertexBufferLayout
che descrivono il modo in cui i dati vengono pacchettizzati nei buffer dei vertici con cui utilizzi questa pipeline. Per fortuna, l'hai già definito in precedenza nel tuo vertexBufferLayout
. Ecco dove devi inserirlo.
Infine, sono disponibili i dettagli sulla fase fragment
. Sono inclusi anche un modulo e un entryPoint dello streamr, come lo stage vertex. L'ultimo passaggio consiste nel definire il targets
con cui viene utilizzata questa pipeline. Si tratta di un array di dizionari che forniscono dettagli, ad esempio la trama format
, degli allegati a colori a cui la pipeline genera output. Questi dettagli devono corrispondere alle texture indicate nel colorAttachments
di tutti i passaggi di rendering con cui viene utilizzata questa pipeline. La tua trasmissione di rendering utilizza le texture del contesto del canvas e usa il valore salvato in canvasFormat
per il formato, quindi passi lo stesso formato qui.
Non sono nemmeno lontanamente tutte le opzioni che puoi specificare quando crei una pipeline di rendering, ma sono sufficienti per le esigenze di questo codelab.
Disegnare il quadrato
Ora hai tutto ciò che ti serve per disegnare il quadrato.
- Per disegnare il quadrato, torna alla coppia di chiamate
encoder.beginRenderPass()
epass.end()
e aggiungi questi nuovi comandi tra di loro:
index.html
// After encoder.beginRenderPass()
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices
// before pass.end()
Questo fornisce a WebGPU tutte le informazioni necessarie per disegnare il quadrato. Innanzitutto, utilizza setPipeline()
per indicare quale pipeline deve essere utilizzata per disegnare. Sono inclusi gli shader utilizzati, il layout dei dati del vertice e altri dati di stato pertinenti.
Successivamente, chiama setVertexBuffer()
con il buffer contenente i vertici del quadrato. Lo chiami con 0
perché questo buffer corrisponde all'elemento 0 nella definizione di vertex.buffers
della pipeline corrente.
Infine, effettui la chiamata a draw()
, che sembra stranamente semplice dopo tutta la configurazione precedente. L'unica cosa che devi passare è il numero di vertici da visualizzare, che vengono estratti dai buffer dei vertici attualmente impostati e interpretati con la pipeline attualmente impostata. Puoi semplicemente codificarlo in modo hardcoded in 6
, ma calcolarlo dalla matrice dei vertici (12 galleggianti / 2 coordinate per vertice == 6 vertici) significa che se dovessi decidere di sostituire il quadrato, ad esempio con un cerchio, c'è meno da aggiornare a mano.
- Aggiorna lo schermo e (finalmente) vedrai i risultati di tutto il tuo duro lavoro: un grande quadrato colorato.
5. Disegna una griglia
Innanzitutto, congratulati con te stesso. Ottenere i primi pezzi di geometria sullo schermo è spesso uno dei passaggi più difficili per la maggior parte delle API GPU. Tutto ciò che fai da qui può essere fatto in passaggi più piccoli, il che semplifica la verifica dei progressi.
In questa sezione scoprirai:
- Come passare le variabili (chiamate uniformi) allo shader da JavaScript.
- Come utilizzare le uniformi per modificare il comportamento di rendering.
- Come utilizzare l'instanziazione per disegnare molte varianti diverse della stessa geometria.
Definire la griglia
Per eseguire il rendering di una griglia, devi conoscere un'informazione di base molto importante. Quante celle contiene, sia in larghezza che in altezza? Spetta a te, in qualità di sviluppatore, ma per semplificare un po' le cose, tratta la griglia come un quadrato (stessa larghezza e altezza) e utilizza una dimensione che sia una potenza di 2. (Questo semplifica alcuni calcoli in un secondo momento). Alla fine vuoi ingrandire l'immagine, ma per il resto di questa sezione, imposta le dimensioni della griglia su 4 x 4 perché semplifica la dimostrazione di alcuni dei calcoli matematici usati in questa sezione. Poi fai lo scale up.
- Definisci le dimensioni della griglia aggiungendo una costante nella parte superiore del codice JavaScript.
indice.html
const GRID_SIZE = 4;
Poi devi aggiornare la modalità di rendering del quadrato, in modo da poterlo adattare a GRID_SIZE
volte GRID_SIZE
sulla tela. Ciò significa che il quadrato deve essere molto più piccolo e devono essercene molti.
Ora, un modo per affrontare il problema è aumentare notevolmente il buffer dei vertici e definire al suo interno GRID_SIZE
volte GRID_SIZE
quadrati con le dimensioni e la posizione corrette. Il codice non sarebbe troppo male, anzi. Solo un paio di cicli for e un po' di matematica. Tuttavia, ciò non consente di sfruttare al meglio la GPU e di utilizzare più memoria del necessario per ottenere l'effetto. Questa sezione esamina un approccio più adatto alle GPU.
Creare un buffer uniforme
Innanzitutto, devi comunicare le dimensioni della griglia che hai scelto allo shader, poiché le utilizza per modificare la modalità di visualizzazione. Potresti semplicemente codificare le dimensioni nello shader, ma questo significa che ogni volta che vuoi modificare le dimensioni della griglia devi ricreare lo shader e la pipeline di rendering, il che è costoso. Un modo migliore è fornire le dimensioni della griglia allo shader come uniformi.
In precedenza hai appreso che a ogni chiamata di un vertex shader viene passato un valore diverso dal buffer di vertici. Un'uniforme è un valore di un buffer che è lo stesso per ogni chiamata. Sono utili per comunicare valori comuni per un elemento geometrico (ad esempio la sua posizione), un frame completo di animazione (ad esempio l'ora corrente) o persino l'intera durata dell'app (ad esempio una preferenza dell'utente).
- Crea un buffer uniforme aggiungendo il seguente codice:
index.html
// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
label: "Grid Uniforms",
size: uniformArray.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);
Dovresti riconoscerlo perché è quasi esattamente lo stesso codice che hai utilizzato per creare il buffer di vertici in precedenza. Questo perché le uniformi vengono comunicate all'API WebGPU tramite gli stessi oggetti GPUBuffer che sono i vertici, con la differenza principale che usage
questa volta include GPUBufferUsage.UNIFORM
anziché GPUBufferUsage.VERTEX
.
Accedere alle uniformi in uno shader
- Definisci una uniforme aggiungendo il seguente codice:
index.html (chiamata a createShaderModule)
// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos / grid, 0, 1);
}
// ...fragmentMain is unchanged
Questo definisce un'uniforme nello shader chiamata grid
, che è un vettore di tipo float 2D che corrisponde all'array che hai appena copiato nell'area dati uniforme. Inoltre, specifica che l'uniforme è vincolata ai punti @group(0)
e @binding(0)
. Imparerai a breve cosa significano questi valori.
Poi, in un altro punto del codice dello shader, puoi utilizzare il vettore della griglia come preferisci. In questo codice dividi la posizione del vertice per il vettore della griglia. Poiché pos
è un vettore 2D e grid
è un vettore 2D, WGSL esegue una divisione in base ai componenti. In altre parole, il risultato è lo stesso di vec2f(pos.x / grid.x, pos.y / grid.y)
.
Questi tipi di operazioni vettoriali sono molto comuni negli shader GPU, poiché molte tecniche di rendering e calcolo si basano su di essi.
Ciò significa che, se hai utilizzato una dimensione della griglia pari a 4, il quadrato visualizzato sarà pari a un quarto delle dimensioni originali. Questa soluzione è perfetta se vuoi adattarne quattro a una riga o colonna.
Creare un gruppo di associazioni
Tuttavia, la dichiarazione dell'uniforme nello shader non la connette al buffer che hai creato. A questo scopo, devi creare e impostare un gruppo di associazione.
Un gruppo di binding è una raccolta di risorse che vuoi rendere accessibili contemporaneamente allo shader. Può includere diversi tipi di buffer, come il buffer uniforme, e altre risorse, come texture e campionatori, che non sono trattati qui, ma sono parti comuni delle tecniche di rendering WebGPU.
- Crea un gruppo di associazione con il buffer uniforme aggiungendo il codice seguente dopo la creazione del buffer uniforme e la pipeline di rendering:
indice.html
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}],
});
Oltre all'elemento label
, ormai standard, devi anche avere un elemento layout
che descriva i tipi di risorse contenuti in questo gruppo di associazioni. Questo è un aspetto che esaminerai più a fondo in un passaggio futuro, ma per il momento puoi tranquillamente chiedere alla pipeline il layout del gruppo di unione perché l'hai creata con layout: "auto"
. Ciò fa sì che la pipeline crei automaticamente i layout dei gruppi di associazione dalle associazioni dichiarate nel codice dello streamr. In questo caso, lo chiedi a getBindGroupLayout(0)
, dove 0
corrisponde a @group(0)
che hai digitato nello shader.
Dopo aver specificato il layout, fornisci un array di entries
. Ogni voce è un dizionario con almeno i seguenti valori:
binding
, che corrisponde al valore@binding()
inserito nello shader. In questo caso,0
.resource
, ovvero la risorsa effettiva che vuoi esporre alla variabile nell'indice di binding specificato. In questo caso, si tratta del buffer uniforme.
La funzione restituisce un GPUBindGroup
, ovvero un handle opaco e immutabile. Non puoi modificare le risorse a cui punta un gruppo di associazione dopo la creazione, ma puoi modificarne i contenuti. Ad esempio, se modifichi il buffer uniforme in modo che contenga una nuova dimensione della griglia, questa modifica viene applicata alle chiamate draw future che utilizzano questo gruppo di binding.
Eseguire il binding del gruppo di binding
Ora che il gruppo di binding è stato creato, devi comunque dire a WebGPU di utilizzarlo durante il disegno. Fortunatamente, è abbastanza semplice.
- Torna al passaggio di rendering e aggiungi questa nuova riga prima del metodo
draw()
:
index.html
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setBindGroup(0, bindGroup); // New line!
pass.draw(vertices.length / 2);
Il parametro 0
passato come primo argomento corrisponde a @group(0)
nel codice shader. Stai dicendo che ogni @binding
che fa parte di @group(0)
utilizza le risorse in questo gruppo di associazione.
E ora il buffer uniforme è esposto al tuo snapshot!
- Aggiorna la pagina e dovresti visualizzare qualcosa di simile al seguente:
Evviva! Ora la tua piazza è un quarto delle dimensioni precedenti. Non è molto, ma dimostra che la tua uniforme è stata effettivamente applicata e che ora lo shaker può accedere alle dimensioni della griglia.
Manipolare la geometria nello shader
Ora che puoi fare riferimento alle dimensioni della griglia nello shaker, puoi iniziare a lavorare per modificare la geometria di rendering in modo che si adatti al pattern di griglia desiderato. A questo scopo, pensa esattamente a ciò che vuoi ottenere.
Devi suddividere concettualmente il canvas in singole celle. Per mantenere la convenzione che l'asse X aumenta man mano che ti sposti verso destra e l'asse Y aumenta man mano che ti sposti verso l'alto, supponiamo che la prima cella si trovi nell'angolo in basso a sinistra del grafico. Il layout sarà simile a questo, con la geometria quadrata attuale al centro:
La sfida consiste nel trovare uno shader che ti consenta di posizionare la geometria quadrata in una qualsiasi di queste celle in base alle coordinate della cella.
Innanzitutto, puoi vedere che il quadrato non è ben allineato a nessuna delle celle perché è stato definito per circondare il centro del foglio. Ti consigliamo di spostare il quadrato di mezza cella in modo che sia allineato all'interno.
Un modo per risolvere il problema è aggiornare il buffer dei vertici del quadrato. Se sposti i vertici in modo che l'angolo in basso a sinistra sia, ad esempio, (0,1, 0,1) anziché (-0,8, -0,8), sposti questo quadrato in modo che sia allineato ai bordi della cella in modo più gradevole. Tuttavia, poiché hai il controllo completo sul modo in cui i vertici vengono elaborati nello shader, è altrettanto facile spostarli utilizzando il codice dello shader.
- Modifica il modulo dello shader vertex con il seguente codice:
index.html (chiamata createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
// Add 1 to the position before dividing by the grid size.
let gridPos = (pos + 1) / grid;
return vec4f(gridPos, 0, 1);
}
In questo modo, ogni vertice viene spostato verso l'alto e verso destra di uno (che, ricorda, corrisponde alla metà dello spazio del clip) prima di dividerlo per la dimensione della griglia. Il risultato è un quadrato ben allineato alla griglia appena fuori dall'origine.
Poiché il sistema di coordinate del canvas posiziona (0, 0) al centro e (-1, -1) in basso a sinistra e vuoi che (0, 0) sia in basso a sinistra, devi traslare la posizione della geometria di (-1, -1) dopo averla divisa per le dimensioni della griglia per spostarla in quell'angolo.
- Trasforma la posizione della geometria, ad esempio:
index.html (chiamata createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
// Subtract 1 after dividing by the grid size.
let gridPos = (pos + 1) / grid - 1;
return vec4f(gridPos, 0, 1);
}
Ora il quadrato è posizionato correttamente nella cella (0, 0).
E se vuoi inserirlo in un'altra cella? Per scoprirlo, dichiara un vettore cell
nello shader e compilalo con un valore statico come let cell = vec2f(1, 1)
.
Se lo aggiungi a gridPos
, viene annullato - 1
nell'algoritmo, quindi non è quello che vuoi. Vuoi invece spostare il quadrato solo di un'unità di griglia (un quarto del canvas) per ogni cella. Sembra che tu debba fare un'altra divisione per grid
.
- Modifica il posizionamento della griglia, in questo modo:
index.html (chiamata createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
let cell = vec2f(1, 1); // Cell(1,1) in the image above
let cellOffset = cell / grid; // Compute the offset to cell
let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!
return vec4f(gridPos, 0, 1);
}
Se aggiorni ora, viene visualizzato quanto segue:
Mm. Non è proprio quello che volevi.
Il motivo è che, poiché le coordinate del canvas vanno da -1 a +1, il risultato è in realtà pari a 2 unità. Ciò significa che se vuoi spostare un vertice di un quarto del canvas, devi spostarlo di 0,5 unità. Questo è un errore facile da fare quando si ragiona con le coordinate della GPU. Per fortuna, risolvere il problema è altrettanto semplice.
- Moltiplica l'offset per 2, come segue:
index.html (chiamata createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
let cell = vec2f(1, 1);
let cellOffset = cell / grid * 2; // Updated
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
In questo modo avrai esattamente ciò che vuoi.
Lo screenshot ha il seguente aspetto:
Inoltre, ora puoi impostare cell
su qualsiasi valore all'interno dei limiti della griglia, quindi aggiornare per visualizzare il rendering quadrato nella posizione desiderata.
Disegna istanze
Ora che puoi posizionare il quadrato dove vuoi con un po' di matematica, il passaggio successivo consiste nel visualizzare un quadrato in ogni cella della griglia.
Un modo per farlo è scrivere le coordinate delle celle in un buffer uniforme e chiamare draw una volta per ogni quadrato nella griglia, aggiornando l'uniforme ogni volta. Tuttavia, sarebbe molto lento, poiché la GPU deve attendere ogni volta che la nuova coordinata viene scritta da JavaScript. Uno dei fattori chiave per ottenere buone prestazioni dalla GPU è ridurre al minimo il tempo di attesa su altre parti del sistema.
In alternativa, puoi utilizzare una tecnica chiamata instanziazione. L'instanziazione è un modo per dire alla GPU di disegnare più copie della stessa geometria con una singola chiamata a draw
, che è molto più veloce di chiamare draw
una volta per ogni copia. Ogni copia della geometria è indicata come istanza.
- Per indicare alla GPU che vuoi abbastanza istanze del quadrato per riempire la griglia, aggiungi un argomento alla chiamata draw esistente:
index.html
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
In questo modo, il sistema viene informato che vuoi che disegni i sei (vertices.length / 2
) vertici del quadrato 16 (GRID_SIZE * GRID_SIZE
) volte. Tuttavia, se aggiorni la pagina, vedrai ancora quanto segue:
Perché? Perché disegni tutti e 16 i quadrati nello stesso punto. Devi avere una logica aggiuntiva nello shader che riposiziona la geometria in base all'istanza.
Nello shader, oltre agli attributi dei vertici come pos
provenienti dal buffer dei vertici, puoi anche accedere ai cosiddetti valori incorporati di WGSL. Si tratta di valori calcolati da WebGPU e uno di questi valori è instance_index
. instance_index
è un numero a 32 bit non firmato da 0
a number of instances - 1
che puoi utilizzare come parte della logica del tuo shaker. Il suo valore è uguale per ogni vertice elaborato che fa parte della stessa istanza. Ciò significa che lo shader vertex viene chiamato sei volte con un instance_index
di 0
, una volta per ogni posizione nel buffer di vertici. Poi altre sei volte con un instance_index
di 1
, poi altre sei volte con instance_index
di 2
e così via.
Per vedere questa funzionalità in azione, devi aggiungere l'instance_index
integrata ai tuoi input dello shaker. Procedi nello stesso modo della posizione, ma anziché taggarla con un attributo @location
, utilizza @builtin(instance_index)
e poi assegna all'argomento il nome che preferisci. Puoi chiamarlo instance
per abbinarlo al codice di esempio. e poi utilizzalo all'interno della logica dello shader.
- 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 ora, vedrai che hai più di un quadrato. ma non puoi vederne 16 contemporaneamente.
Questo accade perché le coordinate delle celle che generi sono (0, 0), (1, 1), (2, 2) e così via fino a (15, 15), ma solo le prime quattro sono visualizzate nel riquadro. Per creare la griglia che preferisci, devi trasformare instance_index
in modo che ogni indice venga mappato a una cella univoca all'interno della griglia, come segue:
I calcoli sono abbastanza semplici. Per il valore X di ogni cella, devi calcolare il modulo di instance_index
e la larghezza della griglia, che puoi eseguire in WGSL con l'operatore %
. Per il valore Y di ogni cella, vuoi che instance_index
venga diviso per la larghezza della griglia, ignorando eventuali residui frazionari. Puoi farlo con la funzione floor()
di WGSL.
- Modifica i calcoli, ad esempio:
indice.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
// Compute the cell coordinate from the instance_index
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
Dopo aver apportato l'aggiornamento al codice, finalmente hai la griglia di quadrati tanto attesa.
- Ora che funziona, torna indietro e aumenta le dimensioni della griglia.
index.html
const GRID_SIZE = 32;
Ecco fatto! Ora puoi creare una griglia davvero molto grande e la GPU media la gestisce perfettamente. Non vedrai più i singoli quadrati molto prima di riscontrare colli di bottiglia nel rendimento della GPU.
6. Un bel voto in più: aggiungi un po' di colore.
A questo punto, puoi passare facilmente alla sezione successiva, poiché hai gettato le basi per il resto del codelab. Anche se la griglia di quadrati che condividono tutti lo stesso colore è utilizzabile, non è esattamente entusiasmante, vero? Fortunatamente, puoi migliorare un po' la situazione con un po' di matematica e codice shader.
Utilizzare gli struct negli Shaper
Fino ad ora, hai passato un dato di Vertex Shader: la posizione trasformata. Tuttavia, puoi restituire molti più dati dallo shader vertex e utilizzarli nello shader fragment.
L'unico modo per trasferire i dati da Vertex Shader è restituirli. Un vertex shader è sempre necessario per restituire una posizione, quindi se vuoi restituire altri dati, devi inserirli in una struct. Gli struct in WGSL sono tipi di oggetti denominati che contengono una o più proprietà denominate. È possibile eseguire il markup delle proprietà anche con attributi come @builtin
e @location
. Le dichiari al di fuori di qualsiasi funzione e poi puoi passare le relative istanze all'interno e all'esterno delle funzioni, in base alle esigenze. Ad esempio, considera il tuo attuale shader vertex:
index.html (chiamata createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
- Esprimere la stessa cosa utilizzando struct per l'input e l'output della funzione:
index.html (chiamata createShaderModule)
struct VertexInput {
@location(0) pos: vec2f,
@builtin(instance_index) instance: u32,
};
struct VertexOutput {
@builtin(position) pos: vec4f,
};
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
let i = f32(input.instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
return output;
}
Tieni presente che devi fare riferimento alla posizione di input e all'indice dell'istanza con input
e lo struct restituito per primo deve essere dichiarato come variabile e avere le singole proprietà impostate. In questo caso, non fa molta differenza e, di fatto, rende la funzione dello shader un po' più lunga, ma man mano che gli shader diventano più complessi, l'utilizzo delle strutture può essere un ottimo modo per organizzare i dati.
Trasferire dati tra le funzioni vertex e fragment
Ti ricordiamo che la funzione @fragment
è il più semplice possibile:
index.html (chiamata createShaderModule)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
Non stai ricevendo input e stai passando un colore a tinta unita (rosso) come output. Se invece sapessi di più sulla geometria che sta colorando, potresti usare quei dati aggiuntivi per rendere le cose un po' più interessanti. Ad esempio, cosa succede se vuoi cambiare il colore di ogni quadrato in base alla sua coordinata di cella? La fase @vertex
sa quale cella viene visualizzata; devi solo passarla alla fase @fragment
.
Per passare qualsiasi dato tra le fasi di vertice e frammento, devi includerlo in uno struct di output con un @location
di nostra scelta. Poiché vuoi passare la coordinata della cella, aggiungila alla struttura VertexOutput
di cui sopra e impostala nella funzione @vertex
prima di tornare.
- Modifica il valore restituito dello shader vertex, ad esempio:
index.html (chiamata createShaderModule)
struct VertexInput {
@location(0) pos: vec2f,
@builtin(instance_index) instance: u32,
};
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) cell: vec2f, // New line!
};
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
let i = f32(input.instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
output.cell = cell; // New line!
return output;
}
- Nella funzione
@fragment
, ricevi il valore aggiungendo un argomento con lo stesso@location
. I nomi non devono corrispondere, ma è più facile tenere traccia delle attività se lo fanno.
index.html (chiamata createShaderModule)
@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
// Remember, fragment return values are (Red, Green, Blue, Alpha)
// and since cell is a 2D vector, this is equivalent to:
// (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
return vec4f(cell, 0, 1);
}
- In alternativa, puoi utilizzare una struct:
index.html (chiamata createShaderModule)
struct FragInput {
@location(0) cell: vec2f,
};
@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
- Un'altra alternativa, dato che nel codice entrambe le funzioni sono definite nello stesso modulo shader, è riutilizzare la struttura di output della fase
@vertex
. In questo modo, è facile passare i valori perché i nomi e le posizioni sono naturalmente coerenti.
index.html (chiamata createShaderModule)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
A prescindere dal pattern scelto, il risultato è che avrai accesso al numero di cella nella funzione @fragment
e potrai utilizzarlo per influenzare il colore. Con uno dei codici precedenti, l'output sarà simile al seguente:
Ora ci sono sicuramente più colori, ma non sono proprio belli da vedere. Potresti chiederti perché solo le righe di sinistra e di fondo sono diverse. Questo perché i valori di colore restituiti dalla funzione @fragment
si aspettano che ogni canale rientri nell'intervallo da 0 a 1 e tutti i valori al di fuori di questo intervallo vengono bloccati. I valori delle celle, invece, sono compresi tra 0 e 32 su ciascun asse. Qui vediamo che la prima riga e la prima colonna raggiungono immediatamente il valore completo 1 nel canale di colore rosso o verde e che ogni cella successiva viene bloccata allo stesso valore.
Se vuoi una transizione più graduale tra i colori, devi restituire un valore frazionario per ogni canale di colore, idealmente iniziando da zero e terminando con 1 lungo ogni asse, il che significa un'altra divisione per grid
.
- Modifica lo shader di frammento, ad esempio:
index.html (chiamata createShaderModule)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell/grid, 0, 1);
}
Aggiorna la pagina e vedrai che il nuovo codice offre un gradiente di colori molto più piacevole nell'intera griglia.
Sebbene si tratti di un miglioramento, ora c'è un angolo scuro in basso a sinistra, dove la griglia diventa nera. Quando inizi a eseguire la simulazione del gioco della vita, una sezione della griglia difficile da vedere oscura ciò che sta accadendo. Sarebbe bello ravvivare l'attenzione.
Fortunatamente, disponi di un intero canale colore inutilizzato, il blu, che puoi utilizzare. L'effetto ideale è che il blu sia più luminoso dove gli altri colori sono più scuri e che svanisca man mano che l'intensità degli altri colori aumenta. Il modo più semplice per farlo è start il canale da 1 e sottrarre uno dei valori della cella. Può essere c.x
o c.y
. Provali entrambi e scegli quello che preferisci.
- Aggiungi colori più brillanti allo shader di frammento, ad esempio:
chiamata a createShaderModule
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let c = input.cell / grid;
return vec4f(c, 1-c.x, 1);
}
Il risultato è davvero bello.
Questo non è un passaggio fondamentale. Tuttavia, poiché ha un aspetto migliore, è stato incluso nel file sorgente del checkpoint corrispondente e il resto degli screenshot di questo codelab riflette questa griglia più colorata.
7. Gestire lo stato delle celle
Successivamente, devi controllare quali celle della griglia vengono visualizzate in base a uno stato memorizzato nella GPU. Questo è importante per la simulazione finale.
Ti serve solo un indicatore di attivazione/disattivazione per ogni cella, quindi qualsiasi opzione che ti consenta di memorizzare un ampio array di quasi tutti i tipi di valori è valida. Potresti pensare che questo sia un altro caso d'uso per gli uniform buffer. Sebbene tu possa farlo funzionare, è più difficile perché gli uniform buffer hanno dimensioni limitate, non supportano gli array con dimensioni dinamiche (devi specificare le dimensioni dell'array nello shader) e non possono essere scritti dagli shader di calcolo. Quest'ultimo elemento è l'aspetto più problematico, dato che vuoi eseguire la simulazione Game of Life sulla GPU in un computing shaker.
Fortunatamente, esiste un'altra opzione di buffer che evita tutte queste limitazioni.
Crea un buffer di archiviazione
Gli storage buffer sono buffer di uso generale che possono essere letti e scritti negli shader di calcolo e letti negli shader di vertice. Possono essere molto grandi e non richiedono una dimensione dichiarata specifica in uno shader, il che li rende molto più simili alla memoria generale. che utilizzi per memorizzare lo stato della cella.
- Per creare un buffer di archiviazione per lo stato della cella, utilizza quello che ormai probabilmente inizia a essere uno snippet di codice di creazione del buffer familiare:
indice.html
// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);
// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
label: "Cell State",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
Come per gli buffer vertex e uniform, chiama device.createBuffer()
con le dimensioni appropriate e assicurati di specificare un utilizzo di GPUBufferUsage.STORAGE
questa volta.
Puoi completare il buffer nello stesso modo di prima riempiendo il campo TypedArray della stessa dimensione con valori e quindi chiamando device.queue.writeBuffer()
. Poiché vuoi vedere l'effetto del buffer sulla griglia, inizia a riempirla con qualcosa di prevedibile.
- Attiva ogni terza cella con il seguente codice:
indice.html
// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);
Leggi il buffer di archiviazione nello shaker
Aggiorna lo shader per esaminare i contenuti del buffer di archiviazione prima di eseguire il rendering della griglia. Questa procedura è molto simile a quella utilizzata in precedenza per aggiungere le divise.
- Aggiorna lo shaker con il seguente codice:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!
In primo luogo, aggiungi il punto di associazione, che si infila sotto l'uniforme della griglia. Vuoi mantenere lo stesso @group
dell'uniforme grid
, ma il numero @binding
deve essere diverso. Il tipo var
è storage
, per riflettere il diverso tipo di buffer e, anziché un singolo vettore, il tipo specificato per cellState
è un array di valori u32
, in modo da corrispondere a Uint32Array
in JavaScript.
Successivamente, nel corpo della funzione @vertex
, esegui una query sullo stato della cella. Poiché lo stato è memorizzato in un array piatto nel buffer di archiviazione, puoi utilizzare instance_index
per cercare il valore della cella corrente.
Come si disattiva una cella se lo stato indica che non è attiva? Dato che gli stati attivi e inattivi che ottieni dall'array sono 1 o 0, puoi scalare la geometria in base allo stato attivo. Se la scala è 1, la geometria rimane invariata, mentre se è 0 la geometria si riduce a un singolo punto, che la GPU poi elimina.
- Aggiorna il codice dello shader per scalare la posizione in base allo stato attivo della cella. Il valore dello stato deve essere sottoposto a conversione in
f32
per soddisfare i requisiti di sicurezza di tipo di WGSL:
index.html
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) -> VertexOutput {
let i = f32(instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let state = f32(cellState[instance]); // New line!
let cellOffset = cell / grid * 2;
// New: Scale the position by the cell's active state.
let gridPos = (pos*state+1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
output.cell = cell;
return output;
}
Aggiungi il buffer di archiviazione al gruppo di associazione
Prima di poter vedere che lo stato della cella diventa effettivo, aggiungi il buffer di archiviazione a un gruppo di associazione. Poiché fa parte dello stesso @group
del buffer uniforme, aggiungilo anche allo stesso gruppo di binding nel codice JavaScript.
- Aggiungi il buffer di archiviazione, ad esempio:
index.html
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
},
// New entry!
{
binding: 1,
resource: { buffer: cellStateStorage }
}],
});
Assicurati che il binding
della nuova voce corrisponda al @binding()
del valore corrispondente nello shader.
A questo punto, dovresti essere in grado di aggiornare e vedere il pattern nella griglia.
Usa il pattern di buffer del ping-pong
La maggior parte delle simulazioni come quella che stai creando in genere utilizza almeno due copie del proprio stato. In ogni passaggio della simulazione, leggono da una copia dello stato e scrivono nell'altra. Poi, nel passaggio successivo, capovolgilo e leggi dallo stato in cui ha scritto in precedenza. Questo è comunemente noto come pattern ping pong perché la versione più aggiornata dello stato passa da una copia all'altra in ogni passaggio.
Perché è necessario? Esamina un esempio semplificato: immagina di scrivere una simulazione molto semplice in cui sposti tutti i blocchi attivi verso destra di una cella ogni passaggio. Per semplicità, definisci i dati e la simulazione in JavaScript:
// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];
function simulate() {
for (let i = 0; i < state.length-1; ++i) {
if (state[i] == 1) {
state[i] = 0;
state[i+1] = 1;
}
}
}
simulate(); // Run the simulation for one step.
Ma se esegui quel codice, la cella attiva si sposta fino alla fine dell'array in un solo passaggio! Perché? Perché continui ad aggiornare lo stato in situ, quindi sposti la cella attiva verso destra, poi guardi la cella successiva e… È attivo. Meglio spostarlo di nuovo a destra. Il fatto che tu modifichi i dati nello stesso momento in cui li osservi corrompe i risultati.
Utilizzando il pattern del ping pong, ti assicuri di eseguire sempre il passaggio successivo della simulazione utilizzando solo i risultati dell'ultimo passaggio.
// Example simulation. Don't copy into the project!
const stateA = [1, 0, 0, 0, 0, 0, 0, 0];
const stateB = [0, 0, 0, 0, 0, 0, 0, 0];
function simulate(inArray, outArray) {
outArray[0] = 0;
for (let i = 1; i < inArray.length; ++i) {
outArray[i] = inArray[i-1];
}
}
// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA);
- Utilizza questo pattern nel tuo codice aggiornando l'allocazione del buffer di archiviazione per creare due buffer identici:
indice.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, compilali con dati diversi:
index.html
// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);
// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
- Per mostrare i diversi buffer di archiviazione nel rendering, aggiorna i gruppi di binding in modo da avere anche due varianti diverse:
index.html
const bindGroups = [
device.createBindGroup({
label: "Cell renderer bind group A",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[0] }
}],
}),
device.createBindGroup({
label: "Cell renderer bind group B",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[1] }
}],
})
];
Configurare un loop di rendering
Finora hai eseguito un solo grafico per aggiornamento della pagina, ma ora vuoi mostrare l'aggiornamento dei dati nel tempo. Per farlo, hai bisogno di un semplice loop di rendering.
Un ciclo di rendering è un ciclo che si ripete all'infinito e disegna i contenuti sulla tela a un determinato intervallo. Molti giochi e altri contenuti che vogliono essere animati senza interruzioni utilizzano la funzione requestAnimationFrame()
per pianificare i richiami alla stessa frequenza con cui lo schermo si aggiorna (60 volte al secondo).
Questa app può utilizzarlo, ma in questo caso è consigliabile che gli aggiornamenti vengano eseguiti in passaggi più lunghi per poter seguire più facilmente l'andamento della simulazione. Gestisci invece il loop in modo da poter controllare la frequenza di aggiornamento della simulazione.
- Innanzitutto, scegli una frequenza di aggiornamento della simulazione (200 ms sono buoni, ma puoi andare più lentamente o più velocemente se vuoi) e poi tieni traccia del numero di passaggi della simulazione completati.
indice.html
const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
- Quindi, sposta tutto il codice attualmente utilizzato per il rendering in una nuova funzione. Programma la ripetizione della funzione all'intervallo che preferisci con
setInterval()
. Assicurati che la funzione aggiorni anche il conteggio dei passaggi e utilizzala per scegliere quale dei due gruppi di associazione eseguire.
indice.html
// Move all of our rendering code into a function
function updateGrid() {
step++; // Increment the step count
// Start a render pass
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
storeOp: "store",
}]
});
// Draw the grid.
pass.setPipeline(cellPipeline);
pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
// End the render pass and submit the command buffer
pass.end();
device.queue.submit([encoder.finish()]);
}
// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);
E ora, quando esegui l'app, vedi che il canvas si alterna continuamente per mostrare i due buffer di stato che hai creato.
A questo punto, hai quasi completato la parte di rendering. È tutto pronto per visualizzare l'output della simulazione Gioco della vita creata nel passaggio successivo, in cui finalmente inizierai a utilizzare i computing Shader.
Ovviamente le funzionalità di rendering di WebGPU sono molto più ampie di quelle che hai esplorato qui, ma il resto esula dallo scopo di questo codelab. Speriamo, però, che abbiate abbastanza un'idea di come funziona il rendering di WebGPU, che aiuta a semplificare l'esplorazione di tecniche più avanzate come il rendering 3D.
8. Esegui la simulazione
Ora passiamo all'ultimo pezzo del puzzle: l'esecuzione della simulazione del gioco della vita in un compute shader.
Finalmente puoi utilizzare gli shader di calcolo.
In questo codelab hai appreso in modo astratto gli shader di calcolo, ma che cosa sono esattamente?
Un compute shader è simile agli shader vertex e fragment in quanto è progettato per funzionare con un parallelismo estremo sulla GPU, ma, a differenza degli altri due stadi shader, non ha un insieme specifico di input e output. Leggi e scrivi dati esclusivamente da origini che scegli, ad esempio i buffer di archiviazione. Ciò significa che, anziché eseguire una volta per ogni vertice, istanza o pixel, devi indicare quante invocazioni della funzione shader vuoi. Quindi, quando esegui lo shaker, ti viene comunicato quale chiamata viene elaborata e puoi decidere a quali dati accedere e quali operazioni eseguirai da lì.
Gli shader di calcolo devono essere creati in un modulo shader, proprio come gli shader di vertici e frammenti, quindi aggiungilo al codice per iniziare. Come puoi immaginare, data la struttura degli altri shader che hai implementato, la funzione principale per lo shader di calcolo deve essere contrassegnata dall'attributo @compute
.
- Crea uno shaker di computing con il codice seguente:
index.html
// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
label: "Game of Life simulation shader",
code: `
@compute
fn computeMain() {
}`
});
Poiché le GPU vengono utilizzate di frequente per la grafica 3D, gli shader di calcolo sono strutturati in modo da poter richiedere l'attivazione dello shader un numero specifico di volte lungo un asse X, Y e Z. In questo modo puoi inviare facilmente i lavori conformi a una griglia 2D o 3D, il che è perfetto per il tuo caso d'uso. Vuoi chiamare questo shader GRID_SIZE
volte GRID_SIZE
volte, una per ogni cella della simulazione.
A causa della natura dell'architettura hardware della GPU, questa griglia è suddivisa in gruppi di lavoro. Un gruppo di lavoro ha dimensioni X, Y e Z e, anche se ogni dimensione può essere pari a 1, spesso è possibile ottenere vantaggi in termini di prestazioni aumentando leggermente le dimensioni dei gruppi di lavoro. Per il tuo shaker, scegli una dimensione del gruppo di lavoro un po' arbitraria di 8 per 8. È utile tenere traccia di questo nel codice JavaScript.
- Definisci una costante per la dimensione del gruppo di lavoro, in questo modo:
indice.html
const WORKGROUP_SIZE = 8;
Devi anche aggiungere le dimensioni del gruppo di lavoro alla funzione shader stessa, utilizzando i letterali di modello di JavaScript in modo da poter utilizzare facilmente la costante appena definita.
- Aggiungi le dimensioni del gruppo di lavoro alla funzione shader, come segue:
index.html (chiamata Compute createShaderModule)
@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {
}
Questo indica allo shader che il lavoro svolto con questa funzione viene eseguito in gruppi (8 x 8 x 1). Per qualsiasi asse non specificato, il valore predefinito è 1, anche se devi specificare almeno l'asse X.
Come per gli altri stadi shader, esistono diversi valori @builtin
che puoi accettare come input nella funzione di compute shader per indicare quale chiamata stai utilizzando e decidere quale lavoro devi svolgere.
- Aggiungi un valore
@builtin
, ad esempio:
index.html (chiamata Compute createShaderModule)
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Passi l'global_invocation_id
integrato, che è un vettore tridimensionale di numeri interi senza segno che ti indica in che punto della griglia delle chiamate Shar ti trovi. Esegui questo shader una volta per ogni cella della griglia. Visualizzi numeri come (0, 0, 0)
, (1, 0, 0)
, (1, 1, 0)
… fino a (31, 31, 0)
, il che significa che puoi trattarli come l'indice della cella su cui vuoi eseguire operazioni.
Gli shader di calcolo possono anche utilizzare uniformi, che vengono utilizzate come negli shader vertex e fragment.
- Utilizza un'uniforme con lo shader di calcolo per indicare le dimensioni della griglia, ad esempio:
index.html (chiamata createShaderModule di Compute)
@group(0) @binding(0) var<uniform> grid: vec2f; // New line
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Come nello shader vertex, esponi anche lo stato della cella come buffer di archiviazione. In questo caso, però, ne devi avere due. Dal momento che i responsabili di computing non hanno un output richiesto, ad esempio una posizione del vertice o il colore di un frammento, scrivere valori in un buffer di archiviazione o una texture è l'unico modo per ottenere risultati da uno shaker di computing. Usa il metodo del ping-pong che hai imparato in precedenza: hai un buffer di archiviazione che alimenta lo stato attuale della griglia e uno in cui ne trascrivi il nuovo stato.
- Esponi lo stato di input e output della cella come buffer di archiviazione, ad esempio:
index.html (chiamata Compute createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Tieni presente che il primo buffer di archiviazione è dichiarato con var<storage>
, il che lo rende di sola lettura, mentre il secondo buffer di archiviazione è dichiarato con var<storage, read_write>
. In questo modo puoi leggere e scrivere nel buffer, utilizzandolo come output per il tuo compute shader. (in WebGPU non è presente una modalità di archiviazione di sola scrittura).
Successivamente, devi avere un modo per mappare l'indice di cella nell'array di archiviazione lineare. Fondamentalmente è l'opposto di quello che hai fatto nel vertex Shar, dove hai preso il instance_index
lineare e l'hai mappato a una cella della griglia 2D. Ti ricordiamo che l'algoritmo utilizzato per questa richiesta era vec2f(i % grid.x, floor(i / grid.x))
.
- Scrivi una funzione per andare nella direzione opposta. Prende il valore Y della cella, lo moltiplica per la larghezza della griglia e poi aggiunge il valore X della cella.
index.html (chiamata createShaderModule di Compute)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
// New function
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
Infine, per verificare che funzioni, implementa un algoritmo molto semplice: se una cella è attiva, si disattiva e viceversa. Non si tratta ancora del Gioco della vita, ma è sufficiente per dimostrare che il computing shaker funziona.
- Aggiungi l'algoritmo semplice, ad esempio:
index.html (chiamata Compute createShaderModule)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines. Flip the cell state every step.
if (cellStateIn[cellIndex(cell.xy)] == 1) {
cellStateOut[cellIndex(cell.xy)] = 0;
} else {
cellStateOut[cellIndex(cell.xy)] = 1;
}
}
Per ora è tutto per lo shader di calcolo. Prima di vedere i risultati, però, è necessario apportare alcune altre modifiche.
Utilizzare i layout di gruppi di collegamento e pipeline
Una cosa che potresti notare dallo shader riportato sopra è che utilizza in gran parte gli stessi input (uniformi e buffer di archiviazione) della pipeline di rendering. Potresti pensare che sia sufficiente utilizzare gli stessi gruppi di binding e non fare altro, giusto? La buona notizia è che puoi. ma richiede una configurazione manuale un po' più complessa.
Ogni volta che crei un gruppo di associazioni, devi fornire un GPUBindGroupLayout
. In precedenza, ottenevi questo layout chiamando getBindGroupLayout()
nella pipeline di rendering, che a sua volta lo creava automaticamente perché avevi fornito layout: "auto"
al momento della creazione. Questo approccio funziona bene quando utilizzi una sola pipeline, ma se hai più pipeline che vogliono condividere risorse, devi creare il layout in modo esplicito e poi fornirlo sia al gruppo di associazione sia alle pipeline.
Per capire perché, considera quanto segue: nelle pipeline di rendering utilizzi un singolo buffer uniforme e un singolo buffer di archiviazione, ma nello shader di calcolo che hai appena scritto hai bisogno di un secondo buffer di archiviazione. Poiché i due shader utilizzano gli stessi valori @binding
per l'uniforme e il primo buffer di archiviazione, puoi condividerli tra le pipeline e la pipeline di rendering ignora il secondo buffer di archiviazione, che non utilizza. Vuoi creare un layout che descriva tutte le risorse presenti nel gruppo di associazione, non solo quelle utilizzate da una pipeline specifica.
- Per creare il layout, chiama
device.createBindGroupLayout()
:
index.html
// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
label: "Cell Bind Group Layout",
entries: [{
binding: 0,
// Add GPUShaderStage.FRAGMENT here if you are using the `grid` uniform in the fragment shader.
visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
buffer: {} // Grid uniform buffer
}, {
binding: 1,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
buffer: { type: "read-only-storage"} // Cell state input buffer
}, {
binding: 2,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: "storage"} // Cell state output buffer
}]
});
La struttura è simile a quella della creazione del gruppo di associazione stesso, in quanto descrivi un elenco di entries
. La differenza è che descrivi il tipo di risorsa che deve essere la voce e come viene utilizzata anziché fornire la risorsa stessa.
In ogni voce, specifica il numero binding
della risorsa, che (come hai appreso quando hai creato il gruppo di binding) corrisponde al valore @binding
negli shader. Devi fornire anche i visibility
, che sono flag GPUShaderStage
che indicano quali fasi dello snapshot possono utilizzare la risorsa. Vuoi che sia l'uniforme sia il primo buffer di archiviazione siano accessibili negli shader vertex e compute, ma il secondo buffer di archiviazione deve essere accessibile solo negli shader compute.
Infine, indica il tipo di risorsa utilizzata. Si tratta di una chiave di dizionario diversa, a seconda di cosa devi esporre. In questo caso, tutte e tre le risorse sono buffer, quindi devi utilizzare la chiave buffer
per definire le opzioni per ciascuna. Altre opzioni includono texture
o sampler
, ma non sono necessarie qui.
Nel dizionario del buffer, puoi impostare opzioni come il tipo di type
di buffer utilizzato. Il valore predefinito è "uniform"
, quindi puoi lasciare il dizionario vuoto per l'associazione 0. Tuttavia, devi impostare almeno buffer: {}
affinché la voce venga identificata come buffer. Alla associazione 1 viene assegnato il tipo "read-only-storage"
perché non la utilizzi con accesso read_write
nello shader, mentre la associazione 2 ha il tipo "storage"
perché la utilizzi con accesso read_write
.
Una volta creato bindGroupLayout
, puoi passarlo durante la creazione dei gruppi di associazione anziché eseguire query sul gruppo di associazione dalla pipeline. In questo modo, devi aggiungere una nuova voce del buffer di archiviazione a ogni gruppo di unione per far corrispondere il layout appena definito.
- Aggiorna la creazione del gruppo di associazioni, ad esempio:
index.html
// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
device.createBindGroup({
label: "Cell renderer bind group A",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[0] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[1] }
}],
}),
device.createBindGroup({
label: "Cell renderer bind group B",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[1] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[0] }
}],
}),
];
Ora che il gruppo di associazione è stato aggiornato in modo da utilizzare questo layout esplicito del gruppo di associazione, devi aggiornare la pipeline di rendering per utilizzare la stessa cosa.
- Crea un
GPUPipelineLayout
.
index.html
const pipelineLayout = device.createPipelineLayout({
label: "Cell Pipeline Layout",
bindGroupLayouts: [ bindGroupLayout ],
});
Un layout della pipeline è un elenco di layout dei gruppi di associazioni (in questo caso ne hai uno) utilizzati da una o più pipeline. L'ordine dei layout dei gruppi di associazione nell'array deve corrispondere agli attributi @group
negli Shaper. Ciò significa che bindGroupLayout
è associato a @group(0)
.
- Una volta ottenuto il layout della pipeline, aggiorna la pipeline di rendering in modo da utilizzarla al posto di
"auto"
.
indice.html
const cellPipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: pipelineLayout, // Updated!
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
Crea la pipeline di calcolo
Proprio come hai bisogno di una pipeline di rendering per utilizzare gli shader vertex e fragment, hai bisogno di una pipeline di calcolo per utilizzare lo shader di calcolo. Fortunatamente, le pipeline di calcolo sono molto meno complicate delle pipeline di rendering, in quanto non hanno alcun stato da impostare, solo lo shader e il layout.
- Crea una pipeline di calcolo con il seguente codice:
index.html
// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
label: "Simulation pipeline",
layout: pipelineLayout,
compute: {
module: simulationShaderModule,
entryPoint: "computeMain",
}
});
Tieni presente che devi passare il nuovo pipelineLayout
anziché "auto"
, proprio come nella pipeline di rendering aggiornata, in modo che sia la pipeline di rendering sia la pipeline di calcolo possano utilizzare gli stessi gruppi di binding.
Tessere per il calcolo
Questo ti porta a utilizzare effettivamente la pipeline di computing. Dato che esegui il rendering in un passaggio di rendering, probabilmente puoi intuire che devi eseguire operazioni di calcolo in un passaggio di calcolo. Il lavoro di calcolo e rendering può avvenire entrambi nello stesso codificatore di comandi, quindi è opportuno eseguire un po' di shuffling della funzione updateGrid
.
- Sposta la creazione del codificatore nella parte superiore della funzione, quindi inizia una pass di computing (prima di
step++
).
indice.html
// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();
const computePass = encoder.beginComputePass();
// Compute work will go here...
computePass.end();
// Existing lines
step++; // Increment the step count
// Start a render pass...
Come le pipeline di calcolo, i passaggi di calcolo sono molto più semplici da avviare rispetto alle relative controparti di rendering perché non devi preoccuparti di eventuali allegati.
Vuoi eseguire il pass di computing prima del pass di rendering perché consente al pass di rendering di utilizzare immediatamente gli ultimi risultati della pass di computing. Questo è anche il motivo per cui aumenti il conteggio di step
tra i passaggi, in modo che il buffer di output della pipeline di calcolo diventi il buffer di input per la pipeline di rendering.
- Quindi, imposta la pipeline e il gruppo di associazione all'interno della pass di computing, utilizzando lo stesso pattern per il passaggio da un gruppo di associazione all'altro come faresti per il passaggio di rendering.
index.html
const computePass = encoder.beginComputePass();
// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);
computePass.end();
- Infine, invece di disegnare come in un passaggio di rendering, invii il lavoro allo shaker di computing, indicando quanti gruppi di lavoro vuoi eseguire su ciascun asse.
index.html
const computePass = encoder.beginComputePass();
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);
// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);
computePass.end();
Un aspetto molto importante da notare è che il numero che passi a dispatchWorkgroups()
non è il numero di invocazioni. Si tratta invece del numero di gruppi di lavoro da eseguire, come definito da @workgroup_size
nello shader.
Se vuoi che lo shader venga eseguito 32 volte per coprire l'intera griglia e le dimensioni del tuo gruppo di lavoro sono 8 x 8, devi inviare gruppi di lavoro 4 x 4 (4 * 8 = 32). Ecco perché devi dividere le dimensioni della griglia per le dimensioni del gruppo di lavoro e passare questo valore a dispatchWorkgroups()
.
Ora puoi aggiornare di nuovo la pagina. Dovresti vedere che la griglia si inverte a ogni aggiornamento.
Implementare l'algoritmo per il gioco della vita
Prima di aggiornare lo shader di calcolo per implementare l'algoritmo finale, devi tornare al codice che inizializza i contenuti dell'area di memoria e aggiornarlo in modo da produrre un buffer casuale a ogni caricamento della pagina. I motivi regolari non sono punti di partenza molto interessanti per il Gioco della vita. Puoi randomizzare i valori come preferisci, ma esiste un modo semplice per iniziare che fornisce risultati ragionevoli.
- Per avviare ogni cella in uno stato casuale, aggiorna l'inizializzazione di
cellStateArray
con il seguente codice:
index.html
// Set each cell to a random state, then copy the JavaScript array
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);
Ora puoi finalmente implementare la logica per la simulazione del gioco della vita. Dopo tutto quello che ci è voluto per arrivare a questo punto, il codice dello shader potrebbe risultare sorprendentemente semplice.
Innanzitutto, devi sapere per ogni cella quanti dei suoi vicini sono attivi. Non ti interessano quelli attivi, ma solo il conteggio.
- Per semplificare l'acquisizione dei dati delle celle adiacenti, aggiungi una funzione
cellActive
che restituisce il valorecellStateIn
della coordinata specificata.
index.html (chiamata createShaderModule di Compute)
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
La funzione cellActive
restituisce 1 se la cella è attiva, quindi l'aggiunta del valore restituito dalla chiamata di cellActive
per tutte le otto celle adiacenti indica quante celle adiacenti sono attive.
- Trova il numero di vicini attivi, ad esempio:
index.html (chiamata Compute createShaderModule)
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines:
// Determine how many active neighbors this cell has.
let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
cellActive(cell.x+1, cell.y) +
cellActive(cell.x+1, cell.y-1) +
cellActive(cell.x, cell.y-1) +
cellActive(cell.x-1, cell.y-1) +
cellActive(cell.x-1, cell.y) +
cellActive(cell.x-1, cell.y+1) +
cellActive(cell.x, cell.y+1);
Tuttavia, si verifica un piccolo problema: cosa succede quando la cella che stai controllando è fuori dal bordo della scheda? In base alla logica attuale di cellIndex()
, il valore tracima nella riga successiva o precedente o esce dal bordo del buffer.
Per il Gioco della vita, un modo comune e semplice per risolvere il problema è fare in modo che le celle ai bordi della griglia trattino le celle sul bordo opposto della griglia come vicine, creando una sorta di effetto di a capo.
- Supporta il wrapping della griglia con una modifica minore alla funzione
cellIndex()
.
index.html (chiamata createShaderModule di Compute)
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
Se utilizzi l'operatore %
per a capo la cella X e Y quando si estende oltre le dimensioni della griglia, ti assicuri di non accedere mai oltre i limiti del buffer di archiviazione. In questo modo, puoi avere la certezza che il conteggio di activeNeighbors
sia prevedibile.
Poi applica una delle quattro regole:
- Qualsiasi cella con meno di due celle adiacenti diventa inattiva.
- Qualsiasi cella attiva con due o tre vicini rimane attiva.
- Qualsiasi cella inattiva con esattamente tre vicini diventa attiva.
- Qualsiasi cella con più di tre celle adiacenti diventa inattiva.
Puoi eseguire questa operazione utilizzando una serie di istruzioni if, ma WGSL supporta anche le istruzioni switch, che sono adatte a questa logica.
- Implementa la logica del gioco della vita, ad esempio:
index.html (chiamata createShaderModule di Compute)
let i = cellIndex(cell.xy);
// Conway's game of life rules:
switch activeNeighbors {
case 2: { // Active cells with 2 neighbors stay active.
cellStateOut[i] = cellStateIn[i];
}
case 3: { // Cells with 3 neighbors become or stay active.
cellStateOut[i] = 1;
}
default: { // Cells with < 2 or > 3 neighbors become inactive.
cellStateOut[i] = 0;
}
}
Come riferimento, la chiamata del modulo shader di calcolo finale ora ha il seguente aspetto:
index.html
const simulationShaderModule = device.createShaderModule({
label: "Life simulation shader",
code: `
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// Determine how many active neighbors this cell has.
let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
cellActive(cell.x+1, cell.y) +
cellActive(cell.x+1, cell.y-1) +
cellActive(cell.x, cell.y-1) +
cellActive(cell.x-1, cell.y-1) +
cellActive(cell.x-1, cell.y) +
cellActive(cell.x-1, cell.y+1) +
cellActive(cell.x, cell.y+1);
let i = cellIndex(cell.xy);
// Conway's game of life rules:
switch activeNeighbors {
case 2: {
cellStateOut[i] = cellStateIn[i];
}
case 3: {
cellStateOut[i] = 1;
}
default: {
cellStateOut[i] = 0;
}
}
}
`
});
E... questo è tutto! È tutto. Aggiorna la pagina e guarda crescere il tuo nuovo automa cellulare.
9. Complimenti!
Hai creato una versione della simulazione del classico Gioco della vita di Conway che funziona interamente sulla tua GPU utilizzando l'API WebGPU.
Passaggi successivi
- Esamina i Samples WebGPU
Letture aggiuntive
- WebGPU: tutti i core, nessuna tela
- GPU web non elaborata
- Nozioni di base su WebGPU
- Best practice per WebGPU