跳一跳是我想玩的游戏类型:3D卡通外观的复古街机游戏。目标是改变每一个填充块的颜色,就像Q * Bert同样。程序员
Hop Out仍在开发中,但引擎的功能已经很完善了,因此我想在这里分享一些关于引擎开发的技巧。编程
你为何想要写一个游戏引擎?可能有不少缘由:json
你是个修理工,喜欢从头开始创建系统,直到系统完成。xcode
关于游戏开发你想了解更多。你在游戏行业工做了多年,如今仍然在不停的琢磨。你甚至不肯定本身是否能够从头开始编写一个引擎,由于它与大型工做室的编程工做的平常职责大不相同。你想知道答案。架构
你喜欢控制。对彻底按照你想要的方式组织代码,知道一切都在哪里,感到满意。app
你能够从AGI(1984),id Tech 1(1993),Build(1995)等经典游戏引擎以及Unity和Unreal等行业巨头那里得到灵感。框架
你相信咱们这个游戏产业应该试着去揭开引擎发展的序幕。咱们并无掌握制做游戏的艺术。还离得很远!咱们对这个过程的研究越多,改进的机会就越大。函数
2017年的游戏平台 – 手机,游戏机和电脑 – 很是强大,并且在不少方面都很是类似。游戏引擎的开发并非像过去同样,在脆弱和怪异的硬件上挣扎。在我看来,更可能是关于本身制造出来的复杂性的斗争。创造一个怪物很容易!这就是为何本文建议围绕着保持事情可控的缘由。我把它分红三部分:工具
这个建议适用于任何类型的游戏引擎。我不会告诉你如何编写着色器,八叉树是什么,或者如何添加物体。这些事儿,都是我假设你已经知道并且应该知道 – 这很大程度上取决于你想要制做的游戏类型。布局
相反,我故意选择了一些彷佛没有被普遍认可或说起的观点 – 这些是我在试图揭开一个主题神秘面纱时最感兴趣的一些观点。
个人第一条建议是使一些东西(任何东西),快速运行起来,而后迭代。
若是可能的话,从一个示例应用程序开始,初始化设备并在屏幕上绘制一些东西。就我而言,我下载了SDL,打开了Xcode-iOS / Test / TestiPhoneOS.xcodeproj,而后在个人iPhone上运行了testgles2示例。
瞧!我使用OpenGL ES 2.0,生成了一个可爱的旋转立方体。
下一步,是下载一个其余人制做的马里奥3D 模型。我写了一个快速和粗糙的OBJ文件加载器 – 文件格式并不太复杂 – 而且修改了例程,来呈现Mario,而不是一个立方体。我还集成了SDL_Image来帮助加载纹理。
而后我实现了一个双摇杆控制器用来操控马里奥(我原本想要建立的是一个双摇杆设计游戏,并非马里奥。)
接下来,我想探索骨骼动画,因此我打开了Blender,作了一个触手模型,而且用一个先后摆动的双骨架来操纵它。
此时,我放弃了OBJ文件格式,编写了一个Python脚原本从Blender导出自定义的JSON文件。这些JSON文件描述了皮肤网格,骨架和动画数据。在C ++ JSON库的帮助下将这些文件加载到游戏中。
一旦这个完成,我回到了Blender,并作了更详细的角色设计。 (这是我创造的第一个被操纵的3D人,我为他感到骄傲。)
在接下来的几个月里,我采起了如下几个步骤:
重点是:在开始编程以前,我没有对引擎架构进行设计。这是一个通过深思熟虑的选择。相反,我只是写了实现下一个特性的最简单的代码,而后我会查看代码,看看会出现什么天然生成的架构。我说的“引擎架构”是指组成游戏引擎的模块集,这些模块之间的依赖关系,以及用于与每一个模块交互的 API。
这是一个迭代的方法,由于它关注于较小的可交付成果。它在编写游戏引擎时效果很是好,由于在每一个步骤中,你都有一个正在运行的程序。若是在将代码合成到新模块中时出现问题,能够随时将作的更改与之前工做的代码进行比较。显然,我假设你在使用某种源代码管理工具。
你可能会认为这种方法浪费了不少时间,由于老是在编写糟糕的代码,以后须要清理。可是大部分的清理操做都是将代码从一个.cpp文件移动到另外一个,将函数声明提取到.h文件中,或者直接进行简单的修改。决定事情应该去哪是难点,可是这在已经有代码的时候会更容易决定。
我认为用相反的方法:试图设计出一个可以提早完成全部需求的架构,会浪费更多的时间。我最喜欢的两篇关于系统过分设计风险的文章是 Tomasz Dąbrowski 的《泛化的恶性循环》和 Joel Spolsky 的《不要让架构太空人吓到你》。
我并非说在用代码处理问题以前,不该该在纸上进行设计。我也不是说你不该该事先决定你想要的功能。好比,我从一开始就知道我想让个人引擎在后台线程中加载全部资源。我只是没有尝试设计或实现该功能,直到个人引擎首先加载一些资源。
迭代的方法给了我一个比我之前盯着一张白纸左思右想更优雅的架构。个人引擎的iOS版本如今是 100% 原始代码,包括自定义数学库,容器模板,反射/序列化系统,渲染框架,物理模块和音频混合器。我能够编写每个模块,可是你可能没有必要本身写全部这些东西。你可能会发现适合本身引擎的许多优秀的开源代码库。 GLM、Bullet Physics 和 STB 头文件只是一些有趣的例子。
做为程序员,咱们尽可能避免代码重复,喜欢代码遵循统一的风格。不过,我认为不要让这些本能凌驾于每个决定之上。
偶尔要抵制一下 DRY 原则 举个例子,个人引擎包含了几个“智能指针”模板类,与 std :: shared_ptr 相似。每个指针做为一个原始指针的包装,有助于防止内存泄漏。
这样可能看起来像其中一些类复制了其它的功能,违反 DRY(不要重复本身)的原则。事实上,在开发早期,我尽量地重用现有的Reference <>类。可是,我发现音频对象的生命周期是由特殊规则来管理的:若是一个音频语音已经完成了一个样本的播放,而且游戏没有指向该语音的指针,那么该语音会被当即到删除排队等待。若是游戏持有指针,则不该删除这个语音对象。若是游戏持有一个指针,但指针的全部者在语音结束以前被销毁,这段语音应该被取消,而不是增长Reference <>的复杂性,我决定引入单独的模板类,这样更为实用。
95% 的时间都在重用现有的代码。可是,若是你开始感到麻痹,或者发现本身增长了一件简单的事情的复杂性,那就问本身,代码库中的东西是否应该是两件事。
我不喜欢Java的一件事是,它强迫你在一个类中定义每一个函数。在我看来,这是无稽之谈。这可能会使你的代码看起来更加一致,可是它也鼓励过分工程,而且不适合我前面描述的迭代方法。
在个人( C++ )引擎中,一些函数属于类,有些则不属于类。例如,游戏中的每一个敌人都是一个类,可能就像你预料的那样,大部分敌人的行为都是在这个类内部实现的。另外一方面,在个人引擎中投射的球体是经过调用 sphereCast() 函数来执行的,这是物理命名空间中的一个函数。 sphereCast() 不属于任何类 – 它只是物理模块的一部分。我构建了一个系统来管理模块之间的依赖关系,这使得个人代码组织得很好。将这个函数包装在一个任意的类中不会以任何有意义的方式改善代码的组织。
而后是动态调度,这是一种多态的形式。咱们常常须要为一个对象调用一个函数,而不知道该对象的确切类型。 C ++程序员的第一本能是用虚函数定义抽象基类,而后在派生类中重写这些函数。这是有效的,但这只是一种技术。还有其余动态调度技术,不会引入额外的代码,或带来其余好处:
(C ++ )11引入了std :: function,这是存储回调函数的一个简便方法。也能够编写本身的std :: function版本,这样在调试中不会那么痛苦。
许多回调函数能够用一对指针来实现:一个函数指针和一个类型不肯定的参数。它只须要在回调函数中进行明确的转换。你在纯C语言库中常常看到。
有时候,底层类型其实是在编译时已知的,你能够绑定这个函数调用而不用额外的运行开销。
Turf是我在游戏引擎中使用的一个库,它很是依赖这种技术。例如看到turf:: Mutex,这只是针对特定平台类的定义。
有时,最直接的方法是本身构建和维护一个原始函数指针表。我在个人音频混音器和序列化系统中使用了这种方法。Python解释器也大量使用这种技术,以下所述。
你甚至能够将函数指针存储在散列表中,使用函数名称做为关键字。我使用这种技术来调度输入事件,如多点触控事件。这是记录游戏输入并用重放系统回放的策略的一部分。
动态调度是一个很大的课题。我只是想代表,有不少方法来实现它。你编写的可扩展底层代码越多(这在游戏引擎中很常见),越会发现替代方法越多。若是你不习惯这种编程,C语言编写的Python解释器是一个很好的学习资源。它实现了一个强大的对象模型:每一个PyObject都指向一个PyTypeObject,每一个PyTypeObject都包含一个用于动态分配的函数指针表。若是你想直接跳转到其中的话,定义新类型的文档是一个很好的起点。
序列化是将运行时对象转换为字节序列的操做。换句话说,就是保存和加载数据。
对于许多游戏引擎来讲,游戏内容以各类可编辑的格式建立,例如.png,.json,.blend或专有格式,而后最终转换为特定于平台的能够快速加载到引擎的游戏格式。流水线中的最后一个应用一般被称为“炊具”。炊具可能被集成到另外一个工具,甚至分布在几台机器上。一般,炊具和一些工具是与游戏引擎自己一块儿开发和维护的。
在创建这样的流水线时,每一个阶段的文件格式的选择取决于你。你能够定义本身的一些文件格式,这些格式可能会随着添加引擎功能而变化。渐渐地可能会发现有必要保持某些程序与之前保存的文件兼容。无论什么格式,你最终都须要用C++来序列化它。
用(C ++)实现序列化有无数种方法。一个至关明显的方式是将加载和保存函数添加到要序列化的(C ++)类。能够经过在文件头中存储版本号来实现向后兼容,而后将这个数字传递给每一个加载函数。这是可行的,尽管这样代码可能维护起来比较繁琐。
void load(InStream& in, u32 fileVersion) { // 加载预期的成员变量 in >> m_position; in >> m_direction; // 仅当正在加载的文件版本是2或更大时才加载新的变量 if (fileVersion >= 2) { in >> m_velocity; } } void load(InStream& in, u32 fileVersion) { // 加载预期的成员变量 in >> m_position; in >> m_direction; // 仅当正在加载的文件版本是2或更大时才加载新的变量 if (fileVersion >= 2) { in >> m_velocity; } }
经过反射(特别是经过建立描述(C ++)类型布局的运行时数据),能够编写更灵活,不容易出错的序列化代码。想要快速了解反射如何进行序列化,请看一下开源项目Blender是如何实现的。
从源代码构建Blender时,有许多步骤。首先,编译并运行一个名为makesdna的自定义实用程序。该实用程序解析Blender源代码树中的一组C语言头文件,而后以SDNA的自定义格式输出全部C定义类型的汇总。这个SDNA数据做为反射数据,连接到Blender自己,并保存在Blender写入的每一个.blend文件中。从这一刻开始,每当Blender加载一个.blend文件,就会将.blend文件的SDNA与连接到当前版本的SDNA进行比较,并使用通用序列化代码来处理差别。这个策略使Blender具备使人印象深入的向前和向后兼容性。你仍然能够在最新版本的Blender中加载1.0版本的文件,也能够在旧版本中加载新的.blend文件。
像Blender同样,许多游戏引擎及其相关工具都会生成并使用本身的反射数据。有不少方法能够作到这一点:能够像Blender同样解析本身的(C / C ++)源代码来提取类型信息。你能够建立一个单独的数据描述语言,并编写一个工具来从该语言生成(C ++)类型定义和反射数据。可使用预处理器宏和(C ++)模板在运行时生成反射数据。一旦你有反射数据可用,有无数的方法来编写一个通用的序列化器。
显然,我省略了不少细节。在这篇文章中,我只想代表有不少不一样的方法来序列化数据,其中一些很是复杂。程序员不会像其余引擎系统那样讨论序列化,尽管大多数其余系统依赖于它。例如,在GDC 2017给出的96个程序设计讲座中,我数了一下,共有31次关于图形,11次关于在线,10次关于工具,4次关于AI,3关于物理模块,2关于音频的 – 但只有一个直接涉及到序列化。
至少,试着想想你的需求会有多复杂。若是你正在制做一个像Flappy Bird这样的小游戏,只有少数资源.,那么你可能不须要想太多的序列化。你能够直接从PNG加载纹理,这样很好处理。若是你须要一个向后兼容的紧凑的二进制格式,但不想本身开发,能够看看第三方库,好比Cereal或者Boost.Serialization。我不认为Google协议缓冲区是序列化游戏资产的理想选择,可是值得研究。
编写一个游戏引擎,即便是一个小游戏引擎,也是一个很大的任务。关于这个我能够说的还有不少,可是对于这个长度的帖子来讲,这真的是我认为最有用的建议:迭代地工做,抵制统一代码的冲动,而且知道序列化是一个大问题,你须要选择一个合适的策略。根据个人经验,若是忽视这些事情,每一件事情均可能成为一个绊脚石。
满满的自豪感,真的很想知道你们的想法,还请持续关注更新,更多干货和资料请直接联系我,也能够加群710520381,邀请码:柳猫,欢迎你们共同讨论