Hello, WebGPU!
Il Web offre diversi strumenti per la creazione di elementi visivi, come immagini bitmap, fogli di stile, grafica vettoriale e l’elemento <canvas>
.
Queste soluzioni, tuttavia, sfruttano solo parzialmente le potenzialità delle schede video (o GPU).
Tra le API moderne per l’accesso diretto alla GPU troviamo Direct3D 12 per Windows, Metal per macOS e Vulkan per Linux e altre piattaforme. Queste API però non sono cross-platform, essendo strettamente legate ai loro rispettivi ambienti.
Architettura di WebGPU
WebGPU rappresenta una nuova specifica progettata per essere cross-platform, supportata da implementazioni come Dawn (Google), wgpu (Mozilla) e WebKit (Apple). Queste implementazioni si appoggiano ai backend nativi, ovvero Direct3D 12, Metal e Vulkan, per offrire un accesso uniforme e performante alle GPU, indipendentemente dal sistema operativo.
In passato, WebGL, nelle versioni 1 e 2, ha segnato un primo passo verso un accesso più diretto alla GPU, seguendo un approccio simile a OpenGL per il desktop. WebGL 2, in particolare, ha mantenuto la portabilità grazie all’uso di OpenGL ES, un’API ampiamente supportata e cross-platform. Tuttavia, lo standard OpenGL ES su cui si basa WebGL non è più in evoluzione, il che lo rende meno adatto a sfruttare le potenzialità delle GPU moderne.
Nel frattempo infatti, le GPU si sono evolute in General Purpose GPU (GPGPU), ampliando il loro impiego oltre la grafica, ad esempio nell’addestramento di modelli di machine learning. In questo contesto, WebGL 2 non espone i compute shaders e necessita di una canvas per il rendering, mentre WebGPU offre maggiore flessibilità e prestazioni grazie alla possibilità di renderizzare su texture off-screen.
#Accesso alla GPU
In Javascript, l’oggetto navigator.gpu
è il principale punto di ingresso per WebGPU. Se questo oggetto
non è definito, significa che WebGPU non è disponibile:
if (!navigator.gpu) { throw new Error('WebGPU not supported on this browser.');}
Firefox al momento supporta WebGPU solo nelle build Nightly, mentre le versioni Release e Beta non includono ancora questa funzionalità.
Un sistema può disporre di più GPU fisiche, come i laptop con una scheda grafica a basso consumo e una ad alte prestazioni.
Ogni GPU è rappresentata da un adapter (GPUAdapter
), che può essere “richiesto” tramite il metodo requestAdapter()
:
const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance"});
if (!adapter) { throw new Error('No appropriate GPUAdapter found.');}
L’impostazione high-performance
per il parametro powerPreference
suggerisce di privilegiare la GPU ad alte prestazioni.
Il valore restituito può essere nullo, per esempio se l’hardware non soddisfa tutte le caratteristiche richieste.
L’adapter fornisce informazioni generali, limiti e caratteristiche opzionali supportate:
// Informazioni genericheconsole.log(adapter.info.vendor);console.log(adapter.info.architecture);console.log(adapter.info.device);console.log(adapter.info.description);
// Limiticonsole.log(adapter.limits.maxTextureDimension2D);
// Featuresconsole.log(adapter.features);
Il sito WebGPU Report mostra tutte queste informazioni in dettaglio.
Dato un adapter, è possibile richiedere il device (GPUDevice
) associato. In WebGPU, il codice che
possiede un device agisce come se fosse l’unico utilizzatore della scheda grafica. Di conseguenza, un device è il proprietario di
tutte le risorse create a partire da esso (texture, ecc.), che vengono liberate quando il device viene rilasciato.
Un device funge da “contesto isolato”, dove le risorse sono private e non accessibili tra device diversi.
Per ottenere un device dall’adapter si usa il metodo requestDevice()
:
const device = await adapter.requestDevice({ requiredFeatures: [ "float32-filterable" ], requiredLimits: { // >= 1024 "maxTextureDimension2D": 1024 }});
Quando si richiede un device, è possibile indicare in requiredFeatures
le funzionalità opzionali che si desidera abilitare.
In requiredLimits
si definiscono invece i valori minimi accettabili per determinate caratteristiche, come le dimensioni massime delle texture.
Le chiavi di requiredLimits
devono corrispondere a quelle definite da GPUSupportedLimits
. In caso di mancato rispetto dei
limiti, verrà sollevata un’eccezione.
#GPU predefinita su Windows
Nei laptop con doppia GPU, la scheda integrata viene solitamente
preferita per eseguire il browser. Questo impedisce l’accesso alla GPU ad alte prestazioni, anche quando si specifica
high-performance
come powerPreference
nella richiesta dell’adapter. Per modificare questa impostazione, è necessario aprire
le impostazioni di Windows (Win + I), navigare su “Sistema > Schermo > Grafica”, aggiungere l’eseguibile del
browser e selezionare l’opzione “Prestazioni elevate”:

Selezione di “prestazioni elevate” come scelta predefinita per il browser
#Rendering su canvas
Uno degli utilizzi più comuni di WebGPU è la generazione di grafica interattiva all’interno di una pagina web. Questo
tipicamente avviene con il supporto dell’elemento <canvas />
. Nel resto dell’articolo ci concentreremo su questo caso d’uso,
sebbene WebGPU non sia vincolato al rendering su schermo.
Per iniziare, assumiamo di avere un elemento <canvas />
definito nell’HTML:
<canvas id="canvas" />
Il primo passo per integrare WebGPU con la canvas è ottenere un contesto di rendering di tipo webgpu
utilizzando il
metodo getContext()
:
const canvas = document.getElementById("canvas")! as HTMLCanvasElement;const ctx = canvas.getContext('webgpu');if (!ctx) { throw new Error("Could not get 'webgpu' context from canvas!");}
Questo passaggio è simile a quanto avviene con le API tradizionali di disegno su canvas, che utilizzano però il contesto di tipo 2d
.
Prima dell’utilizzo, il contesto WebGPU va configurato specificando almeno il formato della texture:
ctx.configure({ device, format: navigator.gpu.getPreferredCanvasFormat()});
Una texture è una matrice di pixel, e il formato con cui è definita ne specifica la codifica binaria.
Per il rendering su canvas, il metodo getPreferredCanvasFormat()
restituisce il formato ottimale supportato dal sistema,
che evita possibili inefficienze legate a conversioni di codifica.
#Formati delle texture
La specifica WebGPU definisce i possibili formati per le texture attraverso l’enumerato GPUTextureFormat
.
La scelta del formato determina come i pixel vengono memorizzati e interpretati, influenzando la precisione del colore, l’utilizzo della memoria e le prestazioni.
I formati delle texture sono caratterizzati principalmente da tre fattori: il numero e l’ordine dei componenti (o canali), il numero di bit per ciascun canale e il tipo di dato di ciascun canale.
#Numero e ordine dei componenti
Questo aspetto definisce quali informazioni sono contenute in ciascun pixel della texture. I formati più comuni includono:
- r: memorizza solo il componente rosso (Red). Utilizzato spesso per maschere, mappe di profondità o dati scalari.
- rg: memorizza i componenti rosso (Red) e verde (Green). Utile per memorizzare coordinate UV o altri dati a due componenti.
- rgb: memorizza i componenti rosso (Red), verde (Green) e blu (Blue). Rappresentazione standard del colore.
- rgba: memorizza i componenti rosso (Red), verde (Green), blu (Blue) e alfa (Alpha). Include la trasparenza.
- bgra: simile a
rgba
, ma con l’ordine dei componenti blu e rosso invertito (Blue, Green, Red, Alpha). Questo formato è spesso utilizzato per compatibilità con specifiche piattaforme o librerie grafiche.
#Numero di bit per canale
Il numero di bit dedicati a ciascun canale determina la precisione con cui quel componente può essere rappresentato. I valori tipici sono 8, 16 o 32 bit. Un numero maggiore di bit consente una rappresentazione più dettagliata del colore o del dato, ma richiede anche più memoria. Esistono anche formati “packed” dove il numero di bit varia tra i canali per ottimizzare l’utilizzo della memoria in scenari specifici.
#Tipo di dato
Il tipo di dato definisce come i valori dei singoli canali vengono memorizzati e interpretati:
- uint (Unsigned Integer): Intero senza segno. I valori rappresentano numeri interi positivi.
- sint (Signed Integer): Intero con segno. I valori rappresentano numeri interi positivi e negativi.
- float: Numero a virgola mobile. Permette di rappresentare valori frazionari, offrendo maggiore precisione e un intervallo dinamico più ampio. Utilizzato per calcoli complessi e rendering ad alta precisione.
- unorm (Unsigned Normalized): Intero senza segno normalizzato. Il valore intero viene convertito in un numero a virgola mobile nell’intervallo [0, 1] prima di essere utilizzato negli shader.
- snorm (Signed Normalized): Intero con segno normalizzato. Il valore intero viene convertito in un numero a virgola mobile nell’intervallo [-1, 1] prima di essere utilizzato negli shader. Utile per rappresentare normali di superficie o altri vettori direzionali.
#Spazio di colore
Alcuni formati possono avere il suffisso -srgb
(ad esempio, rgba8unorm-srgb
). Questo suffisso indica che la texture
memorizza i colori nello spazio di colore sRGB. Quando uno shader legge o scrive un colore su una
texture di questo tipo, vengono applicate automaticamente le conversioni gamma tra lo spazio sRGB (non lineare) e lo
spazio lineare.
#Formati di esempio
Analizziamo alcuni esempi per capire meglio come questi fattori si combinano:
- r32float: Una texture a singolo canale (r) dove il valore di ciascun pixel è un numero in virgola mobile a 32 bit (float). Adatto per memorizzare dati scalari ad alta precisione.
- rgba8uint: Una texture a quattro canali (rgba) dove ogni componente è rappresentato da un intero senza segno a 8 bit (uint). Un formato comune per memorizzare immagini a colori.
- rg16sint: Una texture a due canali (rg) dove ogni componente è un intero con segno a 16 bit (sint). Utile per memorizzare vettori 2D con valori sia positivi che negativi.
- bgra8unorm-srgb: Una texture a quattro canali (bgra) dove ogni componente è un intero senza segno a 8 bit normalizzato (unorm) e i colori sono memorizzati nello spazio di colore sRGB. Formato comune per l’output di rendering destinato alla visualizzazione su canvas.
La scelta del formato dipende dalle esigenze specifiche dell’applicazione, come la precisione del colore richiesta, la quantità di memoria disponibile e le operazioni che verranno eseguite sulla texture all’interno degli shader.
#Dimensionamento canvas
Per una canvas possiamo considerare due dimensioni distinte:
- La dimensione a schermo dell’elemento, definita tramite CSS.
- La dimensione della texture associata alla canvas, determinata dagli attributi HTML
width
eheight
.
È importante che queste due dimensioni coincidano, per evitare artefatti. Ad esempio, se la dimensione a schermo è maggiore rispetto a quella della texture, il risultato sarà un’immagine sfocata, perché il browser ingrandirà la texture.
Si potrebbe pensare di risolvere il problema semplicemente impostando gli stessi valori lato CSS e lato HTML:
<style> #canvas { width: 384px; height: 384px; }</style><canvas id="canvas" width="384" height="384" />
Tuttavia ciò non è sempre corretto, perché un pixel in CSS può non corrispondere a un pixel reale su schermo.
La proprietà window.devicePixelRatio
restituisce il numero di pixel reali che corrispondono a un pixel in CSS.
Per tenere conto di questa discrepanza, bisogna impostare la dimensione della canvas via JavaScript:
<style> #canvas { width: 384px; height: 384px; }</style><canvas id="canvas" /><script> canvas.width = Math.round(384 * window.devicePixelRatio); canvas.height = Math.round(384 * window.devicePixelRatio);</script>
Di seguito un widget che mostra il tuo valore per devicePixelRatio
:
#Canvas responsive
Se la canvas è responsiva, bisogna monitorarne i cambiamenti di dimensione con un ResizeObserver
:
const resizeObserver = new ResizeObserver(onResizeCallback);resizeObserver.observe( canvas, { box: 'content-box' });
function onResizeCallback([entry]: ResizeObserverEntry[]) { let width; let height; let dpr = window.devicePixelRatio; if (entry.devicePixelContentBoxSize) { // NOTE: Only this path gives the correct answer // The other 2 paths are an imperfect fallback // for browsers that don't provide anyway to do this width = entry.devicePixelContentBoxSize[0].inlineSize; height = entry.devicePixelContentBoxSize[0].blockSize; dpr = 1; // it's already in width and height } else if (entry.contentBoxSize) { if (entry.contentBoxSize[0]) { width = entry.contentBoxSize[0].inlineSize; height = entry.contentBoxSize[0].blockSize; } else { // legacy width = (entry.contentBoxSize as any).inlineSize; height = (entry.contentBoxSize as any).blockSize; } } else { // legacy width = entry.contentRect.width; height = entry.contentRect.height; }
const displayWidth = Math.round(width * dpr); const displayHeight = Math.round(height * dpr);
// Make the canvas the same size const canvas = entry.target as HTMLCanvasElement; canvas.width = displayWidth; canvas.height = displayHeight;}
#Code e comandi
L’interazione con la GPU avviene principalmente in due fasi:
- Creazione di uno o più buffer di comandi.
- Invio dei buffer ad una coda.
La coda è rappresentata dall’oggetto device.queue
, che fornisce il metodo submit()
per inviare
i comandi alla GPU:
device.queue.submit([ commandBuffer1, commandBuffer2, // ...]);
È importante ricordare che submit()
si limita a pianificare l’esecuzione dei comandi, mentre l’effettiva
elaborazione da parte della GPU avviene in modo asincrono.
#Creazione dei buffer di comandi
I buffer di comandi vengono generati tramite encoder, che agiscono come builder:
const encoder = device.createCommandEncoder();
// ... Build commands ...
// Build the command bufferconst commandBuffer = commandEncoder.finish();
// Schedule the command execution on the GPUdevice.queue.submit([ commandBuffer]);
Il metodo finish()
dell’encoder restituisce il buffer di comandi vero e proprio, pronto per essere inviato.
#Render pass
Un’operazione di rendering su una texture è denominata render pass. Per iniziare una render pass, si
utilizza il metodo beginRenderPass()
, che restituisce un sotto-builder dedicato alle operazioni di rendering:
const encoder = device.createCommandEncoder();
// Create a render passconst passEncoder = encoder.beginRenderPass({ colorAttachments: [{ view: ctx.getCurrentTexture().createView(), clearValue: { r: 1, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }]});
// ...build render specific commands...
// Terminate the render passpassEncoder.end();
// Build the command bufferconst commands = encoder.finish();
// Schedule the command execution on the GPUdevice.queue.submit([ commands]);
Una render pass viene inizializzata utilizzando un oggetto chiamato descrittore, che contiene diverse proprietà
necessarie per configurare l’operazione. Tra queste, la proprietà colorAttachments
specifica le texture su cui effettuare il rendering.
Poiché colorAttachments
è un array, è possibile effettuare il rendering su più texture contemporaneamente.
Le proprietà più importanti di un color attachment sono:
view
: indica la texture di output. Nell’esempio, viene impostata la texture associata alla canvas tramitectx.getCurrentTexture().createView()
.clearValue
: rappresenta il colore utilizzato per cancellare il contenuto della texture prima del rendering.loadOp
: determina l’operazione da eseguire sui dati già presenti nella texture.storeOp
: stabilisce se i dati generati durante la render pass devono essere memorizzati nella texture di output.
Per mantenere i dati esistenti nella texture, è sufficiente impostare loadOp
su load
,
omettendo il valore di clearValue
. Impostando clear
invece, i dati esistenti vengono sovrascritti con il valore di clearValue
.
Ogni render pass, infine, deve essere terminata con il comando end()
.
#Codice completo
Il codice completo che cancella il contenuto di una canvas tramite WebGPU è il seguente:
<html lang="en">8 collapsed lines
<head><style> #canvas { width: 384px; height: 384px; }</style></head><body><h1>Hello WebGPU</h1><canvas id="canvas" /><script type="module"> const device = await getDevice(); const canvas = document.getElementById("canvas"); canvas.width = Math.round(384 * window.devicePixelRatio); canvas.height = Math.round(384 * window.devicePixelRatio); const ctx = getCanvasContext(device, canvas); device.queue.submit([ clearRenderPass(device, 1, 0, 0) ]);
async function getDevice() {18 collapsed lines
// Get the adapter and device if (!navigator.gpu) { throw new Error('WebGPU not supported on this browser.'); }
const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
if (!adapter) { throw new Error('No appropriate GPUAdapter found.'); }
const device = await adapter.requestDevice(); if (!device) { throw new Error('Could not get the device!'); } return device; }
function getCanvasContext(device, canvas) {12 collapsed lines
// Get the WebGPU canvas context const ctx = canvas.getContext('webgpu'); if (!ctx) { throw new Error("Could not get 'webgpu' context from canvas!"); }
ctx.configure({ device, format: navigator.gpu.getPreferredCanvasFormat() });
return ctx; }
function clearRenderPass(device, r, g, b) { // Create an encoder const encoder = device.createCommandEncoder();
// Create a render pass const passEncoder = encoder.beginRenderPass({ colorAttachments: [{ view: ctx.getCurrentTexture().createView(), clearValue: { r, g, b, a: 1 }, loadOp: 'clear', storeOp: 'store' }] });
// Terminate the render pass passEncoder.end();
// Build the command buffer return encoder.finish(); }</script></body></html>
Il branch 01-hello
del repository GitHub contiene l’implementazione
discussa.
#Conclusioni e passi successivi
WebGPU rappresenta un importante passo avanti nell’evoluzione delle API grafiche per il web, offrendo un’interfaccia moderna e performante per l’interazione con la GPU.
In questo articolo abbiamo esplorato i concetti fondamentali, dall’accesso alla GPU al flusso di lavoro basato su code di comandi. Nel prossimo articolo vedremo come costruire una render pipeline per disegnare della geometria a schermo.