TIL - imgui-node-editor

Updated: 2025-03-03 | Published: 2025-03-02

Charlie tracking Pepe Silvia - It's Always Sunny in Philadelphia
Charlie tracking Pepe Silvia - It's Always Sunny in Philadelphia

🚧 draft post 🚧

Introduction

Over the last few months I've been building a node-based scene editor for game projects and also as an excuse to spend some quality time with Dear ImGui.

This post describes some of the things I've learned while working with the imgui-node-editor ImGui extension.

Motivation

Most of the game projects I've worked on are built with Java using libGDX, and this node editor work is motivated in part because I've repeatedly bounced off of the default ui layout system built into libGDX: Scene2d.ui. It works quite well for relatively simple user interfaces but every time I think I have a grip on it, I run into some layout issue that devolves into poking at layout code randomly and with increasing desperation trying to get it to cooperate.

Dark Souls - I want you, to git gud!
Dark Souls - I want you, to git gud!

Based on some sophisticated user interfaces built with the Scene2d toolkit; such as Spine and Talos VFX, this is clearly a 📉 skill issue and I should just stop whining and git gud. That said, it's possible that I'm a closet masochist and secretly enjoy being frustrated by the "I have no idea what I'm doing" stage of learning new things, because instead of reading the docs and source code, and experimenting until I solidified my understanding, I took this struggle with Scene2d as a sign that I should make my life even harder by learning not just a different UI library, but one that requires Java bindings to work in my accustomed language and framework!

👍 what could be simpler?

Setup

Since Dear ImGui and imgui-node-editor are c++ libraries, the first step was to pick one of the Java bindings libraries, and the two most likely picks were:

Both already include the imgui-node-editor extension in the bindings, and while gdx-imgui is more directly tied into libGDX it is also a bit less mature than imgui-java and I ran into a couple issues with how the node editor extension is set up, so I went with imgui-java. If you just need vanilla imgui support in a libGDX application then gdx-imgui may be a better choice.

Gradle

Adding this dependency in the core module's build.gradle enables use of imgui-java classes in the platform-agnostic code:

api "io.github.spair:imgui-java-binding:$imguiJavaVersion"

Adding these dependencies in the lwjgl3 module's build.gradle enables imgui-java for desktop:

implementation "io.github.spair:imgui-java-binding:$imguiJavaVersion"
implementation "io.github.spair:imgui-java-lwjgl3:$imguiJavaVersion"
implementation "io.github.spair:imgui-java-natives-linux:$imguiJavaVersion"
implementation "io.github.spair:imgui-java-natives-macos:$imguiJavaVersion"
implementation "io.github.spair:imgui-java-natives-windows:$imguiJavaVersion"

Platform interface

There is some platform-specific code that can't live directly in the libGDX core module, so I created an interface that can be implemented in both the lwjgl3 and core modules:

public interface ImGuiPlatform {
    void init();
    void startFrame();
    void endFrame();
    void dispose();
    ImFont getFont(String name);
}

The implementation of this interface for desktop platforms in the lwjgl3 module has to be created on app launch, and I pass it into the game's ApplicationAdapter class (called Main in this snippet) where it can be used by the platform-agnostic implementation:

// in lwjgl3/Lwjgl3Launcher.java...
private static Lwjgl3Application createApplication() {
    var imguiDesktop = new ImGuiDesktop();
    var configuration = getDefaultConfiguration();
    return new Lwjgl3Application(new Main(imguiDesktop), configuration);
}

And the lwjgl3 implementation looks roughly like this:

public class ImGuiDesktop implements ImGuiPlatform {

    public long window;
    public ImGuiImplGlfw imGuiGlfw;
    public ImGuiImplGl3 imGuiGl3;
    
    public final Map<String, ImFont> fontMap = new HashMap<>();

    @Override
    public void init() {
        if (Gdx.graphics instanceof Lwjgl3Graphics lwjgl3Graphics) {
            imGuiGlfw = new ImGuiImplGlfw();
            imGuiGl3 = new ImGuiImplGl3();

            window = lwjgl3Graphics.getWindow().getWindowHandle();
            if (window == 0) {
                throw new GdxRuntimeException("Failed to create the GLFW window");
            }

            ImGui.createContext();
            ImGui.getIO().setIniFilename(null);
            
            initDocking();
            initFonts();

            imGuiGlfw.init(window, true);
            imGuiGl3.init("#version 150");
        } else {
            throw new GdxRuntimeException("This ImGui platform requires Lwjgl3");
        }
    }

    @Override
    public void startFrame() {
        imGuiGl3.newFrame();
        imGuiGlfw.newFrame();
    }

    @Override
    public void endFrame() {
        imGuiGl3.renderDrawData(ImGui.getDrawData());
    }

    @Override
    public void dispose() {
        imGuiGl3.shutdown();
        imGuiGl3 = null;
        imGuiGlfw.shutdown();
        imGuiGlfw = null;
    }

    @Override
    public ImFont getFont(String name) {
        return fontMap.get(name);
    }
    
    private void initDocking() {
        // NOTE: skipped in this example, docking support configured here if needed
    } 
    
    private void initFonts() {
        // NOTE: skipped in this example, but I'll come back to fonts later
    }
}

