Click here to Skip to main content
15,867,594 members
Articles / Desktop Programming / Win32

TetroGL: An OpenGL Game Tutorial in C++ for Win32 Platforms - Part 2

Rate me:
Please Sign up or sign in to vote.
4.95/5 (36 votes)
29 Mar 2009CPOL28 min read 201.4K   5.6K   95   55
Learn how to load images, display them on the screen and manage them efficiently and how to display animations.
Overview.JPG

Foreword

This series of articles focuses on a 2D game development with C++ and OpenGL for Windows platform. We will not only focus on OpenGL but also talk about the designs that are commonly used in game programming with a full object oriented approach. You should already be familiar with the C++ language in order to get the maximum out of this series. There is a message board at the bottom of the article that you can use if you have questions, remarks or suggestions.

The series is divided into three articles:

  • Part 1 : covers the window creation and the setting-up of OpenGL.
  • Part 2: covers resources handling and displaying simple animations.
  • Part 3: groups everything together and talk about the game logic.

Contents

Introduction

The first article in the series focused on the main window creation and the set-up of OpenGL. This article will be a bit more fun because we will be able to load and display graphic files and display some animations. We will also see how we can efficiently manage those resources. The picture you see at the top of the article is what we will reach at the end of the article. This is not yet a game because it doesn't have any game logic: the only thing it does is the ability to move the character on the screen and animating it correctly (collision detection is not implemented).

Organizing the Files

We first start by organizing our files in a better way. I usually create a src folder which contains all my source files (*.cpp and *.h), a bin folder which contains the final executable and all the required resources, an obj folder which is used for the intermediate files resulting from the compilation and a dependencies folder which contains all external dependencies that are required for the compilation of my project (we will see later that we use an external dependency). The main advantage is that we now have a bin folder which contains what will be distributed. If you have many resources (images, music, configuration files, ...), you can even divide this bin folder into specific sub-folders. Take a look at the attached zip file to see the folder organization.

Let's now change the project settings in order to use this folder configuration. For the source files, just copy them into the src folder and add them to your project. To configure the output folder and the intermediate folder, change the Output Directory and the Intermediate Directory in the General section as shown in the following picture.

Settings.JPG

$(SolutionDir) and $(ConfigurationName) are predefined macros. The first one translates to the folder of the solution and the second one translates to the current active configuration (debug or release): in the obj folder, two sub-folders will be created, one for each configuration. Don't forget to apply those changes to both configurations (debug and release).

Loading Images

Unfortunately, OpenGL doesn't provide any support for loading graphic files. So, we have the choice to either write the code to load the images ourselves (and do that for each of the formats we are interested in), or to use an existing library that does the job for us. As you probably already guessed, the second choice is probably the best: we will gain a considerable amount of time and we will use a library that has already been tested and debugged and which is probably compatible with much more file formats than we will be able to write.

There are several options for which library to use. Two that I am aware of are: DevIL and FreeImage. DevIL is a bit more adapted to OpenGL so that is the reason why I've chosen this one, but FreeImage is a perfectly valid choice as well.

The first thing we do is to copy the required DevIL files in the dependencies folder: we first create a sub-folder called DevIL and we copy there the content of the archive that can be found on the DevIL website. We have to modify the name of a file in order to use it correctly: in the "include\IL" folder, you will find a file named config.h.win, rename it to config.h. Then copy the DevIL.dll file into your bin folder because it is used by your executable.

We then have to configure the project settings in order to use DevIL. In C/C++ category -> General -> Additional Include Directories, specify dependencies\DevIL\include\. This tells the compiler where to find the header files required for DevIL. This way, we won't need to supply the full path to the DevIL header file.

DevILSettings1.JPG

In Linker category-> General -> Additional Library Directories, specify dependencies\DevIL\lib. This tells the linker where to find additional folders which may contain library to link with.

DevILSettings2.JPG

And in Linker category -> Input -> Additional Dependencies, specify DevIL.lib. This tells the linker that the project must be linked with the DevIL library. Keep in mind that we were already linking to OpenGL32.lib.

DevILSettings3.JPG

Resource Management

Now that everything is set-up correctly to use DevIL, we are ready to load some images and display them. But first, let's think a bit of how we will manage those files a bit more efficiently. Suppose that we need to display a tree that is contained in a file called tree.png, the brute force approach is to simply load the file and store it in memory so that we can reuse it for each frame that needs to be drawn. This seems nice as a first approach but there is a small problem: Suppose that we now need to display this tree more than once, then we will load the texture several times in memory which is clearly inefficient. We need a way to be able to reuse the same texture if it is needed at different locations in our code. This is easily solved by delegating the loading to a specific class: the texture manager. Let's first take a look at this class before going into the details of the file loading itself:

C++
// The texture manager avoid a same texture to be loaded multiple
// times. It keeps a map containing all the already loaded textures.
class CTextureManager
{
public:
  // Loads a texture specified by its filename. If the texture is not
  // loaded already, the texture manager will load it, store it and
  // return it. Otherwise it simply returns the existing one.
  CTexture* GetTexture(const std::string& strTextName);
  // Release the texture specified by its filename. Returns true if
  // the texture was found, otherwise false.
  bool ReleaseTexture(const std::string& strTextName);

  // Returns the single instance of the texture manager.
  // The manager is implemented as a singleton.
  static CTextureManager* GetInstance();

protected:
  // Both constructor and destructor are protected to make
  // it impossible to create an instance directly.
  CTextureManager();
  ~CTextureManager();

private:
  typedef std::map<std::string,CTexture*> TTextureMap;
  // The map of already loaded textures. There are indexed
  // using their filename.
  TTextureMap m_Textures;
};

The first thing to notice about this class is that it is implemented as a singleton pattern. If you never heard about the singleton pattern before, take a look at the references, there's a link to an article discussing it. Basically, it ensures that the class has only one instance and provides a way to access it. In our case, the constructor is protected which forbids anybody to create an instance directly. Instead, a static method (GetInstance) allows you to retrieve the unique instance of the class:

C++
CTextureManager* CTextureManager::GetInstance()
{
  // Returns the unique class instance.
  static CTextureManager Instance;
  return &Instance;
}

I won't discuss this pattern in detail here but don't hesitate to take a look at the article or Google for it (there are plenty of articles discussing it). In our case, we only want a single instance of this class and having a global point to access it makes it easy to use:

C++
CTexture* pTexture = CTextureManager::GetInstance()->GetTexture("MyTexture.bmp");

The constructor of the class takes care of initializing the DevIL library properly:

C++
CTextureManager::CTextureManager() : m_Textures()
{
  // Initialize DevIL
  ilInit();

  // Set the first loaded point to the
  // upper-left corner.
  ilOriginFunc(IL_ORIGIN_UPPER_LEFT);
  ilEnable(IL_ORIGIN_SET);
}

Before calling any DevIL function, you first have to call ilInit in order to initialize the library. We will also specify how the images will be loaded: the upper-left corner first. This is done so that we won't have inverted textures. By default this option is disabled so we enable it by calling ilEnable(IL_ORIGIN_SET).

Let's now look at the GetTexture method:

C++
CTexture* CTextureManager::GetTexture(const string& strTextName)
{
  // Look in the map if the texture is already loaded.
  TTextureMap::const_iterator iter = m_Textures.find(strTextName);
  if (iter != m_Textures.end())
    return iter->second;

  // If it was not found, try to load it from file. If the load
  // failed, delete the texture and throw an exception.
  CTexture* pNewText = NULL;
  try
  {
    pNewText = new CTexture(strTextName);
  }
  catch (CException& e)
  {
    delete pNewText;
    throw e;
  }

  // Store the newly loaded texture and return it.
  m_Textures[strTextName] = pNewText;
  return pNewText;
}

The code is not too difficult to understand: We first try to retrieve the texture specified by strTextName in the map of already loaded texture. If it was found, it is returned, otherwise we try to load it from the file. As we will see later, the constructor of CTexture attempts to load the file and throw an exception if it fails to do so. Then, in the texture manager, if an exception was caught, we delete the texture (to avoid a memory leak) and we re-throw the exception. If the texture was loaded successfully, it is stored in the map (using its name as a key) and it is returned.

A method to release existing texture is also provided:

C++
bool CTextureManager::ReleaseTexture(const std::string& strTextName)
{
  // Retrieve the texture from the map
  bool bFound = false;
  TTextureMap::iterator iter = m_Textures.find(strTextName);
  if (iter != m_Textures.end())
  {
    // If it was found, we delete it and remove the
    // pointer from the map.
    bFound = true;
    if (iter->second)
      delete iter->second;
    m_Textures.erase(iter);
  }

  return bFound;
}

Here also, the code is rather self-explanatory: we simply try to retrieve the texture from the map and on success, we delete it and remove the pointer from the map. If the texture was successfully removed, the function returns true.

The CTexture Class

Let's now look at the CTexture class in more detail:

C++
class CTexture
{
  friend class CTextureManager;

public:
  // Specifies a color key to be used for the texture. The color
  // specified as arguments will be transparent when the texture
  // is rendered on the screen.
  void SetColorKey(unsigned char Red, unsigned char Green, unsigned char Blue);

  // Returns the width of the texture
  unsigned int GetWidth()  const  { return m_TextData.nWidth;  }
  // Returns the height of the texture.
  unsigned int GetHeight() const  { return m_TextData.nHeight; }

  // Adds/release a reference for the texture. When ReleaseReference
  // is called and decreases the reference count to 0, the texture
  // is released from the texture manager.
  void AddReference();
  void ReleaseReference();

  // Bind this texture with openGL: this texture becomes
  // the 'active' texture in openGL.
  void Bind() const;

protected:
  // Constructor which takes the filename as argument.
  // It loads the file and throw an exception if the load
  // failed.
  CTexture(const std::string& strFileName);
  ~CTexture();

private:
  // Loads the texture from the specified file. Throws an
  // exception if the load failed.
  void LoadFile(const std::string& strFileName);

  // Structure that contains the information about the texture.
  struct STextureData
  {
    // Width of the texture
    unsigned int   nWidth;
    // Height of the texture
    unsigned int   nHeight;
    // Byte array containing the texture data
    unsigned char* pData;
  };
  STextureData m_TextData;

  // The openGL id associated with this texture.
  mutable GLuint m_glId;

  // Reference count of the number of images that still hold a reference
  // to this texture. When no images reference the texture anymore, it is
  // released.
  int m_iRefCount;
  // The filename from which the texture was loaded from.
  std::string m_strTextName;
};

For this class, we can also see that the constructor has been made protected. The reason is that only the CTextureManager class should be able to create textures, that's the reason it has been made a friend of this class. The core of the CTexture class is the STextureData structure, which contains all the data loaded from the file: an array of bytes containing the file data and the width and height of the texture. Let's see how the file is loaded, which is done in the LoadFile(const std::string& strFileName) function:

C++
void CTexture::LoadFile(const std::string& strFileName)
{
  // Generate a new image Id and bind it with the
  // current image.
  ILuint imgId;
  ilGenImages(1,&imgId);
  ilBindImage(imgId);

  // Load the file data in the current image.
  if (!ilLoadImage(strFileName.c_str()))
  {
    string strError = "Failed to load file: " + strFileName;
    throw CException(strError);
  }

  // Store the data in our STextureData structure.
  m_TextData.nWidth = ilGetInteger(IL_IMAGE_WIDTH);
  m_TextData.nHeight  = ilGetInteger(IL_IMAGE_HEIGHT);

  unsigned int size = m_TextData.nWidth * m_TextData.nHeight * 4;
  m_TextData.pData = new unsigned char[size];
  ilCopyPixels(0, 0, 0, m_TextData.nWidth, m_TextData.nHeight,
    1, IL_RGBA, IL_UNSIGNED_BYTE, m_TextData.pData);
  // Finally, delete the DevIL image data.
  ilDeleteImage(imgId);
}

