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

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (39投票s)

2008 年 6 月 24 日

CPOL

27分钟阅读

viewsIcon

261301

downloadIcon

3541

学习如何创建 Win32 消息循环和游戏窗口,以及如何正确设置 2D 游戏的 OpenGL

前言

本系列文章重点介绍使用 C++ 和 OpenGL 在 Windows 平台上进行 2D 游戏开发。目标是在本系列结束时提供一个类似于经典方块益智游戏的游戏。我们不仅会关注 OpenGL,还会讨论游戏编程中常用的设计,采用完全面向对象的方法。您应该已经熟悉 C++ 语言,以便从本系列中获得最大收益。文章底部有一个留言板,您可以使用它来提出问题、意见或建议。

本系列分为三篇文章

  • 第 1 部分:涵盖 Win32 消息循环、窗口创建和 OpenGL 的设置。您还将学习如何绘制一些简单的形状。
  • 第 2 部分:涵盖资源处理和显示简单动画。
  • 第 3 部分:将所有内容组合在一起并讨论游戏逻辑。

目录

引言

本文的这一部分重点介绍在 Windows 环境中设置 OpenGL 窗口。我们将学习如何创建消息循环以接收通知,以及如何创建用于绘图的主窗口。然后,我们将了解如何为 2D 游戏正确配置 OpenGL。最后,当一切准备就绪时,我们将学习如何在新创建的 OpenGL 窗口中显示一些基本形状。

项目设置

我们将从创建一个新项目并配置不同的选项开始。本教程项目是使用 Visual Studio 2005 创建的,但它可以很容易地应用于其他编译器。首先创建一个名为“Win32 控制台应用程序”的新项目并为其指定一个适当的名称,然后单击“确定”。在创建向导中,选择“Windows 应用程序”类型(不是控制台)并勾选“空项目”选项(我们不需要为我们生成的代码)。

完成此操作后,向项目中添加一个新的源文件 Main.cpp(如果项目中没有源文件,则某些选项不可访问)。现在打开项目选项并转到“链接器”类别 -> “输入”。在“附加依赖项”选项中,添加 opengl32.lib。这告诉链接器在链接项目时必须使用 OpenGL 库。

New project

接下来,我们将禁用 UNICODE,因为我们不需要它,而且它会使事情变得有点复杂。进入“C/C++”->“预处理器”并单击“预处理器定义”。右侧会出现一个按钮,单击它,在弹出的对话框中,取消勾选“从父级或项目默认值继承”。这将禁用从项目默认值继承的 UNICODE。

New project

现在项目设置已正确配置,我们准备查看一些代码。让我们首先检查 Win32 应用程序如何接收和处理事件(键盘、鼠标等)。

消息循环

系统 (Windows) 为每个应用程序创建一个消息队列,并在该特定应用程序的窗口上发生事件时将消息推入此队列。您的应用程序应检索并处理这些消息以对其做出反应。这就是所谓的“消息循环”,它是所有 Win32 应用程序的核心。

典型的消息循环如下所示

    MSG Message;
    Message.message = (~WM_QUIT);
    // Loop until a WM_QUIT message is received
    while (Message.message != WM_QUIT)
    {
        if (PeekMessage(&Message, NULL, 0, 0, PM_REMOVE))
        {
            // If a message was waiting in the message queue, process it
            TranslateMessage(&Message);
            DispatchMessage(&Message);
        }
        else
        {
            // Do processing stuff here...
        }
    }

PeekMessage 从队列中检索消息(如果有);PM_REMOVE 告诉 PeekMessage 消息应从队列中删除。消息将存储在第一个参数中,如果检索到消息,函数将返回非零值。函数的第二个参数允许您指定要检索消息的窗口句柄。如果提供 NULL,则将检索应用程序所有窗口的消息。第三和第四个参数允许您指定要检索消息的范围。如果两个都提供 0,则将检索所有消息。
TranslateMessage 函数的目的是将虚拟键消息(WM_KEYDOWN 和 WM_KEYUP)转换为字符消息(WM_CHAR)。WM_CHAR 消息将由 WM_KEYDOWN 和 WM_KEYUP 消息的组合生成。
最后,DispatchMessage 会将消息重定向到正确的窗口过程。正如我们稍后将看到的,应用程序中的每个窗口都有一个特定函数(称为窗口过程)来处理这些消息。
因此,这段代码试图从队列中提取消息。如果消息可用,它将被分派到正确的窗口过程。如果没有消息可用,我们执行一些特定于应用程序的处理。一旦检索到 WM_QUIT 消息,循环就会退出,从而终止应用程序。

如果我们查看本教程的代码,我们可以看到消息循环被封装在一个名为 CApplication 的类中。让我们仔细看看这个类。首先是类声明

// The application class, which simply wraps the message queue and process
// the command line.
class CApplication
{
public:
  CApplication(HINSTANCE hInstance);
  ~CApplication();

