65.9K
CodeProject 正在变化。 阅读更多。
Home

TetroGL:Win32 平台 C++ OpenGL 游戏教程 - 第二部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (36投票s)

2008年8月15日

CPOL

28分钟阅读

viewsIcon

212746

downloadIcon

5602

了解如何加载图像、在屏幕上显示并高效管理它们,以及如何显示动画。

Overview.JPG

前言

本系列文章重点介绍使用 C++ 和 OpenGL 在 Windows 平台上进行 2D 游戏开发。我们不仅会关注 OpenGL,还会讨论游戏编程中常用的设计模式,采用完全面向对象的方法。您应该已经熟悉 C++ 语言,以便充分利用本系列文章。文章底部有一个留言板,如果您有任何问题、意见或建议,可以使用它。

本系列分为三篇文章

  • 第 1 部分:涵盖窗口创建和 OpenGL 的设置。
  • 第 2 部分:涵盖资源处理和显示简单动画。
  • 第 3 部分:将所有内容整合在一起并讨论游戏逻辑。

目录

引言

本系列的第一篇文章重点介绍了主窗口的创建和 OpenGL 的设置。这篇文章会更有趣一些,因为我们将能够加载和显示图形文件并显示一些动画。我们还将看到如何高效管理这些资源。您在文章顶部看到的图片就是我们在文章末尾将实现的效果。这还不是一个游戏,因为它没有任何游戏逻辑:它唯一能做的是能够在屏幕上移动角色并正确地对其进行动画处理(尚未实现碰撞检测)。

组织文件

我们首先以更好的方式组织文件。我通常创建一个 src 文件夹,其中包含所有源文件(*.cpp*.h),一个 bin 文件夹,其中包含最终的可执行文件和所有必需的资源,一个 obj 文件夹,用于存储编译生成的中间文件,以及一个 dependencies 文件夹,其中包含项目编译所需的所有外部依赖项(稍后我们会看到我们使用了外部依赖项)。主要优点是我们现在有一个 bin 文件夹,其中包含将要分发的内容。如果您有许多资源(图像、音乐、配置文件等),您甚至可以将此 bin 文件夹划分为特定的子文件夹。查看随附的 zip 文件以了解文件夹组织。

现在让我们更改项目设置以使用此文件夹配置。对于源文件,只需将它们复制到 src 文件夹并添加到您的项目中。要配置输出文件夹和中间文件夹,请在 General 部分更改 Output DirectoryIntermediate Directory,如以下图片所示。

Settings.JPG

$(SolutionDir)$(ConfigurationName) 是预定义的宏。第一个宏转换为解决方案的文件夹,第二个宏转换为当前活动的配置(调试或发布):在 obj 文件夹中,将为每个配置创建两个子文件夹。不要忘记将这些更改应用于两个配置(调试和发布)。

加载图像

不幸的是,OpenGL 不提供任何加载图形文件的支持。因此,我们有两种选择:要么自己编写代码来加载图像(并为我们感兴趣的每种格式都这样做),要么使用一个现有的库来为我们完成这项工作。正如您可能已经猜到的那样,第二种选择可能是最好的:我们将节省大量时间,并且将使用一个已经经过测试和调试的库,它可能兼容比我们能够编写的更多文件格式。

有几种库可供选择。我所知道的两个是:DevILFreeImage。DevIL 更适合 OpenGL,这就是我选择它的原因,但 FreeImage 也是一个完全有效的选择。

我们做的第一件事是将所需的 DevIL 文件复制到 dependencies 文件夹中:我们首先创建一个名为 DevIL 的子文件夹,然后将 DevIL 网站上找到的存档内容复制到其中。我们必须修改一个文件的名称才能正确使用它:在“include\IL”文件夹中,您会找到一个名为 config.h.win 的文件,将其重命名为 config.h。然后将 DevIL.dll 文件复制到您的 bin 文件夹中,因为它被您的可执行文件使用。

然后我们必须配置项目设置才能使用 DevIL。在 C/C++ 类别 -> General -> Additional Include Directories 中,指定 dependencies\DevIL\include\。这告诉编译器在哪里可以找到 DevIL 所需的头文件。这样,我们就不需要提供 DevIL 头文件的完整路径了。

DevILSettings1.JPG

Linker 类别 -> General -> Additional Library Directories 中,指定 dependencies\DevIL\lib。这告诉链接器在哪里可以找到可能包含要链接的库的其他文件夹。

DevILSettings2.JPG

