Pipeline Direct3D 2169 Visite) DirectX 11
Questo articolo è una introduzione per chi inizia a programmare in DirectX fornendo concetti teorici che saranno dati per scontati nei tutorial che verranno.
Spiegherò brevemente la struttura per chi affronta DirectX per la prima volta.
L’immagine mostra la nuova pipeline Direct3D11, ossia i processi che generano le immagini che scorrono davanti al monitor. Questo processo viene chiamato Rendering.
DirectX lavora attraverso aree di memoria che vengono caricate e gestite attraverso i metodi messi a disposizione nell’API Direct3D, principalmente negli oggetti Device e DeviceContext che sono le interfacce messe a disposizione dei programmatori per comunicare con Direct3D. Se ad esempio vogliamo creare un cubo sullo schermo creeremo diversi buffer di memoria contenenti la struttura del cubo, il suo colore, la posizione che vogliamo abbia sullo schermo istante per istante.
A questo punto, sempre attraverso le API, vengono caricati gli shader, codice simil C che eseguono la logica per la trasformazione dei dati. Una volta impostati i vari buffer e gli shader viene dato il comando di Draw: i dati vengono fatti passare da Direct3D attraverso i vari stadi descritti nell’immagine per poi arrivare sotto forma di immagine bidimensionale in buffer finali di uscita (i cosiddetti Render Target). Questi possono essere inviati al monitor (operazione detta di presentazione) o utilizzati come input per altri cicli (esempio si può generare una scena 3D ed utilizzare l’immagine come foto per una seconda scena 3D). Le possibilità sono moltissime ed a volte complesse. In genere un rendering richiede 3 fasi:
- Creazione Buffer e Shader
- Impostazioni dei buffer e Shader nel Device
- Esecuzione operazione di Draw
La prima fase è quella più dispendiosa e che può richiedere da pochi secondi a diversi minuti (i tempi di caricamento dei vari livelli nei videogiochi sono dovuti a questa fase). Questo significa che questa fase va eseguita solo per eventi importanti. Le altre 2 fasi invece richiedono pochi millisecondi. Un programma Direct3D deve quindi caricare le risorse 1 volta ed utilizzarle il maggior numero di volte possibili. Questo tenendo conto di fattori come la memoria della scheda video ed in genere del computer in cui ci si trova.
Dalla versione 10 Direct3D gestisce ogni logica attraverso gli shaders. Uno shader è una funzione scritta in un codice simile al C (ma senza puntatori) che prende una struttura in ingresso e ne restituisce una in uscita. Uno shader viene caricato e compilato direttamente da Direct3D durante l’esecuzione del programma (tempi che in genere richiedono pochi secondi). Il codice shader è estremamente semplice, occupando pochi kb e si tende a caricarne quanti più possibili per alternarli tra i vari cicli di rendering. I dati di ingresso vengono presi dai vari buffer passati al Device.
Ci sono i seguenti tipi di buffer in ingresso:
- Vertex Buffer
- Index Buffer
- Constant Buffer
- Texture
Un oggetto 3D è composto da triangoli che un disegnatore crea, solitamente tramite editor, ed esporta in file dati. L’oggetto sarà ad esempio un cubo posizionato nel centro dell’asse cartesiano e parallelo agli assi.
Il triangolo contiene a sua volta 3 vertici e per ogni vertice del modello 3D verrà generata una struttura contenente la posizione XYZ ed un set di dati da utilizzare come ingresso (ad esempio il colore di quel vertice o che tipo di materiale vogliamo in quel punto). Prendiamo ad esempio un cubo: è composto da 6 quadrati, quindi da 12 triangoli (ogni quadrato lo dividiamo in 2) e di conseguenza 36 vertici. Ci sono però solo 8 vertici in un cubo di conseguenza salvo esigenze particolari, ben 28 vertici sarebbero duplicati se li salvassimo tutti e 36 in un buffer. Ecco che entrano in gioco i primi 2 buffer:
Nel vertex buffer vengono messi i vertici senza duplicazioni e nell’index buffer l’ordine con cui questi devono essere presi (un array di short o di interi). Questo per risparmiare memoria e numero di esecuzioni dello shader. Un rapido calcolo: un vertice contiene la posizione (3 float per XYZ) ed un colore (3 float per rosso, verde e blu). Ogni vertice occupa 24 byte (i 6 float) che moltiplicati per 36 fanno 864 byte. Usando anche gli index buffer abbiamo solamente 8 vertici (quindi 192 byte) e 36 indici (usando gli short sono 72 byte), per un totale di 264byte, meno di un terzo della memoria. Sommando tutti i modelli 3D sulla scena è un risparmio non indifferente sia di memoria che di esecuzione degli shader (il primo shader viene eseguito una volta per vertice).
I constant buffer e le texture sono 2 buffer contenenti strutture da utilizzare come variabili e che sono uniche per tutto il ciclo di rendering. Una volta lanciato il draw ogni shader può soltanto leggere dai buffer caricati. La differenza tra i 2 sta che i primo sono struttura contenenti dati (esempio la rotazione del cubo), l’altro invece contiene una immagine (ad esempio un logo da applicare al cubo). Le risorse sono utilizzabili indifferentemente dai vari tipi di shader. Ne esistono infatti ben 5 in Direct3D e vengono utilizzati in sequenza in modo che l’uscita di uno sia l’ingresso del successivo. Iniziamo a descrivere la pipeline in dettaglio.
Il primo shader ad essere creato è il Vertex Shader.
Un vertex shader prende in input 1 vertice (il dato grezzo caricato in memoria nel Vertex Buffer) per generare un vertice elaborato. Lo scopo principale del Vertex Shader è quello di trasformare i vertici del modello dalla posizione creata dal grafico a quella desiderata. Dato che una risorsa deve essere creata 1 sola volta non si modifica nel Vertex Buffer ma si lascia il compito al Vertex Shader.
Le formule matematiche della grafica 3D permettono di creare algoritmi che applicati ai vertici trasformano l’intera struttura geometrica. Questo è importante perché le schede video prendono tanti vertici contemporaneamente lavorandoli in parallelo. Di conseguenza nessun vertice può vedere gli altri ma solo se stesso ed i dati che si porta dietro. Tutti i vertici condividono i Constant Buffer e le Texture (ma solo in lettura).
Oltre alla posizione vengono calcolati anche dati da utilizzare negli shader succesivi. Dopo questo passaggio tutti i vertici sono stati elaborati e vengono mandati allo shader successivo: l’Hull Shader, la novità introdotta da Direct3D11.
Anche questo è un codice shader ma a differenza del vertex prende in input una patch (un insieme ordinato di vertici ad esempio un triangolo o una griglia) per restituire parametri che indicheranno in quante parti spezzare la patch ed in che proporzione. Il nostro cubo formato da 36 triangoli ad esempio potrà essere suddiviso in 10000 triangoli, magari accumulandone il numero in cima e non alla base. La divisione viene fatta nella fase di tesselazione che è controllabile unicamente attraverso i parametri restituiti dall’Hull Shader.
Si passa al secondo nuovo shader, il Domain Shader. Il Domain Shader prende in input una patch (quella che generata dalla tessellazione) e restituisce 1 vertice elaborato tramite la patch. La struttura permette di definire una regola che sarà indipendente dal numero effettivo di triangoli generati dalla patch. Nel nostro esempio avremo un insieme di Patch da cui genereremo 1 vertice ciascuna per definire ad esempio una deformazione. Il cubo che dopo l’Hull è ormai costituito da 10000 triangoli ora è deformato secondo una regola da noi stabilita. Il vertice generato viene quindi utilizzato dal successivo step: il Geometry Shader, già presente in Direct3D10.
Ogni triangolo generato dal Domain Shader viene preso in input dal Geometry Shader, modificato e quindi aggiunto all’elenco dei triangoli da mandare a video oppure scartato. In questa fase è possibile anche aumentare il numero di triangoli restituendo più triangoli per ognuno in ingresso. Il processo però non è veloce quanto quello fatto dal Tessellator (che lo esegue interamente tramite l’Hardware e non come il Geometry che deve comunque eseguire codice specifico per ogni triangolo) ma è molto più preciso.
Ora Direct3D ha i triangoli definitivi e può riempirli (processo di Rastering) generando i pixel al suo interno. Per ogni pixel prende le strutture dati dei 3 vertici, ne fa una media in base alla distanza del pixel dai vertici ed esegue l’ultimo codice shader: il Pixel Shader. Il processo di Rastering può essere controllando settando una proprietà del DeviceContext, la RasterState, che permette di impostare alcuni parametri.
Un Pixel Shader prende in input la struttura dati generata dal Rastering per restituire il colore finale. Qui in genere vengono fatti i calcoli di illuminazione o applicate le Texture, immagini che coprono il modello per dare il colore nel dettaglio. Il pixel in uscita sarà poi confrontato con quello già esistente nella stessa posizione nel processo di Merge per dare il colore definitivo. Quest’ultima fase viene fatta settando delle proprietà nel DeviceContext ed esattamente:
- BlendState : ogni pixel viene modificato in base a quello esistente (ad esempio facendo la media tra i 2 si ottiene una trasparenza)
- DepthStencilState: se il cubo si trova dietro ad un altro cubo verrà coperto, tramite il DepthState si può personalizzare tale processo, ad esempio utilizzando il cubo non per essere visualizzato ma per creare una zona in cui nessun pixel dovrà essere scritto (magari per creare una maschera di dissolvenza)
DirectX gestirà tutte le fasi di input ed output dagli shader, quello che servirà fare sarà appunto scrivere un metodo per ogni shader che prenda in input ed in output i tipi corretti di dati.
L’intero processo viene eseguito per tutti i vertici del modello in un solo passaggio. Ripetuto per tutti i modelli avremo l’immagine finale che potrà essere mandata a video o in un altro buffer per un'altra fase (i giochi moderni ormai usano anche diverse decine di fasi).
Il lavoro del programmatore diventa quindi quello di far arrivare ogni vertice del modello 3D al Pixel Shader per restituirne il colore. Utilizzare Direct3D non è complesso in quanto sono pochi alla fine i concetti da sapere, lo è invece gestire la pipeline per creare effetti grafici. Un motore grafico di quelli più conosciuti utilizza migliaia di shader basati su formule matematiche spesso complesse e molto elaborate. La sfida per un programmatore grafico è quella di creare una resa visiva di qualità scrivendo Shader leggeri e flessibili (gli shader vengono eseguiti dalla scheda video miliardi di volte al secondo, un ritardo di una frazione di millisecondo comporta rallentamenti significativi).
La pipeline è molto flessibile. Si possono ad esempio non utilizzare quasi tutti gli shader lasciando un comportamento di default. Il geometry, l’hull ed il domain non sono infatti obbligatori. Anche il pixel shader non è obbligatorio in quanto è possibile inviare i vertici elaborati non al render target ma ad un Vertex Buffer speciale (l’Output Stream) che può fungere da contenitore per i vertici. Può infatti essere utile conservare vertici già elaborati per riutilizzarli più volte.
Ogni risorsa in Direct3D viene gestita attraverso interfacce che le rappresentano e le controllano. L’oggetto più importante è il Device che viene creato prima di tutti gli altri e che si occupa di creare risorse e shader. Questo viene agganciato ad una finestra di windows tramite il suo codice univoco, l’handle. Dal device vengono creati gli shader, le risorse ed il DeviceContext che si occupa della gestione delle risorse (non era presente in DirectX10 in quanto il Device si occupava sia della creazione che della gestione).
Le risorse vengono caricate da memoria o da file attraverso i metodi forniti dall’API. E’ importante ricordare che a seconda del tipo e delle modalità possono essere in lettura e/o in scrittura dalla CPU (tramite dei metodi per farsi restituire il loro contenuto) che dalla GPU (utilizzabile dagli shader). Ci sono 4 modalità:
- Default: possono essere letti e scritti solo dalla GPU attraverso la pipeline
- Dynamic: possono essere letti dalla GPU ma ci può scrivere la cpu
- Immutable: possono essere letti solo dalla GPU, l’unico momento in cui vengono inseriti dati è in fase di creazione
- Staging: è possibile scrivere e leggere tramite la CPU ma per poterla utilizzare nella pipeline deve essere copiata su un’altra risorsa
I render target ad esempio sono risorse di tipo Default, mentre Constant Buffer invece sono Dynamic.
A corredo di tutto questo ci sono diversi metodi ed interfacce per interrogare le risorse, gli shader o monitorare lo stato di rendering.
Sulla struttura di Direct3D non c’è molto altro da aggiungere. Scopo di Microsoft è stato proprio quello di ridurre al minimo la complessità con pochi oggetti semplici da utilizzare.
I tutorial che andrò a realizzare saranno un avvio,spiegando come utilizzare i componenti di Direct3D e le basi teoriche da cui partire. I primi saranno sulla creazione e gestione di Shader e risorse per poi affrontare tecniche sempre più complesse.