重新建文件夹开始构建UtopiaEngine(2)

本篇序言

摸了两个月的鱼,又一次拾起了本身引擎的框架,开始完善引擎系统,若是非要用现实中的什么东西比喻的话,那么咱们目前实现的框架连个脚手架都不是。把这项目这样晾着显然不符合本人的风格,并且要做为毕业设计的东西可不能蒙混过关。因此如今成了既要准备研究生考试又要忙于设计框架并编码的状况,生活已经充实到必须得抽空来写blog了。ios

还有一件事,就是咱们的引擎如今的构建步骤可能要与我曾经参考的Cherno大佬的不一样了,其中一个缘由是由于他的game engine系列还在更新,对代码的修改也相比刚开始有很大区别,目前榛子引擎架构只有大致上与曾经视频中讲述的一致,具体代码实现有许多部分已经不一样了。这也给在下徒增了很多麻烦。固然,本人的引擎在后期也会变成这样,可能你会在很长一段时间后才看到这个系列博文,而此时我发布在GitHub和码云上的引擎源码或许已经彻底不一样(本系列博文在连载时并不会放出源码,因此若是你看的时候本系列还未更新结束,或许不用太担忧),这是不可避免的,不过文档的归档性至少比视频要好。还有一个缘由就是本人的英语听力能力实在太过生草以及Cherno本人后期的语速实在太快,表示已经看不下去。若是对各位形成不便还请理解。c++

这段时间发现了一本比较好的书,是关于游戏引擎的,是由企鹅的程东哲大佬写的《游戏引擎架构与实践》(暂时忽略企鹅家那些游戏烂到家的口碑,那些都是策划的锅,至少企鹅的技术人员仍是很强的),本引擎在后期的内存分配以及数据结构容器等部分可能会参考本书上的实现,各位也能够买来看看。git

好了,正文开始,精彩继续。数据结构

1.应用程序接口

咱们刚开始在引擎核心那里架设了入口点,但当咱们在应用程序(游戏或编辑器)项目中写入任何处理流程时咱们会发现引擎核心是并不会执行的。这很好解释,咱们的引擎核心并不知道咱们应用程序项目的存在,应用程序项目只是单向依赖引擎核心,而且更明显的缘由是咱们没法将应用程序项目中的处理步骤写入引擎核心的入口点的main函数里。强制性经过include来引入没人会知道发生什么事,恐怕只有编译器本身知道。架构

接下来就是解决方案,咱们如今来建立一个应用程序接口,其实接口这个说法并不怎么严谨,按照严格OOP规则,接口内是不容许有方法实现的,但C++在这方面并不怎么“守规矩”以及咱们的引擎核心有时也要实现其相关方法,但实在找不到个什么别的说法,因此就先勉强凑合一下。那么咱们就先在引擎核心类内部声明并定义一个应用程序接口BaseApplication类,声明与定义以下:框架

// BaseApplication.h(声明)
#pragma once
#include "Core.h"
namespace Utopia
{
    // 还记得我在上一篇文章中说过的内核规则么?
    // 这里为了将咱们这个应用程序接口暴露在dll外面,咱们能够对类声明也这样作
    // 在类名前加上已经定义好的ENGINE_API便可,条件编译会保证调用正确,你能够用本身上次定义的宏
    class ENGINE_API BaseApplication
    {
    public:
        BaseApplication();
        virtual ~BaseApplication();

        void ExcuteLoop();
        virtual void ExcuteCallback();
    private:
    };
}

// BaseApplication.cpp(定义)
#include "BaseApplication.h"
#include <iostream>
namespace Utopia
{
    BaseApplication :: BaseApplication() {
        // 构造函数定义,用来在这里进行引擎核心相关的初始化步骤
        // 好比渲染框架的初始化,log系统的初始化等
        std::cout << "BaseApplication default constructor.\n";
    }
    BaseApplication :: ~BaseApplication() {
        // 析构函数的定义,用来释放已经被引擎核心调用的相关资源
        std::cout << "BaseApplication default destructor.\n";
    }
    void BaseApplication :: ExcuteLoop()
    {
        while(true)
        {
            // 把渲染以及每帧消息处理相关代码放在这里
            // 鉴于目前并无开始渲染框架的构建,循环条件暂时以true代替,各位也能够随便编写一些条件测试一下
            // 但后续请记得删掉
            this->ExcuteCallback();
        }
    }
    void BaseApplication :: ExcuteCallback(){}
}

