giovedì 8 ottobre 2009

3D.5) XNA Tutorial 3d: HLSL shader in XNA

Ciao a Tutti!

Eccomi di nuovo dopo una lunghissima pausa !!!
Mi dispiace aver atteso tanto per creare il nuovo tutorial, ma il tempo libero è stato veramente poco e se devo dire la verità anche la voglia non era molta... :P (w la sincerità) .
Questo periodo di blackout è stato parecchio utile, ne ho approfittato per studiacchiare un pò il discorso shader e HLSL, andiamo al dunque senza girarci troppo intorno e cerchiamo di capire di cosa si tratta:


PROCESSO SHADER

Questa immagine rappresenta il percorso che viene seguito per convertire i dati creati dall'applicazione in informazioni visualizzate a Monitor.


Noterete che le funzionalità di Vertex e Pixel shader sono svolte come pipeline nella GPU.


VERTEX SHADER
Attraverso i vertex shader vengono visitati tutti i vertici nella scena, effettuando su questi eventuali operazioni che possono andare dalla modifica delle informazioni di posizione fino alla definizione del colore del vertice.

Vi ricordo che definire il colore di un vertice in XNA fa si che si crei un'interpolazione tra i colori dei vari vertici... lo abbiamo visto in questo tutorial riporto qui il risultato:


Una volta che sono state svolte le operazione nel vertex shader, viene lancianto il processo Rasterizzazione.


Lo scopo di questo processo è convertire i triangoli che definiscono la scena ( o l'oggetto) in Pixel da renderizzare a monitor.


Nel caso di XNA, uno dei compiti principali del Vertex Shader (non ricordo assolutamente se questo valga anche per DirectX) è quello di posizionare il modello ricevendo i dati View, World e Projection Matrix.



PiXEL SHADER
Ora che la scena è stata convertita in Pixel è il turno del Pixel Shader, che effettuerà operazioni su tutti i pixel visibili.

Al termine di questa operazione l'immagine è pronta per essere presentata a Video. La differenza principale tra i due passaggi è dunque data dal punto nel quale opereremo, Vertici e Pixel!

HLSL
Poco fa parlavo di "operazioni" eseguite dai vertex/pixel shader su ogni vertice/pixel disegnato. Tutto quello che viene eseguito durante questi passaggi è definito attraverso linguaggio HLSL (High Level Shader Language). Il modo migliore per raccontarvi qualcosa su questo linguaggio e su come scrivere i vostri shader penso che sia la pratica diretta sul codice.... quindi ecco qui quello che faremo:

1) Creeremo una scena 3d con una delle solite bellissime astronavi di Battlestar Galactica :)
2) Creeremo un Ambient light Shader, che darà alla scena un colore omogeneo e piatto, basato su una luce ambientale non direzionale, regolabile per colore e intensità.....in poche parole una mer*a :P dato che come vedrete non si distingueranno le facce dell'oggetto.... ma per ora serve per imparare quindi ben venga!!


Prima di tutto vi presento il file ambient.fx che è il punto nel quale definiremo il nostro shader:


ambient.fx

// 1 Variabili settate tramite l'applicazione-----------------------------------

extern float4x4 World;
extern float4x4 View;
extern float4x4 Projection;

//Impostazioni (provare a giocare con questi parametri)---------------------
float4 AmbientColor = float4(1,1,1,1); //Colore passato tramite l'ambiente
float AmbientIntesity = 0.2; //intesità



// 2 VERTEX SHADER--------------------------------------------------------------
//Struttura per definire l'input per il vertex shader
struct VertexShaderInput
{
float4 Position : POSITION0;
};
//Struttura per definire l'output del vertex shader
struct VertexShaderOutput
{
float4 Position : POSITION0;
};
//La nostra funzione vertex shader
VertexShaderOutput VertexShaderFunction(VertexShaderInput input)
{
VertexShaderOutput output;
float4 worldPosition = mul(input.Position, World);
float4 viewPosition = mul(worldPosition, View);
output.Position = mul(viewPosition, Projection) ;
return output;
}


// 3 PIXEL SHADER----------------------------------------------------------------
float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
return (AmbientColor * AmbientIntesity);
}

// 4 TECNICA---------------------------------------------------------------------
technique Technique1
{
pass Pass1
{
VertexShader = compile vs_1_1 VertexShaderFunction();
PixelShader = compile ps_1_1 PixelShaderFunction();
}
}



