INTRODUZIONE

Nella scorsa lezione dopo aver analizzato tutta la pipeline di rendering abbiamo disegnato le facce del cubo utilizzando le funzioni OpenGL. In realta' disegnare i triangoli su schermo merita un discorso ben piu' ampio. Oggi affronteremo il problema e, come al solito, inizieremo con un po' di teoria e poi metteremo in pratica quanto studiato utilizzando i comandi ad alto livello della libreria OpenGL. Alla fine della lezione saremo in grado di implementare la tecnica del Texture Mapping.

MODALITA' DI DISEGNO DEI POLIGONI

Fino a qualche anno fa, quando i motori grafici erano sviluppati per MS DOS, anche disegnare un solo punto su schermo richiedeva tantissime righe di codice in assembly. Si lavorava a basso livello ed in MS-DOS poiche' l'hardware di allora non consentiva di creare applicazioni veloci utilizzando sistemi operativi evoluti e librerie grafiche. Non era possibile sprecare le risorse di sistema inutilmente se si volevano creare motori grafici performanti. Era necessario inizializzare il contesto grafico utilizzando l'interrupt 0x10 del BIOS e, se si voleva lavorare in alta risoluzione, lo standard VESA. Poi si dovevano creare tutte le funzioni per la stampa a video. Noi siamo più fortunati, i nostri potenti PC ci consentono di lavorare ad alto livello sotto Windows, Linux e Mac senza rinunciare alla velocità ed oltretutto la nostra cara libreria OpenGL ci risparmia ore, giorni, mesi di lavoro e ci consente di sfruttare appieno le capacità della nostra costosa scheda video 3d.

Nella precedente lezione abbiamo visto come, con un semplice comando: glPolygonMode, potevamo scegliere se stampare il poligono come insieme di punti, linee, o riempito di colore. Spieghiamo in breve le varie modalita:

  • Poligono disegnato come insieme di punti: e' la modalità più semplice ed anche quella che richiede minori risorse hardware, in pratica vengono stampati su schermo tutti i punti corrispondenti ai vertici del poligono.
  • Poligono disegnato come insieme di linee: questa modalità e' chiamata Wireframe. In pratica si disegna il perimetro del poligono congiungendo tutti i vertici tramite segmenti. Il disegno di questi ultimi viene svolto utilizzando un veloce algoritmo per il tracciamento di linee (algoritmo di Bresenham)
  • Poligono riempito con colore: questa modalità richiede molte più risorse rispetto alle precedenti in quanto e' necessario stampare tantissimi punti fino a riempire il poligono. Quest'ultimo viene disegnato riga per riga (tramite Bresenham) partendo dal vertice più in alto ed interpolando linearmente i bordi del poligono per trovare i punti di inizio e fine di ogni riga.
  • Poligono in Texture Mapping: il Texture Mapping e' una tecnica per coprire un oggetto tridimensionale con un'immagine. Ad ogni poligono dell'oggetto viene assegnata una sezione dell'immagine. Le modalità di riempimento di un poligono in Texture Mapping sono molto simili a quelle del poligono riempito con colore.

LE PRINCIPALI FASI DEL TEXTURE MAPPING

Ci sono tre step principali da aggiungere al nostro motore per implementare il Texture Mapping:

  • Caricare l'immagine in memoria: la prima cosa da fare è di creare una funzione in grado di leggere un file immagine e di memorizzarlo da qualche parte. Il formato che utilizzeremo sarà il Bitmap. Abbiamo scelto di utilizzare questo formato poiché in ambito Windows è il più supportato e poi perché non include alcun algoritmo di compressione, che attualmente ci avrebbe solamente complicato il lavoro.
  • Assegnare ad ogni vertice un punto 2d dell'immagine: in questa fase dobbiamo aggiungere alcuni campi alla struttura object_type che poi utilizzeremo per associare ad ogni vertice un punto 2d dell'immagine, in modo da coprire l'oggetto come desideriamo.
  • Disegnare i poligoni dell'oggetto "coprendoli" con sezioni dell'immagine principale: questa fase (chiamata fase di "Filling") è quella più critica. Ovviamente noi non dobbiamo preoccuparci di eseguire questo lavoro a mano poiché, come del resto ci e' gia capitato, OpenGL ci fornirà dei semplici comandi ad alto livello che faranno tutto il lavoro che in passato sarebbe toccato a noi.

Allora, con queste chiacchiere abbiamo perso fin troppo tempo, quindi mettiamoci subito al lavoro...

IL CARICAMENTO DI UN IMMAGINE

Per prima cosa includiamo nel nostro progetto un nuovo file C/C++ chiamandolo "texture.cpp". Il file ci servirà per contenere tutte le routine per manipolare immagini. I meno esperti di voi a questo punto potranno farsi spaventare dal fatto che stiamo utilizzando più di un file .cpp per creare il nostro progetto. Non fatevi prendere dal panico, in realtà stiamo semplificando il lavoro! Infatti un motore 3d, come qualsiasi programma di una certa complessità, deve necessariamente avere una struttura modulare e questo per vari motivi, primo fra tutti l'ordine! Per adesso non spaventiamoci, creiamo il file ed iniziamo a scrivere la funzione LoadBitmap:

int LoadBitmap(char *filename) 
{
   unsigned char *l_texture;
   int l_index, l_index2=0;
   FILE *file;
   BITMAPFILEHEADER fileheader; 
   BITMAPINFOHEADER infoheader;
   RGBTRIPLE rgb;
  • unsigned char *l_texture; La prima variabile che abbiamo inizializzato è un puntatore ad una zona di memoria dove andremo ad inserire l'immagine. Ogni punto dell'immagine sarà rappresentato da 4 valori unsigned char (con range 0-255). Uno per ogni componente di colore.
  • int l_index, l_index2=0; Sono variabili d'appoggio che ci saranno utili per leggere l'immagine.
  • FILE * file; Sarà il puntatore al file bitmap aperto con l'istruzione fopen.

Le restanti variabili sono molto interessanti, infatti ci consentono di leggere il nostro file con estrema facilità poiché la loro struttura è creata appositamente per lavorare con file .bmp.

  • BITMAPFILEHEADER fileheader; ecco il nostro fileheader! In questa struttura sono contenute informazioni riguardanti il tipo e la dimensione del file bmp che andremo a caricare. A noi questa struttura serve solamente per rintracciare la zona del file in cui è contenuto il BITMAPINFOHEADER infoheader; che ci fornirà invece informazioni utili sulla larghezza e l'altezza dell'immagine.

Successivamente incrementiamo la variabile globale num_texture poichè la nostra funzione dovrà restituire il numero della texture attualmente caricata che ci servirà poi per referenziarla ad OpenGL.

   num_texture++; 

Adesso apriamo il file in modalità lettura (se il file non esiste la nostra funzione ritornerà il valore "-1"), leggiamo il fileheader e, tramite la funzione fseek, spostiamo il puntatore del file in corrispondenza dell'inizio del successivo header.

   if( (file = fopen(filename, "rb"))==NULL) return (-1);
   fread(&fileheader, sizeof(fileheader), 1, file);
   fseek(file, sizeof(fileheader), SEEK_SET);

Così ci andiamo a leggere l'infoheader!

   fread(&infoheader, sizeof(infoheader), 1, file);
   l_texture = (byte *) malloc(infoheader.biWidth * infoheader.biHeight * 4);
   memset(l_texture, 0, infoheader.biWidth * infoheader.biHeight * 4);
  • I campi .biWidth e .biHeight dell'infoheader contengono rispettivamente la larghezza e l'altezza dell'immagine che stiamo leggendo. Tali valori ci servono per allocare la quantità esatta di memoria che ci serve. Infatti la dimensione dell'immagine sarà data dalla sua altezza x la sua larghezza x la sua profondità di colore. Le immagini .bmp hanno una profondità di colore di 3 byte, ogni byte è una componente di colore: rosso, verde, blu. Tale sistema di salvataggio dell'immagine viene definito appunto RGB.
  • Tamite la funzione malloc abbiamo allocato una zona di memoria ed abbiamo restituito il puntatore a l_texture. Per ora l_texture è piena di valori casuali poiché malloc ci ha restituito uno spazio non usato ma non per questo privo di valori (che possono essere il risultato di operazioni precedenti).
  • Quindi per pulire quella zona usiamo la funzione memset che ci consente di riempirla con degli zero.

Ora... abbiamo la nostra zona di memoria pronta e pulita! Non ci resta altro da fare che andarla a riempire con l'immagine. Per questo implementiamo questo algoritmo:

   for (l_index=0; l_index < infoheader.biWidth*infoheader.biHeight; l_index++)
   { 
      // We load an RGB value from the file
      fread(&rgb, sizeof(rgb), 1, file); 

      // And store it
      l_texture[l_index2+0] = rgb.rgbtRed; // Red component
      l_texture[l_index2+1] = rgb.rgbtRed; // Green component
      l_texture[l_index2+2] = rgb.rgbtBlue; // Blue component
      l_texture[l_index2+3] = 255; // Alpha value
      l_index2 += 4; // Go to the next position
   }

Molti di voi a questo punto avranno già capito in che modalità viene salvata un'immagine .bmp. Ogni punto della texture (che d'ora in poi chiameremo TEXEL) è rappresentato da un insieme di 3 valori RGB e l'immagine è formata da una vasta serie di punti affiancati. Quando una riga è completa si passa alla riga sottostante e si ricomincia da sinistra.

Nel ciclo di lettura incrementiamo la variabile l_index che ci consente di leggere tramite fread punto per punto dell'immagine. Ogni chiamata a fread infatti legge una tripletta di valori RGB. La variabile rgb usata da questa funzione è implicitamente definita in windows.h esattamente come il BITMAPFILEHEADER e il BITMAPINFOHEADER ed è composta da 3 valori byte (rgb.rgbtRed, rgb.rgbtGreen, rgb.rgbtBlue).

In seguito andiamo a salvare il contenuto di ogni componente RGB in l_texture incrementando il puntatore ogni volta di quattro valori. Perchè quattro e non tre? Abbiamo letto un'immagine con profondità di colore 3 ed abbiamo creato una texture che ha profondità di colore 4! Abbiamo inserito un'altra componente che stiamo settando per default a 255. Di cosa si tratta? Si tratta della componente Alpha! La componente Alpha per ora non ci interessa, comunque ci sarà molto utile quando implementeremo il Blending poiché il suo valore servirà per rappresentare il livello di trasparenza della texture. Quindi smettetela di lamentarvi (altrimenti inserisco un'altra componente! ;-P) e proseguiamo...

   fclose(file); // Closes the file stream

Per quanto riguarda la lettura dell'immagine abbiamo proprio finito! Quindi andiamo a chiudere il file che abbiamo aperto. Ora in l_texture abbiamo la nostra texture pronta per essere usata e possiamo comunicare tutta la nostra felicità ad OpenGL che ci ricompenserà subito dandoci altro lavoro...;-) cioè una serie di comandi per impostare alcuni parametri essenziali al corretto "interfacciamento" della nostra texture con il layer OpenGL.

La prima cosa che dobbiamo fare è comunicare ad OpenGL quale numero di texture utilizzare. Tale valore è memorizzato nella variabile globale num_texture ed è incrementato ad ogni chiamata alla funzione Load Bitmap pertanto ogni immagine che caricheremo avrà il proprio numero identificativo. Abbiamo poi bisogno di effettuare alcune chiamate a delle funzioni per impostare alcuni parametri molto importanti dai quali dipenderà la qualità del risultato finale. Bisogna comunque tenere a mente che migliore è il risultato, più elevati saranno i tempi di rendering, cioè ci vorrà più tempo per disegnare l'immagine.

Alla fine non ci resterà altro che fornire ad OpenGL il puntatore alla zona di memoria dove abbiamo salvato l'immagine.

   glBindTexture(GL_TEXTURE_2D, num_texture);
  • glBindTexture(GLenum target, GLuint texture); Questa funzione indicizza la texture corrente, assegnandogli un determinato identificativo. In questa fase OpenGL prima di effettuare qualunque altra operazione deve necessariamente conoscere il numero della texture corrente ed il "texturing target" (che può essere GL_TEXTURE_1D, che a noi non interessa in quanto le nostre texture sono bidimensionali, o GL_TEXTURE_2D). Questo comando deve essere utilizzato sia nella fase di inizializzazione della texture sia nella fase si rendering.
   glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
   glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
   glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
   glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_NEAREST);
  • void glTexParameterf( GLenum target, GLenum pname, GLfloat param ); Tramite questa funzione possiamo fissare alcuni parametri importanti per il rendering della texture. In target possiamo inserire, come per la precedente funzione, GL_TEXTURE_1D e GL_TEXTURE_2D. In pname decidiamo il parametro da modificare, i parametri che noi impostiamo sono i seguenti:
  • GL_TEXTURE_WRAP_S, GL_TEXTURE_WRAP_T seleziona la modalità in cui OpenGL si comporta nel caso in cui le coordinate della texture vadano oltre o sotto il limite consentito nei bordi (in genere 0,1) . Se in param mettiamo ad esempio il valore GL_REPEAT (come nel nostro caso) allora avremo come effetto la ripetizione della texture partendo dall'inizio. Ad esempio supponiamo di disegnare un pavimento e di avere una texture che riproduce una mattonella: in questo caso la mattonella verrà ripetuta ogni volta che si raggiunge la fine della coordinata utile, a patto di impostare le coordinate in modo corretto (non vi preoccupate di questo per ora). Se invece in param mettiamo il valore GL_CLAMP allora l'ultimo pixel sarà considerato per continuare il disegno bloccando per così dire le coordinate texture in base ai valori dei bordi. Questo provoca un effetto di "spalmatura", io direi molto utile se si vuole disegnare una fetta di pane con la marmellata spalmata... ;-)
  • GL_TEXTURE_MAG_FILTER, GL_TEXTURE_MIN_FILTER tramite questi parametri è possibile dire ad OpenGL come comportarsi quando i texel si trovano ad essere disegnati in uno spazio più grande o più piccolo delle loro dimensioni. Infatti, quando la texture viene applicata sull'oggetto e sono state effettuate tutte le trasformazioni (modeling, viewing ecc.), raramente accade che un singolo texel corrisponda ad un singolo pixel sullo schermo. Per comprendere meglio il concetto facciamo subito un esempio pratico: immaginiamo che il nostro oggetto sia disposto molto lontano rispetto al punto di vista, in questa situazione in un pixel dello schermo dovrebbero essere disegnati molti texel e questo è chiaramente impossibile. Viceversa se l'oggetto viene disegnato molto vicino al punto di vista un texel coprirebbe molti pixel. In entrambi i casi la difficolta stà nel decidere quali texel disegnare e come effettuare un filtraggio decente per evitare risultati spiacevoli: se impostiamo come parametro GL_NEAREST viene considerato il texel più vicino al pixel mentre con GL_LINEAR viene effettuata una mediazione pesata su un array 2x2 di texel intorno al pixel. Per la funzione di minification (cioè quando la nostra immagine è più piccola) abbiamo ulteriori parametri che è possibile utilizzare e che fanno uso di MipMaps ma per ora non possiamo entrare troppo nei dettagli delle funzionalità OpenGL (ci sono libri appositi per questo). Il nostro scopo è di creare un Engine3d pertanto le funzioni saranno analizzate procedendo gradualmente.
   glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);
   glTexImage2D(GL_TEXTURE_2D, 0, 4, infoheader.biWidth, infoheader.biHeight, 0, GL_RGBA, GL_UNSIGNED_BYTE, l_texture);
  • void glTexEnvf( GLenum target, GLenum pname, GLfloat param ); Normalmente quando eseguiamo il texture mapping i texel vengono usati per colorare ogni punto sulla superficie del nostro oggetto. Questo comando ci permette in aggiunta di effettuare una "miscelazione" modulando i colori della texture con quelli che il poligono avrebbe senza texture mapping. In Target dobbiamo inserire GL_TEXTURE_ENV mentre in param scriviamo GL_TEXTURE_ENV_MODE. In param possiamo infine decidere la modalità con cui effettuare la combinazione dei colori, i parametri validi sono: GL_DECAL, GL_REPLACE, GL_MODULATE e GL_BLEND. A noi non interessa effettuare alcuna miscelazione di colori, per questo usiamo GL_REPLACE che ci consente di utilizzare direttamente i pixel della texture map.
  • void glTexImage2D( GLenum target, GLint level, GLint internalformat, GLsizei width, GLsizei height, GLint border, GLenum format, GLenum type, const GLvoid *pixels ); Questo comando è molto importante poichè permette di definire una texture bidimensionale. In target inseriamo GL_TEXTURE_2D mentre level lo useremo solo se intendiamo inserire altre textures con differente livello di dettaglio (noi non utilizzeremo questa opzione poiché faremo creare in automatico queste textures e vedremo come nella prossima funzione, quindi mettiamo 0 come valore). Il successivo parametro è internalformat nel quale inseriremo il formato interno con il quale la nostra immagine sarà memorizzata. Ovviamente il valore prescelto sarà 4 per specificare che intendiamo usare il formato RGBA. Nei due campi successivi inseriamo le dimensioni: larghezza e altezza della texture. A noi non serve avere un bordo nella nostra texture, per questo in border mettiamo 0. I due parametri seguenti format e type invece descrivono il formato ed il tipo di dati con i quali la nostra texture è stata salvata in memoria. Qui inseriremo i valori GL_RGBA e GL_BYTE. Infine in *pixels indicheremo il puntatore alla zona di memoria dove abbiamo memorizzato la nostra texture.

