B10. HLSL: le tecniche Colored e ColoredPlus
Nel tutorial precedente, ho illustrato la sintassi di base di hlsl, ma è difficile imparare senza avere qualche esempio da esaminare. In questa lezione e nella prossima, commenterò il codice contenuto nel file SimpleShader.fx che avete scaricato con il pacchetto iniziale del progetto, spiegando come vengono scritte le varie tecniche.
La tecnica Colored
Apriamo con un qualsiasi editor di testo il file SimpleShader.fx. Al suo interno c'è molto codice, ma notate subito dai commenti
(che sono individuati dai caratteri "//") che ho suddiviso il sorgente in quattro parti, corrispondenti ognuna ad una tecnica diversa. Inoltre,
come avevo accennato nel capitolo precedente, i parametri si trovano tutti all'inizio. Fra questi e le altre dichiarazioni, solo ciò
che riporterò di seguito riguarda la tecnica Colored:
Ora, analizziamo il sorgente passo passo, uno statement alla volta:struct VertexToPixel {float4 Position :POSITION ;float4 Color :COLOR0 ;float LightingFactor :TEXCOORD0 ;float2 TextureCoords :TEXCOORD1 ; };struct PixelToFrame {float4 Color :COLOR0 ; };float4x4 View;float4x4 Projection;float4x4 World;//... //-------------------------------------------- //Tecnica 1 : Colore //-------------------------------------------- VertexToPixel ColoredVertexShader(float4 inPos :POSITION ,float4 inColor:COLOR ) {VertexToPixel Output = (VertexToPixel )0;float4x4 ViewProjection = mul(View, Projection);float4x4 WorldViewProjection = mul(World, ViewProjection); Output.Position = mul(inPos, WorldViewProjection); Output.Color = inColor;return Output; }PixelToFrame ColoredPixelShader(VertexToPixel inPixel ) {PixelToFrame Output = (PixelToFrame )0; Output.Color = inPixel.Color;return Output; }technique Colored {pass Pass0 { VertexShader =compile vs_1_1 ColoredVertexShader(); PixelShader =compile ps_1_1 ColoredPixelShader(); } }
Questa struttura si chiama VertexToPixel e questo nome è significativo, anche se noi siamo liberi di assegnargli qualsiasi altro nome. Esso indica che i dati contenuti nella struttura servono a trasportare le informazioni ottenute dal vertex shader al pixel shader. Perciò useremo variabili di questo tipo per definire il tipo restituito dalla funzione che farà da vertex shader. Inoltre, potrete notare che gli ultimi due membri dichiarati non sono usati nella tecnica Colored, poiché la luce e le texture non sono prerogative di questa elaborazione. Ricorderete che ho introdotto la gestione di questi due concetti nelle lezioni successive all'uso di Colored. Vi chiederete allora perchè mai sono lì. Semplice: per risparmiare spazio. Infatti, poiché è il programmatore stesso a scrivere il pixel shader, può anche decidere di non usare tutti i parametri risultanti dal vertex shader, e nessuno potrebbe obbiettare niente. Useremo questa stessa struttura anche per la tecniche ColoredPlus e Textured.struct VertexToPixel {float4 Position :POSITION ;float4 Color :COLOR0 ;float LightingFactor :TEXCOORD0 ;float2 TextureCoords :TEXCOORD1 ; };
Ancora una volta, il nome suggerisce lo scopo della struttura: essa conterrà i dati di output del pixel shader e li passerà alla GPU. Non ho usato la parola Frame per caso: prima delle operazioni di disegno effettive, tutte le informazioni risultanti dalle elaborazioni precedenti sono immagazzinate in un'area di memoria chiamata Frame Buffer. La struttura che contiene l'output del pixel shader, al contrario della sua corrispettiva nomiata sopra, contiene sempre solo un membro: infatti, l'unica informazione che possiamo far stare in un pixel (dello schermo), è il suo colore.struct PixelToFrame {float4 Color :COLOR0 ; };
Questi sono gli unici tre parametri che avremo bisogno per i calcoli, ossia le matrici View, Projection e World, che individuano il modo in cui l'osservatore sta guardando il mondo 3D. Lo scopo per il quale sono indispensabili tali matrici, verrà chiarito tra poco.float4x4 View;float4x4 Projection;float4x4 World;
Ecco la prima delle due funzioni più importanti del codice. Questo metodo rappresenta il vertex shader usato nella tecnica Colored, come potete facilmente intuire dal suo nome. È di tipo VertexToPixel, poiché i dati da esso elaborati passeranno al pixel shader. I parametri che accetta in input non sono casuali: corrispondono esattamente a quelli della struttura VertexPositionColor, con la quale vengono definiti i vertici usati per questa tecnica: inPos rappresenta la posizione di un punto, inColor il suo colore. La prima riga del corpo serve a inizializzare l'output. Infatti, scrivere:VertexToPixel ColoredVertexShader(float4 inPos :POSITION ,float4 inColor:COLOR ) {VertexToPixel Output = (VertexToPixel )0;float4x4 ViewProjection = mul(View, Projection);float4x4 WorldViewProjection = mul(World, ViewProjection); Output.Position = mul(inPos, WorldViewProjection); Output.Color = inColor;return Output; }
potrebbe essere associato, per farvi capire meglio, ad un codice simile:VertexToPixel Output = (VertexToPixel )0;
In C#, scrivere un tipo tra parentesi tonde equivale a chiamare un'istruzione CType verso quel tipo. Ma questi sono dettagli: l'importante è capire che questa riga crea una nuova struttura VertexToPixel vuota.Dim OutputAs New VertexToPixel()
Prima di commentare le righe successive, si devono introdurre due semplici concetti. Per prima cosa, la funzione mul(a, b), che moltiplica fra loro i due parametri a e b (ossia a * b). Potrebbe sembrare inutile, ma la sua potenza sta nel fatto che è in grado di moltiplicare qualsiasi tipo di dato: numeri puri, vector2, vector3 (quindi float2 e float3), matrici di qualunque dimensione, eccetera... In seconda istanza, dovete sapere che quando si moltiplica un Vector4 (float4) per una matrice 4x4 (float4x4), si ottiene come risultato un altro Vector4: in particolare, quando la matrice in questione equivale al prodotto tra View, Projection e World, il vettore risultante indica, nelle sue coordinate x e y, le coordinate di quel punto sullo schermo. In pratica, l'operazione prodotto tra la posizione 3D di un punto e la combinazione di tutte e tre le matrici esposte, "spalma" quel punto sullo schermo.
Le variabili locali usate, di tipo float4x4, non sono altro che un mezzo per semplificare la lettura del codice. Si sarebbe anche potuto scrivere:Output.Position = mul(inPos, mul(World, mul(View, Projection)));
e ottenere lo stesso risultato, ma sarebbe stato meno ordinato. Ricordate che il prodotto matriciale non è commutativo. Per ottenere un risultato corretto, le tre matrici devono essere sempre moltiplicate secondo questo ordine: World * (View * Projection).
Questo metodo rappresenta il pixel shader. È di tipo PixelToFrame, poiché i dati da esso elaborati passeranno al Frame Buffer. Accetta un solo parametro di tipo PixelToFrame, che è dato dall'output del vertex shader. Come prima, c'è una riga che inizializza l'output, ma il resto è molto meno complesso: l'unica cosa da fare consiste nel passare il colore così com'è alla GPU.PixelToFrame ColoredPixelShader(VertexToPixel inPixel ) {PixelToFrame Output = (PixelToFrame )0; Output.Color = inPixel.Color;return Output; }
Dichiara la tecnica Colored. vs_1_1 significa "Vertex Shader vesione 1.1" e allo stesso modo ps_1_1 "Pixel Shader versione 1.1". Differenti versioni comportano differenti possibilità di rendering. Per ora utilizzeremo solo la versione 1.1.technique Colored {pass Pass0 { VertexShader =compile vs_1_1 ColoredVertexShader(); PixelShader =compile ps_1_1 ColoredPixelShader(); } }
La tecnica ColoredPlus
La novità introdotta in questa tecnica consiste nel poter calcolare anche quanta luce un punto riceve, e quindi, diffonde, basandosi
sulle normali e sulla direzione iniziale della luce stessa. Dato che tutto il codice usato per questa tecnica è uguale a quello di
Colored, con le uniche due eccezioni del vertex shader e del pixel shader, riporterò solo questi ultimi due:
La prima differenza significativa in questa funzione consiste nel terzo parametro aggiuntivo, la normale, che in Colored non era presente per ovvi motivi. La prima parte del corpo del metodo risulta uguale al precedente vertex shader, ma c'è una sostanziale aggiunta. Per prima cosa, impostiamo Output.LightingFactor su 1, il massimo: questa operazione non influenza in nessun modo il rendering finale, poiché nella tecnica Colored, tutti i vertici ricevevano equalmente lo stesso ammontare massimo di luce (infatti i colori erano molto chiari). Successivamente, controlliamo che "la luce sia accesa", testando il valore di LightEnabled. Il costrutto If:VertexToPixel ColoredPlusVertexShader(float4 inPos :POSITION ,float4 inColor:COLOR ,float3 inNormal:NORMAL ) {VertexToPixel Output = (VertexToPixel )0;float4x4 ViewProjection = mul(View, Projection);float4x4 WorldViewProjection = mul(World, ViewProjection); Output.Position = mul(inPos, WorldViewProjection); Output.Color = inColor; Output.LightingFactor = 1;if (LightEnabled) {float3 Normal = normalize(mul(normalize(inNormal), World)); Output.LightingFactor = saturate(dot(Normal, -LightDirection)); }return Output; }
Equivale al vb.net:If (LightEnabled) {//... }
[Volendo, si potrebbero anche lasciare le parentesi tonde] Se il programmatore ha impostato, nel metodo Draw del gioco, il parametro LightEnabled su True, allora bisogna anche calcolare quanta luce riceve il punto. Prestate bene attenzione a questa riga di codice:If LightEnabledThen '... End If
La funzione mul è già stata analizzata. La funzione normalize è comunque praticamente identica alla procedura Normalize dei Vector3, che ridimensiona le componenti del vettore in modo che la sua lunghezza complessiva risulti unitaria. Possiamo schematizzare i passaggi impliciti nel codice con questo schema:float3 Normal = normalize(mul(normalize(inNormal), World));- Prende la normale, inNormal, che rappresenta la normale del vertice (calcolata con CalculateNormals nell'applicazione XNA). Il suo scopo è solo indicare una direzione e un verso, perciò sarà un vettore assolutamente anonimo, non traslato, non ruotato, ossia privo di qualsiasi legame col mondo 3D reale.
- Normalizziamo la normale per essere sicuri che sia di lunghezza unitaria
- Moltiplichiamo la normale per la matrice World e otteniamo un vettore traslato, ruotato o scalato secondo le trasformazioni reali presenti nel mondo 3D. In questo modo potremmo mettere in relazione la normale con la direzione della luce, nel modo più giusto.
- Normalizziamo il vettore risultante, poiché non siamo sicuri che il precedente prodotto abbia fornito un vettore di modulo 1.
Output.LightingFactor = saturate(dot(Normal, -LightDirection));
Sia saturate che dot sono due funzioni nuove e mai incontrate. La prima è molto semplice da capire, mentre la secona implica un po' di conoscenza di trigonometria. Iniziamo dalla più difficile.
La funzione dot(a, b) esegue un prodotto scalare tra due vettori a e b. Il prodotto scalare è convenzionalmente definito con la formula:c = |a| * |b| * cosθ
Dove θ è l'angolo compreso tra a e b. Ecco un disegno per capire meglio:Prodotto scalare
Assumiamo che il vettore a sia quello blu, il vettore b sia quello arancio e chiamamo d quello verde. Ora, consideriamo i moduli (le lunghezze) dei singoli vettori, senza proccuparci del loro verso. Abbiamo che:a * b * cosθ = a * (b * cosθ) = a * d
Sono fondamenti di trigonometria: in un triangolo rettangolo, l'ipotenusa (b) moltiplicata per il coseno dell'angolo compreso tra essa e un cateto (a) dà come risultato l'altro cateto (d). Nel nostro caso, a è al vettore opposto alla direzione della luce (infatti, la luce viene incontro alla normale, mentre nella figura e nel codice, usiamo il vettore opposto, quello che la punta nella stessa direzione della normale), mentre b rappresenta la normale. Ma nel nostro caso particolare, sia a che b sono lunghi 1 unità: infatti entrambi hanno subito un processo di normalizzazione tramite la funzione normalize. Perciò otteniamo che:a * d = 1 * d = d = b * cosθ = 1 * cosθ = cosθ
In definitiva, in questo particolare caso, e solo in questo, la funzione dot restituisce il coseno dell'angolo compreso tra il vettore opposto a LighDirection e la normale. Forse questo non vi dirà niente di che, ma se osservate meglio, capirete. La funzione coseno è una sinusoide perioda il cui grafico è press'a poco questo:Grafico del coseno
In ascissa sono riportati gli angoli in radianti, in ordinata il valore della funzione. Osserviamo che esso vale 1 quando l'angolo vale 0, che descresce armonicamente fino a valere 0 in π/2 (90°), che vale -1 in π (180°) e che ritorna a 0 in 3π/2 (270°). Questo comportamento ci comunica di quanto la direzione della luce e la normale sono sfasate. Ad esempio, se la normale forma un angolo di 45° con la luce, la funzione dot restituirà un valore approssimativamente vicino a 0.7071: questo significa che la superficie che ha questa normale non riceverà il 100% della luce, ma soltanto il 70,71% e quindi sarà più scuro. Se, invece, l'angolo formato è di 180° (ossia la normale è opposta alla luce), dot restituirà -1 e di conseguenza il triangolo riceverà... il -100% di luce? Un momento, non possono esistere percentuali negative in questo caso! È per questo che interviene la funzione saturate a bilanciare il risultato. Saturate(a) equivale a Math.Clamp(a, 0, 1), ossia restituisce a se a è compresa tra 0 e 1; restituisce 0 se a è minore di 0; restituisce 1 se a à maggiore di 1. In questo modo, potremo avere soltanto i valori compresi tra 0 (faccia completamente in ombra) e 1 (faccia direttamente illuminata). LightingFactor assumerà questo valore.
Quasi tutta la funzione è già nota. L'unica linea che ha bisogno di commenti è questa:PixelToFrame ColoredPlusPixelShader(VertexToPixel inPixel ) {PixelToFrame Output = (PixelToFrame )0; Output.Color = inPixel.Color; Output.Color.rgb *= saturate(inPixel.LightingFactor + AmbientFactor);return Output; }Output.Color.rgb *= saturate(inPixel.LightingFactor + AmbientFactor);
Quando si tratta di una variabile che contiene un colore, si possono usare le proprietà a, r, g, b o una qualsiasi combinazione di lettere per ottenere una o più componenti. Dato che, come già detto, gli shader lavorano a basso livello e in particolare a contatto con la memoria, l'unione di due o tre componenti (ad esempio, rg, gb, o rgb come in questo caso) non à altro che un numero, spesso molto elevato, sul quale si possono eseguire normali operazioni matematiche. In particolare, moltiplicando rgb per un fattore qualsiasi si riduce o si amplifica la luminosità del colore finale. Quindi, una volta ottenuto il LightingFactor col procedimento esposto prima, gli si somma l'AmbientFactor (il fattore di ambiente, ricordate: la luce minima che si diffonde nel mondo), e si ottiene un valore di range corretto con la funzione saturate. Moltiplicando rgb per tale valore, si scurisce il colore tanto più il punto in questione si trova in ombra.
Ancora qualche dubbio...
Se avete assimilato bene i concetti e avete seguito le mie dimostrazioni e le mie spiegazioni a questo punto potreste avere alcuni dubbi,
due dei quali trovano risposta in questo paragrafo aggiuntivo.
- Se la posizione è determinata da un Vector3, come mai nei parametri del vertex shader è rappresentata da un float4?
Ottima domanda. La soluzione non è molto prevedibile ed è per questo che non è semplice. La quarta coordinata viene calcolata prima che si passi il punto al vertex shader, e si chiama w: essa contiene un coefficiente particolare per cui, dividendo ogni altra componente (x, y o z) per w si ottiene un numero compreso tra 0 e 1. La useremo in seguito in alcuni shader più complessi. - Se il vertex shader elabora solo i vertici, come fa il pixel shader a colorare tutti i punti dello spazio?
Il passaggio tra vertex shader e pixel shader non è vuoto: ricordate che c'è l'interpolatore, il quale, tra le altre cose, ha il compito di interpolare dai vertici, tutti i punti facenti parte della superficie del triangolo limitato da quei vertici, e assegnarvi posizioni, colori, normali, coordinate texture, eccetera...
The Totem's Lair - Copyright (C) 2009
È vietata la riproduzione sia totale che parziale del sito.