Prima di farvi vedere il codice della scena 3d (che non è lo scopo centrale di questo tutorial...
ma che comunque spiegherò passo passo giusto per non farvi incazzare come vipere :P)
analiziamo questo codice.

Come noterete l'ho diviso in 4 Macro Blocchi
1) variabili passate dall'applicazione
2) vertex shader
3) pixel shader
4) tecnica

Uno alla volta

1)Variabili settate dall'applicazione
Queste variabili sono definite a livello di applicazione, il che significa che tramite un
costrutto particolare, scriveremo direttamente nel nostro game1.cs del codice che invierà queste
informazioni allo shader fx ( vedremo dopo come... per ora sappiate solo che sono dati definiti in game1.cs).

La sintassi della definizione della variabili è simile a C, ma noterete che i tipi di dato sono
leggermente diversi. A dire il vero devo studiarli ancora per bene...(qui la reference) comunque quel float4x4 com'è facile immaginare fa riferimento
a una matrica 4x4 composta di float.


Per ora osservate soltanto il fatto che si tratta delle 3 matrici view, projection e world...
e se fate un passo indietro di un tutorial... noterete che sono proprio i valori che passavamo al basic effect.
Ecco qui un ripassino :

foreach (BasicEffect be in mesh.Effects) {
be.EnableDefaultLighting();
be.Projection = camera.projection;
be.View = camera.view;
be.World = GetWorld() * mesh.ParentBone.Transform;
}

2) vertex shader
Qui abbiamo 3 blocchi:
2 strutture e 1 funzione.

Diamo un'occhiata all'immagine del processo shader, notiamo che la funzione vertex shader ha 1 freccia entrante e una uscente...
quindi un input e un output.

Le due strutture identificano proprio le 2 frecce.

Sia shader input che shader output hanno lo stesso contenuto, in questo caso:

float4 Position : POSiTiON0;

-float4 identifica un vettore di 4 float x,y,z,w ( se non sapete cosa sia w, date un'occhiata a questo link)
-Mentre per :POSiTiON0 dobbiamo fare un paio di riflessioni in più:
Abbiamo detto che lo scopo del vertex shader è quello di elaborare ogni vertice, e abbiamo anche detto
che possiamo modificare informazioni di posizione e colore per ogni vertice elaborato....la voce (chiamata SEMANTICA) :POSITION0 identifica
che quella variabile è
associata all'informazioni POSIZIONE del vertice.

Una lista completa delle semantiche la trovate a questo link.
Quindi facciamo un passo indietro, nel vertex shader entrano dei dati, definiti dalla struttura VertexShaderInput.
La struttura è composta da una variabile float4 con semantica :POSITION0, questo vuol dire che i dati che entranno
nella vertex shader sono i dati di posizione del vertice.
Mettiamo caso che stiamo disegnando triangolo; la funzione VertexShader ricevere a ciclo le posizioni dei 3 vertici che lo compongono.

Quindi diciamo che POSITION0 è una funzionalità predefinità dal linguaggio HLSL che serve proprio per collegare la logica definita nell'applicazione con lo shader.

La struttura VertexShaderOutput (quella che identifica la freccia uscente dalla funzione VertexShader) è composta come dicevamo prima nello stesso identico modo,
ma in questo caso identifica un output....quindi facciamo due più due e otteniamo che in questo caso siamo di fronte a una funzione VerteXShader che riceve in ingresso la posizione di un vertice

la elabora (attraverso world -view - projection) e quindi restituisce la nuova posizione del vertice.

Ricordate che si possono modificare anche altri paramentri del vertice....(in questo esempio lavoriamo solo sulla posizione).
Ora guardiamo in che modo la funzione VertexShader elabora la posizione del vertice in ingresso:

VertexShaderOutput VertexShaderFunction(VertexShaderInput input)
{
VertexShaderOutput output;
float4 worldPosition = mul(input.Position, World);
float4 viewPosition = mul(worldPosition, View);
output.Position = mul(viewPosition, Projection) ;
return output;
}

Ok leggete il prototipo e capiamo subito che .... l'output è un VertexShaderOutput, la funzione si chiama
VertexShaderFunction e riceve in ingresso un VertexShaderInput.
La funzione non fa altro che far passare la posizione del vertice attraverso una moltiplicazione con le 3 matrici principali, ricevute dall'applicazione (vi ricordate? il blocco 1 visto qualche riga fa... )
Mentre la funzione mul è un costrutto predefinito di hlsl che si occupa di moltiplicare 2 matrici.

3) pixel shader
float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
return (AmbientColor * AmbientIntesity);
}

La scopo di questa funzione è di dare a ogni pixel disegnato, un colore basato sulla luce ambientale.
Quindi tramite la moltiplicazione AmbientColor * AmbientIntensity ogni pixel assumerà lo stesso color, la cosa interessante da notare è questo colore
non è definito sull'oggetto tramite materiali, ma è definito attraverso il nostro effetto!


La sintassi della funzione e leggermente diversa dalla Vertex Shader ma solo a scopo dimostrativo... infatti le due sintassi sono interscambiabili.
Ma vediamo dove sta la differenza. Noterete che in questo caso la Semantica :COLOR0 è posizionata in linea al nome funzione prima della definizione.
Questo sta ad indicare che l'OUTPUT della funzione sarà il COLORE del pixel elaborato.
Logica quindi del tutto identica al vertex shader, notate che in questo caso l'output del vertexShader viene diretto come input del PixelShader.

4)Tecnica
Anche se è l'ultima parte che tratteremo in realtà questa è la sezione del codice che viene letta per prima.

technique Technique1
{
pass Pass1
{
VertexShader = compile vs_1_1 VertexShaderFunction();
PixelShader = compile ps_1_1 PixelShaderFunction();
}
}

Andiamo ad analizzare il codice partendo dall'interno.
Una tecnica richiede che per ogni passo vengano settati i paramentri VertexShader e PixelShader che, come è facile immaginare, identificano le funzioni che
associamo a questi due processi.
Nel nostro caso andremo ad associare le funzioni create precedentemente.

La sintassi è un pò particolare :
compile vs_1_1 indica che la funzione VertexShaderFunction() viene compilata utilizzando la versione 1 di vertex shader.

Alcune funzionalità potrebbero essere supportate solo da versione successive (2,3) di vertex shader.
E' buona pratica utilizzare sempre la versione più bassa disponibile in grado di supportare le nostre necessità, questo farà si che anche chi utilizza
schede video "datate" possa visualizzare i nostri shader.


Una tecnica può essere composta da più passi che verranno eseguiti in successione, in ogni passo potrete definire le vostre funzioni, i risultati delle quali si sommerrano
per otterenere un effetto finale composto appunto da più passi.

All'interno di uno shader possono essere create diverse tecniche e tramite l'applicazione si potrebbe decidere di utilizzare una tecnica piuttosto che un'altra.
Questa scelta potrebbe essere guidata dal tipo di scheda video dell'utente che utilizzerà la nostra applicazione, potremmo infatti decidere che in base al tipo di dettaglio grafico scelto venga
caricata una tecnica più leggera o pià pesante.


Ora che abbiamo analizzato lo shader in tutti suoi dettagli vediamo il codice dell'applicazione.

Come sopra.... non fate copia e incolla! usate i sorgenti a fondo articolo :D


using System;
using System.Collections.Generic;
using System.Linq;
......................etcetcetc
namespace hlsl_start
{
public class Game1 : Microsoft.Xna.Framework.Game
{
GraphicsDeviceManager graphics;
GraphicsDevice device;

//L'effetto Basic (standard di xna)

BasicEffect basicEffect;

//Il nostro effetto

Effect effect;

//Ottima camera da libro xna 3.0 recipes

QuakeCamera fpsCam;

//Il modello 3d

Model myModel;

//Matrici

Matrix world = Matrix.Identity;
Matrix projection;
Matrix worldInverseTransposeMatrix;
Matrix[] modelTransforms;

//Varie

float angle = 0.0f;
Vector3 viewVector;
private Vector3 Position = Vector3.One;
float aspectRatio;


public Game1()
{
graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
}


protected override void Initialize()
{
//istanzia la camera
fpsCam = new QuakeCamera(GraphicsDevice.Viewport, new Vector3(10, 10, 50), 0, 0);

//setta ratio e pojection

aspectRatio = graphics.GraphicsDevice.Viewport.Width / graphics.GraphicsDevice.Viewport.Height;
projection = Matrix.CreatePerspectiveFieldOfView(MathHelper.ToRadians(45.0f), aspectRatio, 1.0f, 10000.0f);
base.Initialize();
}


protected override void LoadContent()
{
device = graphics.GraphicsDevice;
//Imposta il modello 3d
myModel = Content.Load<Model>("model/cylon");
modelTransforms = new Matrix[myModel.Bones.Count];
myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
//Imposta gli effetti (ne utilizzaremo uno per volta... quindi commentate e decommentate a seconda del caso
//basicEffect = new BasicEffect(device, null);
//effect = Content.Load("effect/Specular");
//effect = Content.Load("effect/Specular");
effect = Content.Load<Effect>("effect/Ambient");
}


protected override void Update(GameTime gameTime)
{
//Opzioni per la camera
GamePadState gamePadState = GamePad.GetState(PlayerIndex.One);
MouseState mouseState = Mouse.GetState();
KeyboardState keyState = Keyboard.GetState();
fpsCam.Update(mouseState, keyState, gamePadState);

//per shader specular ora non ci interessa...
Vector3 cameraLocation = 2* new Vector3(0, 3, 0);
Vector3 cameraTarget = new Vector3(0, 0, 0);

//Rotazione del modello

angle += 0.01f;
if (angle > 359) {
angle = 0;
}

world = Matrix.CreateRotationY(angle) * Matrix.CreateScale(0.05f, 0.05f, 0.05f) ;

//Da usare per lo shader specular

viewVector = Vector3.Transform(cameraTarget - cameraLocation, fpsCam.ViewMatrix);
viewVector.Normalize();
base.Update(gameTime);
}


protected override void Draw(GameTime gameTime)
{
device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.SlateGray, 1, 0);
DrawModelAmbient(myModel, world);
base.Draw(gameTime);
}


//Disegna il modello con lo shader standard
public void DrawModel(Model model, Matrix w){
foreach (ModelMesh mesh in model.Meshes)
{
foreach (BasicEffect effect in mesh.Effects)
{
effect.EnableDefaultLighting();
effect.World = modelTransforms[mesh.ParentBone.Index] * w ;
effect.View = fpsCam.ViewMatrix;
effect.Projection = fpsCam.ProjectionMatrix;
}
mesh.Draw();
}
}


//funzione per utilizzare lo shader Ambient
private void DrawModelAmbient(Model model, Matrix world)
{
foreach (ModelMesh mesh in model.Meshes)
{
foreach (ModelMeshPart part in mesh.MeshParts)
{
part.Effect = effect;
effect.Parameters["World"].SetValue(world * mesh.ParentBone.Transform);
effect.Parameters["View"].SetValue(fpsCam.ViewMatrix);
effect.Parameters["Projection"].SetValue(fpsCam.ProjectionMatrix);
}
mesh.Draw();
}
}


//Funzione per utilizzare lo shader diffuse
private void DrawModelDiffuse(Model model, Matrix world)
{
foreach (ModelMesh mesh in model.Meshes)
{
foreach (ModelMeshPart part in mesh.MeshParts)
{
part.Effect = effect;
worldInverseTransposeMatrix = Matrix.Transpose(Matrix.Invert(mesh.ParentBone.Transform * world));
effect.Parameters["WorldInverseTranspose"].SetValue(worldInverseTransposeMatrix);
effect.Parameters["World"].SetValue(world * mesh.ParentBone.Transform);
effect.Parameters["View"].SetValue(fpsCam.ViewMatrix);
effect.Parameters["Projection"].SetValue(fpsCam.ProjectionMatrix);
}
mesh.Draw();
}
}


//Funzione per utilizzare lo shader specular
private void DrawModelSpecular(Model model, Matrix world)
{
foreach (ModelMesh mesh in model.Meshes)
{
foreach (ModelMeshPart part in mesh.MeshParts)
{
part.Effect = effect;
worldInverseTransposeMatrix = Matrix.Transpose(Matrix.Invert(mesh.ParentBone.Transform * world));
effect.Parameters["WorldInverseTranspose"].SetValue(worldInverseTransposeMatrix);
effect.Parameters["ViewVector"].SetValue(viewVector);
effect.Parameters["World"].SetValue(world * mesh.ParentBone.Transform);
effect.Parameters["View"].SetValue(fpsCam.ViewMatrix);
effect.Parameters["Projection"].SetValue(fpsCam.ProjectionMatrix);
}
mesh.Draw();
}
}
}
}

Caricamento del modello e Camera 3d:
Per questo tutorial non ho usato il model manager che abbiamo creato nelle scorse lezioni (Sarebbe stato scomodo presentare la gestione degli shader... e vorrei fare in modo che anche
chi non fosse interessato alle altre lezioni possa comunque leggere e seguire il post).


