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

@ -148,21 +148,77 @@ public sealed class RenderFrameOrchestratorTests
phases.ObservedInputs);
}
[Fact]
public void RenderAndRecoveryFailure_AreAggregatedBeforeGpuClose()
{
var calls = new List<string>();
var renderFailure = new InvalidOperationException("render");
var recoveryFailure = new InvalidOperationException("recovery");
var phases = new RecordingPhases(calls)
{
FailurePoint = "world",
Failure = renderFailure,
RecoveryFailure = recoveryFailure,
};
AggregateException actual = Assert.Throws<AggregateException>(
() => Create(phases).Render(Input));
Assert.Equal(2, actual.InnerExceptions.Count);
Assert.Same(renderFailure, actual.InnerExceptions[0]);
Assert.Same(recoveryFailure, actual.InnerExceptions[1]);
Assert.Equal(
["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"],
calls);
}
[Fact]
public void RenderRecoveryAndCloseFailures_AllRemainObservableInCausalOrder()
{
var calls = new List<string>();
var renderFailure = new InvalidOperationException("render");
var recoveryFailure = new InvalidOperationException("recovery");
var closeFailure = new InvalidOperationException("close");
var phases = new RecordingPhases(calls)
{
FailurePoint = "presentation",
Failure = renderFailure,
RecoveryFailure = recoveryFailure,
CloseFailure = closeFailure,
};
AggregateException actual = Assert.Throws<AggregateException>(
() => Create(phases).Render(Input));
Assert.Equal(3, actual.InnerExceptions.Count);
Assert.Same(renderFailure, actual.InnerExceptions[0]);
Assert.Same(recoveryFailure, actual.InnerExceptions[1]);
Assert.Same(closeFailure, actual.InnerExceptions[2]);
Assert.Equal(
[
"gpu-begin", "resources", "world", "presentation",
"recovery-abort", "gpu-end",
],
calls);
}
[Fact]
public void Constructor_RejectsEveryMissingRequiredOwner()
{
var phases = new RecordingPhases([]);
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
null!, phases, phases, phases, phases));
null!, phases, phases, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, null!, phases, phases, phases));
phases, null!, phases, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, null!, phases, phases));
phases, phases, null!, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, null!, phases));
phases, phases, phases, null!, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, phases, null!));
phases, phases, phases, phases, null!, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, phases, phases, null!));
}
[Fact]
@ -175,6 +231,7 @@ public sealed class RenderFrameOrchestratorTests
typeof(IWorldSceneFramePhase),
typeof(IPrivatePresentationFramePhase),
typeof(IRenderFrameDiagnosticsPhase),
typeof(IRenderFrameFailureRecovery),
];
FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
@ -239,14 +296,17 @@ public sealed class RenderFrameOrchestratorTests
private static string[] ExpectedFailureCalls(string failurePoint) => failurePoint switch
{
"resources" => ["gpu-begin", "resources", "gpu-end"],
"world" => ["gpu-begin", "resources", "world", "gpu-end"],
"resources" => ["gpu-begin", "resources", "recovery-abort", "gpu-end"],
"world" => ["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"],
"presentation" =>
["gpu-begin", "resources", "world", "presentation", "gpu-end"],
[
"gpu-begin", "resources", "world", "presentation",
"recovery-abort", "gpu-end",
],
"diagnostics" =>
[
"gpu-begin", "resources", "world", "presentation", "diagnostics",
"gpu-end",
"recovery-abort", "gpu-end",
],
_ => throw new ArgumentOutOfRangeException(nameof(failurePoint)),
};
@ -266,14 +326,15 @@ public sealed class RenderFrameOrchestratorTests
}
private static RenderFrameOrchestrator Create(RecordingPhases phases) =>
new(phases, phases, phases, phases, phases);
new(phases, phases, phases, phases, phases, phases);
private sealed class RecordingPhases :
IRenderFrameLifetime,
IRenderFrameResourcePhase,
IWorldSceneFramePhase,
IPrivatePresentationFramePhase,
IRenderFrameDiagnosticsPhase
IRenderFrameDiagnosticsPhase,
IRenderFrameFailureRecovery
{
private readonly List<string> _calls;
@ -285,6 +346,7 @@ public sealed class RenderFrameOrchestratorTests
public string? FailurePoint { get; init; }
public Exception? Failure { get; init; }
public Exception? CloseFailure { get; init; }
public Exception? RecoveryFailure { get; init; }
public WorldRenderFrameOutcome World { get; init; } = new(7, 19, true);
public PrivatePresentationFrameOutcome Presentation { get; init; } = new(false, false);
public List<(string Phase, RenderFrameInput Input)> ObservedInputs { get; } = [];
@ -300,6 +362,13 @@ public sealed class RenderFrameOrchestratorTests
throw CloseFailure;
}
public void AbortFrame()
{
_calls.Add("recovery-abort");
if (RecoveryFailure is not null)
throw RecoveryFailure;
}
public void Prepare(RenderFrameInput input)
{
ObservedInputs.Add(("resources", input));