refactor(render): establish frame orchestration contract

This commit is contained in:
Erik 2026-07-22 04:18:31 +02:00
parent f316bbd817
commit 7e4cfb37c3
4 changed files with 489 additions and 5 deletions

View file

@ -14,7 +14,7 @@ port, or resource-lifetime redesign.
## Progress ledger
- [ ] A — freeze the complete render graph, correct the architecture SSOT, and
- [x] A — freeze the complete render graph, correct the architecture SSOT, and
introduce data-only frame contracts plus deterministic order/failure tests.
- [ ] B — extract paperdoll, panel-layout/devtools, and render-diagnostics leaf
owners while `GameWindow` still orders the frame.
@ -319,14 +319,24 @@ never choose a draw branch or mutate canonical world ownership.
- correct the stale render-pipeline and per-frame architecture sections;
- add `RenderFrameInput`, phase interfaces, and a recording orchestrator;
- pin normal order, portal replacement, login wait, indoor/outdoor/null-root
branches, and screenshot placement; camera is an initialized runtime
invariant, so the slice does not invent a new graceful missing-camera path;
- pin the outer resource/world/private/diagnostic order across normal,
portal-replacement, login-wait, and screenshot outcome shapes; defer the
concrete indoor/outdoor/null-root branch tests to the world builder and scene
owner in D/F; camera is an initialized runtime invariant, so the slice does
not invent a new graceful missing-camera path;
- pin the exact GPU begin/end exception matrix;
- add source guards against `GameWindow` back-references, mega contexts, and a
new PView delegate bag;
- capture the current connected framebuffer/resource baseline.
Completed 2026-07-22. `RenderFrameOrchestrator` now owns the inert five-owner
outer transaction contract; `GpuFrameFlightController` implements its narrow
lifetime seam. Twenty-six focused render/GPU tests pin value-only contracts,
required-owner construction, exact phase prefixes, per-phase input propagation,
and every begin/render/close failure combination. Release build and the full
suite pass (7,199 passed / 5 skipped after adding 17 tests). All three
corrected-diff reviews are clean; production `GameWindow.OnRender` is unchanged.
### B — leaf presentation and diagnostics
- extract paperdoll refresh/pose/dirty/FBO ownership;

View file

