refactor(render): compose render frame orchestrator

Move the accepted draw transaction and failure recovery behind typed frame-phase owners so GameWindow only supplies immutable frame input. Preserve retail draw order, make ImGui/bootstrap shutdown ownership explicit, and restore exact text-render GL state on failures.\n\nCo-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 08:03:49 +02:00
parent 28e1cf8029
commit 9d7df1bfc5
25 changed files with 1675 additions and 279 deletions

View file

@ -20,7 +20,6 @@ internal sealed class FrameScreenshotController
private sealed record CaptureStatus(CaptureState State, string? Error = null);
private readonly Func<(int Width, int Height)> _getSize;
private readonly Func<int, int, byte[]> _readRgba;
private readonly string _directory;
private readonly Action<string> _log;
@ -30,21 +29,18 @@ internal sealed class FrameScreenshotController
public FrameScreenshotController(
GL gl,
Func<(int Width, int Height)> getSize,
string directory,
Action<string>? log = null)
: this(getSize, (width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log)
: this((width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log)
{
ArgumentNullException.ThrowIfNull(gl);
}
internal FrameScreenshotController(
Func<(int Width, int Height)> getSize,
Func<int, int, byte[]> readRgba,
string directory,
Action<string>? log = null)
{
_getSize = getSize ?? throw new ArgumentNullException(nameof(getSize));
_readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba));
_directory = string.IsNullOrWhiteSpace(directory)
? throw new ArgumentException("A screenshot directory is required.", nameof(directory))
@ -76,7 +72,7 @@ internal sealed class FrameScreenshotController
&& status.State == CaptureState.Complete;
/// <summary>Captures at most one queued image on the current GL thread.</summary>
public bool CapturePending()
public bool CapturePending(int width, int height)
{
if (_pending.Count == 0)
return false;
@ -84,7 +80,6 @@ internal sealed class FrameScreenshotController
string name = _pending.Dequeue();
try
{
(int width, int height) = _getSize();
if (width <= 0 || height <= 0)
throw new InvalidOperationException($"invalid framebuffer size {width}x{height}");

View file

@ -25,10 +25,19 @@ internal enum DevToolsPanelKind
Settings,
}
internal interface IDevToolsFrameLifecycle : IRenderFrameFailureRecovery
{
void BeginFrame(float deltaSeconds);
void Render(double deltaSeconds, int viewportWidth, int viewportHeight);
}
internal interface IDevToolsFrameBackend
{
void BeginFrame(float deltaSeconds);
void AbortFrame();
bool BeginMainMenuBar();
void EndMainMenuBar();
@ -82,10 +91,11 @@ internal interface IDevToolsPanelSet
}
/// <summary>Concrete ImGui backend for the developer presentation owner.</summary>
internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend
internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend, IDisposable
{
private readonly ImGuiBootstrapper _bootstrap;
private readonly ImGuiPanelHost _panels;
private bool _disposed;
public ImGuiDevToolsFrameBackend(
ImGuiBootstrapper bootstrap,
@ -97,6 +107,8 @@ internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend
public void BeginFrame(float deltaSeconds) => _bootstrap.BeginFrame(deltaSeconds);
public void AbortFrame() => _bootstrap.AbortFrame();
public bool BeginMainMenuBar() => ImGuiNET.ImGui.BeginMainMenuBar();
public void EndMainMenuBar() => ImGuiNET.ImGui.EndMainMenuBar();
@ -130,6 +142,15 @@ internal sealed class ImGuiDevToolsFrameBackend : IDevToolsFrameBackend
ImGuiNET.ImGui.SetWindowSize(title, size, imguiCondition);
}
public void Dispose()
{
if (_disposed)
return;
_bootstrap.Dispose();
_disposed = true;
}
}
/// <summary>Mutable late-composition seam for player-mode menu operations.</summary>
@ -229,13 +250,14 @@ internal sealed class DevToolsPanelSet : IDevToolsPanelSet
/// reusable default layout. Retained gameplay UI remains a separate earlier
/// presentation phase.
/// </summary>
internal sealed class DevToolsFramePresenter
internal sealed class DevToolsFramePresenter : IDevToolsFrameLifecycle
{
private readonly IDevToolsFrameBackend _backend;
private readonly IDevToolsCameraMenuOperations _camera;
private readonly IDevToolsCommandBusSource _commands;
private readonly IDevToolsPanelSet _panels;
private readonly AcDream.App.Diagnostics.FrameProfiler _profiler;
private bool _frameOpen;
public DevToolsFramePresenter(
IDevToolsFrameBackend backend,
@ -251,15 +273,47 @@ internal sealed class DevToolsFramePresenter
_panels = panels ?? throw new ArgumentNullException(nameof(panels));
}
public void BeginFrame(float deltaSeconds) => _backend.BeginFrame(deltaSeconds);
public void BeginFrame(float deltaSeconds)
{
if (_frameOpen)
{
throw new InvalidOperationException(
"The previous developer-tools frame must render or abort before a new frame begins.");
}
_frameOpen = true;
_backend.BeginFrame(deltaSeconds);
}
public void Render(double deltaSeconds, int viewportWidth, int viewportHeight)
{
if (!_frameOpen)
{
throw new InvalidOperationException(
"BeginFrame must open the developer-tools frame before Render.");
}
var context = new PanelContext((float)deltaSeconds, _commands.Current);
DrawMenu(viewportWidth, viewportHeight);
_backend.RenderPanels(context);
using var stage = _profiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui);
_backend.RenderDrawData();
_frameOpen = false;
}
public void AbortFrame()
{
if (!_frameOpen)
return;
try
{
_backend.AbortFrame();
}
finally
{
_frameOpen = false;
}
}
public void ToggleDebugPanel()

View file

@ -93,23 +93,14 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Rendering.ConsoleRenderFrameDiagnosticLog();
private readonly AcDream.App.Rendering.DebugVmRenderFactsPublisher
_debugVmRenderFacts = new();
private AcDream.App.Rendering.WorldRenderDiagnostics? _worldRenderDiagnostics;
private AcDream.App.Rendering.RenderFrameDiagnosticsController?
_renderFrameDiagnostics;
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 AcDream.App.Rendering.WorldRenderFrameBuilder?
_worldRenderFrameBuilder;
private AcDream.App.Rendering.RenderFrameOrchestrator?
_renderFrameOrchestrator;
private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState;
private AcDream.App.Rendering.WorldSceneRenderer? _worldSceneRenderer;
private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame;
private ResourceShutdownTransaction? _shutdown;
private readonly DisplayFramePacingController _displayFramePacing;
@ -221,13 +212,8 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer;
private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum;
// R1 (render redesign): the per-cell DrawInside flood and concrete pass
// executor are borrowed by WorldSceneRenderer for each normal-world frame.
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor;
private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells;
private AcDream.App.Rendering.TerrainDrawDiagnosticsController?
_terrainDrawDiagnostics;
// R1 (render redesign): portal-view draw owners are retained transitively
// by the frame orchestrator rather than duplicated as GameWindow roots.
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
@ -746,7 +732,7 @@ public sealed class GameWindow : IDisposable
_gl = GL.GetApi(_window!);
_gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl);
_worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics(
var worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics(
new AcDream.App.Rendering.SilkRenderGlStateReader(_gl),
_renderDiagnosticLog);
_input = _window!.CreateInput();
@ -1682,7 +1668,6 @@ public sealed class GameWindow : IDisposable
{
_frameScreenshots = new AcDream.App.Diagnostics.FrameScreenshotController(
_gl!,
() => (_window!.Size.X, _window.Size.Y),
System.IO.Path.Combine(artifactDirectory, "screenshots"),
UiProbeLog);
_worldLifecycleAutomation =
@ -2285,8 +2270,6 @@ public sealed class GameWindow : IDisposable
landblockRenderPublisher.PrepareAfterRenderPins);
_clipFrame ??= ClipFrame.NoClip();
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer();
// T1: invisible portal depth writes (seal/punch) — retail
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
@ -2782,7 +2765,7 @@ public sealed class GameWindow : IDisposable
_localPlayerMode);
var renderFrameGlState = new AcDream.App.Rendering.RenderFrameGlStateController(
new AcDream.App.Rendering.SilkRenderFrameGlStateApi(_gl!));
_renderFrameLivePreparation =
var renderFrameLivePreparation =
new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation(
_textureCache,
_wbMeshAdapter,
@ -2795,7 +2778,7 @@ public sealed class GameWindow : IDisposable
_particleRenderer,
_frameProfiler,
_frameDiag);
_renderFrameResources =
var renderFrameResources =
new AcDream.App.Rendering.RenderFrameResourceController(
_gpuFrameFlights!,
new AcDream.App.Rendering.RuntimeRenderFrameBeginResources(
@ -2816,14 +2799,14 @@ public sealed class GameWindow : IDisposable
Weather,
teleportRenderState,
_particleVisibility,
_worldRenderDiagnostics!,
worldRenderDiagnostics,
renderFrameGlState),
_renderFrameLivePreparation);
_renderWeatherFrame =
renderFrameLivePreparation);
var renderWeatherFrame =
new AcDream.App.Rendering.RenderWeatherFrameController(
WorldTime,
Weather);
_skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(
var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(
_scriptRunner!,
_particleSink!,
_effectPoses,
@ -2837,8 +2820,8 @@ public sealed class GameWindow : IDisposable
_envCellRenderer,
_sceneLightingUbo,
_renderRange,
_skyPesFrame);
_worldRenderFrameBuilder =
skyPesFrame);
var worldRenderFrameBuilder =
new AcDream.App.Rendering.WorldRenderFrameBuilder(
new AcDream.App.Rendering.RuntimeWorldFrameCameraSource(
_cameraController!,
@ -2869,22 +2852,22 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource(
_landblockPresentationPipeline!,
_cellVisibility));
_terrainDrawDiagnostics =
var terrainDrawDiagnostics =
new AcDream.App.Rendering.TerrainDrawDiagnosticsController(
_frameDiag,
_worldRenderDiagnostics!,
worldRenderDiagnostics,
new AcDream.App.Rendering.RuntimeFramePipelineDiagnosticFactsSource(
_terrain,
_landblockPresentationPipeline,
_renderFrameLivePreparation,
renderFrameLivePreparation,
_wbDrawDispatcher,
_streamingController,
_liveEntities!,
_worldState),
_renderDiagnosticLog);
_retailPViewCells = new AcDream.App.Rendering.RetailPViewCellSource(
var retailPViewCells = new AcDream.App.Rendering.RetailPViewCellSource(
_cellVisibility);
_retailPViewPassExecutor =
var retailPViewPassExecutor =
new AcDream.App.Rendering.RetailPViewPassExecutor(
_gl!,
renderFrameGlState,
@ -2899,11 +2882,11 @@ public sealed class GameWindow : IDisposable
_particleRenderer,
_portalDepthMask,
_retailAlphaQueue,
_worldRenderDiagnostics!,
_terrainDrawDiagnostics);
worldRenderDiagnostics,
terrainDrawDiagnostics);
var worldSceneDiagnostics =
new AcDream.App.Rendering.WorldSceneDiagnosticsController(
_worldRenderDiagnostics!,
worldRenderDiagnostics,
new AcDream.App.Rendering.RuntimeWorldScenePViewDiagnosticSource(
_playerControllerSlot,
_physicsEngine,
@ -2922,27 +2905,63 @@ public sealed class GameWindow : IDisposable
_wbDrawDispatcher!,
_envCellRenderer!,
_terrain,
_terrainDrawDiagnostics,
terrainDrawDiagnostics,
_skyRenderer,
_particleSystem,
_particleRenderer);
_worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer(
_renderFrameResources,
var worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer(
renderFrameResources,
renderLoginState,
_worldSceneSkyState!,
_worldRenderFrameBuilder,
worldRenderFrameBuilder,
new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState),
_retailSelectionScene,
_retailAlphaQueue,
_particleVisibility,
new AcDream.App.Rendering.WorldScenePViewRenderer(
_retailPViewRenderer!,
_retailPViewPassExecutor,
_retailPViewPassExecutor),
_retailPViewCells,
new AcDream.App.Rendering.RetailPViewRenderer(),
retailPViewPassExecutor,
retailPViewPassExecutor),
retailPViewCells,
worldScenePasses,
_renderRange,
worldSceneDiagnostics);
AcDream.App.Rendering.IRetainedGameplayUiFrame? retainedGameplayUi =
_options.RetailUi && _retailUiRuntime is not null
? new AcDream.App.Rendering.RetainedGameplayUiFrame(
_retailUiRuntime,
_input)
: null;
AcDream.App.Rendering.IPrivateFrameScreenshot? privateScreenshot =
_frameScreenshots is not null
? new AcDream.App.Rendering.PrivateFrameScreenshot(
_frameScreenshots)
: null;
var privatePresentation =
new AcDream.App.Rendering.PrivatePresentationRenderer(
new AcDream.App.Rendering.LocalPlayerPortalViewport(
_localPlayerTeleport!,
_cameraController!),
renderFrameResources,
_paperdollFramePresenter,
retainedGameplayUi,
_devToolsFramePresenter,
privateScreenshot);
var framePreparation =
new AcDream.App.Rendering.RenderFramePreparationController(
renderFrameResources,
_devToolsFramePresenter,
renderWeatherFrame);
_renderFrameOrchestrator =
new AcDream.App.Rendering.RenderFrameOrchestrator(
_gpuFrameFlights!,
framePreparation,
worldSceneRenderer,
privatePresentation,
_renderFrameDiagnostics!,
(AcDream.App.Rendering.IRenderFrameFailureRecovery?)
_devToolsFramePresenter
?? AcDream.App.Rendering.NullRenderFrameFailureRecovery.Instance);
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame,
_worldState,
@ -3415,126 +3434,12 @@ public sealed class GameWindow : IDisposable
// window title so there's zero rendering cost (no font/overlay needed).
private void OnRender(double deltaSeconds)
{
_gpuFrameFlights!.BeginFrame();
Exception? renderFailure = null;
try
{
var frameInput = new AcDream.App.Rendering.RenderFrameInput(
Vector2D<int> size = _window!.Size;
_renderFrameOrchestrator!.Render(
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 = foundation.PortalViewportVisible;
// Phase D.2a — begin ImGui frame. Paired with the Render() call
// after the scene draws (below). ImGuiController.Update()
// consumes buffered Silk.NET input events and calls ImGui.NewFrame.
_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);
// Phase G.1: weather state machine — deterministic per-day roll
// + transitions + lightning flash.
_renderWeatherFrame!.Tick(deltaSeconds);
// (Pre-Bug-A code spawned camera-attached rain/snow particle
// emitters here as a workaround for missing weather-mesh
// rendering. Deleted 2026-04-26 once the retail-faithful world-
// space mesh path landed in SkyRenderer.RenderWeather. Retail
// rain is GfxObj 0x01004C42/0x01004C44 — a hollow octagonal
// cylinder anchored at player_pos + (0, 0, -120m) per
// GameSky::UpdatePosition at 0x00506dd0 — drawn after the
// landblock pass per LScape::draw at 0x00506330. There is no
// server-driven weather event and no camera-attached emitter
// in retail. Snow renders identically when a Snowy DayGroup is
// active in some other Region; the partition by Properties&0x04
// and the SkyRenderer.RenderWeather pass both pick up snow
// weather meshes for free.)
AcDream.App.Rendering.WorldRenderFrameOutcome worldOutcome =
_worldSceneRenderer!.Render(frameInput);
int visibleLandblocks = worldOutcome.VisibleLandblocks;
int totalLandblocks = worldOutcome.TotalLandblocks;
bool normalWorldDrawn = worldOutcome.NormalWorldDrawn;
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a
// CreatureMode portal-space viewport. The world block above is skipped
// while this replacement scene is visible. This scene and
// its projection transition draws BEFORE retained UI, so chat, windows,
// cursor, and toolbar remain visible and interactive in transit.
_localPlayerTeleport?.DrawPortalViewport(
_window!.Size.X,
_window.Size.Y,
_cameraController!.Active.Projection);
// Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the
// viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window
// is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle).
// The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state.
_paperdollFramePresenter?.Render();
// Phase D.2b — retail-look UI tree (render-only; input integration deferred).
// Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own
// blend/depth state and restores. Drawn before ImGui so the devtools
// overlay composites on top during development.
if (_options.RetailUi && _retailUiRuntime is not null)
{
_retailUiRuntime.Tick(deltaSeconds);
if (_input is not null)
_retailUiRuntime.UpdateCursor(_input.Mice);
_retailUiRuntime.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
}
// Devtools remain above retained gameplay UI. The focused presenter
// owns menu policy, panels, and ImGui draw-data upload.
_devToolsFramePresenter?.Render(
deltaSeconds,
_window!.Size.X,
_window.Size.Y);
// Explicit diagnostic captures are requested by the retained-UI
// automation tick above and executed only after every presentation
// layer has drawn into the back buffer on this render thread.
bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;
_renderFrameDiagnostics?.Publish(
new AcDream.App.Rendering.RenderFrameInput(
deltaSeconds,
_window!.Size.X,
_window.Size.Y),
new AcDream.App.Rendering.RenderFrameOutcome(
new AcDream.App.Rendering.WorldRenderFrameOutcome(
visibleLandblocks,
totalLandblocks,
normalWorldDrawn),
new AcDream.App.Rendering.PrivatePresentationFrameOutcome(
portalViewportVisible,
screenshotCaptured)));
}
catch (Exception ex)
{
renderFailure = ex;
throw;
}
finally
{
try
{
_gpuFrameFlights.EndFrame();
}
catch (Exception closeFailure) when (renderFailure is not null)
{
throw new AggregateException(
"Rendering failed and the in-flight GPU frame could not be closed.",
renderFailure,
closeFailure);
}
}
size.X,
size.Y));
}
private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot
@ -4563,14 +4468,8 @@ public sealed class GameWindow : IDisposable
[
new("world frame composition", () =>
{
_worldSceneRenderer = null;
_worldRenderFrameBuilder = null;
_renderFrameOrchestrator = null;
_worldSceneSkyState = null;
_skyPesFrame = null;
_retailPViewPassExecutor = null;
_retailPViewCells = null;
_terrainDrawDiagnostics = null;
_retailPViewRenderer = null;
}),
]),
new ResourceShutdownStage("session dependents",
@ -4643,11 +4542,13 @@ public sealed class GameWindow : IDisposable
]),
new ResourceShutdownStage("render frontends",
[
new("frame composition", () =>
new("developer tools", () =>
{
_renderFrameResources = null;
_renderFrameLivePreparation = null;
_renderWeatherFrame = null;
_devToolsFramePresenter = null;
_devToolsCameraMenu = null;
_devToolsCommandBus = null;
_devToolsBackend?.Dispose();
_devToolsBackend = null;
}),
new("portal tunnel", () =>
{

View file

@ -48,7 +48,7 @@ internal interface IPaperdollPoseApplicator
/// The GL renderer remains a borrowed resource disposed by the existing window
/// shutdown transaction.
/// </summary>
internal sealed class PaperdollFramePresenter
internal sealed class PaperdollFramePresenter : IPrivatePaperdollFrame
{
private readonly IPaperdollDollRenderer _renderer;
private readonly IPaperdollFrameView _view;

View file

@ -0,0 +1,148 @@
using System.Numerics;
using AcDream.App.Diagnostics;
using AcDream.App.Streaming;
using AcDream.App.UI;
using Silk.NET.Input;
namespace AcDream.App.Rendering;
internal interface IPrivatePortalViewport
{
void Draw(int width, int height);
}
internal interface IPrivatePaperdollFrame
{
void Render();
}
internal interface IRetainedGameplayUiFrame
{
void Render(double deltaSeconds, int width, int height);
}
internal interface IPrivateFrameScreenshot
{
bool CapturePending(int width, int height);
}
/// <summary>
/// Draws the private and 2-D presentation layers after the world alpha scope
/// has closed: portal CreatureMode, paperdoll FBO, retained gameplay UI,
/// developer UI, then the complete-frame screenshot.
/// </summary>
internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePhase
{
private readonly IPrivatePortalViewport _portal;
private readonly IRenderFrameFoundationSource _foundation;
private readonly IPrivatePaperdollFrame? _paperdoll;
private readonly IRetainedGameplayUiFrame? _gameplayUi;
private readonly IDevToolsFrameLifecycle? _devTools;
private readonly IPrivateFrameScreenshot? _screenshots;
public PrivatePresentationRenderer(
IPrivatePortalViewport portal,
IRenderFrameFoundationSource foundation,
IPrivatePaperdollFrame? paperdoll,
IRetainedGameplayUiFrame? gameplayUi,
IDevToolsFrameLifecycle? devTools,
IPrivateFrameScreenshot? screenshots)
{
_portal = portal ?? throw new ArgumentNullException(nameof(portal));
_foundation = foundation
?? throw new ArgumentNullException(nameof(foundation));
_paperdoll = paperdoll;
_gameplayUi = gameplayUi;
_devTools = devTools;
_screenshots = screenshots;
}
public PrivatePresentationFrameOutcome Render(
RenderFrameInput input,
WorldRenderFrameOutcome world)
{
_ = world;
// Foundation is the immutable result of this frame's clear phase.
// Snapshot it before drawing so a portal transition raised by a
// private layer cannot relabel the viewport that was just presented.
bool portalViewportVisible =
_foundation.Foundation.PortalViewportVisible;
_portal.Draw(input.ViewportWidth, input.ViewportHeight);
_paperdoll?.Render();
_gameplayUi?.Render(
input.DeltaSeconds,
input.ViewportWidth,
input.ViewportHeight);
_devTools?.Render(
input.DeltaSeconds,
input.ViewportWidth,
input.ViewportHeight);
bool screenshotCaptured = _screenshots?.CapturePending(
input.ViewportWidth,
input.ViewportHeight) == true;
return new PrivatePresentationFrameOutcome(
portalViewportVisible,
screenshotCaptured);
}
}
/// <summary>Typed portal viewport adapter over the canonical teleport owner.</summary>
internal sealed class LocalPlayerPortalViewport : IPrivatePortalViewport
{
private readonly LocalPlayerTeleportController _teleport;
private readonly CameraController _camera;
public LocalPlayerPortalViewport(
LocalPlayerTeleportController teleport,
CameraController camera)
{
_teleport = teleport ?? throw new ArgumentNullException(nameof(teleport));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
}
public void Draw(int width, int height) =>
_teleport.DrawPortalViewport(width, height, _camera.Active.Projection);
}
/// <summary>
/// One retained-gameplay-UI tick/cursor/draw transaction. This is the explicit
/// boundary where the render graph reaches the retained UI composition owner;
/// <see cref="RetailUiRuntime"/> intentionally retains its documented state and
/// command bindings. The distinction between direct frame ownership and
/// recursive shared-owner reachability is recorded in
/// docs/architecture/code-structure.md.
/// </summary>
internal sealed class RetainedGameplayUiFrame : IRetainedGameplayUiFrame
{
private readonly RetailUiRuntime _runtime;
private readonly IInputContext? _input;
public RetainedGameplayUiFrame(
RetailUiRuntime runtime,
IInputContext? input)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_input = input;
}
public void Render(double deltaSeconds, int width, int height)
{
_runtime.Tick(deltaSeconds);
if (_input is not null)
_runtime.UpdateCursor(_input.Mice);
_runtime.Draw(new Vector2(width, height));
}
}
/// <summary>Optional complete-frame capture adapter.</summary>
internal sealed class PrivateFrameScreenshot : IPrivateFrameScreenshot
{
private readonly FrameScreenshotController _screenshots;
public PrivateFrameScreenshot(FrameScreenshotController screenshots) =>
_screenshots = screenshots
?? throw new ArgumentNullException(nameof(screenshots));
public bool CapturePending(int width, int height) =>
_screenshots.CapturePending(width, height);
}

View file

@ -60,6 +60,29 @@ internal interface IRenderFrameDiagnosticsPhase
void Publish(RenderFrameInput input, RenderFrameOutcome outcome);
}
/// <summary>
/// Closes presentation transactions which may have opened before a later
/// render phase failed. Recovery is idempotent so failures before the
/// transaction opens and failures after it closes are both safe.
/// </summary>
internal interface IRenderFrameFailureRecovery
{
void AbortFrame();
}
internal sealed class NullRenderFrameFailureRecovery : IRenderFrameFailureRecovery
{
public static NullRenderFrameFailureRecovery Instance { get; } = new();
private NullRenderFrameFailureRecovery()
{
}
public void AbortFrame()
{
}
}
/// <summary>
/// Owns the accepted outer render transaction. Focused phase owners retain the
/// detailed PView, private-viewport, UI, and diagnostic order inside their
@ -79,19 +102,22 @@ internal sealed class RenderFrameOrchestrator
private readonly IWorldSceneFramePhase _world;
private readonly IPrivatePresentationFramePhase _presentation;
private readonly IRenderFrameDiagnosticsPhase _diagnostics;
private readonly IRenderFrameFailureRecovery _recovery;
public RenderFrameOrchestrator(
IRenderFrameLifetime lifetime,
IRenderFrameResourcePhase resources,
IWorldSceneFramePhase world,
IPrivatePresentationFramePhase presentation,
IRenderFrameDiagnosticsPhase diagnostics)
IRenderFrameDiagnosticsPhase diagnostics,
IRenderFrameFailureRecovery recovery)
{
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
_world = world ?? throw new ArgumentNullException(nameof(world));
_presentation = presentation ?? throw new ArgumentNullException(nameof(presentation));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_recovery = recovery ?? throw new ArgumentNullException(nameof(recovery));
}
public RenderFrameOutcome Render(RenderFrameInput input)
@ -115,17 +141,50 @@ internal sealed class RenderFrameOrchestrator
}
finally
{
Exception? recoveryFailure = null;
if (renderFailure is not null)
{
try
{
// ImGui NewFrame is opened during preparation but normally
// closed during private presentation. Abort it before the
// GPU flight closes when any intervening phase fails.
_recovery.AbortFrame();
}
catch (Exception error)
{
recoveryFailure = error;
}
}
try
{
_lifetime.EndFrame();
}
catch (Exception closeFailure) when (renderFailure is not null)
{
if (recoveryFailure is not null)
{
throw new AggregateException(
"Rendering failed and neither presentation recovery nor the in-flight GPU frame could be closed.",
renderFailure,
recoveryFailure,
closeFailure);
}
throw new AggregateException(
"Rendering failed and the in-flight GPU frame could not be closed.",
renderFailure,
closeFailure);
}
if (renderFailure is not null && recoveryFailure is not null)
{
throw new AggregateException(
"Rendering failed and the presentation frame could not be aborted.",
renderFailure,
recoveryFailure);
}
}
}
}

