6.从零开始手敲次世代游戏引擎(初步框架)

总体思路确定了,我们进入编码。首先搭个架子。(演示命令行为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

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

这个条件编译指令对于很老的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;

            <span class="k">virtual</span> <span class="kt">bool</span> <span class="nf">IsQuit</span><span class="p">()</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="p">};</span>

}

可以看到它继承了我们刚才定义的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();

            <span class="k">virtual</span> <span class="kt">bool</span> <span class="nf">IsQuit</span><span class="p">();</span>

    <span class="k">protected</span><span class="o">:</span>
            <span class="c1">// Flag if need quit the main loop of the application</span>
            <span class="kt">bool</span> <span class="n">m_bQuit</span><span class="p">;</span>
    <span class="p">};</span>

}

BaseApplication.cpp

#include "BaseApplication.hpp"

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

    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>

}

// 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;

    <span class="k">if</span> <span class="p">((</span><span class="n">ret</span> <span class="o">=</span> <span class="n">g_pApp</span><span class="o">-&gt;</span><span class="n">Initialize</span><span class="p">())</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">printf</span><span class="p">(</span><span class="s">"App Initialize failed, will exit now."</span><span class="p">);</span>
            <span class="k">return</span> <span class="n">ret</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">while</span> <span class="p">(</span><span class="o">!</span><span class="n">g_pApp</span><span class="o">-&gt;</span><span class="n">IsQuit</span><span class="p">())</span> <span class="p">{</span>
            <span class="n">g_pApp</span><span class="o">-&gt;</span><span class="n">Tick</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="n">g_pApp</span><span class="o">-&gt;</span><span class="n">Finalize</span><span class="p">();</span>

    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>

}

因为我们将不同平台的应用程序进行了抽象,所以我们的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退出。

(– EOF –)



本作品采用知识共享署名 4.0 国际许可协议进行许可。

上一节(引擎概观)                                                                  下一节(图形API介绍)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值