Table of Contents

Chapter 04: Debug UI

Add ImGui.NET to the project for debug visualization

So far, any time we need to adjust a shader's parameter values, we need to edit C# code and recompile. It would be much faster to have a debug user interface (UI) in the game itself that exposes all of the shader parameters as editable text fields and slider widgets. We can also use the sliders to change a shader's input parameter and visualize the difference in realtime, which is a fantastic way to build intuition about our shader code.

In the previous tutorial series, you set up a UI using the GUM framework. GUM is a powerful tool that works wonderfully for player facing UI. However, for the debug UI we will develop in this chapter, we will bring in a second UI library called ImGui.NET. The two libraries will not interfere with one another, and each one works well for different use cases. ImGui.NET is great for rapid iteration speed and building developer facing UI. The existing GUM based UI has buttons and sliders that look and feel like they belong in the Dungeon Slime world. The ImGui.NET UI will look more like an admin console in your game. Despite the lack of visual customization, ImGui.NET is easy and quick to write, which makes it perfect for our developer facing debug UI.

If you are following along with code, here is the code from the end of the previous chapter.

Adding a Debug UI Library

A common approach to building debug UIs in games is to use an Immediate Mode system. An immediate mode UI redraws the entire UI from scratch every frame. ImGui.NET is a popular choice for MonoGame. It is a port of a C++ library called DearImGui.

To add ImGui.NET, add the following Nuget package reference to the MonoGameLibrary project:

<Project Sdk="Microsoft.NET.Sdk">
    <!-- ... -->

    <ItemGroup>
        <PackageReference Include="ImGui.NET" Version="1.91.6.1" />
    </ItemGroup>

    <!-- ... -->
</Project>

In order to render the ImGui.NET UI in MonoGame, we need a few supporting classes that convert the ImGui.NET data into MonoGame's graphical representation.

Note

There is a sample project on ImGui.NET's public repository that we can copy for our use cases.

Create a new folder in the MonoGameLibrary project called ImGui and copy and paste the following files into the folder,

Important

These files are on a GitHub repository, so you CANNOT just "right-click - save as" to download them. Follow the links and then select the "download raw file" button in the upper-right of the file view.

There is unsafe code in the ImGui codebase, like this snippet, so you will need to enable unsafe code in the MonoGameLibrary.csproj file. Add this property:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net9.0</TargetFramework>
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    </PropertyGroup>
    <!-- ... -->
</Project>
Note

Why unsafe? The unsafe keyword in C# allows code to work directly with memory addresses (pointers). This is generally discouraged for safety reasons, but it is necessary for high-performance libraries. The ImGuiRenderer uses pointers to efficiently send vertex data to the GPU.

In order to play around with the new UI tool, we will set up a simple Hello World UI in the main GameScene. As we experiment with ImGui, we will build towards a re-usable debug UI for future shaders.

