Table of Contents

ImGui.NET Text Rendering

Render debug text with ImGui.NET, both in screen space and anchored to positions in the 3D scene. A kinematic Bepu body follows a circular path and knocks over stacks of dynamic boxes, showing why SetTargetPose moves a physics body while writing Transform.Position does not. The ImGui font atlas is rebuilt for the monitor's DPI so the overlay stays crisp on high-DPI displays.

The Program.cs file shows how to:

  • Drawing screen-space text with DrawText
  • Anchoring text to a world-space position
  • Driving a kinematic BodyComponent with SetTargetPose
  • Why writing Transform.Position does not move a physics body
  • Rebuilding the ImGui font atlas for the window DPI
  • Using helpers: AddImGuiNet
  • Using helpers: SetupBase3DScene
  • Using helpers: AddProfiler
Note

This example references Stride.CommunityToolkit.ImGuiNet, which is not on NuGet yet. Run it from a clone of the repository, where the package is a project reference; a copy of the project on its own will not build.

ImGui.NET Text Rendering

View on GitHub.

using Stride.BepuPhysics;
using Stride.BepuPhysics.Definitions.Colliders;
using Stride.CommunityToolkit.Bepu;
using Stride.CommunityToolkit.Engine;
using Stride.CommunityToolkit.ImGuiNet;
using Stride.CommunityToolkit.Rendering;
using Stride.CommunityToolkit.Rendering.ProceduralModels;
using Stride.CommunityToolkit.Skyboxes;
using Stride.CommunityToolkit.Windows;
using Stride.Core.Mathematics;
using Stride.Engine;
using Stride.Games;

// Per-monitor DPI awareness has to be enabled before the window exists, otherwise Windows
// hands us a stretched, blurry window on high-DPI displays.
WindowsDpiManager.EnablePerMonitorV2();
WindowsDpiManager.LogDpiInfo("Before Game: ");

// Path the cube travels along
const float OrbitRadius = 3f;
const float OrbitHeight = 2f;
const float OrbitBobHeight = 0.5f;

// Stacks of boxes standing on that path
const int ObstacleStackCount = 3;
const int BoxesPerStack = 3;
const float BoxSize = 1f;

// Overlay layout, in pixels from the window edge on a 100% display; scaled at draw time
const int TextMargin = 10;
const int TextLineHeight = 20;

const int TargetFps = 60;

using var game = new Game();

ImGuiNetSystem? imGuiSystem = null;
Entity? orbitingCube = null;
BodyComponent? orbitingCubeBody = null;
float elapsedSeconds = 0f;

// Fixed points in the scene, drawn to show that DrawText projects world positions to the screen
WorldLabel[] worldLabels =
[
    new(new Vector3(-2, 1, 0), "Red Text", 255, 0, 0),
    new(new Vector3(2, 1, 0), "Green Text", 0, 255, 0),
    new(new Vector3(0, 1, -2), "Blue Text", 0, 0, 255)
];

game.Run(start: Start, update: Update);

void Start(Scene rootScene)
{
    ConfigureWindow();

    // Camera, directional light and a ground plane
    game.SetupBase3DScene();
    game.AddSkybox();
    game.AddProfiler();

    imGuiSystem = game.AddImGuiNet();

    orbitingCube = CreateOrbitingCube(rootScene);
    orbitingCubeBody = orbitingCube.Get<BodyComponent>();

    CreateObstacleStacks(rootScene);

    game.SetMaxFPS(TargetFps);
}

void Update(Scene rootScene, GameTime gameTime)
{
    if (imGuiSystem is null || orbitingCube is null) return;

    elapsedSeconds += (float)gameTime.Elapsed.TotalSeconds;

    MoveOrbitingCube();

    DrawTopLeftPanel(imGuiSystem, gameTime);
    DrawWorldLabels(imGuiSystem, orbitingCube);
    DrawBottomLeftPanel(imGuiSystem, rootScene);
}

void ConfigureWindow()
{
    game.Window.AllowUserResizing = true;
    game.Window.Title = "ImGui.NET Text Rendering";
}

/// <summary>
/// Creates the cube the world-space label is attached to. It is kinematic, so it follows a
/// scripted path without being affected by gravity or collisions, yet still pushes away the
/// dynamic boxes it runs into.
/// </summary>
Entity CreateOrbitingCube(Scene rootScene)
{
    var cube = game.Create3DPrimitive(PrimitiveModelType.Cube, new Bepu3DPhysicsOptions
    {
        Component = new BodyComponent
        {
            Kinematic = true,
            Collider = new CompoundCollider()
        }
    });

    cube.Transform.Position = new Vector3(0, OrbitHeight, 0);
    cube.Scene = rootScene;

    return cube;
}

