Skip to main content

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.

WebGPU(specification)Dawn / wgpu / Webkit(implementations)Direct3D 12(Microsoft)Metal(Apple)Vulkan(Linux)OS drivers

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 generiche
console.log(adapter.info.vendor);
console.log(adapter.info.architecture);
console.log(adapter.info.device);
console.log(adapter.info.description);
// Limiti
console.log(adapter.limits.maxTextureDimension2D);
// Features
console.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”:

Abilitazione per Chrome

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:

#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:

#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:

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:

  1. La dimensione a schermo dell’elemento, definita tramite CSS.
  2. La dimensione della texture associata alla canvas, determinata dagli attributi HTML width e height.

È 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:

128x128 CSS
128x128 Real

#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:

  1. Creazione di uno o più buffer di comandi.
  2. 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 buffer
const commandBuffer = commandEncoder.finish();
// Schedule the command execution on the GPU
device.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 pass
const 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 pass
passEncoder.end();
// Build the command buffer
const commands = encoder.finish();
// Schedule the command execution on the GPU
device.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:

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.