固然,老规矩,类名和命名空间名任君喜欢,但在后续调用中请记住它们的名字,以便调用。编辑器

这个时候呢,咱们已经建立了引擎的应用程序接口类,接下来就是要在应用程序内建立应用程序接口类实现了,在咱们的应用程序项目下新建一个.cpp文件便可,由于应用程序接口实现类是没有别的类会调用它的。声明与定义以下:函数

// Application.cpp(声明与定义)
#include "Engine.h"
#include <iostream>
class Application : public BaseApplication
{
public:
    Application();
    ~Application();
    
    void ExcuteCallback();
private:
};

Application :: Application()
{
    // 构造函数,用来初始化应用程序内的一些成员
    // 好比编辑器的UI框架,又或者是别的一些东西
    // 这里UI框架有些特殊,这里稍微剧透一下,本引擎打算使用的编辑器UI是著名的DearImGui
    // 但它的初始化过程必须在OpenGL相关API初始化并成功建立上下文以后,但这里不用担忧,
    // 因为程序在运行时会首先运行接口类的初始化过程,完成后才运行本实现类的初始化过程。
    std::cout << "Application default constructor.\n";
}
Appication :: ~Application()
{
    // 析构函数,用来释放资源
    std::cout << "Application default destructior.\n";
}
void Application :: ExcuteCallback()
{
    // 用来将应用程序中须要在渲染与消息处理循环中处理的东西放在这里
    // 想必各位应该已经发现了这个函数实际上是接口类BaseApplicaiton的一个虚函数,
    // 由于只有这样才可让接口类运行应用程序中的处理流程(虚函数可真是个好东西)
    std::cout << "Application ExcuteCallback() has called\n";
}

细心的同窗此时应该发现问题了,你的下一句即是:永乐,这里有点不对劲,即便已经声明了应用程序接口,但引擎核心仍是不知道应用程序中实现类的存在,那么咱们仍是没法在入口点运行,以下:工具

// EntryPoint.h
int main(int argc, char** argv)
{
    BaseApplication* ba = new Application(); // 这里即便支持里氏替换原则,但编译器并不知道这个Application是谁
    ba->ExcuteLoop();
    delete ba;
    std :: cin.get();
    return 0;
}

这里不用着急,咱们能够利用一个特性(Mojang:方块悬空不是bug,是特性!!!):即声明与定义能够在不一样的文件里面。咱们能够在BaseApplication的声明文件里面添加这样一个函数的声明,也就是这样:oop

namespace Utopia
{
    class ENGINE_API BaseApplication{ ··· };
    // 咱们在这里写上声明
    BaseApplication* ReturnAppInstance();
}

而咱们会在Application.cpp里面这样去实现:

Utopia :: BaseApplication* Utopia :: ReturnAppInstance()
{
    return new Application();
}

这下咱们就完成了一次“偷天换日”,咱们将寻找实现的工做交给编译器,接下来要作的就是接一杯摩卡坐在躺椅上慢慢享受缓慢的MSVC编译过程……固然不是,距离成功运行咱们还有些工做没作,那么接下来让咱们一块儿来看看。

首先,就是Engine.h中的问题,咱们虽然成功建立了应用程序接口,但咱们并无在Engine.h中包含应用程序接口的声明文件,以及咱们并未包含引擎规则。因此咱们会这样作:

#pragma once
#include "Engine.h"
#include "Core.h"
#include "BaseApplication.h"

以上就是目前Engine.h的彻底体。

接下来是处理入口点中的一些问题:既然咱们的入口点才是真正的执行体,那么咱们便要定义以下执行体:

#include "Core.h"
#include "BaseApplication.h"
#include <iostream>
// 关于这里为何要使用extern关键字:
// 编译器可没有IDE那么聪明直接进行跳转,因为编译器并未在同名.cpp文件内查找到相关函数声明
// 若是咱们不作些什么的话,那么编译器就将错就错认为咱们并未建立定义了,因此这时使用extern关键字
// 用来告诉编译器这个函数在别的地方已经定义过,让它扩大搜寻范围。
extern Utopia :: BaseApplication* Utopia :: ReturnAppInstance();
int main()
{
    BaseApplication* uBA = ReturnAppInstance();
    uBA->ExcuteLoop();
    delete uBA;
    std::cin.get();
    return 0;
}

