refactor(render): extract frame resource preparation
Move ordered resource begin, clear/state setup, live upload/reveal, weather timing, and display pacing behind focused owners while preserving the accepted frame order and retryable shutdown ownership. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
733126a272
commit
bc6f09f987
8 changed files with 1068 additions and 205 deletions
|
|
@ -0,0 +1,208 @@
|
|||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class DisplayFramePacingControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Binding_surface_preserves_startup_fallback_until_monitor_refresh()
|
||||
{
|
||||
using var profiler = new FrameProfiler();
|
||||
using var controller = new DisplayFramePacingController(
|
||||
uncappedRendering: false,
|
||||
profiler,
|
||||
new FramePacingController(
|
||||
new FakeClock(1_000),
|
||||
new RecordingWaiter()));
|
||||
var surface = new FakeSurface
|
||||
{
|
||||
VSync = true,
|
||||
ActiveMonitorRefreshHz = 144,
|
||||
};
|
||||
|
||||
FramePacingPolicy startup = controller.InitializeStartup(
|
||||
requestedVSync: false);
|
||||
controller.BindSurface(surface);
|
||||
|
||||
Assert.Equal(FramePacingPolicy.FallbackRefreshHz, startup.SoftwareLimitHz);
|
||||
Assert.Equal(startup, controller.Policy);
|
||||
|
||||
controller.RefreshActiveMonitor();
|
||||
|
||||
Assert.Equal(new FramePacingPolicy(false, 144d), controller.Policy);
|
||||
Assert.False(surface.VSync);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preference_and_monitor_changes_resolve_one_owned_policy()
|
||||
{
|
||||
using var profiler = new FrameProfiler();
|
||||
using var controller = new DisplayFramePacingController(
|
||||
uncappedRendering: false,
|
||||
profiler,
|
||||
new FramePacingController(
|
||||
new FakeClock(1_000),
|
||||
new RecordingWaiter()));
|
||||
var surface = new FakeSurface
|
||||
{
|
||||
VSync = false,
|
||||
ActiveMonitorRefreshHz = 120,
|
||||
};
|
||||
controller.InitializeStartup(requestedVSync: false);
|
||||
controller.BindSurface(surface);
|
||||
controller.RefreshActiveMonitor();
|
||||
|
||||
controller.ApplyPreference(requestedVSync: true);
|
||||
Assert.Equal(new FramePacingPolicy(true, null), controller.Policy);
|
||||
Assert.True(surface.VSync);
|
||||
|
||||
surface.ActiveMonitorRefreshHz = 165;
|
||||
controller.ApplyPreference(requestedVSync: false);
|
||||
Assert.Equal(new FramePacingPolicy(false, 120d), controller.Policy);
|
||||
Assert.False(surface.VSync);
|
||||
|
||||
controller.OnWindowStateChanged(default);
|
||||
Assert.Equal(new FramePacingPolicy(false, 165d), controller.Policy);
|
||||
|
||||
surface.ActiveMonitorRefreshHz = 75;
|
||||
controller.OnWindowMoved(default);
|
||||
Assert.Equal(new FramePacingPolicy(false, 75d), controller.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repeated_preference_preview_uses_the_event_refreshed_monitor_cache()
|
||||
{
|
||||
using var profiler = new FrameProfiler();
|
||||
using var controller = new DisplayFramePacingController(
|
||||
uncappedRendering: false,
|
||||
profiler,
|
||||
new FramePacingController(
|
||||
new FakeClock(1_000),
|
||||
new RecordingWaiter()));
|
||||
var surface = new FakeSurface
|
||||
{
|
||||
VSync = false,
|
||||
ActiveMonitorRefreshHz = 144,
|
||||
};
|
||||
controller.InitializeStartup(requestedVSync: false);
|
||||
controller.BindSurface(surface);
|
||||
controller.RefreshActiveMonitor();
|
||||
Assert.Equal(1, surface.RefreshReadCount);
|
||||
|
||||
controller.ApplyPreference(requestedVSync: false);
|
||||
controller.ApplyPreference(requestedVSync: false);
|
||||
controller.ApplyPreference(requestedVSync: false);
|
||||
|
||||
Assert.Equal(1, surface.RefreshReadCount);
|
||||
Assert.Equal(new FramePacingPolicy(false, 144d), controller.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_uncapped_mode_disables_both_pacing_mechanisms()
|
||||
{
|
||||
using var profiler = new FrameProfiler();
|
||||
using var controller = new DisplayFramePacingController(
|
||||
uncappedRendering: true,
|
||||
profiler,
|
||||
new FramePacingController(
|
||||
new FakeClock(1_000),
|
||||
new RecordingWaiter()));
|
||||
var surface = new FakeSurface
|
||||
{
|
||||
VSync = true,
|
||||
ActiveMonitorRefreshHz = 240,
|
||||
};
|
||||
controller.InitializeStartup(requestedVSync: true);
|
||||
controller.BindSurface(surface);
|
||||
|
||||
controller.RefreshActiveMonitor();
|
||||
|
||||
Assert.Equal(new FramePacingPolicy(false, null), controller.Policy);
|
||||
Assert.False(surface.VSync);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_completion_delegates_to_the_owned_software_pacer()
|
||||
{
|
||||
var clock = new FakeClock(1_000);
|
||||
var waiter = new RecordingWaiter(clock);
|
||||
using var profiler = new FrameProfiler();
|
||||
using var controller = new DisplayFramePacingController(
|
||||
uncappedRendering: false,
|
||||
profiler,
|
||||
new FramePacingController(clock, waiter));
|
||||
controller.InitializeStartup(requestedVSync: false);
|
||||
|
||||
clock.Timestamp = 10;
|
||||
controller.OnFrameRendered(0d);
|
||||
|
||||
Assert.Equal([7L], waiter.Durations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_releases_borrowed_surface_and_profiler_without_disposing_surface()
|
||||
{
|
||||
using var profiler = new FrameProfiler();
|
||||
var controller = new DisplayFramePacingController(
|
||||
uncappedRendering: false,
|
||||
profiler,
|
||||
new FramePacingController(
|
||||
new FakeClock(1_000),
|
||||
new RecordingWaiter()));
|
||||
var surface = new FakeSurface();
|
||||
controller.BindSurface(surface);
|
||||
Assert.True(controller.IsSurfaceBound);
|
||||
|
||||
controller.Dispose();
|
||||
controller.Dispose();
|
||||
|
||||
Assert.False(controller.IsSurfaceBound);
|
||||
Assert.Throws<ObjectDisposedException>(() => controller.OnFrameRendered(0d));
|
||||
Assert.False(surface.Disposed);
|
||||
}
|
||||
|
||||
private sealed class FakeSurface : IDisplayFramePacingSurface, IDisposable
|
||||
{
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public bool VSync { get; set; }
|
||||
|
||||
private int? _activeMonitorRefreshHz;
|
||||
|
||||
public int RefreshReadCount { get; private set; }
|
||||
|
||||
public int? ActiveMonitorRefreshHz
|
||||
{
|
||||
get
|
||||
{
|
||||
RefreshReadCount++;
|
||||
return _activeMonitorRefreshHz;
|
||||
}
|
||||
set => _activeMonitorRefreshHz = value;
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
private sealed class FakeClock(long frequency) : IFramePacingClock
|
||||
{
|
||||
public long Frequency { get; } = frequency;
|
||||
|
||||
public long Timestamp { get; set; }
|
||||
|
||||
public long GetTimestamp() => Timestamp;
|
||||
}
|
||||
|
||||
private sealed class RecordingWaiter(FakeClock? clock = null) : IFramePacingWaiter
|
||||
{
|
||||
public List<long> Durations { get; } = [];
|
||||
|
||||
public void Wait(long durationTicks, long clockFrequency)
|
||||
{
|
||||
Durations.Add(durationTicks);
|
||||
if (clock is not null)
|
||||
clock.Timestamp += durationTicks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,16 +20,33 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionRender_BeginsDevToolsBeforeWorldAndBeginsDispatcherOnce()
|
||||
public void ProductionRender_Prepares_resources_then_devtools_weather_and_world()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_renderFrameResources!.Prepare(frameInput);",
|
||||
"_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);",
|
||||
"Weather.Tick(nowSeconds: _weatherAccum",
|
||||
"_renderWeatherFrame!.Tick(deltaSeconds);",
|
||||
"_retailSelectionScene?.BeginFrame();");
|
||||
Assert.Equal(1, CountOccurrences(source, "_wbDrawDispatcher?.BeginFrame("));
|
||||
Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source);
|
||||
Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source);
|
||||
Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Composition_transfers_portal_tunnel_before_constructing_frame_borrowers()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"new AcDream.App.Streaming.LocalPlayerTeleportController(",
|
||||
"_portalTunnel = null;",
|
||||
"_localPlayerTeleportSink.Bind(_localPlayerTeleport);",
|
||||
"new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(",
|
||||
"new AcDream.App.Rendering.RenderFrameResourceController(");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -55,12 +72,21 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
"EmitClipRouteScissorProbe(scissor",
|
||||
"_lastVisibleLandblocks",
|
||||
"_perfAccum",
|
||||
"_entityUploadTiming",
|
||||
"_weatherAccum",
|
||||
"TryGetLoginWorldCell",
|
||||
"ApplyFramePacingPreference",
|
||||
"RefreshActiveMonitorFramePacing",
|
||||
"private void OnFrameRendered(",
|
||||
];
|
||||
foreach (string identifier in removed)
|
||||
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.DevToolsFramePresenter(", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.RenderFrameResourceController(", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.RenderWeatherFrameController(", source);
|
||||
Assert.Contains("new DisplayFramePacingController(", source);
|
||||
Assert.Contains("_inputCapture.WantCaptureMouse", source);
|
||||
}
|
||||
|
||||
|
|
@ -73,9 +99,17 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
source,
|
||||
"new ResourceShutdownStage(\"submitted GPU work\"",
|
||||
"new ResourceShutdownStage(\"render frontends\"",
|
||||
"new(\"frame composition\"",
|
||||
"new(\"portal tunnel\"",
|
||||
"new(\"paperdoll viewport\"",
|
||||
"new ResourceShutdownStage(\"OpenGL context\"");
|
||||
Assert.Contains("_renderFrameResources = null;", source);
|
||||
Assert.Contains("_renderFrameLivePreparation = null;", source);
|
||||
Assert.Contains("_renderWeatherFrame = null;", source);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"new(\"frame pacing\", _displayFramePacing.Dispose)",
|
||||
"new(\"frame profiler\", _frameProfiler.Dispose)");
|
||||
Assert.DoesNotContain("_devToolsBackend?.Dispose()", source);
|
||||
}
|
||||
|
||||
|
|
@ -139,19 +173,6 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
}
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string source, string needle)
|
||||
{
|
||||
int count = 0;
|
||||
int cursor = 0;
|
||||
while ((cursor = source.IndexOf(needle, cursor, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
cursor += needle.Length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class RenderFrameResourceControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Prepare_runs_begin_clear_and_live_in_order_for_one_gpu_slot()
|
||||
{
|
||||
List<string> calls = [];
|
||||
var clearResult = new RenderFrameFoundation(
|
||||
PortalViewportVisible: true,
|
||||
Sky: default,
|
||||
Atmosphere: default);
|
||||
var slots = new AdvancingSlotSource(2);
|
||||
var controller = new RenderFrameResourceController(
|
||||
slots,
|
||||
new RecordingBegin(calls),
|
||||
new RecordingClear(calls, clearResult),
|
||||
new RecordingLive(calls));
|
||||
|
||||
controller.Prepare(new RenderFrameInput(1d / 60d, 1280, 720));
|
||||
|
||||
Assert.Equal(["begin:2", "clear", "live:2"], calls);
|
||||
Assert.Equal(1, slots.ReadCount);
|
||||
Assert.Equal(clearResult, controller.Foundation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Required_phase_dependencies_fail_fast()
|
||||
{
|
||||
var slots = new AdvancingSlotSource(0);
|
||||
var begin = new RecordingBegin([]);
|
||||
var clear = new RecordingClear([], default);
|
||||
var live = new RecordingLive([]);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new RenderFrameResourceController(null!, begin, clear, live));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new RenderFrameResourceController(slots, null!, clear, live));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new RenderFrameResourceController(slots, begin, null!, live));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new RenderFrameResourceController(slots, begin, clear, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_begin_resources_preserve_the_exact_frame_order()
|
||||
{
|
||||
string source = ResourceSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_textures?.BeginCompositeTextureFrame();",
|
||||
"_textures?.TickCompositeTextureCache();",
|
||||
"_dispatcher?.BeginFrame(gpuSlot);",
|
||||
"_environmentCells?.BeginFrame(gpuSlot);",
|
||||
"_portalDepth?.BeginFrame(gpuSlot);",
|
||||
"_worldText?.BeginFrame(gpuSlot);",
|
||||
"_uiText?.BeginFrame(gpuSlot);",
|
||||
"_clip?.BeginFrame(gpuSlot);",
|
||||
"_terrain?.BeginFrame(gpuSlot);",
|
||||
"_lighting?.BeginFrame(gpuSlot);",
|
||||
"_profiler.FrameBoundary(_gl);");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_live_preparation_publishes_meshes_before_reveal_and_particles()
|
||||
{
|
||||
string source = ResourceSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_meshes?.Tick();",
|
||||
"_worldReveal?.PrepareAndEvaluate(revealCell);",
|
||||
"_particles?.BeginFrame(gpuSlot);");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_clear_phase_preserves_atmosphere_and_gl_state_order()
|
||||
{
|
||||
string source = ResourceSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"bool portalViewportVisible = _portal.IsPortalViewportVisible;",
|
||||
"_particleVisibility.Reset();",
|
||||
"SkyKeyframe sky = _worldTime.CurrentSky;",
|
||||
"AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky);",
|
||||
"_gl.DepthMask(true);",
|
||||
"_gl.Clear(",
|
||||
"_gl.CullFace(TriangleFace.Back);",
|
||||
"_gl.FrontFace(FrontFaceDirection.CW);",
|
||||
"_diagnostics.EmitGlStateTripwireIfChanged(");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weather_frame_clock_advances_only_after_the_weather_tick()
|
||||
{
|
||||
var controller = new RenderWeatherFrameController(
|
||||
new AcDream.Core.World.WorldTimeService(
|
||||
AcDream.Core.World.SkyStateProvider.Default()),
|
||||
new AcDream.Core.World.WeatherSystem());
|
||||
|
||||
controller.Tick(0.25d);
|
||||
controller.Tick(0.5d);
|
||||
|
||||
Assert.Equal(0.75d, controller.ElapsedSeconds);
|
||||
AssertAppearsInOrder(
|
||||
ResourceSource(),
|
||||
"_weather.Tick(",
|
||||
"nowSeconds: _elapsedSeconds,",
|
||||
"_elapsedSeconds += deltaSeconds;");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Login_state_is_latched_by_the_shared_player_mode_source()
|
||||
{
|
||||
var mode = new AcDream.App.Input.LocalPlayerModeState();
|
||||
var live = new AcDream.App.Rendering.RenderLoginStateSource(
|
||||
liveMode: true,
|
||||
mode);
|
||||
var offline = new AcDream.App.Rendering.RenderLoginStateSource(
|
||||
liveMode: false,
|
||||
mode);
|
||||
|
||||
Assert.True(live.IsWaitingForLogin);
|
||||
Assert.False(offline.IsWaitingForLogin);
|
||||
|
||||
mode.ChaseModeEverEntered = true;
|
||||
|
||||
Assert.False(live.IsWaitingForLogin);
|
||||
}
|
||||
|
||||
private static string ResourceSource() => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"RenderFrameResourceController.cs"));
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] needles)
|
||||
{
|
||||
int cursor = -1;
|
||||
foreach (string needle in needles)
|
||||
{
|
||||
int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(next >= 0, $"Missing expected source fragment: {needle}");
|
||||
Assert.True(next > cursor, $"Out-of-order source fragment: {needle}");
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
|
||||
private sealed class AdvancingSlotSource(int firstSlot) : IRenderFrameSlotSource
|
||||
{
|
||||
public int ReadCount { get; private set; }
|
||||
|
||||
public int CurrentSlot => firstSlot + ReadCount++;
|
||||
}
|
||||
|
||||
private sealed class RecordingBegin(List<string> calls) : IRenderFrameBeginResources
|
||||
{
|
||||
public void Begin(int gpuSlot) => calls.Add($"begin:{gpuSlot}");
|
||||
}
|
||||
|
||||
private sealed class RecordingClear(
|
||||
List<string> calls,
|
||||
RenderFrameFoundation result) : IRenderFrameClearPhase
|
||||
{
|
||||
public RenderFrameFoundation Clear()
|
||||
{
|
||||
calls.Add("clear");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingLive(List<string> calls) : IRenderFrameLivePreparation
|
||||
{
|
||||
public void Prepare(int gpuSlot) => calls.Add($"live:{gpuSlot}");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue