title: 从0开始手搓游戏引擎(一)
date: 2024-06-20 17:35:52
tags: [ 从0开始手搓游戏引擎, c++, 游戏引擎]
无关痛痒的一些废话
该系列是跟着知乎@文礼大佬的系列教程而写的,由于我本人实在才疏学浅,肯定会有错误的地方,希望写完之后我还能想起来吧。
由于想起来写这篇文章的时候,我已经速通了图形API部分,并且忘记每个section保存提交,所以这一篇可能会特别长,涵盖大佬一至五节的内容。
什么是游戏引擎
为了很方便的理解这个概念,我们不妨想象一下这么一个场景。
你处于
开始吧
小技巧
我推荐各位充分利用git的checkout功能,在每一章的开始都签出一个新的分支
这样做的好处有很多
一是就是当你回顾代码的时候就会发现自己的学习成果.
二是如果有不明白的地方,直接切换到对应的分支,随时回滚,各个分支的状态都是独立的.
在你的目录下创建一个名为Framework
的目录, 这里存放的就是与我们的引擎有关的所有代码。但是我们是通过C/C++来实现,所以要在这个目录下面再创建Common
与Interface
两个目录。Common
目录下存放的是引擎的核心代码,而Interface
目录下存放的则是我们提前定义的接口, 接口与实现分开放。这样做的好处就是当以后的代码文件变多了,项目仍然有个清晰的目录结构,起码其他人来阅读源码也会方便很多。
你可以直接用资源管理器做这些操作,当然你也可以用更GEEK一点的方式,在你准备好的目录里面打开终端,依次输入以下指令。
(由于我的Linux操作水平太低了,等我玩明白Linux之后再补Linux上的命令吧)
mkdir Framework
cd Framework
mkdir Common
mkdir Interface
完成之后你的目录结构应该是这样的:
你准备好的目录
└─Framework
├─Common
└─Interface
在Interface目录下创建文件Interface.h
, 内容如下:
// 定义alias,提高代码可读性
#pragma once
#define Interface class
#define implements public
这里就涉及到了两个知识,#pragma once
代表的是这个头文件在包含的时候只会加载编译一次.因为编译器处理include
的方式是直接将其在文件中展开,在项目体量变大之后,同一个文件被多次include
,而如果这个文件中又include
了其他的文件,那么就有可能会出现重复定义的情况,之后编译器就会报错,所以这种会被经常include
的文件最好加上这句,省的之后出现问题再来排查变得非常麻烦.
而定义了Interface
与implements
主要还是为了区分实例与实现, 像我这样三流水平的码农很长一段时间都没有真正理解下面这段话
接口(interface)
接口是一种规范或合同,它定义了一组方法,但不提供具体实现。接口的主要目的是规定一个类应该具备哪些行为,而不涉及这些行为的具体实现细节。接口中的方法默认是抽象的(在Java中明确声明为public abstract),这意味着它们没有实际的执行体。此外,接口也可以声明常量(默认为public static final),这些常量提供了接口使用者需要遵循的固定值或设定。
接口的作用在于促进多态性,允许不同的类按照相同的接口规范来实现,这样就可以用统一的方式处理不同类型的对象。一个类可以实现一个或多个接口,从而提供接口所要求行为的具体实现。
实现(implement)
实现指的是一个类按照接口所规定的规范,提供具体的方法实现。当一个类声明它实现了某个接口时,它就必须提供那个接口中所有抽象方法的实现。这确保了接口的每个要求在实现类中都有相应的代码来完成。
例如,假设有这样一个接口Animal,它定义了一个speak()方法。任何实现这个接口的类,如Dog和Cat,都需要提供自己版本的speak()方法,狗可能会实现为woof(),猫则是meow()。
(严格意义上来说,接口在C++中可以通过虚函数来实现,但是为了代码更清楚直观,我们选择用alias的方式)
(解释Runtime)
// 定义Runtime Module都应该支持的方法
#pragma once // 头文件在编译的时候只处理一次
#include "Interface.hpp"
namespace My {
Interface IRuntimeModule{
public:
// 虚析构函数,防止派生子类只调用基类的析构函数
virtual ~IRuntimeModule() {};
// 初始化
virtual int Initialize() = 0;
// 模块结束后执行
virtual void Finalize() = 0;
// 最小单位刻
virtual void Tick() = 0;
};
}
在这个Runtime的接口定义中,我们定义了四个要实现的行为.
~IRuntimeModule()
虚析构函数
虚析构函数
对于有其他虚函数的类,建议把析构函数也声明为virtual。这是因为如果不这么做,那么当使用基类指针释放派生类的实例的时候,可能导致只调用了基类的析构函数,从而产生memory leak的情况。
初始化
对于大部分系统来说,生命周期内肯定会有初始化与结束,所以这一块我的理解就是在主循环之前执行一次初始化,来准备好主循环所需要的东西.
Finalize
这个我就不知道该怎么翻译好了,这一部分看大佬的描述有点像一些oop语言里面的gc过程,在主循环结束之后进行清理善后工作.
刻
如果你们有Minecraft玩家的话,刻
这个概念应该会很熟悉.简单来说呢,我们可以把游戏引擎看作另一个世界的规则,在我们这个世界上,时间的最小单位是普朗克时间,也就是说,我们的世界是根据普朗克时间来流动的.我们身边发生的每一件事,都可以看作是一个个普朗克时间的结果,也就是由它推动着世界前进.那么到游戏引擎创建的世界中来,游戏世界中的各种物理、数学法则,都是在刻
这个过程中运算的,所以可以把这个函数看作是游戏引擎的最小单位.
既然我们已经定义好了Runtime的接口,接下来就应该到下一层应用层的定义了
还是在这个目录,创建IApplication.hpp
,文件内容如下:
#pragma once
#include "Interface.hpp"
#include "IRuntimeMoudule.hpp"
namespace My {
Interface IApplication : implements IRuntimeModule
{
public:
virtual int Initialize() = 0;
virtual void Finalize() = 0;
virtual void Tick() = 0;
// 这个接口用来查询App是否需要退出
virtual bool IsQuit() = 0;
};
}
由于我们的Application是Runtime的实例,所以要实现IRuntimeModule
。而且因为我们要手搓的游戏引擎需要实现跨平台的特性,而不同平台让Application退出的方式是不一样的,所以要在这里定义一个IsQuit()
来为退出预留出实现
至此我们已经完成了基本的接口定义,接下来就是具体的实现了,在Common
目录下创建BaseApplication.hpp
, 文件内容如下:
#pragma once
#include "IApplication.hpp"
namespace My {
class BaseApplication : implements IApplication
{
public:
virtual int Initialize();
virtual void Finalize();
// 主循环的一个周期 One cycle of the main loop
virtual void Tick();
virtual bool IsQuit();
protected:
// 标志着App是否需要退出主循环
bool m_bQuit;
};
}