译者 | 朱先忠,审校 | 孙淑娟,游戏开发一直很能激励学生学习高级计算机科学。可能有些人认为游戏是孩子们喜欢的,但对于标准的计算机科学课程来说,游戏开发其实是少数几个能利用当中所有知识的领域。,游戏开发涉及标准计算机科学课程中的诸多内容,根据游戏的性质,你可能还需要深入到更具体的专业,比如分布式系统或人机交互。游戏开发是一项严肃的工作,是助力计算机科学理念学习的有力工具。,本文将详细介绍使用C++创建一款简单游戏引擎所需的一些基本构建模块,解释游戏引擎所需的主要组成元素,并就如何从零开始编写游戏引擎给出一些个人建议。,不过,本文不是一个编程教程,因此也不会深入太多的技术细节或解释所有这些元素是如何通过代码粘合在一起的。如果你想找关于如何编写C++游戏引擎的综合性教程,可以先看看《用C++和Lua创建一个2D游戏引擎》。,概括地讲,游戏引擎是一套优化视频游戏开发的软件工具。这些引擎可以是小而极简型的,简单到只提供一个游戏循环和几个渲染功能;当然,也可以是大而全面型的,类似于IDE应用程序那种,开发人员可以用于编写脚本、调试、自定义关卡逻辑、人工智能、设计、发布、协作,并最终从头到尾构建游戏,而无需离开引擎。,游戏引擎和游戏框架通常向用户公开一组API。这些API允许程序员调用引擎函数,并像执行黑匣子一样执行艰难的任务。,为了真正理解这些API是如何工作的,让我们更具体地结合应用说明一下。例如,游戏引擎API公开一个名为“IsColliding()”的函数,开发者可以调用该函数来检查两个游戏对象是否发生碰撞,这种情况并不罕见。程序员不需要知道该函数是如何实现的,也不需要知道正确确定两个形状是否重叠所需的算法。就我们而言,IsColliding函数仅是一个黑匣子,根据这些对象是否相互碰撞,它会正确地返回true或false。下面是大多数游戏引擎向用户公开的一个功能示例。,大多数引擎都会抽象碰撞检测,并简单地将其作为真/假函数公开,除了编程API,游戏引擎的另一个重要职责是硬件抽象。例如,3D引擎通常构建在一个专用的图形API上,比如OpenGL、Vulkan或Direct3D。这些API为图形处理单元(GPU)提供了软件抽象。,说到硬件抽象,还有一些底层库(如DirectX、OpenAL和SDL),它们提供对许多其他硬件元素的抽象以及多平台访问。这些库帮助我们访问和处理键盘事件、鼠标移动、网络连接,甚至音频等各项功能。,在游戏行业的早期,游戏是使用定制的渲染引擎构建的。开发代码是为了从较慢的机器中尽可能多地提升部分系统性能。每个CPU周期都至关重要,因此代码重用或适用于多种场景的通用函数并不是开发人员能够负担得起的。,随着游戏和开发团队规模和复杂性扩展,大多数工作室最终都会在开发的多款游戏之间重用某些功能和子程序。工作室开发的内部引擎,基本上都是针对处理低级任务的内部文件和库的集合。这些功能允许开发团队的其他成员专注于游戏操作、地图创建和级别定制等高级细节。,一些流行的经典引擎包括id-Tech、Build和AGI等。这些引擎是为了帮助特定游戏的开发而创建的,它们允许团队的其他成员快速开发新的关卡,添加自定义资源,并动态定制地图。这些定制引擎也被用来为他们的原创游戏修改或创建扩展包。,Id Software软件公司(美国得克萨斯州的一家游戏软件公司)研发了id Tech技术。id Tech技术其实是一系列不同引擎的集合,其中每一个引擎的迭代时期都关联着一款不同的游戏。于是,开发人员通常会将id Tech 0描述为“Wolfenstein 3D引擎(Wolfenstein3D engine)”,将id Tech 1描述为“末日引擎(Doom engine)”,将id Tech 2描述为“雷神之锤引擎(Quake engine)”,等等。,Build是上世纪90年代游戏引擎历史中的另一个例子。它由肯·西尔弗曼(Ken Silverman)创建,旨在助力第一人称射击游戏定制。与id Tech的情况类似,Build随着时间而发展,它的不同版本曾经先后帮助程序员开发了《毁灭公爵3D》(Duke Nukem 3D)、《影子武士》(Shadow Warrior)和《血祭》(Blood)等游戏。这三个可以说是使用Build引擎开发的最受欢迎的游戏作品的代表,通常被称为“三巨头(The Big Three)”。,肯·西尔弗曼开发的Build引擎正在2D模式下编辑关卡时的情景,上世纪90年代游戏引擎的另一个例子是“疯狂大楼专用程序脚本创建开发工具(Script Creation Utility for Manic Mansion)”(SCUMM)。SCUMM是卢卡斯艺术公司(LucasArts)开发的一款引擎,它是许多经典点击式(Point-and-Click)游戏的基础,《猴岛小英雄》(Monkey Island)和《全速狂飙》(Full Throttle)这两款游戏就使用这个引擎。,《全速狂飙》游戏的对话框和运作都使用SCUMM脚本语言进行管理,随着机器的发展和功能的不断增强,游戏引擎也随之发展。现代引擎配备了功能丰富的工具,这些工具需要快速的处理器速度、惊人的内存量和专用显卡。,有了备用动力,现代游戏引擎可以用机器循环来换取更多的抽象性。这种权衡意味着,我们可以将现代游戏引擎视为通用工具,以较低的成本和较短的开发时间创建复杂的游戏。,这并不是一个陌生的问题,不同的游戏程序员也各有各的看法。其回答取决于开发的游戏的性质、业务需求以及其他待考虑因素的影响。,开发者可以使用现有许多免费、强大、专业的商业引擎来创建和部署自己的游戏。那么,既然存在这么多游戏引擎可供选择,为什么还会有人费心从头开始制作游戏引擎呢?,我曾经写了一篇博客文章《自己动手编写游戏引擎还是使用现成的?》(https://pikuma.com/blog/why-make-a-game-engine)来解释程序员从头开始制作游戏引擎的一些原因。在我看来,最主要的原因包括:,下文将继续讨论游戏引擎的一些组件,并指导读者自己编写一个游戏引擎。,开发核心引擎代码的编程语言是首要选择。原始汇编语言、C、C++,甚至C#、Java、Lua,还有JavaScript等高级语言都有用来开发引擎。,编写游戏引擎最流行的语言之一是C++。C++编程语言将速度与使用面向对象编程(OOP)和其他编程范式的能力结合起来,帮助开发人员组织和设计大型软件项目。,因为在我们开发游戏时,性能通常是非常重要的,而C++具有编译语言的优势。使用编译语言意味着最终的可执行文件将在目标机器的处理器上以本机方式运行。此外还有许多专用的C++库和开发工具包可用于大多数现代控制台,如PlayStation或XBox等。,开发者可以使用微软提供的C++库访问XBox控制器,在性能方面,我个人不推荐使用虚拟机、字节码或任何其他中间层的语言。除了C++之外,一些适合编写核心游戏引擎代码的替代品还包括Rust、Odin和Zig等语言。,本文将以C++编程语言构建一个简单的游戏引擎。,在MS-DOS等较旧的操作系统中,我们通常可以直接操作内存地址并访问映射到不同硬件组件的特殊位置。例如,我要用某种颜色“绘制”一个像素,所要做的就是加载一个特殊的内存地址,其中的数字代表VGA调色板的正确颜色,而显示驱动程序将该变化转换为物理像素,并将其转换到CRT显示器。,随着操作系统的发展,它们开始负责保护硬件免受程序员的攻击。现代操作系统将不允许代码修改操作系统为进程提供允许地址之外的内存位置。,例如,如果你使用的是Windows、macOS、Linux或BSD,则需要向操作系统请求在屏幕上绘制像素或与任何其他硬件组件对话的正确权限。即使是在操作系统桌面上打开窗口这样的简单任务也必须通过操作系统API来执行。,因此,运行进程、打开窗口、在屏幕上渲染图形、绘制窗口内的像素,甚至从键盘读取输入事件都是特定于操作系统的任务。,SDL(Simple DirectMedia Layer,即简易直控媒体层)是一个非常流行的库,它可以帮助实现多平台硬件抽象。在游戏开发课堂上时,通过SDL,我无需为Windows操作系统、macOS和使用Linux系统的学生创建三个不同版本的代码。SDL不仅是不同操作系统的桥梁,也是不同CPU体系结构(英特尔、ARM、苹果M1等)的桥梁。SDL库对底层硬件访问进行抽象,并“翻译”我们的代码以便在这些不同的平台上都能够正确工作。,下面是使用SDL在操作系统上打开窗口的一小段代码。为了简单起见,代码中没有添加处理错误的部分,但是下面的代码对于Windows、macOS、Linux、BSD,甚至RaspberryPi,都是通用的。,SDL只是我们可以用来实现这种多平台硬件访问的游戏库的一个例子。SDL是2D游戏和将现有代码移植到不同平台和控制台的热门选择方案之一。多平台库的另一个流行选择方案是GLFW,它主要用于3D游戏和3D引擎。GLFW库可以很好地与OpenGL和Vulkan等加速3D API进行通信。,至此,一旦操作系统方案解决,接下来我们就需要创建一个控制整个游戏的主循环。,简单地说,我们通常希望我们的游戏以每秒60帧的速度运行。根据游戏的不同,帧速率可能会有所不同,但为了让景物变得更加清晰,在胶片上拍摄的电影通常都是以每秒24帧的速度运行(每秒钟24幅图像从你眼前闪过)。,在游戏过程中,游戏循环会持续运行,在循环的每一次过程中,我们的引擎都需要运行一些重要的任务。传统的游戏循环必须确保:,但是,一个原始的C++循环对我们来说还不够。游戏循环必须与现实世界的时间有某种关系。毕竟,游戏中的敌人应该在任何机器上都是以相同的速度移动,而不管这些机器的CPU时钟速度如何。,控制这个帧速率并将其设置为固定的FPS数实际上是一个非常有趣的事情。它通常要求我们跟踪帧与帧之间的时差,并进行一些合理的计算,以确保我们的游戏以至少30 FPS的帧速率顺利运行。,很难想象一个游戏不从用户那里读取某种输入事件会是什么情景。所有这些输入事件可能来自键盘、鼠标、游戏板或虚拟现实设备。因此,我们必须在游戏循环中处理不同的输入事件。,要处理用户输入,我们必须请求访问硬件事件,这必须通过操作系统API执行。我们可以使用一些著名的多平台硬件抽象库(SDL、GLFW、SFML等)来处理用户输入。,例如,如果我们使用SDL就可以实现轮询事件,然后仅用几行代码就可以处理各种输入事件。,再强调一次,如果我们使用像SDL这样的跨平台库来处理输入,我们不必太担心特定于操作系统的实现。无论我们的目标平台是什么,C++代码都应该是相同的。,至此,我们已经有了一个可工作的游戏循环和一种处理用户输入的方法。接下来是时候开始考虑在内存中组织游戏对象了。,在设计游戏引擎时,我们需要设计数据结构来存储和访问游戏对象。,程序员在设计游戏引擎时会使用多种技术。一些引擎可能会使用简单的面向对象方法来处理类和继承,而其他引擎可能会将它们的对象组织为实体和组件。,如想学习更多关于算法和数据结构的知识,建议尝试实现这些数据结构。如果你使用的是C++,一个选项是使用STL(标准模板库),并利用它附带的许多数据结构(向量、列表、队列、堆栈、映射、集合等)。C++STL在很大程度上依赖于模板,因此这是一个练习使用模板的好机会,同时可以在实际项目中看到它们发挥的作用。,在阅读过一些游戏引擎架构的内容后,你会发现游戏使用的最流行的设计模式之一是基于实体和组件。实体组件设计将游戏场景中的对象组织为实体(Unity引擎中称之为“游戏对象”,而Unreal引擎中则称之为“角色”)和组件(我们可以添加或附加到实体的数据)。,要了解实体和组件是如何协同工作的,请考虑一个简单的游戏场景。此例中,实体将是我们的主要游戏玩家,还包括敌人、地板、投射物等,而组件将是我们“附加”到实体上的重要数据块,如位置、速度、刚体碰撞器等。,一种流行的游戏引擎设计模式是将游戏元素组织为实体和组件,我们可以选择附加到实体的一些组件,例如下面这些:,脚本组件:有时我们可以在实体上附加一个脚本组件,它可能是一个外部脚本文件(Lua、Python等),我们的引擎必须在后台解释和执行该文件。,上面给出的是一种非常流行的表示游戏对象和重要游戏数据的方法。如今,我们已经有了实体,然后我们就可以将这些不同的组件“插入”到实体中。,目前,市场上已有诸多书籍和文章探讨了实现实体组件设计的方式,以及在这个实现中应该使用什么样的数据结构。我们使用的数据结构和访问它们的方式对我们的游戏性能有直接的影响。开发人员经常会提到诸如面向数据的设计、实体组件系统(ECS)、数据局部性等想法,这些想法与我们的游戏数据在内存中的存储方式以及有效访问数据方式都有密切的关系。,在内存中表示和访问游戏对象可能是一个复杂的主题。根据我的经验,你可以手动编写一个简单的实体组件来实现,也可以简单地使用现有的第三方ECS库(Entity-Component-System,即“实体-组件-系统”的缩写。此模式遵循组合优于继承原则,游戏内的每一个基本单元都是一个实体,每个实体又由一个或多个组件构成,每个组件仅仅包含代表其特性的数据)。,当前市场上,有一些流行的现成的ECS库可供选用,我们可以将它们包含在C++项目中,开始创建实体和附加组件,而不必担心它们是如何在后台实现的。C++ ECS库的一些例子是EnTT和Flecs。,我个人建议那些认真对待编程的学生至少尝试一次手动实现一个非常简单的ECS。我的理由是,即使你的实现并不完美,从头开始编写ECS系统也会迫使你考虑底层数据结构以及相应的性能。,一旦完成了定制的临时ECS实现,我建议你只使用一些流行的第三方ECS库(EnTT、Flecs等),因为这些都是经过行业多年开发和测试的专业的游戏开发库,它们可能比我们自己从零开始编写的要好得多。,总之,一个专业的ECS很难仅凭个人力量从零开始实现。选择一个经过良好测试的第三方ECS库,并将其添加到游戏引擎代码中即可完成你的游戏作品。,游戏引擎的复杂性正在慢慢提升。前文已讨论了在内存中存储和访问游戏对象的方法,接下来我们需要讨论如何在屏幕上渲染对象的问题。,第一步是考虑用引擎创建的游戏的性质。我们创建的游戏引擎是否只用来开发2D游戏?如果是这样,我们需要考虑渲染精灵、纹理、管理层,并可能利用图形卡加速。2D游戏通常比3D游戏简单,因为2D数学比3D数学要简单得多。,如果目标是开发2D引擎,则可以使用SDL帮助进行多平台渲染。SDL抽象了加速的GPU硬件,可以解码和显示PNG图像,绘制精灵,并在我们的游戏窗口中渲染纹理。,如果目标是开发一个3D引擎,那么则需要定义将一些额外的3D信息(顶点、纹理、着色器等)发送到GPU的方式。如果你想使用对图形硬件软件抽象,最流行的选择方案就是OpenGL、Direct3D、Vulkan和Metal。当然,使用哪种API的决定可能取决于自身的目标平台。例如,Direct3D会为微软平台的应用程序提供支持,而Metal则只适合与苹果的产品配合使用。,3D应用程序通过图形管道处理3D数据。该管道将规定引擎必须如何向GPU发送图形信息(顶点、纹理坐标、法线等)。图形API和管道还将规定我们应该如何编写可编程着色器来变换和修改3D场景的顶点和像素。,可编程着色器规定GPU应如何处理和显示3D对象,每个顶点和每个像素(片段)可以有不同的脚本,它们分别用于控制反射、平滑度、颜色、透明度等。,至于3D对象和顶点,最好将读取和解码不同网格格式的任务委托给一些第三方库来实现。大多数第三方3D引擎都应该了解许多流行的3D模型格式,例如OBJ、Collada、FBX和DAE等一些文件格式。我的建议是首选.OBJ文件格式,因为有不少测试和支持力度良好的库可以用C++处理OBJ加载。这方面,TinyOBJLoader和AssImp就是许多游戏引擎使用的上佳选择。,当我们向引擎中添加实体时,我们可能还希望它们在场景中移动、旋转和反弹。,游戏引擎的这个子系统称为物理模拟。这可以手动创建,也可以从现有的现成的物理引擎导入。,在这里,我们还需要考虑想要模拟的物理类型。2D物理通常比3D简单,但物理模拟的底层部分对2D和3D引擎都非常相似。,如果你只想在项目中包含一个物理库,那么已有几个很好的物理引擎可供选择。对于2D物理引擎,我建议调研一下Box2D和Chipmunk2D这两款产品。对于专业且稳定的三维物理模拟引擎,品牌相当不错的包括PhysX和Bullet之类的库。如果物理稳定性和开发速度对项目至关重要,那么使用第三方物理引擎总是一个不错的选择。,BOX2D是一个非常受欢迎的物理库选项,可以与游戏引擎一起使用,你不需要编写一个完美的物理模拟,但要确保物体能够正确加速,并且不同类型的力可以应用到你的游戏物体上。一旦实现基本的物体移动效果,接下来你也可以继续考虑实现一些简单的碰撞检测和碰撞解析。,对于2D刚体物理可参考Box2D源代码和来自Erin Catto的介绍。如果想寻找一门关于游戏物理的综合课程可参看《从零开始编写2D游戏物理》)。,如果想学习3D物理以及物理模拟实现,可参阅大卫·埃伯里(David Eberly)编写的图书《游戏物理》(Game Physics)。,一提到Unity或Unreal等现代游戏引擎,我们会想到复杂的用户界面,其中包含许多面板、滑块、拖放选项和其他漂亮的UI元素,可以帮助用户自定义游戏场景。UI允许开发者添加和删除实体,动态更改组件值,并轻松修改游戏变量。,为了明确起见,我们讨论的是用于开发工具的游戏引擎UI,而不是我们向游戏用户显示的用户界面(如对话框屏幕和菜单)。,游戏引擎不一定需要嵌入编辑器,但由于游戏引擎通常用于提高生产力,友好的用户界面将帮助你和团队快速自定义游戏场景的关卡等其他方面。,对于初学者来说,从头开始开发UI框架可能是游戏引擎制作中最烦人的任务之一。你必须创建按钮、面板、对话框、滑块、单选按钮、管理颜色,还需要正确处理UI的事件并始终保持其状态。向引擎中添加UI工具会增加应用程序的复杂性,并给源代码管理带来大量麻烦。,如果你的目标是为引擎创建UI工具,那么我建议使用现有的第三方UI库,热门UI备选工具有Dear ImGui、Qt和Nuklear等。,Imgui是一个强大的UI库,被许多游戏引擎用作编辑工具,Dear ImGui是极佳的选择之一,它允许我们为引擎工具快速设置用户界面。ImGui项目使用了一种称为“即时模式UI”的设计模式,由于它通过利用加速的GPU渲染与3D应用程序进行良好的通信从而被广泛用于游戏引擎。,总之,如果你想在游戏引擎中添加UI工具,建议使用Dear ImGui。,随着我们游戏引擎的不断发展,一个常见的选择是使用简单的脚本语言实现游戏关卡定制。,想法很简单:我们在原生C++应用程序中嵌入了一种脚本语言,非专业程序员可以使用这种更简单的脚本语言编写实体行为、AI逻辑、动画和游戏的其他重要方面的脚本。,一些流行的游戏脚本语言有Lua、Wren、C#、Python和JavaScript等。所有这些语言的运行级别都比我们的原生C++代码高得多。无论谁使用脚本语言编写游戏行为脚本,都不需要担心内存管理或核心引擎如何工作的其他低级细节。他们要做的就是编写游戏中对应关卡的脚本,而我们的引擎知道如何解释脚本并在幕后执行艰难的任务。,Lua是一种快速、小型的脚本语言,可轻松与C/C++项目集成,我最喜欢的脚本语言是Lua。Lua体积小、速度快,非常容易与C和C++本机代码集成。此外,如果我使用Lua和“现代”C++,我喜欢使用一个名为Sol的包装库(https://github.com/ThePhD/sol2)。Sol库可帮助人们快速熟悉和使用Lua,并提供了许多辅助函数来改进传统的Lua C-API。,如果我们开发的游戏引擎中支持脚本编程的话,那么接下来可以开始在游戏引擎中讨论一些更高级的话题。脚本编程能够帮助定义人工智能逻辑、自定义动画帧和运动,以及其他不需要通过原生C++代码控制,而是可以通过外部脚本轻松管理的游戏行为。,接下来,还有一个需要为游戏引擎添加的支持元素是音频。,如果想要读写音频数据并发出声音,我们需要通过操作系统访问音频设备。再次声明,由于人们通常不想编写特定于操作系统的代码,我建议使用一个多平台库来抽象音频硬件访问。,SDL等多平台库就具有扩展功能,可帮助引擎处理音乐和音效等内容。,但是,我强烈建议只有在确定引擎的其他部分都已经能够正常协同工作之后,再考虑处理音频的问题。控制声音文件发音可能很容易实现,但如果我们过早地开始处理音频同步问题,即把音频与动画、事件和其他游戏元素联系起来共同考虑,事情往往会变得一团糟。,如果你真正是自己手工编写代码,那么由于多线程管理的复杂性,可能会导致音频处理起来变得很棘手。不过,如果你的目标是编写一个简单的游戏引擎,那么我可能更会借助一个专门的库来处理这一部分功能。,例如,你可以考虑将SDL_Mixer、SoLoud和FMOD等优秀的音频库和工具集成到自己开发的游戏引擎中。,《微型战场》(Tiny Combat Arena),这款游戏就使用FMOD声音库来实现多普勒和压缩等音频效果,最后一个子系统是人工智能。我们可以通过脚本来实现人工智能。这意味着,我们可以将人工智能逻辑委托给关卡设计师来编写脚本。另一个选择是,在我们的游戏引擎内核的本机代码中嵌入适当的人工智能系统。,在游戏中,人工智能用于对游戏对象产生响应性、适应性或类似智能的行为。大多数人工智能逻辑被添加到非玩家角色(NPC、敌人)中,以模拟类似人类的智能。,敌人是AI在游戏中应用的一个流行的例子。当敌人追逐地图上的物体时,游戏引擎可以通过路径搜索算法或有趣的模仿人类的行为来创建抽象效果。,伊恩·米林顿(Ian Millington)的《游戏人工智能》是一本关于游戏人工智能理论和实现技术的综合性书籍,值得参考。,在游戏引擎开发工作中,最困难的部分之一是大多数开发者都不会设定明确的界限,即有一种走不到“终点线”的感觉。换句话说,程序员会启动一个游戏引擎项目,渲染对象,添加实体,添加组件,但到最后突然发现一切都很糟糕。因此,如果他们不定义某种边界,就很容易不断添加越来越多的功能,而忽略了全局。如果这种情况发生,游戏引擎很有可能永远看不到曙光。,除了缺乏边界,当看到代码以闪电般的速度在眼前增长时,我们自己反倒很容易被淹没。游戏引擎项目的复杂性可能会迅速扩大,几周内你的C++项目可能会有几个依赖项,需要复杂的构建系统,而随着更多功能添加到引擎中,代码的整体可读性会不断下降。,因此,我的首要建议是,在编写实际游戏时,要始终坚持编写自己的游戏引擎。开始并完成游戏的第一次迭代时,脑海中要有一个真实的游戏。这将帮助你设定限制,并为你需要完成的工作指出一条清晰的路径。尽最大的努力坚持下去,而不是反复改变需求。,大多数学生在项目开始时都感到兴奋,但是随着时间的推移就开始出现焦虑情绪。如果我们从头开始创建一个游戏引擎,尤其是在使用像C++这样的复杂语言时,很容易不知所措失去动力。,因此,要学会及时享受某些阶段性的小胜利。例如,学会如何成功地在屏幕上显示PNG纹理,成功地发现了两个物体之间的碰撞等等。专注并理解基础知识一直都是非常重要的事情。,原文链接:https://hackernoon.com/build-a-game-engine-from-scratch-in-c,朱先忠,51CTO社区编辑,51CTO专家博客、讲师,潍坊一所高校计算机教师,自由编程界老兵一枚。早期专注各种微软技术(编著成ASP.NET AJX、Cocos 2d-X相关三本技术图书),近十多年投身于开源世界(熟悉流行全栈Web开发技术),了解基于OneNet/AliOS+Arduino/ESP32/树莓派等物联网开发技术与Scala+Hadoop+Spark+Flink等大数据开发技术。
© 版权声明
文章版权归作者所有,未经允许请勿转载。