As you can see, we are using DevIL to load the file. The first thing we do is create a new image id in DevIL and bind it with the current image. This is needed if you want to do some manipulation on a certain image using its id. In fact, we will only use it to delete the image later when we have finished using it. Next, we try to load the file using ilLoadImage: The function takes care of the different file formats and will return false if the load failed (you can also retrieve an error code by calling ilGetError). If that's the case, we simply throw an exception. If you remember the first article, those exceptions will be caught in the main function and display an error message before exiting the program. We then retrieve the width and height of the image (the ilGetInteger and ilCopyPixels functions always work on the current active image). We then allocate room for the data in the m_TextData.pData field: each pixel is coded on 4 bytes (we will see this later). We then call the ilCopyPixels function to copy the image data in our buffer. The three first parameters are the X, Y and Z offset of where to start copying (the Z offset is used for volumetric images), and the three next parameters are the number of pixels to copy in those directions (here also, we don't use volumetric images so the Depth is 1). Then we specify the format of the image: a RGBA format which means 1 byte for each color channel (Red, Green and Blue, or RGB) and one byte for the alpha channel (A).The alpha channel is used to specify the transparency of the pixel. A value of 0 means fully transparent and a value of 255 means fully opaque. We then specify the type of each component: they should be coded as unsigned bytes (unsigned chars). The last argument of the function is the pointer to the buffer where to copy the pixels. At the end, we delete the DevIL image data because we won't need it anymore.

Remark: There's an easier way to load textures with DevIL if you want to use them in OpenGL. The ILUT library allows you to load an image and associate it directly with an OpenGL texture by calling ilutGLLoadImage which returns the OpenGL id of the texture. This is the easiest way to go but you won't be able to manipulate the raw data directly as we will do to set the color key.

Once the data has been loaded from the file, we need to generate a new OpenGL texture and supply the data. This is done the first time the texture is requested to be used, in the CTexture::Bind() function:

C++
void CTexture::Bind() const
{
  // If the texture has not been generated in OpenGL yet,
  // generate it.
  if(!m_glId)
  {
    // Generate one new texture Id.
    glGenTextures(1,&m_glId);
    // Make this texture the active one, so that each
    // subsequent glTex* calls will affect it.
    glBindTexture(GL_TEXTURE_2D,m_glId);

    // Specify a linear filter for both the minification and
    // magnification.
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    // Sets drawing mode to GL_MODULATE
    glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);


    // Finally, generate the texture data in OpenGL.
    glTexImage2D(GL_TEXTURE_2D, 0, 4, m_TextData.nWidth, m_TextData.nHeight,
        0,GL_RGBA,GL_UNSIGNED_BYTE,m_TextData.pData);
  }

  // Make the existing texture specified by its OpenGL id
  // the active texture.
  glBindTexture(GL_TEXTURE_2D,m_glId);
}

The important point to understand when working with textures is that OpenGL only works with one texture at a time. So, in order to texture a polygon, you need to select the active texture (also called 'binding'). This is done by calling glBindTexture. Each OpenGL texture has its own Id, which is in our case stored in the m_glId member of the CTexture class. An Id of 0 is reserved and will never be generated by OpenGL, so we can use it to specify that our texture has not been generated in OpenGL yet. So, the first time this function is called, m_glId will be 0. If we look inside the if condition (so, if the texture is not generated), the first thing we do is ask OpenGL to generate a free Id for us by calling glGenTextures.

The m_glId is mutable because we still want the bind function to be const and this member will be modified only once, when the texture is generated. The glGenTextures function lets you generate multiple Id (the first argument is the number of Id to be generated), but we only want a single Id, which will be stored in m_glId. We then call glBindTexture: this binds the texture specified by its Id to the active 2 dimensional active texture (you can also work with 1 dimensional textures). This is needed so that each subsequent calls to texture manipulation routines will affect this specific texture (this in fact makes our texture the active one in OpenGL).

We then specify the filtering for minification and magnification: in general, one point of the texture, also called a texel, does not map directly to one pixel on the screen. Sometimes a texel covers less than a pixel (so that you have more than one texel in one pixel) or sometimes it is the opposite (a texel covers multiple pixels). When a pixel contains only a portion of a texel, it is called magnification and when a pixel contains several texels, it is called minification. Those two functions tells how should OpenGL interpret those situations: if you specify GL_LINEAR, OpenGL uses a linear average of the 2x2 array of texels that lies nearest to the center of the pixel. If you specify GL_NEAREST, OpenGL will use the texel with coordinates nearest the center of the pixel. There are other options for the minification filter which consists of having multiple copies of the texture for different sizes (those copies are called mip-maps) but we won't enter into too much details here.

Next, glTexEnvf sets the drawing mode to GL_MODULATE so that the color of the textured polygons will be a modulation of the texture color and the color on which the texture is pasted. This is needed in order to make some parts of the image transparent using the alpha channel. Finally, we generate the OpenGL texture by calling glTexImage2D: the first argument to the function is the type of texture (1 or 2 dimensions), the second argument is the level of the texture in case we are using multiple resolutions of the texture (mip-maps).

In our case, we don't use multiple resolution, so we specify 0. The third argument is the number of components (R, G, B and A) that are used for modulating and blending. The two following arguments are the width and the height of the texture. The 6th argument specifies the width of the texture border, which is 0 in our case. The 7th and 8th arguments describe the format and data type of the texture data: the texture format is RGBA and each component of a texel is an unsigned byte. The last parameter is the pointer to the data.

Warning: OpenGL works with textures that have a size (width and height) which is a power of two (so, a 128x128 texture is valid but a 128x120 is not). On some graphic cards, displaying textures that do not follow this rule might fail and you will only see a white rectangle. A solution to the problem is to have all your textures follow this rule (even if you have to leave some non-used space in the image file). Another solution is to manage that when loading the image: you could always create a buffer which has the correct dimensions and load the image in it (but you have to take care how you do this, because the unused pixels should be left on each line).

If CTexture::Bind() is called when the texture is already available, the function only calls glBindTexture, which makes this texture the active one. We will see later how this texture will be used to be drawn on the screen.

A feature that is often used in games is what we call color keying. Some file formats do not support a transparent channel (like a BMP file), so if you want to make some parts of the texture transparent, the only option is to use a specific color that will be made transparent. OpenGL does not support color keying but it can easily be added by using the alpha channel of the texture. That is what the CTexture::SetColorKey function is doing:

C++
void CTexture::SetColorKey(unsigned char Red,
               unsigned char Green,
               unsigned char Blue)
{
  // If the texture has already been specified to OpenGL,
  // we delete it.
  if (m_glId)
  {
    glDeleteTextures(1,&m_glId);
    m_glId = 0;
  }

  // For all the pixels that correspond to the specified color,
  // set the alpha channel to 0 (transparent) and reset the other
  // ones to 255.
  unsigned long Count = m_TextData.nWidth * m_TextData.nHeight * 4;
  for (unsigned long i = 0; i<Count; i+=4)
  {
    if ( (m_TextData.pData[i]==Red) && (m_TextData.pData[i+1]==Green)
        && (m_TextData.pData[i+2]==Blue) )
      m_TextData.pData[i+3] = 0;
    else
      m_TextData.pData[i+3] = 255;

  }
}