View file

@ -0,0 +1,35 @@
namespace AcDream.App.Rendering;
internal interface IRenderWeatherFramePhase
{
void Tick(double deltaSeconds);
}
/// <summary>
/// Preserves the accepted pre-world order after the GPU/resource transaction:
/// begin optional developer UI, advance render-time weather, then hand the
/// completed preparation to the world phase.
/// </summary>
internal sealed class RenderFramePreparationController : IRenderFrameResourcePhase
{
private readonly IRenderFrameResourcePhase _resources;
private readonly IDevToolsFrameLifecycle? _devTools;
private readonly IRenderWeatherFramePhase _weather;
public RenderFramePreparationController(
IRenderFrameResourcePhase resources,
IDevToolsFrameLifecycle? devTools,
IRenderWeatherFramePhase weather)
{
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
_devTools = devTools;
_weather = weather ?? throw new ArgumentNullException(nameof(weather));
}
public void Prepare(RenderFrameInput input)
{
_resources.Prepare(input);
_devTools?.BeginFrame((float)input.DeltaSeconds);
_weather.Tick(input.DeltaSeconds);
}
}

View file

@ -350,7 +350,7 @@ internal sealed class RuntimeRenderFrameLivePreparation
}
/// <summary>Owns the render-time weather clock and deterministic day index.</summary>
internal sealed class RenderWeatherFrameController
internal sealed class RenderWeatherFrameController : IRenderWeatherFramePhase
{
private readonly WorldTimeService _worldTime;
private readonly WeatherSystem _weather;

View file

@ -0,0 +1,156 @@
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
internal interface ITextRenderGlStateApi
{
bool IsEnabled(EnableCap capability);
int GetInteger(GetPName parameter);
bool GetBoolean(GetPName parameter);
void SetCapability(EnableCap capability, bool enabled);
void DepthMask(bool enabled);
void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha);
void UseProgram(uint program);
void BindVertexArray(uint vertexArray);
void BindBuffer(BufferTargetARB target, uint buffer);
void ActiveTexture(TextureUnit unit);
void BindTexture(TextureTarget target, uint texture);
}
internal sealed class SilkTextRenderGlStateApi : ITextRenderGlStateApi
{
private readonly GL _gl;
public SilkTextRenderGlStateApi(GL gl) =>
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
public bool IsEnabled(EnableCap capability) => _gl.IsEnabled(capability);
public int GetInteger(GetPName parameter) => _gl.GetInteger(parameter);
public bool GetBoolean(GetPName parameter) => _gl.GetBoolean(parameter);
public void SetCapability(EnableCap capability, bool enabled)
{
if (enabled)
_gl.Enable(capability);
else
_gl.Disable(capability);
}
public void DepthMask(bool enabled) => _gl.DepthMask(enabled);
public void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha) =>
_gl.BlendFuncSeparate(
sourceRgb,
destinationRgb,
sourceAlpha,
destinationAlpha);
public void UseProgram(uint program) => _gl.UseProgram(program);
public void BindVertexArray(uint vertexArray) => _gl.BindVertexArray(vertexArray);
public void BindBuffer(BufferTargetARB target, uint buffer) =>
_gl.BindBuffer(target, buffer);
public void ActiveTexture(TextureUnit unit) => _gl.ActiveTexture(unit);
public void BindTexture(TextureTarget target, uint texture) =>
_gl.BindTexture(target, texture);
}
/// <summary>
/// Exact, focused state transaction for <see cref="TextRenderer.Flush"/>. It
/// captures every GL value that Flush or DrawLayer mutates, while avoiding the
/// dozens of unrelated synchronous reads made by the broad diagnostic scope.
/// </summary>
internal readonly struct TextRenderGlStateScope : IDisposable
{
private readonly ITextRenderGlStateApi _gl;
private readonly bool _depthTest;
private readonly bool _blend;
private readonly bool _cullFace;
private readonly bool _alphaToCoverage;
private readonly bool _multisample;
private readonly bool _depthWrite;
private readonly int _blendSourceRgb;
private readonly int _blendDestinationRgb;
private readonly int _blendSourceAlpha;
private readonly int _blendDestinationAlpha;
private readonly int _program;
private readonly int _vertexArray;
private readonly int _arrayBuffer;
private readonly int _activeTexture;
private readonly int _texture0Binding2D;
public TextRenderGlStateScope(ITextRenderGlStateApi gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_depthTest = gl.IsEnabled(EnableCap.DepthTest);
_blend = gl.IsEnabled(EnableCap.Blend);
_cullFace = gl.IsEnabled(EnableCap.CullFace);
_alphaToCoverage = gl.IsEnabled(EnableCap.SampleAlphaToCoverage);
_multisample = gl.IsEnabled(EnableCap.Multisample);
_depthWrite = gl.GetBoolean(GetPName.DepthWritemask);
_blendSourceRgb = gl.GetInteger(GetPName.BlendSrcRgb);
_blendDestinationRgb = gl.GetInteger(GetPName.BlendDstRgb);
_blendSourceAlpha = gl.GetInteger(GetPName.BlendSrcAlpha);
_blendDestinationAlpha = gl.GetInteger(GetPName.BlendDstAlpha);
_program = gl.GetInteger(GetPName.CurrentProgram);
_vertexArray = gl.GetInteger(GetPName.VertexArrayBinding);
_arrayBuffer = gl.GetInteger(GetPName.ArrayBufferBinding);
_activeTexture = gl.GetInteger(GetPName.ActiveTexture);
try
{
gl.ActiveTexture(TextureUnit.Texture0);
_texture0Binding2D = gl.GetInteger(GetPName.TextureBinding2D);
}
finally
{
gl.ActiveTexture((TextureUnit)_activeTexture);
}
}
public void Dispose()
{
_gl.UseProgram((uint)_program);
_gl.BindVertexArray((uint)_vertexArray);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, (uint)_arrayBuffer);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, (uint)_texture0Binding2D);
_gl.ActiveTexture((TextureUnit)_activeTexture);
_gl.DepthMask(_depthWrite);
_gl.BlendFuncSeparate(
(BlendingFactor)_blendSourceRgb,
(BlendingFactor)_blendDestinationRgb,
(BlendingFactor)_blendSourceAlpha,
(BlendingFactor)_blendDestinationAlpha);
_gl.SetCapability(EnableCap.DepthTest, _depthTest);
_gl.SetCapability(EnableCap.Blend, _blend);
_gl.SetCapability(EnableCap.CullFace, _cullFace);
_gl.SetCapability(EnableCap.SampleAlphaToCoverage, _alphaToCoverage);
_gl.SetCapability(EnableCap.Multisample, _multisample);
}
}