Linker 类别 -> Input -> Additional Dependencies 中,指定 DevIL.lib。这告诉链接器该项目必须与 DevIL 库链接。请记住,我们已经链接了 OpenGL32.lib

DevILSettings3.JPG

资源管理

现在一切都已正确设置以使用 DevIL,我们准备加载一些图像并显示它们。但首先,让我们思考一下如何更有效地管理这些文件。假设我们需要显示一个包含在名为 tree.png 的文件中的树,蛮力方法是简单地加载文件并将其存储在内存中,以便我们可以为需要绘制的每一帧重复使用它。这作为第一种方法看起来不错,但有一个小问题:假设我们现在需要多次显示这棵树,那么我们将多次加载纹理到内存中,这显然是低效的。我们需要一种方法,如果代码中不同位置需要相同的纹理,能够重用它。这可以通过将加载委托给一个特定类来轻松解决:纹理管理器。在我们深入文件加载本身的细节之前,让我们先看看这个类

// 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;
};

关于这个类,首先要注意的是它被实现为单例模式。如果您以前从未听说过单例模式,请查看参考文献,有一个链接讨论它。基本上,它确保该类只有一个实例并提供一种访问它的方式。在我们的例子中,构造函数是受保护的,这禁止任何人直接创建实例。相反,一个 static 方法(GetInstance)允许您检索该类的唯一实例。

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

我不会在这里详细讨论这个模式,但请随时查看文章或在 Google 上搜索它(有很多文章讨论它)。在我们的例子中,我们只想要这个类的一个实例,并且拥有一个全局访问点使其易于使用。

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

类的构造函数负责正确初始化 DevIL 库。

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);
}

在调用任何 DevIL 函数之前,您必须首先调用 ilInit 以初始化库。我们还将指定图像的加载方式:左上角优先。这样做是为了避免纹理倒置。默认情况下此选项是禁用的,因此我们通过调用 ilEnable(IL_ORIGIN_SET) 来启用它。

现在让我们看看 GetTexture 方法。

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;
}

代码不难理解:我们首先尝试在已加载纹理的映射中检索由 strTextName 指定的纹理。如果找到,则返回;否则,我们尝试从文件中加载它。正如我们稍后将看到的,CTexture 的构造函数会尝试加载文件,如果失败则抛出异常。然后,在纹理管理器中,如果捕获到异常,我们会删除纹理(以避免内存泄漏)并重新抛出异常。如果纹理加载成功,它将存储在映射中(以其名称作为键)并返回。

还提供了一个释放现有纹理的方法。

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;
}

这里,代码也是不言自明的:我们只是尝试从映射中检索纹理,成功后,我们删除它并从映射中移除指针。如果纹理成功移除,函数返回 true

CTexture 类

现在让我们更详细地了解 CTexture 类。

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;
};

对于这个类,我们也可以看到构造函数被声明为 protected。原因是只有 CTextureManager 类才能创建纹理,这就是它被声明为该类的友元的原因。CTexture 类的核心是 STextureData 结构,它包含从文件加载的所有数据:一个包含文件数据的字节数组以及纹理的宽度和高度。让我们看看文件是如何加载的,这在 LoadFile(const std::string& strFileName) 函数中完成。

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);
}

正如您所看到的,我们正在使用 DevIL 来加载文件。我们做的第一件事是在 DevIL 中创建一个新的图像 ID 并将其绑定到当前图像。如果您想使用其 ID 对某个图像进行操作,则需要这样做。实际上,我们只会用它在完成使用图像后删除它。接下来,我们尝试使用 ilLoadImage 加载文件:该函数会处理不同的文件格式,如果加载失败,它将返回 false(您也可以通过调用 ilGetError 检索错误代码)。如果是这种情况,我们 simply 抛出一个异常。如果您还记得第一篇文章,这些异常将在主函数中捕获并在退出程序之前显示错误消息。然后我们检索图像的宽度和高度(ilGetIntegerilCopyPixels 函数总是对当前活动图像进行操作)。然后我们在 m_TextData.pData 字段中为数据分配空间:每个像素编码为 4 字节(我们稍后会看到)。然后我们调用 ilCopyPixels 函数将图像数据复制到我们的缓冲区中。前三个参数是开始复制的 X、Y 和 Z 偏移量(Z 偏移量用于体积图像),接下来的三个参数是在这些方向上复制的像素数量(这里我们也不使用体积图像,所以 Depth 为 1)。然后我们指定图像的格式:RGBA 格式,这意味着每个颜色通道(红、绿和蓝,或 RGB)一个字节,以及一个用于 alpha 通道(A)的字节。Alpha 通道用于指定像素的透明度。值为 0 表示完全透明,值为 255 表示完全不透明。然后我们指定每个组件的类型:它们应编码为无符号字节(unsigned chars)。函数的最后一个参数是指向要复制像素的缓冲区的指针。最后,我们删除 DevIL 图像数据,因为我们不再需要它了。

