Texture Manager

écrit par David Henry
le 30 juin 2003






Introduction


Plusieurs objets, modèles ou meshes peuvent parfois posséder une même texture dans une application OpenGL. Cependant, les textures peuvent occuper relativement beaucoup de mémoire et charger pour chaque objet sa texture à chaque fois peut conduire à se retrouver avec plusieurs fois la même texture en mémoire, ce qui est inutile car il suffirait que les objets utilisant la texture dite se partagent juste son ID.

C'est donc le rôle du Texture Manager de vérifier si une texture n'a pas été déjà chargée avant d'en créer une nouvelle et de renvoyer l'ID des textures déjà existantes à la demande du programme. Le Texture Manager donc va devoir disposer d'une base de données recensant toutes les ID des textures chargées.

Nous allons voir dans ce tutorial une implémentation possible assez simple d'une classe encapsulant notre Texture Manager.



Implémentation d'une classe CTextureManager


Pour commencer, voici les spécificités d'un Texture Manager :



Les IDs de texture devront être stockées avec le nom de la texture. Pour cela, on va donc utiliser le conteneur map<> (dictionnaire) de la STL avec pour premier objet une chaîne de caractère string (le nom) et pour second objet un entier non signé GLuint utilisé par OpenGL pour contenir l'ID des textures.

Pour le modèle Singleton, on utilisera celui décrit dans la seconde partie du tutorial sur les Singletons.

Nous pouvons à présent voir une première implémentation de la classe CTextureManager :

// ==============================================
// CTextureManager - OpenGL texture manager.
// ==============================================

class CTextureManager : public CSingleton<CTextureManager>
{
    friend class CSingleton<CTextureManager>;

private:
    // constructeur/destructeur
    CTextureManager( void ) { m_kDataBase.clear(); }
    ~CTextureManager( void ) { ReleaseTextures(); }


public:
    // functions publiques
    void    Initialize( void );

    GLuint  LoadTexture( std::string szFilename );

    GLuint  GetTexture( std::string szFilename );
    char    *GetTextureName( GLuint id );

    void    ReleaseTextures( void );

    void    DeleteTexture( std::string szName );
    void    DeleteTexture( GLuint id );


private:
    // texture map<>
    std::map::iterator m_kItor;
    std::map           m_kDataBase;

};

Le constructeur s'assure que la base de données est bien vide à la création du Manager. Le destructeur quant à lui fait appel à ReleaseTextures() ayant pour tâche de détruire toutes les textures de sa base de données. Il n'y a donc pas besoin de le faire manuellement avant destruction du Manager. Pour le reste des fonctions, leur nom est assez explicite.

Le Manager possède ici un itérateur du conteneur qui sera utilisé à besoin de parcourir la base de données. Il n'est en rien obligatoire d'en posséder un en variable membre, c'est juste pour une question de pratique (on a pas besoin de recréer un itérateur à chaque fois, surtout que le type est assez long à réécrire dans une boucle for).

Voyons à présent les définitions de ces fonctions. Commençons avec la fonction Initialize() :

// ----------------------------------------------
// Initialize() - crée une texture d'échiquier
// pour la texture par défaut.
// ----------------------------------------------

void CTextureManager::Initialize( void )
{
    // vide la base de données<>
    ReleaseTextures();

    // c'est la première texture chargée en mémoire. Si
    // une texture ne peut être lue ou chargée, on utilise
    // celle si à la place.

    std::cout << "CTextureManager : Texture list is empty, creating default texture..." << std::endl;
    GLuint id;

    // création et initialisation de la texture
    glGenTextures( 1, &id );
    glBindTexture( GL_TEXTURE_2D, id );

    glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
    glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR );
    glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT );
    glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT );

    // ajout à la base de données
    m_kDataBase[ "default" ] = id;

    std::cout << "CTextureManager : creating default texture, id [" << id << "]" << std::endl;


    // création d'une texture échiquier pour la texture par défaut
    int i, j, c;            // variables de contrôle
    GLubyte *checker;       // données texels

    checker = new GLubyte[ 64 * 64 * 4 ];

    // construction de l'échiquier
    for( i = 0; i < 64; i++ )
    {
        for( j = 0; j < 64; j++ )
        {
            c = ( !(i & 8) ^ !(j & 8)) * 255;

            checker[ (i * 256) + (j * 4) + 0 ] = (GLubyte)c;
            checker[ (i * 256) + (j * 4) + 1 ] = (GLubyte)c;
            checker[ (i * 256) + (j * 4) + 2 ] = (GLubyte)c;
            checker[ (i * 256) + (j * 4) + 3 ] = (GLubyte)255;
        }
    }

    glTexImage2D( GL_TEXTURE_2D, 0, 4, 64, 64, 0, GL_RGBA, GL_UNSIGNED_BYTE, checker );
    delete [] checker;
}

