从零开始手敲次世代游戏引擎(五)

总体思路确定了,我们进入编码。首先搭个架子。(演示命令行为Linux。Windows大部分类似,小部分命令名字不同请自行置换)

确认我们目前所处的位置:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ git branch
* article_1
  master

新建一个branch用于保存本篇文章开发的内容:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ git checkout -b article_5
Switched to a new branch 'article_5'
[tim@iZ625ivhudwZ GameEngineFromScratch]$ git branch
  article_1
* article_5
  master

新建一个目录,用于存放框架。其中再建立一个Common子目录,用于存放各平台通用代码;建立一个Interface子目录,用于存放模块间接口代码:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ mkdir Framework
[tim@iZ625ivhudwZ GameEngineFromScratch]$ cd Framework
[tim@iZ625ivhudwZ Framework]$ mkdir Common
[tim@iZ625ivhudwZ Framework]$ mkdir Interface
[tim@iZ625ivhudwZ Framework]$ dir
Common  Interface

现在整个项目目录的结构如下:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ tree .
.
├── Framework
│   ├── Common
│   └── Interface
├── LICENSE
├── main.c
└── README.md

将main.c移动到Framework/Common之下。也就是说,我们准备在不同平台直接共用同一个程序入口:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ mv main.c Framework/Common/
[tim@iZ625ivhudwZ GameEngineFromScratch]$ tree .
.
├── Framework
│   ├── Common
│   │   └── main.c
│   └── Interface
├── LICENSE
└── README.md

3 directories, 3 files

在Framework/Interface之下新建Interface.hpp

[tim@iZ625ivhudwZ GameEngineFromScratch]$ vi Framework/Interface/Interface.hpp

通过宏定义定义几个alias,用以提高代码的可读性:

#pragma once

#define Interface class

#define implements public

在Framework/Interface执行新建IRuntimeModule.hpp

vi Framework/Interface/IRuntimeModule.hpp

在其中定义每个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;
        };

}

说明一下:

#pragma once

这是是声明这个头文件在编译的时候只需要处理一次。项目大了,同一个头文件可能会被多个源文件(.cpp文件)包含。由于编译器处理包含文件的方式是将其展开在源文件当中,所以如果不加这个条件编译指令,头文件的内容会在多个源文件里面多次展开,那么编译器就会报错,说同一个东西被多次定义。

这个条件编译指令对于很老的C/C++编译器来说是不认识的。如果遇到这种情况,就需要将其改成下面这种形式:

#ifndef __INTERFACE_H__
#define __INTERFACE_H__

<代码>

#endif // __INTERFACE_H__

效果是一样的。就是啰嗦一些。

       virtual ~IRuntimeModule() {};

虚函数的析构函数。因为是空函数,这个在Visual Studio里面不定义也是可以的。但是按照严格的C++标准,包括《C++ Primer》这本书里面的推荐做法,对于有其他虚函数的类,建议把析构函数也声明为virtual。这是因为如果不这么做,那么当使用基类指针释放派生类的实例的时候,可能导致只调用了基类的析构函数,从而产生memory leak的情况。在某些平台上,比如PSV,如果不定义这个虚析构函数,编译器会报Warning。

       virtual int Initialize() = 0;
        virtual void Finalize() = 0;

        virtual void Tick() = 0;

纯虚成员函数。定义为纯虚函数的目的是强制派生类实现这些方法。可以有效避免遗漏。

然后再说一下这3个函数(接口)的作用:

  1. Initialize(), 这是用来初始化模块的
  2. Finalize(),这是用来在模块结束的时候打扫战场的
  3. Tick(),这个是用来让驱动模块驱动该模块执行的。每调用一次,模块进行一个单位的处理

之所以要单独定义模块的初始化/反初始化函数,而不是在类的构造函数/析构函数里面完成这些工作,主要是有以下一些考虑:

  1. 在C/C++当中,全局变量(包括static变量)的初始化顺序是不可预知的(未定义的)。对于不同的平台,可能顺序不同。类当中的成员变量的初始化顺序也有类似的问题
  2. 有些模块我们可能只是想预加载到内存,后面再初始化。或者有些模块在流程当中可能出现临时不用的情况,想要释放相关的平台资源,但是想保留其状态,并不想将其从内存卸载。

接下来定义Application接口。这个接口用于抽象化不同平台的Application(并将其模块化),使得我们可以用同一个主入口(main.c)启动程序(也意味着我们可以使用同一套启动参数)

[tim@iZ625ivhudwZ GameEngineFromScratch]$ vi Framework/Interface/IApplication.hpp

内容如下:

#pragma once
#include "Interface.hpp"
#include "IRuntimeModule.hpp"

namespace My {
        Interface IApplication : implements IRuntimeModule
        {
        public:
                virtual int Initialize() = 0;
                virtual void Finalize() = 0;
                // One cycle of the main loop
                virtual void Tick() = 0;

                virtual bool IsQuit() = 0;
        };
}

可以看到它继承了我们刚才定义的IRuntimeModule,重载了IRuntimeModule的3个接口,另外增加了一个公共接口:IsQuit(),用于查询应用程序是否需要退出。这是因为,在很多平台上用户关闭应用程序都是通过系统通知过来的。我们的程序自身并不会直接进行这方面的判断。所以当我们收到这样的关闭通知的时候,我们就通过这个接口告诉主循环,我们该结束了。

可以看到这仍然是一个纯虚类。接下来我们可以直接从这个类派生出各个平台的Application类。但是实际上,各个平台的Application虽然有很多不同,共通点也是很多的。提高代码可维护性的一个重要做法,就是要避免同样的代码分散在不同的文件当中。否则很容易出现只改了一处而没有改其他的情况。