Abbiamo precedentemente accennato le difficoltà che si incontrano quando non c'è un'esatta corrispondenza tra i texel ed i pixel dello schermo. E' chiaro che gli oggetti potranno muoversi molto lontano dal punto di vista, specialmente in un motore come il nostro nel quale si ha a che fare cib coordinate spaziali. Ricordate la Proiezione Prospettica? Quello che accade è proprio dovuto a lei: le dimensioni degli oggetti sono strettamente legate alla distanza rispetto al punto di vista. E questo è un bel problema poichè non è facile riuscire a mantenere una buona qualità dell'immagine se le texture sono molto lontane, neanche con sistemi di filtraggio particolari. Una soluzione a questo problema è data dall'uso di MipMaps.

Le MipMaps sono una serie di texture già filtrate derivanti da una texture principale e aventi la particolarità di avere ciascuna una risoluzione inferiore rispetto alla precedente. In genere si parte da una texture base con una dimensione originaria della potenza di 2 (ad esempio 256x256), le MipMaps derivanti avranno dimensioni via via dimezzate: 128x128, 64x64, 32x32, etc. A seconda della distanza verrà disegnata solo la texture che soddisferà al meglio il criterio: dimensione texel = 1. Noi abbiamo la possibilità di inserire manualmente queste texture se le abbiamo pronte richiamando più volte il comando glTexImage per ogni MipMap. Ad ogni chiamata imposteremo il parametro level a seconda del numero di MipMap che stiamo definendo, ricordandoci di inserire ogni volta le dimensioni e la zona di memoria appropriata. Avrete comunque notato che noi, pur usando il MipMapping (si lo stiamo usando... notate un pò che parametro abbiamo inserito nella funzione glTexParameterf alla voce GL_TEXTURE_MIN_FILTER!) non abbiamo definito le nostre MipMaps! Infatti noi non le definiremo a mano useremo invece una bellissima funzione della libreria di utilità GLU che le creerà per noi:

   gluBuild2DMipmaps(GL_TEXTURE_2D, 4, infoheader.biWidth, infoheader.biHeight, GL_RGBA, GL_UNSIGNED_BYTE, l_texture);
  • void gluBuild2DMipmaps( GLenum target, GLint internalformat, GLsizei width, GLsizei height, GLenum format, GLenum type, const GLvoid *pixels ); Se date un attimo un'occhiata alla funzione glTexImage2D noterete sicuramente che i parametri di questa ultima funzione sono gli stessi della precedente, mancano solo il level ed il border. Una volta dichiarata (usando gli stessi valori di glTexImage2D) verranno automaticamente generate le MipMaps derivate dalla texture puntata da *pixel e memorizzate pronte per essere usate da OpenGL nei momenti di bisogno a nostra insaputa. Bello, non trovate?