Pour commencer, la base de données est vidée pour la (ré)initialiser. On crée en suite une texture en damier que l'on va ajouter à la base de données sous le nom de "default". Cette texture sera par la suite utilisée à chaque fois qu'une demande d'ID échouera (c'est à dire, demande d'une texture non présente dans la base de données ou impossible à charger depuis un fichier).

Le conteneur map<> nous facilite grandement les choses pour le rangement des ID des texture avec leur nom, puisqu'il permet d'utiliser le nom de la texture comme index (m_kDataBase[ "default" ] = id;). Il est tout aussi rapide d'obtenir l'ID d'une texture si l'on possède son nom. Quels noms choisir pour les textures ? Et bien on peut tout simplement prendre leur nom de fichier avec le chemin d'accès. Ainsi nous sommes certains de ne pas avoir de conflits de noms, puisque le chemin d'accès à un fichier est unique.

Passons maintenant à la fonction la plus importante, LoadTexture() :

// ----------------------------------------------
// LoadTexture() - charge une texture. Vérifie
// si elle n'est pas déjà stockée en mémoire. Si
// c'est le cas, la fonction retourne l'id de la
// texture déjà stockée. Sinon, elle retourne
// l'id de la nouvelle texture.
// ----------------------------------------------

GLuint CTextureManager::LoadTexture( std::string szFilename )
{
    GLuint      id = 0;         // id de la texture à retourner
    GLubyte     *pixels = 0;    // tableau de pixels de la texture
    int         width, height;  // dimensions de la texture


    // on tente d'obtenir la texture depuis la base de données.
    // Si elle a déjà été chargée au préalable, on retourne son
    // id, sinon l'id retournée est celle de la texture par défaut.
    // On tente alors de la charger dans la base.
    id = GetTexture( szFilename );

    if( id != GetTexture( "default" ) )
    {
        // la texture a déjà été chargée
        std::cout << "CTextureManager : " << szFilename
                  << " is already stored, id [" << id << "]" << std::endl;
    }
    else
    {
        // on tente de charger la texture
        if( LoadFileBMP( szFilename.c_str(), &pixels, &width, &height, false ) > 0 )
        {
            // création et initialisation de la texture opengl
            glGenTextures( 1, &id );
            glBindTexture( GL_TEXTURE_2D, id );

            glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT );
            glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT );
            glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
            glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR );

            glTexImage2D( GL_TEXTURE_2D, 0, 4, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels );

            // création d'une nouvelle texture et ajout à la base de données
            m_kDataBase[ szFilename ] = id;

            std::cout << "CTextureManager : " << szFilename << " successfully loaded!" << std::endl;
            std::cout << " # creating new texture, id [" << id << "], ("
                      << width<< " * " << height<< ")" << std::endl;
        }
        else
        {
            // impossible de charger la texture, texture par défault utilisée
            std::cout << "CTextureManager : failed to load " << szFilename
                      << "! giving default texture id [" << m_kDataBase[ "default" ] << "]" << std::endl;
        }

        // désallocation de mémoire - OpenGL possède sa propre copie de l'image
        if( pixels )
            delete [] pixels;
    }

    return id;
}

La fonction est relativement simple et basique, à vous de l'étendre par la suite suivant vos besoins. Elle sera appelée à chaque fois que l'on aura besoin de charger une texture et retourne l'ID de celle-ci.

Tout d'abord, on tente d'obtenir l'ID de la texture que l'on a besoin depuis la base de données, au cas où elle aurait déjà été chargée. La fonction GetTexture() retourne l'ID de la texture par défaut en cas d'échec de la recherche. C'est donc ce que l'on regarde ensuite : si l'ID est différente de celle de la texture par défaut, la texture a déjà été chargée, inutile d'en faire un doublon, on retourne directement son ID. Dans le cas contraire, il va falloir procéder à son chargement.

