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:
Erik 2026-07-22 05:23:46 +02:00
parent 733126a272
commit bc6f09f987
8 changed files with 1068 additions and 205 deletions

View file

@ -377,6 +377,20 @@ corrected-diff reviews are clean.
- prove `WbMeshAdapter.Tick` remains before reveal evaluation and drawing; - prove `WbMeshAdapter.Tick` remains before reveal evaluation and drawing;
- prove dispatcher begin occurs once for the whole world/paperdoll frame. - 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 ### D — world frame builder
- move animated/equipped classification, camera/root selection, outdoor-node - move animated/equipped classification, camera/root selection, outdoor-node

View file

@ -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; }
}
/// <summary>Narrow Silk window adapter for display pacing.</summary>
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;
}
}
}
/// <summary>
/// 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.
/// </summary>
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<int> _) => 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;
}
}
}

View file

@ -85,8 +85,6 @@ public sealed class GameWindow : IDisposable
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"),
"1", "1",
System.StringComparison.Ordinal); System.StringComparison.Ordinal);
private readonly AcDream.App.Rendering.RollingTimingSampleWindow
_entityUploadTiming = new(256);
private long _frameDiagLastPublicationMilliseconds; private long _frameDiagLastPublicationMilliseconds;
// MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call // 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.FrameScreenshotController? _frameScreenshots;
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation; private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; 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 ResourceShutdownTransaction? _shutdown;
private readonly FramePacingController _framePacing = new(); private readonly DisplayFramePacingController _displayFramePacing;
private bool _requestedVSync =
AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default.VSync;
private int? _activeMonitorRefreshHz;
// Phase A.1: streaming fields replacing the one-shot _entities list. // Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer; private AcDream.App.Streaming.LandblockStreamer? _streamer;
@ -513,8 +514,6 @@ public sealed class GameWindow : IDisposable
private long _loadedSkyDayIndex = long.MinValue; private long _loadedSkyDayIndex = long.MinValue;
private AcDream.Core.World.DayGroupData? _activeDayGroup; private AcDream.Core.World.DayGroupData? _activeDayGroup;
private double _weatherAccum;
// F7 / F10 debug-cycle steps for time + weather. Initialized out of // 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 // range of the real values so the first press hits index 0 of the
// cycle table cleanly. // cycle table cleanly.
@ -720,6 +719,9 @@ public sealed class GameWindow : IDisposable
_datDir = options.DatDir; _datDir = options.DatDir;
_worldGameState = worldGameState; _worldGameState = worldGameState;
_worldEvents = worldEvents; _worldEvents = worldEvents;
_displayFramePacing = new DisplayFramePacingController(
options.UncappedRendering,
_frameProfiler);
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); _selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry; _uiRegistry = uiRegistry;
@ -760,12 +762,8 @@ public sealed class GameWindow : IDisposable
var startupDisplay = startupStore.LoadDisplay(); var startupDisplay = startupStore.LoadDisplay();
var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality);
var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase);
_requestedVSync = startupDisplay.VSync; FramePacingPolicy startupPacing =
FramePacingPolicy startupPacing = FramePacingPolicy.Resolve( _displayFramePacing.InitializeStartup(startupDisplay.VSync);
startupDisplay.VSync,
_options.UncappedRendering,
monitorRefreshHz: null);
_framePacing.Apply(startupPacing);
var options = WindowOptions.Default with var options = WindowOptions.Default with
{ {
Size = new Vector2D<int>(1280, 720), Size = new Vector2D<int>(1280, 720),
@ -789,16 +787,18 @@ public sealed class GameWindow : IDisposable
}; };
_window = Window.Create(options); _window = Window.Create(options);
_displayFramePacing.BindSurface(
new SilkDisplayFramePacingSurface(_window));
_window.Load += OnLoad; _window.Load += OnLoad;
_window.Update += OnUpdate; _window.Update += OnUpdate;
_window.Render += OnRender; _window.Render += OnRender;
// Registered after OnRender so software pacing waits after all frame // Registered after OnRender so software pacing waits after all frame
// work and before Silk.NET performs its automatic buffer swap. // work and before Silk.NET performs its automatic buffer swap.
_window.Render += OnFrameRendered; _window.Render += _displayFramePacing.OnFrameRendered;
_window.Closing += OnClosing; _window.Closing += OnClosing;
_window.FocusChanged += OnFocusChanged; _window.FocusChanged += OnFocusChanged;
_window.Move += OnWindowMoved; _window.Move += _displayFramePacing.OnWindowMoved;
_window.StateChanged += OnWindowStateChanged; _window.StateChanged += _displayFramePacing.OnWindowStateChanged;
// L.0 Display tab: keep the GL viewport + camera aspect in sync // L.0 Display tab: keep the GL viewport + camera aspect in sync
// with the window framebuffer. Without this handler, resizing // with the window framebuffer. Without this handler, resizing
// the window (or applying a Display-tab Resolution change at // the window (or applying a Display-tab Resolution change at
@ -2840,11 +2840,56 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Streaming.LocalPlayerTeleportSession( new AcDream.App.Streaming.LocalPlayerTeleportSession(
_liveSessionController), _liveSessionController),
localTeleportPresentation); localTeleportPresentation);
_localPlayerTeleportSink.Bind(_localPlayerTeleport);
// Ownership transferred to LocalPlayerTeleportController. This field // Ownership transferred to LocalPlayerTeleportController. This field
// remains only as the staged-startup fallback used by shutdown when // remains only as the staged-startup fallback used by shutdown when
// composition fails before the transfer. // composition fails before the transfer.
_portalTunnel = null; _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( var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame, liveObjectFrame,
_worldState, _worldState,
@ -3223,18 +3268,6 @@ public sealed class GameWindow : IDisposable
session.SendTurbineChatTo( session.SendTurbineChatTo(
roomId, chatType, dispatchType, senderGuid, text, cookie), roomId, chatType, dispatchType, senderGuid, text, cookie),
Log: Console.WriteLine); 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( private void UpdateSkyPes(
float dayFraction, float dayFraction,
AcDream.Core.World.DayGroupData? dayGroup, AcDream.Core.World.DayGroupData? dayGroup,
@ -3440,112 +3473,23 @@ public sealed class GameWindow : IDisposable
Exception? renderFailure = null; Exception? renderFailure = null;
try try
{ {
_textureCache?.BeginCompositeTextureFrame(); GL gl = _gl!;
_textureCache?.TickCompositeTextureCache(); var frameInput = new AcDream.App.Rendering.RenderFrameInput(
_wbDrawDispatcher?.BeginFrame(_gpuFrameFlights.CurrentSlot); deltaSeconds,
_envCellRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); _window!.Size.X,
_portalDepthMask?.BeginFrame(_gpuFrameFlights.CurrentSlot); _window.Size.Y);
_textRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot); _renderFrameResources!.Prepare(frameInput);
_uiHost?.TextRenderer.BeginFrame(_gpuFrameFlights.CurrentSlot); AcDream.App.Rendering.RenderFrameFoundation foundation =
_clipFrame?.BeginFrame(_gpuFrameFlights.CurrentSlot); _renderFrameResources.Foundation;
_terrain?.BeginFrame(_gpuFrameFlights.CurrentSlot);
_sceneLightingUbo?.BeginFrame(_gpuFrameFlights.CurrentSlot);
_frameProfiler.FrameBoundary(_gl!);
// gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the // gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the
// normal SmartBox viewport and UIElement_Viewport's portal // normal SmartBox viewport and UIElement_Viewport's portal
// CreatureMode; it does not redraw the world behind portal space. // CreatureMode; it does not redraw the world behind portal space.
bool portalViewportVisible = bool portalViewportVisible = foundation.PortalViewportVisible;
_localPlayerTeleport?.IsPortalViewportVisible == true;
if (portalViewportVisible)
_particleVisibility.Reset();
// Phase G.1: set the clear color from the current sky's fog AcDream.Core.World.SkyKeyframe kf = foundation.Sky;
// tint so the horizon band continues naturally past the AcDream.Core.World.AtmosphereSnapshot atmo = foundation.Atmosphere;
// rendered geometry. Fog blends to this color at max distance bool environOverrideActive = foundation.EnvironOverrideActive;
// 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);
}
// Phase D.2a — begin ImGui frame. Paired with the Render() call // Phase D.2a — begin ImGui frame. Paired with the Render() call
// after the scene draws (below). ImGuiController.Update() // 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 // Phase G.1: weather state machine — deterministic per-day roll
// + transitions + lightning flash. // + transitions + lightning flash.
var cal = WorldTime.CurrentCalendar; _renderWeatherFrame!.Tick(deltaSeconds);
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;
// (Pre-Bug-A code spawned camera-attached rain/snow particle // (Pre-Bug-A code spawned camera-attached rain/snow particle
// emitters here as a workaround for missing weather-mesh // emitters here as a workaround for missing weather-mesh
@ -3636,7 +3574,7 @@ public sealed class GameWindow : IDisposable
_cameraController.Fly.FovY = fovYRad; _cameraController.Fly.FovY = fovYRad;
if (_cameraController.Chase is not null) if (_cameraController.Chase is not null)
_cameraController.Chase.FovY = fovYRad; _cameraController.Chase.FovY = fovYRad;
ApplyFramePacingPreference(d.VSync); _displayFramePacing.ApplyPreference(d.VSync);
} }
// Phase E.2 audio: update listener pose so 3D sounds pan/attenuate // 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 // Outdoor frames have one no-clip terrain state. Indoor frames
// defer both allocations to RetailPViewRenderer, which knows the // defer both allocations to RetailPViewRenderer, which knows the
// exact slice count and packs every terrain state into one arena. // exact slice count and packs every terrain state into one arena.
_clipFrame.ReserveTerrainUploads(_gl, 1); _clipFrame.ReserveTerrainUploads(gl, 1);
_clipFrame.UploadRegions(_gl); _clipFrame.UploadRegions(gl);
TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(_gl); TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(gl);
_wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo); _wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo); _envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_terrain?.SetClipUbo(terrainBinding); _terrain?.SetClipUbo(terrainBinding);
@ -4035,13 +3973,13 @@ public sealed class GameWindow : IDisposable
sigSkyDrawn = drawSkyThisFrame; sigSkyDrawn = drawSkyThisFrame;
if (drawSkyThisFrame) if (drawSkyThisFrame)
{ {
_clipFrame.BindTerrainClip(_gl); _clipFrame.BindTerrainClip(gl);
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) 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, _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf, environOverrideActive); _activeDayGroup, kf, environOverrideActive);
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) 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) if (_particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(camera, camPos, _particleRenderer.Draw(camera, camPos,
@ -4147,9 +4085,9 @@ public sealed class GameWindow : IDisposable
? null ? null
: () => : () =>
{ {
_gl.Disable(EnableCap.ScissorTest); gl.Disable(EnableCap.ScissorTest);
_gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson) gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson)
_gl.Clear(ClearBufferMask.DepthBufferBit); gl.Clear(ClearBufferMask.DepthBufferBit);
}, },
DrawExitPortalMasks = sliceCtx => DrawExitPortalMasks = sliceCtx =>
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, 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 // 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.) // pre-scene pass above already did the same. Both are scissored to the doorway too.)
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) 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 // Phase G.1 / E.3: draw all live particles after opaque
// scene geometry so alpha blending composites correctly. // scene geometry so alpha blending composites correctly.
@ -4342,13 +4280,13 @@ public sealed class GameWindow : IDisposable
if (clipRoot is null && drawSkyThisFrame) if (clipRoot is null && drawSkyThisFrame)
{ {
sigSkyDrawn = true; sigSkyDrawn = true;
_clipFrame.BindTerrainClip(_gl); _clipFrame.BindTerrainClip(gl);
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) 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, _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf, environOverrideActive); _activeDayGroup, kf, environOverrideActive);
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) 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) if (_particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(camera, camPos, _particleRenderer.Draw(camera, camPos,
@ -5390,8 +5328,8 @@ public sealed class GameWindow : IDisposable
// resolution + fullscreen use the on-Save path below. // resolution + fullscreen use the on-Save path below.
if (_window is not null) if (_window is not null)
{ {
RefreshActiveMonitorFramePacing(); _displayFramePacing.RefreshActiveMonitor();
ApplyFramePacingPreference(_persistedDisplay.VSync); _displayFramePacing.ApplyPreference(_persistedDisplay.VSync);
ApplyDisplayWindowState(_persistedDisplay); ApplyDisplayWindowState(_persistedDisplay);
} }
@ -5532,39 +5470,6 @@ public sealed class GameWindow : IDisposable
_window.WindowState = desiredState; _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<int> _)
=> RefreshActiveMonitorFramePacing();
private void OnWindowStateChanged(Silk.NET.Windowing.WindowState _)
=> RefreshActiveMonitorFramePacing();
private static bool TryParseResolution(string spec, out int width, out int height) private static bool TryParseResolution(string spec, out int width, out int height)
{ {
width = height = 0; width = height = 0;
@ -6007,7 +5912,7 @@ public sealed class GameWindow : IDisposable
double ticksToMicros = double ticksToMicros =
1_000_000.0 / System.Diagnostics.Stopwatch.Frequency; 1_000_000.0 / System.Diagnostics.Stopwatch.Frequency;
AcDream.App.Rendering.RollingTimingPercentiles upload = AcDream.App.Rendering.RollingTimingPercentiles upload =
_entityUploadTiming.Snapshot(); _renderFrameLivePreparation?.UploadTiming ?? default;
double uploadMedian = upload.MedianHundredthsMicroseconds / 100.0; double uploadMedian = upload.MedianHundredthsMicroseconds / 100.0;
double uploadP95 = upload.Percentile95HundredthsMicroseconds / 100.0; double uploadP95 = upload.Percentile95HundredthsMicroseconds / 100.0;
int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0; int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0;
@ -6154,6 +6059,12 @@ public sealed class GameWindow : IDisposable
]), ]),
new ResourceShutdownStage("render frontends", new ResourceShutdownStage("render frontends",
[ [
new("frame composition", () =>
{
_renderFrameResources = null;
_renderFrameLivePreparation = null;
_renderWeatherFrame = null;
}),
new("portal tunnel", () => new("portal tunnel", () =>
{ {
_localPlayerTeleport?.Dispose(); _localPlayerTeleport?.Dispose();
@ -6200,8 +6111,8 @@ public sealed class GameWindow : IDisposable
new("debug lines", () => _debugLines?.Dispose()), new("debug lines", () => _debugLines?.Dispose()),
new("text renderer", () => _textRenderer?.Dispose()), new("text renderer", () => _textRenderer?.Dispose()),
new("debug font", () => _debugFont?.Dispose()), new("debug font", () => _debugFont?.Dispose()),
new("frame pacing", _displayFramePacing.Dispose),
new("frame profiler", _frameProfiler.Dispose), new("frame profiler", _frameProfiler.Dispose),
new("frame pacing", _framePacing.Dispose),
]), ]),
new ResourceShutdownStage("frame flight owner", new ResourceShutdownStage("frame flight owner",
[ [

View file

@ -217,6 +217,7 @@ internal sealed class ImmediateGpuResourceRetirementQueue : IGpuResourceRetireme
internal sealed class GpuFrameFlightController : internal sealed class GpuFrameFlightController :
IGpuResourceRetirementQueue, IGpuResourceRetirementQueue,
IRenderFrameLifetime, IRenderFrameLifetime,
IRenderFrameSlotSource,
IDisposable IDisposable
{ {
internal const int DefaultMaximumFramesInFlight = 3; internal const int DefaultMaximumFramesInFlight = 3;

View file

@ -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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>Exact ordered begin calls for resources shared by the frame.</summary>
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;
}
/// <summary>Atmosphere clear and frame-global GL state establishment.</summary>
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;
}
}
/// <summary>Render-thread mesh publication, reveal evaluation, and VFX begin.</summary>
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);
}
}
}
/// <summary>Owns the render-time weather clock and deterministic day index.</summary>
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;
}
}

View file

@ -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;
}
}
}

