refactor(render): compose render frame orchestrator

Move the accepted draw transaction and failure recovery behind typed frame-phase owners so GameWindow only supplies immutable frame input. Preserve retail draw order, make ImGui/bootstrap shutdown ownership explicit, and restore exact text-render GL state on failures.\n\nCo-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 08:03:49 +02:00
parent 28e1cf8029
commit 9d7df1bfc5
25 changed files with 1675 additions and 279 deletions

View file

@ -42,8 +42,10 @@ public sealed class DevToolsFramePresenterTests
var commands = new RecordingCommandSource { Current = first };
var presenter = Create(backend, commands: commands);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 800, 600);
commands.Current = second;
presenter.BeginFrame(0.2f);
presenter.Render(0.2, 800, 600);
Assert.Same(first, backend.Contexts[0].Commands);
@ -60,6 +62,7 @@ public sealed class DevToolsFramePresenterTests
var panels = new RecordingPanels();
var presenter = Create(backend, panels: panels);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 1920, 1080);
Assert.Equal(
@ -93,6 +96,7 @@ public sealed class DevToolsFramePresenterTests
var camera = new RecordingCamera { IsFlyMode = true, CollideCamera = true };
var presenter = Create(backend, camera: camera);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 1280, 720);
Assert.Equal(1, camera.ToggleCount);
@ -113,6 +117,7 @@ public sealed class DevToolsFramePresenterTests
presenter.ToggleSettingsPanel();
presenter.ResetLayout(1280, 720, DevToolsPanelLayoutCondition.FirstUseEver);
presenter.BeginFrame(0.1f);
presenter.Render(0.1, 1280, 720);
Assert.DoesNotContain(DevToolsPanelKind.Settings, panels.Toggles);
@ -156,6 +161,68 @@ public sealed class DevToolsFramePresenterTests
call.WithoutCondition()));
}
[Fact]
public void AbortFrame_ClosesAnOpenBackendFrameAndAllowsTheNextFrame()
{
var calls = new List<string>();
var backend = new RecordingBackend(calls);
var presenter = Create(backend);
presenter.BeginFrame(0.1f);
presenter.AbortFrame();
presenter.AbortFrame();
presenter.BeginFrame(0.2f);
presenter.Render(0.2, 800, 600);
Assert.Equal(1, calls.Count(call => call == "abort"));
Assert.Equal(2, calls.Count(call => call.StartsWith("begin:")));
Assert.Equal(1, calls.Count(call => call == "draw-data"));
}
[Fact]
public void RenderFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame()
{
var calls = new List<string>();
var backend = new RecordingBackend(calls)
{
DrawFailure = new InvalidOperationException("draw"),
};
var presenter = Create(backend);
presenter.BeginFrame(0.1f);
Assert.Throws<InvalidOperationException>(() =>
presenter.Render(0.1, 800, 600));
presenter.AbortFrame();
backend.DrawFailure = null;
presenter.BeginFrame(0.2f);
presenter.Render(0.2, 800, 600);
Assert.Equal(0, calls.Count(call => call == "abort"));
Assert.Equal(2, calls.Count(call => call == "draw-data"));
}
[Fact]
public void BeginFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame()
{
var calls = new List<string>();
var backend = new RecordingBackend(calls)
{
BeginFailure = new InvalidOperationException("begin"),
};
var presenter = Create(backend);
Assert.Throws<InvalidOperationException>(() => presenter.BeginFrame(0.1f));
presenter.AbortFrame();
backend.BeginFailure = null;
presenter.BeginFrame(0.2f);
presenter.Render(0.2, 800, 600);
Assert.Equal(1, calls.Count(call => call == "abort"));
Assert.Equal(2, calls.Count(call => call.StartsWith("begin:")));
}
[Fact]
public void InputFacingActionsDelegateToTheTypedPanelOwner()
{
@ -198,7 +265,7 @@ public sealed class DevToolsFramePresenterTests
field => field.FieldType == typeof(Silk.NET.Windowing.IWindow));
}
Assert.False(typeof(IDisposable).IsAssignableFrom(
Assert.True(typeof(IDisposable).IsAssignableFrom(
typeof(ImGuiDevToolsFrameBackend)));
}
@ -221,8 +288,29 @@ public sealed class DevToolsFramePresenterTests
public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = [];
public List<PanelContext> Contexts { get; } = [];
public List<LayoutCallWithCondition> Layouts { get; } = [];
public Exception? BeginFailure { get; set; }
public Exception? DrawFailure { get; set; }
public void BeginFrame(float deltaSeconds) => calls.Add($"begin:{deltaSeconds}");
public bool ControllerFrameOpen { get; private set; }
public void BeginFrame(float deltaSeconds)
{
calls.Add($"begin:{deltaSeconds}");
ControllerFrameOpen = true;
if (BeginFailure is not null)
throw BeginFailure;
}
public void AbortFrame()
{
// Silk ImGuiController.Render is idempotent. It closes an active
// controller frame, but is a no-op if Render already cleared its
// private _frameBegun flag before a GL upload failure.
if (!ControllerFrameOpen)
return;
ControllerFrameOpen = false;
calls.Add("abort");
}
public bool BeginMainMenuBar()
{
@ -254,7 +342,14 @@ public sealed class DevToolsFramePresenterTests
calls.Add($"panels:{context.DeltaSeconds}");
}
public void RenderDrawData() => calls.Add("draw-data");
public void RenderDrawData()
{
Assert.True(ControllerFrameOpen);
ControllerFrameOpen = false;
calls.Add("draw-data");
if (DrawFailure is not null)
throw DrawFailure;
}
public void SetWindowLayout(
string title,