title: 从0开始手搓游戏引擎(一)
date: 2024-06-20 17:35:52

tags: [ 从0开始手搓游戏引擎, c++, 游戏引擎]

无关痛痒的一些废话

该系列是跟着知乎@文礼大佬的系列教程而写的,由于我本人实在才疏学浅,肯定会有错误的地方,希望写完之后我还能想起来吧。

由于想起来写这篇文章的时候,我已经速通了图形API部分,并且忘记每个section保存提交,所以这一篇可能会特别长,涵盖大佬一至五节的内容。

什么是游戏引擎

为了很方便的理解这个概念,我们不妨想象一下这么一个场景。

你处于

开始吧

小技巧

我推荐各位充分利用git的checkout功能,在每一章的开始都签出一个新的分支

这样做的好处有很多

一是就是当你回顾代码的时候就会发现自己的学习成果.

二是如果有不明白的地方,直接切换到对应的分支,随时回滚,各个分支的状态都是独立的.

在你的目录下创建一个名为Framework的目录, 这里存放的就是与我们的引擎有关的所有代码。但是我们是通过C/C++来实现,所以要在这个目录下面再创建CommonInterface两个目录。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的文件最好加上这句,省的之后出现问题再来排查变得非常麻烦.

而定义了Interfaceimplements主要还是为了区分实例与实现, 像我这样三流水平的码农很长一段时间都没有真正理解下面这段话

接口(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;
    };
}

一个还在寻找自己的三流开发者