To get started, we need to have an instance of ImGuiRenderer. Similar to how there is a single static SpriteBatch, we will create a single static ImGuiRenderer to be re-used throughout the game.

  1. In the Core.cs file of the MonoGameLibrary project, add the following using statements at the top of the Core.cs file to get access to ImGui:

    using ImGuiNET;
    using ImGuiNET.SampleProgram.XNA;
    
    Note

    If you see red squiggles on either using statement, remove them, build the project and try again to ensure there are no build issues with the project and the nuget package for ImGui has been cached for the project.

  2. Then, add the following property to the Core class:

    /// <summary>  
    /// Gets the ImGui renderer used for debug UIs.  
    /// </summary>  
    public static ImGuiRenderer ImGuiRenderer { get; private set; }
    
  3. Then to initialize the instance, in the Initialize() method, add the following snippet:

    protected override void Initialize()
    {
        base.Initialize();
    
        // Set the core's graphics device to a reference of the base Game's
        // graphics device.
        GraphicsDevice = base.GraphicsDevice;
    
        // Create the sprite batch instance.
        SpriteBatch = new SpriteBatch(GraphicsDevice);
    
        // Create a new input manager
        Input = new InputManager();
    
        // Create a new audio controller.
        Audio = new AudioController();
    
        // Create the ImGui renderer.
        ImGuiRenderer = new ImGuiRenderer(this);
        ImGuiRenderer.RebuildFontAtlas();
    }
    
  4. Similar to SpriteBatch's .Begin() and .End() calls, the ImGuiRenderer has a start and end function call. In the GameScene class, add these lines to end of the .Draw() method:

    protected override void Draw(GameTime gameTime)
    {
        // If there is an active scene, draw it.
        if (s_activeScene != null)
        {
            s_activeScene.Draw(gameTime);
        }
    
        // Draw debug UI
        Core.ImGuiRenderer.BeforeLayout(gameTime);
        // Finish drawing the debug UI here
        Core.ImGuiRenderer.AfterLayout();
    
        base.Draw(gameTime);
    }
    
  5. ImGui draws by adding draggable windows to the screen. To create a simple window that just prints out "Hello World", use the following snippet between the BeforeLayout and AfterLayout calls:

    protected override void Draw(GameTime gameTime)
    {
        // ...
    
        // Draw debug UI
        Core.ImGuiRenderer.BeforeLayout(gameTime);  
        
        ImGui.Begin("Demo Window");  
        ImGui.Text("Hello world!");  
        ImGui.End();
    
        // Finish drawing the debug UI here
        Core.ImGuiRenderer.AfterLayout();
    
        base.Draw(gameTime);
    }
    

When you run your DungeonSlime project, you should get a new "Debug" window as shown below:

Figure 4-1: a simple ImGui window
Figure 4-1: a simple ImGui window

Building a Material Debug UI