The function is quite basic: we walk over our texture data and if we find a pixel of the specified color, we set its alpha channel to 0, which means fully transparent. For all the other pixels, we reset the channel to 255 (suppress a previous color key). But we first need to check if the texture was already specified to OpenGL. If that is the case, we need to reload the texture in OpenGL. This is done by simply setting m_glId to 0 (if you remember, the Bind function first checks if this variable is 0). By calling glDeleteTextures, we delete the texture in OpenGL (the first argument is the number of textures we want to delete and the second is their Id).

Finally, the texture is reference counted and its constructor is protected, so that you can't create a CTexture object directly. The reference counted is done through the AddReference and ReleaseReference functions:

C++
void CTexture::AddReference()
{
  // Increase the reference count.
  m_iRefCount++;
}

void CTexture::ReleaseReference()
{
  // Decrease the reference count. If it reaches 0,
  // the texture is released from the texture manager.
  m_iRefCount--;
  if (m_iRefCount == 0)
    CTextureManager::GetInstance()->ReleaseTexture(m_strTextName);
}

As you can see, nothing really fancy here: whenever a CTexture object is referenced, AddReference is called which increases the reference count. Once the texture is not needed anymore, ReleaseReference is called which decrements the reference count. Once it reaches 0, the texture will be released from the texture manager (which will delete it). Reference counting is used because several CImage objects can reference the same texture. We need to know how many of them are still using the texture instead of releasing it whenever one of the image objects is destroyed.

The CImage Class

Let's now look at how this texture is used by the CImage class. As we saw earlier, the CTexture is not manipulated directly by the user. The reason is that it is mainly a wrapper around a resource file and such file can be made of several images: suppose that you want to display several kind of trees in your game, it could be convenient to have them all stored in the same file. So, the texture class in itself doesn't have any functionality to draw the image on the screen, but only to load a file. The image class is the one responsible to draw the texture (or only a part of it) on the screen. Several images can then reference the same texture but use a different portion of it.

C++
// Typedef of a CImage class that is wrapped inside a smart
// pointer.
typedef CSmartPtr<CImage> TImagePtr;

// An image is manipulated directly by the end user (instead of
// the texture). The main difference between an image and a texture
// is that the texture can contain multiple images (it is the
// complete file).
class CImage
{
public:
  // Blit the image at the specified location
  void BlitImage(int iXOffset=0, int iYOffset=0) const;
  // Returns the texture that this image is using.
  CTexture* GetTexture() const  { return m_pTexture; }

  // Helper functions to create an new image. A smart pointer
  // holding the new image is returned. strFileName is the
  // name of the file containing the texture and textCoord is
  // the rectangle in this texture which contains the image.
  static TImagePtr CreateImage(const std::string& strFileName);
  static TImagePtr CreateImage(const std::string& strFileName,
                 const TRectanglei& textCoord);

  ~CImage();

protected:
  // Protected constructors to avoid to be able to create a
  // CImage instance directly.
  CImage(const std::string& strFileName);
  CImage(const std::string& strFileName, const TRectanglei& textCoord);

private:
  // The texture from which this image is part of.
  CTexture*   m_pTexture;
  // The rectangle that specifies the position of the image
  // in the full texture.
  TRectanglei  m_rectTextCoord;
};

Before going into the details about how to instantiate this class, we will look at how it works. It has two members: the texture from which the image comes from and a rectangle specifying the portion of the texture which contains the image. I won't put the code of the CRectangle class because it is very trivial: It contains four members which are the top, bottom, left and right coordinates of the rectangle plus some support functions (like checking if it intersects with another rectangle, retrieve the width and height of the rectangle, ...). It is a template class, so you can choose the type of the rectangle coordinates (integer, float, double, ...). TRectanglei is a typedef for a rectangle with integer coordinates. Let's see how the BlitImage function works, by drawing the texture at the location specified by the arguments:

C++
void CImage::BlitImage(int iXOffset, int iYOffset) const
{
  if (m_pTexture)
  {
    m_pTexture->Bind();

    // Get the coordinates of the image in the texture, expressed
    // as a value from 0 to 1.
    float Top  = ((float)m_rectTextCoord.m_Top)/m_pTexture->GetHeight();
    float Bottom = ((float)m_rectTextCoord.m_Bottom)/m_pTexture->GetHeight();
    float Left   = ((float)m_rectTextCoord.m_Left)/m_pTexture->GetWidth();
    float Right  = ((float)m_rectTextCoord.m_Right)/m_pTexture->GetWidth();

    // Draw the textured rectangle.
    glBegin(GL_QUADS);
    glTexCoord2f(Left,Top);      glVertex3i(iXOffset,iYOffset,0);
    glTexCoord2f(Left,Bottom);   glVertex3i(iXOffset,iYOffset+
                                    m_rectTextCoord.GetHeight(),0);
    glTexCoord2f(Right,Bottom);  glVertex3i(iXOffset+m_rectTextCoord.GetWidth(),
                                    iYOffset+m_rectTextCoord.GetHeight(),0);
    glTexCoord2f(Right,Top);     glVertex3i(iXOffset+m_rectTextCoord.GetWidth(),
                                    iYOffset,0);
    glEnd();
  }
}

We first bind the texture (make it the active one in OpenGL), then we calculate the coordinates of the image within the texture. Those values are expressed between 0 and 1, with 0 being the top/left side of the texture and 1 being the bottom/right side of the texture. We then draw a rectangle as seen in the first tutorial, except that before specifying each point, we call glTexCoord2f which specifies a texel (point in a texture) in the current binded OpenGL texture. By doing this, OpenGL will be able to associate texels from the texture to pixels on the screen, and display our textured rectangle using the active texture.

Let's now look at the constructors and destructor. There are two constructors (which are protected): one which accepts only a texture name and one which accepts both a texture name and a rectangle. The one with only the texture name will use the full texture as the image, and the other one will use the image contained at the specified rectangle in the file.