  // Parses the command line to see if the application
  // should be in fullscreen mode.
  void ParseCmdLine(LPSTR lpCmdLine);
  // Creates the main window and starts the message loop.
  void Run();

private:
  HINSTANCE m_hInstance;
  // Specifies if the application has to be started in fullscreen
  // mode. This option is supplied through the command line
  // ("-fullscreen" option).
  bool m_bFullScreen;
};

ParseCmdLine 函数非常直接:它只是检查命令行中是否存在参数“-fullscreen”。在这种情况下,m_bFullScreen 标志设置为 true。

我们来看看 Run 函数

void CApplication::Run()
{
  // Create the main window first
  CMainWindow mainWindow(800,600,m_bFullScreen);

    MSG Message;
    Message.message = ~WM_QUIT;
  DWORD dwNextDeadLine = GetTickCount() + FRAME_TIME;
  DWORD dwSleep = FRAME_TIME;
  bool bUpdate = false;

  // Loop until a WM_QUIT message is received
    while (Message.message != WM_QUIT)
    {
    // Wait until a message comes in or until the timeout expires. The
    // timeout is recalculated so that this function will return at
    // least every FRAME_TIME msec.
    DWORD dwResult = MsgWaitForMultipleObjectsEx(0,NULL,dwSleep,QS_ALLEVENTS,0);
    if (dwResult != WAIT_TIMEOUT)
    {
      // If the function returned with no timeout, it means that at 
      // least one message has been received, so process all of them.
      while (PeekMessage(&Message, NULL, 0, 0, PM_REMOVE))
      {
        // If a message was waiting in the message queue, process it
        TranslateMessage(&Message);
        DispatchMessage(&Message);
      }

      // If the current time is close (or past) to the 
      // deadline, the application should be processed.
      if (GetTickCount() >= dwNextDeadLine)
        bUpdate = true;
      else
        bUpdate = false;
    }
    else
      // On a timeout, the application should be processed.
      bUpdate = true;

    // Check if the application should be processed
    if (bUpdate)
    {
      DWORD dwCurrentTime = GetTickCount();
      // Update the main window
      mainWindow.Update(dwCurrentTime);
      // Draw the main window
      mainWindow.Draw();

      dwNextDeadLine = dwNextDeadLine + FRAME_TIME;
    }

    // Process the sleep time, which is the difference
    // between the current time and the next deadline.
    dwSleep =  dwNextDeadLine - GetCurrentTime();
    // If the sleep time is larger than the frame time,
    // it probably means that the processing was stopped 
    // (e.g. the window was being moved,...), so recalculate
    // the next deadline.
    if (dwSleep>FRAME_TIME)
    {
      dwSleep = FRAME_TIME;
      dwNextDeadLine = GetCurrentTime() + FRAME_TIME;
    }
  }
}

函数的第一行只是创建主窗口。我们将在下一章中看到它具体做了什么。现在,只需想象它创建并显示具有特定宽度和高度的主窗口,以及是否全屏。您可能会看到,循环本身与我们之前看到的有点不同。原因很简单:对于 2D 游戏来说,通常不需要尽可能快地刷新屏幕。以恒定速率刷新它,足以显示动画并执行处理工作。在我们的例子中,我们定义了一个常量(FRAME_TIME),它指定了两个帧之间的时间(毫秒)。
我们可以做得更简单一些:在我们看到的第一个消息循环示例中,我们可以将“// 在这里执行处理工作...”替换为检查自上次更新以来是否已经过了 30 毫秒

        else
        {
            // Do processing stuff here...
            if(GetCurrentTime() >= dwLastUpdate+30)
            {
              dwLastUpdate = GetCurrentTime();
              // Update the main window
              mainWindow.Update(dwCurrentTime);
              // Draw the main window
              mainWindow.Draw();
            }
        }

这会运行良好,除了它会忙等待:如果没有收到消息,我们将连续循环并耗尽所有可用的 CPU 时间。这不太好,因为 CPU 被用于无所事事。
最好的方法是等待消息到达,或者直到我们达到下一个刷新截止日期。这就是 MsgWaitForMultipleObjectsEx 函数的作用。简而言之,我们可以指定多个要等待的对象,但我们只对消息感兴趣(所以,这就是为什么我们在第一个参数中指定 0 个对象,在第二个参数中指定 NULL)。此函数将在不消耗 CPU 周期的情况下等待,直到超时期限到期(在第三个参数中指定)或收到消息。您可以在第四个参数中指定要接收的消息的过滤器,但我们对所有消息都感兴趣。当函数超时时,它返回 WM_TIMEOUT,这在代码中用于检测何时刷新屏幕和更新游戏逻辑。如果函数没有超时,则意味着队列中有一个或多个消息正在等待,因此我们使用 PeekMessage 提取所有这些消息(当队列中不再有消息时,函数返回 FALSE)。然后我们确定应用程序是否应该被处理。在函数结束时,我们根据下一个截止日期重新计算睡眠时间。如果这个睡眠时间大于帧时间,则意味着当前时间大于下一个截止日期(负溢出)。这通常发生在窗口移动或调整大小时:在此期间,应用程序不再被处理。在这种情况下,我们只需根据当前时间重新计算新的截止日期和睡眠时间。

很好,现在我们有一个消息循环来将消息分派到正确的窗口。但是缺少一些东西:窗口本身。所以让我们看看这个窗口是如何创建的以及发送给它的消息是如何处理的。

主窗口

创建窗口

正如我们之前所见,我们只需在应用程序类的 Run() 方法中创建 CMainWindow 类的一个实例即可创建主窗口。那么,让我们看看构造函数,所有事情都在那里处理。

CMainWindow::CMainWindow(int iWidth, int iHeight, bool bFullScreen)
  :  m_hWindow(NULL), m_hDeviceContext(NULL), m_hGLContext(NULL),
     m_bFullScreen(bFullScreen)
{
  RegisterWindowClass();

  RECT WindowRect;
  WindowRect.top = WindowRect.left = 0;
  WindowRect.right = iWidth;
  WindowRect.bottom = iHeight;

  // Window Extended Style
  DWORD dwExStyle = 0;
  // Windows Style
  DWORD dwStyle = 0;

  if (m_bFullScreen)
  {
    DEVMODE dmScreenSettings;
    memset(&dmScreenSettings,0,sizeof(dmScreenSettings));
    dmScreenSettings.dmSize = sizeof(dmScreenSettings);
    dmScreenSettings.dmPelsWidth  = iWidth;
    dmScreenSettings.dmPelsHeight = iHeight;
    dmScreenSettings.dmBitsPerPel = 32;
    dmScreenSettings.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_BITSPERPEL;

    // Change the display settings to fullscreen. On error, throw
    // an exception.
    if (ChangeDisplaySettings(&dmScreenSettings,CDS_FULLSCREEN)
        != DISP_CHANGE_SUCCESSFUL)
    {
      throw CException("Unable to switch to fullscreen mode");
    }

    dwExStyle = WS_EX_APPWINDOW;
    dwStyle = WS_POPUP;
    // In fullscreen mode, we hide the cursor.
    ShowCursor(FALSE);
  }
  else
  {
    dwExStyle = WS_EX_APPWINDOW | WS_EX_WINDOWEDGE;
    dwStyle = WS_OVERLAPPEDWINDOW;
  }

  // Adjust the window to the true requested size
  AdjustWindowRectEx(&WindowRect, dwStyle, FALSE, dwExStyle);
  // Now create the main window
  m_hWindow = CreateWindowEx(dwExStyle,TEXT(WINDOW_CLASSNAME),
               TEXT("Tutorial1"),
               WS_CLIPSIBLINGS | WS_CLIPCHILDREN | dwStyle,
               0, 0, WindowRect.right-WindowRect.left,
               WindowRect.bottom-WindowRect.top,
               NULL, NULL,
               GetModuleHandle(NULL),
               this);
  if (m_hWindow==NULL)
    throw CException("Cannot create the main window");

  CreateContext();
  InitGL();
  ShowWindow(m_hWindow,SW_SHOW);
  // Call OnSize manually because in fullscreen mode it will be
  // called only when the window is created (which is too early
  // because OpenGL is not initialized yet).
  OnSize(iWidth,iHeight);
}

看起来有很多代码,但它并不复杂。我们做的第一件事是调用 RegisterWindowClass,它将(顾名思义)为我们的应用程序注册窗口类。那么什么是窗口类呢?基本上,它是一个用于定义窗口的模板:您可以指定图标、背景画刷、光标以及其他内容。每个窗口都是此类的一个实例。让我们看看这个函数的实现

void CMainWindow::RegisterWindowClass()
{
    WNDCLASS WindowClass;
    WindowClass.style         = 0;
    WindowClass.lpfnWndProc   = &CMainWindow::OnEvent;
    WindowClass.cbClsExtra    = 0;
    WindowClass.cbWndExtra    = 0;
    WindowClass.hInstance     = GetModuleHandle(NULL);
    WindowClass.hIcon         = NULL;
    WindowClass.hCursor       = 0;
    WindowClass.hbrBackground = 0;
    WindowClass.lpszMenuName  = NULL;
    WindowClass.lpszClassName = WINDOW_CLASSNAME;

    RegisterClass(&WindowClass);
}