Bene, adesso possiamo liberare la zona di memoria che abbiamo occupato per caricare l'immagine.

   free(l_texture);

Qualcuno di voi potrà pensare che con questo procedimento andremo a cancellare irrimediabilmente la texture che abbiamo caricato con tanta fatica. Non preoccupatevi perché tramite il comando glTexImage2D OpenGL ha automaticamente salvato nella sua zona di memoria dedicata l'immagine che gli abbiamo fornito. Per questo non abbiamo più bisogno di lasciare la zona di memoria piena di valori che non useremo più. E' ora di uscire fuori da questa funzione: come ultima cosa restituiremo il numero della texture che abbiamo definito il quale ci servirà in seguito.

   return (num_texture);
}

Ci siamo stressati un pò vero? Bene! E' un buon segno, dopotutto siamo programmatori, lo stress è un nostro compagno fedele... Ora non rilassatevi troppo perché abbiamo altro lavoro da fare, passiamo alla fase 2.

ASSEGNARE AD OGNI VERTICE UN PUNTO DELL'IMMAGINE

A questo punto abbiamo la nostra bella texture già definita, non ci resta altro da fare che modificare la struttura dati del nostro tipo oggetto: dobbiamo infatti aggiungere alcuni campi che poi utilizzeremo per associare ad ogni vertice un punto 2d dell'immagine, in modo da coprire l'oggetto come meglio desideriamo.

