希望:它是一个制作(互动)3D实时渲染应用程序的引擎。
本质是一个数据转换机器,基本上就是读取数据,然后引擎对数据进行某些处理。
我们要构建的是一种,读取文件、转换、然后展现到屏幕上的东西,且有交互能力。程序的具体行为是面向数据的,而非硬编码的。
我说的是,游戏引擎通常包含一个平台、一套工具,可以创建那些资产。它创造了一种我们提供一个(工具)集合的方式。
这样说的意思是,比如某个人在为游戏制作一个关卡,或者一个3D模型,或者纹理之类的,比如用 PS 做纹理、用 3D Studio Max 或者 Maya 或者 Blender 等制作3D模型,但是工作的最后他们仍然需要将那个3D模型转换为游戏引擎真正接受的格式。因为对于大多数正经的大型游戏引擎,几乎从来不会直接读取 png、jpeg 或者 obj 模型之类的。它们通常会读取游戏引擎特定的内建的自定义的格式。可能是纹理、模型、关卡等等,无关紧要。
所以通常来说,内容创作者有职责把模型从第三方格式转换为游戏引擎格式,比如要定义所有引擎需要的东西,要对那个模型做一些事情。因为通常来说,引擎可能需要更多从 Maya 导出的模型本身包含的数据。
为了让我们的数据转换更方便,我们需要大量的各种各样的系统,包括平台抽象层,能让我们的代码运行在不同的平台上。
因为一个人构建出 unreal 之类的肯定不现实。我们会聚焦于构建我们需要的最小的基础框架,先能够运行,然后我们会不停迭代、增强、增加更多特性、提高稳定性、更好的性能和优化,和其他之类的事情。我们不会一开始就尝试去写百分百完美的强大的可扩展的代码,因为那会花费我们一年的时间来最终获得一个窗口应用程序。
那么下面就来讨论一下一个游戏引擎需要些什么(只是看看要包括些什么,并不是实现的顺序)。
第一点要做的是需要一个入口点:Entry Point。一个入口点本质上就是当我们希望我们的应用程序或者游戏使用这个引擎启动的时候会发生什么。这是很多人会忽视的东西。比如是什么控制了 main 函数?什么控制了main函数的代码执行?是客户端控制的?或者游戏控制的?或者是某些东西,比如是引擎实际控制的?或者我们可能用宏实现,或者导入什么东西。
之后要有一个 Application Layout (应用层):处理程序的生命周期和事件之类的东西的那部分代码。比如说我们的主循环,也就是保持我们的程序运行和渲染的那个循环,也就是保持时间流动的那个循环。也就是执行(驱动)我们的游戏希望执行的全部代码的那个循环。关于事件比如改变窗口大小或者关闭窗口,或者输入事件,比如鼠标或者键盘,所有这些东西都要在应用层处理。我们需要一种方式来让我们的游戏或者引擎作为一个实际的应用程序运行在任何可能要运行的平台上,这就是应用层要解决的。
接下来需要一个 Window Layout (窗口层)。然后在这一层里面我们要处理 input (输入)和 event (事件)。由于输入可以放到事件里面(输入事件),所以我们的事件管理系统会处理输入事件,从我们的窗口和应用程序捕获输入事件。事件管理系统也会是非常重要的东西,我们需要构建一个非常基础的消息和广播系统。基础的意思是说只要,我们应用程序内的某些层可以订阅事件。并且当事件发生的时候,可以从应用层的堆栈获得通知。当事件传播到那些层的时候,那些层可以选择是否要把事件传播到哪些东西,就像一个所有者系统。某种程度上它就是一个基础的消息系统。当事件发生的时候,我们可以中断,或者不是真的中断。总之事件发生的时候,然后我们会得到通知,本质上就是因为我们的 onEvent() 函数会被调用。
然后是渲染器 Render ,可能会是我们需要处理的最大的系统之一。渲染器就是实际渲染图形到屏幕上的那个东西。
接下来是 Render API abstraction 。最开始我们只会用 OpenGL ,之后还需要各种 API 。我们要把所有的东西设置成API无关的,这样新增一种 API 就不是完全重写。显然有的东西不得不是API独特的,比如我们可以就写一个叫 “上载纹理” 的函数,然后为四种API各做一个实现不同,因为我们创建渲染器的方式会因为API而不同。因为如果你用Vulkan的方式,相比于使用OpenGL的方式做特定的事情的时候vulkan可能会更高效。因此我们仍然需要写两份代码,而不是把每个 OpenGL 的函数拷贝一份然后。。。
接着是 Debugging Support 调试支持。就比如日志系统,那可以是很简单的东西。还有比如性能,为此我们需要一种分析系统,我们希望应用程序能有一种潜在的方式,运行一种在 VisualStudio 设置之外的特殊的模式。我们希望应用程序能够自己运行调试,因此可以运行在任何平台上。而不用担心,运行特定平台可用的特定工具。我们希望把工具代码插入到自己的代码中,但是(工具代码)只在调试模式下运行。可以为每个函数计时并且汇总成好看的视图,任何也许用某种工具查看之类的。
然后还希望有一些 Scripting 脚本,比如 Lua 或者 C# 或者 Python 之类的。需要脚本语言,避免完全使用 C++。我们可以像艺术家和内容创造者那样轻松地写高级脚本语言而不用担心内存的问题。
还有 Memory System 内存系统。需要管理好资源。以及调试内存之类的。
还需要实体组件系统(ECS,Entity Component System)。这是一种让我们能在世界创建游戏对象的模块化的方式。比如让世界里的每个独立实体或游戏对象能包含特定的组件或系统,因此我们能定义行为以及动作的具体细节。它就是一种让我们能定义引擎要对实体做的事情的方式。
还需要 Physics Solution 物理解算。
还有 File I/O ,VFS(虚拟文件系统)
还有Build System 构建系统,把3D模型或材质需要能转换为自定义格式,那是为我们的引擎优化的格式。所以不用在运行时浪费时间转换格式,因为我们可以离线处理。热交换资产,希望比如开着 PS 在纹理上画了一些东西,按下 Ctrl + S,我们希望在运行时构建系统捕获然后实时地重新构建导入游戏,所以我们能更新东西,甚至比如3D模型,调整一些顶点或者进行某种修改,然后就热交换到引擎里。所以我们能在游戏运行时修改,这不是一个非常重要的系统,现在可能不值得讨论。
不过暂时我们只支持Windows只支持OpenGL。我们的C++代码文件里不会包含任何Windows代码,比如 Win32 API 代码。因为显然引擎要在未来支持其他的平台。所以会抽象那些东西,保证平台或渲染API独特的代码分散在它自己的文件里。在其他平台或者渲染API的时候它们不会被编译。
这一节我们要设置好所有东西:Github仓库、Visual Studio 解决方案和项目、依赖配置,然后我们要链接项目到一期,做一个屏幕上打印 Hello World 的简单程序。但是会为我们的游戏引擎组织好结构和配置。
GitHub上建立好工程,选择 Apache-2.0 License ,具体选什么 License 可以看下面这个图:
然后在 VS 中创建好工程,并且 git clone 一下:(这里因为文件夹非空会有冲突,所以我们先创建到 Git 文件夹中,但是关心的是里头的内容,然后选择复制粘贴出来)
这里我们把引擎选择编译成一个 DLL ,然后外部链接上最终的 exe ,选择 DLL 的原因是我们可以自己选择加载或者卸载 DLL ,主要的原因是我们会有很多依赖,我们会需要链接很多库到引擎里。如果用静态库的话,我们的所有的静态库都会被链接到游戏里面,我们的引擎依赖那些库,如果使用静态库的话,那些需要链接到引擎的库文件实际上全部会链接到游戏。用DLL的话基本上就像一个exe文件,所以我们可以把所有东西都链接到那个DLL,然后我们的游戏只会依赖那一个单独的包含了全部需要的内容的DLL文件,而不是无数的其他的库文件。
所以本质上我们要做的,就是把所有的依赖都链接到那个引擎的DLL文件,这意味着它们都需要是静态库。我们需要做的就是,把所有静态库链接到引擎DLL然后把引擎DLL链接到游戏。
我们不希望管 x86 32位的平台,所以直接remove掉:
然后我们这样配置 VS :
output 为:$(SolutionDir)bin\$(Configuration)-$(Platform)\$(ProjectName)\
intermediate 为:$(SolutionDir)bin-int\$(Configuration)-$(Platform)\$(ProjectName)\
这样输出的 HEngine.dll 就会在 \bin\debug-x64\HEngine\HEngine.dll 了。中间文件这样设置是想不放在 bin 文件夹中,这样我们就能直接复制粘贴 bin 文件夹而不要中间文件了。
然后 Solution -> Add -> New Proj :
然后新的Proj一样的设置:
接着我们关闭 Solution,在文件夹中查看这个 sln 文件:
打开的话源码会是这样的:
我们改一下顺序,把 Sandbox 提前:
这样别人查看的时候 Sandbox 就会自动成为启动项目,而不是 HEngine 了。
而在我们的文件中有 .vs 来记录一些用户的设置,比如谁是启动文件之类的:
然后 Sandbox 添加 reference :
而 VS 在编译 dll 文件时,会一起生成 dll 和 lib,lib 文件会包含所有那个dll文件导出的函数,因此我们就不需要手动从dll文件加载函数或者符号了。同时也会生成包含实际机器码和其他链接内容的dll文件。因此我们可以静态链接 HEngine.lib 但是运行时需要dll文件。
但是现在我们仍然不能直接运行,因为还没设置dll的查找路径,所以我们手动贴一下(把HEngine.dll帖到下面这里来):
上一节我们的 Sandbox 的项目的 Application.cpp是这样写的:
namespace HEngine
{
__declspec(dllimport) void Print();
}
void main()
{
HEngine::Print();
}
这意味着 main 函数是由 Application 定义的,但是引擎的入口应该由引擎负责定义,我们希望这些是由引擎端控制的。我们要在 Sandbox 项目里创建一个 Application 类,来定义和启动我们的应用程序。我们还要把 __declspec(dllimport)
和 __declspec(dllexport)
写到宏里,然后我们可以重用头文件。
同时最终我们再手动写一个 gitignore:
日志就是我们记录事件的一种方式。这里的事件不只是字面意义,因为事件可以是任何东西。
我们要写一个日志库。日志最大的议题是格式化不同的类型。只是打印一个字符串很简单,但是我们希望能打印的不只是文本,所以需要一种好的格式化方式,不定参的格式化。
总之,我们要使用一个叫 spdlog 的库:https://github.com/gabime/spdlog
C++没有定义导入和使用库的方法。很多时候基本上就是选择一个你想用的构建系统,比如 CMake 或者 Premake 之类的。然后保证你使用的每个库写到构建系统里。像这样你可以更新和维护它。或者用 git-submodule 添加它们,如果你用 GitHub 就可以用 git-submodule ,这可能是最好的方式。
我们要做的就是添加一个 .gitmodules 文件,然后我们实际克隆 HEngine 的时候,也会克隆所有 submodule 。这很有用,因为可以持有一个版本的完整代码。
我们在命令行里输入:$ git submodule add https://github.com/gabime/spdlog HEngine/vendor/spdlog
同时也会自动帮我们生成一个 .gitmodules 文件:
但是毕竟是我们的引擎,我们希望 HEngine::Log 而不是 spdlog::log,于是我们在引擎中创建了一个Log类。我们要做的是创建两个控制台,一个客户的,一个引擎的,一个叫 Core,一个叫 App。
本期会讨论CMake之类的构建问题。首先是为什么需要项目生成,而不是直接VS呢?主要就是不同平台的问题了。
我们要用的是 Premake:https://github.com/premake/premake-core/releases
不用CMake是Cherno个人不喜欢。Premake是用lua来写,而且用起来很简单。
然后我们写 premake5.lua :
这里release未必是发行版本,Dist才是完全的发行版本;release就是一个更快的Debug比如去掉一些日志啥的来测试。
这里两个星号意思是递归搜索文件夹、子文件夹
我们可以使用过滤器,即平台又配置可以这样:
最后我们用命令行生成:vendor\bin\premake\premake5.exe vs2019
这一节要写一个事件系统,从而可以处理收到的窗口事件等,比如窗口关闭、改变大小、输入事件等等。
我们不希望 Application 依赖 window,window 类应当完全不知晓 Application,而Application要创建window 。所以我们需要创建一种方法,可以把所有事件发送回到 App,然后 Application 可以处理它们。当窗口中发生了一个事件,window 类会收到一个事件回调,然后它要构造一个 HEngine 事件,然后用某种方法传给 App。
当 App 创建了一个 window 类的时候,同时给 window 类设置一个事件回调,所以每当窗口得到一个事件,它检查回调现在是否为 null ,如果不是 null,就用这些事件数据调用回调。然后 App 会有一个函数叫 onEvent() 接受一个事件的引用,会从 window 调用这个函数。
这些一般被称为阻塞事件,因为当我们处理这些鼠标按下事件的时候,可能直接在栈上构造事件,然后立即调用回调函数。所以当我们处理这个事件的时候,会暂停所有其他事件。因此称为阻塞事件。未来可以创建带缓冲的事件,基本上就是捕获这些信息,在某个地方队列存储,不阻塞其他事件,然后可能每帧遍历事件队列。然后调度和处理它们,而不是在收到事件时立即处理。
对于KeyEvent,第一次是按下事件,之后都是重复事件。
这里我们的公共抽象基类 Event 有这些接口:
virtual EventType GetEventType() const = 0;
virtual const char* GetName() const = 0;
virtual int GetCategoryFlags() const = 0;
virtual std::string ToString() const { return GetName(); }
然后我们通过两个宏来实现:
#define EVENT_CLASS_TYPE(type) static EventType GetStaticType() { return EventType::##type; }\
virtual EventType GetEventType() const override { return GetStaticType(); }\
virtual const char* GetName() const override { return #type; }
#define EVENT_CLASS_CATEGORY(category) virtual int GetCategoryFlags() const override { return category; }
GetStaticType 是想直接通过 Event 名字就获取到它的类型,又搞了一个成员函数 GetEventType,主要是想通过 Event 实现多态的目的,后面的 GetName 就是调试的目的了。
VS 预编译头需要一个 include 预编译头文件的源文件,而 GCC 和 Clang 则不需要。
premake5.lua 只需要新增这两行:
pchheader "hepch.h"
pchsource "HEngine/src/hepch.cpp"
其中 pchsource 只有在为 vs 才会需要,相当于 vs 中的use pch和create pch
当目前为止我们起码有了 日志 和 事件 系统,在 Cherno 看来这是创建窗口之前必须的。
我们之前说过 glfw ,一个跨平台的库,然而可能最终我们还是要上 win32 api,因为glfw不能支持dx,然而dx是由Windows开发,在Windows上运行时显然会比其他 API 都要好。
所以此时我们必须思考要如何进行抽象。Cherno 的选择是,为每个平台实现一个窗口类。(当然 glfw 已经是一种平台抽象的方式了,然而未来在 windows 我们要切换到使用 win32 api)
基本上平台抽象我们要建一个 Platform 文件夹,然后在其中再建一个 Windows 文件夹之类的,之后可能还会加 Linux、Mac 之类的,在 Platform 文件夹中也会放渲染 API 独特的代码,比如 OpenGL、DirectX、Vulkan、Metal 之类的东西也会收在里面。
我们甚至可能会有一个 POSIX 文件夹,放 Linux、Mac、Android 相关的相同的代码。
git submodule add https://github.com/glfw/glfw HEngine/vendor/GLFW
但是在这里我们的 GLFW 也需要构建成一个 project,于是我们 fork 一下原 GLFW 仓库,然后加一个 premake5.lua
关于删去 gitsubmodule 的内容:https://stackoverflow.com/questions/12898278/issue-with-adding-common-code-as-git-submodule-already-exists-in-the-index
这里事件回调有点晕,梳理一下:
首先是有一个抽象接口 Window 类,我们写在 src \ HEngine \ Window.h 中,其中有一个函数 static Window* Create(const WindowProps& props = WindowProps());
,用来让各个平台予以实现,其中 WindowProps 是一个包含窗口属性(window properties)的结构体:
struct WindowProps
{
std::string Title;
unsigned int Width;
unsigned int Height;
WindowProps(const std::string& title = "HEngine",
unsigned int width = 1280,
unsigned int height = 720)
: Title(title), Width(width), Height(height)
{
}
};
于是乎,在我们的框架中,Application 类中加一个成员:std::unique_ptr
,那么在它的构造函数中我们只需要调用Create方法就好了:
Application::Application()
{
m_Window = std::unique_ptr<Window>(Window::Create());
m_Window->SetEventCallback(HE_BIND_EVENT_FN(Application::OnEvent));
}
注意,我们之前的 Create 方法只是在 Window 类中声明了,但是其真正链接的时候是在 WindowsWindow.cpp (位于 src \ Platform \ Windows)中的:
Window* Window::Create(const WindowProps& props)
{
return new WindowsWindow(props);
}
我想在之后我们会用宏去判断,应该是链接到那个平台的实现。现在由于只有Windows平台,就直接写了。
以上是窗口的创建,那么事件是如何回调的呢?
在void WindowsWindow::Init(const WindowProps& props)
方法中,首先是要glfw的窗口初始化,这里我们使用了:
glfwSetWindowUserPointer(m_Window, &m_Data);
而 m_Window 是由函数 glfwCreateWindow 创建的 GLFWwindow 窗口,m_Data 则是 WindowsWindow 类中的成员,类型为我们自定义的结构体:
struct WindowData
{
std::string Title;
unsigned int Width, Height;
bool VSync;
EventCallbackFn EventCallback;
};
这一行其实就是让窗口内部的userPointer指向我们的m_Data:window->userPointer = pointer;
,那么之后我们就可以使用代码:
WindowData& data = *(WindowData*)glfwGetWindowUserPointer(window);
通过上面这个代码,我们就可以直接从窗口拿到我们自定义的窗口数据,此为回调的基础。
注意我们的窗口数据有一个成员:EventCallbackFn EventCallback;
,此为回调函数:using EventCallbackFn = std::function
而要对事件处理,首先自然有一个事件的抽象基类 Event ,其对应的处理类为 EventDispatcher :
class EventDispatcher
{
template<typename T>
using EventFn = std::function<bool(T&)>;
public:
EventDispatcher(Event& event)
: m_Event(event)
{
}
template<typename T>
bool Dispatch(EventFn<T> func)
{
if (m_Event.GetEventType() == T::GetStaticType())
{
m_Event.m_Handled = func(*(T*)&m_Event);
return true;
}
return false;
}
private:
Event& m_Event;
};
如上,其中有成员 Event,还有对应的处理函数 Dispatch,Dispatch 接收一个函数,然后在内部进行处理(先判断一下这个函数是不是处理咱们的内部事件的)。这里我们直接转换为模板参数T的类型,因为我们在if中已经判断好了,就不用较慢的 dynamic_cast 了。
那么各种事件是如何进行交互回调的呢?
比如我们鼠标移动,要在控制台打印,它会这样做到:
void WindowsWindow::OnUpdate()
{
glfwPollEvents();
glfwSwapBuffers(m_Window);
}
glfwSetCursorPosCallback
:glfwSetCursorPosCallback(m_Window, [](GLFWwindow* window, double xPos, double yPos)
{
WindowData& data = *(WindowData*)glfwGetWindowUserPointer(window);
MouseMovedEvent event((float)xPos, (float)yPos);
data.EventCallback(event);
});
data.EventCallback(event);
,而 data 是我们之前所述的,通过 window 的 UserPointer 获取得到的,是我们自定义的类型 WindowData,内部有回调函数 EventCallback(通过 std::function
封在闭包内),从而通过这个回调函数来进行事件的处理。Application::Application()
{
m_Window = std::unique_ptr<Window>(Window::Create());
m_Window->SetEventCallback(HE_BIND_EVENT_FN(Application::OnEvent));
}
void Application::OnEvent(Event& e)
{
EventDispatcher dispatcher(e);
dispatcher.Dispatch<WindowCloseEvent>(HE_BIND_EVENT_FN(Application::OnWindowClose));
HE_CORE_TRACE("{0}", e);
}
可以看到,这个函数的最后会调用 HE_CORE_TRACE("{0}", e);
,这是我们引擎中日志打印的方法。能够对事件生效是因为我们写了如下重载:
inline std::ostream& operator<<(std::ostream& os, const Event& e)
{
return os << e.ToString();
}
而 ToString 则是一个多态函数,每个我们写的 Event 事件都会 override 实现这个函数。
同时我们发现,OnEvent 中还有一个 EventDispatcher 来对事件进行特定的处理,比如我们想实现关闭窗口,对应我们写的事件为 WindowCloseEvent,并且写下对应的处理函数:
bool Application::OnWindowClose(WindowCloseEvent& e)
{
m_Running = false;
return true;
}
这里的 HE_BIND_EVENT_FN 实现为 #define HE_BIND_EVENT_FN(fn) std::bind(&fn, this, std::placeholders::_1)
,所以这一行实际上是把 OnWindowClose 先绑定好 this 指针,剩下一个参数就是 WindowCloseEvent,这个参数需要与 Dispatch 的模板参数对应,然后就会通过这个传入的函数去执行事件的处理了。
以上便是整个事件系统的实现。
我们现在是有一个 App 框架,里头的 Run 会不断进行游戏循环。
那么这一讲的 Layer 层系统,我们希望在进行游戏循环的时候,每个被启用的层(Layer)都会按照层栈顺序更新。通过这种更新循环,可以在层上进行渲染。显然因为层栈是有顺序的,这很重要。意味着你可以把层放在其他层的上面,这会决定绘制顺序。层栈对于构建、覆层系统也很有用。覆层能让你把层推至层栈的后半部分。
基本上就是说,我们有一个连续的层列表,但是覆层总会在列表的最后,因此总在最后渲染。
但是在进行事件的时候,比如角色开枪,其点击了一下屏幕的按钮,我们希望这个事件直接被按钮处理了,而不是传播到之后的开枪处理中。因此我们要有正向遍历列表和反向遍历列表——正向遍历列表来渲染、更新等等,然后反向遍历来处理事件。 反向的意思是说从最上层开始向下处理,而不是像渲染一样自底向上。
这里我们的 Layer 是这样的:
class HENGINE_API Layer
{
public:
Layer(const std::string& name = "Layer");
virtual ~Layer();
virtual void OnAttach() {}
virtual void OnDetach() {}
virtual void OnUpdate() {}
virtual void OnEvent(Event& event) {}
inline const std::string& GetName() const { return m_DebugName; }
protected:
std::string m_DebugName;
};
当层推入层栈,成为程序的一部分时,被 Attached (链接)。当层被移除时,Detach(分离)。基本上和 OnInit 和 OnShutdown 差不多。OnUpdate 则是在层更新时由 Application 调用,应该每帧调用一次。OnEvent,当层得到事件时,我们从这里接收。这些都是虚函数,所以可以在创建自己的层时 override
LayerStack 代码如下:
class HENGINE_API LayerStack
{
public:
LayerStack();
~LayerStack();
void PushLayer(Layer* layer);
void PushOverlay(Layer* overlay);
void PopLayer(Layer* layer);
void PopOverlay(Layer* overlay);
std::vector<Layer*>::iterator begin() { return m_Layers.begin(); }
std::vector<Layer*>::iterator end() { return m_Layers.end(); }
private:
std::vector<Layer*> m_Layers;
std::vector<Layer*>::iterator m_LayerInsert;
};
注意我们搞了一个 overlay,这个 overlay 就是刚刚说的覆层。实现如下:
LayerStack::LayerStack()
{
m_LayerInsert = m_Layers.begin();
}
LayerStack::~LayerStack()
{
for (Layer* layer : m_Layers)
delete layer;
}
void LayerStack::PushLayer(Layer* layer)
{
m_LayerInsert = m_Layers.emplace(m_LayerInsert, layer);
}
void LayerStack::PushOverlay(Layer* overlay)
{
m_Layers.emplace_back(overlay);
}
void LayerStack::PopLayer(Layer* layer)
{
auto it = std::find(m_Layers.begin(), m_Layers.end(), layer);
if (it != m_Layers.end())
{
m_Layers.erase(it);
m_LayerInsert--;
}
}
void LayerStack::PopOverlay(Layer* overlay)
{
auto it = std::find(m_Layers.begin(), m_Layers.end(), overlay);
if (it != m_Layers.end())
m_Layers.erase(it);
}
也就是说,推入一个 layer,就想正常地 push 进去,通过类中的迭代器m_LayerInsert放入,m_LayerInsert也就是正常 layer 的最后一个位置;而要是是 overlay ,我们就不需要管迭代器 m_LayerInsert 的位置,而是直接 emplace_back 进去,因此 LayerStack 中的排列像是这样 : layer layer overlayer overlayer ,而 m_LayerInsert 就应该指向这里第二个 layer 的位置,即最后一个非 overlayer 的 layer。
GLAD GitHub:https://github.com/Dav1dde/glad
在线生成服务:https://glad.dav1d.de/
对于Cherno而言(UI)是在游戏引擎或是任何图形项目中第一个要设置的,因为这可以同时显示信息和调整参数。比如 Cherno 不希望每次都重新编译来调整一个参数,只要在屏幕上有个滑条就好。
为了能让 IMGUI 工作,以及我们实现的前瞻性规划,我们首先要能使用现代OpenGL。
基本上我们需要一种方式,从显卡驱动加载所有的现代OpenGL的函数,到C++代码中。因此我们能调用存储于图形驱动程序的函数。
在 OpenGL 系列中我们使用了 GLEW(OpenGL Extension Wrangler),这里要用 GLAD(相比GLEW更好更现代)。
我们将首先在网站上配置好它,下载,然后添加为一个单独的项目,添加项目premake文件,提交到我们的存储库。因此不会像之前 GLFW 以及未来的 IMGUI 一样,要fork其他的存储库。
Generate 后,点 glad.zip 下载,然后就有了我们需要的所有东西。
随后添加 glad 进premake管理,然后在glfw窗口初始化的时候添加这一行调用 gladLoadGLLoader :
今天我们要做的事被称为 “hack” 。
编写软件的其中一种方式是,第一件事是 make it work,第二件事是 make it correct,最后一件事是 make it fast。所以我们要让它运行起来,用奇技淫巧,因为我希望在屏幕上看到 IMGUI
正确方法是合理抽象,以替代原始的 OpenGL 函数调用。(原始调用是我们今天要做的)我们希望整个 IMGUI 依赖 HEngine 而不是原始的 OpenGL api。因为我们还没用渲染器,那些的前提是首先要有一个渲染器。但我们希望在有渲染器之前就加入 IMGUI 因而我们能在编写渲染器的时候调试,这样就会方便很多。
这一节来整合 ImGui 和之前写好的事件系统。
我们先这样写:
void ImGuiLayer::OnEvent(Event& event)
{
EventDispatcher dispatcher(event);
dispatcher.Dispatch<MouseButtonPressedEvent>(HE_BIND_EVENT_FN(ImGuiLayer::OnMouseButtonPressedEvent));
dispatcher.Dispatch<MouseButtonReleasedEvent>(HE_BIND_EVENT_FN(ImGuiLayer::OnMouseButtonReleasedEvent));
dispatcher.Dispatch<MouseMovedEvent>(HE_BIND_EVENT_FN(ImGuiLayer::OnMouseMovedEvent));
dispatcher.Dispatch<MouseScrolledEvent>(HE_BIND_EVENT_FN(ImGuiLayer::OnMouseScrolledEvent));
dispatcher.Dispatch<KeyPressedEvent>(HE_BIND_EVENT_FN(ImGuiLayer::OnKeyPressedEvent));
dispatcher.Dispatch<KeyReleasedEvent>(HE_BIND_EVENT_FN(ImGuiLayer::OnKeyReleasedEvent));
//dispatcher.Dispatch(HE_BIND_EVENT_FN(ImGuiLayer::OnKeyTypedEvent));
dispatcher.Dispatch<WindowResizeEvent>(HE_BIND_EVENT_FN(ImGuiLayer::OnWindowResizeEvent));
}
bool ImGuiLayer::OnMouseButtonPressedEvent(MouseButtonPressedEvent& e)
{
ImGuiIO& io = ImGui::GetIO();
io.MouseDown[e.GetMouseButton()] = true;
return false;
}
这里先写成 return false 是因为我们暂时不希望有其他层吸收这个事件。无论点击哪里,我们不希望这个 Layer 消耗掉一个鼠标事件,因此现在应当要是 false。
这里的 KeyPressed 的实现中,注意这个 KeySuper,Super 是 Win 键,取决于你的平台。Mac上是 Cmd,也所以叫 Super 而不是 Win 键。
bool ImGuiLayer::OnKeyPressedEvent(KeyPressedEvent& e)
{
ImGuiIO& io = ImGui::GetIO();
io.KeysDown[e.GetKeyCode()] = true;
io.KeyCtrl = io.KeysDown[ImGui_ImplGlfw_KeyToImGuiKey(GLFW_KEY_LEFT_CONTROL)] || io.KeysDown[ImGui_ImplGlfw_KeyToImGuiKey(GLFW_KEY_RIGHT_CONTROL)];
io.KeyShift = io.KeysDown[ImGui_ImplGlfw_KeyToImGuiKey(GLFW_KEY_LEFT_SHIFT)] || io.KeysDown[ImGui_ImplGlfw_KeyToImGuiKey(GLFW_KEY_RIGHT_SHIFT)];
io.KeyAlt = io.KeysDown[ImGui_ImplGlfw_KeyToImGuiKey(GLFW_KEY_LEFT_ALT)] || io.KeysDown[ImGui_ImplGlfw_KeyToImGuiKey(GLFW_KEY_RIGHT_ALT)];
io.KeySuper = io.KeysDown[ImGui_ImplGlfw_KeyToImGuiKey(GLFW_KEY_LEFT_SUPER)] || io.KeysDown[ImGui_ImplGlfw_KeyToImGuiKey(GLFW_KEY_RIGHT_SUPER)];
return false;
}
这里的 ImGui_ImplGlfw_KeyToImGuiKey 函数是我从官方的示例抄下来的。这时按下 Ctrl + S 就会在 S 按下的时候检查修饰键并设置为 true 或 false。