注意:如果您想在 OpenGL 中使用纹理,DevIL 有一种更简单的方式来加载纹理。ILUT 库允许您加载图像并直接将其与 OpenGL 纹理关联,通过调用 ilutGLLoadImage,它返回纹理的 OpenGL ID。这是最简单的方法,但您将无法像我们设置颜色键那样直接操作原始数据。

一旦数据从文件中加载,我们需要生成一个新的 OpenGL 纹理并提供数据。这在第一次请求使用纹理时完成,在 CTexture::Bind() 函数中。

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);
}

使用纹理时要理解的重要一点是,OpenGL 一次只处理一个纹理。因此,要对多边形进行纹理映射,您需要选择活动纹理(也称为“绑定”)。这是通过调用 glBindTexture 完成的。每个 OpenGL 纹理都有自己的 ID,在我们的例子中,它存储在 CTexture 类的 m_glId 成员中。ID 0 是保留的,OpenGL 永远不会生成它,因此我们可以使用它来指定我们的纹理尚未在 OpenGL 中生成。所以,第一次调用此函数时,m_glId 将为 0。如果我们查看 if 条件内部(即,如果纹理未生成),我们做的第一件事是请求 OpenGL 通过调用 glGenTextures 为我们生成一个空闲 ID。

m_glId 是可变的,因为我们仍然希望 bind 函数是 const,并且此成员只会在纹理生成时修改一次。glGenTextures 函数允许您生成多个 Id(第一个参数是要生成的 Id 数量),但我们只需要一个 Id,它将存储在 m_glId 中。然后我们调用 glBindTexture:这将通过其 Id 指定的纹理绑定到活动的二维活动纹理(您也可以使用一维纹理)。这是必需的,以便后续对纹理操作例程的所有调用都将影响此特定纹理(这实际上使我们的纹理成为 OpenGL 中的活动纹理)。

然后我们指定缩小和放大过滤:通常,纹理的一个点(也称为纹素)不会直接映射到屏幕上的一个像素。有时一个纹素覆盖的像素少于一个(因此一个像素中有多个纹素),有时则相反(一个纹素覆盖多个像素)。当一个像素只包含纹素的一部分时,称为放大;当一个像素包含多个纹素时,称为缩小。这两个函数告诉 OpenGL 如何解释这些情况:如果指定 GL_LINEAR,OpenGL 会使用最接近像素中心的 2x2 纹素数组的线性平均值。如果指定 GL_NEAREST,OpenGL 将使用坐标最接近像素中心的纹素。缩小过滤器还有其他选项,包括为不同大小拥有纹理的多个副本(这些副本称为 Mipmap),但我们在这里不会深入太多细节。

接下来,glTexEnvf 将绘制模式设置为 GL_MODULATE,以便纹理多边形的颜色将是纹理颜色与纹理粘贴到的颜色的调制。这对于使用 alpha 通道使图像的某些部分透明是必需的。最后,我们通过调用 glTexImage2D 生成 OpenGL 纹理:函数的第一个参数是纹理类型(1 或 2 维),第二个参数是纹理的级别,以防我们使用纹理的多个分辨率(mip-maps)。

在我们的例子中,我们不使用多分辨率,所以我们指定 0。第三个参数是用于调制和混合的组件数量(R、G、B 和 A)。接下来的两个参数是纹理的宽度和高度。第六个参数指定纹理边框的宽度,在我们的例子中为 0。第七和第八个参数描述纹理数据的格式和数据类型:纹理格式是 RGBA,纹素的每个组件都是一个无符号字节。最后一个参数是指向数据的指针。

警告:OpenGL 处理尺寸(宽度和高度)为 2 的幂的纹理(因此 128x128 纹理有效,但 128x120 无效)。在某些显卡上,显示不遵循此规则的纹理可能会失败,您只会看到一个白色矩形。解决此问题的方法是让所有纹理都遵循此规则(即使您必须在图像文件中留下一些未使用的空间)。另一个解决方案是在加载图像时进行管理:您总是可以创建一个具有正确尺寸的缓冲区并将图像加载到其中(但您必须注意如何执行此操作,因为未使用的像素应留在每行上)。

