引擎开发阶段性记录#1
文章仅作个人学习记录,不具备参考价值
Win32 API
这次开发原本是打算依赖于SDL3的广袤跨平台功能和封装好的GPU渲染API作为底层。无意中了解到动物井的开发者尽然是自己一手搭建的跨平台,没有第三方库的依赖,除了一个小小的stb图像加载器,顿时心生憧憬,也想效仿大佬。
然后就去看了一下Win32API。总的来说API复杂但不难,文档给的也算清晰了,就是来来回回检索关键讯息实在是麻烦,还有文档的翻译经常有误,要小心甄别,疑惑时要看原文。
使用的API大类别当然是跟窗口创建和显示设置有关。API中有大量的宏提供创建窗口时的参数,是非常非常多的选择,对于一个强迫症患者,我很难取舍,又想着以后可能会经常修改,所以要兼顾以后可能的配置功能。索性地把一堆常用的宏定义成了常量,用于在引擎初始化时使用。窗口创建的过程意外的简单,大概流程无非:声明窗口类,注册窗口类,创建窗口。对于窗口类,需要知道一个被称为“WndProc”的东西,我的理解就是“处理窗口的回调”,也就是每当窗口接收到信息的时候,要过一边这个回调,在文档中有很多演示案例,大都是写一个这样的处理窗口消息的回调函数,然后在里面switch一下,case一下想要处理的函数,default里塞一个所谓的默认窗口过程“DefaultWndProc”这么个东西用来处理不想处理的那些消息。
提到这个所谓默认处理,你大可不必写一个处理回调,而是直接以默认处理给窗口类赋值。这就足够一个窗口正常工作了。还有创建窗口类时,记得给类指定一个默认的光标图标。否则你的鼠标将会一直是蓝色小圆圈。
至于什么是所谓的“windows消息”,这就不得不提一嘴windows的处理窗口的形式了,目前来看是一个基于“事件系统”的玩意,如果还记得SDL是怎么处理话:SDL里通过一个叫事件循环的东西处理这个窗口包括其输入。其实在Windows里也是一样的,不过他们管这个叫“消息循环”。重要的是,这是一种重要的输入处理方式,而这种处理方式可以很明显的看出是于窗口强关联的,只有窗口收到了来自系统的消息,我们才可以根据消息处理输入,比如:KEY_DOWN消息,故名思意,就是所谓“键”按下的消息,如果你的窗口此时正在被“聚焦”的话,系统就会给窗口发送这么一个KEY_DOWN消息,我们可以根据这个消息的有关“附加参数”进行处理,至于这些“附加参数”都是些什么呢?每个参数都不太一样,比如KEY_DOWN的话自然就有一些关于“被按下的键是什么”,“这个键之前有没有被按下”之类的附加参数,最佳实践就是去文档里一个一个看然后根据自己的需求进行处理。
你可能会好奇为什么会有“这个键之前有没有被按下”这样的消息呢,这就不得不扯到键盘的一个非常“混蛋”的特性了。现在找一个可以敲字符的地方,然后按下某个字母键a,它会很正常的显示在屏幕上,但是当你一直摁住它的话,过了大概一秒,它就开始aaaaaaaaaaaaaaaaaaa了。这是因为键盘有一个连击机制,如果一直摁住的话,它会一直进行输入。这个东西可千万不能用来判断某个键是否按下,因为它有延迟!你可不希望玩家按下奔跑键一秒后能跑起来吧?
所以我目前的最佳实践就是,每次按下某个键的时候,就意味着这个键“刚刚被按下”而且已经处于“被按下”的状态了,我们应该同时记录这两个状态。好了,如果我们正在一直按住某个键,这个按下的消息可不会只有一次,因为它还是会被系统发送到窗口,只不过附加消息中有了“这个键之前有没有被按下”,而且被设为了真。
现在知道为什么会有这个附加参数了吧。就是为了给我们精密控制键盘消息的手段。然后,我并不推荐大家使用所谓“消息”或“事件”的形式去处理输入,这个我得放在后面说。
还有一个就是窗口模式和显示模式的切换方面,这里要提一嘴,微软文档中管显示器叫monitor,显示模式叫display。对没错,他们管显示模式叫display,确实也能理解,不过如果不仔细看的话容易混淆,首先显示器很容易理解,就是一个独立的物理设备。而显示模式就是该显示器能提供的呈现图像的形式,说明白点就是1920X1080,180HZ,32位像素之类的,显示器一般会支持许多显示模式,1620的,1280的,144HZ的等等等等。
对于处理窗口模式,我只提供了三种模式,窗口,全屏,全屏无边框。对于每次到窗口模式的切换,我直接选择将窗口置于显示器中心,这样就不用担心全屏窗口来回切换时发送的窗口位移了。基于这一点,可以在首次创建窗口的时候设置一下窗口模式,以使窗口居中。
然后还有记住窗口模式不等于显示模式,窗口模式只应用于创建的那个窗口,而显示模式应用于你的整个显示器画面,关于显示模式还有一些比较好玩的关键点,比如你可以让整个屏幕旋转或反转,灰白或缩放,这些东西感兴趣的话可以自己去文档研究。
还有一点,就是你可能会发现,WinAPI里有很多以A,W结尾的函数,他们跟其他函数名称大致相同,这是因为现代Windows使用宽字符,Unicode, UTF-16,c++里叫wchar_t就是16位的字符处理会比较多。而以前则是用的UTF-8,就是ASCII的8位字符。我一开始认为既然你用宽字符性能好,那就用吧,但是如果想要跨平台的话,mac的宽字符好像是32位的,Linux的好像也不一样,但是每个平台的char是一样的,所以对于跨平台的API设计层面,可能用char作为参数传递会更加合理一点,至于Windows,它底层会自己作类型转换的,不用管它。
核心架构
说实话,我感觉不出我的引擎有什么核心架构。一开始想法是一个叫做Meta的类,整个引擎的所有类,都是这个Meta的子类,这个Meta提供了一堆游戏循环相关的处理函数,比如什么Init,Tick,Quit,OnFrameStart等等等等。
继承自Meta的子类重写其处理函数,并根据需要DIY功能,比如窗口类的Init就是创建一个窗口。不得不提,这样设计一开始感觉良好,因为自己觉得很松耦合,整个游戏框架就是这些Meta一个一个连接起来执行的结构,跟Godot的节点设计不谋而合。
但是在思考良久之后,还是觉得这是一个过度设计,一种对于游戏框架而言完全没有必要的设计模式。
int main()
{
Meta game{};
Window window(L"Game", 1280, 720, WindowMode::Windowed);
Input input{};
Renderer renderer{};
game.AddChild(window);
window.AddChild(input);
window.AddChild(renderer);
game.Init();
while (true)
{
game.OnFrameStart();
game.Tick();
if (input.IsKeyJustUp(VirtualKey::KEY_ESCAPE))
{
break;
}
game.OnFrameEnd();
}
game.Quit();
}
这是整个游戏循环大概的样子。你可能已经想到问题了,这些window啊,input啊,真的都需要封装成为对象嘛?我的意思是这些东西都大概率不会有“第二个”。他们在整个游戏周期中极大概率都是单例。是,每次设计游戏循环的时候可以随意修改执行顺序,但是我有必要去修改嘛。而且这样设计话,万一哪一天手误加了两个window,效果会这么样?这些当然都是未知数。
当然这样的最大好处就是,可以很自由的修改游戏循环。所以我现在对这样设计模式感到不少困惑,可以考虑的替代方案是用栈做一个启动器,按入栈顺序初始化,按出栈顺序析构。
输入处理
前面有说到,我不建议大家使用以“接收窗口消息”的形式去处理输入。请看代码:
void OSIReadWindowInputMessages
(
OSIWindowHandler window,
OSIKeyboardMsgQueue& kmsgs,
OSIMouseMsgQueue& mmsgs
)
{
// 这里把Windows窗口事件映射成OSI的事件并放入消息队列。
// 要是想添加新窗口事件的处理功能,就要在这里修改。
static std::unordered_map<UINT, std::function<void(MSG&, OSIKeyboardMsgQueue&, OSIMouseMsgQueue&)>> msgHandlers
{
{ WM_KEYDOWN, [](MSG& msg, OSIKeyboardMsgQueue& kmsgs, OSIMouseMsgQueue&)
{
OSIKeyboardMsg kmsg{};
kmsg.window = (OSIWindowHandler)msg.hwnd;
kmsg.key = (OSIVK)msg.wParam;
kmsg.pressed = true;
kmsgs.emplace(std::move(kmsg));
}
},
{ WM_KEYUP, [](MSG& msg, OSIKeyboardMsgQueue& kmsgs, OSIMouseMsgQueue&)
{
OSIKeyboardMsg kmsg{};
kmsg.window = (OSIWindowHandler)msg.hwnd;
kmsg.key = (OSIVK)msg.wParam;
kmsg.pressed = false;
kmsgs.emplace(std::move(kmsg));
}
},
{ WM_LBUTTONDOWN, [](MSG& msg, OSIKeyboardMsgQueue&, OSIMouseMsgQueue& mmsgs)
{
OSIMouseMsg mmsg{};
mmsg.window = (OSIWindowHandler)msg.hwnd;
mmsg.button = OSIVK::MOUSE_LEFT_BUTTON;
mmsg.pressed = true;
mmsg.x = GET_X_LPARAM(msg.lParam);
mmsg.y = GET_Y_LPARAM(msg.lParam);
mmsg.wheelDelta = 0;
mmsgs.emplace(std::move(mmsg));
}
},
{ WM_LBUTTONUP, [](MSG& msg, OSIKeyboardMsgQueue&, OSIMouseMsgQueue& mmsgs)
{
OSIMouseMsg mmsg{};
mmsg.window = (OSIWindowHandler)msg.hwnd;
mmsg.button = OSIVK::MOUSE_LEFT_BUTTON;
mmsg.pressed = false;
mmsg.x = GET_X_LPARAM(msg.lParam);
mmsg.y = GET_Y_LPARAM(msg.lParam);
mmsg.wheelDelta = 0;
mmsgs.emplace(std::move(mmsg));
}
},
{ WM_RBUTTONDOWN, [](MSG& msg, OSIKeyboardMsgQueue&, OSIMouseMsgQueue& mmsgs)
{
OSIMouseMsg mmsg{};
mmsg.window = (OSIWindowHandler)msg.hwnd;
mmsg.button = OSIVK::MOUSE_RIGHT_BUTTON;
mmsg.pressed = true;
mmsg.x = GET_X_LPARAM(msg.lParam);
mmsg.y = GET_Y_LPARAM(msg.lParam);
mmsg.wheelDelta = 0;
mmsgs.emplace(std::move(mmsg));
}
},
// 省略许多处理
// 。。。。。。
// 省略许多处理
{ WM_MOUSEMOVE, [](MSG& msg, OSIKeyboardMsgQueue&, OSIMouseMsgQueue& mmsgs)
{
OSIMouseMsg mmsg{};
mmsg.window = (OSIWindowHandler)msg.hwnd;
mmsg.button = OSIVK_NULL;
mmsg.pressed = false;
mmsg.x = GET_X_LPARAM(msg.lParam);
mmsg.y = GET_Y_LPARAM(msg.lParam);
mmsg.wheelDelta = 0;
mmsgs.emplace(std::move(mmsg));
}
},
{ WM_MOUSEWHEEL, [](MSG& msg, OSIKeyboardMsgQueue&, OSIMouseMsgQueue& mmsgs)
{
OSIMouseMsg mmsg{};
mmsg.window = (OSIWindowHandler)msg.hwnd;
mmsg.button = OSIVK_NULL;
mmsg.pressed = false;
mmsg.x = GET_X_LPARAM(msg.lParam);
mmsg.y = GET_Y_LPARAM(msg.lParam);
mmsg.wheelDelta = GET_WHEEL_DELTA_WPARAM(msg.wParam);
mmsgs.emplace(std::move(mmsg));
}
},
};
HWND hwnd = (HWND)window;
MSG msg{};
while (PeekMessageW(&msg, hwnd, 0, 0, PM_REMOVE))
{
//TranslateMessage(&msg); // 不太需要 WM_CHAR
DispatchMessageW(&msg);
if (msgHandlers.contains(msg.message))
msgHandlers[msg.message](msg, kmsgs, mmsgs);
}
}
我想你已经明白了。这是一个对Windows消息的映射和封装处理,目的是为了提供OSI(操作系接口)层面的消息,后者是我自行封装的消息。这里的问题就在于你需要处理一大堆恼人的,性质相似但又有细微不同的消息函数,好的,比如说键盘按下消息,和键盘松开消息,它们提供了对应操作的虚拟键位。
虚拟键位是操作系统对于输入键位的抽象。在我自己的实现中,我又一次封装了操作系统的虚拟键,并为未来的跨平台虚拟键作封装,并且把手柄的虚拟键纳入总虚拟键的集合中,Windows系统是没有手柄输入对于的虚拟键的。为了方便管理的开发,我选择将二者集成。
然后对于鼠标事件,这些键位直接对应了事件名字!!!你可以看到有叫LBUTTONDOWN的事件,和它对应的UP事件,你不能像处理键盘事件一样获取它的键位,而只能再写一个映射,为其专门提供几乎几乎重复的处理方式。更有甚者,窗口可以接收一个叫DOUBLECLIKCK的事件,也就是鼠标双击事件,这个事件会覆盖双击中的第二次鼠标按下事件,换句话说,就是双击鼠标只有一个鼠标按下事件,另一个变成了鼠标双击事件,所以你还得为此专门开一个映射进行处理。所以个人认为对于以后新功能的添加和维护都是非常不友好的。
所以在后面的开发中,我极有可能放弃以消息的形式处理。原因除了上述的繁琐之外,还有一个就是这样设计会使得输入系统与窗口系统强行耦合。也就是只有游戏窗口接收到了系统消息,输入系统才能进行处理。
首先,为了使游戏支持手柄,我使用了Xinput,而Xinput的输入处理跟窗口没有半毛钱关系,但是键盘和鼠标的处理却这般强行依赖,这对于我的强迫症来说是致命的催化剂,我迫切地想让这些输入方式的处理更加规范化,相似化,而不是为了同时支持多个输入手段另外设置一大堆额外变量进行状态记录。
其实对于手柄而言,还可以使用RawInput这个东西,也就是所谓的原生输入,这个东西理论上可以支持所有输入设备,但是写起来来繁琐,我直接向XInput投诚。
其次,你可能有意地希望,输入跟窗口有关,比如当玩家在副屏幕上恢复消息时,不会影响正在进行的游戏。这很贴心合理,但是即使不用窗口消息也一样能做到,只需要调用几个检测窗口是否被聚焦的API,然后还是可以实现相似的效果。
所以我个人更偏好于输入和窗口分离,而我目前的架构则是强依赖,额,或者说半依赖,因为手柄不依赖与窗口。在后面开发中,我大可能重构输入系统,把它与窗口消息完全解耦,完全用不到消息这个东西。这样一来,也许我的程序架构能更加简洁明了。对于这种输入与窗口完全无关的模式,微软管这叫异步输入检测,主要通过GetAsyncKeyState之类的函数获取某个键位的输入状态,这里面也包括了鼠标的虚拟键,所以可以不用在处理繁琐的鼠标消息了。
如果你执意要用使用消息处理,记得消息循环中的GetMessage是一个阻塞行为,PeekMessage是一个非阻塞行为,不用问为什么,你会感谢我的。
内存管理
对于一个游戏引擎,内存管理是绝对不能缺少的部分,因为自定义的内存分配器可以很好的避免系统级别的内存分配,比如new。所以可以大大降低内存分配的开销。
所以,,,我没有使用内存分配器。为什么?因为我懒。
好吧,其实是目前还不需要,我目前更希望看到一个简洁的,干净的引擎架构。
渲染
欧欧,重量级嘉宾!我真是对这个东西又爱又恨。一开始觉得先用OpenGL搭个简单的渲染框架,到后期再用Vulkan替换吧。很好很合理,然后发现OpenGL的教程更难找,我想找用原生WinAPI跟OpenGL的交,发现太麻烦了,索性就直接使用Vulkan。
然后现在就卡在怎么想明白要怎么利用VK的特性,和我真的需要用到VK嘛?对于一个很可能仅有一个人维护的引擎,我有必要使用一个相当复杂的API嘛?
其他
我的C++基础并不好,所以写起来还是很比较吃力的,尤其是使用STL时,因为用习惯了C#的糖氏集合,对于STL的复杂用法稍有不适,比如那个逆天的队列。
还有诸如类型检测之类的,要用到typeid,这玩意不能用来对指针操作,不然返回的是指针类型而非动态类型,比如某个类是动态类(有虚函数),对指针进行typeid只会得到它的指针类型,而对实例则是可以得到其子类的类型。
总结
总的来说算上开了个头,下一篇大概率是渲染篇了,希望那是能有更加惊艳的表现吧。