GUI系统建立记录

GUI

ImGui

C++常用的GUI一般是QT和ImGui。
在这里我选择Dear ImGui来开发GUI。

首先,两者最大的区别在于QT属于一种叫Retained Mode的GUI,这种UI的最大特点就是“Stateful”,UI本身记忆状态。一般来说只有当状态改变的时候UI才会重新绘制,当然,在状态改变的时候,你也得建立相应的回调函数来处理对应的事件,不然搞乱状态,数据不同步什么的...

ImGui属于Immediate Mode GUI,这种UI会实时绘制,或者说,每帧绘制一次。UI本身不记忆信息,UI的信息都是在绘制的时候得到。

我个人采用ImGui的原因就是因为它太简单了...
所有的例子都能在其文件imgui_demo.cppexamples/中找到。

当然要让这个UI系统变得好看起来也有点难度,,,,,

UI的建立

ImGUI为不同的渲染API建立了不同的后端,甚至不同的窗口管理库也有相对应的后端。
比如说我当前使用的是OpenGL+GLFW,那么建立起ImGUI就需要把对应的后端实现和Imgui头文件一起拖到项目里。
对我,其需要的文件是这样的

之后就是初始化ImGUI,这一步需要放在初始化Glfw和OpenGL之后,渲染主循环之前

#include"imgui.h"
....
//获取GLFWwindow*  

ImGui::CreateContext();

ImGui_ImplGlfw_InitForOpenGL(windowPtr, true);ImGui_ImplOpenGL3_Init("#version 430");

ImGui_ImplGlfw_InitForOpenGL,该函数初始化了glfw-opengl后端,第一个参数显然是glfw的窗口,第二个参数则是告诉imGui去主动捕获窗口的事件,如果去看下它内部的实现,就是先把用户的回调函数保存下来,再把自己的换上去...当然也可以手动Hack它的事件处理,达到一些有趣的效果

ImGui_ImplOpenGL3_Init则是用来初始化OpenGL后端,其内部填入版本,就跟shader开头一样

在渲染循环的内部还需要生成其imgui帧,而ImGUI的具体设计则需要放在帧的开头和结尾中间。

//比如说   
while(appRun){
//渲染逻辑
//.........//
  ImGui_ImplOpenGL3_NewFrame();
  ImGui_ImplGlfw_NewFrame();
  ImGui::NewFrame();

//---这里放Imgui的逻辑---//
//Like 

  frameCounts ++;
  ImGui::Text("Frame counts: %d",frameCounts );

//或者看看官方的实例
  ShowDemoWindow(true);

  ImGui::Render();
  ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
}

我个人去学习ImGUI的方法就是看ShowDemoWindow里的实现,真的是大而全。

不过一些简单的功能我我还是会在这列一点..
按钮:

if(ImGui::Button("Hello Button")){  //当按钮被按下,函数返回true
    clickTime++;
    std::cout<<"Hello";
}      
ImGui::SameLine();
ImGui::Text("Click button times: %d",clickTime);

imgui的text格式化输入和printf的格式是一摸一样的,所以用起来也很方便啦
Button则是在点击后会返回true,进而执行逻辑。
然后你就能得到这样的结果啦

为什么上面有个标题叫Debug呢?实际上ImGui所有的UI都是渲染在一个所谓的ImGui window上的。
ImGui会自动为你创建一个ImGui window,叫“Debug”,当然你也能手动创建

ImGui::Begin("Hello!");
if (ImGui::Button("Hello Button")) { // 当按钮被按下,函数返回true
  clickTime++;
  std::cout << "Hello";
}
ImGui::SameLine();
ImGui::Text("Click button times: %d", clickTime);
ImGui::BeginChild("I am a sub child");
ImGui::Text("Click button times: %d", clickTime);
ImGui::End();

还有个小小的技巧,在begin中窗口的名字前面加俩#号,就能在之前的窗口里面继续添加控件了!

ImGui::Begin("##Hello!");
ImGui::End();

更进一步

我使用的是ImGui的docking分支,因为它支持了非常有趣的特性
Docking就是类似浏览器页面栏那种,可以把页面栏拖来拖去,让他能够贴在某一边...
Docking分支还额外支持了multi-viewport,就是把类似把浏览器的标签页拖出来成为另一个窗口。

比如这样的效果

这些效果是需要手动开启的,ImGui也确实给了简单的方法来配置它的窗口

  auto &IO = ImGui::GetIO();
  IO.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
  IO.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;

但是注意注意,当启用了multi viewport后,在ImGui的帧结束后还得加上这几句

  if (IO.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) {
    auto backUp = glfwGetCurrentContext();
    ImGui::UpdatePlatformWindows();
    ImGui::RenderPlatformWindowsDefault();
    glfwMakeContextCurrent(backUp);
  }

在Imgui的上下文初始化后,通过改变IO的ConfigFlags来启动一些特性,其可配置的特性可以通过去搜索符号ImGuiConfigFlags_找到,代码里的注释给的真的很全。
除了对于ImGui整体特性的配置,也可以对单个窗口进行一些配置...通过ImGuiWindowFlags来达到这点
比如说我特别喜欢用一个简单的覆盖窗口作为显示Debugger信息的工具

//在渲染循环中   
ImGuiWindowFlags window_flags =
      ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoDocking |
      ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings |
      ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav;
ImGui::SetNextWindowBgAlpha(0.8f);
  if (!ImGui::Begin("Debugger", NULL, window_flags)) {
    return;
  }
//放入组件   
ImGui::End();   

这里可以看到ImGui::Begin其实由三个参数,第一个自然是窗口名字,第二个是个bool*,当不为null时窗口就会出现一个可以按的小叉叉,如果叉掉了这个bool就会变成false,第三个参数就是窗口的配置啦。

ImGui::Begin本身也有返回值,在之前的图里可以看到标题的旁边是有个小箭头,点击它窗口就可以折叠起来。如果该窗口被折叠了,ImGui::Begin()就会返回false,但是,ImGui::End()一定得和ImGui::Begin()成对出现,不论其返回值。


左上角,简单好用的覆盖窗口\( ̄︶ ̄*\))

其他的效果

当然,作为UI,还得有设置窗口位置,大小,或者布局什么...
ImGUI对此做的特别简陋

毕竟是个轻量级UI,原谅他了

在窗口开始前,使用ImGui::SetNextWindowPos();来设置其位置。
但这个窗口位置的表示...额有那么一点点奇怪,他是相当于应用窗口在整个屏幕的绝对位置再加上Imgui窗口的偏移,在默认情况下以应用窗口的左上角作为当前应用的窗口位置

const ImGuiViewport *viewport = ImGui::GetMainViewport();
ImVec2 work_pos =
      viewport->WorkPos; //这个workpos就是我所说的窗口位置了...
      //这个WorkPos是减去了menu-bar/task-bar的位置
      //虽然我没搞懂他的意思,但官方说的话还是放一下
ImGui::SetNextWindowPos(ImVec2{20 + work_pos.x, 20 + work_pos.y});   

哦对了,这个屏幕的(0,0)是在左上角,并且这个坐标往右下角增大....

把这段代码放入渲染上面那个Overlay的前面,就能得到和我一样的窗口效果了!

再进一步,可以手动设置窗口的中心位置,这通过ImGui::SetNextWindowPos的第三个参数来改变

ImGui::SetNextWindowPos(window_pos, ImGuiCond_Always, window_pos_pivot);   

当pivot为(0,0)就是左上角,(1,1)是右下角,那么......以此类推

第二个参数的意思则是设置何时需要改变该窗口的位置这里有三个选项,只能选择一个,不能贪心owo

enum ImGuiCond_
{
    ImGuiCond_None          = 0,        // No condition (always set the variable), same as _Always
    ImGuiCond_Always        = 1 << 0,   // No condition (always set the variable), same as _None
    ImGuiCond_Once          = 1 << 1,   // Set the variable once per runtime session (only the first call will succeed)
    ImGuiCond_FirstUseEver  = 1 << 2,   // Set the variable if the object/window has no persistently saved data (no entry in .ini file)
    ImGuiCond_Appearing     = 1 << 3,   // Set the variable if the object/window is appearing after being hidden/inactive (or the first time)
};

上面说的都是在应用的窗口内改变ImGUI窗口的位置,那么改变窗口内元素的位置呢?
这需要用到ImGUI的Cusor了,这里的Cursor指的是它下一次绘制地点的坐标
用函数SetCursorPos(...)来设置...这里的坐标,再一次的,是屏幕坐标
在设置完后调用对应的Widget绘制函数就好啦...这个我也没操作过,所以只能说个大概qwq

ImGUI的文字

ImGui的文字设置部分是最令我无语的部分,操作之恶心绝无第二
因为,它不支持动态调整字体大小啊哈哈哈哈
而且其字体的操作是基于一种像堆栈一样的机制,通过两个函数来实现:

void PushFont(ImFont* font);
void PopFont();

你也应该猜到怎么切换字体了...对,push之后绘制,然后pop出来
动态字体这事在Issue里不乏有呼声,但作者说这太简单里,我不想做

hahaha

首先是创建字体的API:

ImFont* ImFontAtlas::AddFontFromFileTTF(const char* filename, float size_pixels, const ImFontConfig* font_cfg_template, const ImWchar* glyph_ranges)

第一个参数是TTF字体的文件位置,第二个参数是字体的大小,第三个参数是字体设置,第四个参数是加载的字体范围。
字体的设置可以直接搜索符号ImFontConfig查看,不多赘述
但其中有个很有意思的功能是MergeMode,他能把当前的字体与上一个字体合并(如果当前只有一个字体则不能设置该属性)
比如说这样一段代码

