DirectX11 With Windows SDK–06 使用ImGui

前言

Dear ImGui是一個開源GUI框架。除了UI部分外,本身還支援簡單的鍵鼠交互。目前項目內置的是V1.87版本,大概半年時間會更新一次版本,並且對源碼有小幅度調整。

注意:直接下載源碼使用會導致19章之後的UI效果有誤,修改了源碼imgui_impl_dx11.cpp,需要用項目源碼中的替換下載的。具體原因參考文末

DirectX11 With Windows SDK完整目錄

Github項目源碼

歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裡彙報。

生成ImGui靜態庫

方法一:VS建立項目

在本項目的ImGui文件夾中內置了一個VS項目,你可以參考其做法,也可以直接使用它。具體要用到的頭文件和源文件包括這些:

image

如果你是從網上下載的ImGui包,則需要在根目錄、backends裡面找出上圖這些頭文件和源文件。

在將文件夾中列出的.cpp和.h拖入自建的ImGui項目後,進入項目屬性頁進行改動:

image

部分內容根據自己VS版本進行修改

然後我們需要生成x64 Debug和x64 Release版本的靜態庫,生成位置分別位於:

  • ImGui/x64/Debug/ImGui.lib
  • ImGui/x64/Release/ImGui.lib

然後打開自己之前新建的項目,在屬性頁中找到C/C++ → 附加包含目錄,添加ImGui的目錄進你的項目。

緊接著是鏈接器 → 輸入 → 附加依賴項中直接加入ImGui.lib。在Debug和Release下使用統一使用YourImGuiDir\$(Platform)\$(Configuration)\ImGui.lib,前面是相對或絕對路徑均可。

方法二:使用cmake

在ImGui文件夾中已經包含了一個CMakeLists.txt:

cmake_minimum_required(VERSION 3.14)

aux_source_directory(. IMGUI_DIR_SRCS)
add_library(ImGui STATIC ${IMGUI_DIR_SRCS})

target_include_directories(ImGui PUBLIC .)

你可以將ImGui項目文件夾複製到你的項目路徑內,然後在你項目的CMakeList.txt加上這樣一句話就可以在你的解決方案中添加並使用ImGui庫了:

# ImGui
target_link_libraries(YourTargetName ImGui)

初始化ImGui

D3DApp.h中添加這三個頭文件

#include <imgui.h>
#include <imgui_impl_dx11.h>
#include <imgui_impl_win32.h>

添加D3DApp::InitImGui方法:

bool D3DApp::InitImGui()
{
    IMGUI_CHECKVERSION();
    ImGui::CreateContext();
    ImGuiIO& io = ImGui::GetIO();
    io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;     // 允許鍵盤控制
    io.ConfigWindowsMoveFromTitleBarOnly = true;              // 僅允許標題拖動

    // 設置Dear ImGui風格
    ImGui::StyleColorsDark();

    // 設置平台/渲染器後端
    ImGui_ImplWin32_Init(m_hMainWnd);
    ImGui_ImplDX11_Init(m_pd3dDevice.Get(), m_pd3dImmediateContext.Get());

    return true;

}

D3DApp::Init中調用InitImGui

bool D3DApp::Init()
{
    if (!InitMainWindow())
        return false;

    if (!InitDirect3D())
        return false;

    if (!InitImGui())
        return false;

    return true;
}

然後在D3DApp.cpp的上方添加一句話來引用外部函數:

extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);

在消息處理函數D3DApp::MsgProc的開頭添加ImGui的處理:

LRESULT D3DApp::MsgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    if (ImGui_ImplWin32_WndProcHandler(m_hMainWnd, msg, wParam, lParam))
        return true;
    
    // ...
}

D3DApp::Run()中,我們插入這三個函數用於啟動ImGui新一幀的記錄與繪製:

int D3DApp::Run()
{
    MSG msg = { 0 };

    m_Timer.Reset();

    while (msg.message != WM_QUIT)
    {
        if (PeekMessage(&msg, 0, 0, 0, PM_REMOVE))
        {
            TranslateMessage(&msg);
            DispatchMessage(&msg);
        }
        else
        {
            m_Timer.Tick();

            if (!m_AppPaused)
            {
                CalculateFrameStats();
                // 這裡添加
                ImGui_ImplDX11_NewFrame();     
                ImGui_ImplWin32_NewFrame();
                ImGui::NewFrame();
                // --------
                UpdateScene(m_Timer.DeltaTime());
                DrawScene();
            }
            else
            {
                Sleep(100);
            }
        }
    }

    return (int)msg.wParam;
}

最後就是在GameApp::DrawScene()中插入這兩句:

void GameApp::UpdateScene(float dt)
{
    // 可以在這之前調用ImGui的UI部分
}

void GameApp::DrawScene()
{
    
    // 可以在這之前調用ImGui的UI部分
    // Direct3D 繪製部分
    
    ImGui::Render();
    // 下面這句話會觸發ImGui在Direct3D的繪製
    // 因此需要在此之前將後備緩衝區綁定到渲染管線上
    ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());

    HR(m_pSwapChain->Present(0, 0));
}

這樣就完成了ImGui的初始化。接下來可以在UpdateScene裡面放幾個ImGui的實例窗口看看:

void GameApp::UpdateScene(float dt)
{
    // ImGui內部示例窗口
    ImGui::ShowAboutWindow();
    ImGui::ShowDemoWindow();
    ImGui::ShowUserGuide();
}

出現這些窗口且可以操作的話就是成功了,但是在前面的程式碼中已經禁用了雙擊窗口區域摺疊的功能。

image

需要注意的是程式運行後,exe所在的路徑會生成一個imgui.ini的文件,用於記錄窗口的布局,這樣下次打開的話就會保持先前的窗口狀態。可以在將布局弄好後,把imgui.ini複製複製到項目路徑,然後通過cmake複製過去:

file(COPY imgui.ini DESTINATION ${CMAKE_CURRENT_BINARY_DIR})

項目窗口實例

通常一個窗口的結構為:

static bool is_open = true;
if (ImGui::Begin("Window" /*, &is_open */))  // 窗口右上角加上X關閉
{
    // 添加組件
}
ImGui::end();

以本項目的為例:

//
// 自定義窗口與操作
//
static float tx = 0.0f, ty = 0.0f, phi = 0.0f, theta = 0.0f, scale = 1.0f, fov = XM_PIDIV2;
static bool animateCube = true, customColor = false;
if (animateCube)
{
    phi += 0.3f * dt, theta += 0.37f * dt;
    phi = XMScalarModAngle(phi);
    theta = XMScalarModAngle(theta);
}

if (ImGui::Begin("Use ImGui"))
{
    ImGui::Checkbox("Animate Cube", &animateCube);   // 複選框
    ImGui::SameLine(0.0f, 25.0f);                    // 下一個控制項在同一行往右25像素單位
    if (ImGui::Button("Reset Params"))               // 按鈕
    {
        tx = ty = phi = theta = 0.0f;
        scale = 1.0f;
        fov = XM_PIDIV2;
    }
    ImGui::SliderFloat("Scale", &scale, 0.2f, 2.0f);  // 拖動控制物體大小

    ImGui::Text("Phi: %.2f degrees", XMConvertToDegrees(phi));     // 顯示文字,用於描述下面的控制項 
    ImGui::SliderFloat("##1", &phi, -XM_PI, XM_PI, "");            // 不顯示控制項標題,但使用##來避免標籤重複
                                                 // 空字元串避免顯示數字
    ImGui::Text("Theta: %.2f degrees", XMConvertToDegrees(theta));
    // 另一種寫法是ImGui::PushID(2);
    // 把裡面的##2刪去
    ImGui::SliderFloat("##2", &theta, -XM_PI, XM_PI, "");          
    // 然後加上ImGui::PopID(2);
    
    ImGui::Text("Position: (%.1f, %.1f, 0.0)", tx, ty);

    ImGui::Text("FOV: %.2f degrees", XMConvertToDegrees(fov));
    ImGui::SliderFloat("##3", &fov, XM_PIDIV4, XM_PI / 3 * 2, "");

    if (ImGui::Checkbox("Use Custom Color", &customColor))
        m_CBuffer.useCustomColor = customColor;
    // 下面的控制項受上面的複選框影響
    if (customColor)
    {
        ImGui::ColorEdit3("Color", reinterpret_cast<float*>(&m_CBuffer.color));  // 編輯顏色
    }
}
ImGui::End();

