Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -879,7 +879,9 @@ Things that bite when writing these:
recorded by the probe, but clicking their recorded position lands on whatever is actually there,
so the click silently does nothing. The tabbed demos use 1600x1200 or larger.
- **Run in Release.** The software rasterizer is roughly 17x faster there — about 180 ms per frame
versus 3 s in Debug — so keep frame counts modest either way.
versus 3 s in Debug. Only a frame whose pixels are read (`Capture()`, `Target`) is
rasterized, so stepping is cheap and each read pays for one frame; hold no `Bitmap32` from
`Target` across a step, since it is drawn on read.
- **The demos mark their own controls.** `ktsu.ImGui.Widgets` and `ktsu.ImGui.Popups` mark items
themselves; the plain ImGui buttons, headers, tabs and sliders the demos draw go through a small
`DemoProbe` helper (or a local `DemoButton`/`DemoHeader`/`DemoTab`) that calls
Expand Down
15 changes: 12 additions & 3 deletions ImGui.App.Testing/HeadlessImGuiContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,22 @@ public void BeginFrame(float delta)
ImGui.NewFrame();
}

/// <summary>Ends the frame and submits its draw data to the renderer.</summary>
public void EndFrame()
/// <summary>Ends the frame and rasterizes its draw data.</summary>
public void EndFrame() => renderer.RenderDrawData(FinishFrame());

/// <summary>
/// Ends the frame and hands its draw data to the renderer to rasterize only if its pixels are
/// read. The renderer's deferred frame must be discarded before the next <see cref="BeginFrame"/>.
/// </summary>
public void EndFrameDeferred() => renderer.DeferDrawData(FinishFrame());

/// <summary>Renders the frame and brings the renderer's textures up to date with it.</summary>
private ImDrawDataPtr FinishFrame()
{
ImGui.Render();
ImDrawDataPtr drawData = ImGui.GetDrawData();
ProcessTextureUpdates(drawData);
renderer.RenderDrawData(drawData);
return drawData;
}

/// <inheritdoc/>
Expand Down
35 changes: 26 additions & 9 deletions ImGui.App.Testing/ImGuiAppHarness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,15 @@
private static ImGuiAppHarness? live;

private readonly ImGuiAppConfig config;
private readonly SoftwareRenderer renderer;
private readonly HeadlessImGuiContext context;
private bool disposed;

private ImGuiAppHarness(ImGuiAppConfig config, HarnessOptions options)
{
this.config = config;
Options = options;
renderer = new SoftwareRenderer(options.Width, options.Height);
context = new HeadlessImGuiContext(options.Width, options.Height, options.DpiScale, renderer);
Renderer = new SoftwareRenderer(options.Width, options.Height);
context = new HeadlessImGuiContext(options.Width, options.Height, options.DpiScale, Renderer);
Mouse = new HarnessMouse(this);
Keyboard = new HarnessKeyboard(this);
}
Expand All @@ -53,7 +52,16 @@
public ImGuiMouseCursor MouseCursor { get; private set; }

/// <summary>Gets the render target holding the most recently rendered frame.</summary>
public Bitmap32 Target => renderer.Target;
/// <remarks>
/// A frame is rasterized when its pixels are first read, here or through <see cref="Capture"/>,
/// rather than when it is stepped, so read this again after each step instead of holding the
/// bitmap across one. After the harness is disposed it no longer reflects a frame that was
/// stepped but never read.
/// </remarks>
public Bitmap32 Target => Renderer.Target;

/// <summary>Gets the rasterizer drawing this harness's frames.</summary>
internal SoftwareRenderer Renderer { get; }