Then the platform-agnostic implementation can call into the desktop implementation as needed:

public class ImGuiCore implements ImGuiPlatform {

    private final ImGuiPlatform platform;

    private InputProcessor imGuiInputProcessor;

    public ImGuiCore(ImGuiPlatform platform) {
        this.platform = platform;
        this.imGuiInputProcessor = null;
    }

    // NOTE: call this in the main ApplicationAdapter's 'create()' lifecycle method
    @Override
    public void init() {
        platform.init();
    }

    // NOTE: call this in the main ApplicationAdapter's 'dispose()' lifecycle method
    @Override
    public void dispose() {
        platform.dispose();
        ImGui.destroyContext();
    }

    @Override
    public void startFrame() {
        // restore the input processor after ImGui caught all inputs
        if (imGuiInputProcessor != null) {
            Gdx.input.setInputProcessor(imGuiInputProcessor);
            imGuiInputProcessor = null;
        }

        platform.startFrame();

        ImGui.newFrame();
    }

    @Override
    public void endFrame() {
        ImGui.render();

        platform.endFrame();

        // if ImGui wants to capture the input, disable libGDX's input processor
        if (ImGui.getIO().getWantCaptureKeyboard() || ImGui.getIO().getWantCaptureMouse()) {
            imGuiInputProcessor = Gdx.input.getInputProcessor();
            Gdx.input.setInputProcessor(null);
        }
    }

    @Override
    public ImFont getFont(String name) {
        return platform.getFont(name);
    }
}

Finally, in a game screen where you want to use imgui, setup the render() lifecycle method like this:

@Override
public void render(SpriteBatch batch) {
    ScreenUtils.clear(backgroundColor);
    imgui.startFrame();
    ImGui.pushStyleVar(ImGuiStyleVar.WindowRounding, 10f);

    // update within the imgui frame to make sure the imgui input processor is active
    imguiScene.update();

    // imgui window fills entire screen, layout of panes and widgets is handled internally in the scene
    ImGui.setNextWindowPos(0, 0, ImGuiCond.Always);
    ImGui.setNextWindowSize(ImGui.getMainViewport().getSize());

    imguiScene.render();

    ImGui.popStyleVar();
    imgui.endFrame();
}

Lessons Learned

The Big One

The one quirk of imgui-node-editor that has had the most substantial impact on the way I planned to build the editor is that any widgets that rely on the imgui child window API just don't work when embedded in a node.

Unfortunately this impacts some foundational widget types:

Using any of these widgets in a node requires some fiddly workarounds if it's even possible at all. For example, I don't think the Table API is usable in a node or at least I haven't figured out how to make it work yet.

There's a treasure trove of discussion in github issue #242 that digs into the details of this; why it happens, what's impacted, and how it could be fixed.

The main way to work around this limitation is to 'fake' the widget type in the node context; i.e. between NodeEditor.begin/endNode(), and then use it 'normally' outside that context, typically by triggering a popup on a click within the node context, and then displaying the popup after NodeEditor.endNode(), often in a NodeEditor.suspend/resume() pair.

This is a little vague so let's look at a concrete example for the combo widget...

Workaround: Widgets using Child API

đŸ—ī¸ example with code to be added soon! đŸ—ī¸

The Other Big One

Node sizing can be challenging, mostly because the nodes are set up to expand to fit their content. What this means in practice is that I have to really fight to get a node layout just right.

This is especially true when using imgui-node-editor through the java bindings, because the node editor library embeds an older version of imgui that also exposes functions for a 'stack layout' customization that has been in upstream pull request limbo since 2016.

Unfortunately the more elaborate blueprint example uses these stack layout functions heavily, and it's difficult to replicate because of how embedded the layout customizations are into imgui internals.

Workaround: Node Layout / Size

Since we can't use the Table API for the reason described in the previous section, the best alternative I've found so far is to use begin/endGroup() with some helper functions like this:

// add these utility methods wherever it's appropriate, like the main 'Node' class
public void beginColumn() {
    ImGui.beginGroup();
}
public void nextColumn() {
    ImGui.endGroup();
    ImGui.sameLine();
    ImGui.beginGroup();
}
public void endColumn() {
    ImGui.endGroup();
}

// usage between a 'NodeEditor.begin/endNode()' pair
public void renderNodeContents() {
    ImGui.setNextItemWidth(columnSize1);
    beginColumn();
    // render widgets in first column
    ImGui.setNextItemWidth(columnSize2);
    nextColumn();
    // render widgets in second column
    endColumn();
}

Future Plans

Since I've spent a fair amount of time on this, I plan to make a standalone node editor available on github. There are several architectural things that I have yet to work out about how to make it generally useful for any game rather than the one I'm currently working on. I'll update this post with a link to the repo once I've started work on it.

Additionally, since there are some fundamental incompatibilities between imgui-node-editor and some of the core widgets provided by imgui, I've been considering setting up my own library using bindings created with the new Java Foreign Functions and Memory API similar to what I did with SDL3 as a learning project: github - jsdl3. This would allow me to integrate some of the workarounds that folks have put together that haven't been merged into imgui-node-editor itself for various reasons so that people could use more (if not all) of the imgui widgets directly rather than having to implement workarounds.

🚧 to be continued 🚧

References