然後就可以得到這樣的一個窗口:

image

為了讓立方體顯示自己設置的顏色,著色器改為了下面這樣:

// Cube.hlsli
cbuffer ConstantBuffer : register(b0)
{
    matrix g_World; // matrix可以用float4x4替代。不加row_major的情況下,矩陣默認為列主矩陣,
    matrix g_View;  // 可以在前面添加row_major表示行主矩陣
    matrix g_Proj;  // 該教程往後將使用默認的列主矩陣,但需要在C++程式碼端預先將矩陣進行轉置。
    vector g_Color;
    uint g_UseCustomColor;
}


struct VertexIn
{
    float3 posL : POSITION;
    float4 color : COLOR;
};

struct VertexOut
{
    float4 posH : SV_POSITION;
    float4 color : COLOR;
};

// Cube_VS.hlsl
VertexOut VS(VertexIn vIn)
{
    VertexOut vOut;
    vOut.posH = mul(float4(vIn.posL, 1.0f), g_World);  // mul 才是矩陣乘法, 運算符*要求操作對象為
    vOut.posH = mul(vOut.posH, g_View);               // 行列數相等的兩個矩陣,結果為
    vOut.posH = mul(vOut.posH, g_Proj);               // Cij = Aij * Bij
    vOut.color = vIn.color;                         // 這裡alpha通道的值默認為1.0
    return vOut;
}

// Cube_PS.hlsl
// 像素著色器
float4 PS(VertexOut pIn) : SV_Target
{
    return g_UseCustomColor ? g_Color : pIn.color;
}

然後常量緩衝區記得更新:

m_CBuffer.world = XMMatrixTranspose(
    XMMatrixScalingFromVector(XMVectorReplicate(scale)) * 
    XMMatrixRotationX(phi) * XMMatrixRotationY(theta) * 
    XMMatrixTranslation(tx, ty, 0.0f));
m_CBuffer.proj = XMMatrixTranspose(XMMatrixPerspectiveFovLH(fov, AspectRatio(), 1.0f, 1000.0f));
// 更新常量緩衝區
D3D11_MAPPED_SUBRESOURCE mappedData;
HR(m_pd3dImmediateContext->Map(m_pConstantBuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedData));
memcpy_s(mappedData.pData, sizeof(m_CBuffer), &m_CBuffer, sizeof(m_CBuffer));
m_pd3dImmediateContext->Unmap(m_pConstantBuffer.Get(), 0);

現在嘗試一下效果:

image

利用ImGui的IO事件

通過ImGui本身提供的函數,我們能夠獲取到一些常用的鍵鼠事件:

ImVec2 pos = ImGui::GetCursorPos();         // 滑鼠位置
bool active = ImGui::IsMouseDragging(ImGuiMouseButton_Left);  // 滑鼠左鍵是否在拖動
active = ImGui::IsMouseDown(ImGuiMouseButton_Right);		// 滑鼠右鍵是否處於按下狀態
active = ImGui::IsKeyPressed(ImGuiKey_W);   // 是否剛按下W鍵
active = ImGui::IsKeyReleased(ImGuiKey_S);	// 是否剛鬆開S鍵
active = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left);  // 是否雙擊左鍵
// ...

還有一些事件無法通過函數獲取的,我們可以使用ImGuiIO來獲取:

ImGuiIO& io = ImGuiIO::GetIO();
auto& delta = io.MouseDelta; // 當前幀滑鼠位移量
io.MouseWheel;               // 滑鼠滾輪

下面展示了利用ImGui的IO事件操作物體:

// 不允許在操作UI時操作物體
if (!ImGui::IsAnyItemActive())
{
    // 滑鼠左鍵拖動平移
    if (ImGui::IsMouseDragging(ImGuiMouseButton_Left))
    {
        tx += io.MouseDelta.x * 0.01f;
        ty -= io.MouseDelta.y * 0.01f;
    }
    // 滑鼠右鍵拖動旋轉
    else if (ImGui::IsMouseDragging(ImGuiMouseButton_Right))
    {
        phi -= io.MouseDelta.y * 0.01f;
        theta -= io.MouseDelta.x * 0.01f;
    }
    // 滑鼠滾輪縮放
    else if (io.MouseWheel != 0.0f)
    {
        scale += 0.02f * io.MouseWheel;
        if (scale > 2.0f)
            scale = 2.0f;
        else if (scale < 0.2f)
            scale = 0.2f;
    }
}

現在就可以操作這個立方體了:

image

至此ImGui就算是入門了。由於ImGui本身是沒有文檔的,讀者需要通過Dear ImGui Demo的窗口來尋找自己需要的控制項,然後在imgui_demo.cpp中搜索對應位置的控制項以查看它程式碼是怎麼用的。這裡也推薦一個網站方便檢索:ImGui Manual (pthom.github.io)

後備緩衝區使用sRGB時需要解決的問題(進階)

初學者可以跳過這一段,在遇到下面UI過亮的情況時候可以回頭看。

通常情況下我們使用的渲染目標格式是DXGI_FORMAT_R8G8B8A8_UNORM的,而如果是DXGI_FORMAT_R8G8B8A8_UNORM_SRGB的話,會導致ImGui缺乏伽馬校正而過亮,就像下面的圖這樣:

ImGui本身是對sRGB無動於衷的。根據sRGB and linear color spaces · Issue #578 · ocornut/imgui,我們可以利用ImGui提供的沒有使用到的枚舉值ImGuiConfigFlags_IsSRGB,然後需要對ImGui的源碼做一些修改。

imgui_impl_dx11.cpp大致382行的位置,我們使用如下程式碼替換vertexShader,使得能夠根據ImGuiConfigFlags_IsSRGB的設置與否來決定是否需要去除伽馬校正:

static const char* vertexShader = nullptr;
if (ImGui::GetIO().ConfigFlags & ImGuiConfigFlags_IsSRGB)
{
    vertexShader = 
        "cbuffer vertexBuffer : register(b0) \
                {\
                  float4x4 ProjectionMatrix; \
                };\
                struct VS_INPUT\
                {\
                  float2 pos : POSITION;\
                  float4 col : COLOR0;\
                  float2 uv  : TEXCOORD0;\
                };\
                \
                struct PS_INPUT\
                {\
                  float4 pos : SV_POSITION;\
                  float4 col : COLOR0;\
                  float2 uv  : TEXCOORD0;\
                };\
                \
                PS_INPUT main(VS_INPUT input)\
                {\
                  PS_INPUT output;\
                  output.pos = mul( ProjectionMatrix, float4(input.pos.xy, 0.f, 1.f));\
                  output.col = pow(input.col, 2.2f);\
                  output.uv  = input.uv;\
                  return output;\
                }";
}
else
{
    vertexShader =
        "cbuffer vertexBuffer : register(b0) \
                {\
                  float4x4 ProjectionMatrix; \
                };\
                struct VS_INPUT\
                {\
                  float2 pos : POSITION;\
                  float4 col : COLOR0;\
                  float2 uv  : TEXCOORD0;\
                };\
                \
                struct PS_INPUT\
                {\
                  float4 pos : SV_POSITION;\
                  float4 col : COLOR0;\
                  float2 uv  : TEXCOORD0;\
                };\
                \
                PS_INPUT main(VS_INPUT input)\
                {\
                  PS_INPUT output;\
                  output.pos = mul( ProjectionMatrix, float4(input.pos.xy, 0.f, 1.f));\
                  output.col = input.col;\
                  output.uv  = input.uv;\
                  return output;\
                }";
}

然後在D3DApp::InitImGui中添加ImGuiConfigFlags_IsSRGB標誌:

IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;     // 允許鍵盤控制
io.ConfigFlags |= ImGuiConfigFlags_IsSRGB;                // 標記當前使用的是SRGB,目前對ImGui源碼有修改
io.ConfigWindowsMoveFromTitleBarOnly = true;              // 僅允許標題拖動

這樣顯示就正常了。

DirectX11 With Windows SDK完整目錄

Github項目源碼

歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裡彙報。