The jump from 'engine that renders things' to 'engine I can actually use' is mostly editor UX. Here is how to build a dockable, drag-and-drop editor with Dear ImGui that does not fight you.
The first version of every hobby engine looks the same: a viewport, a sidebar, a few buttons. You open it, you appreciate that the renderer works, and then you never use it because moving a single asset takes six clicks. The second version of every hobby engine, if it survives, has a dockable editor. That is the version that starts to feel like a real tool.
I have done this enough times — Pi, Photon, the C++ engine — to have opinions about what makes the editor experience snap into place and what makes it stay a toy.
Dear ImGui is immediate-mode: there is no retained widget tree. Every frame, your code says "here is a window, here is a slider, here is the value, draw it now". The library tracks state internally by ID. There is no model, no view, no controller, no signals. You write:
if (ImGui::SliderFloat("Sun Intensity", &scene.sun.intensity, 0.0f, 10.0f)) {
scene.dirty = true;
}
and the slider both renders and reports its change in the same line. For a tool that is being written by one person and changed every week, this is the right tradeoff. Retained-mode UIs (Qt, GTK, web frameworks) optimize for stable layouts authored once. Engine editors are the opposite — every system you build adds three new panels.
The pieces of ImGui you actually need for a serious editor:
imgui_internal.h plus the docking branch's DockSpace and DockBuilder APIs. Stable, used by every major game studio that ships ImGui-based tools.That is the shipping configuration for almost every ImGui-based engine editor in the wild.
The easy mistake is to put Begin/End calls for your panels inside the main render loop in a fixed order. The dockable version requires a host window that owns a dockspace, and panels that simply ask to be docked into it.
void Editor::BeginDockspace() {
const ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(vp->WorkPos);
ImGui::SetNextWindowSize(vp->WorkSize);
ImGui::SetNextWindowViewport(vp->ID);
ImGuiWindowFlags flags =
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus |
ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoDocking;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::Begin("EditorRoot", nullptr, flags);
ImGui::PopStyleVar(3);
ImGuiID dockspace = ImGui::GetID("EditorDockSpace");
ImGui::DockSpace(dockspace, ImVec2(0, 0), ImGuiDockNodeFlags_None);
DrawMenuBar();
}
End with ImGui::End() after all panels have run. That host window does nothing visible — it is a transparent canvas the dock system lays panels onto. You set this up once and never touch it again.
Resist the temptation to write a full plugin system. A Panel base class with three virtuals is enough:
class Panel {
public:
virtual ~Panel() = default;
virtual const char* Name() const = 0;
virtual void Draw() = 0;
bool open = true;
};
class SceneHierarchy : public Panel { ... };
class Inspector : public Panel { ... };
class AssetBrowser : public Panel { ... };
class Viewport : public Panel { ... };
class ConsoleLog : public Panel { ... };
The editor owns a std::vector<std::unique_ptr<Panel>>. Each frame:
for (auto& p : panels) {
if (!p->open) continue;
if (ImGui::Begin(p->Name(), &p->open)) {
p->Draw();
}
ImGui::End();
}
ImGui handles docking, resizing, tab arrangement, save/restore. You write the panel content. Adding a new panel is one new file plus one push_back. That is the property you want.
ImGui's drag-and-drop API is small and underused:
// Source: in the asset browser
if (ImGui::BeginDragDropSource()) {
ImGui::SetDragDropPayload("ASSET_MESH", &asset_id, sizeof(AssetID));
ImGui::Text("%s", asset.name.c_str());
ImGui::EndDragDropSource();
}
// Target: in the inspector's mesh slot
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("ASSET_MESH")) {
AssetID id = *(const AssetID*)p->Data;
component.mesh = engine.assets.Load<Mesh>(id);
}
ImGui::EndDragDropTarget();
}
That is the entire story. Use typed payloads (the string tag) so an audio clip cannot be dragged into a mesh slot. The preview is whatever you draw between Begin and End — a name, a thumbnail, an icon.
The visceral upgrade is dragging a .glb from your OS file manager. ImGui exposes IO.AddInputCharactersUTF8 and friends, but the drop event is OS-level — you receive it from your windowing layer (GLFW: glfwSetDropCallback, SDL: SDL_DROPFILE) and forward to a queue your panels can poll. Ten lines of integration, an enormous UX win.
The viewport is not the main framebuffer. You render the scene into an off-screen framebuffer, then draw that framebuffer's color attachment as a texture inside an ImGui window. This is what lets the viewport be docked, resized, and dragged out into a separate OS window.
void ViewportPanel::Draw() {
ImVec2 size = ImGui::GetContentRegionAvail();
if (size.x != fbo.width || size.y != fbo.height) {
fbo.Resize((int)size.x, (int)size.y);
camera.SetAspect(size.x / size.y);
}
scene_renderer.Render(fbo, camera, scene);
ImGui::Image((ImTextureID)(intptr_t)fbo.ColorAttachment(),
size, ImVec2(0, 1), ImVec2(1, 0));
// ImGuizmo on top of the viewport image
ImGuizmo::SetRect(ImGui::GetItemRectMin().x,
ImGui::GetItemRectMin().y, size.x, size.y);
ImGuizmo::Manipulate(glm::value_ptr(camera.View()),
glm::value_ptr(camera.Proj()),
current_op, ImGuizmo::LOCAL,
glm::value_ptr(selected.transform));
}
The UV flip ((0,1)/(1,0)) is for OpenGL; Vulkan and D3D do not need it. Resizing on every layout change is fine — framebuffer reallocation is cheap compared to a frame of rendering. The fact that this works at all is the part that surprises newcomers: ImGui treats your render target as just another texture.
ImGui can serialize the entire docking state to an imgui.ini file. You want this to persist between runs:
io.IniFilename = "editor_layout.ini";
Better, expose Save Layout / Load Layout menu items that write to named files in the project directory. When a user opens a different project, restore that project's layout. This costs you 30 lines of code and is the difference between an editor that respects the user's setup and one that resets every launch. Not respecting the user's setup is, in practice, what makes editors feel toyish.
A short list, all of which are easy to skip and obvious in hindsight:
ImGui::PushID(entity.id) around each one. Otherwise widgets in row 1 and row 2 share state and you spend an afternoon debugging why expanding one tree node expands them all.printf. Pipe stdout/stderr into a ring buffer and render with color-coded log levels. You will never look at a separate terminal again.Command interface with Do()/Undo() and a stack on the editor. Wrap every SliderFloat change as one command. This is the single largest jump in editor "weight".A dockable, drag-and-drop ImGui editor with the pieces above is roughly a thousand lines of editor code on top of your engine. That is two weekends of work for someone who has shipped a renderer before. The payoff is that every system you build afterwards — physics debug overlays, animation timelines, profilers — slots into the panel system without ceremony, and the engine starts to feel less like a renderer and more like a tool you would actually use.
The bar is not whether your engine can render Sponza. The bar is whether you can lay out a scene, save it, reload it, and tweak a light without thinking about the editor itself. The editor is good when it disappears.