这样便万无一失了,来按下f5键开始编译。最后运行结果应该是以下几句(前两句打印完后实际上是会再也不打印的,缘由是我为循环设的条件为true,这时为了显示下面两句(运行析构,强制性关闭并不会运行析构),能够考虑加入某些循环成立条件):

BaseApplication default constructor.
Application default constructor.
Application default destructor.
BaseApplication default destructor.

不知你们发现没有,BaseApplication的构造和析构流程将Application的执行流程“包裹”起来。这样也便成功达到咱们的目的:即先进行基础框架的初始化,再完成更高级模块的初始化,释放资源时正好相反。这样就能防止像Imgui初始化和释放资源时特殊状况了。

2. 日志系统

还记得我在上一篇文章说的日志系统么?此次就来填掉这个坑。这个部分是几乎全部应用程序都会有的一个子模块,好比CAD,模拟器(RPCS3,PPSSPP和PCX2等),以及你如今正在用的VS,各式各样的控制台程序等等……咱们的引擎固然也不能少,至少在编辑器中咱们是很是须要这个系统的,以及在游戏制做中的调试里咱们也有很大的须要。因此,接下来开始构建日志系统,不过别担忧,这个系统很简单,稍微一点点步骤就会完成。

2.1 spdlog

咱们如今先在解决方案文件夹里新建一个文件夹Vendor(小摊贩?不过也差很少,后续咱们引用的第三方库多起来的时候是否是就应该叫作Supermarket了?),专门在这个文件夹里放置各类第三方工具或代码。

咱们的并不会本身从头去写一个日志系统,咱们将采用一个第三方代码库:spdlog,这是一个调用很是简单,使用容易上手而且极其强大的专门的日志代码库,它默认有三种提示类型:error,warning,information,分别对应不一样的提示颜色,你能够增长类型并自定义颜色,并且你甚至能够不只让日志输出在控制台上,你也可让它输出在任何你想要的界面上,不过鉴于本人技术力太过生草以及本引擎的体量,使用默认的设置就足以完成咱们的需求。

前往GitHub去下载spdlog的源码(连接我就不放了,在GitHub搜索很容易就找到),记住,是下载源码,若是你的引擎项目添加了Git跟踪,你能够直接用git module命令扒取下来,这里不对这个命令作过多解释。下好源码后就能够将源码文件一股脑地全扔进Vendor文件夹里面。接下来请打开你的VS,咱们要对咱们引擎项目作些设置:

2.1.1 新建项目(模块)

注意,这里的“项目”并非指在引擎以外新建一个项目,而是VS解决方案中的“项目”,借此机会说明一下对应关系,其实咱们的引擎项目对应的是VS中的解决方案,而VS中的项目的概念对应的是咱们引擎项目中引擎模块的概念。正好就在这里进行严格规定,之后我会将VS的解决方案称为解决方案或者引擎项目,VS的项目咱们会称为引擎模块,以此来避免概念混淆。

在本系列的第一篇文章发出后,有同窗提出了反馈,说是新建项目用premake步骤仍是比较麻烦,但愿仍是可使用VS图形化界面来建立,本人想了一下以为也是比较可行的,一个缘由即是屡次引擎项目从新载入花的时间太长,尤为是在后期引擎模块增多了之后那更是缓慢,并且使用脚本并不必定每次都会考虑周到将项目所有设置完毕,模块的依赖项太多时此缺点极其明显,相似于“热编译”这种的仍是有些吃不消。因此接下来全部的项目构建过程本人都会采用VS自带的图形化界面建立,除了特殊之处须要说明外,其余步骤不放图。

首先在解决方案下新建一个新模块(VS选择“增长新建项目”),因为这个模块是专门为日志系统准备的,因此就起名叫作EngineLog便可,接下来在模块属性中添加附加目录,咱们能够用VS提供的宏定义来编写附加目录项。若是此时个人spdlog的路径是:

D:/Project/UtopiaEngine/Vendor/spdlog

那么咱们能够来这么写:

$(SolutionDir)Vendor\spdlog\include

这里$(SolutionDir)就是D:/Project/UtopiaEngine/路径的宏定义,这样就会在因为由于某些缘由更改引擎项目目录的状况时不用担忧得一条条更改依赖路径了。如下提供几个经常使用宏定义:

$(SolutionDir) // 解决方案路径
$(ProjectName) // 项目(模块)名称
$(Platform) // CPU平台名称,有x86,x64和arm三种
$(Configuration) // 项目属性,即Debug,Release,Dist等

接下来设置模块生成的二进制文件为“动态连接库(.dll)”,生成二进制文件的目录以及obj文件的目录和引擎核心与应用程序同步便可。(切记必定要将各个模块最终生成的二进制文件(.lib .dll .exe)均放在同一个文件夹内,premake5中的复制命令也能够完成,具体作法请参考上一篇)

2.1.2 编写

在继续以前请为应用程序和引擎核心模块添加依赖项,即将咱们的EngineLog做为它们的依赖项(即项目资源管理器中的依赖项以及模块属性中的附加包含目录均要添加),再而后为本模块新建一个文件夹src,代码文件均放在这里。完成此步骤以后,让咱们开始编写相关代码。首先呢,咱们须要和引擎核心同样规定内核规则,新建一个头文件LogLibDefine.h用来规定条件编译(固然不要忘记在模块属性的预处理器定义里面加上UTOPIA_LOG_DLLEXPORT哦):

#pragma once
#ifdef UTOPIA_LOG_DLLEXPORT
	#define LOG_API _declspec(dllexport)
#else
	#define LOG_API _declspec(dllimport)
#endif

接下来就是建立相关类的声明与定义了:

// EngineLog.h
#pragma once

#include <string>
#include <memory>
#include <spdlog\spdlog.h>

#include "LogLibDefine.h"

// 设置两个宏定义来指定我要使用的日志输出类型,分为引擎日志和应用程序日志两部分
// 引擎日志主要用在编辑器以及其余的开发环境中,应用程序日志主要用在游戏程序调试或编辑器的相关信息中。
#define UTOPIA_ENGINE_LOG 1
#define UTOPIA_APP_LOG 2

namespace Utopia
{
    class LOG_API EngineLog
    {
    public:
        // 关于这里我为何所有使用静态成员:
        // 因为日志系统的代码能够说几乎在引擎中的全部地方都会调用,若是使用非静态成员,那每次调用都要在相应类中
        // 设定一个日志类的成员对象,浪费了内存资源不说,可能还会形成不可必要的麻烦。
        // 其实关于这个还有一个更好的方法:将本模块转为静态库(.lib),这样便减小了模块调用之间的麻烦关系与限制。
        // 并且本模块并复杂,因此以静态库的形式在程序运行时就装载进内存对效率的影响影响不算大
        // 具体方法具体选择,你们能够尝试用静态库包装本模块。我目前在这里先使用动态库包装。
        static void LogInit();
        
        // 对参数解释一下:
        // 1. 类型是整型,用来存放我在上面的宏定义的,程序会根据宏定义的指定来选择日志输出方,便是引擎仍是应用程序
        // 2. 类型是字符串,这很好懂啊,你想让输出什么信息,那就把它传进这个字符串里就好
        static void ErrorLog(int _iLogType, string _sLogInfo);
        static void WarningLog(int _iLogType, string _sLogInfo);
        static void InfoLog(int _iLogType, string _sLogInfo);
    private:
        // 关于这里我为何使用智能指针:官方给的建议是这样,诶嘿
        // 但其实真实缘由也是由于智能指针真的太香了,尤为是对于这种静态成员来讲,我能够彻底不用关心什么时候进行释放。
        static std::shared_ptr<spdlog::logger> s_CoreLogger;
        static std::shared_ptr<spdlog::logger> s_ClientLogger;
    };
}

// EngineLog.cpp
#include "EngineLog.h"
#include <spdlog\sinks\stdout_color_sinks.h>
namespace Utopia
{
    // 因为是静态成员,因此须要在这里实现一下
    std::shared_ptr<spdlog::logger> EngineLog::s_CoreLogger;
    std::shared_ptr<spdlog::logger> EngineLog::s_ClientLogger;
    