La logica per caricare il modello è molto semplice:
1) nella funzione LoadContent carico il modello e gestisco le informazione sulle bones
2) nella funzione Draw richiamo la funzione DrawAmbient che si occupa di scorrere le componenti della mesh e renderizzarle utilizzando il nostro materiale.
3) nella funzione update incrementiamo il valore angle, che verrà adottato insieme alla funzione createRotationY per costruire la matrice world e far ruotare l'oggetto.

La camera che ho utilizzato è la QuakeCamera (prelevata pari pari dal libro xna 3.0 game programming recipes) che viene istanziata nella funzione initialize ricevendo come secondo paramentro la posizione della camera.
Nella funzione Update passeremo alla camera le informazioni relative agli input(keyboard, mouse, gamepad) permettendo alla classe di ricostruire la nuova posizione e la nuova matrice View.


Effect:
Ora che abbiamo visto come viene caricato il modello, possiamo tornare allo scopo del post, gli shader.
Prima di tutto per creare un file fx dovete aggiungere il file nella vostra cartella content (io ho creato una cartella effect che ospiterà questi e i prossimi effetti che vi racconterò).
Per aggiungere l'effetto cliccate nella gestione file del progetto e "Add New Item" quindi scegliete file .fx.
Il caricamento di un file effect utilizza la funzione Load della pipeline xna, posizioniamo la funzione nella LoadContent:

effect = Content.Load("effect/Ambient")

Ora facciamo una passo indietro.. nel blocco 1) del file Ambient.fx abbiamo parlato di alcune variabili da definire attraverso l'applicazione:

extern float4x4 World;
extern float4x4 View;
extern float4x4 Projection;

La voce extern serve proprio ad indicare che queste variabili sono definite tramite un processo esterno al file, ed è quello che faremo nella funzione drawAmbient:

foreach (ModelMeshPart part in mesh.MeshParts)
{
part.Effect = effect;
effect.Parameters["World"].SetValue(world * mesh.ParentBone.Transform);
effect.Parameters["View"].SetValue(fpsCam.ViewMatrix);
effect.Parameters["Projection"].SetValue(fpsCam.ProjectionMatrix);
}

Per ogni Mesh destineremo alle varie parti l'effetto ambient e imposteremo i parametri dell'effetto tramite la funzione setValue(); Quindi i valori World View e Projection andranno in automatico a
valorizzare le variabili dichiarate extern a inizio codice ambient.fx.


Tutto qui! :D

I codici che incorporerò a fondo articolo comprendo già altri 2 materiali (Specular e Diffuse) che vedremo nei prossimi post.
Voi potete iniziare a testarli modificando la chiamata alla funzione che si occupa di disegnare il modello (seguite i commenti nella funzione draw)


Questo è il risultato che otterete lanciando il codice:



brutto forte eh?...bhe diciamo che non è cool come le altre volte :P però siamo alle basi dell'HLSL.. andando avanti magari uscirà qualcosa di più interessante!!



Direi che è tutto... non esitate a fare domande nel caso in cui ci fossero passaggi poco chiari !

Ciao! Alla prossima

Yari

Sorgenti:
imparandoxna_3d.4.hlsl.zip


Lezione in PDF

7 commenti:

Yari ( ImparandoXNA ) ha detto...

Ho avuto parecchi problemi con la pubblicazione del post, devono aver cambiato qualcosa dell'editor.... allego la lezione anche in pdf, così potete scaricare direttamente quella!

ciao!

Odino ha detto...

Chiaro ed efficace ^^
Aspetto di vedere le successive guide :)

Yari ( ImparandoXNA ) ha detto...

Grazie mille per il commento, penso che scriverò i prossimi due materiali settimana prossima!

Ciao

Alex ha detto...

Ottimo tutorial, come sempre!

Anonimo ha detto...

Complimenti molto interessante.
Spero di leggere altro materiale.
Ciao

Anonimo ha detto...

interessante.
ho aggiunto nel costruttore di quakecamera il parametro game ,passato come this al richiamo .
Poi il controllo che se viene premuto escape finisce il gioco.
diversamente non riesco ad uscire
ciao
walter

Yari ( ImparandoXNA ) ha detto...

Ciao effettivamente non sto gestendo l'aspetto dell'uscita dal game... hai fatto bene :P

Posta un commento