diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md
index ac50d0e7..1cf8ede2 100644
--- a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md
+++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md
@@ -377,6 +377,20 @@ corrected-diff reviews are clean.
- prove `WbMeshAdapter.Tick` remains before reveal evaluation and drawing;
- prove dispatcher begin occurs once for the whole world/paperdoll frame.
+Completed 2026-07-22. `RenderFrameResourceController` now owns the exact
+resource-begin → atmosphere/GL-clear → live-upload/reveal/particle transaction,
+with a single captured GPU slot shared by every phase. Focused sources own the
+login reveal cell, teleport render state, upload timing, and render-time weather
+clock. `DisplayFramePacingController` owns requested VSync, the event-refreshed
+monitor-rate cache, software pacing, and the post-render callback without adding
+a monitor query to the frame hot path. Borrowed frame owners are withdrawn before
+render teardown; portal fallback ownership transfers before the throwable sink
+bind; pacing releases its borrowed surface/profiler before their owners close.
+`GameWindow.cs` is 6,181 raw lines, 60.7% below the 15,723-line campaign
+baseline. Twenty-one focused tests, the Release build, and the complete suite
+pass (7,269 passed / 5 skipped). Retail, architecture, and adversarial
+corrected-diff reviews are clean.
+
### D — world frame builder
- move animated/equipped classification, camera/root selection, outdoor-node
diff --git a/src/AcDream.App/Rendering/DisplayFramePacingController.cs b/src/AcDream.App/Rendering/DisplayFramePacingController.cs
new file mode 100644
index 00000000..a9f378b2
--- /dev/null
+++ b/src/AcDream.App/Rendering/DisplayFramePacingController.cs
@@ -0,0 +1,141 @@
+using AcDream.App.Diagnostics;
+using Silk.NET.Maths;
+using Silk.NET.Windowing;
+
+namespace AcDream.App.Rendering;
+
+internal interface IDisplayFramePacingSurface
+{
+ bool VSync { get; set; }
+
+ int? ActiveMonitorRefreshHz { get; }
+}
+
+/// Narrow Silk window adapter for display pacing.
+internal sealed class SilkDisplayFramePacingSurface : IDisplayFramePacingSurface
+{
+ private readonly IWindow _window;
+
+ public SilkDisplayFramePacingSurface(IWindow window)
+ {
+ _window = window ?? throw new ArgumentNullException(nameof(window));
+ }
+
+ public bool VSync
+ {
+ get => _window.VSync;
+ set => _window.VSync = value;
+ }
+
+ public int? ActiveMonitorRefreshHz
+ {
+ get
+ {
+ int? refreshHz = _window.Monitor?.VideoMode.RefreshRate;
+ return refreshHz is > 0 ? refreshHz : null;
+ }
+ }
+}
+
+///
+/// Owns requested VSync, monitor-aware policy resolution, the software pacer,
+/// and the post-render pacing event. Window lifecycle and frame preparation
+/// call this typed owner rather than duplicating pacing state.
+///
+internal sealed class DisplayFramePacingController : IDisposable
+{
+ private readonly bool _uncappedRendering;
+ private FrameProfiler? _profiler;
+ private readonly FramePacingController _pacing;
+ private IDisplayFramePacingSurface? _surface;
+ private int? _activeMonitorRefreshHz;
+ private bool _requestedVSync =
+ AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default.VSync;
+
+ public DisplayFramePacingController(
+ bool uncappedRendering,
+ FrameProfiler profiler)
+ : this(uncappedRendering, profiler, new FramePacingController())
+ {
+ }
+
+ internal DisplayFramePacingController(
+ bool uncappedRendering,
+ FrameProfiler profiler,
+ FramePacingController pacing)
+ {
+ _uncappedRendering = uncappedRendering;
+ _profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
+ _pacing = pacing ?? throw new ArgumentNullException(nameof(pacing));
+ }
+
+ internal bool RequestedVSync => _requestedVSync;
+
+ internal FramePacingPolicy Policy => _pacing.Policy;
+
+ internal bool IsSurfaceBound => _surface is not null;
+
+ public FramePacingPolicy InitializeStartup(bool requestedVSync)
+ {
+ _requestedVSync = requestedVSync;
+ FramePacingPolicy policy = Resolve(monitorRefreshHz: null);
+ _pacing.Apply(policy);
+ return policy;
+ }
+
+ public void BindSurface(IDisplayFramePacingSurface surface)
+ {
+ _surface = surface ?? throw new ArgumentNullException(nameof(surface));
+ }
+
+ public void ApplyPreference(bool requestedVSync)
+ {
+ _requestedVSync = requestedVSync;
+ ApplyResolvedPolicy();
+ }
+
+ public void RefreshActiveMonitor()
+ {
+ _activeMonitorRefreshHz = _surface?.ActiveMonitorRefreshHz;
+ ApplyResolvedPolicy();
+ }
+
+ public void OnWindowMoved(Vector2D _) => RefreshActiveMonitor();
+
+ public void OnWindowStateChanged(WindowState _) => RefreshActiveMonitor();
+
+ public void OnFrameRendered(double _)
+ {
+ FrameProfiler profiler = _profiler
+ ?? throw new ObjectDisposedException(nameof(DisplayFramePacingController));
+ using var pacingStage = profiler.BeginStage(FrameStage.Pacing);
+ _pacing.CompleteFrame();
+ }
+
+ private FramePacingPolicy Resolve(int? monitorRefreshHz) =>
+ FramePacingPolicy.Resolve(
+ _requestedVSync,
+ _uncappedRendering,
+ monitorRefreshHz);
+
+ private void ApplyResolvedPolicy()
+ {
+ FramePacingPolicy policy = Resolve(_activeMonitorRefreshHz);
+ _pacing.Apply(policy);
+ if (_surface is not null && _surface.VSync != policy.UseVSync)
+ _surface.VSync = policy.UseVSync;
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ _pacing.Dispose();
+ }
+ finally
+ {
+ _surface = null;
+ _profiler = null;
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 654acb7d..884af989 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -85,8 +85,6 @@ public sealed class GameWindow : IDisposable
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"),
"1",
System.StringComparison.Ordinal);
- private readonly AcDream.App.Rendering.RollingTimingSampleWindow
- _entityUploadTiming = new(256);
private long _frameDiagLastPublicationMilliseconds;
// MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call
@@ -104,11 +102,14 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
+ private AcDream.App.Rendering.RenderFrameResourceController?
+ _renderFrameResources;
+ private AcDream.App.Rendering.RuntimeRenderFrameLivePreparation?
+ _renderFrameLivePreparation;
+ private AcDream.App.Rendering.RenderWeatherFrameController?
+ _renderWeatherFrame;
private ResourceShutdownTransaction? _shutdown;
- private readonly FramePacingController _framePacing = new();
- private bool _requestedVSync =
- AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default.VSync;
- private int? _activeMonitorRefreshHz;
+ private readonly DisplayFramePacingController _displayFramePacing;
// Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer;
@@ -513,8 +514,6 @@ public sealed class GameWindow : IDisposable
private long _loadedSkyDayIndex = long.MinValue;
private AcDream.Core.World.DayGroupData? _activeDayGroup;
- private double _weatherAccum;
-
// F7 / F10 debug-cycle steps for time + weather. Initialized out of
// range of the real values so the first press hits index 0 of the
// cycle table cleanly.
@@ -720,6 +719,9 @@ public sealed class GameWindow : IDisposable
_datDir = options.DatDir;
_worldGameState = worldGameState;
_worldEvents = worldEvents;
+ _displayFramePacing = new DisplayFramePacingController(
+ options.UncappedRendering,
+ _frameProfiler);
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry;
@@ -760,12 +762,8 @@ public sealed class GameWindow : IDisposable
var startupDisplay = startupStore.LoadDisplay();
var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality);
var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase);
- _requestedVSync = startupDisplay.VSync;
- FramePacingPolicy startupPacing = FramePacingPolicy.Resolve(
- startupDisplay.VSync,
- _options.UncappedRendering,
- monitorRefreshHz: null);
- _framePacing.Apply(startupPacing);
+ FramePacingPolicy startupPacing =
+ _displayFramePacing.InitializeStartup(startupDisplay.VSync);
var options = WindowOptions.Default with
{
Size = new Vector2D(1280, 720),
@@ -789,16 +787,18 @@ public sealed class GameWindow : IDisposable
};
_window = Window.Create(options);
+ _displayFramePacing.BindSurface(
+ new SilkDisplayFramePacingSurface(_window));
_window.Load += OnLoad;
_window.Update += OnUpdate;
_window.Render += OnRender;
// Registered after OnRender so software pacing waits after all frame
// work and before Silk.NET performs its automatic buffer swap.
- _window.Render += OnFrameRendered;
+ _window.Render += _displayFramePacing.OnFrameRendered;
_window.Closing += OnClosing;
_window.FocusChanged += OnFocusChanged;
- _window.Move += OnWindowMoved;
- _window.StateChanged += OnWindowStateChanged;
+ _window.Move += _displayFramePacing.OnWindowMoved;
+ _window.StateChanged += _displayFramePacing.OnWindowStateChanged;
// L.0 Display tab: keep the GL viewport + camera aspect in sync
// with the window framebuffer. Without this handler, resizing
// the window (or applying a Display-tab Resolution change at
@@ -2840,11 +2840,56 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Streaming.LocalPlayerTeleportSession(
_liveSessionController),
localTeleportPresentation);
- _localPlayerTeleportSink.Bind(_localPlayerTeleport);
// Ownership transferred to LocalPlayerTeleportController. This field
// remains only as the staged-startup fallback used by shutdown when
// composition fails before the transfer.
_portalTunnel = null;
+ _localPlayerTeleportSink.Bind(_localPlayerTeleport);
+ var teleportRenderState =
+ new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
+ _localPlayerTeleport);
+ _renderFrameLivePreparation =
+ new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation(
+ _textureCache,
+ _wbMeshAdapter,
+ _worldReveal,
+ teleportRenderState,
+ new AcDream.App.Rendering.RenderLoginStateSource(
+ _options.LiveMode,
+ _localPlayerMode),
+ new AcDream.App.Rendering.LiveLoginRevealCellSource(
+ _liveEntities!,
+ _localPlayerIdentity),
+ _particleRenderer,
+ _frameProfiler,
+ _frameDiag);
+ _renderFrameResources =
+ new AcDream.App.Rendering.RenderFrameResourceController(
+ _gpuFrameFlights!,
+ new AcDream.App.Rendering.RuntimeRenderFrameBeginResources(
+ _textureCache,
+ _wbDrawDispatcher,
+ _envCellRenderer,
+ _portalDepthMask,
+ _textRenderer,
+ _uiHost?.TextRenderer,
+ _clipFrame,
+ _terrain,
+ _sceneLightingUbo,
+ _frameProfiler,
+ _gl!),
+ new AcDream.App.Rendering.RuntimeRenderFrameClearPhase(
+ _gl!,
+ WorldTime,
+ Weather,
+ teleportRenderState,
+ _particleVisibility,
+ _worldRenderDiagnostics!),
+ _renderFrameLivePreparation);
+ _renderWeatherFrame =
+ new AcDream.App.Rendering.RenderWeatherFrameController(
+ WorldTime,
+ Weather);
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame,
_worldState,
@@ -3223,18 +3268,6 @@ public sealed class GameWindow : IDisposable
session.SendTurbineChatTo(
roomId, chatType, dispatchType, senderGuid, text, cookie),
Log: Console.WriteLine);
- private bool TryGetLoginWorldCell(out uint cell)
- {
- cell = 0;
- if (_liveEntities is null
- || !_liveEntities.TryGetSnapshot(_playerServerGuid, out var playerSpawn)
- || playerSpawn.Position is not { } position)
- return false;
-
- cell = position.LandblockId;
- return cell != 0;
- }
-
private void UpdateSkyPes(
float dayFraction,
AcDream.Core.World.DayGroupData? dayGroup,
@@ -3440,112 +3473,23 @@ public sealed class GameWindow : IDisposable
Exception? renderFailure = null;
try
{
- _textureCache?.BeginCompositeTextureFrame();
- _textureCache?.TickCompositeTextureCache();
- _wbDrawDispatcher?.BeginFrame(_gpuFrameFlights.CurrentSlot);
- _envCellRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
- _portalDepthMask?.BeginFrame(_gpuFrameFlights.CurrentSlot);
- _textRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
- _uiHost?.TextRenderer.BeginFrame(_gpuFrameFlights.CurrentSlot);
- _clipFrame?.BeginFrame(_gpuFrameFlights.CurrentSlot);
- _terrain?.BeginFrame(_gpuFrameFlights.CurrentSlot);
- _sceneLightingUbo?.BeginFrame(_gpuFrameFlights.CurrentSlot);
- _frameProfiler.FrameBoundary(_gl!);
+ GL gl = _gl!;
+ var frameInput = new AcDream.App.Rendering.RenderFrameInput(
+ deltaSeconds,
+ _window!.Size.X,
+ _window.Size.Y);
+ _renderFrameResources!.Prepare(frameInput);
+ AcDream.App.Rendering.RenderFrameFoundation foundation =
+ _renderFrameResources.Foundation;
// gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the
// normal SmartBox viewport and UIElement_Viewport's portal
// CreatureMode; it does not redraw the world behind portal space.
- bool portalViewportVisible =
- _localPlayerTeleport?.IsPortalViewportVisible == true;
- if (portalViewportVisible)
- _particleVisibility.Reset();
+ bool portalViewportVisible = foundation.PortalViewportVisible;
- // Phase G.1: set the clear color from the current sky's fog
- // tint so the horizon band continues naturally past the
- // rendered geometry. Fog blends to this color at max distance
- // so there's no visible seam. Updated each frame from the
- // interpolated keyframe.
- var kf = WorldTime.CurrentSky;
- var atmo = Weather.Snapshot(in kf);
- bool environOverrideActive = atmo.Override != AcDream.Core.World.EnvironOverride.None;
- var fogColor = atmo.FogColor;
- // Clear to fog color (horizon haze) so if sky meshes have alpha
- // gaps or don't cover the full view, the "missing" area reads as
- // distant haze, not as pitch-black. Fog color is clamped to 0..1
- // since keyframes may pre-multiply DirBright and produce over-1
- // values that some drivers interpret as "bright clamp" (pink/green
- // frames).
- if (portalViewportVisible)
- {
- // SceneTool::BeginScene @ 0x0043DAD0 begins every retail frame
- // with RenderDevice.Clear(7, black, 1). The portal viewport later
- // clears depth only, preserving this frame's black target—not a
- // previous swap-chain image or the hidden destination world.
- _gl!.ClearColor(0f, 0f, 0f, 1f);
- }
- else
- {
- _gl!.ClearColor(
- System.Math.Clamp(fogColor.X, 0f, 1f),
- System.Math.Clamp(fogColor.Y, 0f, 1f),
- System.Math.Clamp(fogColor.Z, 0f, 1f),
- 1f);
- }
-
- // §4 outdoor full-world flap (2026-06-10): the depth clear DEPENDS on the depth
- // write mask — glClear(GL_DEPTH_BUFFER_BIT) is silently gated by glDepthMask.
- // A pass that leaked DepthMask(false) at frame end (EnvCellRenderer's empty
- // Transparent pass, fixed same-day) turned this clear into a no-op and the whole
- // world failed GL_LESS against its own previous-frame depth ghost. Per
- // feedback_render_self_contained_gl_state: the clear site asserts the state it
- // depends on rather than inheriting it. The [gl-state] tripwire still detects
- // any future leak.
- _gl!.DepthMask(true);
- _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit);
-
- // WB GameScene.cs:830-843 establishes CW as the frame-global
- // front-face convention; per-batch CullMode changes only the culled
- // side. A8 indoor/env-cell geometry and setup meshes share that
- // convention, so keep the GL state aligned before any scene pass.
- _gl.CullFace(TriangleFace.Back);
- _gl.FrontFace(FrontFaceDirection.CW);
-
- // §4 flap apparatus: GL-state tripwire — one [gl-state] line whenever the
- // state entering the world passes differs from the previous frame.
- _worldRenderDiagnostics?.EmitGlStateTripwireIfChanged(
- AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled);
-
- // Phase N.6 slice 1: one-shot surface-format histogram dump under
- // ACDREAM_DUMP_SURFACES=1. Zero cost when off.
- _textureCache?.TickSurfaceHistogramDumpIfEnabled();
-
- // Phase N.4: drain WB pipeline queues (staged mesh data +
- // GL thread queue). Must happen before any draw work so that
- // resources uploaded this frame are available immediately.
- // No-op when ACDREAM_USE_WB_FOUNDATION is off (_wbMeshAdapter is null).
- // [FRAME-DIAG]: this OnRender drain is where staged GfxObj/entity meshes hit
- // the GPU (uncapped) — the H2 entity-upload suspect, separate from the apply.
- long fdE0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
- using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload))
- {
- _wbMeshAdapter?.Tick();
- uint revealCell = _localPlayerTeleport?.ActiveDestinationCell is uint teleportCell
- && teleportCell != 0u
- ? teleportCell
- : IsLiveModeWaitingForLogin && TryGetLoginWorldCell(out uint loginCell)
- ? loginCell
- : 0u;
- if (revealCell != 0)
- {
- _worldReveal?.PrepareAndEvaluate(revealCell);
- }
- _particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
- }
- if (_frameDiag)
- {
- _entityUploadTiming.PushStopwatchTicks(
- System.Diagnostics.Stopwatch.GetTimestamp() - fdE0);
- }
+ AcDream.Core.World.SkyKeyframe kf = foundation.Sky;
+ AcDream.Core.World.AtmosphereSnapshot atmo = foundation.Atmosphere;
+ bool environOverrideActive = foundation.EnvironOverrideActive;
// Phase D.2a — begin ImGui frame. Paired with the Render() call
// after the scene draws (below). ImGuiController.Update()
@@ -3554,13 +3498,7 @@ public sealed class GameWindow : IDisposable
// Phase G.1: weather state machine — deterministic per-day roll
// + transitions + lightning flash.
- var cal = WorldTime.CurrentCalendar;
- int dayIndex = cal.Year * (AcDream.Core.World.DerethDateTime.DaysInAMonth *
- AcDream.Core.World.DerethDateTime.MonthsInAYear)
- + (int)cal.Month * AcDream.Core.World.DerethDateTime.DaysInAMonth
- + (cal.Day - 1);
- Weather.Tick(nowSeconds: _weatherAccum, dayIndex: dayIndex, dtSeconds: (float)deltaSeconds);
- _weatherAccum += deltaSeconds;
+ _renderWeatherFrame!.Tick(deltaSeconds);
// (Pre-Bug-A code spawned camera-attached rain/snow particle
// emitters here as a workaround for missing weather-mesh
@@ -3636,7 +3574,7 @@ public sealed class GameWindow : IDisposable
_cameraController.Fly.FovY = fovYRad;
if (_cameraController.Chase is not null)
_cameraController.Chase.FovY = fovYRad;
- ApplyFramePacingPreference(d.VSync);
+ _displayFramePacing.ApplyPreference(d.VSync);
}
// Phase E.2 audio: update listener pose so 3D sounds pan/attenuate
@@ -4015,9 +3953,9 @@ public sealed class GameWindow : IDisposable
// Outdoor frames have one no-clip terrain state. Indoor frames
// defer both allocations to RetailPViewRenderer, which knows the
// exact slice count and packs every terrain state into one arena.
- _clipFrame.ReserveTerrainUploads(_gl, 1);
- _clipFrame.UploadRegions(_gl);
- TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(_gl);
+ _clipFrame.ReserveTerrainUploads(gl, 1);
+ _clipFrame.UploadRegions(gl);
+ TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(gl);
_wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_terrain?.SetClipUbo(terrainBinding);
@@ -4035,13 +3973,13 @@ public sealed class GameWindow : IDisposable
sigSkyDrawn = drawSkyThisFrame;
if (drawSkyThisFrame)
{
- _clipFrame.BindTerrainClip(_gl);
+ _clipFrame.BindTerrainClip(gl);
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
- _gl.Enable(EnableCap.ClipDistance0 + _cp);
+ gl.Enable(EnableCap.ClipDistance0 + _cp);
_skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf, environOverrideActive);
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
- _gl.Disable(EnableCap.ClipDistance0 + _cp);
+ gl.Disable(EnableCap.ClipDistance0 + _cp);
if (_particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(camera, camPos,
@@ -4147,9 +4085,9 @@ public sealed class GameWindow : IDisposable
? null
: () =>
{
- _gl.Disable(EnableCap.ScissorTest);
- _gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson)
- _gl.Clear(ClearBufferMask.DepthBufferBit);
+ gl.Disable(EnableCap.ScissorTest);
+ gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson)
+ gl.Clear(ClearBufferMask.DepthBufferBit);
},
DrawExitPortalMasks = sliceCtx =>
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
@@ -4283,7 +4221,7 @@ public sealed class GameWindow : IDisposable
// below DOES use sky.vert — it re-enables the planes in its OWN local bracket; the sky
// pre-scene pass above already did the same. Both are scissored to the doorway too.)
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
- _gl.Disable(EnableCap.ClipDistance0 + _cp);
+ gl.Disable(EnableCap.ClipDistance0 + _cp);
// Phase G.1 / E.3: draw all live particles after opaque
// scene geometry so alpha blending composites correctly.
@@ -4342,13 +4280,13 @@ public sealed class GameWindow : IDisposable
if (clipRoot is null && drawSkyThisFrame)
{
sigSkyDrawn = true;
- _clipFrame.BindTerrainClip(_gl);
+ _clipFrame.BindTerrainClip(gl);
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
- _gl.Enable(EnableCap.ClipDistance0 + _cp);
+ gl.Enable(EnableCap.ClipDistance0 + _cp);
_skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf, environOverrideActive);
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
- _gl.Disable(EnableCap.ClipDistance0 + _cp);
+ gl.Disable(EnableCap.ClipDistance0 + _cp);
if (_particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(camera, camPos,
@@ -5390,8 +5328,8 @@ public sealed class GameWindow : IDisposable
// resolution + fullscreen use the on-Save path below.
if (_window is not null)
{
- RefreshActiveMonitorFramePacing();
- ApplyFramePacingPreference(_persistedDisplay.VSync);
+ _displayFramePacing.RefreshActiveMonitor();
+ _displayFramePacing.ApplyPreference(_persistedDisplay.VSync);
ApplyDisplayWindowState(_persistedDisplay);
}
@@ -5532,39 +5470,6 @@ public sealed class GameWindow : IDisposable
_window.WindowState = desiredState;
}
- private void ApplyFramePacingPreference(bool requestedVSync)
- {
- _requestedVSync = requestedVSync;
- FramePacingPolicy policy = FramePacingPolicy.Resolve(
- requestedVSync,
- _options.UncappedRendering,
- _activeMonitorRefreshHz);
- _framePacing.Apply(policy);
-
- if (_window is not null && _window.VSync != policy.UseVSync)
- _window.VSync = policy.UseVSync;
- }
-
- private void RefreshActiveMonitorFramePacing()
- {
- int? refreshHz = _window?.Monitor?.VideoMode.RefreshRate;
- _activeMonitorRefreshHz = refreshHz is > 0 ? refreshHz : null;
- ApplyFramePacingPreference(_requestedVSync);
- }
-
- private void OnFrameRendered(double _)
- {
- using var pacingStage = _frameProfiler.BeginStage(
- AcDream.App.Diagnostics.FrameStage.Pacing);
- _framePacing.CompleteFrame();
- }
-
- private void OnWindowMoved(Silk.NET.Maths.Vector2D _)
- => RefreshActiveMonitorFramePacing();
-
- private void OnWindowStateChanged(Silk.NET.Windowing.WindowState _)
- => RefreshActiveMonitorFramePacing();
-
private static bool TryParseResolution(string spec, out int width, out int height)
{
width = height = 0;
@@ -6007,7 +5912,7 @@ public sealed class GameWindow : IDisposable
double ticksToMicros =
1_000_000.0 / System.Diagnostics.Stopwatch.Frequency;
AcDream.App.Rendering.RollingTimingPercentiles upload =
- _entityUploadTiming.Snapshot();
+ _renderFrameLivePreparation?.UploadTiming ?? default;
double uploadMedian = upload.MedianHundredthsMicroseconds / 100.0;
double uploadP95 = upload.Percentile95HundredthsMicroseconds / 100.0;
int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0;
@@ -6154,6 +6059,12 @@ public sealed class GameWindow : IDisposable
]),
new ResourceShutdownStage("render frontends",
[
+ new("frame composition", () =>
+ {
+ _renderFrameResources = null;
+ _renderFrameLivePreparation = null;
+ _renderWeatherFrame = null;
+ }),
new("portal tunnel", () =>
{
_localPlayerTeleport?.Dispose();
@@ -6200,8 +6111,8 @@ public sealed class GameWindow : IDisposable
new("debug lines", () => _debugLines?.Dispose()),
new("text renderer", () => _textRenderer?.Dispose()),
new("debug font", () => _debugFont?.Dispose()),
+ new("frame pacing", _displayFramePacing.Dispose),
new("frame profiler", _frameProfiler.Dispose),
- new("frame pacing", _framePacing.Dispose),
]),
new ResourceShutdownStage("frame flight owner",
[
diff --git a/src/AcDream.App/Rendering/GpuFrameFlightController.cs b/src/AcDream.App/Rendering/GpuFrameFlightController.cs
index d2342d7d..a0a6b78b 100644
--- a/src/AcDream.App/Rendering/GpuFrameFlightController.cs
+++ b/src/AcDream.App/Rendering/GpuFrameFlightController.cs
@@ -217,6 +217,7 @@ internal sealed class ImmediateGpuResourceRetirementQueue : IGpuResourceRetireme
internal sealed class GpuFrameFlightController :
IGpuResourceRetirementQueue,
IRenderFrameLifetime,
+ IRenderFrameSlotSource,
IDisposable
{
internal const int DefaultMaximumFramesInFlight = 3;
diff --git a/src/AcDream.App/Rendering/RenderFrameResourceController.cs b/src/AcDream.App/Rendering/RenderFrameResourceController.cs
new file mode 100644
index 00000000..7d98733f
--- /dev/null
+++ b/src/AcDream.App/Rendering/RenderFrameResourceController.cs
@@ -0,0 +1,373 @@
+using AcDream.App.Diagnostics;
+using AcDream.App.Input;
+using AcDream.App.Rendering.Vfx;
+using AcDream.App.Rendering.Wb;
+using AcDream.App.Streaming;
+using AcDream.App.World;
+using AcDream.Core.World;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Rendering;
+
+internal readonly record struct RenderFrameFoundation(
+ bool PortalViewportVisible,
+ SkyKeyframe Sky,
+ AtmosphereSnapshot Atmosphere)
+{
+ public bool EnvironOverrideActive =>
+ Atmosphere.Override != EnvironOverride.None;
+}
+
+internal interface IRenderFrameBeginResources
+{
+ void Begin(int gpuSlot);
+}
+
+internal interface IRenderFrameSlotSource
+{
+ int CurrentSlot { get; }
+}
+
+internal interface IRenderFrameClearPhase
+{
+ RenderFrameFoundation Clear();
+}
+
+internal interface IRenderFrameLivePreparation
+{
+ void Prepare(int gpuSlot);
+}
+
+///
+/// Owns the pre-world resource transaction after GPU-flight begin. The three
+/// typed phases preserve resource begin, clear/state, then live publication
+/// order without exposing a renderer service bag.
+///
+internal sealed class RenderFrameResourceController : IRenderFrameResourcePhase
+{
+ private readonly IRenderFrameSlotSource _frameSlots;
+ private readonly IRenderFrameBeginResources _resources;
+ private readonly IRenderFrameClearPhase _clear;
+ private readonly IRenderFrameLivePreparation _live;
+
+ public RenderFrameResourceController(
+ IRenderFrameSlotSource frameSlots,
+ IRenderFrameBeginResources resources,
+ IRenderFrameClearPhase clear,
+ IRenderFrameLivePreparation live)
+ {
+ _frameSlots = frameSlots
+ ?? throw new ArgumentNullException(nameof(frameSlots));
+ _resources = resources ?? throw new ArgumentNullException(nameof(resources));
+ _clear = clear ?? throw new ArgumentNullException(nameof(clear));
+ _live = live ?? throw new ArgumentNullException(nameof(live));
+ }
+
+ public RenderFrameFoundation Foundation { get; private set; }
+
+ public void Prepare(RenderFrameInput input)
+ {
+ int gpuSlot = _frameSlots.CurrentSlot;
+ _resources.Begin(gpuSlot);
+ Foundation = _clear.Clear();
+ _live.Prepare(gpuSlot);
+ }
+}
+
+/// Exact ordered begin calls for resources shared by the frame.
+internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResources
+{
+ private readonly TextureCache? _textures;
+ private readonly WbDrawDispatcher? _dispatcher;
+ private readonly EnvCellRenderer? _environmentCells;
+ private readonly PortalDepthMaskRenderer? _portalDepth;
+ private readonly TextRenderer? _worldText;
+ private readonly TextRenderer? _uiText;
+ private readonly ClipFrame? _clip;
+ private readonly TerrainModernRenderer? _terrain;
+ private readonly SceneLightingUboBinding? _lighting;
+ private readonly FrameProfiler _profiler;
+ private readonly GL _gl;
+
+ public RuntimeRenderFrameBeginResources(
+ TextureCache? textures,
+ WbDrawDispatcher? dispatcher,
+ EnvCellRenderer? environmentCells,
+ PortalDepthMaskRenderer? portalDepth,
+ TextRenderer? worldText,
+ TextRenderer? uiText,
+ ClipFrame? clip,
+ TerrainModernRenderer? terrain,
+ SceneLightingUboBinding? lighting,
+ FrameProfiler profiler,
+ GL gl)
+ {
+ _textures = textures;
+ _dispatcher = dispatcher;
+ _environmentCells = environmentCells;
+ _portalDepth = portalDepth;
+ _worldText = worldText;
+ _uiText = uiText;
+ _clip = clip;
+ _terrain = terrain;
+ _lighting = lighting;
+ _profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
+ _gl = gl ?? throw new ArgumentNullException(nameof(gl));
+ }
+
+ public void Begin(int gpuSlot)
+ {
+ _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);
+ }
+}
+
+internal interface IRenderFramePortalStateSource
+{
+ bool IsPortalViewportVisible { get; }
+
+ uint ActiveDestinationCell { get; }
+}
+
+internal sealed class LocalPlayerTeleportRenderStateSource
+ : IRenderFramePortalStateSource
+{
+ private readonly LocalPlayerTeleportController _teleport;
+
+ public LocalPlayerTeleportRenderStateSource(LocalPlayerTeleportController teleport)
+ {
+ _teleport = teleport ?? throw new ArgumentNullException(nameof(teleport));
+ }
+
+ public bool IsPortalViewportVisible => _teleport.IsPortalViewportVisible;
+
+ public uint ActiveDestinationCell => _teleport.ActiveDestinationCell;
+}
+
+/// Atmosphere clear and frame-global GL state establishment.
+internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
+{
+ private readonly GL _gl;
+ private readonly WorldTimeService _worldTime;
+ private readonly WeatherSystem _weather;
+ private readonly IRenderFramePortalStateSource _portal;
+ private readonly ParticleVisibilityController _particleVisibility;
+ private readonly WorldRenderDiagnostics _diagnostics;
+
+ public RuntimeRenderFrameClearPhase(
+ GL gl,
+ WorldTimeService worldTime,
+ WeatherSystem weather,
+ IRenderFramePortalStateSource portal,
+ ParticleVisibilityController particleVisibility,
+ WorldRenderDiagnostics diagnostics)
+ {
+ _gl = gl ?? throw new ArgumentNullException(nameof(gl));
+ _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
+ _weather = weather ?? throw new ArgumentNullException(nameof(weather));
+ _portal = portal ?? throw new ArgumentNullException(nameof(portal));
+ _particleVisibility = particleVisibility
+ ?? throw new ArgumentNullException(nameof(particleVisibility));
+ _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
+ }
+
+ public RenderFrameFoundation Clear()
+ {
+ bool portalViewportVisible = _portal.IsPortalViewportVisible;
+ if (portalViewportVisible)
+ _particleVisibility.Reset();
+
+ SkyKeyframe sky = _worldTime.CurrentSky;
+ AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky);
+ if (portalViewportVisible)
+ {
+ // SceneTool::BeginScene @ 0x0043DAD0 starts the replacement
+ // CreatureMode frame with an opaque black target.
+ _gl.ClearColor(0f, 0f, 0f, 1f);
+ }
+ else
+ {
+ _gl.ClearColor(
+ Math.Clamp(atmosphere.FogColor.X, 0f, 1f),
+ Math.Clamp(atmosphere.FogColor.Y, 0f, 1f),
+ Math.Clamp(atmosphere.FogColor.Z, 0f, 1f),
+ 1f);
+ }
+
+ _gl.DepthMask(true);
+ _gl.Clear(
+ ClearBufferMask.ColorBufferBit
+ | ClearBufferMask.DepthBufferBit
+ | ClearBufferMask.StencilBufferBit);
+ _gl.CullFace(TriangleFace.Back);
+ _gl.FrontFace(FrontFaceDirection.CW);
+ _diagnostics.EmitGlStateTripwireIfChanged(
+ AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled);
+
+ return new RenderFrameFoundation(
+ portalViewportVisible,
+ sky,
+ atmosphere);
+ }
+}
+
+internal interface IRenderLoginStateSource
+{
+ bool IsWaitingForLogin { get; }
+}
+
+internal sealed class RenderLoginStateSource : IRenderLoginStateSource
+{
+ private readonly bool _liveMode;
+ private readonly ILocalPlayerModeSource _mode;
+
+ public RenderLoginStateSource(bool liveMode, ILocalPlayerModeSource mode)
+ {
+ _liveMode = liveMode;
+ _mode = mode ?? throw new ArgumentNullException(nameof(mode));
+ }
+
+ public bool IsWaitingForLogin => _liveMode && !_mode.ChaseModeEverEntered;
+}
+
+internal interface ILoginRevealCellSource
+{
+ bool TryGet(out uint cellId);
+}
+
+internal sealed class LiveLoginRevealCellSource : ILoginRevealCellSource
+{
+ private readonly LiveEntityRuntime _entities;
+ private readonly ILocalPlayerIdentitySource _identity;
+
+ public LiveLoginRevealCellSource(
+ LiveEntityRuntime entities,
+ ILocalPlayerIdentitySource identity)
+ {
+ _entities = entities ?? throw new ArgumentNullException(nameof(entities));
+ _identity = identity ?? throw new ArgumentNullException(nameof(identity));
+ }
+
+ public bool TryGet(out uint cellId)
+ {
+ cellId = 0;
+ if (!_entities.TryGetSnapshot(_identity.ServerGuid, out var playerSpawn)
+ || playerSpawn.Position is not { } position)
+ {
+ return false;
+ }
+
+ cellId = position.LandblockId;
+ return cellId != 0;
+ }
+}
+
+/// Render-thread mesh publication, reveal evaluation, and VFX begin.
+internal sealed class RuntimeRenderFrameLivePreparation
+ : IRenderFrameLivePreparation
+{
+ private readonly TextureCache? _textures;
+ private readonly WbMeshAdapter? _meshes;
+ private readonly WorldRevealCoordinator? _worldReveal;
+ private readonly IRenderFramePortalStateSource _portal;
+ private readonly IRenderLoginStateSource _login;
+ private readonly ILoginRevealCellSource _loginCell;
+ private readonly ParticleRenderer? _particles;
+ private readonly FrameProfiler _profiler;
+ private readonly bool _diagnosticsEnabled;
+ private readonly RollingTimingSampleWindow _uploadTiming = new(256);
+
+ public RuntimeRenderFrameLivePreparation(
+ TextureCache? textures,
+ WbMeshAdapter? meshes,
+ WorldRevealCoordinator? worldReveal,
+ IRenderFramePortalStateSource portal,
+ IRenderLoginStateSource login,
+ ILoginRevealCellSource loginCell,
+ ParticleRenderer? particles,
+ FrameProfiler profiler,
+ bool diagnosticsEnabled)
+ {
+ _textures = textures;
+ _meshes = meshes;
+ _worldReveal = worldReveal;
+ _portal = portal ?? throw new ArgumentNullException(nameof(portal));
+ _login = login ?? throw new ArgumentNullException(nameof(login));
+ _loginCell = loginCell ?? throw new ArgumentNullException(nameof(loginCell));
+ _particles = particles;
+ _profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
+ _diagnosticsEnabled = diagnosticsEnabled;
+ }
+
+ public RollingTimingPercentiles UploadTiming => _uploadTiming.Snapshot();
+
+ public void Prepare(int gpuSlot)
+ {
+ _textures?.TickSurfaceHistogramDumpIfEnabled();
+ long start = _diagnosticsEnabled
+ ? System.Diagnostics.Stopwatch.GetTimestamp()
+ : 0L;
+ using (var uploadStage = _profiler.BeginStage(FrameStage.Upload))
+ {
+ _meshes?.Tick();
+ uint revealCell = _portal.ActiveDestinationCell;
+ if (revealCell == 0
+ && _login.IsWaitingForLogin
+ && _loginCell.TryGet(out uint loginCell))
+ {
+ revealCell = loginCell;
+ }
+
+ if (revealCell != 0)
+ _worldReveal?.PrepareAndEvaluate(revealCell);
+ _particles?.BeginFrame(gpuSlot);
+ }
+
+ if (_diagnosticsEnabled)
+ {
+ _uploadTiming.PushStopwatchTicks(
+ System.Diagnostics.Stopwatch.GetTimestamp() - start);
+ }
+ }
+}
+
+/// Owns the render-time weather clock and deterministic day index.
+internal sealed class RenderWeatherFrameController
+{
+ private readonly WorldTimeService _worldTime;
+ private readonly WeatherSystem _weather;
+ private double _elapsedSeconds;
+
+ public RenderWeatherFrameController(
+ WorldTimeService worldTime,
+ WeatherSystem weather)
+ {
+ _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
+ _weather = weather ?? throw new ArgumentNullException(nameof(weather));
+ }
+
+ internal double ElapsedSeconds => _elapsedSeconds;
+
+ public void Tick(double deltaSeconds)
+ {
+ DerethDateTime.Calendar calendar = _worldTime.CurrentCalendar;
+ int dayIndex = calendar.Year
+ * (DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear)
+ + (int)calendar.Month * DerethDateTime.DaysInAMonth
+ + (calendar.Day - 1);
+ _weather.Tick(
+ nowSeconds: _elapsedSeconds,
+ dayIndex,
+ dtSeconds: (float)deltaSeconds);
+ _elapsedSeconds += deltaSeconds;
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/DisplayFramePacingControllerTests.cs b/tests/AcDream.App.Tests/Rendering/DisplayFramePacingControllerTests.cs
new file mode 100644
index 00000000..bfd804ec
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/DisplayFramePacingControllerTests.cs
@@ -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(() => 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 Durations { get; } = [];
+
+ public void Wait(long durationTicks, long clockFrequency)
+ {
+ Durations.Add(durationTicks);
+ if (clock is not null)
+ clock.Timestamp += durationTicks;
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs
index fc01780b..b56d9b31 100644
--- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs
@@ -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);
diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs
new file mode 100644
index 00000000..f08673d0
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs
@@ -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 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(() =>
+ new RenderFrameResourceController(null!, begin, clear, live));
+ Assert.Throws(() =>
+ new RenderFrameResourceController(slots, null!, clear, live));
+ Assert.Throws(() =>
+ new RenderFrameResourceController(slots, begin, null!, live));
+ Assert.Throws(() =>
+ 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 calls) : IRenderFrameBeginResources
+ {
+ public void Begin(int gpuSlot) => calls.Add($"begin:{gpuSlot}");
+ }
+
+ private sealed class RecordingClear(
+ List calls,
+ RenderFrameFoundation result) : IRenderFrameClearPhase
+ {
+ public RenderFrameFoundation Clear()
+ {
+ calls.Add("clear");
+ return result;
+ }
+ }
+
+ private sealed class RecordingLive(List calls) : IRenderFrameLivePreparation
+ {
+ public void Prepare(int gpuSlot) => calls.Add($"live:{gpuSlot}");
+ }
+}