Hazel引擎学习(八)
我自己维护引擎的github地址在这里,里面加了不少注释,有需要的可以看看
参考视频链接在这里
Testing Hazel’s Performance
这节课主要是对当前的Hazel游戏引擎进行性能测试,拿了网友的一个Demo进行测试,整体来说,性能还不错。测试结果显示,对于Hazel引擎,Release下性能的大概是Debug下的十倍,这节课重点有:
- 学会用Visual Studio自带的性能分析工具
- 基于VS的性能分析工具,知道当前最大CPU用在了哪里,当前引擎的性能lagging点在哪
- IMGUI在引擎里的用途
学会用Visual Studio自带的性能分析工具
参考:https://www.youtube.com/watch?v=X1-uHpEqNGM&ab_channel=MicrosoftVisualStudio
打开对应的项目,点击Debug->Performance Profiler,或者按Alt+F2,会出现下面的窗口:
选择分析CPU Usage,点击开始,此时会启动项目分析,可以看到这里有个图表,它代表了当前进程每帧占据总CPU的比例:
点击Enable Collection一段时间后,点击Stop Collection,然后点击得到的数据,可以分析对应的信息,界面如下图所示:
界面的说明在这里,感觉还挺复杂的。它这里是通过Sample CPU来实现分析的,可以在相关界面进行设置,默认的是1s Sample一千次,也就是说,图里面的CPU Unit就是1毫秒,Total CPU是指执行这个函数消耗的所有时间,而Self CPU是指执行该函数本身(不包含执行其内部函数的时间)所用的时间,比如说A调用B,A总共执行5ms,B自身执行了2ms,那么A的Self CPU就是3。
所以这里其实应该看Self CPU,我重新排序如下:
虽然我这个Demo只绘制了几个三角形,但还是可以看出来,目前性能最大消耗点在glm数学库的运算和imgui上
IMGUI在引擎里的用途
Hazel引擎里的ImGUI,跟Unity里的ImGUI的功能是一样的,都是方便快速搭建UI,主要是为了方便Debugging用,并不是游戏Runtime实际会跑的UI,而且它在runtime下的性能也比较差。
目前Hazel还是会将就用着imgui,也会用它来搭建Hazel Editor,不过后期会搭建自己的Runtime UI
Let’s Make Something in Hazel
这一章也没啥难的,有以下几点:
- 写了个简易的Particle System,然后做了个很简单的Demo,就是鼠标点在哪里,就在那里释放Particles,这一部分的内容跟我之前做Flappy Rocket的Particle System是差不多的,无非这里是把它接入到支持Batch的Renderer2D里而已
- 做了个Clipping的操作,就是停止绘制正交相机区域外的Quad,给正交相机添加了个GetBounds的函数,绘制的时候去判断是否有交集
细节就不多说了,没啥特别的。不过视频里提到了个Bug,可以注意一下,这是一个关于C++类的成员变量初始化顺序引起的Bug。
如下图所示,先写的m_Bounds(...)
,并不意味着就会先初始化m_Bounds
,而是按照对应变量出现在C++类头文件定义的顺序先后来初始化的:
How Sprite Sheets/Texture Atlases Work
对于Hazel引擎,它已经写好了一个2D Renderer。目前的想法,是对现有的2D Renderer进行测试,上节课做了个粒子系统的测试,看上去还是OK的,但是测试还不够。为了测试,这里尽可能的,制作出一个真正的2D游戏里会出现的场景地图,因为它更贴近实际生产环境。
更长远的计划则是让Hazel能够在User不写C++的情况下,制作出任何的2D游戏,这就不多说。
制作2D地图需要很多精度不高的贴图,代表不同的地图块,为了制作地图,需要加入Texture Atlase系统,Texture Atlases可以把它们放到同一个大的Texture上,基本概念就不多介绍了,这章的目的有:
- 介绍资源网站,可以用来获取免费的贴图资源
- 如何分割Texture Atlas为小的Textures
贴图资源网站
链接为:https://www.kenney.nl/assets
这是一个有很多免费游戏资源的网站,不只是贴图,如下图所示:
如何分割Texture Atlas
做法比较暴力…就是把贴图在PS里打开,然后自己手动测量出每个小Texture的左下角的TexCoord和右上角的TexCoord,即手动计算每个要的子Texture的UV坐标,这个UV坐标是normalized坐标,在[0, 1]区间。
如下图所示,手动测呗,稍微注意一下,这里的宽度高度一般都是2的倍数,比如128、256啥的,也不是很难测:
其他的没啥区别了,这里额外注意一下图片的WrapMode是Linear
还是Nearest
即可,前者是进行了像素的融合,边缘会模糊柔和一些,而Nearest
的图片边缘锯齿感会强一些
具体的代码不难,不多说了,就是改变UV坐标而已。
SubTextures
这节课也都是工程性的东西:
- 为了方便处理SpriteSheet或者说Texture Atlas,可以添加一个额外的
SubTexture
类,它本质上就是一个Texture
的Wrapper,然后添加了额外的四个TexCoord坐标,用于表示Texture对应部分区域的Texture,从而起到SubTexture的作用。 SubTexture
是一个笼统的概念,虽然Texture是跨平台的(需要有OpenGLTexture
等类),但它作为一个Wrapper,不需要跨平台(不需要有OpenGLSubTexture
等类)- 在
Renderer2D
类里添加额外的DrawCall函数,用于支持SubTexture
还需要注意一点,目前的游戏引擎是没有合并Geometry的功能,就目前的2D Renderer来说,暂时是不需要的。因为2D Renderer里一般只会绘制看得见的东西,不会像3D游戏里,需要绘制到很多屏幕看不到的东西(Occlusion)。3D渲染才需要Geometry合并的功能,比如说它会把只看得见的部分合并成一个Mesh,然后把它绘制出来,从而优化性能。
Creating a Map of Tiles
这节课的重点:
- 如何表示Tiles组成的地图,每个Tile用哪个SubTexture?
- 二维数组优先行遍历还是优先列遍历,哪一种更Cache Friendly一些 (后面的附录写了)
如何表示Tiles组成的地图
方法其实很多,总之目的是为了表示出地图上用了哪些类型的Tile,以及每个Tile的位置,就目前所用的2D贴图而言,它最小的图形应该是128*128
像素的,所以1920*1080(16:9)
的屏幕最多也就是15*8.4375
个Tile而已。
这里介绍了两种:
- 用字符串表示,字符串里的不同字符用于代表不同类型的Tile
- 用很小的像素图表示场景,一个Tile用一个像素表示,这样地图看起来比较直观,而且是用图片资源存储的,比较方便迭代
我用的是第一种,代码如下:
// 16行9列
static const char s_MapTiles[] =
{
// 这种写法其实代表一个长字符串, D代表Dirt土地Tile, W代表Water Tile, S代表路标Tile
// 注意第一个Tile为D, 虽然在数组里坐标为(0,0), 但是在屏幕上对应的坐标应该是(0,1)
"DDWWWWWWWWWWWWWW"
"DDWWWWWWWWWWWWWW"
"DDDDDDDDDDDWWWWW"
"DDDDDSDDDDDWWWWW"
"DDDDDDDDDDDWWWWW"
"DDWWWWWWWWWWWWWW"
"DDWWWWWWWWWDDSDD"
"DDWWWWWWWWWWWWWW"
"DDWWWWWWWWWWWWWW"
};
Next Steps + Dockspace
引擎接下来的方向是:
- 制作Hazel Editor界面,让用户方便的创建物体,而不用写代码
- 创建Scene系统
- 创建ECS系统
这节课,在代码部分,加入了ImGUI的Dockspace作为程序启动的主窗口,取代了原本绘制的megenta的洋红色窗口。所以这节课的知识点其实是参照imgui_demo.cpp
里的代码,调用对应绘制Dockspace的代码。
只要循环调用ImGui::ShowDemoWindow(true)
代码,就可以看到示例窗口,里面展示了所有的ImGUI的基本功能:
找到的示例函数是ShowExampleAppDockSpace
,它是在ShowDemoWindow
里被直接调用的,下面有这么句话:
This function demonstrate using DockSpace() to create an explicit docking node within an existing window.DockSpace() is only useful to construct to a central location for your application.
根据注释,正常来说,ImGui的窗口之间都是互相支持拖拽的,它们本来就是支持Docking,无非DockSpace是可以用于Application窗口的中心hub。
最后,为了避免窗口里除了Dockspace和ImGui窗口,其他的啥也没有,这里通过ImGUI绘制一张贴图。需要调用ImGui::Image
函数,这里为了支持跨平台,需要把textureID从原本的GLuint
改成void*
类型,注意它是直接取整型数作为一个指针,这个指针当然是无效的,而不是取GLuint
的地址改成void*
,具体原因可以看附录:
void Renderer2DTestLayer::OnImGuiRender()
{
ImGui::Begin("Test");
ImGui::ColorEdit4("Flat Color Picker", glm::value_ptr(m_FlatColor));
auto& stats = Hazel::Renderer2D::GetStatistics();
ImGui::Text("DrawCalls: %d", stats.DrawCallCnt);
ImGui::Text("DrawQuads: %d", stats.DrawQuadCnt);
ImGui::Text("DrawVertices: %d", stats.DrawVerticesCnt());
ImGui::Text("DrawTiangles: %d", stats.DrawTrianglesCnt());
ImGui::Image(m_Texture2D->GetTextureId(), { 1080, 720 });
m_ProfileResults.clear();
ImGui::End();
...// 下面是绘制Dockspace的代码
显示如下:
注意,这里的图片是倒着的,要校正的话需要这么写:
// 应该是ImGui对于贴图的Y坐标的认知跟目前的Texture是相反的
ImGui::Image(m_Texture2D->GetTextureId(), { 1080, 720 }, ImVec2{ 0, 1 }, ImVec2{ 1, 0 });
Framebuffers
A framebuffer (frame buffer, or sometimes framestore) is a portion of random-access memory (RAM)[1] containing a bitmap that drives a video display. It is a memory buffer containing data representing all the pixels in a complete video frame.[2] Modern video cards contain framebuffer circuitry(电路) in their cores.
这章的目的是把当前不断变化的场景,通过framebuffer渲染到一张贴图上,然后通过imgui绘制出来,为了后续放到Viewport
窗口里,步骤如下:
- 了解什么是Framebuffer
- 创建Framebuffer类,为其添加Texture作为默认的Color Attachment
- 创建Framebuffer,把原本渲染的贴图渲染到这个framebuffer object上,再通过Framebuffer的GetColorAttachment的接口,得到对应的texture、最后让ImGUI绘制出来,说白了就是自行创建fbo,代替OpenGL默认的fbo,把动态变化的场景渲染到一张贴图上了
什么是Framebuffer
Framebuffer其实很简单,第一,它是一块buffer,也就是一块内存;第二,它是存储的是一帧的buffer数据。
Framebuffer的基础知识就不多介绍了,之前我写过一篇文章,里面有相关介绍。
创建Framebuffer类
Framebuffer类也是跨平台的类,写法跟Vertex Buffer类差不多,创建Framebuffer类,然后基于各个平台创建对应的子类,比如OpenGLFramebuffer
类,初步版本的FrameBuffer的数据成员如下:
uint32_t Width, Height;
// 当RenderToScreen为true时, 在OpenGL里会执行glBindFramebuffer(0);
// 在Cherno的引擎版本里, 这个变量叫做SwapChainTarget, 这个叫法后续学习了vulkan应该更能理解
bool RenderToScreen = false;// whether this framebuffer should be renderer to the swap chain
Render Pass
后续的引擎还会添加Render Pass的概念,Render Pass在OpenGL里更像是一个抽象的概念,但在Vulkan里却是一个实实在在存在的类,它会有一个Framebuffer,和一个target。像上面的RenderToScreen为false的Framebuffer,其实就是一个渲染到屏幕上的Render Pass而已,渲染时的代码大概是:
renderer.BeginRenderPass();
Making a New C++ Project in Hazel
这节课没啥东西,就是创建了一个 新项目叫做Hazelnput,类似于Sandbox,我这里叫Hazel Editor。原本的Sandbox文件夹保留,用HazelEditor创建用于游戏的GameData,用Sandbox加载对应的data作为runtime测试环境。
Scene Viewport
这节课还挺重要的,主要做了以下工作:
- 新建一个ImGui窗口,把它拖拽到Dockspace里,把frambebuffer渲染的贴图在里面显示出来
- 添加Framebuffer接口,更新Framebuffer里作为output的Color Attachment
- 利用ImGui的API,检测Viewport窗口的size,如果产生变化,则更新更新Framebuffer里的Color Attachment
- 根据Viewport窗口的大小变化,调整Camera的Viewport
新建Viewport窗口
这么写就行了:
void EditorLayer::OnImGuiRender()
{
// 先是绘制Dockspace的代码
...
// 再是绘制RenderStats的代码
...
// 绘制Viewport
ImGui::Begin("Viewport");
ImGui::Text("Viewport");
ImGui::End();
}
然后手动把窗口都拖到Dockspace里存好就行了,相关UI布局配置应该都会存到imgui.init
文件里,不必每次都去拖拽了。不过这里我拖拽Dockspace时,有时候可以拖拽,有时候不可以拖,还不确定是啥问题,可能是Dock Branch有bug,后面可能要考虑更新submodule。
目前是这个样子:
更新Framebuffer里的Color Attachment
由于Framebuffer里对应贴图是有固定大小的,这里需要添加API,更改里面的Texture的大小。不过视频里Cherno的做法是重新生成整个Framebuffer,我觉得没有必要。那么这就是一个OpenGL调用API的问题,就是如何更改Framebuffer里的Color Attachment的size,相关知识放在附录。
核心代码如下:
void OpenGLFramebuffer::ResizeColorAttachment(uint32_t width, uint32_t height)
{
if (m_FramebufferId != -1)
{
// 注意, 这里不需要BindFramebuffer
glBindTexture(GL_TEXTURE_2D, m_ColorAttachmentTextureId);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
}
}
// 在EditorLayer的OnImguiRender函数里
void EditorLayer::OnImGuiRender()
{
// 绘制Dockspace和RenderStats的代码
...
ImGui::Begin("Viewport");
ImVec2 size = ImGui::GetContentRegionAvail();
glm::vec2 viewportSize = { size.x, size.y };
// 放前面先画, 是为了防止重新生成Framebuffer的ColorAttachment以后, 当前帧渲染会出现黑屏的情况
if (viewportSize != m_LastViewportSize)
m_Framebuffer->ResizeColorAttachment(viewportSize.x, viewportSize.y);
ImGui::Image(m_Framebuffer->GetColorAttachmentTexture2DId(), size, { 0,1 }, { 1,0 });
m_LastViewportSize = viewportSize;
ImGui::End();
}
根据Viewport窗口的大小变化,调整Camera的Viewport
在做这一块功能之前,我仔细想了下,在游戏引擎里,通常都会有Viewport窗口。在Unity里叫Scene窗口,在UE4里就叫Viewport窗口。那么,在我拖拽改变对应窗口大小时,引擎的Viewport窗口视角应该怎么改变?
为此我跑到UE4和Unity里做了个测试,相关过程放到了附录,得到的结果是:
- 游戏引擎的Viewport拖拽时,展示的结果,绝不是把Viewport展示的这张贴图进行Resize,然后展示出来这么简单
- Unity和UE4拖拽viewport,得到的结果不完全相同,但核心思路都是改变窗口大小,意味着改变相机Frustum,包括更改Frustum的宽度和高度、修改Near平面和Far平面的值
- 对于正交投影来说,改变窗口大小,只需要修改Frustum的宽度和高度即可
鉴于UE4和Unity俩引擎在这上面的做法都有区别,而且我测试的是透视投影。而目前Renderer2D用的是的正交投影,那么我这里也没必要太过纠结,Copy Cherno的就行。
具体代码如下:
// 当窗口调整大小时, 改变相机可以看到的区域
void OrthographicCamera::OnResize(uint32_t width, uint32_t height)
{
m_AspectRatio = (float)width / (float)height;
// 里面会调用glm::ortho(float left, float right, float bottom, float top)函数
// 根据这段代码可知, camera看到的区域高度不会随着窗口大小而改变
// 而是会随着鼠标滚动改变zoom值而变化
SetProjectionMatrix(-m_AspectRatio * m_ZoomLevel, m_AspectRatio * m_ZoomLevel, -m_ZoomLevel, m_ZoomLevel);
}
// 绑定framebuffer时, 调整窗口的viewport跟framebuffer贴图大小一样
void OpenGLFramebuffer::Bind()
{
glBindFramebuffer(GL_FRAMEBUFFER, m_FramebufferId);
glViewport(0, 0, m_Width, m_Height);
}
所以目前我的Viewport窗口的效果是:
- 改变Viewport窗口大小,竖直方向的区域会随着缩放,但竖直方向的视野不变;水平方向的区域也会缩放,但水平方向的区域视野也会变化
至于,为什么可以这么写,正交相机的投影矩阵为什么可以用AspectRatio
和ZoomLevel
来表示,附录里提到了。
Code Review + ImGui Layer Events
主要是:
- 修一些Bug
- 重构一些代码
- 修复ImGui Layer事件与EditorLayer事件冲突的问题
Framebuffer析构函数为虚函数
基类的析构函数需要是虚函数,这是很常见的面试题目了。虽然我这里的Framebuffer类没有在堆上进行内存分配,但还是需要使用虚的析构函数。否则对于Framebuffer指针来说,即使其实际类型为派生类,该指针对应的对象析构时也不会调用派生类的析构函数。
// 比如下面这个情况
class EditorLayer : public Layer
{
...
private:
// 如果Framebuffer析构函数不为虚函数, 则EditorLayer析构时, m_Framebuffer析构不会调用派生类析构函数
std::shared_ptr<Framebuffer> m_Framebuffer;
}
修改Input类
目前的Input类是这么写的
namespace Hazel
{
// 引擎提供给用户查询Input的类, 里面的静态public函数是给用户使用的
// 里面的protected函数是给Input的子类使用的, 具体需要根据用户所在的平台决定使用哪种子类对象
// 这里的keycode是用int表示的, 具体哪个Key, 代表值为多少的int, 被统一定义在了引擎的KeyCode.h文件里
// 比如#define HZ_KEY_D 68, 这种写法借鉴于glfw3.h
class HAZEL_API Input
{
public:
// 这种写法很乱
inline static bool IsMouseButtonPressed(int button) { return s_Instance->IsMouseButtonPressedImp(button); }
inline static bool IsKeyPressed(int keycode) { return s_Instance->IsKeyPressedImp(keycode); }
inline static std::pair<float, float> GetMousePos() { return s_Instance->GetMousePosImp(); }
protected:
virtual bool IsKeyPressedImp(int keycode) = 0;
virtual bool IsMouseButtonPressedImp(int button) = 0;
virtual std::pair<float, float> GetMousePosImp() = 0;
private:
static Input* s_Instance;
};
}
// 然后在对应的Platform下会有对应的派生类
class WindowsInput : public Input
{
public:
virtual bool IsKeyPressedImp(int keycode);
virtual bool IsMouseButtonPressedImp(int button);
virtual std::pair<float, float> GetMousePosImp();
};
// 在WindowsInput.cpp里创建Singleton实例
namespace Hazel
{
Input* Input::s_Instance = new WindowsInput();
bool WindowsInput::IsKeyPressedImp(int keycode)
{
GLFWwindow* w = static_cast<GLFWwindow*>(Application::Get().GetWindow().GetNativeWindow());
auto r = glfwGetKey(w, keycode);
return r == GLFW_PRESS || r == GLFW_REPEAT;
}
...
}
Cherno表示这种写法很垃圾,他更倾向于目前Renderer2D的写法,就是用一堆static函数,代替Singleton,原本Singleton的数据成员现在用一个struct表示,即创建一个对应static对象来存储Singleton的数据成员
那我可不可以像VertexBuffer类一样,它是根据RenderAPIType返回对应的VertexBuffer子类,那我这里也可以根据自己的操作系统返回对应的Input子类。感觉是可以,但是不太好。前面的VertexBuffer这么写,是因为Runtime可以改变RenderAPIType,但是对于Input派生类来说,只有一个子类会被编译到最终的exe里,这是个Compile Time决定的事情,不需要借助虚函数
来实现,因为一个Application只可能同时在一个Platform上运行。
所以更好的办法,是在Input类里创建一堆static函数,然后在各个平台对应的Input派生类类实现这些static函数,代码如下所示:
删除WindowsInput类对应的头文件,保留其cpp文件,在里面实现Input.h
的static接口
// WindowsInput.cpp里
bool Input::IsKeyPressed(int keycode)
{
GLFWwindow* w = static_cast<GLFWwindow*>(Application::Get().GetWindow().GetNativeWindow());
auto r = glfwGetKey(w, keycode);
return r == GLFW_PRESS || r == GLFW_REPEAT;
}
bool Input::IsMouseButtonPressed(int button)
{
GLFWwindow* w = static_cast<GLFWwindow*>(Application::Get().GetWindow().GetNativeWindow());
auto r = glfwGetMouseButton(w, button);
return r == GLFW_PRESS || r == GLFW_REPEAT;
return false;
}
std::pair<float, float> Input::GetMousePos()
{
GLFWwindow* w = static_cast<GLFWwindow*>(Application::Get().GetWindow().GetNativeWindow());
double x, y;
glfwGetCursorPos(w, &x, &y);
return std::pair<float, float>((float)x, (float)y);
}
修复Framebuffer没有ClearColor的bug
我目前的代码是这样的:
void EditorLayer::OnUpdate(const Hazel::Timestep& ts)
{
...
// 这里清除的是OpenGL默认的framebuffer, 没有清除我自己的framebuffer
Hazel::RenderCommand::Clear();
Hazel::RenderCommand::ClearColor(glm::vec4(1.0f, 0.0f, 1.0f, 1.0f));// magenta color
m_Framebuffer->Bind();
Hazel::Renderer2D::BeginScene(m_OrthoCameraController.GetCamera());
{
...
}
Hazel::Renderer2D::EndScene();
m_Framebuffer->UnBind();
}
这样写有问题,因为我的Clear操作,没有Clear我的Framebuffer,此时的画面上会出现很多不干净的东西,所以应该修改成:
void EditorLayer::OnUpdate(const Hazel::Timestep& ts)
{
...
// This is for the color for default window
// 保留原本默认窗口对应framebuffer的颜色, 注意, 一定要先设置ClearColor, 再去Clear
Hazel::RenderCommand::SetClearColor(glm::vec4(1.0f, 0.0f, 1.0f, 1.0f));// 默认窗口颜色仍为magenta
Hazel::RenderCommand::Clear();
m_Framebuffer->Bind();
Hazel::RenderCommand::SetClearColor(glm::vec4(0.1f, 0.1f, 0.1f, 1.0f));
Hazel::RenderCommand::Clear();
Hazel::Renderer2D::BeginScene(m_OrthoCameraController.GetCamera());
{
...
}
Hazel::Renderer2D::EndScene();
m_Framebuffer->Unbind();
}
ImGui与Event
现在有个问题,我有俩窗口,一个是绘制Render Stats的窗口,另一个是Viewport窗口。但是我锁定了Render Stats窗口时,对应的键鼠事件还是会影响到Viewport窗口。
首先,梳理一下ImGui在引擎里的执行情况。目前的引擎里,我把ImGuiLayer
的创建放到了基类Application
的构造函数里,ImGuiLayer
里面会有专门的ImGui的代码,比如ImGui的初始化与Update等。此外,每个普通Layer,比如我这里的EditorLayer
,里面的OnImGuiRender
函数里也可以写一些ImGui代码,我在EditorLayer.OnImGuiRender
里绘制了Viewport窗口和Render Stats窗口。
关于ImGui,最好的入门方法还是参考An-introduction-to-the-Dear-ImGui-library,附录后面也写了。
至于Event,目前Hazel引擎处理Event的核心代码如下:
// WindowsWindow.cpp里, 登记glfw的窗口事件
// 当产生WindowCloseEvent时, 执行WindowsWindow类里记录的eventCallback这个函数指针对应的函数
glfwSetWindowCloseCallback(m_Window, [](GLFWwindow* window)
{
WindowData &data = *(WindowData*)glfwGetWindowUserPointer(window);
WindowCloseEvent closeEvent;
data.eventCallback(closeEvent);
});
// Application.cpp里
Application::Application()
{
s_Instance = this;
m_Window = std::unique_ptr<Hazel::Window>(Hazel::Window::Create());
// 这里会设置m_Window里的std::function<void(Event&)>对象, 当接受Event时, 会调用Application::OnEvent函数
m_Window->SetEventCallback(std::bind(&Application::OnEvent, this, std::placeholders::_1));
// Application应该自带ImGuiLayer, 这段代码应该放到引擎内部而不是User的Application派生类里
m_ImGuiLayer = new ImGuiLayer();
m_LayerStack.PushOverlay(m_ImGuiLayer);
}
// 当窗口触发事件时, 会调用此函数
void Application::OnEvent(Event& e)
{
EventDispatcher dispatcher(e);
// 1. 当接受窗口来的Event时, 首先判断是否是窗口关闭的事件
// Dispatch函数只有在Event类型跟模板T匹配时, 才响应事件
// std::bind其实是把函数和对应的参数绑定的一起
dispatcher.Dispatch<WindowCloseEvent>(
// std::bind第一个参数是函数指针, 第二个代表的类对象(因为是类的成员函数)
// 第三个代表的是放到函数的第一位
std::bind(&Application::OnWindowClose, this, std::placeholders::_1));
dispatcher.Dispatch<WindowResizedEvent>(
std::bind(&Application::OnWindowResized, this, std::placeholders::_1));
// 2. 否则才传递到layer来执行事件, 逆序遍历是为了让ImGuiLayer最先收到Event
uint32_t layerCnt = m_LayerStack.GetLayerCnt();
for (int i = layerCnt - 1; i >= 0; i--)
{
if (e.IsHandled())
break;
m_LayerStack.GetLayer((uint32_t)i)->OnEvent(e);
}
}
目前的思路是,既然ImGuiLayer是最先收到Event,当Focus的是Viewport对应的窗口时,那么ImGuiLayer不会处理这个Event,然后该Event会继续发送给剩下的Layer,也就是EditorLayer,让它来执行和处理这个Event。
// EditorLayer.cpp里
void EditorLayer::OnImGuiRender()
{
...
// 核心的ImGui代码如下:
ImGui::Begin("Viewport");
// BeginWindow之后, 这里返回的就是该Window的Focus状态了
// 这里Begin()操作应该会把viewport设置为当前window
m_ViewportFocused = ImGui::IsWindowFocused();
m_ViewportHovered = ImGui::IsWindowHovered();
// 相关状态存到ImGuiLayer里(感觉存到Applciation里更好)
Hazel::Application::Get().GetImGuiLayer()->SetViewportFocusedStatus(m_ViewportFocused);
Hazel::Application::Get().GetImGuiLayer()->SetViewportHoveredStatus(m_ViewportHovered);
...
}
// 然后在ImGuiLayer.cpp里
void Hazel::ImGuiLayer::OnEvent(Event &e)
{
// 只有鼠标在Viewport窗口上、且窗口被Focus时, Viewport窗口才可以接收到Event
if (!(m_ViewportFocused && m_ViewportHovered))// Viewport区域以外的Event会被ImGui接受
e.MarkHandled();
}
Code Review
不要把重要的最通用的宏定义放到头文件里
Cherno的Hazel引擎里的Core.h
(后来改名为Base.h
),内容如下:
#pragma once
#include <memory>
// Platform detection using predefined macros
#ifdef _WIN32
/* Windows x64/x86 */
#ifdef _WIN64
/* Windows x64 */
#define HZ_PLATFORM_WINDOWS
#else
/* Windows x86 */
#error "x86 Builds are not supported!"
#endif
#elif defined(__APPLE__) || defined(__MACH__)
#include <TargetConditionals.h>
/* TARGET_OS_MAC exists on all the platforms
* so we must check all of them (in this order)
* to ensure that we're running on MAC
* and not some other Apple platform */
#if TARGET_IPHONE_SIMULATOR == 1
#error "IOS simulator is not supported!"
#elif TARGET_OS_IPHONE == 1
#define HZ_PLATFORM_IOS
#error "IOS is not supported!"
#elif TARGET_OS_MAC == 1
#define HZ_PLATFORM_MACOS
#error "MacOS is not supported!"
#else
#error "Unknown Apple platform!"
#endif
/* We also have to check __ANDROID__ before __linux__
* since android is based on the linux kernel
* it has __linux__ defined */
#elif defined(__ANDROID__)
#define HZ_PLATFORM_ANDROID
#error "Android is not supported!"
#elif defined(__linux__)
#define HZ_PLATFORM_LINUX
#error "Linux is not supported!"
#else
/* Unknown compiler/platform */
#error "Unknown platform!"
#endif // End of platform detection
#ifdef HZ_DEBUG
#if defined(HZ_PLATFORM_WINDOWS)
#define HZ_DEBUGBREAK() __debugbreak()
#elif defined(HZ_PLATFORM_LINUX)
#include <signal.h>
#define HZ_DEBUGBREAK() raise(SIGTRAP)
#else
#error "Platform doesn't support debugbreak yet!"
#endif
#define HZ_ENABLE_ASSERTS
#else
#define HZ_DEBUGBREAK()
#endif
#ifdef HZ_ENABLE_ASSERTS
#define HZ_ASSERT(x, ...) { if(!(x)) { HZ_ERROR("Assertion Failed: {0}", __VA_ARGS__); HZ_DEBUGBREAK(); } }
#define HZ_CORE_ASSERT(x, ...) { if(!(x)) { HZ_CORE_ERROR("Assertion Failed: {0}", __VA_ARGS__); HZ_DEBUGBREAK(); } }
#else
#define HZ_ASSERT(x, ...)
#define HZ_CORE_ASSERT(x, ...)
#endif
#define BIT(x) (1 << x)
#define HZ_BIND_EVENT_FN(fn) std::bind(&fn, this, std::placeholders::_1)
namespace Hazel {
template<typename T>
using Scope = std::unique_ptr<T>;
template<typename T, typename ... Args>
constexpr Scope<T> CreateScope(Args&& ... args)
{
return std::make_unique<T>(std::forward<Args>(args)...);
}
template<typename T>
using Ref = std::shared_ptr<T>;
template<typename T, typename ... Args>
constexpr Ref<T> CreateRef(Args&& ... args)
{
return std::make_shared<T>(std::forward<Args>(args)...);
}
}
可以看到,它定义了基本上Hazel在所有Platforms上的宏,但是它有致命的缺点:
它很难被所有cpp引用,即使把它放到pch里,它也只能保证被Hazel引擎内的代码使用,对于引擎使用的第三方库文件,不大可能会include这个Core.h
文件。
更好的方法,是通过premake5.lua
把宏定义放到项目的Preprocessing对应的属性栏里,如下图所示:
VertexArray在跨平台的图形API里并不存在
除了OpenGL 其他的渲染API是根本没有VertexArray这个概念 所以VertexArray
这个类要大改 因为它不是一个Render跨平台通用的概念
VertezArray其实是用来描述vertex buffer的,其实DX和Vulkan的设计理念更好,就是让vertex layout绑定到shader,而OpenGL里,vertex layout是存在vertex array里,vertex arrray会绑定到context上,跟shader没有绑定关系
附录
遍历二维数组需要注意的问题
这个其实属于比较经典的问题,也会出现在一些面试题目里面,参考:https://stackoverflow.com/questions/33722520/why-is-iterating-2d-array-row-major-faster-than-column-major
C arrays are stored in a contiguous by row major order. This means if you ask for element x, then element x+1 is stored in main memory at a location directly following where x is stored.
结论是:应该按照行进行遍历,因为C++里数组是按行进行存储的
但是,注意代码里,应该是先是h,再是w,也就是第一个for loop里的是y,而不是x,如下所示:
// 先写列号y并不代表着先遍历列
for(int y = 0; y < h; y++)
for(int x = 0; x < h; x++)
{
// 最内部的for loop才代表最早开始遍历的东西, 比如y = 0时遍历第一行
...
}
这样才能先遍历数组行元素
OpenGL里的RGBA32F
比如说:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
这样可以创建一个贴图,它的RGBA每个分量都是由一个32位浮点数组成的,这里的RGBA每个分量的范围不再是[0, 1]之间,而是任意的浮点数,所以这种贴图可以用来存储很多自定义数据
glTexImage2D和glTexStorage2D的区别
参考:https://stackoverflow.com/questions/23362497/how-can-i-resize-existing-texture-attachments-at-my-framebuffer
If you were using glTexImage2D (…) to allocate storage for your texture, it would be possible to re-allocate the storage for any image in the texture at any time without first deleting the texture.
glTexImage2D
创建的texture的内存是可以变化的,如果想改贴图的大小,不需要delete再重新创建texture;而glTexStorage2D
创建的贴图大小是固定的,它会创建一个immutable(不可变的)贴图对象,相关的贴图设置永远不可以再改变,如果执意更改其大小,会给一个GL_INVALID_OPERATION
的报错glTexStorage2D
的速度会比glTexImage2D
更快
glTexStorage2D specifies the storage requirements for all levels of a two-dimensional texture or one-dimensional texture array simultaneously. Once a texture is specified with this command, the format and dimensions of all levels become immutable unless it is a proxy texture. The contents of the image may still be modified, however, its storage requirements may not change. Such a texture is referred to as an immutable-format texture.
The behavior of glTexStorage2D depends on the target parameter.
Dear ImGui里的ImTextureID
参考:https://github.com/ocornut/imgui/blob/master/docs/FAQ.md#q-how-can-i-display-an-image-what-is-imtextureid-how-does-it-work
参考:https://github.com/ocornut/imgui/blob/master/backends/imgui_impl_opengl3.cpp
代码里是这么定义的,本质上是void*
:
// Other types
#ifndef ImTextureID // ImTextureID [configurable type: override in imconfig.h with '#define ImTextureID xxx']
typedef void* ImTextureID; // User data for rendering backend to identify a texture. This is whatever to you want it to be! read the FAQ about ImTextureID for details.
#endif
参考文档里有这么两句:
- You may use functions such as
ImGui::Image()
,ImGui::ImageButton()
or lower-levelImDrawList::AddImage()
to emit draw calls that will use your own textures. - OpenGL里:
ImTextureID = GLuint
See ImGui_ImplOpenGL3_RenderDrawData() function in imgui_impl_opengl3.cpp
想了想ImGUI是如何实现跨平台的,它应该是给了一批通用的头文件,然后在不同平台实现了对应的头文件,也就是说不同平台会有各自平台的cpp文件,比如OpenGL3对应的两个cpp文件为:imgui_impl_opengl3.cpp
和imgui_impl_glfw.cpp
,我参考了一下ImGui_ImplOpenGL3_RenderDrawData
函数,发现它是在End函数里被调用的:
void Hazel::ImGuiLayer::End()
{
ImGuiIO& io = ImGui::GetIO();
// Rendering
ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
// Update and Render additional Platform Windows
// (Platform functions may change the current OpenGL context, so we save/restore it to make it easier to paste this code elsewhere.
// For this specific demo app we could also call glfwMakeContextCurrent(window) directly)
if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable)
{
GLFWwindow* backup_current_context = glfwGetCurrentContext();
ImGui::UpdatePlatformWindows();
ImGui::RenderPlatformWindowsDefault();
glfwMakeContextCurrent(backup_current_context);
}
}
// 实际调用时
// 3. 最后调用ImGUI的循环
m_ImGuiLayer->Begin();
for (Hazel::Layer* layer : m_LayerStack)
{
// 每一个Layer都在调用ImGuiRender函数
// 目前有两个Layer, Sandbox定义的ExampleLayer和构造函数添加的ImGuiLayer
layer->OnImGuiRender();
}
m_ImGuiLayer->End();
然后这里的ImGuiRender的函数,就会调用ImGui::Image()
函数,内部是这样的:
// imGui_widgets.cpp里
void ImGui::Image(ImTextureID user_texture_id, const ImVec2& size, const ImVec2& uv0, const ImVec2& uv1, const ImVec4& tint_col, const ImVec4& border_col)
{
ImGuiWindow* window = GetCurrentWindow();
if (window->SkipItems)
return;
ImRect bb(window->DC.CursorPos, window->DC.CursorPos + size);
if (border_col.w > 0.0f)
bb.Max += ImVec2(2, 2);
ItemSize(bb);
if (!ItemAdd(bb, 0))
return;
// 加入到DrawList里, 这也是类似于Batch的概念
if (border_col.w > 0.0f)
{
window->DrawList->AddRect(bb.Min, bb.Max, GetColorU32(border_col), 0.0f);
window->DrawList->AddImage(user_texture_id, bb.Min + ImVec2(1, 1), bb.Max - ImVec2(1, 1), uv0, uv1, GetColorU32(tint_col));
}
else
{
window->DrawList->AddImage(user_texture_id, bb.Min, bb.Max, uv0, uv1, GetColorU32(tint_col));
}
}
最后会在ImGui::End函数里进行批处理和绘制,然后我看到了这个代码:
if (clip_rect.x < fb_width && clip_rect.y < fb_height && clip_rect.z >= 0.0f && clip_rect.w >= 0.0f)
{
// Apply scissor/clipping rectangle
glScissor((int)clip_rect.x, (int)(fb_height - clip_rect.w), (int)(clip_rect.z - clip_rect.x), (int)(clip_rect.w - clip_rect.y));
// Bind texture, Draw, 看到没, 在这里把TextureId最终转换成了GLuint
glBindTexture(GL_TEXTURE_2D, (GLuint)(intptr_t)pcmd->TextureId);
#ifdef IMGUI_IMPL_OPENGL_MAY_HAVE_VTX_OFFSET
if (g_GlVersion >= 320)
glDrawElementsBaseVertex(GL_TRIANGLES, (GLsizei)pcmd->ElemCount, sizeof(ImDrawIdx) == 2 ? GL_UNSIGNED_SHORT : GL_UNSIGNED_INT, (void*)(intptr_t)(pcmd->IdxOffset * sizeof(ImDrawIdx)), (GLint)pcmd->VtxOffset);
else
#endif
glDrawElements(GL_TRIANGLES, (GLsizei)pcmd->ElemCount, sizeof(ImDrawIdx) == 2 ? GL_UNSIGNED_SHORT : GL_UNSIGNED_INT, (void*)(intptr_t)(pcmd->IdxOffset * sizeof(ImDrawIdx)));
}
总之,这应该是为了支持跨平台用的,就是DrawImage的时候,把所有的TextureId的格式换成通用的void*
,然后实际绘制时,根据跑的Platform的类型,解析该TextureId,各个平台的解析都不同:
- OpenGL: ImTextureID = GLuint
- DirectX9: ImTextureID = LPDIRECT3DTEXTURE9
- DirectX11: ImTextureID = ID3D11ShaderResourceView*
- DirectX12: ImTextureID = D3D12_GPU_DESCRIPTOR_HANDLE
感觉写法有点意思,注意一下,这里是直接把类型转换为void*
,比如GLuint
,得到的指针本身的地址就是这个数据的值,该指针所对应的地址的内容是无效的。
Delegating constructors
参考:https://docs.microsoft.com/en-us/cpp/cpp/delegating-constructors?view=msvc-170
像下面这种的,一个类的调用了另外的构造函数的构造函数,叫做Delegating constructor(委托构造函数),这是C++11的新特性,代码如下所示:
class class_c
{
public:
int max;
int min;
int middle;
class_c(int my_max)
{
max = my_max > 0 ? my_max : 10;
}
class_c(int my_max, int my_min) : class_c(my_max)
{
min = my_min > 0 && my_min < max ? my_min : 1;
}
class_c(int my_max, int my_min, int my_middle) : class_c (my_max, my_min)
{
middle = my_middle < max && my_middle > min ? my_middle : 5;
}
};
int main()
{
class_c c1{ 1, 3, 2 };
}
但是这么写,就会编译报错:
class class_a
{
public:
class_a() {}
// member initialization here, no delegate
class_a(string str) : m_string{ str } {}
// can’t do member initialization here, 只能有class_a(str)这一个member-initializer
// error C3511: a call to a delegating constructor shall be the only member-initializer
class_a(string str, double dbl) : class_a(str) , m_double{ dbl } {}
// only member assignment, 这种写法是对的
class_a(string str, double dbl) : class_a(str) { m_double = dbl; }
double m_double{ 1.0 };
string m_string;
};
派生类无法使用Initializer list来初始化基类的对象
写了这么个简单代码,结果报错了:
class Framebuffer
{
public:
uint32_t m_Width = 800;
uint32_t m_Height = 600;
};
class OpenGLFramebuffer : public Framebuffer
{
public:
OpenGLFramebuffer(uint32_t width, uint32_t height);
}
// 这句代码编译报错: "m_Width" is not a nonstatic data member or base class of class "OpenGLFramebuffer"
OpenGLFramebuffer::OpenGLFramebuffer(uint32_t width, uint32_t height): m_Width(width), m_Height(height)
报错信息表示,m_Width
不是OpenGLFramebuffer
的成员、也不是OpenGLFramebuffer
的基类。但是这么写是可以的:
OpenGLFramebuffer::OpenGLFramebuffer(uint32_t width, uint32_t height)
{
m_Width = width;
m_Height = height;
}
事实就是这样的,派生类里继承于基类的成员变量,只可以通过调用基类的构造函数来初始化,可以在派生类的构造函数函数体内对其进行赋值(但是此时变量已经初始化好了)。
原因参考:https://stackoverflow.com/questions/2290733/initialize-parents-protected-members-with-initialization-list-c
参考:https://stackoverflow.com/questions/18479295/member-initializer-does-not-name-a-non-static-data-member-or-base-class
对于一个派生类,对于其不是delegating的构造函数,也就是不调用其他相同类构造函数的构造函数而言,它的初始化顺序是这样的:
- 首先,是先创建最深处的基类,然后慢慢创建到派生类,这个顺序应该很清楚
- 然后会创建该类的直接父类的构造函数,创建其对象
- 然后,会按照成员声明顺序,初始化该类的非static成员
- 最后,会执行该类的构造函数里的内容
不是特别懂,大概意思应该是,在执行:
后面的代码时,该类的父类的构造函数还没被调用,所以不可以这么写,只可以在派生类里调用基类的构造函数,不允许在派生类的initializer_list里初始化继承来的成员变量
能不能直接把fbo得到的Texture进行Resize,作为Viewport
为了研究这个问题,我特意去看了Unity和UE4里Viewport是怎么Resize的。
对于Unity而言,好像是直接对Texture进行Resize的,因为我Viewport里看到的东西总是那么多,只是尺寸变了:
比如我创建了这么一个关卡,下面这是原图,为了保证这个窗口大小在我Resize之前都是一个大小的,我每次都会重置到Unity默认的窗口布局:
横向拉伸后,可以明显看到,这里的中心物体的大小完全没有变化,而如果单纯的拉长贴图,物体也会被拉伸,是不会产生这种效果的,:
纵向拉伸后:
纵向缩短后:
横向缩短后:
我还测试了UE4的viewport的拖拽情况,跟上面的操作方式差不多,得到以下结论:
- 游戏引擎的Viewport拖拽时,展示的结果,绝不是把一张贴图进行Resize得到的效果,没有这么简单
- Unity和UE4拖拽viewport,得到的结果不完全相同,但它们的核心思路都是改变相机Frustum,具体怎么修改的,两个引擎存在差异:
- 对于横轴缩放来说,当Unity的viewport进行横向缩小时,最开始的中心物体大小是不变的,但在某一个值后,再缩小Viewport窗口,物体则会整体变小,此时skybox对应的天际线也会降低(我猜测一开始会缩小Frustum的水平宽度,后来则会增加Far平面与Camera的距离);而横向放大viewport时,中心物体大小基本是不会变化的(只会增加Frustum的宽度);而UE4的viewport进行横向拖拽时,无论是缩小还是放大,物体大小一直是会随着viewport的宽度变大变小而对应变化的(不仅改变Frustum的宽度,好像还拉近了Far平面)。
- 对于竖轴缩放,又不一样。Unity竖轴缩放的效果跟UE4横轴缩放效果类似,物体大小会随着viewport的高度变大变小而对应变化(不仅改变Frustum的宽度,好像还拉近了Far平面);但UE4的竖轴缩放,不会更改物体大小,只会改变可视区间,也就是说画面里的物体不会有任何大小的变化,但是会产生竖直方向上的位移,缩小到最后只能看到部分内容(移动了Frustum的高度值,即y坐标值,且纵向改变了Frustum的长度),如下图所示:
更改Framebuffer里的Color Attachment的size
参考:https://stackoverflow.com/questions/23362497/how-can-i-resize-existing-texture-attachments-at-my-framebuffer
方法很简单,就是用glTexImage2D
来创建贴图,这样的贴图允许动态改变大小:
void OpenGLFramebuffer::ResizeColorAttachment(uint32_t width, uint32_t height)
{
if (m_FramebufferId != -1)
{
glBindTexture(GL_TEXTURE_2D, m_ColorAttachmentTextureId);
// 再次调用它即可
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
}
}
正交矩阵与Zoom和Aspect Radio的关系
由于这里调用的是glm::ortho(left, right, bottom, top, -1.0f, 1.0f)
,left < 0
,right = - left
,bottom < 0
,top = - bottom
。这里作为2D的Camera,默认绘制的区间在横轴[-1.7778f, 1.778f],纵轴[-1, 1]之间,所以这里直接用ZoomLevel来表示top,因为拉近镜头时,ZoomLevel变大,而对应可见区域会变小,所以这里用ZoomLevel来表示top是一种很巧妙的方法。
而Aspect Radio就是bottom与left的比了,这个没啥
glm::ortho(left, right, bottom, top, -1.0f, 1.0f)
这个矩阵,应该是会把横轴为[left, right],纵轴为[bottom, top]的长方形区间,映射为横纵轴均为[-1, 1]的正方形区间。
函数签名我看有两种:
glm::ortho(xmin, xmax, ymin, ymax);
glOrtho( xmin, xmax, ymin, ymax, near, far);
我的几个问题:
- xmin与xmax、ymin与ymax一定是互为相反数吗,如果不一定,那么结果会咋样(毕竟相机不是在原点吗,如果xmin与xmax不对称,那么,难道左右边的长度不一样?还能不能映射到[-1, 1]区间了)
- 这个zmin和zmax有啥用,是不是如果不加,就代表绘制无限Z区间,而设置了,就只设置这个区间的2D投影
没有看到说明,我怀疑就是这样的,即:
- xmin与xmax、ymin与ymax一般都是互为相反数
- zmin和zma应该是,如果不加,就代表绘制无限Z区间,而设置了,就只设置这个区间的2D投影
Dear ImGui的hello world项目
参考:https://blog.conan.io/2019/06/26/An-introduction-to-the-Dear-ImGui-library.html
代码如下所示:
int main()
{
// 一些对OpenGL的初始化
...
// Setup Dear ImGui context
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO &io = ImGui::GetIO();
// Setup Platform/Renderer bindings
ImGui_ImplGlfw_InitForOpenGL(window, true);
ImGui_ImplOpenGL3_Init(glsl_version);
// Setup Dear ImGui style
ImGui::StyleColorsDark();
while (!glfwWindowShouldClose(window))
{
glfwPollEvents();
glClearColor(0.45f, 0.55f, 0.60f, 1.00f);
glClear(GL_COLOR_BUFFER_BIT);
// 相当于Update
// feed inputs to dear imgui, start new frame
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
// rendering our geometries, 这个shader应该是随便写的, 跟ImGui没啥关系
triangle_shader.use();
glBindVertexArray(vao);
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
// render your GUI, 调用ImGui在原本绘制的三角形上, 再画一个按钮
ImGui::Begin("Demo window");
ImGui::Button("Hello!");
ImGui::End();
// Render dear imgui into screen, 把按钮绘制出来
ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
int display_w, display_h;
glfwGetFramebufferSize(window, &display_w, &display_h);
glViewport(0, 0, display_w, display_h);
glfwSwapBuffers(window);
}
}
// 在结束后, 调用
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();