/// <summary>Gets the mouse input injector.</summary>
public HarnessMouse Mouse { get; }
Expand Down Expand Up @@ -92,7 +100,7 @@
// backend that was never installed.
try
{
ImGuiApp.BeginExternalFrameSession(harness.renderer);
ImGuiApp.BeginExternalFrameSession(harness.Renderer);
}
catch
{
Expand Down Expand Up @@ -153,6 +161,12 @@

try
{
// The previous frame is discarded unread here unless something read its pixels,
// which is the point: rasterizing is nearly all a headless frame costs, and most
// frames a test steps through are never looked at. Every frame clears the whole
// target, so the last frame alone decides what a read sees. This has to precede
// NewFrame, which reuses the draw data the discarded frame points at.
Renderer.BeginDeferredFrame(Options.ClearColor);
context.BeginFrame(Options.FrameDelta);

// Must follow NewFrame, as it does in ImGuiController.
Expand All @@ -164,10 +178,9 @@
config.OnUpdate?.Invoke(Options.FrameDelta);
ImGuiApp.Invoker.DoInvokes();

renderer.Clear(Options.ClearColor);
ImGuiApp.RenderFrameContents(config, Options.FrameDelta);

context.EndFrame();
context.EndFrameDeferred();
MouseCursor = ImGui.GetMouseCursor();
}
catch (Exception error) when (error is not HarnessFrameException)
Expand Down Expand Up @@ -258,7 +271,7 @@

return FrameCount == 0
? throw new InvalidOperationException("No frame has been rendered yet, so there is nothing to capture. Call Step first.")
: new CapturedFrame(renderer.Target);
: new CapturedFrame(Renderer.Target);
}

/// <inheritdoc/>
Expand All @@ -269,6 +282,10 @@
return;
}

// A deferred frame points into ImGui's draw data, which is about to be destroyed with the
// context, and textures are about to be released underneath it.
Renderer.DiscardDeferredFrame();

// Extension teardown reaches into ImGui's context, so it has to happen while that context
// is still alive. Destroying the ImPlot context after ImGui's has gone faults in native code.
if (ReferenceEquals(live, this))
Expand All @@ -277,11 +294,11 @@
}

context.Dispose();
renderer.Dispose();
Renderer.Dispose();

if (ReferenceEquals(live, this))
{
live = null;

Check warning on line 301 in ImGui.App.Testing/ImGuiAppHarness.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this set, which updates a 'static' field from an instance method.

Check warning on line 301 in ImGui.App.Testing/ImGuiAppHarness.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this set, which updates a 'static' field from an instance method.

Check warning on line 301 in ImGui.App.Testing/ImGuiAppHarness.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this set, which updates a 'static' field from an instance method.

Check warning on line 301 in ImGui.App.Testing/ImGuiAppHarness.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this set, which updates a 'static' field from an instance method.
ImGuiProbes.SetProbe(null);
ImGuiApp.EndExternalFrameSession();
}
Expand Down
9 changes: 9 additions & 0 deletions ImGui.App.Testing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ holds or a frame budget runs out, returning false rather than throwing so the ca
timeout is a failure. The budget counts frames rather than milliseconds, so a loaded machine takes
longer in real time without changing the outcome.

A frame is rasterized only when its pixels are read, through `Capture()` or `Target`, not when it is
stepped. Rasterizing is nearly all a headless frame costs and most frames a test steps through are
never looked at, so stepping is cheap and a read pays for one frame. Every frame clears the whole
target, so what a read sees is the last frame stepped, drawn with the textures it was submitted
with: a texture or render target changed after the step is drawn as it was, because the renderer
draws the pending frame before letting the change through. Read `Target` again after stepping
rather than holding the bitmap across a step, since a held reference still shows the frame it was
read on.

Input is injected into ImGui's event queue:

```csharp
Expand Down
9 changes: 8 additions & 1 deletion ImGui.App.Testing/SoftwareRenderer.ThreeD.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@

nint handle = nextId++;
nint textureId = nextId++;
RenderTarget3D target = new(width, height, depth, textureId);

Check warning on line 39 in ImGui.App.Testing/SoftwareRenderer.ThreeD.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Rename 'target' which hides the field with the same name.

Check warning on line 39 in ImGui.App.Testing/SoftwareRenderer.ThreeD.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Rename 'target' which hides the field with the same name.

Check warning on line 39 in ImGui.App.Testing/SoftwareRenderer.ThreeD.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Rename 'target' which hides the field with the same name.

Check warning on line 39 in ImGui.App.Testing/SoftwareRenderer.ThreeD.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Rename 'target' which hides the field with the same name.

Check warning on line 39 in ImGui.App.Testing/SoftwareRenderer.ThreeD.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename 'target' which hides the field with the same name.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImGuiApp&issues=AaEWiSOQ5Dj2PXO_S2Tn&open=AaEWiSOQ5Dj2PXO_S2Tn&pullRequest=602

targets[handle] = target;
targetTextureIds.Add(textureId);
Expand Down Expand Up @@ -64,6 +64,8 @@
return false;
}

RasterizeDeferredDrawData();

RenderTarget3D resized = new(width, height, existing.Depth is not null, existing.TextureId);

targets[target] = resized;
Expand All @@ -76,11 +78,14 @@
/// <param name="target">A handle from <see cref="CreateRenderTarget"/>.</param>
public void DeleteRenderTarget(nint target)
{
if (!targets.Remove(target, out RenderTarget3D? existing))
if (!targets.TryGetValue(target, out RenderTarget3D? existing))
{
return;
}

RasterizeDeferredDrawData();
targets.Remove(target);

targetTextureIds.Remove(existing.TextureId);
textures.Remove(existing.TextureId);
}
Expand All @@ -103,6 +108,7 @@
{
RenderTarget3D resolved = Resolve(target, nameof(target));

RasterizeDeferredDrawData();
resolved.Color.Clear(new Rgba32(Channel(color.X), Channel(color.Y), Channel(color.Z), Channel(color.W)));
resolved.Depth?.Clear(depth);
}
Expand All @@ -116,6 +122,7 @@
public void Draw(nint target, ReadOnlySpan<Vertex3D> vertices, ReadOnlySpan<uint> indices, in DrawState3D state)
{
RenderTarget3D resolved = Resolve(target, nameof(target));
RasterizeDeferredDrawData();
TextureSource? texture = state.TextureId != 0 && textures.TryGetValue(state.TextureId, out TextureSource? found)
? found
: null;
Expand Down
133 changes: 117 additions & 16 deletions ImGui.App.Testing/SoftwareRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,44 @@ namespace ktsu.ImGui.App.Testing;
public sealed partial class SoftwareRenderer(int width, int height) : IRendererBackend
{
private readonly Dictionary<nint, TextureSource> textures = [];
private readonly Bitmap32 target = new(width, height);
private nint nextId = 1;
private bool disposed;

// A frame the harness has finished but not yet rasterized. Nearly every frame a UI test steps
// through is never looked at, and rasterizing on the CPU is almost all of what a frame costs,
// so the harness hands the frame over here and it is drawn only when its pixels are read.
// ImGui keeps the draw data alive until the next NewFrame, and the harness discards the
// deferred frame before calling it, so the pointer is never read after ImGui has reused it.
private Rgba32? deferredClear;
private ImDrawDataPtr deferredDrawData;
private bool drawDataDeferred;

/// <summary>Gets the render target holding the most recently rendered frame.</summary>
public Bitmap32 Target { get; } = new Bitmap32(width, height);
/// <remarks>
/// Under <see cref="ImGuiAppHarness"/> a frame is rasterized when this is read rather than when
/// it ends, so read it again after stepping rather than holding the bitmap across steps: a
/// reference kept from before a step still shows the frame it was read on.
/// </remarks>
public Bitmap32 Target
{
get
{
RasterizeDeferredFrame();
return target;
}
}

/// <summary>Gets how many deferred frames have been rasterized, which tests read to tell a skipped frame from a drawn one.</summary>
internal int DeferredFramesRasterized { get; private set; }

/// <summary>Fills the render target with one color, discarding the previous frame.</summary>
/// <param name="color">The clear color.</param>
public void Clear(Rgba32 color) => Target.Clear(color);
public void Clear(Rgba32 color)
{
DiscardDeferredFrame();
target.Clear(color);
}

/// <summary>Uploads a texture and returns an opaque handle usable as an ImGui texture id.</summary>
/// <param name="rgba">Tightly packed RGBA8 pixels.</param>
Expand All @@ -55,6 +84,7 @@ public nint CreateTexture(ReadOnlySpan<byte> rgba, int width, int height)
/// <returns>Always true. A CPU texture can always be replaced in place.</returns>
public bool UpdateTexture(nint id, ReadOnlySpan<byte> rgba, int width, int height)
{
RasterizeDeferredDrawData();
textures[id] = new TextureSource(ToBitmap(rgba, width, height));
return true;
}
Expand All @@ -74,6 +104,7 @@ public void DeleteTexture(nint id)
$"Texture {id} is a render target's colour attachment. Release it with DeleteRenderTarget.", nameof(id));
}

RasterizeDeferredDrawData();
textures.Remove(id);
}

Expand All @@ -84,8 +115,90 @@ public void DeleteTexture(nint id)

/// <summary>Rasterizes a complete ImGui draw-data tree into the render target.</summary>
/// <param name="drawData">Draw data obtained after calling <c>ImGui.Render</c>.</param>
[SuppressMessage("Major Code Smell", "S6640:Make sure that using \"unsafe\" is safe here", Justification = "Required to read a native function pointer during ImGui interop; the pointer is compared and never dereferenced or retained.")]
public void RenderDrawData(ImDrawDataPtr drawData)
{
RasterizeDeferredFrame();
Rasterize(drawData);
}

/// <inheritdoc/>
public void Dispose()
{
if (disposed)
{
return;
}

DiscardDeferredFrame();
textures.Clear();
disposed = true;
}

/// <summary>
/// Starts a frame whose rasterization is deferred until its pixels are read, discarding any
/// earlier deferred frame unread. Call it before <c>ImGui.NewFrame</c>, which invalidates the
/// draw data of the frame being discarded.
/// </summary>
/// <param name="clearColor">The color the frame is cleared to.</param>
internal void BeginDeferredFrame(Rgba32 clearColor)
{
DiscardDeferredFrame();
deferredClear = clearColor;
}

/// <summary>
/// Records the draw data ending a deferred frame, to be rasterized when the target is next read
/// or before anything it samples changes.
/// </summary>
/// <param name="drawData">Draw data obtained after calling <c>ImGui.Render</c>.</param>
internal void DeferDrawData(ImDrawDataPtr drawData)
{
deferredDrawData = drawData;
drawDataDeferred = true;
}

/// <summary>Forgets a deferred frame without drawing it.</summary>
internal void DiscardDeferredFrame()
{
deferredClear = null;
deferredDrawData = default;
drawDataDeferred = false;
}

/// <summary>Draws the deferred frame, if there is one, so the target holds it.</summary>
private void RasterizeDeferredFrame()
{
if (deferredClear is Rgba32 clear)
{
deferredClear = null;
target.Clear(clear);
}

if (drawDataDeferred)
{
ImDrawDataPtr drawData = deferredDrawData;
deferredDrawData = default;
drawDataDeferred = false;
DeferredFramesRasterized++;
Rasterize(drawData);
}
}

/// <summary>
/// Draws a deferred frame's draw data before a texture it may sample changes, so the frame is
/// drawn with the textures it was submitted with. A deferred clear alone samples nothing and is
/// left deferred.
/// </summary>
private void RasterizeDeferredDrawData()
{
if (drawDataDeferred)
{
RasterizeDeferredFrame();
}
}

[SuppressMessage("Major Code Smell", "S6640:Make sure that using \"unsafe\" is safe here", Justification = "Required to read a native function pointer during ImGui interop; the pointer is compared and never dereferenced or retained.")]
private void Rasterize(ImDrawDataPtr drawData)
{
if (drawData.Equals(default) || drawData.CmdListsCount == 0)
{
Expand Down Expand Up @@ -135,24 +248,12 @@ public void RenderDrawData(ImDrawDataPtr drawData)
Vertex b = ToVertex(cmdList.VtxBuffer[(int)(cmd.VtxOffset + i1)], origin);
Vertex c = ToVertex(cmdList.VtxBuffer[(int)(cmd.VtxOffset + i2)], origin);

SoftwareRasterizer.FillTriangle(Target, a, b, c, texture, scissor);
SoftwareRasterizer.FillTriangle(target, a, b, c, texture, scissor);
}
}
}
}

/// <inheritdoc/>
public void Dispose()
{
if (disposed)
{
return;
}

textures.Clear();
disposed = true;
}

private static Vertex ToVertex(ImDrawVert vertex, Vector2 origin) => new(
new Vector2(vertex.Pos.X - origin.X, vertex.Pos.Y - origin.Y),
new Vector2(vertex.Uv.X, vertex.Uv.Y),
Expand Down
Loading
Loading