In questo articolo verranno spiegati gli shader introducendo il Vertex ed il Pixel Shader. Mostriamo un semplicissimo codice Shader. Questo codice prende un Vertice dotato di posizione e normale, lo posiziona nello spazio utilizzando anche una telecamera e lo colora utilizzando una formula di illuminazione (formula di Lambert).
cbuffer data :register(b0)
{
float4x4 transform;
float4x4 world;
float4 lightDirection;
};
struct VS_INPUT
{
float4 position : POSITION;
float3 normal : NORMAL;
};
struct VS_OUTPUT
{
float4 position : SV_POSITION;
float3 normal: NORMAL;
};
VS_OUTPUT VSMain( VS_INPUT input )
{
VS_OUTPUT Output;
Output.position = mul(transform,input.position);
Output.normal=mul(world,input.normal);
return Output;
}
float4 PSMain( VS_OUTPUT input ) : SV_TARGET
{
float diff = dot(lightDirection.xyz,input.normal);
return float4(diff, diff,diff,1);
}
Com’è possible vedere si tratta di strutture e funzioni che possono essere scritte in un comune file di testo. Direct3D si occuperà di compilarle ed eseguirle. Nella porzione di codice si possono distinguere 2 funzioni: VSMain e PSMain, rispettivamente il nostro Vertex Shader e Pixel Shader le due funzioni che saranno eseguite dal Device. Uno shader è molto simile ad un codice C ma molto limitato in complessità e con diverse caratteristiche specializzate per lavorare su vettori (il 3D è matematica vettoriale). Questo linguaggio prende il nome di HLSL (High Level Shading Language).
Sono definiti i principali tipi (float, int, bool, double ma anche half che è un tipo in virgola mobile a 16bit) e per tutti è possibile utilizzarne una forma vettoriale.
Float3 ad esempio è un vettore di 3 valori (utilizzabile come una struttura avente x,y,z) mentre un Float4x4 è una matrice con valori m11,m12…m44. Sono inoltre definiti tutti gli operatori primitivi quali addizione, sottrazione etc, che funzionano anche tra vettori della stessa dimensione o tra vettori e scalari.
Il linguaggio definisce anche un folto set di metodi per le operazioni matematiche (esempio dot che esegue un prodotto tra vettori, equivalente al calcolo dell’angolo tra i 2). Questo articolo non vuole entrare in dettaglio e consiglio la lettura della SDK o dell’articolo “De Rerum Shader” presente nel sito.
Una particolarità importante è la Semantica. Le variabili possono essere accompagnate da una semantica. Per semantica si intende un sistema di identificazione che permette al Device di riconoscere una determinata risorsa.
Ad esempio
Cbuffer data:register(b0)
Indica che il buffer data conterrà ciò che sarà caricato nello Slot 0 dei Constant Buffer del DeviceContext. Questo tipo di semantica è destinato alle risorse che vengono inserite all’interno di slot opportuni (che rappresentano di fatto zone del processore grafico). Ci sono diversi tipi di registri dedicati a diversi tipi di risorse (texture, sampler e simili). In caso di assenza il numero sarà dato in base alla posizione nel codice.
Un secondo tipo di semantica è quello relativo alle strutture di input ed output degli shader.
Ad esempio
struct VS_INPUT
{
float4 Pos : POSITION;
float3 Nrm : NORMAL;
};
A fianco al nome della variabile è indicato il nome semantico. La struttura che arriva in ingresso dal Vertex Shader dovrà rispettare la semantica definita nel DeviceContext al momento del rendering ed i valori che passeranno tra uno shader e l’altro saranno legati sempre attraverso il nome che daremo alle semantiche, mai a quello delle variabili. Questo perché possiamo anche utilizzare Shader dichiarati in file diversi ed utilizzare strutture con nomi completamente diversi. Neanche l’ordine delle variabili nelle strutture è importante ma solo la dimensione ed il nome. Se quindi in uscita dal Vertex Shader metteremo un float3 di semantica chiamata VETTORE allora potremo utilizzarlo nello shader successivo se dichiaremo un vettore float3 con semantica uguale. E’ inoltre indifferente come definiamo l’ingresso e l’uscita, se come struttura o come parametri diretti (il pixel shader nell’esempio deve restituire una struttura di un solo valore con semantica SV_TARGET e quindi non serve creare una struttura).
Alcune semantiche sono tuttavia speciali ed in alcuni casi obbligatorie. SV_TARGET indica ad esempio che il valore sarà mandato al render target, SV_POSITION indica invece il vettore che fungerà da posizione del vertice. Nell’SDK sono definiti tutti i vettori speciali che sono molto utili in alcune situazioni, altrimenti definiremo i nostri con i nomi che più ci piacciono.
Per il Vertex Shader è obbligatorio restituire un vettore con semantica SV_POSITION mentre per il Pixel Shader almeno un vettore SV_TARGET o SV_TARGET0.
Il linguaggio HLSL prevede la possibilità di scrivere funzioni e di includere file (come in C basta usare la direttiva #include “nomeFile”). L’estensione non è importante, di solito si utilizza proprio hlsl.
Il linguaggio HLSL supporta infine la compilazione condizionale, ad esempio
#ifdef ALFA
……
#else
…..
#endif
A seconda se ALFA è definita o meno il compilatore sceglierà quale porzione compilare.
Utilizzare gli Shader
Gli shader vengono in genere scritti in file di testo. Questi devono poi essere compilati e quindi caricarti. La compilazione è molto semplice e veloce, in genere bastano pochissimi secondi anche per codice molto complesso. Gli shader possono essere messi tutti in uno stesso file o messi in file diversi. L’importante è che tutto ciò che è utilizzato dalla funzione shader sia presente nel file o raggiungibile tramite include. Gli shader possono essere raccolti in una struttura più complessa chiamata Effect che prevede la possibilità di definire sia gli shader che come questi debbano essere inviati al Device. La gestione tramite Effect viene fatta attraverso un Framework opzionale inserito nella SDK ed è semplificata rispetto a quella normale. Il difetto però è che essa si basa comunque sulla gestione senza Effect e la semplicità viene pagata con un elevato numero di istruzioni eseguite inutilmente. Per questo ho preferito utilizzare direttamente gli shader che fanno parte delle API base di Direct3D. Nel compilare lo shader dovremo caricare il file e dire al compilatore il nome della funzione, questo per ogni shader contenuto nel file. Nell’esempio riportato salveremo il codice in un file di testo e lo compileremo 2 volte, uno per creare il Vertex Shader, l’altra per creare il Pixel Shader. Ogni volta indicheremo al compilatore il nome della funzione da utilizzare e la versione shader. Le versioni utilizzabili in Direct3D11 sono
- vs_x_y: Vertex Shader
- ps_x_y: Pixel Shader
- gs_x_y: Geometry Shader
- ds_x_y: Domain Shader
- hs_x_y: Hull Shader
- cs_x_y: Compute Shader
Dove X e Y sono le versioni. A seconda dell’hardware potrete usare al massimo
- Level9.1 : 2_0 o 4_0_level_9_1
- Level9.2 : 2_0 o 4_0_level_9_1
- Level9.3 : 2_0 o 4_0_level_9_3
- Level10 : 4_0
- Level10.1 : 4_1
- Level11 : 5_0
I livelli 4_0_Level equivalgono al vecchio 2.0. La differenza tra il 9_3 ed il 9_1 è che con il 9_3 sono supportati 4 render target (possibilità di riempire fino a 4 target contemporaneamenti). La versione 4_0 e superiori ne supportano fino a 8.
Ci sono diversi modi per compilare un codice shader, quello da preferire è tramite il metodo D3DCompile che prende in input un array di char (il contenuto del file in pratica) o il metodo utility D3DX11CompileFromFileA che prende direttamente in input il path del file
ID3DBlob* code;
ID3DBlob* error;
HRESULT hr= D3DCompile(memblock,size,filename,NULL,NULL,entryPoint,model,0,0,&code,&error);
HRESULT hr = D3DX11CompileFromFileA(filename,NULL,NULL,entryPoint,model,0,0,NULL,&code,&error,NULL);
Al metodo vengono passati i seguenti parametri
- File: il file di testo caricato (solo per il D3DCompile)
- Dimensione file: la dimensione del file (solo per il D3DCompile)
- Path del file: utilizzato per l’include o quello reale nel caso si utili il CompileFromFile
- Macro: un array di D3DXMacro che permettono di dare parametri di compilazione (o NULL di default)
- Include: una classe che eredita da ID3D11Include, utilizzata in caso di #include o NULL. La classe che erediterà da Include dovrà caricare a sua volta i file e restituirli
- EntryPoint: il nome del metodo che si vuole compilare
- Model: la versione shader in cui il metodo sarà compilato (esempio vs_4_0 indica che useremo la versione 4.0 del vertex shader)
- Opzione di compilazione per lo shader o 0
- Opzione di compilazione per l’effect o 0
- Puntatore ad un buffer che conterrà lo shader compilato
- Puntatore ad un buffer che conterrà gli errori di compilazione
In caso hr sia diversa da S_OK allora error->GetBufferPointer() punterà ad array di char che contiene il testo generato dal compilatore. In caso di successo invece hr sarà uguale ad S_OK e code conterrà il codice compilato.
Con il codice compilato useremo i metodi del Device per creare gli shader, ad esempio
ID3D11VertexShader* vertexShader;
device->CreateVertexShader(code->GetBufferPointer(), code->GetBufferSize(),NULL,&vertexShader);
Lo stesso sarà per i Pixel, i Geometry, i Domain e gli Hull Shader.
Per utilizzare lo shader si passa l’oggetto al DeviceContext
context->VSSetShader(vertexShader,NULL,0);
Da questo momento il rendering successivo utilizzerà questo shader. Per rimuoverlo
context->VSSetShader(NULL,NULL,0);
o sostituendone con un altro.
Gli shader 5.0 supportano la programmazione ad oggetti che sarà argomento di futuri tutorial.
Il byte code compilato può essere salvato in un file e caricato da li. Questo velocizzerà il caricamento (anche se abbastanza inutile come cosa in genere) e permetterà di tenere nascosto il codice shader in fase di distribuzione. L’utility fxc permette di compilare gli shader salvandoli in un file.
Dal byte code si può riottenere il codice shader (non in HLSL ma in Shader Assembly). La cosa è estremamente utile per analizzare il codice. Il compilatore riesce a fare delle ottimizzazioni in grado di ridurre all’osso il numero di istruzioni che la GPU andrà ad eseguire, cosa che difficilmente un programmatore è in grado di fare. Tuttavia vedere il codice assembly permette di comprendere quanto è complesso il nostro shader ed intervenire per semplificarlo ed ottimizzarlo.
Il metodo da utilizzare è D3DDisassemble (o l’utility fxc).
Constant Buffer
Il constant buffer è la variabile per lo shader. All’interno dello shader possiamo definire ad esempio
cbuffer data :register(b0)
{
float4x4 transform;
float4x4 world;
float4 lightDirection;
};
Questo indica la presenza di 2 matrici seguite da un vettore. In totale sono 36 float, 144 byte. Questa sarà la dimensione da avere. Nel vostro programma creerete una struct avente la stessa dimensione
struct Transform
{
XMMATRIX transform;
XMMATRIX world;
XMVECTOR lightDirection;
};
Quindi creare il vostro buffer
ID3D11Buffer * buffer;
D3D11_BUFFER_DESC bd;
bd.Usage = D3D11_USAGE_DYNAMIC;
bd.ByteWidth = 144;
bd.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
bd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
bd.MiscFlags = 0;
devicePointer->device->CreateBuffer( &bd, NULL, & buffer );
Una volta creato il buffer in ogni istante potete caricare al suo interno i dati
Se data è una struttura di tipo Transform
D3D11_MAPPED_SUBRESOURCE subRes;
devicePointer->context->Map(buffer,0,D3D11_MAP_WRITE_DISCARD,0,&subRes);
memcpy_s( subRes.pData,144,source,144);
devicePointer->context->Unmap(buffer,0);
Infine si passa il tutto al device context
ID3D11Buffer* buffers[1]={buffer};
context->VSSetConstantBuffers(0,1,buffers);
In questo momento allo shader abbiamo passato un array con 1 buffer (che quindi andrà nello slot 0). Se avete più slot basterà creare un array più grande ed aumentare il numero.
Gli Shader sono separati. I Constant Buffer vanno passati separatamente ai vari Shader. Dovrete quindi usare VSSetConstantBuffers per i vertex shader, PSSetConstantBuffers per i Pixel Shader e così via.
Molto importante: se compilate uno Shader non verrà incluso un ConstantBuffer finché questi non utilizzerà almeno una sua variabile. In pratica compilando uno Shader tutto il codice non utilizzato sarà come se non ci fosse.