auto &IO = ImGui::GetIO();
IO.Fonts->AddFontDefault();
ImFontConfig config;
config.MergeMode = true;
auto fontChinese = IO.Fonts->AddFontFromFileTTF("resource/Fonts/AlibabaPuHuiTi-2-55-Regular.ttf",
                               m_baseFontSize,&config,IO.Fonts->GetGlyphRangesChineseFull());
IO.Fonts->Build();//把两个字体融合为一个啦,此时改字体在堆栈的最底下,也就是默认字体力   

然后就可以愉快的使用中文辣

ImGui::Button(u8"我有中文你没有,这就是差距")//不要忘了u8字面量

那如何设置不同的字体大小呢?
wow这是个大难题
作者给出的方法是:构建不同大小的字体,push,pop,然后运用style.ScaleAllSizes()这个函数,缩放大小(当然包括UI大小)
当然,由于是缩放,所以丢失些渲染的精度在所难免,所以准备两套字体,一套小的,一套大的,这样在渲染的时候就能做到游刃有余了。

或者你会惊喜的发现有个函数叫做SetWindowFontScale,然而更惊喜的,这已经被弃用了

GUI风格

ImGUI准备了几套默认风格,通过ImGui::StyleColorsxxxxx();函数就能切换
我最喜欢的是ImGui::StyleColorsDark();嘿嘿

ImGUI也提供了更丰富的风格选项...手动修改简直是噩梦

But.....github上有人做了这么一款风格可视化调整工具叫 ImThemes,贼好用,try it

还有怎么让GUI变好看,我也发现了些有意思的教程

那么ImGUI到底能做出好看的UI么,答案是能!啊!
ImGUI的作者明显特别闲,不仅经常回答Issue,还喜欢搞点Gallery玩
比如说这里,而且issue里很多老哥手把手教你搞好康的效果,帧NIce啊

最后

之前大部分的话题都集中在UI的好看,UI的功能上
但重要的问题是:
ImGui是否能满足大型应用UI的需求呢?
如上文所有的展示,我个人认为这种UI的语法虽然简单,但是会使得数据和渲染逻辑混杂到一起去,导致的后果就是混乱,越来越混乱...

我的做法是对ImGui进行一层包装,让他更加的面向对对象

比如imgui的窗口我是这么包装的...

class Panel : public Widget {
public:
  Panel(const std::string &name, float winPosX, float winPosY);
  
  Panel(const std::string &name = "This is a pannel!");
  
  virtual void draw() override = 0;
  
  virtual void addSubWidget(std::shared_ptr<Widget> widget);
  
  virtual void setBgAlpha(float transparent) ;

  virtual void setWinPos(float x, float y) {
    m_winX = x;
    m_winY = y;
  }

  protected: 
  
  //在这一函数里,执行imGui::Begin("Panel name.")
  virtual void panelBegin() = 0;

  //imGui::End();
  virtual void panelEnd() = 0;
};

对于按钮我也有些简单的包装

class BasicButton : public Widget {
public:
  BasicButton(const std::string &Hint);
  virtual void setBtnSize(float x, float y);
  void draw() override;
  Event::Event<> clickEvent;

protected:
};

这些代码光看声明应该就能想到逻辑了吧...我就不放出来了

让UI的逻辑更加集中,绝对不能分散在代码各处,这也很重要
首先我目前在做的东西里,所有的事件逻辑都是发生在类Layer中的,在每帧循环都会去挨个儿Tick layer的逻辑,就像Unity MonoBehavior的OnUpdate
除此之外,还额外加入了一个OnGUIRender函数,所有的GUI逻辑都得在这个函数中被渲染

class Layer {
public:
  Layer(const std::string &name = "Layer");
  virtual ~Layer() {}
  Layer(const Layer &) = delete;
  Layer(Layer &&);

  virtual void OnAttach() {}
  virtual void OnDetach() {}
  virtual void OnUpdate() {}
  virtual void OnGUIRender(){}
};

在主循环中,则有如下的逻辑

//生成Imgui帧
this->m_GuiLayer->Begin();
    
//GUI pass
m_GuiLayer->OnGUIRender();
for (auto &&i : m_layers) {
  i->OnGUIRender();
}

//结束Imgui帧
this->m_GuiLayer->End();

当然,这么做的好处远不止逻辑清晰

如果你注意到imgui的渲染和其他的程序逻辑是发生在一起的,那么当每帧的工作负载太重,ImGUI就会很卡很卡....
那么,把渲染和逻辑分开....下一步我想你猜的到,把渲染放入渲染线程中,逻辑放入逻辑线程中

而分离UI渲染逻辑则是这个架构的小小一环...

这些等我做好了再说(^∀^●)ノシ

END