它所做的是注册一个新的类实例(名为 Tutorial1),我们唯一指定的是当该窗口接收到消息时将调用的窗口过程。这是类的 OnEvent 函数。如果您仔细查看函数声明,您会注意到它是一个静态函数。原因很简单:非静态成员函数即使具有相同的参数列表,也与全局函数具有不同的原型。这是因为一个隐式参数被传递给该函数:this 参数,它标识了调用该函数的类实例。静态成员函数不遵循相同的规则,因为它们不属于特定的实例(它们在类的所有实例之间共享)。WNDCLASS 结构只接受全局或静态成员函数作为 lpfnWndProc 参数。我们稍后会看到这样做的后果。

现在回到 CMainWindow 构造函数。我们接下来要做的就是检查窗口是否应该全屏显示。如果是这种情况,我们将切换到全屏模式(通过调用 ChangeDisplaySettings)。如果此函数调用失败,我们将抛出异常。我们将在接下来的章节中更详细地讨论异常和异常处理。

现在我们将创建主窗口,但首先,我们需要调整矩形大小,因为窗口标题和边框会占用一些空间。为了纠正这一点,我们只需调用 AdjustWindowRectEx。如果处于全屏模式,此函数不会产生任何效果。我们最终调用 CreateWindowEx,它将使用所需的样式创建窗口。函数的第二个参数指定要使用的窗口类(当然是我们之前注册的窗口类)。在函数的最后一个参数中,我们传递了 this 指针(指向此 CMainWindow 实例的指针)。我们稍后会看到为什么这样做。如果窗口创建失败,我们也会抛出异常。CreateContext 和 InitGL 函数将正确初始化 OpenGL,但我们将在后续章节中看到这一点。

窗口过程

我们刚刚通过调用 CreateWindowEx 创建了一个新窗口,并且我们指定该窗口应该使用我们之前注册的窗口类。此窗口类使用 OnEvent 函数作为窗口过程。让我们看看这个函数

LRESULT CMainWindow::OnEvent(HWND Handle, UINT Message, WPARAM wParam, LPARAM lParam)
{
  if (Message == WM_NCCREATE)
  {
        // Get the creation parameters.
    CREATESTRUCT* pCreateStruct = reinterpret_cast<CREATESTRUCT*>(lParam);

    // Set as the "user data" parameter of the window
    SetWindowLongPtr(Handle, GWLP_USERDATA,
          reinterpret_cast<long>(pCreateStruct->lpCreateParams));
  }

  // Get the CMainWindow instance corresponding to the window handle
  CMainWindow* pWindow = reinterpret_cast<CMainWindow*>
    (GetWindowLongPtr(Handle, GWLP_USERDATA));
  if (pWindow)
    pWindow->ProcessEvent(Message,wParam,lParam);

  return DefWindowProc(Handle, Message, wParam, lParam);
}

如您所记,此函数是一个静态函数。当消息被接收并分派到我们的主窗口时,该函数将被调用。它接受四个参数

  • 句柄:消息发送到的窗口句柄
  • 消息:消息 ID
  • wParam:可选消息参数
  • lParam:可选消息参数

根据消息类型,一些额外信息将存储在 wParam、lParam 或两者中(例如,鼠标移动消息包含鼠标坐标,按键事件包含按键代码...)。

由于此函数是静态的,我们无法访问其他非静态类成员,这在我们的情况下当然不太有用。但是,不要惊慌,有一个简单的解决方案,这也是我们为什么在 CreateWindowEx 的最后一个参数中传递 this 指针的原因。发送到您的窗口过程的第一个消息之一是 WM_NCCREATE 消息。当收到此消息时,lParam 参数包含一个指向 CREATESTRUCT 结构的指针,该结构包含有关窗口创建的信息,实际上是 CreateWindowEx 调用中传递的参数。lpCreateParams 字段包含附加数据,在我们的例子中是指向 CMainWindow 实例的指针。不幸的是,此附加数据不会随每条消息发送,因此我们需要一种方法来存储此指针以供以后使用。这就是我们通过调用 SetWindowLongPtr 所做的事情:此函数允许您为特定窗口(由其句柄标识)保存一些用户数据 (GWLP_USERDATA)。在这种情况下,我们保存指向类实例的指针。当收到其他消息时,我们将通过调用 (GetWindowLongPtr) 简单地检索此指针,然后在指针上调用非静态函数:ProcessEvent,它负责处理消息。WM_NCCREATE 消息不是第一个发送的消息,这就是为什么我们需要检查 GetWindowLongPtr 的调用是否返回了非 NULL 的内容。

我们来看看 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 :
      break;
    case WM_KEYUP :
      break;
    }
}

这里代码不多,但这个函数将在接下来的教程中填充,因为我们需要处理一些事件。当用户点击窗口的红色叉号时,会发送 WM_CLOSE 消息。此时,我们需要发送 WM_QUIT 消息以退出主循环并退出程序。当窗口被调整大小时,会发送 WM_SIZE 消息,新大小包含在 lParam 中(LOWORD 和 HIWORD 是两个从参数中提取前 2 个字节和后 2 个字节的宏)。当收到这样的消息时,我们将调整大小的处理委托给我们的 OnSize 成员函数。其他一些消息将在稍后处理:WM_KEYDOWN(当按键被按下时),WM_KEYUP(当按键被释放时),...

到目前为止,我们的程序所做的只是创建一个空窗口并将其显示在屏幕上(全屏模式或非全屏模式)。

异常处理

错误管理是所有程序的重要一点,对于游戏也是如此:你不想因为缺少资源而导致游戏崩溃。我处理游戏错误的首选方法是使用异常。它比从函数返回错误代码(并将它们路由到我希望处理错误的地方)方便得多。主要原因是,我可以在一个地方委托错误处理:在我的主函数中,所有异常都将被捕获。让我们首先看看我们的异常类,它非常基本

class CException : public std::exception
{
public:
  const char* what() const  { return m_strMessage.c_str(); }

  CException(const std::string& strMessage="") : m_strMessage(strMessage)  { }
  virtual ~CException()  { }

  std::string m_strMessage;
};

所以,这里没有什么花哨的:我们的异常类继承自 std::exception(这不是强制性的,但被认为是好的做法)。我们只是简单地重写了 what() 函数,它返回错误消息。我在这里将场景保持得相当简单,但对于一个更大的游戏,您可能希望将此异常专门化为特定的异常:内存不足、资源丢失、文件加载失败等等。这可能证明很有用,因为有时过滤异常很有用。一个典型的例子是当您的游戏用户想要加载一个已损坏的文件(包含以前保存的游戏)时。在这种情况下,加载文件函数将抛出异常,但您不想因此退出程序。您想要做的是向用户显示一条消息,告诉他文件已损坏。然后,您可以在早期阶段轻松捕获所有“文件已损坏”的异常,并让所有其他异常路由到您的主异常处理函数。毕竟,如果在加载文件时缺少某些资源,这可能是一个严重错误,您可能需要退出程序。

那么,我的主函数是什么样子,我如何处理异常呢?

int WINAPI WinMain(HINSTANCE Instance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, INT)
{
  try
  {
    // Create the application class,
    // parse the command line and
    // start the app.
    CApplication theApp(Instance);
    theApp.ParseCmdLine(lpCmdLine);
    theApp.Run();
  }
  catch(CException& e)
  {
    MessageBox(NULL,e.what(),"Error",MB_OK|MB_ICONEXCLAMATION);
  }

  return 0;
}

很容易理解,不是吗?我们已经看到了 CApplication 类在做什么,对于异常处理,我们只是将所有内容包装在一个小的 try/catch 块中。当程序中某个地方抛出异常时,我们只是显示一个包含异常文本的错误消息,然后优雅地退出程序。请注意,由于 theApp 是函数局部变量,它将在函数结束时被销毁,并且其析构函数将被调用。

设置 OpenGL

如果你还记得,在我们的 CMainWindow 构造函数中,我们调用了两个函数:CreateContext 和 InitGL。我还没有解释这些函数做了什么,所以现在来纠正一下。CreateContext 将初始化渲染上下文,以便可以在窗口上绘制 OpenGL 基元。

void CMainWindow::CreateContext()
{
  // Describes the pixel format of the drawing surface
  PIXELFORMATDESCRIPTOR pfd;
  memset(&pfd, 0, sizeof(PIXELFORMATDESCRIPTOR));
  pfd.nSize = sizeof(PIXELFORMATDESCRIPTOR);
  pfd.nVersion = 1; // Version Number
  pfd.dwFlags = PFD_DRAW_TO_WINDOW |  // Draws to a window
                PFD_SUPPORT_OPENGL |  // The format must support OpenGL
                PFD_DOUBLEBUFFER;     // Support for double buffering
  pfd.iPixelType = PFD_TYPE_RGBA;     // Uses an RGBA pixel format
  pfd.cColorBits = 32;                // 32 bits colors

  if (!(m_hDeviceContext=GetDC(m_hWindow)))
    throw CException("Unable to create rendering context");

  int PixelFormat;
  // Do Windows find a matching pixel format ?
  if (!(PixelFormat=ChoosePixelFormat(m_hDeviceContext,&pfd)))
    throw CException("Unable to create rendering context");
  // Set the new pixel format
  if(!SetPixelFormat(m_hDeviceContext,PixelFormat,&pfd))
    throw CException("Unable to create rendering context");
  // Create the OpenGL rendering context
  if (!(m_hGLContext=wglCreateContext(m_hDeviceContext)))
    throw CException("Unable to create rendering context");
  // Activate the rendering context
  if(!wglMakeCurrent(m_hDeviceContext,m_hGLContext))
    throw CException("Unable to create rendering context");
}

