Skip to main content

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.

PuntoLineaTriangolo
Primitive geometriche

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.

-111-1
Le coordinate NDC rappresentano il sistema di riferimento da -1 a 1 sugli assi cardinali. Ridimensiona lo "schermo" per vedere come le coordinate si adattano.

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.

Ciascun vertice è transformato dal vertex shader, che per un triangolo viene eseguito 3 volte. Ogni cella evidenziata rappresenta uno dei pixel processati dal fragment shader. Trascina i vertici per modificare la forma del triangolo.

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

@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);
}

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.

@fragment
fn 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.

src/webgpu/utils.ts
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:

src/webgpu/triangle_pass.ts
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:

  1. Topologia: definisce come assemblare i vertici per formare le primitive.
  2. Vertex shader: funzione che trasforma i vertici.
  3. Fragment shader: funzione che calcola il colore dei pixel.
  4. 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:

src/webgpu/triangle_pass.ts
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:

src/webgpu/triangle_pass.ts
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

Un triangolo giallo su sfondo nero.

Riassumendo, la fase di inizializzazione prevede:

  1. La compilazione degli shader con createShaderModule().
  2. 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:

  1. La creazione di un encoder mediante createCommandEncoder().
  2. L’avvio della render pass attraverso beginRenderPass() con la configurazione della texture di output.
  3. L’attivazione della render pipeline tramite setPipeline() seguita dal disegno della geometria con il metodo draw().
  4. La conclusione della render pass chiamando end() e la generazione del command buffer con finish().
  5. 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:

src/webgpu/triangle_pass.ts
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 con i vertici rossi verde e blu

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 y1y_1 in corrispondenza di x1x_1 e un valore y2y_2 in corrispondenza di x2x_2. L’interpolazione lineare permette di calcolare il valore della funzione per qualsiasi xx compreso tra x1x_1 e x2x_2, 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.

x y
Modifica il punto x con gli slider. I valori intermedi sono ottenuti tramite interpolazione lineare.

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