在第一年编写游戏引擎:轻松!(几乎)

你好!我的名字叫Gleb Maryin,我在圣彼得堡HSE大学学习“应用数学和计算机科学”的第一年。在第二学期,我们程序中的所有新生都将使用C ++进行团队项目。我和我的队友决定编写一个游戏引擎。 

阅读我们从猫身上得到的东西。


团队中共有三个人:我,Alexei Luchinin和Ilya Onofriychuk。我们谁都不是游戏开发专家,更不用说创建游戏引擎了。这对我们来说是第一个大项目:在此之前,我们只做家庭作业和实验室工作,因此计算机图形学领域的专业人员不太可能在这里找到自己的新信息。如果我们的想法对那些也想创建自己的引擎的人有所帮助,我们将感到非常高兴。但是,这个主题是复杂且多方面的,并且本文决不声称是完整的专业文献。

其他对学习我们的实施感兴趣的人-请阅读!

平面艺术


第一个窗口,鼠标和键盘


为了创建窗口,处理鼠标和键盘输入,我们选择了SDL2库。这是一个随机选择,但到目前为止我们还没有后悔。 

在开始的第一阶段,很重要的一点是在库上编写一个方便的包装器,这样您就可以创建一个包含几行的窗口,并对其进行操作,例如移动光标和进入全屏模式并处理事件:击键,光标移动。任务并不困难:我们迅速制作了一个可以关闭和打开窗口的程序,当您单击RMB时,显示“ Hello,World!”。 

然后出现了主要的游戏周期:

Event ev;
bool running = true;
while (running):
	ev = pullEvent();
	for handler in handlers[ev.type]:
		handler.handleEvent(ev);

每个事件处理程序都附加了- handlers例如handlers[QUIT] = {QuitHandler()}他们的任务是处理相应的事件。QuitHandler在示例中,它将暴露running = false,从而停止游戏。

你好,世界


为了绘制引擎,我们使用OpenGLHello World一个,我认为,在许多项目中,是一个白色正方形的黑色背景: 

glBegin(GL_QUADS);
glVertex2f(-1.0f, 1.0f);
glVertex2f(1.0f, 1.0f);
glVertex2f(1.0f, -1.0f);
glVertex2f(-1.0f, -1.0f);
glEnd();


然后,我们学习了如何绘制二维多边形,并在单独的类中进行图形处理GraphicalObject2d,这些可以随旋转glRotate,随和glTranslate拉伸glScale我们使用设置四个通道的颜色glColor4f(r, g, b, a)

使用此功能,您已经可以制作出漂亮的正方形喷泉。创建一个ParticleSystem具有对象数组的在主循环的每次迭代中,粒子系统都会更新旧的正方形并收集一些新的正方形,它们以随机方向开始:



相机


下一步是编写可以移动并朝不同方向看的相机。要了解如何解决此问题,我们需要线性代数的知识。如果这对您来说不是很有趣,则可以跳过本节,查看gif,然后继续阅读

我们想在屏幕的坐标中绘制一个顶点,知道它相对于其所属对象中心的坐标。

  1. 首先,我们需要找到相对于对象所在的世界中心的坐标。
  2. 然后,在知道摄像机的坐标和位置的情况下,找到顶点在摄像机底部的位置。
  3. 然后将顶点投影到屏幕平面上。 

如您所见,共有三个阶段。与三个矩阵相乘对应于它们。我们称这些矩阵ModelViewProjection

让我们从获得世界基准的对象的坐标开始。一个对象可以完成三种转换:缩放,旋转和移动。通过将原始向量(基于对象的坐标)乘以相应的矩阵,可以指定所有这些操作。然后矩阵Model将如下所示: 

Model = Translate * Scale * Rotate. 

此外,知道摄像机的位置后,我们要确定其坐标:将先前获得的坐标乘以矩阵View在C ++中,可以使用以下函数方便地进行计算:


glm::mat4 View = glm::lookAt(cameraPosition, objectPosition, up);

从字面上看:objectPosition从一个位置看cameraPosition,向上方向是“向上”。为什么需要这个方向?想象一下拍摄茶壶。您将相机对准他,然后将水壶放在框架中。此时,您可以准确说出框架在顶部的位置(最可能是水壶有盖的位置)。该程序无法为我们找出如何布置框架,这就是为什么必须指定“向上”向量的原因。

我们在相机的基础上获得了坐标,剩下的就是将获得的坐标投影到相机的平面上。矩阵与此有关Projection,当从我们移除对象时,它会产生减少对象的效果。

要获得屏幕上顶点的坐标,您需要将向量乘以矩阵至少五次。所有矩阵的大小均为4 x 4,因此您必须执行许多乘法运算。我们不想为处理器核心加载许多简单的任务。为此,具有必要资源的视频卡更好。因此,您需要编写一个着色器:视频卡的一条小指令。OpenGL具有类似于C的特殊GLSL着色器语言,可以帮助我们实现这一目标。让我们不讨论编写着色器的详细信息,最好最后看看发生了什么:


说明:有十个正方形,彼此相距不远。在他们的右边是旋转和移动相机的玩家。 

物理


什么是没有物理学的游戏?为了处理物理交互,我们决定使用Box2d库,并创建了一个WorldObject2d继承自的类GraphicalObject2d。不幸的是,Box2d不能立即使用,因此勇敢的Ilya为b2Body和该库中的所有物理连接编写了包装。


在那一刻之前,我们一直认为要使引擎中的图形绝对是二维的,并且如果要决定添加灯光,请使用光线投射技术。但是我们手上有一台很棒的相机,可以显示所有三个维度的物体。因此,我们为所有二维对象增加了厚度-为什么不呢?另外,在将来,这将使您能够制作出非常漂亮的照明,从而不会留下厚实物体的阴影。

案件之间出现了照明。要创建它,必须编写用于绘制每个像素的适当指令-片段着色器。



贴图


我们使用DevIL库上传图像。每个都GraphicalObject2d适合作为该类的一个实例GraphicalPolygon-对象的前部分-和GraphicalEdge-侧面部分。在每个上,您都可以拉伸纹理。第一个结果:


图形所需的一切都准备就绪:绘图,一个光源和纹理。图形-仅此而已。

状态机,设置对象的行为


每个对象,无论它可能是什么-状态机中的状态,图形或物理状态-都必须“滴答”,也就是说,游戏循环的每次迭代都会更新。

可以更新的对象继承自我们创建的Behavior类。它具有的功能onStart, onActive, onStop可让您在启动时,生命周期和活动结束时覆盖继承人的行为。现在我们需要创建一个Activity从所有对象调用这些函数的最高对象。执行此操作的循环函数如下:

void loop():
    onAwake();
    awake = true;
    while (awake):
        onStart();
        running = true
        while (running):
            onActive();
        onStop();
    onDestroy();

现在running == true,有人可以调用一个函数pause(),做running = false。如果有人呼叫kill(),然后awake,并running转向false,和活动完全停止。

问题:我们想暂停一组对象,例如一个粒子系统和其中的粒子系统。在当前状态下,您需要手动调用onPause每个对象,这不是很方便。

解决方案:每个人Behavior都有一个subBehaviors要更新的数组,即:

void onStart():
	onStart() 		//     
	for sb in subBehaviors:
		sb.onStart()	//       Behavior
void onActive():
	onActive()
	for sb in subBehaviors:
		sb.onActive()

依此类推,针对每个功能。

但是,并非每种行为都可以通过这种方式设置。例如,如果一个敌人在平台上行走,那么该敌人很可能具有不同的状态:他站立idle_stay,在平台上行走而没有注意到我们idle_walk,并且随时可以注意到我们并进入攻击状态attack。我还想方便地设置状态之间转换的条件,例如:

bool isTransitionActivated(): 		//  idle_walk->attack
	return canSee(enemy);

所需的模式是状态机。我们还让她成为继承人Behavior,因为每次打勾时都需要检查是否已经到了切换状态的时间。这不仅对游戏中的对象有用。例如,Level这是一个state Level Switcher,控制器机器内部的转换是游戏中切换关卡的条件。

状态分为三个阶段:开始,滴答作响,停止。您可以在每个阶段添加一些动作,例如,将纹理附加到对象,对其施加脉冲,设置速度等等。

保护


我希望能够在编辑器中创建一个关卡,并且游戏本身应该能够从已保存的数据中加载关卡。因此,所有需要保存的对象都从class继承NamedStoredObject。它存储带有名称,类名的字符串,并具有dump()将有关对象的数据转储到字符串中的功能。  

要进行保存,只需dump()为每个对象覆盖即可。加载是从包含有关对象的所有信息的字符串构成的构造函数。当为每个对象创建这样的构造函数时,下载完成。 

实际上,游戏和编辑器几乎是同一类,只是在游戏中,级别是在读取模式下加载的,而在编辑器中是记录模式的。该引擎使用rapidjson库从json写入和读取对象。

图形用户界面


在某个时候,问题摆在我们面前:让图形,状态机以及所有其他东西都被编写。用户如何使用它编写游戏? 

在原始版本中,他将必须继承Game2d并重写onActive,并在类的字段中创建对象。但是在创建过程中,他看不到自己正在创建的内容,并且还需要编译程序并链接到我们的库。恐怖!会有好处-人们可能会问到人们可能想像到的如此复杂的行为:例如,移动这块土地与玩家的生活一样多,并且前提是天王星位于金牛座并且欧元不超过40卢布。但是,我们仍然决定制作图形界面。

在图形界面中,可以对一个对象执行的动作数量将受到限制:在动画幻灯片中翻页,施加力,设置一定的速度等等。状态机中的过渡情况相同。在大型引擎中,通过将当前程序与另一个程序链接来解决有限动作的问题-例如,Unity和Godot使用C#绑定。通过此脚本,您可以执行任何操作:查看天王星所在的星座以及当前的欧元汇率是多少。目前我们尚无此功能,但我们的计划包括将引擎与Python 3连接。

为了实现图形界面,我们决定使用Dear ImGui,因为它非常小(与著名的Qt相比)并且编写起来非常简单。ImGui-创建图形界面的范例。在其中,主循环的每次迭代,所有窗口小部件和窗口都仅在必要时重绘。一方面,这减少了消耗的内存量,但另一方面,它很可能花费比执行复杂功能创建和保存必要信息以进行后续绘制所需的时间更长的时间。仅保留实现用于创建和编辑的接口。

本文发布时的GUI如下所示:


关卡编辑器


状态机编辑器

结论


我们仅创建了您可以挂起更多有趣内容的基础。换句话说,还有增长的空间:您可以实现阴影渲染,可以创建多个光源,可以将引擎与Python 3解释器连接以编写游戏脚本。我想完善该界面:使其更美观,添加更多不同的对象,支持热键...

仍有很多工作要做,但我们对目前的状况感到满意。 

在创建项目的过程中,我们获得了很多不同的经验:使用图形,创建图形界面,使用json文件,大量C库的包装。还有编写团队中第一个大项目的经验。我们希望我们能够讲述它的有趣之处和处理它的有趣之处:)

链接到gihab项目:github.com/Glebanister/ample

All Articles