Pipeline di rendering
Nell’articolo precedente abbiamo visto come configurare una render pass per cancellare il contenuto di una texture con un colore di sfondo fisso. In questo articolo vedremo come usare le pipeline di rendering (o render pipelines): oggetti che, agganciati a una render pass, permettono di disegnare della geometria.
#Primitive geometriche
Le GPU sono in grado di disegnare fondamentalmente tre tipi di primitive: punti, linee e triangoli. Curve e forme complesse sono approssimate come combinazione delle primitive di base.
Le primitive sono composte dai vertici, ciascuno definito dalle loro coordinate. Le coordinate sono espresse in un sistema di riferimento detto Normalized Device Coordinates (NDC). In questo sistema le coordinate vanno da -1 a 1 per ciascuna dimensione, con l’origine al centro dello schermo.
Le coordinate NDC sono indipendenti dalla risoluzione dello schermo e dal rapporto d’aspetto. La scheda grafica si occupa di convertire le coordinate NDC nelle coordinate dello schermo (o window coordinates) che invece sono quelle effettive del dispositivo.
#Shaders
Uno shader è un programma che viene eseguito sulla GPU. Esistono due tipi di shader per il rendering: il vertex shader e il fragment shader. Il vertex shader è programma che viene eseguito per ciascun vertice delle primitive che si vogliono disegnare. Il suo compito è quello di fornire le coordinate dei vertici nel sistema NDC.
Il fragment shader, invece, è invocato per ciascun pixel (detto fragment) che compone le primitive. Il suo scopo è quello di calcolare il colore di ciascun pixel. Sia vertex che fragment shader vengono eseguiti in modo parallelo su vertici e pixel.
#Linguaggio WGSL
Gli shader sono scritti in un linguaggio chiamato WebGPU Shading Language (WGSL):
@vertexfn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { let pos = array<vec2f, 3>( vec2f(-0.8, -0.8), vec2f(0.8, -0.8), vec2f(0, 0.8) );
return vec4f(pos[index], 0.0, 1.0);}
Il vertex shader è una funzione definita con la parola chiave fn
e annotata con @vertex
. Nell’esempio la funzione vs
dichiara un parametro index
di tipo u32
, un numero intero senza segno a 32 bit. L’annotazione @builtin(vertex_index)
indica che
il parametro rappresenta l’indice (zero-based) del vertice. Il tipo di ritorno invece è vec4f
, un vettore di 4 componenti a precisione singola.
L’annotazione @builtin(position)
indica che il valore di ritorno rappresenta la posizione del vertice in Normalized Device Coordinates.
Il metodo inizia dichiarando una variabile pos
di tipo array<vec2f, 3>
, che contiene le coordinate di 3 vertici in NDC. A differenza di
Javascript, in WGSL la parola chiave let
si usa per dichiarare costanti, mentre per le variabili si usa var
.
Le coordinate dei vertici vengono prese indicizzando l’array pos
in base al parametro index
, ragione per cui il
vertex shader di questo esempio è predisposto per disegnare un singolo triangolo. Poichè il tipo di ritorno è vec4f
,
il vettore a due componenti pos[index]
viene espanso in un vettore a quattro componenti impostando la terza coordinata a 0 e la quarta ad 1.
La terza e quarta componente intervengono quando si fa rendering di geometria 3d, ma in questo momento non sono importanti.
@fragmentfn fs() -> @location(0) vec4f { return vec4f(1, 1, 0, 1);}
Il fragment shader è una funzione annotata con @fragment
. In questo semplice esempio non vengono usati parametri in input.
Il tipo di ritorno dipende da quante e quali texture vengono scritte in output. Nell’esempio assumiamo di scrivere
in una sola texture in formato a virgola mobile, quindi usiamo il tipo vec4f
annotato con @location(0)
.
Un formato di texture adatto per questo tipo di valore è ad esempio rgba32float
.
Il fragment shader ritorna un colore costante vec4f(1, 1, 0, 1)
, con i canali rosso e verde a 1 e il canale blu a 0; la quarta componente
rappresenta la trasparenza, dove 1 è completamente opaco.
#Compilazione
WGSL è un linguaggio ad alto livello, quindi deve essere compilato prima di poter essere eseguito dalla GPU. Il codice viene tradotto in un linguaggio di livello intermedio, come SPIR-V (Vulkan, OpenGL, OpenCL), Metal Shading Language (Apple) o HLSL (Microsoft), che a sua volta viene compilato in codice macchina (ISA, o Instruction Set Architecture). Il codice macchina è specifico per ciascun vendor, per esempio NVIDIA, AMD o Intel.
export async function compileShader(device: GPUDevice, code: string) { // Create shader module device.pushErrorScope('validation');
const shaderModule = device.createShaderModule({ code });
const errors = await device.popErrorScope();
if (errors) { throw new Error('Could not compile shader!'); }
return shaderModule;}
La funzione compileShader
compila il codice WGSL utilizzando il metodo createShaderModule()
.
In caso di errori di compilazione questi vengono registrati nello scope di errore validation
.
Tramite pushErrorScope
e popErrorScope
è possibile esaminare lo scope di validazione così da sollevare un’eccezione in presenza di errori.
L’oggetto restituito shaderModule
è un riferimento al codice compilato, che può essere agganciato a una pipeline di rendering.
Per sviluppare gli shader, è conveniente creare delle funzioni apposite che restituiscono il codice:
export class TrianglePass { static function shaderCode() { // language=WGSL return ` ```wgsl @vertex fn vs(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f { let pos = array<vec2f, 3>( vec2f(-0.8, -0.8), vec2f(0.8, -0.8), vec2f(0, 0.8) );
return vec4f(pos[index], 0.0, 1.0); }
@fragment fn fs() -> @location(0) vec4f { return vec4f(1, 1, 0, 1); } ``` `; }}
Rispetto all’import di file esterni, questa tecnica permette di usare a pieno JavaScript per generare il codice dinamicamente. Parti duplicate tra più shader possono essere messe a fattor comune, inoltre è possibile variare il codice in base a dei parametri, ad esempio il formato della texture in output.
#Rendering
Per utilizzare gli shader è necessario creare una pipeline di rendering, che è definita principalmente da:
- Topologia: definisce come assemblare i vertici per formare le primitive.
- Vertex shader: funzione che trasforma i vertici.
- Fragment shader: funzione che calcola il colore dei pixel.
- Layout: definisce come i dati necessari agli shader sono organizzati.
Dopo aver generato il codice WGSL e compilato gli shader, è possibile creare la pipeline di rendering utilizzando il metodo
createRenderPipeline()
, come mostrato nell’esempio seguente:
import { compileShader } from "@/webgpu/utils.ts";
export class TrianglePass { private readonly device: GPUDevice; private readonly pipeline: GPURenderPipeline;
static async create(device: GPUDevice, format: GPUTextureFormat) { const code = TrianglePass.shaderCode(); const module = await compileShader(device, code);
// Create render pipeline const pipeline = device.createRenderPipeline({ layout: "auto", vertex: { module, entryPoint: "vs" }, fragment: { module, targets: [{ format }], }, primitive: { topology: 'triangle-list' } });
return new TrianglePass(device, pipeline); }
private constructor( device: GPUDevice, pipeline: GPURenderPipeline ) { this.device = device; this.pipeline = pipeline; }
static shaderCode() { // ... }}
Analizziamo ora le diverse opzioni utilizzate nella creazione della pipeline: nei casi più semplici, per la proprietà layout
si può usare la stringa auto
, lasciando che WebGPU scelga in automatico una struttura adeguata.
Le proprietà vertex
e fragment
definiscono module
, ossia il codice WGSL compilato, e opzionalmente un entryPoint
,
che è il nome della funzione specifica da invocare. Questa possibilità è particolarmente utile quando lo stesso modulo contiene
più di una funzione annotata con @vertex
o @fragment
. La proprietà fragment
definisce inoltre un array targets
contenente
almeno un oggetto che specifica il formato di texture in output.
La topologia triangle-list
indica di formare un triangolo ogni 3 vertici. Altre topologie sono point-list
(1 punto per vertice), line-list
(1 segmento ogni 2 vertici), line-strip
(dopo il primo vertice, un segmento per ogni vertice), e triangle-strip
(dopo i primi due vertici, un triangolo per ogni vertice).
#Utilizzo della render pipeline
Come visto in precedenza, per effettuare il rendering è necessario creare una render pass:
export class TrianglePass { private readonly device: GPUDevice; private readonly pipeline: GPURenderPipeline;
// ...
render(texture: GPUTexture) { // Create root encoder const commandEncoder = this.device.createCommandEncoder();
// Create a render pass const passEncoder = commandEncoder.beginRenderPass({ colorAttachments: [{ view: texture.createView(), loadOp: 'load', storeOp: 'store' }] }); passEncoder.setPipeline(this.pipeline); passEncoder.draw(3);
// Terminate the render pass passEncoder.end();
// Build the command buffer return commandEncoder.finish(); }}
La render pipeline viene agganciata alla render pass tramite il metodo setPipeline()
; esistono diversi metodi per disegnare geometria,
ma il più semplice è draw()
, che prende come argomento il numero di vertici da disegnare. Fatto questo, è possibile terminare la render pass,
per poi creare il command buffer da inviare alla coda di rendering.
#Risultati
Il risultato di tutti questi passaggi è un triangolo giallo disegnato sulla canvas. 🎉

Un triangolo giallo su sfondo nero.
Riassumendo, la fase di inizializzazione prevede:
- La compilazione degli shader con
createShaderModule()
. - La creazione di una pipeline di rendering con
createRenderPipeline()
specificando vertex shader, fragment shader, formato della texture in output e topologia desiderata.
Per quanto riguarda il rendering abbiamo:
- La creazione di un encoder mediante
createCommandEncoder()
. - L’avvio della render pass attraverso
beginRenderPass()
con la configurazione della texture di output. - L’attivazione della render pipeline tramite
setPipeline()
seguita dal disegno della geometria con il metododraw()
. - La conclusione della render pass chiamando
end()
e la generazione del command buffer confinish()
. - L’invio del buffer di comandi alla coda di rendering con
submit()
.
È possibile trovare il sorgente completo di questo esempio su GitHub.
#Chrome e la gestione dei colori
Durante la scrittura di questo articolo, mi sono imbattuto in uno strano problema: il triangolo disegnato sulla canvas non era esattamente giallo (#FFFF00) come previsto, ma di un colore leggermente diverso (#FBFF25). Dopo alcune ricerche, ho scoperto che la causa è la gestione dei colori in Chrome. Il browser infatti applica automaticamente una correzione dei colori, alterando il risultato finale.
Per risolvere questo problema, è possibile visitare l’indirizzo chrome://flags
e impostare l’opzione Force color profile
su
sRGB
invece di Default
. Mozilla Firefox, al contrario, non presenta questo comportamento: i colori sono
gestiti in modo più coerente con le aspettative.
#Inter-stage variables
Il vertex shader, oltre alla posizione, può restituire valori aggiuntivi che vengono trasmessi al fragment shader.
Questi valori aggiuntivi sono chiamati inter-stage variables. Nel branch 02-shaders-interstage
del
repository GitHub, la classe TrianglePass
viene
aggiornata per aggiungere un colore distinto a ciascun vertice:
export class TrianglePass { static function shaderCode() { // language=WGSL return ` ```wgsl struct VertexOutput { @builtin(position) pos: vec4f, @location(0) color: vec4f }
@vertex fn vs(@builtin(vertex_index) index: u32) -> VertexOutput { let pos = array<vec2f, 3>( vec2f(-0.8, -0.8), vec2f(0.8, -0.8), vec2f(0, 0.8) ); let col = array<vec4f, 3>( vec4f(1, 0, 0, 1), vec4f(0, 1, 0, 1), vec4f(0, 0, 1, 1) );
var vertex: VertexOutput; vertex.pos = vec4f(pos[index], 0.0, 1.0); vertex.color = col[index]; return vertex; }
struct FragmentInput { @location(0) color: vec4f }
@fragment fn fs(fragment: FragmentInput) -> @location(0) vec4f { return fragment.color; } ``` `; }}
Il vertex shader ora restituisce una struttura chiamata VertexOutput
, che include sia la posizione che il colore del vertice.
Dall’altra parte, il fragment shader riceve una struttura denominata FragmentInput
che contiene il colore del pixel.
Un aspetto fondamentale da comprendere è che il collegamento delle inter-stage variables tra il vertex e il fragment shader
avviene attraverso le annotazioni @location()
. In particolare, l’annotazione @location(0)
associa (binding)
l’inter-stage variable color
definita nel vertex shader con quella utilizzata nel fragment shader. Questo collegamento
si basa esclusivamente sul numero di locazione e non sul nome delle variabili.
Il risultato finale è il seguente:

Un triangolo su sfondo nero con un vertice rosso, verde e blu. L’interno del triangolo è un gradiente di colore risultante dall’interpolazione bilineare dei colori dei vertici.
Nonostante il vertex shader definisca solo 3 colori (uno per ciascun vertice), l’interno del triangolo viene riempito con un gradiente di colore. Questo accade perché le inter-stage variables, per default, vengono interpolate linearmente tra i vertici, generando così il gradiente.
È possibile cambiare la modalità di interpolazione tramite l’annotazione @interpolate
, che accetta i
valori flat
, linear
e perspective
(il valore predefinito):
struct VertexOutput { @builtin(position) pos: vec4f, @location(0) @interpolate(linear) color: vec4f}
#Interpolazione lineare
L’interpolazione lineare è un modo per stimare un valore intermedio tra due valori noti. Immagina una funzione 1D che ha un valore in corrispondenza di e un valore in corrispondenza di . L’interpolazione lineare permette di calcolare il valore della funzione per qualsiasi compreso tra e , tracciando una linea retta tra i due valori. Questa stessa idea si estende naturalmente a più dimensioni e a diversi tipi di attributi, tra cui i colori.
#Conclusioni e passi successivi
In questo articolo abbiamo visto come disegnare geometria con WebGPU, partendo dalle primitive fino alla creazione di una pipeline di rendering. Abbiamo introdotto i concetti di vertex e fragment shader, applicandoli per disegnare un triangolo colorato.
Nel prossimo articolo, vedremo come manipolare la geometria mediante trasformazioni geometriche.