View file

@ -24,6 +24,7 @@ public sealed unsafe class TextRenderer : IDisposable
private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4)
private readonly GL _gl;
private readonly ITextRenderGlStateApi _glState;
private readonly Shader _shader;
private uint _vao;
private uint _vbo;
@ -53,6 +54,7 @@ public sealed unsafe class TextRenderer : IDisposable
// at its FIRST-insertion point, so later bar sprites covered glyphs emitted
// earlier via the shared dat-font atlas — the stamina/mana numbers vanished.)
private sealed class SpriteSeg { public uint Texture; public readonly List<float> Verts = new(256); }
private readonly List<SpriteSeg> _spriteSegs = new();
private int _segUsed;
private int _textVerts;
@ -77,6 +79,7 @@ public sealed unsafe class TextRenderer : IDisposable
public TextRenderer(GL gl, string shaderDir)
{
_gl = gl;
_glState = new SilkTextRenderGlStateApi(gl);
_shader = new Shader(gl,
Path.Combine(shaderDir, "ui_text.vert"),
Path.Combine(shaderDir, "ui_text.frag"));
@ -350,23 +353,24 @@ public sealed unsafe class TextRenderer : IDisposable
bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0;
if (!anyNormal && !anyOverlay) return;
// Retained UI is a private render pass: an upload or draw failure must
// not leak its depth/cull/blend/MSAA state into a later recoverable
// frame. The focused scope restores from Flush's generated finally,
// including when either DrawLayer call throws.
using var stateScope = new TextRenderGlStateScope(_glState);
_shader.Use();
_shader.SetVec2("uScreenSize", _screenSize);
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
// Save GL state.
bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest);
bool wasBlend = _gl.IsEnabled(EnableCap.Blend);
bool wasCull = _gl.IsEnabled(EnableCap.CullFace);
// Establish the self-contained UI pass state.
// The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher,
// QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha
// EDGE is converted to dithered MSAA coverage instead of a clean alpha blend —
// the "text not sharp / fuzzy" artifact. The UI composites with straight alpha
// blending and must own this state (feedback_render_self_contained_gl_state).
bool wasA2C = _gl.IsEnabled(EnableCap.SampleAlphaToCoverage);
bool wasMsaa = _gl.IsEnabled(EnableCap.Multisample);
_gl.Disable(EnableCap.SampleAlphaToCoverage);
_gl.Disable(EnableCap.Multisample);
_gl.Disable(EnableCap.DepthTest);
@ -386,15 +390,6 @@ public sealed unsafe class TextRenderer : IDisposable
DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font);
DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font);
// Restore GL state.
_gl.DepthMask(true);
if (!wasBlend) _gl.Disable(EnableCap.Blend);
if (wasCull) _gl.Enable(EnableCap.CullFace);
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
if (wasA2C) _gl.Enable(EnableCap.SampleAlphaToCoverage);
if (wasMsaa) _gl.Enable(EnableCap.Multisample);
_gl.BindVertexArray(0);
}
/// <summary>Draw one compositing layer: sprites (submission order, one call per

View file

@ -60,5 +60,15 @@ public sealed class ImGuiBootstrapper : IDisposable
/// </summary>
public void Render() => _controller.Render();
/// <summary>
/// Close an unfinished frame through the owning Silk controller. Silk's
/// controller clears its private frame-begun flag before uploading draw
/// data, so this is both the correct abort path for pre-render failures and
/// an idempotent no-op when a render upload failed after logical close.
/// Calling raw <c>ImGui.EndFrame</c> here would leave the controller's state
/// open and poison its next <see cref="BeginFrame"/> call.
/// </summary>
public void AbortFrame() => _controller.Render();
public void Dispose() => _controller.Dispose();
}