如果当纹理已经可用时调用 CTexture::Bind(),该函数只会调用 glBindTexture,这会使该纹理成为活动纹理。我们稍后会看到如何使用此纹理在屏幕上绘制。

游戏中经常使用的一项功能是所谓的色度键控。某些文件格式不支持透明通道(如 BMP 文件),因此如果想使纹理的某些部分透明,唯一的选择是使用一种特定颜色,该颜色将被设置为透明。OpenGL 不支持色度键控,但可以通过使用纹理的 alpha 通道轻松添加。这就是 CTexture::SetColorKey 函数所做的工作。

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;

  }
}

该函数非常基本:我们遍历我们的纹理数据,如果找到指定颜色的像素,我们将其 alpha 通道设置为 0,表示完全透明。对于所有其他像素,我们将通道重置为 255(取消之前的颜色键)。但我们首先需要检查纹理是否已经指定给 OpenGL。如果是这种情况,我们需要在 OpenGL 中重新加载纹理。这通过简单地将 m_glId 设置为 0 来完成(如果您还记得,Bind 函数首先检查此变量是否为 0)。通过调用 glDeleteTextures,我们在 OpenGL 中删除纹理(第一个参数是我们要删除的纹理数量,第二个是它们的 ID)。

最后,纹理是引用计数的,其构造函数是受保护的,因此您不能直接创建 CTexture 对象。引用计数通过 AddReferenceReleaseReference 函数完成。

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);
}

正如您所看到的,这里没有什么特别花哨的地方:每当一个 CTexture 对象被引用时,都会调用 AddReference,这会增加引用计数。一旦纹理不再需要,就会调用 ReleaseReference,这会减少引用计数。一旦它达到 0,纹理将从纹理管理器中释放(这将删除它)。使用引用计数是因为几个 CImage 对象可以引用相同的纹理。我们需要知道有多少个它们仍在​​使用纹理,而不是在其中一个图像对象被销毁时就释放它。

CImage 类

现在让我们看看 CImage 类如何使用这个纹理。正如我们前面看到的,CTexture 不直接由用户操作。原因是它主要是资源文件的包装器,而这样的文件可以由多个图像组成:假设您想在游戏中显示多种树,将它们全部存储在同一个文件中可能会很方便。因此,纹理类本身没有任何在屏幕上绘制图像的功能,而只有加载文件的功能。图像类是负责在屏幕上绘制纹理(或其一部分)的类。然后,多个图像可以引用相同的纹理,但使用其不同的部分。

// 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;
};

在深入了解如何实例化这个类之前,我们先看看它的工作原理。它有两个成员:图像来源的纹理和一个指定纹理中包含图像部分的矩形。我不会贴出 CRectangle 类的代码,因为它非常简单:它包含四个成员,分别是矩形的顶部、底部、左侧和右侧坐标,以及一些支持函数(例如检查是否与另一个矩形相交、检索矩形的宽度和高度等)。它是一个模板类,因此您可以选择矩形坐标的类型(integerfloatdouble 等)。TRectanglei 是一个带有整数坐标的矩形的类型定义。让我们看看 BlitImage 函数是如何工作的,通过在由参数指定的位置绘制纹理。

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();
  }
}

我们首先绑定纹理(使其在 OpenGL 中处于活动状态),然后计算纹理中图像的坐标。这些值表示在 0 到 1 之间,其中 0 是纹理的左上角,1 是纹理的右下角。然后我们按照第一个教程中所示绘制一个矩形,不同的是在指定每个点之前,我们调用 glTexCoord2f,它在当前绑定的 OpenGL 纹理中指定一个纹素(纹理中的点)。通过这样做,OpenGL 将能够将纹理中的纹素与屏幕上的像素关联起来,并使用活动纹理显示我们的纹理矩形。

现在让我们看看构造函数和析构函数。有两个构造函数(它们是受保护的):一个只接受纹理名称,另一个同时接受纹理名称和矩形。只包含纹理名称的构造函数将使用完整纹理作为图像,而另一个将使用文件中指定矩形中包含的图像。

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();
}

构造函数通过纹理管理器检索纹理。请记住,如果纹理不存在,此调用可能会抛出异常。然后纹理的引用计数增加。如果未指定矩形,则将完整纹理用作图像。析构函数只是释放纹理,这会像纹理类中看到的那样递减引用计数。

