四、构建游戏引擎
现代游戏公司的生产力主要是由从一个项目到下一个项目的代码和工具的重用驱动的。通过重用代码,公司和个人可以腾出更多的时间来开发实际的游戏,而不是重新实现技术,虽然这是必要的,但不会对成品产生明显的影响。
我们在第 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
的内容与我们之前创建的基础游戏相比相对简单。这很好,因为我们已经设法创建了一个接口,它将允许我们从这个级别隐藏许多更复杂的操作,并且应该给我们更容易阅读和使用的代码。
Initialize
和Run
方法的定义目前同样是基本的和空的,正如你在清单 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
)。当我们开始研究SuspendTask
和ResumeTask
方法时,我们将会看到它的用途。
定义任务界面
首先我们来看看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 显示了PriorityAdd
和AddTask
方法。在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
中的方法,现在我们来看看这些方法。
OnSuspend
、OnResume
、Stop
都是空方法。目前,我们不需要在其中加入任何东西。同样,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_RESUME
和APP_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
对象并将其添加到内核所需的更改。您可以看到我们将Kernel
和Android
对象作为私有成员添加到了应用中。在Application
构造函数初始化列表中调用Android
构造函数,并向其传递android_app
结构及其优先级。在Initialize
方法中将Android
对象添加到内核中,我们在Application::Run
中调用Kernel::Execute
。
我们现在有了一个可以与 Android 操作系统正常连接的游戏循环。在我们能够开始编写游戏代码之前,我们还有许多底层的准备工作要做。接下来是帧时序。
计时
多年来,游戏中一个更重要的基准是 fps 或每秒帧数。对于目前这一代游戏主机来说,30fps 已经是大多数游戏达到的标准。id 等少数公司仍以 60fps 为目标,并通过减少延迟来提高响应速度。
无论你希望达到什么样的帧率,游戏中的计时都是很重要的。准确的帧时间对于在游戏中以一致的方式移动物体是必要的。20 世纪 90 年代初,在 SNES 和世嘉创世纪上,游戏在不同国家以不同速度运行是很常见的。这是因为游戏中的角色每帧以一致的速度移动。问题是电视在欧洲以每秒 50 次的速度更新,但在北美却是每秒 60 次。结果是欧洲玩家的游戏速度明显变慢了。
如果开发者根据时间而不是帧速率来更新他们的游戏,这种情况完全可以避免。我们通过存储处理最后一帧花了多长时间,并相对于该时间移动对象来实现这一点。我们将认为这已经在第 6 章中完成了,但是现在我们将看看如何存储前一帧的时间,如清单 4-16 所示。
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
来更新每一帧的Timer
。Timer
将被赋予零优先级,并且将是每帧中第一个被更新的任务。
在我们的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_gettime
。clock_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 网站获得。
版权属于:月萌API www.moonapi.com,转载请注明出处