函数的第一部分用正确的信息填充 PIXELFORMATDESCRIPTOR:缓冲区用于在窗口上绘图,必须支持 OpenGL 并使用双缓冲(以避免闪烁)。然后我们调用 ChoosePixelFormat 查看是否支持此像素格式。函数返回一个像素格式索引(如果未找到匹配的像素格式则返回 0)。一旦我们有了像素格式的索引,我们通过调用 SetPixelFormat 设置新格式。然后我们通过调用 wglCreateContext 创建 OpenGL 渲染上下文。最后,通过调用 wglMakeCurrent,我们指定线程进行的所有后续 OpenGL 调用都将在此设备上下文中绘图。您还可以看到,如果在创建上下文时遇到错误,将抛出异常并将在我们的主函数中处理。

InitGL 函数相当简单

void CMainWindow::InitGL()
{
  // Enable 2D texturing
  glEnable(GL_TEXTURE_2D);
  // Choose a smooth shading model
    glShadeModel(GL_SMOOTH);
  // Set the clear color to black
  glClearColor(0.0, 0.0, 0.0, 0.0);

  // Enable the alpha test. This is needed 
  // to be able to have images with transparent 
  // parts.
  glEnable(GL_ALPHA_TEST);
  glAlphaFunc(GL_GREATER, 0.0f);
}

我们首先启用 2D 纹理。没有这个调用,我们将无法将纹理应用于屏幕上的形状。这些纹理将从文件中加载并用于显示不同的游戏元素。然后我们选择一个平滑着色模型。这在我们的例子中并不重要,但它只是告诉 OpenGL,如果一个基元(一个基本形状,如三角形或矩形)的点有不同的颜色,它们将被插值。我们稍后会在具体示例中看到它的作用。然后我们指定一个清除颜色。此颜色用于在绘制任何内容之前清除颜色缓冲区。最后,我们启用 Alpha 测试。如果我们要渲染纹理的某些部分透明,则需要这样做。例如,假设您要在屏幕上绘制一艘船,并且这艘船是从文件中加载的。船不是矩形形状,所以您希望将船周围的纹理设为透明,这样您就不会有一个包含船的白色矩形。这是通过使用 Alpha 通道来指定像素的不透明度来完成的(这将在第二篇文章中更详细地介绍)。一旦启用了 Alpha 测试,我们还需要选择哪个函数将根据其 Alpha 通道丢弃像素。这是通过 glAlphaFunc 完成的:我们指定所有 Alpha 通道大于 (GL_GREATER) 指定阈值 (0) 的像素都将被丢弃(不绘制)。还存在其他 Alpha 函数 (GL_LESS, GL_EQUAL, ...)。

现在让我们看看 OnSize 函数。如果你还记得,这个函数在窗口调整大小(并且至少一次,在窗口创建时)时被调用。

void CMainWindow::OnSize(GLsizei width, GLsizei height)
{
  // Sets the size of the OpenGL viewport
    glViewport(0,0,width,height);

  // Select the projection stack and apply
  // an orthographic projection
  glMatrixMode(GL_PROJECTION);
  glLoadIdentity();
  glOrtho(0.0,width,height,0.0,-1.0,1.0);
  glMatrixMode(GL_MODELVIEW);
}

它将新窗口的大小作为参数接收。我们在这里做的第一件事是调用 glViewport。这个函数指定 OpenGL 将用于绘图的窗口区域。例如,您可以将绘图限制在整个窗口的一部分。在我们的例子中,我们将使用整个窗口作为视口。默认情况下,OpenGL 将使用整个窗口大小,因此此调用不是必需的(仅用于教育目的)。

现在我们将使用 glMatrixMode。为了理解它的作用,我首先解释一下 OpenGL 在过程的不同阶段使用三个矩阵堆栈。这些堆栈是

  • GL_MODELVIEW:此矩阵堆栈影响场景中的对象。在我们的教程中,这些对象将只是带纹理的矩形。通过操作此矩阵堆栈,您将能够平移、旋转和缩放场景中的对象。
  • GL_PROJECTION:此矩阵堆栈影响场景中的对象如何投影到视口上。通过操作此堆栈,您可以指定应应用于对象的投影类型。
  • GL_TEXTURE:此矩阵堆栈定义纹理在应用于对象之前如何进行操作。在本教程中我们不会操作此堆栈。

OpenGL 总是使用每个堆栈顶部的当前矩阵,但是使用堆栈可能会很有用,因为您可以将当前矩阵向下推入堆栈以供以后使用。我们将在本教程的末尾看到一个更具体的示例。

经过这个简短的解释,我们回到代码:glMatrixMode 所做的是它只是告诉 OpenGL 下一个操作将影响哪个矩阵堆栈。在您的代码中,我们选择投影堆栈。然后我们在堆栈中加载单位矩阵(它只是将当前矩阵重置为单位矩阵),然后我们指定我们希望在视口上对对象进行正交投影。我们最终切换回默认矩阵,即模型视图矩阵。