正如我之前所说,该类的构造函数是受保护的。原因是强制用户使用封装 CImage 类的智能指针。好的,在因为这个奇怪的东西而惊慌之前,请允许我先说,将 CImage 类封装到智能指针中并不是必需的,但它对于确保在不再使用所有资源时释放它们非常有用。如果您不动态分配 CImage 对象(使用 new),那么这已经为您完成了(通过析构函数)。但是,一旦您创建动态对象,您总是可能忘记删除它们,这会导致资源未释放。此外,如果您开始在代码的不同部分之间交换这些对象,那么哪一部分应该负责删除对象呢?所有这些问题都通过将对象封装到智能指针类中来解决。我不会在这里详细讨论它的实现方式,因为已经有很多文章涵盖这个主题(您可以查看参考文献,有一个指向一篇好文章的链接)。简而言之,智能指针负责维护对象的生命周期:当对象不再需要时,它就会被销毁。您可以“共享”此指针,一旦不再需要,指向的对象将被删除。您还可以轻松访问封装的对象,就像您直接操作它一样:智能指针重载 ->. 运算符以将它们重定向到拥有的对象。所有这些听起来有点复杂,但使用起来真的很容易:您不必直接使用对象的指针,而是将其交给一个智能指针,它将为您管理其生命周期(您不再需要删除指针)。对象的访问几乎是透明的,因为您仍然可以像直接使用指针一样访问成员。

对于本教程,我提供了自己的智能指针类,但通常最好使用 boost::shared_ptr 类(参见参考文献)。我提供自己的原因仅仅是为了避免引入另一个依赖项,以便您更容易编译项目(您不必从 boost 下载包)。您可以查看它是如何实现的,但我不会在这里提供完整的解释。

最后,CImage 类提供了两个 static 辅助函数,以便能够创建该类的实例。它们只是创建一个新实例,将其传递给智能指针并返回智能指针。

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;
}

显示动画

没有动画的游戏会是什么样?玩起来可能相当无聊,所以让我们看看如何通过播放动画来增加一些活力。2D 游戏中动画的基本思想相当简单:它与卡通片相同,即将运动分解为不同的图像。蛮力方法是在一个循环中休眠一段时间,然后显示下一张图像。正如您可能已经猜到的那样,这根本不起作用。如果您尝试这样做,您会遇到几个问题:首先,什么都不会显示,因为您从未交换缓冲区(这在 CMainWindow::Draw() 函数中完成)。其次,如果您这样做,程序的其余部分根本不会被处理,这也意味着您一次只能显示一个动画。不是很方便... 正确的方法是让每个“动画”记住其状态(例如,它当前正在显示哪个图像),并要求所有动画绘制其当前图像。当需要绘制新帧时,每个动画都被“要求”进入动画中的下一张图像。这样,您就可以在程序中保持连续的流程。

现在让我们看看 CImageList 类。这个类基本上是一个围绕 std::list 的包装类,它包含图像并提供一些辅助函数来播放图像。

// 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;
};

实现非常简单:它基本上按需将图像添加到 std::list 中,并保持一个指向当前活动图像的迭代器。例如,让我们看看 GoToNextImage() 函数。

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;
}

我们首先检查迭代器是否有效(不指向列表的末尾)。当列表为空时,迭代器无效:在这种情况下,我们只是从函数返回,否则我们增加迭代器。然后我们检查迭代器是否到达列表的末尾(当它之前指向最后一个图像时会发生这种情况)。在这种情况下,我们将其重置为第一个图像并返回 true。我不会解释其他函数,因为它们非常简单,但请随时查看代码。

现在让我们来看看 CAnimatedSprite 类,它允许您将多个动画组合在一起。让我们举个例子:假设您正在编写一个游戏,玩家扮演一个骑士。这个骑士当然会有多种不同的动画:行走、攻击、静止等等。通常,您需要为您的骑士在游戏中可以拥有的每个方向提供此类动画。然后,这个类将用于表示您的骑士:您将能够加载多个动画,并在以后按需重播它们。

// 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;
};

该类的原理如下:它包含一个映射,其中存储了可用于精灵的所有动画,键是标识动画的 string,值是包含动画的 CImageList 对象。AddAnimationPlayAnimation 只是从映射中添加或检索动画。

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);
  }
}

当尝试播放不存在的动画时,会抛出异常。m_iterCurrentAnim 变量是一个迭代器,指向当前动画。它在 DrawSpriteNextFrame 方法中用于访问当前动画。

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();
}

DrawSprite 方法中,我们检索当前动画的当前图像,并简单地将其绘制到屏幕上的指定位置(记住 CImage 类是如何工作的)。在 NextFrame 中,我们只是前进到当前动画中的下一帧。

