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