你可能想知道什么是正交投影?让我们深入了解一下它的作用。你可以在 OpenGL 中有两种不同的投影:透视投影或正交投影。我将用两张图片展示这两种投影,而不是进行详细解释。

Orthographic projection

正交投影。

Perspective projection

透视投影。

如您所见,如果您开发 3D 游戏,透视投影是首选:它将类似于您的眼睛所能看到的,因为远离相机的物体会显得很小。另一方面,正交投影不会扭曲物体:远处的一个立方体看起来与相机正前方的一个立方体大小相同(假设它们大小相同)。对于 2D 游戏,我更喜欢使用正交投影,因为这样我就不必考虑 Z 位置:我可以给出任何值,物体不会因此值而变小或变大。

传递给 glOrtho 的参数是观察体的坐标(left、right、bottom、top、nearVal 和 farVal)。您在此处选择的值实际上将定义您将使用的“单位”:OpenGL 本身不定义任何单位。例如,我选择了窗口宽度作为观察体的宽度。这意味着如果我将一个对象向左移动 1 个单位,它将移动 1 个像素。您还会经常看到 left/bottom 和 right/top 的值为 0.0 到 1.0。在这种情况下,一个单位是窗口在水平方向的宽度,是窗口在垂直方向的高度。在 2D 游戏中,我更喜欢使用第一种选项,因为如果我想将两个纹理并排绘制,我确切地知道我的第二个纹理需要移动多少:它是第一个纹理的宽度(例如,如果我的纹理宽度为 24 像素,我的第二个纹理将向右移动 24 个单位)。另一方面,如果我想将某个东西放置在窗口的中间,我必须考虑窗口的宽度。对于另一种选项,0.5 个单位是窗口的中间。这只是一个选择问题,但由于我熟悉 MFC 和 GDI,我倾向于使用第一种选项以获得相同的感觉。您可能还注意到另一点:我给 bottom 的值是 height,给 top 的值是 0。这意味着我的顶部和底部是颠倒的。这里也只是一个选择问题:OpenGL 中的 Y 轴从下到上,这与我习惯的做法相反(窗口坐标从窗口顶部开始到窗口底部)。

绘制简单形状

现在一切都设置正确了,我们终于可以在我们的窗口上绘制一些基本形状了。我们使用双缓冲来避免闪烁,这意味着所有内容都将写入屏幕外缓冲区,一旦图像合成,缓冲区将被交换,将屏幕外缓冲区带到屏幕上,反之亦然。这避免了直接在显示在屏幕上的缓冲区上绘图。让我们看看我们的 CMainWindow::Draw() 函数,其中应该包含绘图代码

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

  SwapBuffers(m_hDeviceContext);
}

代码的第一行只是使用我们之前在 InitGL 函数中指定的清除颜色(黑色)清除缓冲区。在函数的末尾,我们通过调用 SwapBuffers 交换缓冲区。我们的绘图代码将放置在这些调用之间。

OpenGL 允许您绘制一些简单的形状,称为**图元**,可以是点、线和多边形(大多数情况下是三角形和矩形)。这些图元由它们的**顶点**描述,即点本身的坐标、线段的端点或多边形的角。对于 2D 游戏,我们可能会将自己限制在矩形:当纹理化时,它们允许您显示位图,这几乎是 2D 游戏所需的一切。对于更复杂的游戏(如 3D 游戏),可以通过将三角形组装在一起形成网格来创建复杂形状。让我们在屏幕上绘制一个矩形和一个三角形:我们将把这段代码放在绘图函数中的两个函数调用之间。

  glBegin(GL_QUADS);
    glVertex3i(50,200,0);
    glVertex3i(250,200,0);
    glVertex3i(250,350,0);
    glVertex3i(50,350,0);
  glEnd();

  glBegin(GL_TRIANGLES);
    glVertex3i(400,350,0);
    glVertex3i(500,200,0);
    glVertex3i(600,350,0);
  glEnd();

指定顶点(调用 glVertex3i)应始终封装在 glBegin/glEnd 对中。提供给 glBegin 的参数定义了我们正在绘制的形状类型。您可以在同一个 glBegin/glEnd 对中绘制多个形状,您只需提供足够的顶点:例如,如果您想绘制两个矩形,您必须提供 8 个顶点。提供给 glVertex3i 的参数是顶点的坐标,这取决于投影的定义方式(还记得我们在 CMainWindow::OnSize() 方法中做了什么吗?)。我选择在此示例中坚持使用窗口坐标。函数末尾的“3i”指定了函数的参数数量和类型。此函数存在多个版本:从两个到四个参数,可以是整数、浮点数、双精度数、有符号数、无符号数、数组等。只需选择最适合您需求的版本即可。

您还可以为形状的每个顶点指定颜色,所以让我们在这里尝试一些不错的东西

  glBegin(GL_QUADS);
    glColor3f(1.0,0.0,0.0);   glVertex3i(50,200,0);
    glColor3f(0.0,1.0,0.0);   glVertex3i(250,200,0);
    glColor3f(0.0,0.0,1.0);   glVertex3i(250,350,0);
    glColor3f(1.0,1.0,1.0);   glVertex3i(50,350,0);
  glEnd();

  glBegin(GL_TRIANGLES);
    glColor3f(1.0,0.0,0.0);  glVertex3i(400,350,0);
    glColor3f(0.0,1.0,0.0);  glVertex3i(500,200,0);
    glColor3f(0.0,0.0,1.0);  glVertex3i(600,350,0);
  glEnd();

通过调用 glColor3f 来指定当前颜色,这里也有该函数的多个版本。对于浮点版本,完全强度对应于 1.0,无强度对应于 0.0。如果您运行代码,您会看到每个顶点的颜色很好地融合在一起(这是本文顶部的图像)。那是因为我们在 CMainWindow::InitGL() 函数中调用 glShadeModel 时选择了 GL_SMOOTH 着色模型。如果您将其更改为 GL_FLAT,您会看到形状只有一种颜色,即最后提供的颜色。

模型变换

我将通过向您展示如何操作模型视图矩阵堆栈来结束本教程。这不会在后续教程(甚至最终游戏)中使用,但理解这些概念很有用。这就是我对此主题将非常简短的原因。

我之前已经谈到了一些模型视图矩阵堆栈,并且说过您可以对该矩阵应用变换,这将影响场景中的对象。我还解释说,当您想保存当前矩阵以备后用时,使用堆栈而不是单个矩阵可能很有用。通过调用 glPushMatrix,您可以将顶部矩阵下推到当前选定的堆栈(默认是模型视图堆栈),并在堆栈顶部创建该矩阵的副本。一旦您操作了模型视图矩阵以影响场景中的某些对象,您可以通过调用 glPopMatrix 返回到之前推入的矩阵。这在您必须绘制具有子元素的元素时特别有用:子元素的位置和旋转取决于父元素的位置和旋转(例如,机器人手上的手指取决于手的位置,而手的位置又取决于机器人手臂的位置)。在这种情况下,您为父元素应用变换,将矩阵下推到堆栈,为第一个子元素应用变换并绘制它,然后弹出第一个矩阵以重置到父元素的位置和旋转。然后您可以使用相同的方法绘制第二个子元素。当然,这些子元素本身也可以有子元素,在这种情况下您应用相同的技术。

对场景中的对象应用变换是通过在模型视图矩阵堆栈中加载特定矩阵来完成的。您可以手动复合此矩阵,但我猜这是您希望避免的事情。这就是为什么 OpenGL 提供了三个可用于模型变换的例程:glTranslate、glRotate 和 glScale。您必须考虑的一点是,每次调用此类函数都等效于创建相应的平移、旋转或缩放矩阵,然后将当前模型视图矩阵与此矩阵相乘(并将结果存储在模型视图矩阵中)。这意味着您可以“链接”这些调用以产生您喜欢的变换。您可能还知道(或从您的数学课中记得)矩阵乘法不是可交换的。这意味着您调用函数的顺序很重要。实际上,程序中最后调用的变换命令是第一个应用的。您可以这样理解:想象您必须以您希望它们应用的相反顺序调用变换。假设您想将一个对象放置在位置 (100,100)(我们这里不考虑 z 轴),并使其围绕 z 轴旋转 180 度(但仍以相同位置为中心),那么您需要先应用平移,然后旋转对象。如果您做相反的事情,平移将首先应用,然后旋转将应用,这意味着您的对象将移动到位置 (100,100),然后围绕 (0,0) 旋转 180 度。这意味着它将最终位于位置 (-100,-100)。

我不想在这里深入过多细节,因为矩阵操作和模型变换本身就值得一篇完整的文章。我只是想向您展示操作模型视图矩阵可以非常强大,例如,如果您想添加一些简单的特殊效果(如旋转和缩放)。

结论

在本文中,我提供了一个基本框架,可以重用于编写 2D 游戏。它创建了主窗口并相应地设置了 OpenGL。我们将在下一篇文章中看到如何使用从文件加载的图像对形状进行纹理处理以及如何有效地管理这些资源。

参考文献

致谢

我要感谢 Andrew Vos 提供了漂亮的投影图像。
感谢审阅者:Vunic,Andrew。
也感谢 Jeremy Falcon、El Corazon 和 Nemanja Trifunovic 的建议和帮助。

历史

  • 2008 年 6 月 23 日
    • 初始版本

  • 2008 年 8 月 23 日
    • 在前言部分添加了第二篇文章的链接。
    • CApplication 类的 Run 方法已调整。
    • CMainWindow 类的 OnEvent 方法已调整。
    • 在 CMainWindow 类的 InitGL 方法中添加了混合支持。
© . All rights reserved.