示例

在所有这些解释之后,是时候看一个具体的例子,看看我们将如何使用所有这些类。这个例子将非常简单,远非一个完整的游戏,但它展示了原理。目的是有一个可以通过方向键控制的动画角色(一个骑士)。它在一个简单的场景中移动:草地上有一些树,采用等距视图。目前没有碰撞检测,这意味着骑士可以穿过树。另一个尚未实现的是精灵的绘制顺序:骑士将始终绘制在场景之上,无论他在哪里,这在某些情况下是错误的(如果他在树后面,树应该绘制在骑士之上)。这留给读者作为练习:)

所有代码都将在 CMainWindow 类中实现。让我们首先在这个类中添加一些成员变量。

  // 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;

我们首先声明一些 TImagePtr,它将保存将要绘制的几张图像(草地和树木)。然后我们声明 CAnimatedSprite,它将用于绘制骑士。最后,我们有一个包含 4 个布尔值的数组,用于存储方向键的当前状态,以及一个包含骑士当前方向的 string。这些变量在主窗口类的构造函数中初始化。

  // 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);

这看起来像很多代码,但我们需要为我们的骑士加载相当多的动画:每个方向(8 个不同的方向)2 个动画(行走和暂停)。我们这里使用了一个新类:CAnimFileLoader 类。它是一个简单的辅助类,可以轻松地从文件中加载图像列表。它在构造函数中接受文件名、每行图像数量、图像宽度和高度作为参数,您可以通过简单地指定文件中图像的起始索引和停止索引来稍后检索图像列表(它返回一个 CImageList 对象)。如果您现在查看代码,我们首先加载草地图像并指定其颜色键,然后加载我们骑士的所有“行走”动画。每个动画名称都取决于方向,例如,对于“行走”东方向,动画名称是“WalkE”。这将在以后用于播放特定动画。然后我们指定默认动画是“PauseE”动画。

现在让我们看看如何处理按键事件。这在 ProcessEvent 函数中完成。

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;
  }
}

如您所见,我们处理 WM_KEYDOWNWM_KEYUP 消息,它们分别对应于按键和释放键。当发送此类消息时,WPARAM 包含按键或释放键的代码。然后我们简单地设置或重置数组中的标志以指定相应键的状态(因此,数组中的第一个元素对应于上键,第二个对应于下键,等等)。然后我们调用 UpdateAnimation 函数。

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);
}

我们首先检查是否至少有一个键被按下。如果不是,我们指定要播放的动画是“暂停”+上次骑士方向的名称。如果至少有一个键被按下,我们检查哪些键被按下,并构建上次方向字符串。现在让我们看看 Draw 函数。

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);
}

我们首先绘制草地:如果您打开 GrassIso.bmp 文件,您会看到这是一个菱形,而不是矩形。这种形状通常用于等距游戏,以给人一种 3D 的印象。绘制完草地后,我们在屏幕上一些预定义的位置绘制一些树木。如您所见,操作智能指针中包含的对象是完全透明的(就好像它直接操作对象一样)。我们最后绘制骑士精灵并切换到动画中的下一帧。移动骑士精灵在 Update 函数中完成。

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);
}

如果某个键被按下,我们就以一定的偏移量移动精灵。由于时间已传递给函数,我们还可以根据经过的时间计算要应用于精灵的偏移量。因此,您现在已准备好测试示例并在屏幕上移动您的骑士。当然,场景可能应该从特定编辑器生成的文件中加载,但这超出了本文的范围。

结论

这结束了本系列文章的第二篇,其中我们了解了如何加载图形文件并将其渲染到屏幕上以及如何显示动画。下一篇文章是本系列的最后一篇。我们将在其中了解如何在屏幕上绘制文本,如何管理游戏的不同状态,并将我们所看到的一切应用于一个具体的示例。

参考文献

[1] 单例文章:对单例模式的良好介绍
[2] 共享指针:一篇关于共享指针的 uitgebreid 的文章
[3] Boost shared_ptr:关于 shared_ptr 的 Boost 库
[4] Reiner's tileset:示例图片来源的免费资源
[5] DevILDevIL
[6] FreeImageFreeImage

致谢

感谢 Jeremy Falcon 和 El Corazon 的建议和帮助。也感谢 CodeProject 编辑的卓越工作。

历史

  • 2008年8月15日:首次发布
  • 2009年3月29日:更新源代码
© . All rights reserved.