Leveraging Inheritance and Template Classes for Asset Tracking

Published November 05, 2007 by Christopher Harvey, posted by Myopic Rhino
Do you see issues with this article? Let us know.
Advertisement

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.

Cancel Save
0 Likes 0 Comments

Comments

Nobody has left a comment. You can be the first!
You must log in to join the conversation.
Don't have a GameDev.net account? Sign up!
Advertisement