Shaders 3611 Visite) DirectX 11
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 contemporaneamente). La versione 4_0 e superiori ne supportano fino a 8.
Ci sono diversi modi per compilare un codice shader, da file, da stringa, etc. Ecco come compilare uno shader
var shaderByteCode = ShaderBytecode.CompileFromFile(filename, shaderFunction,shaderType);
Al metodo vengono passati i seguenti parametri
- Filename: path del file
- shaderFunction: nome del metodo da compilare
- shaderType: la versione ed il tipo (ad esempio "vs_5_0" per un vertex shader 5.0)
In caso di errori di compilazione verrà lanciata un’eccezione da leggere per capire l’errore presente nel codice.
Con il codice compilato useremo i metodi del Device per creare gli shader, ad esempio
VertexShader = new VertexShader(Device.Device, vertexShaderByteCode);
Lo stesso sarà per i Pixel, i Geometry, i Domain e gli Hull Shader.
Per utilizzare lo shader si passa l’oggetto al DeviceContext
Device.DeviceContext.VertexShader.Set(VertexShader);
Da questo momento il rendering successivo utilizzerà questo shader. Per rimuoverlo
Device.DeviceContext.VertexShader.Set(null);
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 vista la velocità di compilazione) 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 è shaderByteCode.Bytecode.Disassemble();
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
{
Matrix transform;
Matrix world;
Matrix lightDirection;
};
Quindi creare il vostro buffer
Buffer Buffer= new Buffer(device, Utilities.SizeOf<Transform>(), ResourceUsage.Default, BindFlags.ConstantBuffer, CpuAccessFlags.None, ResourceOptionFlags.None, 0);
Una volta creato il buffer in ogni istante potete caricare al suo interno i dati
Se data è una struttura di tipo Transform
deviceContext.UpdateSubresource(ref data, buffer);
Per passarli al Device
DeviceContext.VertexShader.SetConstantBuffer(0, buffer);
In questo momento al Vertex Shader abbiamo passato il nostro buffer in posizione 0. Per passare più buffer si può eseguire l’istruzione più volte, variando l’indice, oppure usando SetConstantBuffers e passargli un array di buffer.
I Constant Buffer vanno passati separatamente ai vari Shader. Dovrete quindi usare VertexShader.SetConstantBuffer per i vertex shader, PixelShader.SetConstantBuffer 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.
Vi lascio un pdf realizzato da me per dare una prima panoramica in merito agli shader: il De Rerum Shader.
Buona lettura
- De Rerum Shader.pdf 1065 KB) Download File