四、构建游戏引擎

现代游戏公司的生产力主要是由从一个项目到下一个项目的代码和工具的重用驱动的。通过重用代码,公司和个人可以腾出更多的时间来开发实际的游戏,而不是重新实现技术,虽然这是必要的,但不会对成品产生明显的影响。

我们在第 2 章中看到的示例游戏没有任何可以以简单的方式从一个项目重用到下一个项目的代码。对于一个小例子来说,这可能是可以接受的,但是对于那些想制作不止一个游戏或者希望在游戏开发领域创业的人来说,这种方法并不是特别有益。

我们将在本章开始开发我们自己的游戏引擎。引擎本身将远离商业引擎(如 Unreal 或 Unity)的复杂性,但它将帮助我们理解为什么引擎是一个好主意,以及我们如何才能实现游戏代码和框架代码的分离。我们将从一个可重复使用的游戏循环开始,与 Android 操作系统通信,并学习如何为我们的帧计时。

让我们从查看我们的应用对象开始。

创建应用对象

面向对象设计的第一课是创建代表应用设计中名词的类。我们在为 Android 开发游戏时遇到的第一个名词是单词 app 。对我们来说,创建一个类来封装我们的应用是有意义的,这也是我们编写的第一个类,它是一个可重用的对象;这意味着类本身不应该包含任何特定于您正在构建的应用的代码。幸运的是,C++ 通过继承为我们提供了一种机制,这不是问题,但是开始时我们只看创建类本身,如清单 4-1 所示。

清单 4-1。 应用类:Application.h

namespace Framework
{
       class Application
       {
       private:

       public:
              Application(android_app* state);
              virtual Application();

              bool       Initialize();
              void       Run();
       };
}

这是应用的类定义。目前,这段代码没有什么特别有趣的地方,但是随着我们的发展,我们会向应用添加对象。你可以认为Application是你的应用中的根对象。我们将从main中使用它,如清单 4-2 所示。

清单 4-2。 android_main,App 入口点:Chapter4.cpp

void android_main(struct android_app* state)
{
       app_dummy();

       Framework::Application app(state);

       if (app.Initialize())
       {
              app.Run();
       }
}

这里你可以看到main的内容与我们之前创建的基础游戏相比相对简单。这很好,因为我们已经设法创建了一个接口,它将允许我们从这个级别隐藏许多更复杂的操作,并且应该给我们更容易阅读和使用的代码。

InitializeRun方法的定义目前同样是基本的和空的,正如你在清单 4-3 中看到的。

清单 4-3。 应用的初始化和运行方法:Application.h

bool Application::Initialize()
{
       bool ret = true;
       return ret;
}

void Application::Run()
{
}

所有的实时游戏都在所谓的游戏循环 中运行。在下一节中,我们将看到一个对象,我们将使用它来创建这个循环。

使用内核和任务创建游戏循环

封装我们游戏循环的对象叫做内核。这个对象的基本设计是由 Richard Fine 在他的 Enginuity 系列中提出的,可以在www.gamedev.net找到。内核通过维护一个任务列表来工作。任务按优先级顺序添加到列表中,内核按顺序更新任务,每帧更新一次。

启动内核类

同样,Kernel类已经在Framework名称空间中声明,但是为了简洁起见,我将从文本中省略这一行;你可以在清单 4-4 中看到重要的代码。您可以看到这些类是如何编写的,以及它们相关的 includes 等。,在本章的示例代码中。

清单 4-4。 内核类:Kernel.h

class Kernel
{
       private:
              typedef std::list<Task*>                 TaskList;
              typedef std::list<Task*>::iterator       TaskListIterator;

              TaskList       m_tasks;
              TaskList       m_pausedTasks;

              void              PriorityAdd(Task* pTask);

       public:
              Kernel();
              virtual Kernel();

              void       Execute();

              bool        AddTask(Task* pTask);
              void       SuspendTask(Task* task);
              void       ResumeTask(Task* task);
              void       RemoveTask(Task* task);
              void       KillAllTasks();

              bool       HasTasks()       { return m_tasks.size(); }
};

Kernel类的定义相当简单明了。正如我前面提到的,我们有一个包含指向Task对象的指针的列表。我们还声明了公共方法,允许我们添加和删除以及暂停和恢复单个任务。还有一个KillAllTasks方法,它允许我们杀死所有当前的任务,我们还可以使用HasTasks方法检查内核是否有任何当前正在运行的任务。

还有一个我们之前没有讨论过的成员,那就是暂停任务列表(m_pausedTasks)。当我们开始研究SuspendTaskResumeTask方法时,我们将会看到它的用途。

定义任务界面

首先我们来看看Task接口,如清单 4-5 所示。

清单 4-5。 任务界面:Task.h

class Task
{
private:
       unsigned int       m_priority;
       bool               m_canKill;

public:
       explicit Task(const unsigned int priority);
       virtual Task();

       virtual bool       Start()              = 0;
       virtual void       OnSuspend()          = 0;
       virtual void       Update()             = 0;
       virtual void       OnResume()           = 0;
       virtual void       Stop()               = 0;

       void               SetCanKill(const bool canKill);
       bool              CanKill() const;
       unsigned int       Priority() const;
};

这个接口是所有未来的Task类将继承的基类。我们可以看到每个Task都会有一个优先级和一个标志来告诉它是否可以被杀死(m_canKill)。

纯虚拟方法也是内核用来与Task交互的接口。Task的每个子方法都将覆盖这些方法,为给定的Task提供特定的功能。当我们实现实际的Task时,我们会更详细地看这些,但是现在我们可以看一下Kernel的方法,看看它是如何使用Task接口的。

检查内核方法

清单 4-6 显示了PriorityAddAddTask方法。在PriorityAdd中,我们得到一个任务列表的迭代器,循环遍历列表,直到当前任务的优先级大于新任务的优先级。这意味着零将是我们系统中的最高优先级,因为Task::m_priority字段是无符号的。然后,任务会在该点插入到列表中。如您所见,这意味着我们的优先级决定了任务更新的顺序。

我们可以看到,Kernel::AddTask在第一行调用了Task::Start。这很重要,因为这意味着只有当任务成功启动时,我们才会向内核添加任务。如果任务开始了,我们叫PriorityAdd

清单 4-6。 内核的优先级添加和添加任务 : Kernel.cpp

void Kernel::PriorityAdd(Task* pTask)
{
       TaskListIterator iter;
       for (iter = m_tasks.begin(); iter != m_tasks.end(); ++iter)
       {
              Task* pCurrentTask = (*iter);
              if (pCurrentTask->Priority() > pTask->Priority())
              {
                     break;
              }
       }
       m_tasks.insert(iter, pTask);
}

bool Kernel::AddTask(Task* pTask)
{
       bool started = pTask->Start();

       if (started)
       {
              PriorityAdd(pTask);
       }
       return started;
}

RemoveTask 直截了当;我们在列表中找到任务,并将其设置为可杀,如清单 4-7 所示。

清单 4-7。T3】内核的 RemoveTask: Kernel.cpp

void Kernel::RemoveTask(Task* pTask)
{
       if (std::find(m_tasks.begin(), m_tasks.end(), pTask) != m_tasks.end())
       {
              pTask->SetCanKill(true);
       }
}

SuspendTask 找到当前正在运行的任务,并对该任务调用OnSuspend。然后,它会从“正在运行的任务”列表中删除该任务,并将其添加到“暂停的任务”列表中。清单 4-8 展示了SuspendTask方法。

清单 4-8。 内核的挂起任务:Kernel.cpp

void Kernel::SuspendTask(Task* pTask)
{
       if (std::find(m_tasks.begin(), m_tasks.end(), pTask) != m_tasks.end())
       {
              pTask->OnSuspend();
              m_tasks.remove(pTask);
              m_pausedTasks.push_back(pTask);
       }
}

ResumeTask 检查任务当前是否暂停(见清单 4-9 )。接下来,它调用Task::OnResume,将其从暂停列表中移除,然后以正确的优先级将任务添加回运行列表中。

清单 4-9。 内核的 ResumeTask: Kernel.cpp

void Kernel::ResumeTask(Task* pTask)
{
       if (std::find(m_pausedTasks.begin(), m_pausedTasks.end(), pTask) != m_pausedTasks.end())
       {
              pTask->OnResume();
              m_pausedTasks.remove(pTask);

              PriorityAdd(pTask);
       }
}

KillAllTasks 是另一种直截了当的方法(见清单 4-10 )。它简单地循环所有正在运行的任务,并将它们的 can-kill 标志设置为true

清单 4-10。 内核的 KillAllTasks: Kernel.cpp

void Kernel::KillAllTasks()
{
       for (TaskListIterator iter = m_tasks.begin(); iter != m_tasks.end(); ++iter)
       {
              (*iter)->SetCanKill(true);
       }
}

Execute 方法是我们游戏循环的所在,如清单 4-11 所示。这个方法循环遍历任务,并对每个任务调用Task::Update

清单 4-11。 内核的执行,游戏循环:Kernel.cpp

void Kernel::Execute()
{
       while (m_tasks.size())
           {
                  if (Android::IsClosing())
               {
                          KillAllTasks();
               }

              TaskListIterator iter;
                  for (iter = m_tasks.begin(); iter != m_tasks.end(); ++iter)
                  {
                         Task* pTask = (*iter);
                         if (!pTask->CanKill())
                         {
                                pTask->Update();
                         }
                  }

                  for (iter = m_tasks.begin(); iter != m_tasks.end();)
                  {
                         Task* pTask = (*iter);
                         ++iter;
                         if (pTask->CanKill())
                         {
                                pTask->Stop();
                              m_tasks.remove(pTask);
                              pTask = 0;
                         }
                  }
           }

       Android::ClearClosing();
}

这里我们可以看到,只要有任务要执行,Execute就会在while循环中运行。

如果系统正在关闭应用,我们会调用KillAllTasks来通知他们游戏即将关闭。Execute然后遍历任务列表,并对任何没有被标记为销毁的任务调用Task::Update。这很重要,因为我们不能保证任何预期要删除的任务仍然有有效的数据。运行第二个循环以从正在运行的循环中移除被标记为销毁的任何任务。

此时,您会注意到对名为Android的类的引用。这个类用于轮询 Android 事件系统;我们将在下一节讨论这个问题。

安卓的原生应用 Glue

Android NDK 提供了一个框架,该框架提供了一个到操作系统的接口,而不需要使用 Java 编程语言实现基本的应用结构。这个接口就是NativeActivity。尽管如此,程序员仍然需要实现大量的粘合代码来将来自NativeActivity的生命周期更新转换成他们自己的应用中可用的格式。幸运的是,Android NDK 开发者也在其原生应用 Glue 代码中提供了这一层。

首先,这个粘合代码为我们提供了一个访问 Android 应用生命周期的接口,这将是本节的重点。在后面的章节中,我们还会看到这个框架提供的其他接口,比如输入和传感器信息。

Android类将需要每帧更新一次,以从 Android 操作系统获取最新事件。这使得它成为我们首要任务的完美候选;关于Android类的详细信息,参见清单 4-12

清单 4-12。 一个安卓任务:Android.h

class Android
       :       public Task
{
private:
       static bool       m_bClosing;
       static bool       m_bPaused;
       android_app*       m_pState;

public:
       Android(android_app* pState, const unsigned int priority);
       virtual Android();

       android_app*       GetAppState() { return m_pState; }

       virtual bool       Start();
       virtual void        OnSuspend();
       virtual void        Update();
       virtual void        OnResume();
       virtual void        Stop();

       static void ClearClosing()                     { m_bClosing = false; }
       static bool IsClosing()                            { return m_bClosing; }
       static void SetPaused(const bool paused)       { m_bPaused = paused; }
       static bool IsPaused()                             { return m_bPaused; }
};

这里我们可以看到Android类继承自Task。为了方便起见,我们在结束和暂停标志中使用了静态变量。内核需要知道应用是否正在关闭,但是它不一定需要访问Android对象来这样做。我们还覆盖了Task中的方法,现在我们来看看这些方法。

OnSuspendOnResumeStop都是空方法。目前,我们不需要在其中加入任何东西。同样,Start除了返回true什么也不做。我们不需要运行任何初始化代码来允许 Android 系统执行,所以没有必要阻止我们的任务被添加到内核的运行列表中。剩下的是Update,如清单 4-13 中的所示。

清单 4-13。 安卓的更新:Android.cpp

void Android::Update()
{
       int events;
       struct android_poll_source* pSource;
       int ident = ALooper_pollAll(0, 0, &events, (void**)&pSource);
       if (ident >= 0)
       {
              if (pSource)
              {
                     pSource->process(m_pState, pSource);
              }

              if (m_pState->destroyRequested)
              {
                     m_bClosing = true;
              }
       }
}

Update方法相当简单。调用ALooper_pollAll方法,并从 Android 操作系统中检索我们的应用的任何当前事件。

  • 传递的第一个参数是超时值。因为我们是在实时循环中运行,所以我们不希望这个调用被阻塞的时间超过一定的时间。我们通过将零作为第一个参数来告诉该方法立即返回,而不等待事件。
  • 在某些情况下,第二个参数可以用来获取指向文件描述符的指针。我们不关心这个,过零。
  • 我们对第三个参数也不感兴趣,但是我们需要传递一个int的地址来检索它的值。
  • 第四个参数检索事件的源结构。我们在这个结构上调用process,并为我们的应用传递状态对象。

我们可以看看状态对象现在是在哪里初始化的,如清单 4-14 所示。

清单 4-14。 Android 的构造器和事件处理器:Android.cpp

static void android_handle_cmd(struct android_app* app, int32_t cmd)
{
       switch (cmd)
       {
       case APP_CMD_RESUME:
              {
                     Android::SetPaused(false);
              }
              break;

       case APP_CMD_PAUSE:
              {
                     Android::SetPaused(true);
              }
              break;
       }
}

Android::Android(android_app* pState, unsigned int priority)
       :       Task(priority)
{
       m_pState = pState;
       m_pState->onAppCmd = android_handle_cmd;
}

Android 提供了处理系统事件的回调机制。回调签名返回void,并被传递一个android_app结构指针和一个包含要处理的事件值的整数。对于 Win32 程序员来说,这种设置与 WndProc 并没有什么不同。

我们还不需要处理任何 Android OS 事件,但是为了举例,我添加了一个处理APP_CMD_RESUMEAPP_CMD_PAUSE事件的switch语句。

我们可以从 Android 构造函数中看到,我们已经存储了提供给我们的android_app指针,并将onAppCmd函数指针设置为静态命令处理程序方法的地址。这个方法将在ALooper_pollAll提供给我们的事件结构上的process调用期间被调用。

关于Android类剩下要做的唯一一件事就是实例化一个实例并将其添加到我们的内核中,如清单 4-15 所示。

清单 4-15。 实例化 Android 任务:Application.h、Task.h、Application.cpp

class Application
{
private:
       Kernel       m_kernel;
       Android       m_androidTask;

public:
       Application(android_app* state);
       virtual Application();

       bool       Initialize();
       void       Run();
};

class Task
{
private:
       unsigned int       m_priority;
       bool               m_canKill;

public:
       explicit Task(const unsigned int priority);
       virtual Task();

       virtual bool       Start()              = 0;
       virtual void       OnSuspend()          = 0;
       virtual void       Update()             = 0;
       virtual void       OnResume()           = 0;
       virtual void       Stop()               = 0;

       void               SetCanKill(const bool canKill);
       bool              CanKill() const;
       unsigned int       Priority() const;

       static const unsigned int PLATFORM_PRIORITY = 1000;
};

Application::Application(android_app* state)
       :       m_androidTask(state, Task::PLATFORM_PRIORITY)
{
}

bool Application::Initialize()
{
       bool ret = true;

       m_kernel.AddTask(&m_androidTask);

       return ret;
}

void Application::Run()
{
       m_kernel.Execute();
}

这些是实例化Android对象并将其添加到内核所需的更改。您可以看到我们将KernelAndroid对象作为私有成员添加到了应用中。在Application构造函数初始化列表中调用Android构造函数,并向其传递android_app结构及其优先级。在Initialize方法中将Android对象添加到内核中,我们在Application::Run中调用Kernel::Execute

我们现在有了一个可以与 Android 操作系统正常连接的游戏循环。在我们能够开始编写游戏代码之前,我们还有许多底层的准备工作要做。接下来是帧时序。

计时

多年来,游戏中一个更重要的基准是 fps 或每秒帧数。对于目前这一代游戏主机来说,30fps 已经是大多数游戏达到的标准。id 等少数公司仍以 60fps 为目标,并通过减少延迟来提高响应速度。

无论你希望达到什么样的帧率,游戏中的计时都是很重要的。准确的帧时间对于在游戏中以一致的方式移动物体是必要的。20 世纪 90 年代初,在 SNES 和世嘉创世纪上,游戏在不同国家以不同速度运行是很常见的。这是因为游戏中的角色每帧以一致的速度移动。问题是电视在欧洲以每秒 50 次的速度更新,但在北美却是每秒 60 次。结果是欧洲玩家的游戏速度明显变慢了。

如果开发者根据时间而不是帧速率来更新他们的游戏,这种情况完全可以避免。我们通过存储处理最后一帧花了多长时间,并相对于该时间移动对象来实现这一点。我们将认为这已经在第 6 章中完成了,但是现在我们将看看如何存储前一帧的时间,如清单 4-16 所示。

清单 4-16。T5【定时器任务:Timer.h