@ -214,7 +214,10 @@ internal sealed class ImmediateGpuResourceRetirementQueue : IGpuResourceRetireme
}
}
internal sealed class GpuFrameFlightController : IGpuResourceRetirementQueue, IDisposable
internal sealed class GpuFrameFlightController :
IGpuResourceRetirementQueue,
IRenderFrameLifetime,
IDisposable
{
internal const int DefaultMaximumFramesInFlight = 3;
private const ulong WaitSliceNanoseconds = 1_000_000;

View file

@ -0,0 +1,131 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Host-owned values for one render callback. Render phases borrow this value
/// for the duration of <see cref="RenderFrameOrchestrator.Render"/> only.
/// </summary>
internal readonly record struct RenderFrameInput(
double DeltaSeconds,
int ViewportWidth,
int ViewportHeight);
/// <summary>
/// World facts published after the normal-world phase has either drawn or
/// intentionally skipped the viewport.
/// </summary>
internal readonly record struct WorldRenderFrameOutcome(
int VisibleLandblocks,
int TotalLandblocks,
bool NormalWorldDrawn);
/// <summary>
/// Private-presentation facts published after portal, paperdoll, retained UI,
/// developer UI, and screenshot work has completed.
/// </summary>
internal readonly record struct PrivatePresentationFrameOutcome(
bool PortalViewportDrawn,
bool ScreenshotCaptured);
/// <summary>The immutable result observed by post-screenshot diagnostics.</summary>
internal readonly record struct RenderFrameOutcome(
WorldRenderFrameOutcome World,
PrivatePresentationFrameOutcome Presentation);
internal interface IRenderFrameLifetime
{
void BeginFrame();
void EndFrame();
}
internal interface IRenderFrameResourcePhase
{
void Prepare(RenderFrameInput input);
}
internal interface IWorldSceneFramePhase
{
WorldRenderFrameOutcome Render(RenderFrameInput input);
}
internal interface IPrivatePresentationFramePhase
{
PrivatePresentationFrameOutcome Render(
RenderFrameInput input,
WorldRenderFrameOutcome world);
}
internal interface IRenderFrameDiagnosticsPhase
{
void Publish(RenderFrameInput input, RenderFrameOutcome outcome);
}
/// <summary>
/// Owns the accepted outer render transaction. Focused phase owners retain the
/// detailed PView, private-viewport, UI, and diagnostic order inside their
/// typed boundaries.
/// </summary>
/// <remarks>
/// <see cref="IRenderFrameLifetime.BeginFrame"/> intentionally runs outside
/// the protected render body. Once begin succeeds, close is attempted exactly
/// once. This preserves the production GPU-flight contract: a render failure
/// and close failure are reported together, while either failure alone
/// propagates directly.
/// </remarks>
internal sealed class RenderFrameOrchestrator
{
private readonly IRenderFrameLifetime _lifetime;
private readonly IRenderFrameResourcePhase _resources;
private readonly IWorldSceneFramePhase _world;
private readonly IPrivatePresentationFramePhase _presentation;
private readonly IRenderFrameDiagnosticsPhase _diagnostics;
public RenderFrameOrchestrator(
IRenderFrameLifetime lifetime,
IRenderFrameResourcePhase resources,
IWorldSceneFramePhase world,
IPrivatePresentationFramePhase presentation,
IRenderFrameDiagnosticsPhase diagnostics)
{
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
_world = world ?? throw new ArgumentNullException(nameof(world));
_presentation = presentation ?? throw new ArgumentNullException(nameof(presentation));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
}
public RenderFrameOutcome Render(RenderFrameInput input)
{
_lifetime.BeginFrame();
Exception? renderFailure = null;
try
{
_resources.Prepare(input);
WorldRenderFrameOutcome world = _world.Render(input);
PrivatePresentationFrameOutcome presentation =
_presentation.Render(input, world);
var outcome = new RenderFrameOutcome(world, presentation);
_diagnostics.Publish(input, outcome);
return outcome;
}
catch (Exception error)
{
renderFailure = error;
throw;
}
finally
{
try
{
_lifetime.EndFrame();
}
catch (Exception closeFailure) when (renderFailure is not null)
{
throw new AggregateException(
"Rendering failed and the in-flight GPU frame could not be closed.",
renderFailure,
closeFailure);
}
}
}
}

View file

@ -0,0 +1,340 @@
using System.Reflection;
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class RenderFrameOrchestratorTests
{
private static readonly RenderFrameInput Input = new(
DeltaSeconds: 1.0 / 60.0,
ViewportWidth: 1920,
ViewportHeight: 1080);
[Theory]
[InlineData(12, 40, true, false, false)]
[InlineData(0, 0, false, false, false)]
[InlineData(0, 0, false, true, false)]
[InlineData(4, 17, true, false, true)]
public void Render_PreservesOuterOrderAcrossWorldAndPresentationOutcomes(
int visibleLandblocks,
int totalLandblocks,
bool normalWorldDrawn,
bool portalViewportDrawn,
bool screenshotCaptured)
{
var calls = new List<string>();
var phases = new RecordingPhases(calls)
{
World = new WorldRenderFrameOutcome(
visibleLandblocks,
totalLandblocks,
normalWorldDrawn),
Presentation = new PrivatePresentationFrameOutcome(
portalViewportDrawn,
screenshotCaptured),
};
var orchestrator = Create(phases);
RenderFrameOutcome outcome = orchestrator.Render(Input);
Assert.Equal(
["gpu-begin", "resources", "world", "presentation", "diagnostics", "gpu-end"],
calls);
Assert.Equal(phases.World, outcome.World);
Assert.Equal(phases.Presentation, outcome.Presentation);
Assert.Equal(
[
("resources", Input),
("world", Input),
("presentation", Input),
("diagnostics", Input),
],
phases.ObservedInputs);
Assert.Equal(phases.World, phases.ObservedWorld);
Assert.Equal(outcome, phases.ObservedOutcome);
}
[Fact]
public void BeginFailure_DoesNotAttemptAnyPhaseOrClose()
{
var calls = new List<string>();
var expected = new InvalidOperationException("begin");
var phases = new RecordingPhases(calls)
{
FailurePoint = "gpu-begin",
Failure = expected,
};
InvalidOperationException actual = Assert.Throws<InvalidOperationException>(
() => Create(phases).Render(Input));
Assert.Same(expected, actual);
Assert.Equal(["gpu-begin"], calls);
}
[Theory]
[InlineData("resources")]
[InlineData("world")]
[InlineData("presentation")]
[InlineData("diagnostics")]
public void RenderFailure_ClosesExactlyOnceAndPropagates(string failurePoint)
{
var calls = new List<string>();
var expected = new InvalidOperationException(failurePoint);
var phases = new RecordingPhases(calls)
{
FailurePoint = failurePoint,
Failure = expected,
};
InvalidOperationException actual = Assert.Throws<InvalidOperationException>(
() => Create(phases).Render(Input));
Assert.Same(expected, actual);
Assert.Equal(ExpectedFailureCalls(failurePoint), calls);
Assert.Equal(
ExpectedPhaseInputs(failurePoint),
phases.ObservedInputs);
}
[Fact]
public void CloseOnlyFailure_PropagatesDirectly()
{
var calls = new List<string>();
var expected = new InvalidOperationException("close");
var phases = new RecordingPhases(calls)
{
CloseFailure = expected,
};
InvalidOperationException actual = Assert.Throws<InvalidOperationException>(
() => Create(phases).Render(Input));
Assert.Same(expected, actual);
Assert.Equal(
["gpu-begin", "resources", "world", "presentation", "diagnostics", "gpu-end"],
calls);
}
[Theory]
[InlineData("resources")]
[InlineData("world")]
[InlineData("presentation")]
[InlineData("diagnostics")]
public void RenderAndCloseFailure_AreAggregatedInCausalOrder(string failurePoint)
{
var calls = new List<string>();
var renderFailure = new InvalidOperationException("render");
var closeFailure = new InvalidOperationException("close");
var phases = new RecordingPhases(calls)
{
FailurePoint = failurePoint,
Failure = renderFailure,
CloseFailure = closeFailure,
};
AggregateException actual = Assert.Throws<AggregateException>(
() => Create(phases).Render(Input));
Assert.Equal(
"Rendering failed and the in-flight GPU frame could not be closed.",
actual.Message.Split(" (")[0]);
Assert.Equal(2, actual.InnerExceptions.Count);
Assert.Same(renderFailure, actual.InnerExceptions[0]);
Assert.Same(closeFailure, actual.InnerExceptions[1]);
Assert.Equal(ExpectedFailureCalls(failurePoint), calls);
Assert.Equal(
ExpectedPhaseInputs(failurePoint),
phases.ObservedInputs);
}
[Fact]
public void Constructor_RejectsEveryMissingRequiredOwner()
{
var phases = new RecordingPhases([]);
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
null!, phases, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, null!, phases, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, null!, phases, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, null!, phases));
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
phases, phases, phases, phases, null!));
}
[Fact]
public void Orchestrator_UsesOnlyTheExplicitTypedOwnerGraph()
{
Type[] expectedFieldTypes =
[
typeof(IRenderFrameLifetime),
typeof(IRenderFrameResourcePhase),
typeof(IWorldSceneFramePhase),
typeof(IPrivatePresentationFramePhase),
typeof(IRenderFrameDiagnosticsPhase),
];
FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields(
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.Equal(
expectedFieldTypes.OrderBy(type => type.FullName),
fields.Select(field => field.FieldType).OrderBy(type => type.FullName));
Assert.All(fields, field =>
{
Assert.NotEqual(typeof(GameWindow), field.FieldType);
Assert.False(typeof(Delegate).IsAssignableFrom(field.FieldType));
});
Assert.All(
expectedFieldTypes,
contract => Assert.False(contract.IsAssignableFrom(typeof(GameWindow))));
foreach (Type contract in expectedFieldTypes)
{
foreach (MethodInfo method in contract.GetMethods())
{
Assert.True(
method.ReturnType == typeof(void) || method.ReturnType.IsValueType,
$"{contract.Name}.{method.Name} returned owner-like type "
+ method.ReturnType.FullName);
Assert.All(
method.GetParameters(),
parameter => Assert.True(
parameter.ParameterType.IsValueType,
$"{contract.Name}.{method.Name} accepted owner-like type "
+ parameter.ParameterType.FullName));
}
}
Assert.True(typeof(IRenderFrameLifetime).IsAssignableFrom(
typeof(GpuFrameFlightController)));
}
[Fact]
public void FrameContracts_AreDataOnlyValuesWithoutOwnerOrDelegateReferences()
{
Type[] contracts =
[
typeof(RenderFrameInput),
typeof(WorldRenderFrameOutcome),
typeof(PrivatePresentationFrameOutcome),
typeof(RenderFrameOutcome),
];
foreach (Type contract in contracts)
{
Assert.True(contract.IsValueType);
Assert.All(
contract.GetFields(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic),
field =>
{
Assert.True(
field.FieldType.IsValueType,
$"{contract.Name}.{field.Name} retained owner-like type "
+ field.FieldType.FullName);
});
}
}
private static string[] ExpectedFailureCalls(string failurePoint) => failurePoint switch
{
"resources" => ["gpu-begin", "resources", "gpu-end"],
"world" => ["gpu-begin", "resources", "world", "gpu-end"],
"presentation" =>
["gpu-begin", "resources", "world", "presentation", "gpu-end"],
"diagnostics" =>
[
"gpu-begin", "resources", "world", "presentation", "diagnostics",
"gpu-end",
],
_ => throw new ArgumentOutOfRangeException(nameof(failurePoint)),
};
private static (string Phase, RenderFrameInput Input)[] ExpectedPhaseInputs(
string failurePoint)
{
string[] phases = failurePoint switch
{
"resources" => ["resources"],
"world" => ["resources", "world"],
"presentation" => ["resources", "world", "presentation"],
"diagnostics" => ["resources", "world", "presentation", "diagnostics"],
_ => throw new ArgumentOutOfRangeException(nameof(failurePoint)),
};
return phases.Select(phase => (phase, Input)).ToArray();
}
private static RenderFrameOrchestrator Create(RecordingPhases phases) =>
new(phases, phases, phases, phases, phases);
private sealed class RecordingPhases :
IRenderFrameLifetime,
IRenderFrameResourcePhase,
IWorldSceneFramePhase,
IPrivatePresentationFramePhase,
IRenderFrameDiagnosticsPhase
{
private readonly List<string> _calls;
public RecordingPhases(List<string> calls)
{
_calls = calls;
}
public string? FailurePoint { get; init; }
public Exception? Failure { get; init; }
public Exception? CloseFailure { 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; } = [];
public WorldRenderFrameOutcome ObservedWorld { get; private set; }
public RenderFrameOutcome ObservedOutcome { get; private set; }
public void BeginFrame() => Record("gpu-begin");
public void EndFrame()
{
_calls.Add("gpu-end");
if (CloseFailure is not null)
throw CloseFailure;
}
public void Prepare(RenderFrameInput input)
{
ObservedInputs.Add(("resources", input));
Record("resources");
}
public WorldRenderFrameOutcome Render(RenderFrameInput input)
{
ObservedInputs.Add(("world", input));
Record("world");
return World;
}
public PrivatePresentationFrameOutcome Render(
RenderFrameInput input,
WorldRenderFrameOutcome world)
{
ObservedInputs.Add(("presentation", input));
ObservedWorld = world;
Record("presentation");
return Presentation;
}
public void Publish(RenderFrameInput input, RenderFrameOutcome outcome)
{
ObservedInputs.Add(("diagnostics", input));
ObservedOutcome = outcome;
Record("diagnostics");
}
private void Record(string call)
{
_calls.Add(call);
if (FailurePoint == call && Failure is not null)
throw Failure;
}
}
}