Torniamo su Tutorial3.cpp e definiamo un nuovo tipo:

typedef struct {
   float u,v;
} mapcoord_type;

Possiamo subito notare che il mapcoord_type inizializza 2 variabili chiamate u, v. Queste non sono altro che una coppia di coordinate necessarie per identificare un punto 2d nella texture. Sicuramente vi starete chiedendo come mai chiamare due variabili u, v anziche' x, y? Il fatto e' che le coordinate della texture sono state da sempre implicitamente chiamate u, v per non confonderle con le coordinate x,y,z dei vertici.

Cosa facciamo adesso? Semplice... a questo punto bisogna assegnare ad ogni vertice un punto della texture, abbiamo detto infatti che per ogni triangolo che compone l'oggetto dobbiamo assegnare una piccola sezione triangolare dell'immagine.

Quindi andiamo a modificare il nostro tipo oggetto in questo modo:

/*** The object type ***/
typedef struct { 
   vertex_type vertex[MAX_VERTICES]; 
   polygon_type polygon[MAX_POLYGONS];
   mapcoord_type mapcoord[MAX_VERTICES];
   int id_texture;
} obj_type, *obj_type_ptr;

Abbiamo aggiunto l'array mapcoord, che ci servirà per memorizzare le coordinate della texture per ogni vertice.

La variabile id_texture e' un numero per identificare la texture da utilizzare (noi lo riempiremo con il valore di ritorno della funzione LoadBitmap). Constateremo la sua utilita' quando inizieremo a lavorare con piu' di un oggetto.

Adesso andiamo a riempire la struttura obj_type, l'array mapcoord dovrà essere riempito in questo modo:

obj_type cube = 
{
   {
      -10, -10, 10, // vertex v0
       10, -10, 10, // vertex v1
       10, -10, -10, // vertex v2
      -10, -10, -10, // vertex v3
      -10, 10, 10, // vertex v4
       10, 10, 10, // vertex v5
       10, 10, -10, // vertex v6 
      -10, 10, -10 // vertex v7
   }, 
   {
        0, 1, 4, // polygon v0,v1,v4
        1, 5, 4, // polygon v1,v5,v4
        1, 2, 5, // polygon v1,v2,v5
        2, 6, 5, // polygon v2,v6,v5
        2, 3, 6, // polygon v2,v3,v6
        3, 7, 6, // polygon v3,v7,v6
        3, 0, 7, // polygon v3,v0,v7
        0, 4, 7, // polygon v0,v4,v7
        4, 5, 7, // polygon v4,v5,v7
        5, 6, 7, // polygon v5,v6,v7
        3, 2, 0, // polygon v3,v2,v0
        2, 1, 0 // polygon v2,v1,v0
   },
   {
        0.0, 0.0, // mapping coordinates for vertex v0
        1.0, 0.0, // mapping coordinates for vertex v1
        1.0, 0.0, // mapping coordinates for vertex v2
        0.0, 0.0, // mapping coordinates for vertex v3
        0.0, 1.0, // mapping coordinates for vertex v4
        1.0, 1.0, // mapping coordinates for vertex v5
        1.0, 1.0, // mapping coordinates for vertex v6 
        0.0, 1.0 // mapping coordinates for vertex v7
   },
   0, // identifier for the texture 
};

Ora ci troviamo a buon punto: abbiamo la nostra texture in memoria, abbiamo una struttura dati riempita, non ci resta altro che disegnare il nostro cubo in Texture Mapping!

