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

@ -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"),
"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<int>(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<int> _)
=> 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",
[

View file

@ -217,6 +217,7 @@ internal sealed class ImmediateGpuResourceRetirementQueue : IGpuResourceRetireme
internal sealed class GpuFrameFlightController :
IGpuResourceRetirementQueue,
IRenderFrameLifetime,
IRenderFrameSlotSource,
IDisposable
{
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;
}
}