• 11/05/07 10:41 PM
    Sign in to follow this  

    Leveraging Inheritance and Template Classes for Asset Tracking

    General and Gameplay Programming

    Abstract

    This article presents a method for tracking assets that should only be loaded once during the lifetime of an application. For the purposes of this article an asset is anything that is loaded from a file. Since this article is published on Gamedev.net, I will use examples like textures, models, shaders or physics/collision data. It is important to note that the data is too large to be loaded several times, and is used by many other parts of the application. Textures, for instance, should only be loaded once, but are used by many models throughout a typical game. To make sure that assets are not loaded more than once by different parts of the application an asset tracker is sometimes used. An asset tracker keeps track of which assets of a certain type were loaded, where they are in memory, and then returns that memory address as various parts of the application request it. There are many types of assets, yet the trackers that track them all have more or less the same functionality. To avoid the writing of trivial redundant code, C++ lets the programmer use templates and abstraction. This article will explain how to combine the two in order to create a generic asset tracker that handles any type, with no time wasted writing code for various asset types.

    What this article is not

    This article assumes familiarity with C++ and inheritance and template classes, therefore they will not be explained in detail. The only explanation given will be how to use them both together. Note that in-depth knowledge of either of these is not required, familiarity will suffice.

    What every asset tracker needs

    This section will show the logic behind the creation of the asset tracker base class. The asset tracker needs to be able to compile on any platform and any compiler, so stl is a good choice for managing data. stl::list will be used in this implementation to keep a list of files that have been loaded, and where they are in memory. The list must hold the pointer to the loaded object, as well as its file name and optionally other application-specific information. Here is a possible structure for holding such information:

    template< typename T > 
    struct AssetHolder {	//Bundles the asset with an ID and a file name so that the asset manager can track it 
    	T *assetPtr; 
    	std::string filename; 
    	unsigned int ID;	//Optional use, for super classes. 
    	//Optionally add any other required information 
    }; 
    
    Note that the structure is a template structure, so it can handle any type required as follows:
    AssetHolder texHolder; 
    
    The base class also needs to be created, again using a template class:
    template< typename T > 
    class AssetManager 
    { 
    public: 
    	/*Creates an asset manager and if logging is compiled in it will log loads and unloads to "logFile".*/ 
    	AssetManager(const char *logFile); 
    	/*Deletes the asset manager and all assets it was tracking.*/ 
    	virtual ~AssetManager(); 
    protected: 
    	 
    	#ifdef ASSETLOG 
    	std::string logFileName; 
    	std::ofstream logFile; 
    	#endif 
    	 
    private: 
    	/*A list of pointers to assets this class is tracking.*/ 
    	std::list< AssetHolder< T > > assetList; 
    }; 
    
    For the remainder of the article the preprocessor definition 'ASSETLOG' is considered to be a switch to enable and disable text file logging of loading and unloading events to track memory leaks and general program debugging. When the application is stable and released the text file logs are quickly removed from the source code. Of course this is totally optional.

    Every asset tracker should have certain basic functions, so they should be implemented in the base class. Here is a list of public functions that every asset tracker will inherit.

    /*Returns the AssetHolder struct with "filename" as a file name. If it does not exist, it returns NULL.*/ 
    T* checkLoaded(char *filename); 
    	 
    /*Returns a pointer to a structure created from a file, making sure that the file was not loaded twice. Also assigns the id of the returned object to uid.*/ 
    T* Aquire(char *filename, unsigned int &uid); 
    	 
    /*Returns a pointer to a structure created from a file, making sure that the file was not loaded twice.*/ 
    T* Aquire(char *filename); 
    
    /*Returns the pointer to the type if it has been loaded, else returns NULL*/ 
    T* isIDLoaded(unsigned int ID); 
    
    /*Deletes an object, by its ID, and returns true if the ID was in the list, false otherwise.*/ 
    bool DeleteByID(unsigned int ID); 
    /*Deletes an object, by its memory address, and returns true if the object was found, false otherwise.*/ 
    bool DeleteByPtr(T* ptr); 
    /*Deletes an object by its file name. If there is no such object the function returns false.*/ 
    bool DeleteByFilename(char *filename); 
    
    Note the T again. When a super class inherits this base class it will replace the T with whatever type it is tracking. So 'checkLoaded', for instance, would return a pointer to a texture, and a model tracker would return a pointer to a model. This next section of code will implement the 'checkLoaded' function so that the reader can get a feel for the syntax of template classes. Note that full source code is given at the end of this article.
    template< typename T > 
    T* AssetManager::checkLoaded(char *filename) 
    { 
      typename std::list< AssetHolder< T > >::iterator iter = assetList.begin();	  //Get the first element in the asset list. 
      while(iter!=assetList.end())							//Iterate through all the elements 
      { 
        if(strcmp(iter->filename.c_str(), filename)==0) 
          return iter->assetPtr;						//If we find a match return the pointer to whatever type we are tracking. 
        iter++; 
      } 
      return NULL;									//If there was no match, return NULL; 
    } 
    
    Also worthy of note with regards to the base class is that it must have a virtual Load function. The base class can track all data types but that doesn't mean that it can load them all. Every superclass needs to implement a Load function that returns a pointer to the memory it just loaded so that the code in the base class can track it. Therefore a virtual function needs to be declared protected in the base class.
    virtual T* Load(char *filename, unsigned int &userID) = 0; 
    
    This line forces all super classes to implement this function so that the base class can call it without knowing what asset type it's loading. Load is called in the base class even if it's not implemented, as follows:
    /*This is the main function for the asset loader. it returns the pointer to a loaded asset if it's loaded, or loads it then returns the pointer if it's not. 
    Returns NULL if there was a problem during loading.*/ 
    
    template< typename T > 
    T* AssetManager< T >::Aquire(char *filename, unsigned int &uid) 
    { 
      T* check = checkLoaded(filename);	        //checks to see if the file is already loaded. 
      if(check==NULL) 
      { 
        unsigned int userID; 
        T *loadedPtr = Load(filename, userID);	    //Called but not implemented, super class implements. Load assigns a uid and returns a pointer. 
        if(loadedPtr!=NULL) 
        { 
          AssetHolder newAssetHolder; 
          newAssetHolder.assetPtr = loadedPtr; 
          newAssetHolder.filename = filename; 
          newAssetHolder.ID = userID; 
          uid = userID; 
          assetList.push_back(newAssetHolder);	//Add the loaded object to the list of tracked objects. 
          #ifdef ASSETLOG 
          logFile<<"II - Loaded "<< filename <<"\n"; 
          logFile.flush(); 
          #endif 
          return loadedPtr; 
        } else 
        { 
          #ifdef ASSETLOG 
          logFile<<"EE - Failed to load "<< filename <<"\n"; 
          logFile.flush(); 
          #endif 
          return NULL;		        //indicates an error while loading. 
        } 
      } else 
        return check;		            //Return the already loaded object pointer. 
    } 
    

    Implementing a superclass

    Now that all the base functionality it is possible to use it over and over for any data type. The main purpose of a super class is to tell the base class what type to track and implement the load function. A texture manager class might look something like this:

    #include "AssetManager.h" 
    #include "Texture2D.h" 
    
    class TextureManager : public AssetManager< Texture2D > 
    { 
    public: 
      TextureManager(void); 
      ~TextureManager(void); 
     
    protected: 
      Texture2D* Load(char *filename, unsigned int &userID); 
    }; 
    
    To make this as clear as possible I will break it down line by line:
    #include "AssetManager.h"	//Our base class implementation 
    #include "Texture2D.h"		//The definition of the type we want to track, a texture in this case. 
    
    This next line is key.
    class TextureManager : public AssetManager< Texture2D > 
    
    It tells the compiler that the TextureManager will inherit from the AssetManager class, however the assetmanager class on its own isn't completely valid because the type T has to be defined. The < Texture2D > part does this. Texture2D gets passed into asset manager at compile time and "rewrites" the asset manager code to be a base class as if it were specifically written to track textures. All the T's are replaced with Texture2D's. This new Texture2D Assetmanager class is then inherited and the texture loading function is implemented. An example of a texture loading function might look like this: (using opengl and devIL for image loading)
    Texture2D* TextureManager::Load(char *filename, unsigned int &userID) 
    { 
    			//load image with devIL 
    	unsigned int devID; 
    	ilGenImages(1, &devID); 
    	ilBindImage(devID); 
    	if(ilLoadImage(filename)==IL_FALSE) 
    	{ 
    		ilDeleteImages(1, &devID);	 
    		return NULL; 
    	} 
    
    		//Get image attributes 
    	unsigned char *data = ilGetData(); 
    	int bbp = ilGetInteger(IL_IMAGE_BYTES_PER_PIXEL);		//if there are 3 bytes per pixel, it's RGB, RGBA otherwise. 
    	int width = ilGetInteger(IL_IMAGE_WIDTH); 
    	int height = ilGetInteger(IL_IMAGE_HEIGHT); 
    
    		//make sure we can convert the image to an appropriate format 
    	if(bbp==3) 
    	{ 
    		if(ilConvertImage(IL_RGB, IL_UNSIGNED_BYTE)==IL_FALSE) 
    		{ 
    			ilDeleteImages(1, &devID);	 
    			return NULL; 
    		} 
    	} else if(bbp==4) 
    	{ 
    		if(ilConvertImage(IL_RGBA, IL_UNSIGNED_BYTE)==IL_FALSE) 
    		{ 
    			ilDeleteImages(1, &devID);	 
    			return NULL; 
    		} 
    	} else 
    	{ 
    		ilDeleteImages(1, &devID); 
    		return NULL; 
    	} 
    
    	Texture2D *created; 
    	if(bbp==4) 
    		created = new Texture2D((unsigned int)width, (unsigned int)height, data, true, true, false);	//make an alpha texture 
    	else if(bbp==3) 
    		created = new Texture2D((unsigned int)width, (unsigned int)height, data, false, true, false); //make a normal texture 
    	else 
    		return NULL; 
    
    	userID = uIDCount; 
    	uIDCount++; 
    	return created; 
    } 
    
    With these two classes, the application can now call textureManager.Aquire("sometexture.*"); and not have to worry about loading the texture twice. If a model loading class is needed, the assetManager class doesn't have to be rewritten, only a new superclass with the correct type and loading function added in.

    User data

    It may also be useful to let the user of the loading class to be able to tell the class how to load files at runtime. An example could be scaling textures at runtime. One way would be to keep the loading settings in the file itself and let the superclass handle them. If the texture needs to be scaled differently each time the only way to pass these settings is via a void * pointer in the base class. A void * has to be used because the base has has no way of knowing what type of data the superclass will need. Basically by adding a void * to the Aquire function the superclass has to accept this void *. The only thing to watch out for is passing the right type to the right loader. The superclass needs to cast the void pointer back into whatever type it expects, then use the type normally. When passing that type into the Aquire function it needs to be cast into a void *. The sample code in this article has this implemented.

    Advanced usage

    If one wants to truly exploit the usage of base classes one could write a threaded base classe to implement threaded loading. A properly written AssetManager class would enable the threaded loading of any asset, even if the application is nearing the final stages of completion, and that is exactly what makes inheritance and template classes so powerful.

    Source Code

    Download Here

    Side Notes

    The whole notion of loading data from file could be replaced by generated data. For instance, instead of filenames and loading methods, they could be replaced with generation methods and seed numbers respectively. One could implement a texture generator, or terrain generator in this way.

    Acknowledgements and credits

    Thanks to the regulars at ##c++ for helping me work out the typename problem while porting to open source compilers.

    Brief Bio:
    I'm a self taught programmer, going into university in computer science this fall. I have about 3-4 years C++ and opengl, and about 3 years Java/BASIC before that.



      Report Article
    Sign in to follow this  


    User Feedback

    Create an account or sign in to leave a review

    You need to be a member in order to leave a review

    Create an account

    Sign up for a new account in our community. It's easy!

    Register a new account

    Sign in

    Already have an account? Sign in here.

    Sign In Now


      

    Share this review


    Link to review