    // spdlog初始化步骤
    void EngineLog::LogInit()
	{
        // 这里是对Log的格式进行设置,最终输出结果是:
        // [xx:xx:xx]Utopia/APP:日志消息
        // 其余格式你们能够参考spdlog的官方文档本身去编写一个格式
		spdlog::set_pattern("%^[%T] %n: %v%$");
		s_CoreLogger = spdlog::stdout_color_mt("Utopia");
		s_CoreLogger->set_level(spdlog::level::trace);

		s_ClientLogger = spdlog::stdout_color_mt("APP");
		s_ClientLogger->set_level(spdlog::level::trace);
	}

	void EngineLog::ErrorLog(int _iLogType, string _sLogInfo)
	{
		string s_logErrInfo = "Cannot find log type, please check your code. Origin information: ";
		switch (_iLogType)
		{
		case UTOPIA_ENGINE_LOG:
			s_CoreLogger.get()->error(_sLogInfo);
			break;
		case UTOPIA_APP_LOG:
			s_ClientLogger.get()->error(_sLogInfo);
			break;
		default:
			s_CoreLogger.get()->warn(s_logErrInfo + _sLogInfo);
			break;
		}
	}

	void EngineLog::WarningLog(int _iLogType, string _sLogInfo)
	{
		string s_logErrInfo = "Cannot find log type, please check your code. Origin information: ";
		switch (_iLogType)
		{
		case UTOPIA_ENGINE_LOG:
			s_CoreLogger.get()->warn(_sLogInfo);
			break;
		case UTOPIA_APP_LOG:
			s_ClientLogger.get()->warn(_sLogInfo);
			break;
		default:
			s_CoreLogger.get()->warn(s_logErrInfo + _sLogInfo);
			break;
		}
	}

	void EngineLog::InfoLog(int _iLogType, string _sLogInfo)
	{
		string s_logErrInfo = "Cannot find log type, please check your code. Origin information: ";
		switch (_iLogType)
		{
		case UTOPIA_ENGINE_LOG:
			s_CoreLogger.get()->info(_sLogInfo);
			break;
		case UTOPIA_APP_LOG:
			s_ClientLogger.get()->info(_sLogInfo);
			break;
		default:
			s_CoreLogger.get()->warn(s_logErrInfo + _sLogInfo);
			break;
		}
	}
 }

完成了以上工做后,咱们即可以开始下面的一步。

2.2 建立关联并部署进引擎

首先咱们并不但愿日志系统相关初始化步骤在每一个调用它的模块里都执行一遍,那岂不是太麻烦了,濒危内存保护协会会提出抗议的,因此咱们会让它在引擎核心老老实实地初始化后就不用再管其余的事情了。因为日志系统并非状态机系统,因此也便不须要上下文的获取与释放,这样就让咱们的行动更加灵活了。

老规矩,先为引擎核心建立相关模块依赖,两个依赖建立完成后,咱们还要为引擎核心也包含spdlog的路径,在这些前置工做都作完后,咱们即可以肆无忌惮地在引擎核心中调用其相关初始化方法,好比这样:

BaseApplication :: BaseApplication() {
    std::cout << "BaseApplication default constructor.\n";
    EngineLog :: LogInit();
}

当咱们想要调用的时候就不须要再次初始化即可直接在想要调用其方法的函数体里调用。固然,别忘了为调用日志系统的模块建立依赖以及附加包含目录。运行效果的话你们能够参考上一篇那里的截图,那个就是我用了spdlog所建立的日志系统

3. 本篇结语

你看,多简单,就只有简简单单的两步,咱们就建立了一个引擎的框架,其实目前看来这才算是一个应用程序框架,固然,距离游戏引擎框架还有必定的路要走,不过也不远了。再更上个三四回吧,咱们大概就能够出搭建一个既具备底层渲染框架,事件系统以及音效系统的较为完善的游戏引擎框架。哦,作一个预告,下次更新我会开始搭建底层渲染框架以及部署咱们引擎编辑器的UI底层。还请各位敬请期待。

知识共享许可协议

本做品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行过许可