因此,我们在Framework/Common下面,新建两个文件,用来提供各平台共通的Application实现:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ touch Framework/Common/BaseApplication.{hpp,cpp}

大括号是Linux Bash情况下的一种Hack,就是小技巧,可以一次生成两个文件。如果是Windows,请分别生成这两个文件。

现在我们编辑这两个文件:

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:
                // Flag if need quit the main loop of the application
                bool m_bQuit;
        };
}

BaseApplication.cpp

#include "BaseApplication.hpp"

// Parse command line, read configuration, initialize all sub modules
int My::BaseApplication::Initialize()
{
        m_bQuit = false;

        return 0;
}


// Finalize all sub modules and clean up all runtime temporary files.
void My::BaseApplication::Finalize()
{
}


// One cycle of the main loop
void My::BaseApplication::Tick()
{
}

bool My::BaseApplication::IsQuit()
{
        return m_bQuit;
}

好了。这个类里面有一个受保护的变量m_bQuit,用于记录应用程序是否被通知退出。

最后让我们来修改我们的main.c。首先把它重新命名为main.cpp,因为我们用到了C++的特性,类。然后改写成下面这个样子:

#include <stdio.h>
#include "IApplication.hpp"

using namespace My;

namespace My {
        extern IApplication* g_pApp;
}

int main(int argc, char** argv) {
        int ret;

        if ((ret = g_pApp->Initialize()) != 0) {
                printf("App Initialize failed, will exit now.");
                return ret;
        }

        while (!g_pApp->IsQuit()) {
                g_pApp->Tick();
        }

        g_pApp->Finalize();

        return 0;
}

因为我们将不同平台的应用程序进行了抽象,所以我们的main函数不需要关心我们目前到底是工作在哪个平台。我们只需要通过IApplication接口提供的方法进行调用就可以了。

好了,一个基本的架子我们已经搭建好了。但是要让它跑起来之前,我们还需要做一些事情。什么事情?注意这一行:

namespace My {
        extern IApplication* g_pApp;
}

我们需要定义一个具体的Application实例。让我们新建一个Empty目录,代表一个特殊的平台(无平台),然后在里面写一个EmptyApplication.cpp, 来创建这个实例:

#include "BaseApplication.hpp"

namespace My {
        BaseApplication g_App;
        IApplication* g_pApp = &g_App;
}

好了,现在我们的项目差不多是这个样子:

[tim@iZ625ivhudwZ GameEngineFromScratch]$ tree
.
├── Empty
│   └── EmptyApplication.cpp
├── Framework
│   ├── Common
│   │   ├── BaseApplication.cpp
│   │   ├── BaseApplication.hpp
│   │   └── main.cpp
│   └── Interface
│       ├── IApplication.hpp
│       ├── Interface.hpp
│       └── IRuntimeModule.hpp
├── LICENSE
└── README.md

4 directories, 9 files

为了编译它,我们需要创建CMakeLists.txt。首先我们需要在项目根目录创建一个如下:

cmake_minimum_required (VERSION 3.1)
set (CMAKE_C_STANDARD 11)
set (CMAKE_CXX_STANDARD 11)
project (GameEngineFromScrath)
include_directories("${PROJECT_SOURCE_DIR}/Framework/Common")
include_directories("${PROJECT_SOURCE_DIR}/Framework/Interface")
add_subdirectory(Framework)
add_subdirectory(Empty)

注意前三行是因为我们的引擎后面会用到一些C/C++ 11的特性。现在删掉这三行也可以。

然后是Framework目录里面需要一个:

add_subdirectory(Common)

这个只是用来完成一个CMake的递归搜索

Framework/Common下面需要一个,用来建立Framework库:

add_library(Common
BaseApplication.cpp
main.cpp
)

最后就是Empty目录下面一个,用来建立Empty平台的最后的可执行文件:

add_executable(Empty EmptyApplication.cpp)
target_link_libraries(Empty Common)

好了,可以编译了。仍然是采用out of source tree的方式,退回到项目根目录,创建一个build目录,进入build目录,执行

cmake ..
make

就可以了。生成文件为build/Empty/Empty(.exe)

如果是Linux,也可以采用docker的方式进行编译。这也是我推荐的方式,可以有效避免在系统里安装一大堆开发用的包和工具。同样回到根目录,执行

[tim@iZ625ivhudwZ GameEngineFromScratch]$ docker run -it --rm -v $(pwd):/usr/src tim03/clang
bash-4.4# cd build
bash-4.4# cmake ../
-- The C compiler identification is GNU 6.3.0
-- The CXX compiler identification is GNU 6.3.0
-- Check for working C compiler: /usr/bin/gcc
-- Check for working C compiler: /usr/bin/gcc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /usr/src/build

bash-4.4# make
Scanning dependencies of target Common
[ 20%] Building CXX object Framework/Common/CMakeFiles/Common.dir/BaseApplication.cpp.o
[ 40%] Linking CXX static library libCommon.a
[ 60%] Built target Common
Scanning dependencies of target Empty
[ 80%] Building CXX object Empty/CMakeFiles/Empty.dir/EmptyApplication.cpp.o
[100%] Linking CXX executable Empty
[100%] Built target Empty
bash-4.4# Empty/Empty
^C

注意我们这个引擎目前正常情况下不会有任何输出,而且会死循环。按Ctrl+C退出。

发表评论

Fill in your details below or click an icon to log in:

WordPress.com 徽标

您正在使用您的 WordPress.com 账号评论。 登出 /  更改 )

Google photo

您正在使用您的 Google 账号评论。 登出 /  更改 )

Twitter picture

您正在使用您的 Twitter 账号评论。 登出 /  更改 )

Facebook photo

您正在使用您的 Facebook 账号评论。 登出 /  更改 )

Connecting to %s

%d 博主赞过: