Un videogioco moderno disegna decine o centinaia di volte al secondo scene composte da milioni di triangoli. Ci riesce perché la scheda video (GPU) esegue sempre la stessa sequenza di passaggi, la pipeline di rasterizzazione, su una quantità enorme di dati in parallelo. Conoscere le fasi della pipeline aiuta a capire da dove nascono i problemi di prestazioni e molti artefatti visivi, anche se non si scrive codice grafico.
Panoramica delle fasi
- Input dei vertici: la GPU legge vertici e indici dalla memoria.
- Vertex shader: trasforma ogni vertice.
- Assemblaggio delle primitive e clipping: i vertici vengono raggruppati in triangoli; ciò che è fuori dall'inquadratura viene scartato o tagliato.
- Rasterizzazione: ogni triangolo viene convertito nell'insieme di pixel che copre.
- Fragment shader: calcola il colore di ciascun frammento.
- Test e fusione: profondità, trasparenza e scrittura nell'immagine finale.
Alcune fasi sono programmabili (gli shader), altre sono fisse, cioè eseguite da hardware specializzato con poche impostazioni.
Il vertex shader
Il vertex shader è un piccolo programma eseguito una volta per ogni vertice. Il suo compito minimo è trasformare la posizione del vertice dalle coordinate dell'oggetto alle coordinate di clip, moltiplicandola per la matrice Model-View-Projection. Può anche calcolare e passare alle fasi successive altri dati: normali trasformate, coordinate di texture, colori.
Clipping e culling
Dopo il vertex shader, i triangoli completamente fuori dal volume visibile vengono scartati; quelli che lo attraversano vengono tagliati. Segue la divisione prospettica e la trasformazione nelle coordinate dello schermo.
In questa fase avviene spesso anche il backface culling: i triangoli visti da dietro, determinati dall'ordine dei loro vertici sullo schermo, vengono eliminati. Per un oggetto chiuso, circa metà dei triangoli è rivolta all'indietro, quindi il risparmio è notevole.
La rasterizzazione
Questa fase fissa determina quali pixel sono coperti da ciascun triangolo. Per ogni pixel, l'hardware verifica se il suo centro cade all'interno del triangolo, usando tre semplici funzioni di bordo, una per lato.
Per ogni pixel coperto viene generato un frammento, cioè un candidato pixel con i dati interpolati dai tre vertici: profondità, coordinate di texture, normali, colori. L'interpolazione tiene conto della prospettiva, altrimenti le texture apparirebbero deformate sulle superfici inclinate.
Il fragment shader
Il fragment shader (detto anche pixel shader) viene eseguito per ogni frammento. Qui si calcola l'aspetto della superficie: si leggono le texture, si applicano i modelli di illuminazione, si combinano materiali ed effetti. È spesso la fase più costosa, perché una scena in alta risoluzione genera milioni di frammenti per fotogramma, e su ciascuno possono essere eseguite decine di operazioni.
Un concetto importante è l'overdraw: se più oggetti si sovrappongono, lo stesso pixel può essere elaborato più volte, sprecando calcoli per superfici che poi verranno nascoste.
Il test di profondità (z-buffer)
Come fa la GPU a sapere quale superficie è davanti? Usa lo z-buffer (o depth buffer), un'immagine della stessa dimensione dello schermo che memorizza, per ogni pixel, la profondità della superficie più vicina disegnata finora.
Per ogni nuovo frammento:
- se è più vicino del valore memorizzato, il colore viene scritto e lo z-buffer aggiornato;
- se è più lontano, il frammento viene scartato.
Il vantaggio è che gli oggetti possono essere disegnati in qualunque ordine. Molte GPU eseguono il test di profondità prima del fragment shader quando possibile (early-z), evitando di calcolare il colore di frammenti che sarebbero comunque nascosti. Per questo disegnare gli oggetti opachi dal più vicino al più lontano migliora le prestazioni.
Trasparenza e fusione
Le superfici trasparenti sono un caso difficile: lo z-buffer memorizza una sola profondità per pixel, quindi non può gestire più strati semitrasparenti. La soluzione abituale è:
- disegnare prima tutti gli oggetti opachi;
- ordinare gli oggetti trasparenti dal più lontano al più vicino;
- disegnarli con la fusione alfa, senza aggiornare lo z-buffer.
L'ordinamento per oggetto non è sempre corretto, per esempio con oggetti trasparenti che si intersecano: da qui nascono molti degli errori di trasparenza visibili nei giochi, come foglie o vetri che appaiono davanti a ciò che dovrebbero coprire.
Antialiasing
La rasterizzazione campiona ogni pixel in un solo punto, quindi i bordi dei triangoli appaiono seghettati. Le tecniche principali sono:
| Tecnica | Principio | Costo |
|---|---|---|
| MSAA | più campioni di copertura per pixel, shader eseguito una volta | medio |
| SSAA | tutta l'immagine calcolata a risoluzione più alta e poi ridotta | alto |
| FXAA e simili | filtro sull'immagine finale che individua e ammorbidisce i bordi | basso |
| TAA | combina campioni di fotogrammi successivi leggermente spostati | basso-medio, possibile scia |
Dove si perdono le prestazioni
Nel mio lavoro, quando analizzo una scena lenta, la domanda iniziale è sempre quale fase sia il collo di bottiglia:
- troppi vertici: modelli troppo dettagliati per la distanza a cui sono visti;
- troppi frammenti: risoluzione alta, overdraw, molte superfici trasparenti sovrapposte;
- shader troppo pesanti: calcoli costosi ripetuti per ogni pixel;
- troppe chiamate di disegno: molti oggetti piccoli inviati separatamente, che sovraccaricano la CPU più della GPU.
Un test semplice: ridurre la risoluzione. Se le prestazioni migliorano molto, il limite è nella fase dei frammenti.
In sintesi
- La pipeline trasforma vertici in pixel con fasi programmabili (shader) e fasi fisse.
- Il vertex shader trasforma i vertici; il fragment shader calcola il colore di ogni frammento.
- La rasterizzazione stabilisce quali pixel copre ogni triangolo e interpola i dati dei vertici.
- Lo z-buffer risolve la visibilità; la trasparenza richiede ordinamento e fusione.
- Per ottimizzare bisogna individuare la fase che fa da collo di bottiglia.