Sprite

Come creare uno "sprite" e animarlo. Special thanks to Elevator2 per gli sprites di megaman
Autore: Stefano Cristiano Livello:
Il termine sprite sta ad indicare in genere un rettangolino di pixel che si muovono sullo schermo. Se avete mai giocato ad un qualsiasi gioco 2D, che ne so Super Mario Bros per fare un esempio, il vostro caro Mario è perfettamente definibile come uno “sprite” bidimensionale. Detto questo possiamo subito dividere gli sprite in due categorie, quelli animati e quelli non animati. Per animati intendo degli sprites dove con il passare del tempo vengono mostrati diversi frame di animazione in maniera sequenziale, per dare appunto l’impressione del movimento (esattamente come il cinema). Per ora ci occuperemo solo di sprites “statici” poi vedremo come realizzare sprite animati.

Quali sono i parametri che definiscono un sprite statico?Beh, sicuramente ogni sprite porta sempre con se una texture “sorgente” dalla quale legge i pixel. Poiché in generale gli sprite non hanno dimensioni molto elevate, di solito si preferisce, per questioni legate all’hardware e per evitare di avere 10000 textures, mettere più bitmap nella stessa texture una di fianco l’altra. All’atto del rendering poi si specificano coordinate texture opportune in modo tale da andare a “beccare” esattamente il rettangolino della texture contenente i pixel che ci interessano

Altri parametri sono sicuramente la posizione (quindi coordinate x e y), un angolo di rotazione.

Esiste un simpaticissimo oggetto COM di D3DX chiamato ID3DXSprite che fa esattamente quello che ci serve. Vediamo prima come si usa, nell’esempio “PrimoSprite”:


bool OnCreateDeviceObjects()
{
HRESULT hr;
hr = D3DXCreateTextureFromFile(GetDevice(),"data\\ruota.tga",&texture);
CHECKHR(hr);
return true;
}

bool OnDraw()
{
HRESULT hr;
IDirect3DDevice8* lpDev = GetDevice();
D3DVIEWPORT8 view;
lpDev->GetViewport(&view);
float t = wrapper->fTime;
sprite->Begin();
D3DXVECTOR2 center(128,128);


for(int i = 0; i < 5; i++)
{

D3DXVECTOR2 pos=...;//Omissis...
D3DXVECTOR2 size( sinf(t)*0.2f + 0.8f,
sinf(t)*0.2f+0.8f);
D3DXCOLOR color(1,sinf(i*t/4),cosf(t/5),1);
float angle = t*(4-i);
hr = sprite->Draw(texture,0,&size,¢er,
angle,&pos,color);
CHECKHR(hr);
}
sprite->End();
return true;
}


Creiamo la texture e renderizziamo 5 sprites, disegnandoli attraverso ID3DXSprite::Draw. Da notare che le chiamate a questa funzione sono posizionate tra ID3DXSprite::Begin() e ID3DXSprite::End(). Se dovete disegnare tanti sprites queste due funzioni fanno si che non vengano cambiati e rimessi a posto continuamente i render state necessari per il disegno degli sprites. I parametri di ID3DXSprite::Draw() sono abbastanza auto-esplicativi: la texture sorgente, il sotto-rettangolo dal quale leggere i pixel (in questo caso avendo passato 0 chiediamo l’intera texture), un vettore con dei fattori di scala, un vettore con il centro di rotazione, l’angolo di rotazione (in radianti), la posizione ed un colore che viene modulato sulla texture. In quest’esempio ho provato ad animare quasi tutti i parametri. Su ID3DXSprite non c’è moltissimo altro da dire, se non che se il vostro problema sono le performance, effettivamente questa classe non è il massimo. Il problema sta nel fatto che si disegna 1 sprite alla volta. In DirectXGraphics in generale è buona cosa effettuare il batching delle primitive, in parole povere riempire un vertex buffer con “un bel po’” di sprites e mandarli al device di rendering tutto in una volta. Ovviamente se siete alle vostre prime esperienze nel campo, non preoccupatevi di questo, il mio era solo un avvertimento nel caso volesse usare ID3DXSprite per qualcosa di più serio.

Esiste anche un altro metodo chiamato ID3DXSprite::DrawTransform() che accetta una matrice di trasformazione che dovrà contenere concatenate tutte le trasformazioni che ci interessano. Le matrici da usare sono le stesse di cui abbiamo parlato riguardo l’animazione delle coordinate texture. Ho scritto anche una classe chiamata DX8Sprite che emula più o meno bene quello che fa ID3DXSprite. La sua utilità è solo didattica, non ha senso usare questa classe al posto di ID3DXSprite, ma può essere utile per capire cosa succede “dietro le quinte” ed eventualmente per modificarla se ID3DXSprite non vi soddisfa pienamente.

L’esempio in questione si chiama "CopiandoID3DXSprite ".

Algoritmo delle "Bisezioni ". Texture Packer
Introduciamo ora una classe veramente utile a chi fa 2D con DirectXGraphics, che prende in input un set di IDirect3DSurface (ottenute da dove vi pare, anche da una texture con GetSurfaceLevel per esempio) e cerca di inserirle nel modo più ottimizzato possibile in una texture più grande con le dimensioni che più vi aggradano.

Questa classe la utilizzeremo nel nostro sprite system, ma può anche essere utilizzata per esempio per gli algoritmi di lightmapping. Il lightmapping è quella tecnica usata dalla maggior parte degli engine della penultima generazione, dove viene precalcolata una piccola “mappa” di illuminazione per ogni poligono. Una scena 3D è composta da un bel po’ di poligoni, e se immaginate di creare una texture per ognuno di essi state a posto…Grazie al cielo ci basta utilizzare il nostro Texture Packer e potremo “raccogliere” dei bei gruppi di lightmap piccole in texture più grandi e facilmente gestibili.

In primo luogo cerchiamo di capire il funzionamento l’algoritmo delle “Bisezioni”, alla base di questa classe. Potremmo paragonare quest’algoritmo bidimensionale a quanto succede per i BSP tree in tre dimensioni.

Immaginiamo di avere un nodo vuoto, di a cui è assegnato un rettangolo di dimensioni qualsiasi. Vogliamo inserire una bitmap al suo interno:

- Se le dimensioni della bitmap sono maggiori di quelle del rettangolo, vuol dire che non c’è spazio, torniamo al nodo padre (o terminiamo l’algoritmo se siamo il nodo padre)

- Se c’e’ spazio, la posizioniamo nell’angolo in alto a sinistra del nostro rettangolo iniziale

- Suddividiamo il rettangolo iniziale in altri due, prendendo i prolungamenti dei lati che non coincidono con i bordi.

- Per ognuno di questi rettangoli figli possiamo ripetere il ragionamento fatto per quello “padre” e così via, ricorsivamente.

Se proviamo ad inserire un bel po’ di surface, noteremo che esse si disporranno ordinatamente in questi rettangoli, cercando di minimizzare lo spreco inevitabile di spazio. Per evitare tali sprechi bisogna:

- Evitare per quanto possibile dimensioni di surface “strane”. Voglio dire: non è molto facile inserire tante bitmap 45x67 e 43x88 in una texture 256x256 senza sprecare spazio…

- Ordinare le texture per dimensione, dalla più grande alla più piccola.

L’ultima cosa da dire su questo algoritmo è che in alcuni casi può essere necessario lasciare una spaziatura, più o meno grande, tra una “tile” e quelle ad essa adiacenti. Per capire i motivi di questa scelta bisogna capire cos’è il filtering. Quando bisogna disegnare a schermo una texture con dimensioni diverse da quelle originali, tutti gli acceleratori esistenti applicano degli algoritmi per tentare di “addolcire” l’immagine in questione, ed evitare l’effetto “pixel”. Per effetto pixel intendo la visione dei “quadrettoni” che compongono la texture. Di tutti questi algoritmi sicuramente il più famoso è il bilinear filtering ed alcune sue varianti, dove ogni pixel viene filtrato effettuando una media pesata di quelli adiacenti. Potete ben capire che nel momento in cui ci divertiamo a fare questi giochetti con il nostro texture packer, inserendo diverse tile all’interno della stessa texture, avremo qualche problema sui loro bordi quando tenteremo di disegnarle. Se per esempio abbiamo due tile, una completamente bianca ed una completamente nera messe vicino nella texture, i bordi di entrambe le tile sfumeranno verso il grigio quando le applicheremo ad un qualsiasi poligono a schermo. Per evitare ciò si lascia di solito una spaziatura tra le tile e si copia l’ultima fila di pixel adiacenti al lato, in modo da aggirare questo sgradevole problema.

L’esempio chiamato "Bisezioni " fa esattamente questo lavoro, ed in più vi permette di vedere interattivamente come vengono disposte le tiles dall’algoritmo. Il nodo di tutto il programma sta nella classe TexturePacker che si utilizza in questo modo:


TexturePacker packer(GetDevice());
int spacing = 2;
packer.Begin(256,256,D3DFMT_R8G8B8,spacing);
for(int i = 0; i < numtex; i++)
{
packer.AddTexture(surfaces);
}


packer.End();
texture = packer.GetPackedTexture();


Create l’oggetto passando il device, chiamate TexturePacker::Begin(…) passando le dimensioni della texture finale in cui verranno inserite le tiles, il suo formato e la spaziatura tra una tile e l’altra. Attraverso il metodo AddTexture() aggiungete le tiles che vi interessano e se questo metodo ritorna false vuol dire che non c’è più spazio. Finito il tutto ci basta chiamare TexturePacker::End() e prendere un puntatore alla texture generata con TexturePacker::GetPackedTexture().

Quasi sicuramente una volta effettuata questa operazione, avrete bisogno di effettuare una trasformazione di tutte le coordinate texture dal loro valore nelle vecchie texture ai nuovi valori nella texture “packed” (vi ricordo che facendo AddTexture() non sappiamo dove l’algoritmo andrà a posizionare la nostra tile). Per tale motivo ho introdotto due metodi abbastanza comodi che fanno questo lavoro:


std::vector coords;
for(int j=0; j < 3; j++)//…inseriamo le coordinate da convertire
packer.RemapTexCoords(surf,coords)

//oppure
RECT rect;
packer.RemapRect(surf,&rect)


Queste funzioni prendono in input le coordinate texture da convertire e la surface a cui sono riferite e ritornano le coordinate texture nel nuovo “sistema di riferimento”, ovverosia nella nuova texture. Si possono sia convertire le coordinate nella forma “percentuale” (utile per il 3D) oppure i rettangoli con dimensioni espresse in pixel (nel caso per esempio si faccia 2D con ID3DXSprite, che vuole le dimensioni in pixel). Queste funzioni non fanno nulla di particolarmente difficile:


bool TexturePacker::RemapTexCoords(IDirect3DSurface8* surface,std::vector& coords)
{
img_node* node = root->FindNode(surface);
if(!node)
return false;

for(int j=0; j < coords.size(); j++)
{
texcoord& c = coords[j];
c.newx = ((float)node->rect.left+c.x*node->width)/(float)width;
c.newy = ((float)node->rect.top +c.y*node->height)/(float)height;
}
return true;
}

bool TexturePacker::RemapRect(IDirect3DSurface8* surface,RECT* rect)
{
img_node* node = root->FindNode(surface);
if(!node)
return false;
OffsetRect(rect,node->rect.left,node->rect.top);
return true;
}


Entrambe cercano il nodo associato a quella surface:RemapRect “trasla” le vecchie coordinate nel nuovo sistema mentre RemapTexCoords applica una banale proporzione per convertire le vecchie percentuali nel nuovo sistema.

Animare uno sprite
Avendo a disposizione la comodissima TexturePacker, non ci vuole molto a mettere su un sistema per animare degli sprites. Per animare ovviamente intendo disegnare a schermo una sequenza di immagini che rappresentano le varie configurazioni temporali del nostro sprite, esattamente come accade per i cartoni animati. Se attraverso il nostro packer riusciamo a mettere tutte le nostre tile ognuna delle quali rappresenta un frame di animazione, in un’unica texture ci basterà giocare con le coordinate texture. Tra l’altro le coordinate texture le possiamo ottenere attraverso TexturePacker::RemapRect(…) e allora…il gioco è fatto. L’esempio “PrimoSpriteAnimato” non fa altro che utilizzare in maniera furba quanto detto fin’ora, prende delle bitmap lette da disco contenenti i singoli frames, li assembla con il packer, salva le nuove coordinate texture è le mette in ciclo temporizzandole per ottenere l’effetto dell’animazione. Il codice da scrivere è davvero poco:


IDirect3DTexture8* texture;
ID3DXSprite* sprite;
std::vector frames;
int currFrame;
float lastTime;

bool OnCreateDeviceObjects()
{
std::vector surfs;
TexturePacker packer(GetDevice());
packer.Begin(256,256,D3DFMT_X8R8G8B8,2);
for(int i = 2; i < 16; i++)
{
IDirect3DSurface8* surf=0;
TCHAR buf[400];
sprintf(buf,"tile2%d",i);
surfs.push_back(LoadSurfaceFromFile(GetDevice(),buf));
packer.AddTexture(surfs[surfs.size()-1]);
}
texture = packer.GetPackedTexture();
frames.clear();

for(i =0; i < surfs.size();i++)
{
RECT r;
D3DSURFACE_DESC desc;
surfs->GetDesc(&desc);
SetRect(&r,0,0,desc.Width,desc.Height);
packer.RemapRect(surfs,&r);
frames.push_back(r);
SURERELEASE(surfs);
}

packer.End();
surfs.clear();
return true;
}

bool OnDraw()
{

float t = wrapper->fTime;
if((t-lastTime)>0.08f)
{
currFrame++;
if(currFrame==frames.size())
currFrame = 0;
lastTime = t;
}
RECT r=frames[currFrame];
D3DXVECTOR2 pos=//...Omissis;
sprite->Draw(texture,&r,0,0,0,&pos,0xffffffff);
return true;
}


Non c’e’ niente di particolare da dire, durante il disegno si pensa solamente a scegliere il rettangolo “sorgente” all’interno della texture, scegliendo tra quelli nel vettore “frames”, indicizzando con un contatore che viene messo in loop (currFrame). In questo caso abbiamo temporizzato il tutto ad 8/100 di secondo, ecco a che serve quello 0.08f. Beh, in una manciata di righe di codice abbiamo in mano ora tutti gli elementi per uno sprite system di tutto rispetto.

Sprite System
Cerchiamo ora di mettere un po’ d’ordine in tutte queste cose che abbiamo imparato. Creiamo uno sprite system non banale che legge le definizioni di sprites da files di testo e fornisce delle astrazioni che ne rendono più facile l’utilizzo. La più importante di queste astrazioni è sicuramente il concetto di “azione” che potremmo definire come segue. Ogni nostro “personaggio” 2D (sprite) può fare diverse cose, per esempio correre, saltare, scomparire, etc etc. Ognuna di queste “azioni” non è altro che un’opportuna sequenza di bitmap messe nel giusto ordine. Questa è esattamente la definizione di azione che ci serve. Prima di entrare nel dettaglio vediamo innanzitutto COME può essere usato lo sprite system dall’esterno. Un consiglio un po’ fuori tema: quando progettate dei sotto-sistemi come stiamo facendo noi ora, è molto utile scrivere codice facendo finta di usare il sistema prima ancora di averlo progettato e programmato. So che ciò può sembrare senza senso, ma in questo modo avrete sotto i vostri occhi il modo più semplice e naturale di usare quell’interfaccia, che sarà un’importantissima guida nella sua progettazione.


SpriteManager* manager = new SpriteManager;
Attach(manager);

Sprite* sprite;
sprite = manager->LoadSprite("IlMioSprite.sprite");
if(!sprite)
return false;//Errore!!!

sprite->SetAction("muori");
//....
sprite->SetAction(3);
//...
sprite->SetActionSequence("action,TeleTrasporto,TeleTrasportoRitorno,energy");
//...per animarlo...
sprite->x = ...;//quello che ci pare
sprite->y = ...;//quello che ci pare


Sinceramente non riesco ad immaginare codice più semplice da usare di questo: creiamo uno SpriteManager, un’oggetto che terrà conto di tutti gli sprite allocati e delle risorse. Poi attraverso questo manager leggiamo uno sprite, la cui definizione è salvata in un file di testo con estensione “.sprite” e impostiamo le azioni che ci interessano con dei nomi, oppure attraverso un numero. Possiamo anche impostare delle “sequenze” di azioni che saranno eseguite l’una dopo l’altra. Attraverso alcuni membri (ad es. sprite->x e sprite->y) possiamo spostare e ruotare il nostro sprite. Non abbiamo nemmeno bisogno di chiamare esplicitamente i metodi di disegno o di temporizzazione dello sprite, sarà lo SpriteManager a farlo (dato che è nostro figlio tramite l’Attach, quindi riceve i messaggi OnDraw, OnAnimate() etc). Questo codice l’ho scritto PRIMA di fare il coding del sistema vero e proprio. Per eliminare lo sprite system e tutti gli sprites allocati (e tutte le risorse allocate, cioè texture, oggetti ID3DXSprite etc) ci basta fare SUREDESTROY(manager). Sono questi i vantaggi della programmazione OOP ad “Albero” come illustrato nel capitolo introduttivo.

Il formato dei file di definizione degli sprite è veramente banale:


MegaMan
[
action //nome azione
25 //FPS
noloop //Loop flag
]
action\00_mgmaction1.bmp
action\01_mgmaction1.bmp
//…
action\16_mgmaction1.bmp
[
corsa
20
loop
]

corsa\02_megaman.bmp
//…
corsa\15_megaman.bmp


La prima riga è il nome dello sprite, poi ogni azione è definita da una parentesi quadra aperta e una chiusa dove c’è il nome dell’azione, il numero di frames per secondo a cui deve essere temporizzata quell’azione, un flag che dice se l’azione deve essere eseguita in loop oppure no. Dopo la definizione di azione ci sono i percorsi (anche relativi) delle immagini che definiscono i singoli frames. Il parsing di questo formato di definizione è molto banale, il nostro sprite system legge tutto ciò e crea uno sprite con le azioni ad esso associate. A livello di codice possiamo sapere praticamente tutto, il nome, il numero delle azioni, i FPS, basta chiamare i metodi giusti. Possiamo anche fermare, far ripartire un’animazione. In realtà questo sistema potrebbe essere ulteriormente migliorato: per esempio viene creata una nuova texture attraverso il packer per ogni azione. Sebbene questo renda molto più facile la scrittura del codice, potenzialmente potrebbe essere causa di sprechi, anche ingenti, di memoria texture.

Penso sia abbastanza inutile dilungarsi nella spiegazione del sistema riga per riga, lo ripeto, si è cercato solamente di mettere ordine in quello che è stato già trattato e dopotutto i sorgenti sono commentati abbastanza dettagliatamente.
Voto medio articolo: 2.5 Numero Voti: 2
Stefano Cristiano

Stefano Cristiano

3D Coder. Sto lavorando ad un engine chiamato "Muse Engine" di cui potete leggere al mio sito http://pagghiu.gameprog.it Profilo completo

Articoli collegati

Integrare le Direct3D 8.1 con MFC Usando Visual Studio 6.0
In questo articolo mostreremo come creare una semplice applicazione SDI (Single Document Interface), in cui mostrare un oggetto tridimensionale in formato *.X e di farlo ruotare intorno all'asse Y.
Autore: Biagio Iannuzzi | Difficoltà:
Particle System
Brevi cenni su come implementare un particle system in DirectX
Autore: Stefano Cristiano | Difficoltà: | Voto:
Texture Blending (Parte II) Quake 3 Arena Shader System
Questo articolo è simile alla Parte I e ripete molti aspetti però tratta anche un pò degli shaders di Quake 3 Arena.
Autore: Stefano Cristiano | Difficoltà: | Voto:
Texture Blending (Parte I)
Come gestire gli effetti di blending tra texture. Multitexturing e Multipassing. Effect Files
Autore: Stefano Cristiano | Difficoltà: | Voto:
Texture
Introduzione al concetto di texture, di coordinate texture. Come animare le coordinate texture
Autore: Stefano Cristiano | Difficoltà:
Grafica 2D in DirectX. Vertex Buffers
Introduzione alla grafica 2D. Utilizzo dei vertex buffers
Autore: Stefano Cristiano | Difficoltà: | Voto:
Debbugging di applicazioni DirectX
Consigli e strategie per il Debugging delle applicazioni DirectX.
Autore: Stefano Cristiano | Difficoltà: | Voto:
Framework DXGWrapper
Spiega la filosofia di fondo del framework ideato dal sottoscritto ed usato da tutti i samples degli articoli relativi a DirectX.
Autore: Stefano Cristiano | Difficoltà: | Voto:
Gamma Correction e Colori
Una trattazione breve ma con una base matematica che spiega in parole povere il problema del fattore gamma e i colori in generale
Autore: Stefano Cristiano | Difficoltà: | Voto:
Inizializzazione e schermo
Si parla dell'inizializzazione dello schermo e delle modalità video in DirectX e di come gestire gli eventi "lost device", come scrivere un Game Loop e alcune info sul VSYNC
Autore: Stefano Cristiano | Difficoltà: | Commenti: 1 | Voto:
Introduzione a DirectX
Introduzione al mondo DirectX , requisiti, tools necessari, configurazione dell’ambiente di sviluppo.
Autore: Stefano Cristiano | Difficoltà: | Commenti: 2 | Voto:
Copyright © dotNetHell.it 2002-2024
Running on Windows Server 2008 R2 Standard, SQL Server 2012 & ASP.NET 3.5