Modern C++ GUI: Debug Tools & Dashboards with Dear ImGui
Why Dear ImGui?
Printf-based debugging works until your program runs at 60 frames per second and you need to watch 20 variables simultaneously while tweaking constants without recompiling. Dear ImGui solves this: it is a C++ immediate-mode GUI library that is trivial to drop into an existing OpenGL/Vulkan/DirectX render loop and gives you sliders, graphs, tables, and log windows in real time.
It is widely used in game engines and their in-house tools, emulators, simulation software, robot-control GUIs, and virtually any situation where a developer wants a visual interface without committing to a full UI framework.
Immediate mode is the key concept. Unlike retained-mode toolkits (Qt, wxWidgets) that build a widget object tree and fire callbacks, ImGui rebuilds the UI from scratch every frame:
// Every frame — no widget objects survive between calls
if (ImGui::Button("Reset"))
simulation.reset(); // called only when clicked
ImGui::SliderFloat("Speed", &speed, 0.0f, 100.0f);
// 'speed' is YOUR variable — ImGui reads and writes it in place
There are no signals, no XML layout files, no style sheets to maintain. The result is extremely low API surface — you learn 20 functions and can build almost anything.
The practical benefit of immediate mode is that the UI can never go out of sync with your data. In a retained toolkit, displaying a value means creating a label widget and remembering to update it whenever the value changes; forgetting one update path gives you a stale display. In ImGui, ImGui::Text("%d", enemies.size()) runs every frame, so it always shows the current value. Adding or removing UI is just adding or removing a function call — which is why ImGui is so good for debug tools that change constantly.
“Immediate mode” describes the API, not the absence of all state. ImGui internally remembers window positions and sizes, which widget is hovered or being dragged, scroll offsets, and collapsed headers — keyed by each widget’s ID, which is derived from its label. That is why labels matter (see “Avoiding ID Clashes” below). What ImGui never holds is your data: the speed above lives in your program, and ImGui reads and writes it through the pointer only during the call.
The cost is that the whole UI code runs every frame. For typical debug panels that is a fraction of a millisecond, but the application also re-renders continuously, so a standalone ImGui tool keeps a CPU core and the GPU busy even when nothing changes. For desktop utilities, replacing glfwPollEvents() with glfwWaitEventsTimeout(0.1) lets the program sleep until there is input.
Project Structure
Dear ImGui is a header+source drop-in. The minimal set of files:
your-project/
├── imgui/ ← copy from the Dear ImGui repo
│ ├── imgui.h
│ ├── imgui.cpp
│ ├── imgui_draw.cpp
│ ├── imgui_tables.cpp
│ ├── imgui_widgets.cpp
│ ├── backends/
│ │ ├── imgui_impl_glfw.h
│ │ ├── imgui_impl_glfw.cpp
│ │ ├── imgui_impl_opengl3.h
│ │ └── imgui_impl_opengl3.cpp
├── main.cpp
└── CMakeLists.txt
A minimal CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(imgui_demo)
find_package(OpenGL REQUIRED)
find_package(glfw3 REQUIRED)
add_executable(demo
main.cpp
imgui/imgui.cpp
imgui/imgui_draw.cpp
imgui/imgui_tables.cpp
imgui/imgui_widgets.cpp
imgui/backends/imgui_impl_glfw.cpp
imgui/backends/imgui_impl_opengl3.cpp
)
target_include_directories(demo PRIVATE imgui imgui/backends)
target_link_libraries(demo PRIVATE OpenGL::GL glfw)
Compiling the sources directly into your target, rather than linking a prebuilt library, is the approach the Dear ImGui README recommends. The library has no build system of its own by design: it is a handful of .cpp files with no dependencies beyond the C++ standard library, and each project picks the backends it needs. Pin a specific release (by copying a tagged version or using a git submodule at a tag), because the API evolves — functions get renamed or obsoleted between versions, and tutorials written for older releases may not compile against the latest one. Keep imgui_demo.cpp in the build during development: calling ImGui::ShowDemoWindow() gives you a live, searchable catalog of every widget with the source code that produced it, which is the fastest way to find how to do something.
The backend files come in pairs: a platform backend (GLFW, SDL, Win32) that feeds mouse, keyboard, and window events into ImGui, and a renderer backend (OpenGL3, Vulkan, DirectX) that turns ImGui’s draw lists into GPU calls. They are independent, so GLFW + Vulkan or SDL + DirectX 11 are just a different pair of files.
The Frame Order
Every ImGui frame follows a strict sequence. Getting this wrong causes blank windows or crashes:
1. Backend::NewFrame() ← poll input, compute delta-time
2. ImGui::NewFrame() ← start building the UI
3. ImGui::Begin() ... ImGui::End() ← define windows and widgets
4. ImGui::Render() ← emit draw commands
5. Backend::RenderDrawData() ← hand draw list to GPU
In code:
// 1 & 2 — start of frame
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
// 3 — your UI code
ImGui::Begin("Debug");
ImGui::Text("Frame time: %.3f ms", 1000.0f / ImGui::GetIO().Framerate);
ImGui::End();
// 4 & 5 — render
ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
The split between ImGui::Render() and RenderDrawData() is what makes ImGui renderer-agnostic. Render() does not touch the GPU; it finalizes a list of vertices, indices, and draw commands (textured triangles with clip rectangles) in CPU memory. The renderer backend then uploads and draws them. You can therefore draw your 3D scene first and ImGui last, so the UI appears on top — the usual order in a game loop.
The ordering rules are enforced by assertions in debug builds, and the assertion messages are the main debugging aid. Calling a widget function before NewFrame() or after Render() triggers an assert such as “Forgot to call NewFrame()?”, and a Begin() without its matching End() reports a mismatch at the end of the frame. Unlike most libraries, ImGui treats these as programmer errors rather than recoverable conditions, so do not compile with NDEBUG while integrating it — the asserts point straight at the mistake.
Complete Minimal Example
A full working program with GLFW + OpenGL3 backend:
#include <GLFW/glfw3.h>
#include "imgui.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"
#include <cstdio>
#include <vector>
int main() {
if (!glfwInit()) return 1;
// OpenGL 3.3 core profile
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWwindow* window = glfwCreateWindow(1280, 720, "Dear ImGui Demo", nullptr, nullptr);
if (!window) return 1;
glfwMakeContextCurrent(window);
glfwSwapInterval(1); // vsync
// ImGui setup
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
ImGui::StyleColorsDark();
ImGui_ImplGlfw_InitForOpenGL(window, true);
ImGui_ImplOpenGL3_Init("#version 330");
// Persistent state — MUST survive between frames
static float speed = 10.0f;
static bool show_debug = true;
static int counter = 0;
static std::vector<float> frame_times;
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
// --- ImGui frame start ---
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
// --- Your UI code ---
if (show_debug) {
ImGui::Begin("Control Panel", &show_debug);
ImGui::Text("FPS: %.1f", io.Framerate);
ImGui::Separator();
ImGui::SliderFloat("Speed", &speed, 0.0f, 100.0f);
ImGui::Text("Current speed: %.2f", speed);
if (ImGui::Button("Increment counter"))
++counter;
ImGui::SameLine();
ImGui::Text("Count: %d", counter);
// Live frame-time graph
frame_times.push_back(1000.0f / io.Framerate);
if (frame_times.size() > 120) frame_times.erase(frame_times.begin());
ImGui::PlotLines("Frame ms",
frame_times.data(),
static_cast<int>(frame_times.size()),
0, nullptr, 0.0f, 50.0f,
ImVec2(0, 80));
ImGui::End();
}
// --- Render ---
ImGui::Render();
int display_w, display_h;
glfwGetFramebufferSize(window, &display_w, &display_h);
glViewport(0, 0, display_w, display_h);
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
glfwSwapBuffers(window);
}
// Cleanup
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
glfwDestroyWindow(window);
glfwTerminate();
}
A few lines in this program deserve attention. The second argument of ImGui_ImplGlfw_InitForOpenGL(window, true) tells the backend to install its own GLFW callbacks and chain to any you installed before; if your application sets GLFW callbacks after this call, it replaces ImGui’s and input stops reaching the UI — a common “the buttons don’t respond” bug. The "#version 330" string must match the context you requested; macOS only offers core profiles with GLFW_OPENGL_FORWARD_COMPAT set, and without that hint glfwCreateWindow returns nullptr there.
When ImGui runs on top of a game or 3D viewport, the application must decide who gets each input event. ImGui sets io.WantCaptureMouse and io.WantCaptureKeyboard every frame; check them before passing input to your camera controller or game logic. Without this, dragging a slider also rotates the camera behind it, and typing in an InputText moves the player.
The frame-time graph erases from the front of a std::vector every frame, which is O(n) — negligible for 120 floats, but the dashboard below shows the more scalable ring-buffer approach that PlotLines supports through its values_offset parameter.
Text, sliders, color pickers, and tables
Text, Buttons, and Input
ImGui::Text("Position: (%.2f, %.2f)", x, y); // formatted text
ImGui::TextColored(ImVec4(1,0,0,1), "ERROR: %s", msg); // colored text
if (ImGui::Button("Fire Missile")) // returns true on click
fireMissile();
static char buf[256] = "";
ImGui::InputText("Name", buf, sizeof(buf)); // text input field (fixed-size buffer)
static bool enabled = true;
ImGui::Checkbox("Enable lighting", &enabled); // checkbox
Most widget functions return bool, and the meaning is consistent: “the user interacted with this widget in this frame” — clicked for buttons, changed the value for sliders, checkboxes, and inputs. That return value is how you react to changes without callbacks: if (ImGui::Checkbox("Enable lighting", &enabled)) rebuildShaders(); runs the expensive work only on the frame the checkbox toggles. InputText works on a fixed char buffer; to edit a std::string directly, include misc/cpp/imgui_stdlib.h and its .cpp from the ImGui repository, which adds an overload that resizes the string as the user types.
Sliders and Drag Controls
static float brightness = 1.0f;
ImGui::SliderFloat("Brightness", &brightness, 0.0f, 2.0f);
static int count = 10;
ImGui::SliderInt("Count", &count, 1, 100);
static float pos[3] = {0, 0, 0};
ImGui::DragFloat3("Position", pos, 0.1f); // drag to change, faster than slider
Color Picker
static float color[4] = {1.0f, 0.5f, 0.0f, 1.0f}; // RGBA
ImGui::ColorEdit4("Ambient color", color);
Collapsible Sections
if (ImGui::CollapsingHeader("Physics")) {
ImGui::SliderFloat("Gravity", &gravity, -20.0f, 0.0f);
ImGui::SliderFloat("Friction", &friction, 0.0f, 1.0f);
}
if (ImGui::CollapsingHeader("Rendering")) {
ImGui::Checkbox("Wireframe", &wireframe);
ImGui::Checkbox("Show normals", &show_normals);
}
Tables
if (ImGui::BeginTable("entities", 3,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_Resizable))
{
ImGui::TableSetupColumn("ID");
ImGui::TableSetupColumn("Position");
ImGui::TableSetupColumn("Health");
ImGui::TableHeadersRow();
for (const auto& e : entities) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0); ImGui::Text("%d", e.id);
ImGui::TableSetColumnIndex(1); ImGui::Text("(%.1f, %.1f)", e.x, e.y);
ImGui::TableSetColumnIndex(2); ImGui::Text("%.0f%%", e.health);
}
ImGui::EndTable();
}
Building a Live Dashboard
A server monitoring dashboard that reads from a shared stats struct:
// stats.h — updated by background thread, read by UI
struct ServerStats {
std::atomic<int> active_connections{0};
std::atomic<int> requests_per_second{0};
std::atomic<float> cpu_usage{0.0f};
std::atomic<float> memory_mb{0.0f};
static constexpr int HISTORY = 120;
// ring buffer updated under a mutex
std::array<float, HISTORY> cpu_history{};
std::array<float, HISTORY> rps_history{};
int history_offset = 0;
std::mutex history_mutex;
};
extern ServerStats g_stats;
// In the ImGui frame:
void renderDashboard() {
ImGui::SetNextWindowSize(ImVec2(500, 400), ImGuiCond_FirstUseEver);
ImGui::Begin("Server Dashboard");
// Summary row
ImGui::Text("Connections: %d", g_stats.active_connections.load());
ImGui::SameLine(200);
ImGui::Text("RPS: %d", g_stats.requests_per_second.load());
ImGui::Separator();
// CPU gauge
float cpu = g_stats.cpu_usage.load();
char overlay[32];
snprintf(overlay, sizeof(overlay), "CPU %.1f%%", cpu);
ImGui::ProgressBar(cpu / 100.0f, ImVec2(-1, 0), overlay);
// Memory
float mem = g_stats.memory_mb.load();
ImGui::Text("Memory: %.1f MB", mem);
// Historical graphs — copy under lock to avoid tearing
float cpu_snap[ServerStats::HISTORY];
float rps_snap[ServerStats::HISTORY];
int offset;
{
std::lock_guard lock(g_stats.history_mutex);
std::copy(g_stats.cpu_history.begin(), g_stats.cpu_history.end(), cpu_snap);
std::copy(g_stats.rps_history.begin(), g_stats.rps_history.end(), rps_snap);
offset = g_stats.history_offset;
}
ImGui::PlotLines("CPU %", cpu_snap, ServerStats::HISTORY, offset,
nullptr, 0.0f, 100.0f, ImVec2(0, 60));
ImGui::PlotLines("RPS", rps_snap, ServerStats::HISTORY, offset,
nullptr, 0.0f, 1000.0f, ImVec2(0, 60));
ImGui::End();
}
The design splits shared data by how it is updated. Single values that are written independently (cpu_usage, active_connections) are atomics, so the UI can read them without locking and never sees a torn value. The history arrays must be read consistently with history_offset, so they sit behind a mutex, and the UI copies them out under the lock and then draws from the copy. Holding the lock only for the copy — not for the PlotLines calls — keeps the worker thread from waiting on rendering, which could otherwise stall for a whole frame (16 ms at 60 Hz) whenever the UI is drawing.
values_offset tells PlotLines where the ring buffer’s oldest sample is, so the graph scrolls correctly without shifting the array. ImGuiCond_FirstUseEver applies the window size only the first time the window appears (or when there is no saved layout), so a user who resizes the dashboard keeps their size across runs. Note that four separate atomic loads are not a consistent snapshot of all four values together; for a monitoring display that does not matter, but if you ever compute a ratio from two of them, read both under the same lock.
Avoiding ID Clashes
ImGui identifies widgets by the text label you pass. When you have two buttons both labeled “Delete”, ImGui sees them as the same widget:
// PROBLEM: every button has the same label → same ID
for (auto& item : items) {
if (ImGui::Button("Delete")) deleteItem(item); // clicks may hit the wrong item, or none
}
// FIX 1: PushID/PopID with the loop index
for (int i = 0; i < items.size(); ++i) {
ImGui::PushID(i);
if (ImGui::Button("Delete")) deleteItem(items[i]);
ImGui::PopID();
}
// FIX 2: PushID/PopID with a stable unique value
for (const auto& item : items) {
ImGui::PushID(item.id);
if (ImGui::Button("Delete")) deleteItem(item);
ImGui::PopID();
}
// FIX 3: embed unique value in label with ## separator
ImGui::Button("Delete##42"); // displays "Delete", ID is "Delete##42"
Always use PushID/PopID around loops that create multiple widgets of the same type.
The index and the stable-id variants behave differently when the list changes. With PushID(i), deleting item 0 shifts every remaining item to a new index, so ImGui-side state such as an open tree node or an in-progress drag moves to the wrong row. PushID(item.id) (or PushID(&item) for objects whose address is stable) keeps the state attached to the right entity. One more subtlety: deleteItem inside the loop modifies the container you are iterating over, which invalidates iterators — collect the id to delete and remove it after the loop. Recent ImGui versions highlight conflicting IDs when you hover the widgets, which makes this class of bug much easier to spot.
Threading Rules
Dear ImGui is not thread-safe. All calls must come from the rendering thread. The correct pattern:
// Worker thread — update shared state only
void workerThread(ServerStats& stats) {
while (running) {
stats.cpu_usage.store(measureCPU());
stats.active_connections.store(countConnections());
{
std::lock_guard lock(stats.history_mutex);
stats.cpu_history[stats.history_offset] = stats.cpu_usage.load();
stats.history_offset = (stats.history_offset + 1) % ServerStats::HISTORY;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
// Main/render thread — only place ImGui is called
void renderThread() {
while (!glfwWindowShouldClose(window)) {
// ... ImGui frame sequence ...
renderDashboard(); // reads g_stats, all atomics or under lock
}
}
Frame-order, state, and ID mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| State in a stack local | Slider jumps back every frame | Use static local, member var, or global |
| Wrong NewFrame/Render order | Blank window or crash | Always: backend NewFrame → ImGui::NewFrame → widgets → Render → RenderDrawData |
| Missing backend init | Black screen, no input | Call ImGui_ImplGlfw_Init and ImGui_ImplOpenGL3_Init before the loop |
| Duplicate widget labels | Button only works once / wrong target | Use PushID/PopID or ##suffix |
| Calling ImGui from worker thread | Random crashes | Route all ImGui calls to render thread |
Forgetting ImGui::End() | Assertion failure | Every Begin() needs a matching End() |
Shipping ImGui in a real application
Conditional compilation: wrap the entire UI behind a macro so release builds include zero ImGui code:
#ifdef ENABLE_DEBUG_UI
renderDashboard();
#endif
Saving window layout: ImGui saves window positions and sizes to imgui.ini in the working directory by default. Set io.IniFilename to choose another path, or to nullptr to disable it:
io.IniFilename = "debug_layout.ini"; // nullptr to disable
The pointer is stored, not copied, so it must point to a string that outlives the ImGui context — a string literal is fine, but someStdString.c_str() from a temporary is a dangling pointer that corrupts the save path. Because the default is relative to the working directory, launching the tool from a different folder silently creates a second imgui.ini; an absolute path next to the executable avoids that.
Docking: enable the docking branch for tabbable, dockable windows (the API is available only in the docking branch of the repository, and the DockSpaceOverViewport signature has changed between releases):
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
// Then in each frame:
ImGui::DockSpaceOverViewport();
Fonts: load a custom font for better readability at small sizes:
ImGuiIO& io = ImGui::GetIO();
io.Fonts->AddFontFromFileTTF("fonts/Roboto-Regular.ttf", 14.0f);
// Must be called before the first frame
The default font is a small bitmap font that covers ASCII only. Non-Latin text (Korean, Japanese, Cyrillic) renders as ? boxes until you load a TTF with the required glyph ranges — for example io.Fonts->GetGlyphRangesKorean() passed as the last argument — which also makes the font atlas texture much larger. On high-DPI displays, text looks tiny or blurry unless you scale both the font size and the style (ImGui::GetStyle().ScaleAllSizes(scale)) by the monitor’s content scale, which GLFW reports through glfwGetWindowContentScale. AddFontFromFileTTF returns nullptr if the file is not found, and a relative path is again resolved against the working directory, so a missing font is a common reason a tool that works from the IDE looks wrong when launched by double-clicking.
ImGui rules that prevent the common bugs
- Frame order is sacred: backend NewFrame → ImGui::NewFrame → widgets → ImGui::Render → backend RenderDrawData
- State must be persistent — local variables reset every frame; use
static, members, or globals for slider values PushID/PopIDaround loops to prevent ID collisions for repeated widget labels- Not thread-safe — call ImGui only from the render thread; use atomics/mutexes to share data with worker threads
PlotLines+ atomic stats = live graphs with minimal code; copy under lock to avoid tearingCollapsingHeaderandBeginTablekeep complex dashboards organized- Release builds: wrap all ImGui code in
#ifdefto compile it out for shipping
Frequently Asked Questions (FAQ)
Q. Why do buttons with the same label interfere with each other?
A. Dear ImGui derives each widget’s ID from its label combined with the current ID stack, so two “Delete” buttons in the same window share one ID and clicks or state end up on the wrong widget. Add a hidden suffix after ## (for example “Delete##row3”), which is part of the ID but not displayed, or wrap each loop iteration in ImGui::PushID(i) / ImGui::PopID().