La fonction LoadFileBMP() ne va pas être détaillée ici, ce n'est pas le but de ce tutorial. Vous pourrez toujours l'examiner avec le code source téléchargeable à la fin de l'article. Cette fonction lit un fichier bitmap .bmp et retourne un tableau de pixels en RGBA 32 bits ainsi que les dimensions du bitmap (*pixels, width et height y sont donc initialisés). Le dernier paramètre est un flag pour flipper l'image verticalement. Cette fonction retourne 1 en cas de succès, 0 en cas d'échec et -1 si aucun pointeur vers un tableau de pixels n'est spécifié (on peut l'utiliser juste pour obtenir les dimensions du bitmap par exemple).

Deux cas sont possibles à présent : l'image a bien été lue et le tableau de pixels initialisé, on crée une nouvelle texture qu'on ajoute à la base de données (m_kDataBase[ szFilename ] = id;) ou bien l'image n'a pas pus être chargée (fichier introuvable, format incorrect, etc.) et l'on retourne alors l'ID de la texture par défaut.

Avant de sortir de la fonction, on libère la mémoire allouée par LoadFileBMP() pour le tableau *pixels, OpenGL créant ses propres copies des tableaux de pixels, on n'en a plus besoin et il encombre la mémoire.


On en a fini avec les deux grosses fonctions, passons aux fonctions utilitaires. Tout d'abord, GetTexture() :

// ----------------------------------------------
// GetTexture() - retourne l'id de la texture
// filename si déjà chargée.
// ----------------------------------------------

GLuint CTextureManager::GetTexture( std::string szFilename )
{
    if( (m_kItor = m_kDataBase.find( szFilename )) != m_kDataBase.end() )
    {
        return (*m_kItor).second;
    }
    else
    {
        return m_kDataBase[ "default" ];
    }
}

La fonction est très simple, on utilise la méthode find() du conteneur map<> nous permettant de faire une recherche dans la base de données à partir du nom de la texture. Si l'itérateur ("tête de lecture" de la base de données si vous préférez) arrive à la fin du conteneur, c'est qu'il n'a rien trouvé, on retourne alors l'ID de la texture générique que l'on obtient simplement avec un appel à m_kDataBase[ "default" ]. En revanche, si la texture est trouvée, on retourne son ID avec (*m_kItor).second. second correspond au second type du conteneur que nous avons déclaré, ici c'est donc l'ID de la texture de type GLuint. Le premier type (string) peut être accédé avec (*m_kItor).first.

Il se peut également que l'on aie besoin d'obtenir le nom d'une texture à partir de son ID : c'est le boulot de la fonction GetTextureName().

// ----------------------------------------------
// GetTextureName() - retourne un pointeur sur le
// nom de l'image d'id spécifiée.
// ----------------------------------------------

char *CTextureManager::GetTextureName( GLuint id )
{
    if( glIsTexture( id ) )
    {
        for( m_kItor = m_kDataBase.begin(); m_kItor != m_kDataBase.end(); ++m_kItor )
        {
            if( (*m_kItor).second == id )
            {
                return (char *)((*m_kItor).first.c_str());
            }
        }
    }

    // la texture n'a pas été trouvée
    return (char *)"failed";
}

La fonction commence par vérifier que l'ID passée en paramètre est bien une ID valide, sinon inutile de poursuivre la recherche. Ensuite, par le biais de l'itérateur, on va tester l'ID spécifiée avec celle de chaque élément de la base de données. Si elle correspond, on retourne le nom de la texture ((*m_kItor).first - le type string), sinon on retourne un pointeur sur la chaîne "failed".

Passons à la fonction ReleaseTextures() dont la tâche est de vider la base de données et de détruire toutes les textures que le Manager avait à gérer :

// ----------------------------------------------
// ReleaseTextures() - détruit toutes les textures
// stockées dans la base de données.
// ----------------------------------------------

void CTextureManager::ReleaseTextures( void )
{
    std::cout << "CTextureManager : cleaning all textures!!! size = " << m_kDataBase.size();

    // on détruit chaque texture de la base de données du manager
    for( m_kItor = m_kDataBase.begin(); m_kItor != m_kDataBase.end(); )
    {
        glDeleteTextures( 1, &(*m_kItor).second );
        m_kDataBase.erase( m_kItor++ );
    }

    std::cout << " // cleaned! size = " << m_kDataBase.size() << std::endl;
}

La fonction se contente de parcourir (grâce à l'itérateur) le conteneur du début jusqu'à la fin et de détruire la texture dont on récupère l'ID à chaque fois, puis de supprimer l'objet de la base de données avec la fonction erase().

La fonction DeleteTexture() permet elle de ne détruire qu'une texture isolée :

// ----------------------------------------------
// DeleteTexture() - détruit la texture szName.
// ----------------------------------------------

void CTextureManager::DeleteTexture( std::string szName )
{
    // on recherche l'id de la texture à détruire
    if( (m_kItor = m_kDataBase.find( szName )) != m_kDataBase.end() )
    {
        std::cout << "CTextureManager : erasing " << szName << std::endl;

        // destruction de la texture et retrait de l'id
        // de la base de données du manager
        glDeleteTextures( 1, &(*m_kItor).second );
        m_kDataBase.erase( m_kItor );
    }
    else
    {
        std::cout << "CTextureManager : couldn't erase " << szName << std::endl;
    }
}

On procède tout d'abord à une recherche de la texture. Une fois trouvée, l'itérateur pointe sur l'objet à détruire. On détruit la texture puis on efface l'objet de la base de données. La version surchargée de cette fonction permet de détruire une texture à partir de son ID :

// ----------------------------------------------
// DeleteTexture() - détruit la texture de l'id
// spécifiée.
// ----------------------------------------------

void CTextureManager::DeleteTexture( GLuint id )
{
    if( glIsTexture( id ) )
    {
        for( m_kItor = m_kDataBase.begin(); m_kItor != m_kDataBase.end(); ++m_kItor )
        {
            if( (*m_kItor).second == id )
            {
                std::cout << "CTextureManager : erasing " << (*m_kItor).first << " ["
                          << (*m_kItor).second << "], size = " << m_kDataBase.size() << std::endl;

                // destruction de la texture et retrait de l'id
                // de la base de données du manager
                glDeleteTextures( 1, &(*m_kItor).second );
                m_kDataBase.erase( m_kItor );

                return;
            }
        }
    }
}

Ici aussi on parcourt la base à la recherche de l'objet possédant l'ID spécifiée. Une fois trouvé, on le détruit.



Exemple d'utilisation du Texture Manager


Le Manager est un Singleton, il est donc nécessaire d'utiliser un pointeur et de récupérer son unique instance avec GetInstance(). Lors de l'initialisation du programme, on peut appeler Initialize() et charger les textures nécessaires à l'application :

// initialisation du texture manager
CTextureManager *pTexMgr = CTextureManager::GetInstance();
pTexMgr->Initialize();

GLuint texture[5];

// chargement de 5 textures
texture[0] = pTexMgr->LoadTexture( "data/tree.bmp" );
texture[1] = pTexMgr->LoadTexture( "data/wood.bmp" );
texture[2] = pTexMgr->LoadTexture( "data/grass.bmp" );

// test GetTextureName() et GetTexture()
std::cout << "texture ID 2's name is : " << pTexMgr->GetTextureName( 2 ) << std::endl;
std::cout << "data/back.bmp has ID " << pTexMgr->GetTexture( "data/grass.bmp" ) << std::endl;

texture[3] = pTexMgr->GetTexture( "data/wood.bmp" );

// même effet que GetTexture() car la texture a déjà été chargée
texture[4] = pTexMgr->LoadTexture( "data/grass.bmp" );
texture[5] = CTextureManager::GetInstance()->LoadTexture( "data/grass.bmp" );

// test DeleteTexture()
pTexMgr->DeleteTexture( 2 );               // "data/tree.bmp"
pTexMgr->DeleteTexture( "data/rock.bmp" ); // échec - texture inexistante
pTexMgr->DeleteTexture( "data/wood.bmp" ); // succès

A l'exécution, texture[2], texture[4] et texture[5] devraient avoir la même valeur et la texture n'aura été chargée qu'une seule fois. De même pour texture[1] et texture[3]. On peut initialiser un pointeur sur le Manager pour l'utiliser ou bien passer par CTextureManager::GetInstance(), le résultat est le même.

A la fin du programme, on vide la base de données en détruisant l'instance de CTextureManager (voir tutorial sur le modèle Singleton pour plus de détails à ce sujet) :

// ----------------------------------------------
// Shutdown() - fin du programme.
// ----------------------------------------------

void Shutdown( void )
{
    // destruction du texture manager
    CTextureManager::Kill();
}

La fonction Kill() détruisant le Manager, le destructeur est appelé. Il va appeler à son tour ReleaseTextures() qui va vider la base et détruire toutes les textures chargées pour l'application.

Vous pouvez connaître le nombre de textures référencées dans la base de données en appelant m_kDataBase.size(). La fonction renverra le nombre d'éléments stockées dans le conteneur, donc le nombre de textures.

Le Texture Manager peut être très pratique d'utilisation. A chaque besoin d'une texture, il suffit d'appeler LoadTexture() et on récupère l'ID de la texture demandée, sans avoir à se soucier de leur futur destruction ou de doublons. Il suffit d'ajouter les fichiers sources (et en-tête) nécessaires au Manager à votre projet et rajouter un #include là où il y'a besoin et le Manager est opérationnel (n'oubliez pas d'initialiser au début et de détruire l'instance du manager à la fin du programme).

La classe décrite dans ce tutorial n'est qu'une simple implémentation contenant quelques fonctions basiques. Libre à vous de vous en inspirer pour l'améliorer, lui permettre de charger des textures de différents formats, etc. Il peut être utile de créer à l'intérieur du Manager des sous groupes de textures pour accélérer les recherches, vous pouvez également remplacer le second type (l'ID de type GLuint) par une structure contenant dimensions, nombre de couleurs, ID et autres informations à propos de la texture, gérer le mip mapping, vérifier les dimensions des textures, etc., ou bien simplement vous contenter des tâches rudimentaires que ce Manager peut assurer.



Le code source de ce tutorial est disponible ici.