View file

@ -20,16 +20,33 @@ public sealed class GameWindowRenderLeafCompositionTests
} }
[Fact] [Fact]
public void ProductionRender_BeginsDevToolsBeforeWorldAndBeginsDispatcherOnce() public void ProductionRender_Prepares_resources_then_devtools_weather_and_world()
{ {
string source = GameWindowSource(); string source = GameWindowSource();
AssertAppearsInOrder( AssertAppearsInOrder(
source, source,
"_renderFrameResources!.Prepare(frameInput);",
"_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);", "_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);",
"Weather.Tick(nowSeconds: _weatherAccum", "_renderWeatherFrame!.Tick(deltaSeconds);",
"_retailSelectionScene?.BeginFrame();"); "_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] [Fact]
@ -55,12 +72,21 @@ public sealed class GameWindowRenderLeafCompositionTests
"EmitClipRouteScissorProbe(scissor", "EmitClipRouteScissorProbe(scissor",
"_lastVisibleLandblocks", "_lastVisibleLandblocks",
"_perfAccum", "_perfAccum",
"_entityUploadTiming",
"_weatherAccum",
"TryGetLoginWorldCell",
"ApplyFramePacingPreference",
"RefreshActiveMonitorFramePacing",
"private void OnFrameRendered(",
]; ];
foreach (string identifier in removed) foreach (string identifier in removed)
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal); Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source); Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source);
Assert.Contains("new AcDream.App.Rendering.DevToolsFramePresenter(", 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); Assert.Contains("_inputCapture.WantCaptureMouse", source);
} }
@ -73,9 +99,17 @@ public sealed class GameWindowRenderLeafCompositionTests
source, source,
"new ResourceShutdownStage(\"submitted GPU work\"", "new ResourceShutdownStage(\"submitted GPU work\"",
"new ResourceShutdownStage(\"render frontends\"", "new ResourceShutdownStage(\"render frontends\"",
"new(\"frame composition\"",
"new(\"portal tunnel\"", "new(\"portal tunnel\"",
"new(\"paperdoll viewport\"", "new(\"paperdoll viewport\"",
"new ResourceShutdownStage(\"OpenGL context\""); "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); 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() private static string FindRepoRoot()
{ {
DirectoryInfo? directory = new(AppContext.BaseDirectory); DirectoryInfo? directory = new(AppContext.BaseDirectory);

View file

@ -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}");
}
}