La generazione di pelo in real time è una tecnica estremamente semplice ma nello stesso tempo di grande impatto visivo.
Le conoscenze richieste sono, inoltre, veramente poche:
Caricamento generico di Mesh (scegliete il formato che vi pare)
Solite equazioni generiche di luce (Lambert e Phong)
Map e Unmap di risorse, creazione di textures generiche NON da file.
Geometry Instancing (opzionale, per l’ottimizzazione del tutto)
La tecnica sarà implementata in D3D10. Tuttavia, non sarà usata nessuna esaltante novità delle nuove DirectX (l’Instancing, tra l’altro non obbligatorio, è presente anche in D3D9, anche se in modo più laborioso). Quindi è completamente fattibile anche in Direct3D9 (anche le 8 e persino XNA, volendo.)
La prima cosa da fare è, ovviamente, caricare la mesh-target su schermo.
Studiamo ora un metodo per renderizzare delle ciocche di pelo su una geometria.
Quando si pensa al rendering del pelo, si pensa immediatamente alla creazione di 1 o massimo 2 poligoni ogni ciocca. Questo metodo permette di gestire ogni singolo filo di pelo in modo autonomo, ma ovviamente è costosissimo anche per zone molto piccole.
Già una prima risposta sarebbe l’Instancing, che permettere di disegnare più copie dello stesso oggetto con una sola chiamata di Draw ma lasciando comunque al programmatore la facoltà di comandare ogni ciocca a modo proprio passando opportuni parametri, ma non è questo lo scopo del tutorial.
Posizionare le ciocche singolarmente inoltre, richiede un enorme lavoro per ogni tipo di mesh, o uno ancor più laborioso nello stabilire un algoritmo che generi opportuni punti in cui istanziare una copia di pelo.
Il metodo che implementeremo in questo esempio è chiamato Shell rendering. L’idea, a grosse linee, è espressa da questa immagine.
Supponiamo che il quadrato più in basso sia il pezzo di mesh che vogliamo renderizzare con il pelo. L’idea dello Shell si basa sul duplicare lo stesso pezzo della geometria e proiettarlo lungo una direzione, creando quindi più copie dello stesso oggetto. Nel renderizzare però le copie, renderemo visibili solo una parte dei pixel, facendo quindi emergere soltanto delle parti di mesh (dei peli, appunto). Dunque l’immagine sopra rappresenta, nel quadrato più in basso, la geometria originale, gli altri quadrati sono invece le IPOTETICHE copie dell’oggetto, ma ne sono rese visibili soltanto le linee in grassetto.
Per renderizzare usando questa tecnica, ci sono alcune problematiche da risolvere:
- Servono informazioni riguardo ai singoli peli. Quando renderizziamo la copia dell’oggetto, dobbiamo sapere quali pixel saranno visibili (che sono quelli che appartengono alla ciocca) e quali no. Inseriremo queste informazioni in una texture nella quale il pixel opaco rappresenterà un pelo, il pixel trasparente sarà semplicemente “saltato”. Quando creiamo questa texture, genereremo i valori a caso. Faremo anche in modo di decidere la densità dei peli e la loro lunghezza, per poter customizzare questo effetto al massimo.
- La direzione della proiezione. In generale, potremmo proiettare la geometria semplicemente verso l’alto, anche se non si adatterà a tutte le situazioni. Comunque, l’obiettivo nostro è di fare uno shader generico: proietteremo il modello lungo le normali dei vertici, in modo da creare un modello che si adatti a tutte le mesh ma che non si discosti troppo dal reale. In ogni copia muoveremo la geometria originale di un po’, lungo le normali.
- Il colore dei peli. Questo non è un gran problema, semplicemente useremo il colore della texture, e poi sarà possibile aggiungere i vari effetti di luce.
Vediamo ora come generare una mappa di pelo (che chiameremo Fur Map).
Come visto sopra, la Fur Map servirà a contenere i pixel nei quali ci saranno ciocche di peli e dove no. Calcoleremo il numero dei pixel in base ad una densità, e le posizioneremo a caso sulla texture.
void __fastcall MakeFurMap()
{
#define TEXTSIZE 768
static const float density = 0.3f;
srand((UINT)GetTickCount() * 0.0001);
D3D10_TEXTURE2D_DESC desc;
desc.ArraySize = 1;
desc.BindFlags = D3D10_BIND_SHADER_RESOURCE;
desc.CPUAccessFlags = 0;
desc.Usage = D3D10_USAGE_IMMUTABLE;
desc.MiscFlags = 0;
desc.MipLevels = 1;
desc.SampleDesc.Count = 1;
desc.SampleDesc.Quality = 0;
desc.Width = TEXTSIZE;
desc.Height = TEXTSIZE;
desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
const unsigned int totalpixels = TEXTSIZE * TEXTSIZE;
D3DCOLOR *Colori = new D3DCOLOR[totalpixels];
memset(Colori,0,sizeof(D3DCOLOR) * totalpixels);
int sRands = static_cast<int>(density * totalpixels);
for (int i = 0; i < sRands; i++)
{
int x = rand() % TEXTSIZE;
int y = rand() % TEXTSIZE;
Colori[x * TEXTSIZE + y] = D3DCOLOR_RGBA(150,20,0,255);
}
D3D10_SUBRESOURCE_DATA data;
data.pSysMem = Colori;
data.SysMemPitch = desc.Height;
device->CreateTexture2D(&desc,&data,&FurMap);
delete[] Colori;
D3D10_SHADER_RESOURCE_VIEW_DESC dsc;
dsc.Format = desc.Format;
dsc.ViewDimension = D3D10_SRV_DIMENSION_TEXTURE2D;
dsc.Texture2D.MipLevels = 1;
dsc.Texture2D.MostDetailedMip = 0;
device->CreateShaderResourceView(FurMap,&dsc,&SFurMap);
shader->GetVariableByIndex(9)->AsShaderResource()->SetResource(SFurMap);
}
Vediamo le parti salienti del codice.
In primo luogo scelgo la grandezza della texture (ovviamente più grande più l’effetto sarà realistico, a discapito di velocità e memoria). Più piccola è la texture, più grande sarà il singolo pelo (fino a diventare proprio delle patatine) e la densità del pelo, e preparo il seme per la funzione rand, cosi ad ogni esecuzione avremo peli posizionati in zone diverse.
Preparata la struttura per la creazione della texture, generiamo un array di D3DCOLOR (che è un typedef di DWORD).
Così, in base alla densità, impostiamo un ciclo e mettiamo in valori casuali un colore con 255 come trasparenza (che vuol dire pixel da visualizzare).
A questo punto creiamo la texture con i nostri dati, e dopo di ché uno shader resource view che impostiamo anche allo shader.
Come abbiamo detto poco fa, abbiamo bisogno di disegnare un certo numero di copie dell’oggetto in modo adeguato per ottenere l’effetto desiderato. Tutte le copie devono essere semplicemente proiettate di una piccola quantità lungo le normali. Generare tutte queste copie usando la CPU sarebbe una perdita di tempo. Avendo accesso ad una pipeline programmabile quale Vertex Shader, è possibile accollare il carico direttamente alla GPU, che eseguirà in modo nativo ciò che impiegherebbe la CPU per un gran numero di cicli.
Ecco quindi il nostro prossimo punto: lo shader che gestirà le copie multiple degli oggetti.
float4x4 ViewMatrix;
float4x4 WorldMatrix;
float4x4 ProjMatrix;
float4 LightDirection = { 0.577f, 0.577f, 0.577f, 0.0f };
float4 MaterialColor;
float4 EyePosition;
float CurrentLayer;
float MaxLunghezzaPelo;
float3 Displacement;
Texture2D FurMap;
Texture2D Texture;
BlendState Blending
{
AlphaToCoverageEnable = FALSE;
BlendEnable[0] = true;
SrcBlend = SRC_ALPHA;
DestBlend = INV_SRC_ALPHA;
BlendOp = ADD;
RenderTargetWriteMask[0] = 0x0F;
};
SamplerState FurText
{
Filter = MIN_MAG_MIP_POINT;
AddressU = WRAP;
AddressV = WRAP;
};
SamplerState SamplText
{
Filter = ANISOTROPIC;
AddressU = WRAP;
AddressV = WRAP;
};
struct VS_INPUT
{
float4 Pos : POSITION;
float3 Tex : TEXCOORD;
float3 Nor : NORMAL;
};
struct PS_INPUT
{
float4 Pos : SV_POSITION;
float4 WorldPos: WORLDPOSITION;
float3 Tex : TEXCOORD;
float3 Nor : NORMAL;
};
PS_INPUT vs_main(VS_INPUT Input)
{
PS_INPUT Out = (PS_INPUT)0;
float3 Pos = Input.Pos.xyz + (Input.Nor * CurrentLayer * MaxLunghezzaPelo);
Out.WorldPos = mul(float4(Pos,1),WorldMatrix);
Out.Pos = mul(mul(Out.WorldPos,ViewMatrix),ProjMatrix);
Out.Tex = Input.Tex;
Out.Nor = mul(Input.Nor,WorldMatrix);
return Out;
}
float4 ps_main(PS_INPUT In) : SV_TARGET
{
return FurMap.Sample(FurText,In.Tex);
}
technique10 FurTech
{
Pass Fur
{
SetVertexShader( CompileShader( vs_4_0, vs_main() ) );
SetGeometryShader(NULL);
SetPixelShader( CompileShader( ps_4_0, ps_main() ) );
SetBlendState(Blending,float4(0,0,0,0),0xffffffff);
}
}
Lo shader è di per se semplicissimo: l’unico cambiamento al solito Posizione e Texture è uno spostamento lungo la normale di un valore CurrentLayer (che sarebbe il numero di copie dell’oggetto che creeremo).
Il blendstate imposta in modo che se l’alpha è = 0, il pixel verrà lasciato trasparente.
La funzione di rendering associata a questo shader è anch’essa semplice.
for (UINT f = 0; f < LayerNum; f++)
{
shader->GetVariableByIndex(6)->AsScalar()->SetFloat(static_cast<float>(f)/LayerNum);
for (unsigned int i = 0; i < geometry->MatNum; i++)
{
shader->GetVariableByIndex(10)->AsShaderResource()->SetResource(geometry->Textures[i]);
shader->GetTechniqueByIndex(0)->GetPassByIndex(1)->Apply(0);
geometry->Mesh->DrawSubset(i);
}
}
Il motivo per il quale mandiamo allo shader il quoziente di f/LayerNum è chiaro: sarà così possibile passare un valore float limitato tra 0 e 1. Questo ci fa riflettere anche sul perché della moltiplicazione con MaxLunghezzaPelo (impostatelo come volete).
Ok, se non siete arrivati al perché vi spiegherò meglio nel dettaglio. Per definizione, la normale è normalizzata. Se la normale è normalizzata, vuol dire che la sua lunghezza è uguale a 1. Non potendo uscire fuori da questo valore, abbiamo bisogno:
- Che i valori lungo cui effettuare il displacement siano compresi tra 0 e 1 (e ciò spiega f/LayerNum.)
- Volendo allungare di più il pelo bisogna per forza effettuare una moltiplicazione sul risultato ottenuto, altrimenti la teorica lunghezza massima del pelo sarebbe 1. E praticamente non avremo altro che delle proiezioni di alcune normali della geometria.
In effetti, anche ora non abbiamo altro che delle proiezioni di alcune normali, solo un po’ più lunghe. Risolveremo questo problema più avanti.
Avviate e controllate il risultato che sarà una cosa del genere.
Il colore del pelo dipenderà dal colore dato nel creare l’array di D3DCOLOR.
Effettuate le vostre feste, cominciamo a vedere i primi miglioramenti.
In primo luogo è il caso di colorare i peli utilizzando la texture originale dell’oggetto, ma soprattutto rendere visibile il primo livello della geometria.
Per il colore, basterà usare quello originale della texture e prendere dalla Fur Map solo il valore dell’Alpha anziché Alpha+Colori.
Praticamente, da questa riga
Colori[x * TEXTSIZE + y] = D3DCOLOR_RGBA(150,20,0,255);
Preleveremo soltanto il 4 valore, gli altri 3 li prenderemo dalla texture della geometria.
Il nostro nuovo pixel shader, sarà
float4 ps_main(PS_INPUT In) : SV_TARGET
{
float4 Fur = FurMap.Sample(FurText,In.Tex);
float4 TextColor = Texture.Sample(SamplText,In.Tex);
TextColor.a = (CurrentLayer == 0) ? 1 : Fur.a;
return TextColor;
}
Con il quale otterremo un risultato già ben diverso.
In pratica, basta aggiungere
TextColor *= lerp(0.4,1,CurrentLayer);
Subito dopo il caricamento della texture, per ottenere questo.
L’effetto è completamente cambiato vero? Non preoccupatevi delle aree vuote: basterà aumentare la densità generale per limitare questo effetto (altrimenti fate meno i tirchi e aumentate le dimensioni della Fur Map).
Prossimo miglioramento: tutti i peli hanno la stessa lunghezza. Sarebbe però molto più realistico che ogni pelo abbia una lunghezza propria. Dato che il colore viene preso dalla texture, possiamo usare un altro valore dell’array di D3DCOLOR per passare la lunghezza massima del pelo. Bastano poche modifiche:
Come colore G, passiamo un semplice rand(). Nello shader, limiteremo i valori in questo modo.
float4 ps_main(PS_INPUT In) : SV_TARGET
{
float4 Fur = FurMap.Sample(FurText,In.Tex);
float4 TextColor = Texture.Sample(SamplText,In.Tex);
TextColor *= lerp(0.4,1,CurrentLayer);
float furVisibility = ( CurrentLayer > Fur.g) ? 0 : Fur.a;
TextColor.a = (CurrentLayer == 0) ? 1 : furVisibility;
return TextColor;
}
Ecco il nostro nuovo risultato. Peli a lunghezza variabile. Ovviamente il canale R può essere modificato a piacimento, usando formule con seno, coseno, equazioni con la posizione e qualsiasi altra cosa, e ottenere tantissimi tipi di risultati. Nessuno vieta di usare gli altri 2 valori dell’array di D3DCOLOR per passare altri tipi di informazioni in base alla ciocca.
L’ultima cosa che ci rimane da fare, è un minimo di movimento e deformazione dovuta alla gravità.
Semplicissimo: basta sommare il risultato del vertex shader per un vettore apposito che sarà dato da gravità (il vettore {0, -1,0} e uno di movimento generico).
PS_INPUT vs_main(VS_INPUT Input)
{
PS_INPUT Out = (PS_INPUT)0;
float3 Pos = Input.Pos.xyz + (Input.Nor * CurrentLayer * MaxLunghezzaPelo);
float DisplacementFactor = pow(CurrentLayer,3);
Out.WorldPos = mul(float4(Pos,1),WorldMatrix);
Out.WorldPos.xyz += Displacement * DisplacementFactor;
Out.Pos = mul(mul(Out.WorldPos,ViewMatrix),ProjMatrix);
Out.Tex = Input.Tex;
Out.Nor = mul(Input.Nor,WorldMatrix);
return Out;
}
Con il vettore Displacement così valorizzato
D3DXVECTOR3 Displacement(sin(GetTickCount() * 0.001),0,0);
Displacement.x -= 3;
Si ottiene un vento quasi perenne a destra, con qualche lieve spostamento verso il centro (grazie alla periodicità del seno) (questo è il mio effetto preferito)
Ma potete quando volete creare le vostre formule di movimenti semplicemente modificando il vettore. Potreste addirittura far ruotare i peli semplicemente moltiplicando il vettore per un’opportuna matrice che farete poi andare in un ciclo.
Lo stesso effetto con una texture di dimensioni 2560
Provate ora ad aprire il modello originale semplicemente: vi stupirete del salto di qualità fatto con una tecnica così semplice da applicare.
Voglio farmi vedere una cosa che ho notato zoomando la mesh pensando che ci fossero problemi di antialiasing:
Si possono vedere chiaramente le copie dell’oggetto e il loro displacement lungo le normali con le dovute modifiche. Se avete la pazienza di contarle tutte le copie, potrete constatare che 0 < Copie < NumeroLayers
NOTE SULL’INSTANCING
E’ possibile velocizzare le prestazioni della scena tramite il geometry instancing. Non vi spiegherò come fare, vi rimando all’esempio che trovate allegato (usate il tasto spazio per switchare dalla modalità normale a quella Instancing), ma darò i punti chiave della sua realizzazione
· Dovendo generare più copie dello stesso oggetto, l’instancing è ottimo in questo caso. Si possono anche triplicare le performance.
· Il float CurrentLayer e i suoi dati creati al volo nel rendering andranno precalcolati e messi in un secondo vertex buffer: quindi il valore verrà passato dalla struttura di input del VertexShader.
· Il ciclo for del passaggio dei livelli andrà quindi eliminato, riducendo le chiamate alla funzione draw di NumeroCopie volte. Su un semplice oggetto da 5 materiali (come il dinosauro qui usato), le chiamate da 200 (ipotizzando 40 layers) diventano semplicemente 5.
· Nota per chi usa ID3DX10Mesh: la funzione DrawSubsetInstanced è buggata. Ho fatto un report alla Microsoft ( che sembra se ne sia fregata ): effettua la sovrascrittura del primo buffer nel secondo, invece di cambiare l’indice in IASetVertexBuffers (potete accorgervene usando PIX), quindi prevedete di disegnare usando i semplici Buffer (che alla fine sono sempre i migliori.)
Vincenzo Chianese – http://xvincentx.netsons.org/programBlog
Demo