class Timer
       :       public Task
{
public:
       typedef long long       TimeUnits;

private:
       TimeUnits nanoTime();

       TimeUnits          m_timeLastFrame;
       float              m_frameDt;
       float              m_simDt;
       float              m_simMultiplier;

public:
       Timer(const unsigned int priority);
       Timer();

       float              GetTimeFrame() const;
       float              GetTimeSim() const;
       void               SetSimMultiplier(const float simMultiplier);

       virtual bool        Start();
       virtual void        OnSuspend();
       virtual void        Update();
       virtual void        OnResume();
       virtual void        Stop();
};

不出所料,我们使用一个Task来更新每一帧的TimerTimer将被赋予零优先级,并且将是每帧中第一个被更新的任务。

在我们的Timer中,我们有两种时间概念。我们有帧时间,这是完成最后一帧的实际时间,我们有模拟时间。模拟时间是我们将在代码的游戏性部分使用的时间。sim 卡时间将被乘数修改。这个乘数将允许我们修改游戏更新的速度。我们也许可以将它用于游戏目的,但是它也可以用于调试目的。如果我们有一个 bug 要重现,它发生在一系列事件的末尾,我们可以通过增加时间乘数来加快重现过程,让游戏中的所有事情发生得更快。清单 4-17 显示了计算当前系统时间的方法。

清单 4-17。 定时器,nanoTime: Timer.cpp

Timer::TimeUnits Timer::nanoTime()
{
       timespec now;
       int err = clock_gettime(CLOCK_MONOTONIC, &now);
       return now.tv_sec*1000000000L + now.tv_nsec;
}

由于 Android 是基于 Linux 的操作系统,我们可以从 C++ 环境中访问许多 Linux 方法。一个例子就是clock_gettimeclock_gettime包含在time.h头文件中,它为我们提供了一个到我们正在运行的计算机中的系统时钟的接口。我们特别使用单调时钟,它给出了自过去事件以来的任意时间。我们不知道那个事件是什么时候,但是因为我们正在比较我们自己的帧时间,所以我们并不过度担心。

timespec结构包含两个成员:

  • 第一个是以秒为单位的时间;
  • 第二个是纳秒。

我们返回的值以纳秒为单位;我们将秒乘以 1,000,000,000,转换成纳秒,然后加上纳秒值。

Timer任务的初始值设置在Start中,如清单 4-18 所示。

清单 4-18。 启动和重启定时器:Timer.cpp

bool Timer::Start()
{
       m_timeLastFrame = nanoTime();
       return true;
}

void Timer::OnResume()
{
       m_timeLastFrame = nanoTime();
}

在这里设置初始值是必要的,因为我们总是需要一个先前的值来比较。如果我们在启动计时器时没有初始化这个值,我们的初始帧时间将完全不可靠。通过初始化前面的时间,我们将最坏的情况限制在初始帧时间为零,这并不是灾难性的。我们在OnResume中也做了同样的事情,尽管我无法想象在正常运行的情况下计时器会暂停。

计时器类的最后一个重要方法是Update,如清单 4-19 中的所示。

清单 4-19。 定时器的更新 : Timer.cpp

void Timer::Update()
{
       // Get the delta between the last frame and this
       TimeUnits currentTime = nanoTime();
       const float MULTIPLIER = 0.000000001f;
       m_frameDt = (currentTime-m_timeLastFrame) * MULTIPLIER;
       m_timeLastFrame = currentTime;
       m_simDt = m_frameDt * m_simMultiplier;
}

在这种方法中,您可以看到我们正在获取上一帧和当前帧之间的时间增量。我们从从系统时钟获取最新的nanoTime值开始。然后我们通过从当前的nanoTime中减去先前的nanoTime来计算帧时间。在这一步中,我们还将时间转换成一个浮点数,其中包含以秒为单位的帧时间。这种格式是在游戏代码中处理时间的最简单的方式,因为我们可以用每秒的数值来处理所有的移动速度,并将时间作为乘数。

然后将currentTime存储到最后一个帧时间成员中,用于计算下一个处理帧中的时间,最后,我们计算 sim 时间,作为帧时间乘以 sim 乘数的结果。

摘要

在这一章中,我们已经为我们的引擎做了很多基础工作。我们已经创建了一个可重用的任务和内核系统,并用一个与操作系统通信的Android任务和一个能够计算出我们的帧需要处理多长时间的Timer任务来填充它。现在这些任务已经写好了,我们希望再也不用写了。这是游戏引擎的开始,我们将在下一章通过查看我们的 OpenGL 渲染器来扩展这个框架。

与本书中的所有章节一样,完整的示例源代码可以从 Apress 网站获得。