DISEGNARE I POLIGONI DELL'OGGETTO "COPRENDOLI" CON SEZIONI DELLA TEXTURE

La nostra prima funzione da modificare è la "init". Durante la procedura di inizializzazione dobbiamo abilitare il texture mapping e richiamare la funzione LoadBitmap per caricare la texture.

glEnable(GL_TEXTURE_2D);
cube.id_texture=LoadBitmap("texture1.bmp");
if (cube.id_texture==-1)
{
   MessageBox(NULL,"Image file: texture1.bmp not found", "Zetadeck",MB_OK | MB_ICONERROR);
   exit (0);
}
  • Tramite glEnable(GL_TEXTURE_2D); abbiamo abilitato il texture mapping bidimensionale. Potete notare che il valore di ritorno della funzione LoadBitmap serve come identificativo univoco per assegnare la texture all'oggetto. Quando nel nostro motore implementeremo la possibilità di gestire più oggetti assegneremo ad ognuno una propria texture. Se la funzione LoadBitmap non è riuscita a caricare l'immagine verrà visualizzata una MessageBox con il messaggio d'errore ed il programma sarà inevitabilmente interrotto.

Passiamo ora alla funzione di disegno e togliamo i richiami che abbiamo fatto alla funzione glColor3f perché adesso non abbiamo più bisogno di assegnare i colori ai vertici. La parte di codice che si occupa del disegno del cubo dovrà essere sostituita con questa:

glBindTexture(GL_TEXTURE_2D, cube.id_texture); 

glBegin(GL_TRIANGLES); 
for (l_index=0;l_index<12;l_index++)
{
   /*** FIRST VERTEX ***/
   glTexCoord2f( cube.mapcoord[ cube.polygon[l_index].a ].u,
                 cube.mapcoord[ cube.polygon[l_index].a ].v);
   glVertex3f( cube.vertex[ cube.polygon[l_index].a ].x,
               cube.vertex[ cube.polygon[l_index].a ].y,
               cube.vertex[ cube.polygon[l_index].a ].z);

   /*** SECOND VERTEX ***/
   glTexCoord2f( cube.mapcoord[ cube.polygon[l_index].b ].u,
                 cube.mapcoord[ cube.polygon[l_index].b ].v);
   glVertex3f( cube.vertex[ cube.polygon[l_index].b ].x,
               cube.vertex[ cube.polygon[l_index].b ].y,
               cube.vertex[ cube.polygon[l_index].b ].z);

   /*** THIRD VERTEX ***/
   glTexCoord2f( cube.mapcoord[ cube.polygon[l_index].c ].u,
                 cube.mapcoord[ cube.polygon[l_index].c ].v);
   glVertex3f( cube.vertex[ cube.polygon[l_index].c ].x,
               cube.vertex[ cube.polygon[l_index].c ].y,
               cube.vertex[ cube.polygon[l_index].c ].z);
}
glEnd();

Abbiamo aggiunto solo due funzioni:

  • void glBindTexture( GLenum target, GLuint texture ); Questa funzione l'abbiamo già analizzata durante l'inizializzazione della texture. La differenza ora è che nel parametro texture abbiamo inserito l'identificativo della texture relativa all'oggetto cube. Tramite questa funzione abiamo comunicato ad OpenGL qual'è la texture attualmente attiva. Si, lo so che era comunque implicito il fatto che abbiamo una sola texture e quindi era impossibile sbagliarsi ma questo, ripeto, ci sarà utile in futuro quando il nostro motore gestirà più di un oggetto e quindi più di una texture.
  • void glTexCoord2f( GLfloat s, GLfloat t ); Tramite questa funzione possiamo definire le due coordinate u, v della texture (che OpenGL chiama rispettivamente s, t). Come abbiamo gia visto per la funzione glVertex3f, questa funzione deve essere inserita tra i due comandi glBegin e glEnd, e deve essere richiamata ogni volta insieme ad ogni glVertex3f. In questo modo assegniamo ad ogni vertice una coordinata della texture.

L'ULTIMA COSA

L'ultima cosa da fare è di inserire un altro file di testo, nominarlo "texture.h" e riempiendolo con queste due righe di codice:

extern int num_texture;
extern int LoadBitmap(char *filename);

Sarà poi necessario inserire questa linea nella sezione "include" del file "main.cpp":

#include "texture.h"

Se state usando la linea di comando per compilare il progetto ricordatevi di compilare anche il file texture.cpp inserendolo, se necessario, nel vostro makefile.

Non c'è niente di più facile non credete!

Ora ci sono alcune considerazioni da fare, se siete stanchi potete passare direttamente alla conclusione in quanto questo discorso potrebbe essere evitato, ma non mi va di lasciare alcuni punti non chiariti.

ALCUNE CONSIDERAZIONI

I più attenti di voi avranno notato che in realtà abbiamo disegnato completamente la texture solo su due facce del cubo. Infatti solo le facce composte dai vertici v0,v1,v5,v4 e v3,v2,v6,v7 hanno la texture disegnata in modo corretto. Questo perchè le coordinate di mappatura sono giuste solo in corrispondenza dei 4 triangoli che compongono quelle facce. Per le restanti facce purtroppo non c'è nulla da fare.

La causa di questa anomalia è dovuta al fatto che noi abbiamo legato strettamente ad ogni vertice una ed una sola coordinata di mapping. Così alcune facce, poichè condividono i vertici con le altre, sono costrette a sacrificare le loro coordinate u, v con valori non corretti.

Per risolvere questo inconveniente ci sono due soluzioni:

1-Anzichè legare ogni coordinata di mapping ad ogni vertice si può utilizzare il polygon_type per aggiungere una coordinata u, v per ogni punto del poligono. In questo modo un vertice può avere più di una coordinata di mapping ed il problema è risolto. Ecco un esempio:

typedef struct{
   int a,b,c;
   mapcoord_type map_a, map_b, map_c;//Every point of the polygon has a point u,v in the texture
}polygon_type;

2-Si aggiungono tanti vertici quanti sono necessari per disegnare la texture su ogni triangolo in modo corretto. Anche in questo caso abbiamo risolto il problema, ma computazionalmente abbiamo appesantito il lavoro. Abbiamo aumentato infatti modo considerevole il numero dei vertici. Ad esempio nel nostro cubo dovremmo utilizzare fino a 4 vertici per ogni faccia fino ad arrivare a 4 vertici x 6 facce = 24 vertici.

Indovinate quale soluzione adotteremo noi? State pensando alla soluzione 1? No! Noi siamo masochisti! Noi adotteremo la 2! Perchè direte voi? In effetti in una figura complessa accade raramente che vengano aggiunti dei vertici per mantenere una uniformità nel mapping. In aggiunta c'è da dire che quasi la totalità dei motori 3d utilizza questa soluzione ed inoltre il formato 3ds che noi utilizzeremo per caricare i modelli lavora allo stesso modo.

Comunque tutto questo discorso per adesso è inutile in quanto per questo tutorial noi ci limiteremo a disegnare il cubo così com'è, con le sue 4 facce mal disegnate e le restanti due con la texture applicata correttamente.

Successivamente non ci dovremo neanche preoccupare ad implementare la soluzione 2 a mano perchè sarà il formato 3ds che ci aiuterà. Il 3d studio infatti provvede automaticamente ad aggiungere vertici dove necessario durante la fase di editing ed applicazione della Texture.

Perchè ho iniziato questo discorso direte voi? Beh semplicemente perchè non è possibile continuare a lavorare lasciandosi dei dubbi alle spalle, non credo sia il modo corretto di lavorare. Io ho perso molto tempo a causa di questi problemi e non vedo perchè dobbiate perderne anche voi. ;)

CONCLUSIONE

Ed anche questa è fatta! Non so voi ma io mi sono veramente stancato! Indovinate un po che bella sorpresa vi riserverà la prossima lezione? Si, finalmente non vedremo più quell'orrendo cubo sullo schermo perchè programmeremo un Loader di oggetti in formato 3ds! Sicuramente molti di voi conosceranno il programma di modellazione 3d studio. Sulla rete si possono trovare un'infinità di oggetti 3ds tra i quali ovviamente delle astronavi!

Per ora vi lascio con il vostro grandioso cubo, divertitevi!

SOURCE CODE

The Source Code of this lesson can be downloaded from the Tutorials Main Page