C++
CImage::CImage(const string& strFileName)
  : m_pTexture(NULL), m_rectTextCoord()
{
  // This line will throw an exception if the texture is not found.
  m_pTexture = CTextureManager::GetInstance()->GetTexture(strFileName);
  m_pTexture->AddReference();

  // Set the texture coordinate to the full texture
  m_rectTextCoord.m_Top = m_rectTextCoord.m_Left = 0;
  m_rectTextCoord.m_Bottom = m_pTexture->GetHeight();
  m_rectTextCoord.m_Right = m_pTexture->GetWidth();
}

CImage::CImage(const string& strFileName, const TRectanglei& textCoord)
  : m_pTexture(NULL), m_rectTextCoord(textCoord)
{
  // This line will throw an exception if the texture is not found.
  m_pTexture = CTextureManager::GetInstance()->GetTexture(strFileName);
  m_pTexture->AddReference();
}

CImage::~CImage()
{
  if (m_pTexture)
    m_pTexture->ReleaseReference();
}

The constructors retrieve the texture through the texture manager. Remember that this call can throw an exception if the texture doesn't exist. Then the reference count of the texture is increased. In case no rectangle was specified, the full texture is used as an image. The destructor simply releases the texture which decrements the reference count as seen earlier in the texture class.

As I already said, the constructors of the class are protected. The reason for that is to force the user to use a smart pointer that wraps the CImage class. Ok, before panicking because of this strange thing, let me first say that wrapping the CImage class into a smart pointer is not a necessity but it is very useful to make sure that all of the resources are released when not used anymore. If you don't allocate dynamically CImage objects (using new), this is already done for you (through the destructor). But as soon as you are creating dynamic objects, you can always forget to delete them, which lead to unreleased resources. Furthermore, if you start exchanging those objects between different parts of your code, which part should be responsible to delete the object? All those problems are solved by wrapping the object into a smart pointer class. I won't fully discuss how it is implemented because there are already a lot of articles covering this subject (you can have a look at the references, there is a link to a good article). In brief, a smart pointer takes care of the lifetime of the object which it is maintaining: when the object is not needed anymore, it is destroyed. You can 'share' this pointer and once it is not needed anymore, the pointed object will be deleted. You can also easily access the wrapped object as if you were manipulating it directly: the smart pointer overloads the -> and . operators to redirect them to the owned object. All of that sounds a bit complicated, but the usage is really easy: Instead of using the pointer to the object directly, you give it to a smart pointer which will take care of its lifetime for you (you don't have to delete the pointer anymore). The access to the object is almost transparent because you can still access the members as if you were using the pointer directly.

For this tutorial, I provided my own smart pointer class but it is preferable in general to use the boost::shared_ptr class (see references). The reason why I provided mine is simply to avoid having yet another dependency so that it is easier for you to compile the project (you don't have to download the package from boost). You can have a look at how it is implemented but I won't give a full explanation here.

Finally, the CImage class provides two static helper functions to be able to create instances of the class. They simply create a new instance, pass it to a smart pointer and return the smart pointer:

C++
TImagePtr CImage::CreateImage(const string& strFileName)
{
  TImagePtr imgPtr(new CImage(strFileName));
  return imgPtr;
}

TImagePtr CImage::CreateImage(const string& strFileName, const TRectanglei& textCoord)
{
  TImagePtr imgPtr(new CImage(strFileName,textCoord));
  return imgPtr;
}

Displaying Animations

What would be a game without animations? Probably quite boring to play, so let's look at how we can add some dynamism here by playing animations. The basic idea behind animations in 2D games is rather simple: It is the same as a cartoon, which consists of breaking up the movement into distinct images. The brute force approach would be to have a loop in which you sleep for a while before displaying the next image. As you might already have guessed, this doesn't work at all. You have several issues if you try to do that: first, nothing will be displayed at all because you never swap the buffers (which was done in the CMainWindow::Draw() function). Second, if you do that, the rest of your program is not processed at all, which also means that you would only be able to display one animation at a time. Not very convenient... The correct approach consists of letting each 'animation' remember its state (e.g. which image it is currently displaying) and asking all of them to draw their current image. When a new frame should be drawn, each animation is 'asked' to go to the next image in the animation. This way, you keep a continuous flow in your program.

Let's now take a look at the CImageList class. This class is basically a wrapper class around a std::list which contains images and provides some helper functions to play the images:

C++
// Wraps a list of images which is used to play animations.
class CImageList
{
public:
  // Default constructor: construct an empty list.
  CImageList();
  // Copy constructor: copies the content of the
  // list passed in argument.
  CImageList(const CImageList& other);
  // Default destructor.
  ~CImageList();

  // Assignment operator: empty the current content
  // and copies the content of the list passed in argument.
  CImageList& operator= (const CImageList& other);

  // Empty the content of the list
  void Clear();
  // Append a new image to the list
  void AppendImage(TImagePtr pImage);
  // Return the number of images in this list
  unsigned GetImagesCount() const;

  // Make the first image active
  void GoToFirstImage();
  // Make the next image active. If the last image
  // was active, we go back to the first image. In
  // that case, the function returns true.
  bool GoToNextImage();
  // Get the current image
  TImagePtr GetCurrentImage() const;

private:
  // Typedef for a std::list containing TImagePtr objects
  typedef std::list<TImagePtr> TImageList;
  // The list of images
  TImageList m_lstImages;

  // Iterator pointing to the current image
  TImageList::iterator m_iterCurrentImg;
};

The implementation is pretty straightforward: it basically adds images to a std::list<TImagePtr> on demand and keeps an iterator which points to the currently active image. Let's for example take a look at the GoToNextImage() function:

C++
bool CImageList::GoToNextImage()
{
  if (m_iterCurrentImg != m_lstImages.end() )
    m_iterCurrentImg++;
  else
    return false;

  if (m_iterCurrentImg != m_lstImages.end() )
  {
    m_iterCurrentImg = m_lstImages.begin();
    return true;
  }
  return false;
}

We first check if the iterator is valid (doesn't point at the end of the list). The iterator is invalid when the list is empty: in that case we simply return from the function, otherwise we increase the iterator. We then check if the iterator reached the end of the list (which happens when it was previously pointing to the last image). In that case we reset it to the first image and we return true. I won't explain the other functions because they are rather trivial, but don't hesitate to take a look at the code.

Let's now look at the CAnimatedSprite class which allows you to group several animations together. Let's take an example: suppose that you are writing a game in which the player plays a knight. This knight will of course have multiple different animations: walk, attack, standstill, ... In general, you will need to provide such animations for each direction your knight can have in your game. This class will then be used to represent your knight: you will be able to load several animations and replay them later on demand:

C++
// This class represent an animated sprite: it is able to play
// different animations that were previously loaded.
class CAnimatedSprite
{
public:
  // Default constructor and destructor.
  CAnimatedSprite();
  ~CAnimatedSprite();

  // Adds a new animation for the sprite. The strAnimName
  // is a string that identifies the animation and should
  // be unique for this sprite.
  void AddAnimation(const std::string& strAnimName,
            const CImageList& lstAnimation);
  // Plays a previously loaded animation. The strAnimName
  // is the name that was passed when calling AddAnimation.
  void PlayAnimation(const std::string& strAnimName);

  // Draw the current frame of the animation at the sprite
  // current position.
  void DrawSprite();
  // Go to the next animation frame.
  void NextFrame();

  // Set the position of the sprite
  void SetPosition(int XPos, int YPos)
  {
    m_iXPos = XPos;
    m_iYPos = YPos;
  }
  // Move the sprite from its current position
  void OffsetPosition(int XOffset, int YOffset)
  {
    m_iXPos += XOffset;
    m_iYPos += YOffset;
  }

private:
  typedef std::map<std::string, CImageList> TAnimMap;
  typedef TAnimMap::iterator TAnimMapIter;

  // Map containing all the animations that can be
  // played.
  TAnimMap m_mapAnimations;
  // Iterator to the current animation being played
  TAnimMapIter  m_iterCurrentAnim;

  // Position of the sprite
  int m_iXPos;
  int m_iYPos;
};

The principle of the class is the following: it contains a map of all animations that can be played for the sprite, with the key being a string identifying the animation and the value being a CImageList object containing the animation. The AddAnimation and PlayAnimation simply add or retrieve an animation from the map:

C++
void CAnimatedSprite::AddAnimation(const string &strAnimName,
                   const CImageList& lstAnimation)
{
  m_mapAnimations[strAnimName] = lstAnimation;
}

void CAnimatedSprite::PlayAnimation(const string &strAnimName)
{
  m_iterCurrentAnim = m_mapAnimations.find(strAnimName);
  if (m_iterCurrentAnim == m_mapAnimations.end())
  {
    string strError = "Unable to play: " + strAnimName;
    strError += ". Animation not found.";
    throw CException(strError);
  }
}

When trying to play an non existing animation, an exception is thrown. The m_iterCurrentAnim variable is an iterator pointing to the current animation. It is used in the DrawSprite and NextFrame method to access the current animation:

C++
void CAnimatedSprite::DrawSprite()
{
  if (m_iterCurrentAnim == m_mapAnimations.end())
    return;
  m_iterCurrentAnim->second.GetCurrentImage()
    ->BlitImage(m_iXPos,m_iYPos);
}

void CAnimatedSprite::NextFrame()
{
  if (m_iterCurrentAnim == m_mapAnimations.end())
    return;

  m_iterCurrentAnim->second.GoToNextImage();
}

In the DrawSprite method, we retrieve the current image of the current animation and simply blit it at the specified position on the screen (remember how the CImage class was working). In the NextFrame, we simply go to the next image in the current animation.

Example

After all those explanations, it is time for a concrete example to see how we will use all those classes. The example will be quite simple and far from a complete game, but it shows the principles. The purpose is to have an animated character (a knight) that can be controlled through the direction keys. It moves in a simple scene: grass with some trees on it, in an isometric view. There is no collision detection yet, which means that the knight can move through the trees. Another thing that is not implemented is the order in which the sprites are drawn: the knight will always be drawn on top of the scene, no matter where he is, which is wrong in some situations (if he is behind a tree, the tree should be drawn on top of the knight). This is left as an exercise to the reader :).

All the code will be implemented in the CMainWindow class. Let's first add some member variables in this class:

C++
// The image for the grass.
TImagePtr m_pGrassImg;

// Images for the trees
TImagePtr m_pTreesImg[16];

// The animated sprite of the knight
CAnimatedSprite* m_pKnightSprite;
// Which keys are currently pressed
bool m_KeysDown[4];
// The last direction of the knight
std::string m_strLastDir;

We first declare some TImagePtr which will hold several images that will be drawn (grass and trees). We then declare the CAnimatedSprite which will be used to draw the knight. We finally have an array of 4 booleans to store the current state of the direction keys and a string that contains the current direction of the knight. Those variables are initialized in the constructor of the main window class:

C++
// Load the grass image and set the color key.
m_pGrassImg = CImage::CreateImage("GrassIso.bmp");
m_pGrassImg->GetTexture()->SetColorKey(0,128,128);

// Load all the 'walk' animations for the knight.
m_pKnightSprite = new CAnimatedSprite;
CAnimFileLoader fileLoader1("KnightWalk.bmp", 8, 96, 96);
CTextureManager::GetInstance()->GetTexture("KnightWalk.bmp")
  ->SetColorKey(111, 79, 51);
m_pKnightSprite->AddAnimation("WalkE",
    fileLoader1.GetAnimation(0,7));
m_pKnightSprite->AddAnimation("WalkSE",
    fileLoader1.GetAnimation(8,15));
m_pKnightSprite->AddAnimation("WalkS",
    fileLoader1.GetAnimation(16,23));
m_pKnightSprite->AddAnimation("WalkSW",
    fileLoader1.GetAnimation(24,31));
m_pKnightSprite->AddAnimation("WalkW",
    fileLoader1.GetAnimation(32,39));
m_pKnightSprite->AddAnimation("WalkNW",
    fileLoader1.GetAnimation(40,47));
m_pKnightSprite->AddAnimation("WalkN",
    fileLoader1.GetAnimation(48,55));
m_pKnightSprite->AddAnimation("WalkNE",
    fileLoader1.GetAnimation(56,63));

// Load all the 'pause' animations for the knight.
CAnimFileLoader fileLoader2("KnightPause.bmp", 8, 96, 96);
CTextureManager::GetInstance()->GetTexture("KnightPause.bmp")
  ->SetColorKey(111, 79, 51);
m_pKnightSprite->AddAnimation("PauseE",
    fileLoader2.GetAnimation(0,7));
m_pKnightSprite->AddAnimation("PauseSE",
    fileLoader2.GetAnimation(8,15));
m_pKnightSprite->AddAnimation("PauseS",
    fileLoader2.GetAnimation(16,23));
m_pKnightSprite->AddAnimation("PauseSW",
    fileLoader2.GetAnimation(24,31));
m_pKnightSprite->AddAnimation("PauseW",
    fileLoader2.GetAnimation(32,39));
m_pKnightSprite->AddAnimation("PauseNW",
    fileLoader2.GetAnimation(40,47));
m_pKnightSprite->AddAnimation("PauseN",
    fileLoader2.GetAnimation(48,55));
m_pKnightSprite->AddAnimation("PauseNE",
    fileLoader2.GetAnimation(56,63));
m_pKnightSprite->PlayAnimation("PauseE");

for (int i=0; i<4; i++)
  m_KeysDown[i] = false;
// Set the initial direction to the east.
m_strLastDir = "E";
m_pKnightSprite->SetPosition(350,250);

This looks like a lot of code but we need to load quite a bunch of animations for our knight: 2 animations (walk and pause) for each direction (8 different directions). We are using a new class here: the CAnimFileLoader class. It is a simple helper class to easily load an image list from a file. It takes the file name, the number of images per row, the width and the height of an image as parameters in the constructor and you can retrieve an image list later by simply specifying the start index and the stop index of images in the file (it returns a CImageList object). If you now look at the code, we first load the grass image and specify its color key, then we load all the 'walk' animations for our knight. Each animation name depends on the direction, e.g. for the 'walk' east direction, the animation name is "WalkE". This will be used later to play a specific animation. We then specify that the default animation is the "PauseE" animation.

Let's now look at how we handle the events when a key is pressed. This is done in the ProcessEvent function:

C++
void CMainWindow::ProcessEvent(UINT Message, WPARAM wParam, LPARAM lParam)
{
  switch (Message)
  {
  // Quit when we close the main window
  case WM_CLOSE :
    PostQuitMessage(0);
    break;
  case WM_SIZE:
    OnSize(LOWORD(lParam),HIWORD(lParam));
    break;
  case WM_KEYDOWN :
    switch (wParam)
    {
    case VK_UP:
      m_KeysDown[0] = true;
      break;
    case VK_DOWN:
      m_KeysDown[1] = true;
      break;
    case VK_LEFT:
      m_KeysDown[2] = true;
      break;
    case VK_RIGHT:
      m_KeysDown[3] = true;
      break;
    }
    UpdateAnimation();
    break;
  case WM_KEYUP :
    switch (wParam)
    {
    case VK_UP:
      m_KeysDown[0] = false;
      break;
    case VK_DOWN:
      m_KeysDown[1] = false;
      break;
    case VK_LEFT:
      m_KeysDown[2] = false;
      break;
    case VK_RIGHT:
      m_KeysDown[3] = false;
      break;
    }
    UpdateAnimation();
    break;
  }
}

As you can see, we handle the WM_KEYDOWN and the WM_KEYUP messages, which correspond to a key pressed and a key released respectively. When such message is sent, the WPARAM contains the code of the key which is pressed or released. We simply then set or reset the flag in our array to specify the state of the corresponding key (so, the first element in the array corresponds to the up key, the second to the down key, ...). We then call the UpdateAnimation function:

C++
void CMainWindow::UpdateAnimation()
{
  // First check if at least one key is pressed
  bool keyPressed = false;
  for (int i=0; i<4; i++)
  {
    if (m_KeysDown[i])
    {
      keyPressed = true;
      break;
    }
  }

  string strAnim;
  if (!keyPressed)
    strAnim = "Pause" + m_strLastDir;
  if (keyPressed)
  {
    string vertDir;
    string horizDir;
    if (m_KeysDown[0])
      vertDir = "N";
    else if (m_KeysDown[1])
      vertDir = "S";
    if (m_KeysDown[2])
      horizDir = "W";
    else if (m_KeysDown[3])
      horizDir = "E";
    m_strLastDir = vertDir + horizDir;
    strAnim = "Walk" + m_strLastDir;
  }
  m_pKnightSprite->PlayAnimation(strAnim);
}

We first check if at least one key is pressed. If that's not the case, we specify that the animation that should be played is "Pause" + the name of the last knight direction. If at least one key is pressed, we check which ones are pressed and we build the last direction string. Let's now look at the Draw function:

C++
void CMainWindow::Draw()
{
  // Clear the buffer
  glClear(GL_COLOR_BUFFER_BIT);

  // Draw the grass
  int xPos=0, yPos=0;
  for (int i=0; i<8; i++)
  {
    for (int j=0; j<6; j++)
    {
      xPos = i * 256/2 - 128;
      if (i%2)
        yPos = (j * 128) - 128/2;
      else
        yPos = (j * 128);

      m_pGrassImg->BlitImage(xPos, yPos);
    }
  }

  // Draw some trees
  m_pTreesImg[0]->BlitImage(15,25);
  m_pTreesImg[1]->BlitImage(695,55);
  m_pTreesImg[2]->BlitImage(15,25);
  m_pTreesImg[3]->BlitImage(300,400);
  m_pTreesImg[4]->BlitImage(125,75);
  m_pTreesImg[5]->BlitImage(350,250);
  m_pTreesImg[6]->BlitImage(400,350);
  m_pTreesImg[7]->BlitImage(350,105);
  m_pTreesImg[8]->BlitImage(530,76);
  m_pTreesImg[9]->BlitImage(125,450);
  m_pTreesImg[10]->BlitImage(425,390);
  m_pTreesImg[11]->BlitImage(25,125);
  m_pTreesImg[12]->BlitImage(550,365);
  m_pTreesImg[13]->BlitImage(680,250);
  m_pTreesImg[14]->BlitImage(245,325);
  m_pTreesImg[15]->BlitImage(300,245);

  // Draw the knight
  m_pKnightSprite->DrawSprite();
  // Move to the next frame of the animation
  m_pKnightSprite->NextFrame();
  // Swap the buffers
  SwapBuffers(m_hDeviceContext);
}

We first draw the grass: if you open the GrassIso.bmp file, you can see that this is a losange, and not a rectangle. That shape is typically used for isometric games to give an impression of 3D. After the grass is drawn, we draw some trees at some predefined positions on the screen. As you can see, manipulating the object contained in the smart pointer is completely transparent (it is as if it were manipulating the object directly). We finally draw the knight sprite and switch to the next frame in the animation. Moving the knight sprite is done in the Update function:

C++
void CMainWindow::Update(DWORD dwCurrentTime)
{
  int xOffset = 0;
  int yOffset = 0;
  if (m_KeysDown[0])
    yOffset -= 5;
  if (m_KeysDown[1])
    yOffset += 5;
  if (m_KeysDown[2])
    xOffset -= 5;
  if (m_KeysDown[3])
    xOffset += 5;
  m_pKnightSprite->OffsetPosition(xOffset, yOffset);
}

If one of the keys is pressed, we move the sprite by a certain offset. As the time is passed to the function, we could also calculate the offset to apply to the sprite depending on the time elapsed. So, you are now ready to test the example and move your knight on the screen. Of course, the scene should probably be loaded from a file that is generated from a specific editor, but that falls outside the scope of this article.

Conclusion

This terminates the second article of the series, in which we saw how to load graphic files and render them on the screen and how to display animations. The next article is the last one of the series. We will see there how to draw text on the screen, how to manage the different states of a game and apply everything we saw on a concrete example.

References

[1] Singleton article: A good introduction to the singleton pattern
[2] Shared pointers: An extensive article about shared pointers
[3] Boost shared_ptr: The boost library about shared_ptr
[4] Reiner's tileset: Free resources from which the images of the example were taken from
[5] DevIL: DevIL library
[6] FreeImage: FreeImage library

Acknowledgement

Thanks to Jeremy Falcon and El Corazon for their advices and help. Thanks also to the CodeProject editors for their great job.

History

  • 15th August, 2008: Initial post
  • 29th March, 2009: Updated source code

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Engineer
Belgium Belgium
I am a 29 years old guy and I live with my girlfriend in Hoegaarden, little city from Belgium well known for its white beer Smile | :) .
I studied as an industrial engineer in electronics but I oriented myself more towards software development when I started to work.
Currently I am working in a research centre in mechatronica. I mainly develop in C++ but I also do a bit of Java.
When I have so spare time, I like to read (mainly fantasy) and play electric guitar.

Comments and Discussions

 
QuestionFailed to load MainBackground.png Pin
Carl Finch12-Jan-15 14:14
Carl Finch12-Jan-15 14:14 
Questionthanks for tutorial Pin
matafill++c1-Oct-13 11:20
matafill++c1-Oct-13 11:20 
QuestionCompiling error: Failed to load file: GrassIso.bmp Pin
dimkaok11-Apr-13 7:59
dimkaok11-Apr-13 7:59 
AnswerRe: Compiling error: Failed to load file: GrassIso.bmp Pin
Cedric Moonen14-Apr-13 21:33
Cedric Moonen14-Apr-13 21:33 
You got a compilation error ? This is strange since the image files are only loaded at runtime (as far as I remember). Are you 100% sure that this is a compilation error and not a runtime error ?
Cédric Moonen
Software developer

Charting control [v3.0]
OpenGL game tutorial in C++

GeneralRe: Compiling error: Failed to load file: GrassIso.bmp Pin
dimkaok16-Apr-13 21:47
dimkaok16-Apr-13 21:47 
QuestionVery good tutorial but DevIL does'nt work on Embarcadero's compiler Pin
Hansgösta29-Sep-12 3:32
Hansgösta29-Sep-12 3:32 
AnswerRe: Very good tutorial but DevIL does'nt work on Embarcadero's compiler Pin
Cedric Moonen30-Sep-12 20:18
Cedric Moonen30-Sep-12 20:18 
GeneralRe: Very good tutorial but DevIL does'nt work on Embarcadero's compiler Pin
Hansgösta1-Oct-12 3:05
Hansgösta1-Oct-12 3:05 
GeneralMy vote of 5 Pin
Mukit, Ataul20-Apr-11 18:51
Mukit, Ataul20-Apr-11 18:51 
GeneralMy vote of 5 Pin
babyxteen16-Dec-10 2:58
babyxteen16-Dec-10 2:58 
GeneralGreate article Pin
babyxteen16-Dec-10 2:56
babyxteen16-Dec-10 2:56 
GeneralCan't complie with Code::Blocks Pin
Thurok8-Dec-09 1:08
Thurok8-Dec-09 1:08 
GeneralRe: Can't complie with Code::Blocks Pin
Cedric Moonen8-Dec-09 1:31
Cedric Moonen8-Dec-09 1:31 
GeneralRe: Can't complie with Code::Blocks Pin
Thurok8-Dec-09 2:19
Thurok8-Dec-09 2:19 
GeneralRe: Can't complie with Code::Blocks Pin
Cedric Moonen8-Dec-09 2:44
Cedric Moonen8-Dec-09 2:44 
GeneralRe: Can't complie with Code::Blocks Pin
Thurok11-Dec-09 1:47
Thurok11-Dec-09 1:47 
GeneralRe: Can't complie with Code::Blocks Pin
Cedric Moonen12-Dec-09 22:20
Cedric Moonen12-Dec-09 22:20 
GeneralSorry to Necro the post Pin
siegeon28-Nov-09 15:35
siegeon28-Nov-09 15:35 
GeneralRe: Sorry to Necro the post Pin
Cedric Moonen29-Nov-09 20:45
Cedric Moonen29-Nov-09 20:45 
Generalneed help Pin
newbieusc25-Nov-09 20:23
newbieusc25-Nov-09 20:23 
GeneralRe: need help Pin
Cedric Moonen25-Nov-09 20:43
Cedric Moonen25-Nov-09 20:43 
GeneralRe: need help Pin
pohanwu2-Nov-10 22:59
pohanwu2-Nov-10 22:59 
GeneralRe: need help Pin
Cedric Moonen2-Nov-10 23:06
Cedric Moonen2-Nov-10 23:06 
GeneralRe: need help Pin
newbieusc25-Nov-09 21:07
newbieusc25-Nov-09 21:07 
GeneralRe: need help Pin
Cedric Moonen25-Nov-09 21:16
Cedric Moonen25-Nov-09 21:16 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.