Each instance of Material is going to draw a custom debug window. The window will show the latest time the shader was reloaded into the game, which will help demonstrate when a new shader is being used. The window can also show the parameter values for the shader.

  1. Add the following using statements to the top of the Material.cs file in the Graphics folder:

    using ImGuiNET;
    
  2. And then add the following function to the Material class:

    [Conditional("DEBUG")]
    public void DrawDebug()
    {
        ImGui.Begin(Effect.Name);
        
        var currentSize = ImGui.GetWindowSize();
        ImGui.SetWindowSize(Effect.Name, new System.Numerics.Vector2(MathHelper.Max(100, currentSize.X), MathHelper.Max(100, currentSize.Y)));
        
        ImGui.AlignTextToFramePadding();
        ImGui.Text("Last Updated");
        ImGui.SameLine();
        ImGui.LabelText("##last-updated", Asset.UpdatedAt.ToString() + $" ({(DateTimeOffset.Now - Asset.UpdatedAt).ToString(@"h\:mm\:ss")} ago)");
    
        ImGui.NewLine();
    
    
        bool ScalarSlider(string key, ref float value)
        {
            float min = 0;
            float max = 1;
            
            return ImGui.SliderFloat($"##_prop{key}", ref value, min, max);
        }
        
        foreach (var prop in ParameterMap)
        {
            switch (prop.Value.ParameterType, prop.Value.ParameterClass)
            {
                case (EffectParameterType.Single, EffectParameterClass.Scalar):
                    ImGui.AlignTextToFramePadding();
                    ImGui.Text(prop.Key);
                    ImGui.SameLine();
                                
                    var value = prop.Value.GetValueSingle();
                    if (ScalarSlider(prop.Key, ref value))
                    {
                        prop.Value.SetValue(value);
                    }
                    break;
                
                case (EffectParameterType.Single, EffectParameterClass.Vector):
                    ImGui.AlignTextToFramePadding();
                    ImGui.Text(prop.Key);
    
                    var vec2Value = prop.Value.GetValueVector2();
                    ImGui.Indent();
                    
                    ImGui.Text("X");
                    ImGui.SameLine();
                    
                    if (ScalarSlider(prop.Key + ".x", ref vec2Value.X))
                    {
                        prop.Value.SetValue(vec2Value);
                    }
                    
                    ImGui.Text("Y");
                    ImGui.SameLine();
                    if (ScalarSlider(prop.Key + ".y", ref vec2Value.Y))
                    {
                        prop.Value.SetValue(vec2Value);
                    }
                    ImGui.Unindent();
                    break;
                
                case (EffectParameterType.Texture2D, EffectParameterClass.Object):
                    ImGui.AlignTextToFramePadding();
                    ImGui.Text(prop.Key);
                    ImGui.SameLine();
    
                    var texture = prop.Value.GetValueTexture2D();
                    if (texture != null)
                    {
                        var texturePtr = Core.ImGuiRenderer.BindTexture(texture);
                        ImGui.Image(texturePtr, new System.Numerics.Vector2(texture.Width, texture.Height));
                    }
                    else
                    {
                        ImGui.Text("(null)");
                    }
                    break;
                
                default:
                    ImGui.AlignTextToFramePadding();
                    ImGui.Text(prop.Key);
                    ImGui.SameLine();
                    ImGui.Text($"(unsupported {prop.Value.ParameterType}, {prop.Value.ParameterClass})");
                    break;
            }
        }
        ImGui.End();
    }
    

    Currently however, the control is not fully bound to the Saturation parameter of the grayscale shader, inputs will always be overridden because the GameScene itself keeps setting the value. In order to solve this, we introduce a custom property in the Material class that causes the debug UI to override the various SetParameter() methods.

  3. Next, add this new boolean to the Material class:

    /// <summary>
    /// Override the default behaviour of the material properties so they can be controlled in Debug mode.
    /// </summary>
    public bool DebugOverride;
    
  4. Then, modify ALL of the SetParameter() methods (float, matrix, vector2, etc) to exit early when the DebugOverride variable is set to true:

    public void SetParameter(string name, float value)
    {
        if (DebugOverride) return;
    
        if (TryGetParameter(name, out var parameter))
        {
            parameter.SetValue(value);
        }
        else
        {
            Console.WriteLine($"Warning: cannot set shader parameter=[{name}] because it does not exist in the compiled shader=[{Asset.AssetName}]");
        }
    }
    
  5. Then, in the DrawDebug() method, after the LastUpdated field gets drawn, add this following:

    [Conditional("DEBUG")]
    public void DrawDebug()
    {
        ImGui.Begin(Effect.Name);
    
        var currentSize = ImGui.GetWindowSize();
        ImGui.SetWindowSize(Effect.Name, new System.Numerics.Vector2(MathHelper.Max(100, currentSize.X), MathHelper.Max(100, currentSize.Y)));
    
        ImGui.AlignTextToFramePadding();
        ImGui.Text("Last Updated");
        ImGui.SameLine();
        ImGui.LabelText("##last-updated", Asset.UpdatedAt.ToString() + $" ({(DateTimeOffset.Now - Asset.UpdatedAt).ToString(@"h\:mm\:ss")} ago)");
    
        ImGui.AlignTextToFramePadding();
        ImGui.Text("Override Values");
        ImGui.SameLine();
        ImGui.Checkbox("##override-values", ref DebugOverride);
    
        ImGui.NewLine();    
        // ...
    

Turning it off

As the number of shaders and Material instances grows throughout the rest of the tutorial series, it will become awkward to manage drawing all of the debug UIs manually like the _grayscaleEffect's UI is being drawn. Rather, it would be better to have a single function that would draw all of the debug UIs at once. Naturally, it would not make sense to draw every Material's debug UI, so the Material class needs a setting to decide if the debug UI should be drawn or not.

We will keep track of all the Material instances to draw as a static variable inside the Material class itself.

Note

Static classes are a useful feature to use when you need something to be accessible globally, meaning there can only ever be one. In the case of the Material class, we are adding a collection which will be the same collection no matter how many different Material definitions are running in the project. But remember, with great power comes great responsibility, Statics should be carefully considered before use as they can also be a trap if used incorrectly.

  1. First, add the following at the top of the Material.cs class:

    // materials that will be drawn during the standard debug UI pass.  
    private static HashSet<Material> s_debugMaterials = new HashSet<Material>();
    
  2. Now, add a boolean property to the Material class, which adds or removes the given instances to the static set:

    /// <summary>
    /// Enable this variable to visualize the debugUI for the material
    /// </summary>
    public bool IsDebugVisible
    {
        get
        {
            return s_debugMaterials.Contains(this);
        }
        set
        {
            if (!value)
            {
                s_debugMaterials.Remove(this);
            }
            else
            {
                s_debugMaterials.Add(this);
            }
        }
    }
    
  3. To finish off the edits to the Material class, add a method that actually renders all of the Material instances in the static set, consuming the DrawDebug method we created earlier:

    [Conditional("DEBUG")]
    public static void DrawVisibleDebugUi(GameTime gameTime)
    {
        // first, cull any materials that are not visible, or disposed. 
        var toRemove = new List<Material>();
        foreach (var material in s_debugMaterials)
        {
            if (material.Effect.IsDisposed)
            {
                toRemove.Add(material);
            }
        }
    
        foreach (var material in toRemove)
        {
            s_debugMaterials.Remove(material);
        }
        
        Core.ImGuiRenderer.BeforeLayout(gameTime);
        foreach (var material in s_debugMaterials)
        {
            material.DrawDebug();
        }
        Core.ImGuiRenderer.AfterLayout();
    }
    
  4. Now in the Core class's Draw method, we need to call the new method in order to render the new Debug UI. And also delete the old ImGui test code used in the GameScene to draw the _grayscaleEffect's debugUI. Replace the "entire" Draw method with the following version:

    protected override void Draw(GameTime gameTime)
    {
        // If there is an active scene, draw it.
        if (s_activeScene != null)
        {
            s_activeScene.Draw(gameTime);
        }
    
        Material.DrawVisibleDebugUi(gameTime);
        
        base.Draw(gameTime);
    }
    
    Note

    The highlighted line is showing the new call for drawing the debug material renderers instead of the test ImGui code we had previously.

  5. The Core class does not yet know about the Material class, so we will need to add an additional using to the top of the class:

    using MonoGameLibrary.Graphics;
    
    Tip

    To keep things clean, you can also remove the old using ImGuiNET; as you will see it is now greyed out because it is not used anymore since we removed the test ImGui drawing code.

  6. Finally, in order to render the debug UI for the _grayscaleEffect, just enable the IsDebugVisible property to true in the LoadContent method of the GameScene class:

    // Load the grayscale effect
    _grayscaleEffect = Content.WatchMaterial("effects/grayscaleEffect");
    _grayscaleEffect.IsDebugVisible = true;
    

Now, when you run the game, you can see the debug window with the Saturation value as expected. If you enable the "Override Values" checkbox, you will also be able set the value by hand while the game is running (without setting it, the game has control, but still useful to see).

Figure 4-2: Shader parameters shown while game is running
Tip

If you do not want to see the debug UI for the grayscaleEffect anymore, just set IsDebugVisible to false, or delete the line entirely.

(Optional) DPI Scaling

If the Debug UI is too small on your screen, you can add the following snippet to Core's Initialize() method. Adjust the floating point values as necessary to fit your preferences.

protected override void Initialize()
{
    //...

    // Create the ImGui renderer.
    ImGuiRenderer = new ImGuiRenderer(this);
    ImGuiRenderer.RebuildFontAtlas();

    // Optional: Scale text and widgets for easier readability.
    var io = ImGui.GetIO();
    io.FontGlobalScale = 1.75f;
    ImGui.GetStyle().ScaleAllSizes(1.5f);
}

RenderDoc

The debug UI in the game is helpful, but sometimes you may need to take a closer look at the actual graphics resources MonoGame is managing. There are various tools that intercept the graphics API calls between an application and the graphics software. RenderDoc is a great example of a graphics debugger tool. Unfortunately, it only works with MonoGame when the game is targeting the WindowsDX profile. It may not be possible to switch your game to WindowsDX under all circumstances. At this time, there are very few options for graphic debuggers tools for MonoGame when targeting openGL.

Switch to WindowsDX

To switch DungeonSlime to target WindowsDX, you need to modify the .csproj file, and make some changes to the .mgcb content file.

  1. First, in the .csproj file, remove the reference to MonoGame's openGL backend:

    <PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.*" />
    

    And replace it with this line:

    <PackageReference Include="MonoGame.Framework.WindowsDX" Version="3.8.*" />
    
  2. The MonoGame.Framework.WindowsDX Nuget package is not available for the net9.0 framework. Instead, it is only available specifically on the Windows variant, called net9.0-windows. Change the <TargetFramework> in your .csproj to the new framework:

    <TargetFramework>net9.0-windows</TargetFramework>
    
  3. Next, in the Content.mgcb file, update the target platform from DesktopGL to Windows,

    /platform:Windows
    
  4. RenderDoc only works when MonoGame is targeting the HiDef graphics profile. This needs to be changed in two locations. First, in the .mgcb file, change the /profile from Reach to HiDef.

    /profile:HiDef
    
  5. Finally, in the Core constructor, set the graphics profile immediately after constructing the Graphics instance:

    // Create a new graphics device manager.
    Graphics = new GraphicsDeviceManager(this);
    Graphics.GraphicsProfile = GraphicsProfile.HiDef;
    
Important

The rest of this tutorial series assumes you are using the DesktopGL configuration. After you are done experimenting with RenderDoc using WindowsDX, please switch the project back to DesktopGL.

If you want to see how a project can be built for both DesktopGL and WindowsDX at the same time, check out the mgblank2dstartkit (MonoGame Blank 2D Starter Kit) MonoGame template.

The WindowsDX format should work for most of this tutorial, except for chapter 9 (shadows) which needs a bit of extra complexity to work due to the complexities in building cross-platform shaders.

Using RenderDoc

Make sure you have built DungeonSlime. You can build it manually by running the following command from the DungeonSlime directory:

dotnet build

Once you have downloaded RenderDoc, open it and Go to the Launch Application tab, then select your built executable in the Executable Path.

For example, the path may look similar to the following:

C:\proj\MonoGame.Samples\Tutorials\2dShaders\src\04-Debug-UI\DungeonSlime\bin\Debug\net9.0-windows\DungeonSlime.exe
Figure 4-3: The setup for RenderDoc
Figure 4-3: The setup for RenderDoc

Then, click the Launch button in the lower right. DungeonSlime should launch with a small warning text in the upper left of the game window that states the graphics API is being captured by RenderDoc.

Figure 4-4: Renderdoc analysing and capturing frames

Press F12 to capture a frame (as shown above), and it will appear in RenderDoc. In RenderDoc, Double click the frame to open the captured frame, and go to the Texture Viewer tab. The draw calls are split out one by one and you can view the intermediate buffers.

Note

You MUST have the game in focus to capture frames, as the target window has to be selected in order to capture frames. If you have the RenderDoc window in focus, nothing will happen.

Figure 4-5: RenderDoc shows the intermediate frame
Figure 4-5: RenderDoc shows the intermediate frame

From here, you can better understand how rendering is working in your game, especially if you are having performance or graphical issues. It is a bit beyond the scope of this tutorial to go into the deep details, for now you can just examine it to get used to the tool.

Tip

RenderDoc is a powerful tool. To learn more about how to use the tool, please refer to the RenderDoc Documentation.

Conclusion

What a difference a good tool makes! In this chapter, you accomplished the following:

  • Integrated the ImGui.NET library into a MonoGame project.
  • Created a reusable ImGuiRenderer to draw the UI.
  • Built a dynamic debug window for our Material class.
  • Learned how to use a graphics debugger like RenderDoc to inspect frames.

With our workflow and tooling in place, it is finally time to write some new shaders. Up next, we will dive into our first major pixel shader effect and build a classic screen wipe transition!

You can find the complete code sample for this chapter - here.

Continue to the next chapter, Chapter 05: Transition Effect.