/// <summary>
/// Builds towers of dynamic boxes spaced evenly around the cube's path, so there is something
/// visible for it to knock over.
/// </summary>
void CreateObstacleStacks(Scene rootScene)
{
    for (var stack = 0; stack < ObstacleStackCount; stack++)
    {
        var angle = MathF.Tau * stack / ObstacleStackCount;
        var x = MathF.Sin(angle) * OrbitRadius;
        var z = MathF.Cos(angle) * OrbitRadius;

        for (var level = 0; level < BoxesPerStack; level++)
        {
            var box = game.Create3DPrimitive(PrimitiveModelType.Cube);
            box.Transform.Position = new Vector3(x, BoxSize * (0.5f + level), z);
            box.Scene = rootScene;
        }
    }
}

/// <summary>
/// Moves the cube along its circular path. <see cref="BodyComponent.SetTargetPose(Vector3)"/>
/// turns the target into a velocity for the next physics tick, so Bepu sweeps the body there and
/// it collides on the way. Assigning <c>Transform.Position</c> instead would move only the mesh:
/// the transform sync runs physics to transform, never the other way round.
/// </summary>
void MoveOrbitingCube()
{
    var target = new Vector3(
        MathF.Sin(elapsedSeconds) * OrbitRadius,
        OrbitHeight + MathF.Sin(elapsedSeconds * 2f) * OrbitBobHeight,
        MathF.Cos(elapsedSeconds) * OrbitRadius);

    orbitingCubeBody?.SetTargetPose(target);
}

/// <summary>
/// Draws the status lines in screen space, growing downwards from the top-left corner.
/// </summary>
void DrawTopLeftPanel(ImGuiNetSystem imGui, GameTime gameTime)
{
    string[] lines =
    [
        "ImGui.NET Text Rendering Example",
        $"Frame Time: {gameTime.Elapsed.TotalMilliseconds:F2}ms",
        "Press ESC to exit"
    ];

    // The font atlas is already scaled by the system; the layout has to follow it too
    var scale = DisplayScale.GetOrCreate(game).Value;

    for (var i = 0; i < lines.Length; i++)
    {
        imGui.DrawText((int)(TextMargin * scale), (int)(TextLineHeight * scale * (i + 1)), lines[i]);
    }
}

/// <summary>
/// Draws text anchored to positions in the 3D scene rather than to the screen.
/// </summary>
void DrawWorldLabels(ImGuiNetSystem imGui, Entity cube)
{
    // Physics owns the transform of a kinematic body, so reading it here tracks the cube exactly
    imGui.DrawText(cube.Transform.Position + Vector3.UnitY, "Moving Cube", 255, 255, 0);

    foreach (var label in worldLabels)
    {
        imGui.DrawText(label.Position, label.Text, label.Red, label.Green, label.Blue);
    }
}

/// <summary>
/// Draws the diagnostics block anchored to the bottom-left corner, so it stays put when the
/// window is resized.
/// </summary>
void DrawBottomLeftPanel(ImGuiNetSystem imGui, Scene rootScene)
{
    // The font atlas follows this by itself - ImGuiNetSystem rebuilds it when the value changes,
    // which is what happens when the window is dragged to a differently scaled monitor
    var displayScale = DisplayScale.GetOrCreate(game).Value;
    var awareness = WindowsDpiManager.GetProcessDpiAwareness();
    var cameraPosition = rootScene.GetCamera()?.Entity.Transform.Position;

    string[] lines =
    [
        $"Display scale: {displayScale:F2}x  (process {awareness?.ToString() ?? "unknown"})",
        $"Camera Position: {cameraPosition?.ToString() ?? "n/a"}",
        $"Entities: {rootScene.Entities.Count}",
        $"Time: {elapsedSeconds:F1}s"
    ];

    var windowHeight = game.Window.ClientBounds.Height;

    for (var i = 0; i < lines.Length; i++)
    {
        imGui.DrawText((int)(TextMargin * displayScale), windowHeight - (int)(TextLineHeight * displayScale * (lines.Length - i)), lines[i]);
    }
}

/// <summary>
/// A coloured text label anchored to a fixed point in world space.
/// </summary>
readonly record struct WorldLabel(Vector3 Position, string Text, byte Red, byte Green, byte Blue);