acdream/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs
Erik 0eb6648589 feat(rendering): compare the incremental shadow scene
Construct Slice F's non-drawing scene only for lifecycle automation, drain accepted static and live deltas at the final update boundary, and compare exact current-path fingerprints at cadence and checkpoints. Publish bounded mismatch, journal, index, digest, and memory evidence without changing normal launches or draw submission.

Release: 8,211 passed, 5 skipped.
2026-07-24 22:24:30 +02:00

287 lines
9.4 KiB
C#

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);
}
/// <summary>
/// Diagnostic GPU timer bracketing only render-resource, world, and private
/// presentation submission. It deliberately excludes diagnostics, pacing,
/// and the idle interval before the next render callback.
/// </summary>
internal interface IRenderFrameGpuMeasurement
{
void BeginFrame();
void EndFrame();
}
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);
}
internal interface IRenderFramePostDiagnosticsPhase
{
void Process(RenderFrameInput input, RenderFrameOutcome outcome);
}
internal sealed class NullRenderFramePostDiagnosticsPhase :
IRenderFramePostDiagnosticsPhase
{
public static NullRenderFramePostDiagnosticsPhase Instance { get; } = new();
private NullRenderFramePostDiagnosticsPhase()
{
}
public void Process(RenderFrameInput input, RenderFrameOutcome outcome)
{
}
}
/// <summary>
/// Fixed-order post-diagnostic fan-out. The shadow referee must run before
/// lifecycle checkpoint publication so each artifact observes the comparison
/// made against that exact completed render frame.
/// </summary>
internal sealed class SerialRenderFramePostDiagnosticsPhase :
IRenderFramePostDiagnosticsPhase
{
private readonly IRenderFramePostDiagnosticsPhase[] _phases;
public SerialRenderFramePostDiagnosticsPhase(
params IRenderFramePostDiagnosticsPhase[] phases)
{
ArgumentNullException.ThrowIfNull(phases);
if (phases.Length == 0 || phases.Any(static phase => phase is null))
{
throw new ArgumentException(
"At least one non-null post-diagnostic phase is required.",
nameof(phases));
}
_phases = [.. phases];
}
public void Process(RenderFrameInput input, RenderFrameOutcome outcome)
{
for (int i = 0; i < _phases.Length; i++)
_phases[i].Process(input, outcome);
}
}
/// <summary>
/// Closes presentation transactions which may have opened before a later
/// render phase failed. Recovery is idempotent so failures before the
/// transaction opens and failures after it closes are both safe.
/// </summary>
internal interface IRenderFrameFailureRecovery
{
void AbortFrame();
}
internal sealed class NullRenderFrameFailureRecovery : IRenderFrameFailureRecovery
{
public static NullRenderFrameFailureRecovery Instance { get; } = new();
private NullRenderFrameFailureRecovery()
{
}
public void AbortFrame()
{
}
}
/// <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 : IGameRenderFrameRoot
{
private readonly IRenderFrameLifetime _lifetime;
private readonly IRenderFrameGpuMeasurement _gpuMeasurement;
private readonly IRenderFrameResourcePhase _resources;
private readonly IWorldSceneFramePhase _world;
private readonly IPrivatePresentationFramePhase _presentation;
private readonly IRenderFrameDiagnosticsPhase _diagnostics;
private readonly IRenderFramePostDiagnosticsPhase _postDiagnostics;
private readonly IRenderFrameFailureRecovery _recovery;
public RenderFrameOrchestrator(
IRenderFrameLifetime lifetime,
IRenderFrameGpuMeasurement gpuMeasurement,
IRenderFrameResourcePhase resources,
IWorldSceneFramePhase world,
IPrivatePresentationFramePhase presentation,
IRenderFrameDiagnosticsPhase diagnostics,
IRenderFramePostDiagnosticsPhase postDiagnostics,
IRenderFrameFailureRecovery recovery)
{
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
_gpuMeasurement = gpuMeasurement
?? throw new ArgumentNullException(nameof(gpuMeasurement));
_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));
_postDiagnostics = postDiagnostics
?? throw new ArgumentNullException(nameof(postDiagnostics));
_recovery = recovery ?? throw new ArgumentNullException(nameof(recovery));
}
public RenderFrameOutcome Render(RenderFrameInput input)
{
_lifetime.BeginFrame();
Exception? renderFailure = null;
try
{
WorldRenderFrameOutcome world;
PrivatePresentationFrameOutcome presentation;
Exception? measuredRenderFailure = null;
_gpuMeasurement.BeginFrame();
try
{
_resources.Prepare(input);
world = _world.Render(input);
presentation = _presentation.Render(input, world);
}
catch (Exception error)
{
measuredRenderFailure = error;
throw;
}
finally
{
try
{
_gpuMeasurement.EndFrame();
}
catch (Exception measurementFailure)
when (measuredRenderFailure is not null)
{
throw new AggregateException(
"Rendering failed and its GPU measurement could not be closed.",
measuredRenderFailure,
measurementFailure);
}
}
var outcome = new RenderFrameOutcome(world, presentation);
_diagnostics.Publish(input, outcome);
_postDiagnostics.Process(input, outcome);
return outcome;
}
catch (Exception error)
{
renderFailure = error;
throw;
}
finally
{
Exception? recoveryFailure = null;
if (renderFailure is not null)
{
try
{
// ImGui NewFrame is opened during preparation but normally
// closed during private presentation. Abort it before the
// GPU flight closes when any intervening phase fails.
_recovery.AbortFrame();
}
catch (Exception error)
{
recoveryFailure = error;
}
}
try
{
_lifetime.EndFrame();
}
catch (Exception closeFailure) when (renderFailure is not null)
{
if (recoveryFailure is not null)
{
throw new AggregateException(
"Rendering failed and neither presentation recovery nor the in-flight GPU frame could be closed.",
renderFailure,
recoveryFailure,
closeFailure);
}
throw new AggregateException(
"Rendering failed and the in-flight GPU frame could not be closed.",
renderFailure,
closeFailure);
}
if (renderFailure is not null && recoveryFailure is not null)
{
throw new AggregateException(
"Rendering failed and the presentation frame could not be aborted.",
renderFailure,
recoveryFailure);
}
}
}
}