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:
parent
28e1cf8029
commit
9d7df1bfc5
25 changed files with 1675 additions and 279 deletions
|
|
@ -1,9 +1,9 @@
|
|||
# acdream — code structure & extraction sequence
|
||||
|
||||
**Status:** Living document. Created 2026-05-16; implementation reconciliation
|
||||
completed 2026-07-21; Slices 1–6 landed by 2026-07-22. Slice 7 draw-frame
|
||||
orchestration is the active structural program before new M4 subsystems enter
|
||||
the App layer.
|
||||
completed 2026-07-21; Slices 1–6 and the Slice 7 draw-frame cutover landed by
|
||||
2026-07-22. Slice 7 connected closeout is active before the final Slice 8
|
||||
composition/shutdown cleanup and new M4 subsystems enter the App layer.
|
||||
**Purpose:** Describe the desired structural state of the App layer,
|
||||
explain the rules we've adopted, and lay out the safe extraction
|
||||
sequence from today's reality (one 15,723-line `GameWindow.cs` at the
|
||||
|
|
@ -30,6 +30,7 @@ after Slice 3 14,310 lines / 274 fields / 190 method
|
|||
after Slice 4 10,301 lines / 267 fields / 163 methods
|
||||
after Slice 5 closeout 8,811 lines / 247 fields / 153 methods
|
||||
after Slice 6 closeout 7,026 lines / 241 fields / 108 methods
|
||||
after Slice 7 draw-frame cutover 4,666 lines / 196 fields / 70 methods
|
||||
```
|
||||
|
||||
`GameWindow` is the single object that:
|
||||
|
|
@ -168,6 +169,45 @@ App currently wires it.
|
|||
|
||||
---
|
||||
|
||||
### Render-frame ownership versus recursive reachability
|
||||
|
||||
The typed `RenderFrameOrchestrator` graph owns frame sequencing and borrows
|
||||
long-lived runtime/presentation owners. The enforceable ownership invariant is
|
||||
that the orchestrator and its immediate phase owners retain neither
|
||||
`GameWindow`, an anonymous callback bag, nor direct window delegates. It is
|
||||
**not** an invariant that recursively walking every borrowed canonical owner
|
||||
can never reach an event subscriber or callback that was composed by
|
||||
`GameWindow`; that claim would be false and would confuse object-graph
|
||||
reachability with frame ownership.
|
||||
|
||||
Known recursive callback-bearing paths include (this is evidence and design
|
||||
documentation, not an exhaustive allowlist):
|
||||
|
||||
- `RetainedGameplayUiFrame -> RetailUiRuntime -> RetailUiRuntimeBindings` for
|
||||
retained gameplay UI state and commands.
|
||||
- `PrivatePresentationRenderer -> PaperdollFramePresenter ->
|
||||
RetailPaperdollFrameView/PaperdollInventoryVisibility -> UiElement tree` for
|
||||
private paperdoll visibility and texture publication. This is a second,
|
||||
independent retained-UI entry because `UiElement.Parent` links back to the
|
||||
shared root and sibling controls.
|
||||
- `DevToolsFramePresenter -> DevToolsPanelSet -> panel/ViewModel bindings` for
|
||||
the optional developer UI.
|
||||
- `WorldRenderFrameBuilder -> RuntimeWorldFrameSettingsPreview -> SettingsVM`
|
||||
for the live settings draft preview applied before world drawing.
|
||||
- `LocalPlayerPortalViewport -> LocalPlayerTeleportController ->
|
||||
GameplayInputFrameController -> InputDispatcher.Fired -> GameWindow` for the
|
||||
canonical portal/input lifetime and the host's input-action subscription.
|
||||
|
||||
These are presentation state/command seams, not alternative owners of the
|
||||
window or frame transaction. The screenshot path is deliberately not on this
|
||||
list: viewport width/height now travel in `RenderFrameInput`, so
|
||||
`FrameScreenshotController` no longer stores a window-size callback. New
|
||||
immediate phase owners must still satisfy the direct ownership invariant;
|
||||
recursive shared-owner paths are reviewed against their canonical subsystem
|
||||
lifetime rather than an impossible global no-callback assertion.
|
||||
|
||||
---
|
||||
|
||||
## 3. Target structure of the App layer
|
||||
|
||||
The end state — not what we're shipping in one pass, but the shape
|
||||
|
|
@ -608,7 +648,7 @@ registered; this behavior-preserving extraction introduced no new divergence.
|
|||
|
||||
Detailed execution ledger:
|
||||
[`docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md`](../plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md)
|
||||
(active).
|
||||
(connected closeout active).
|
||||
|
||||
Move the complete draw graph and its reusable frame-local scratch state into a
|
||||
GL-owning App collaborator. Preserve the exact modern pipeline order, clip
|
||||
|
|
@ -622,6 +662,14 @@ Automated acceptance uses framebuffer artifacts and render/resource
|
|||
checkpoints. Visual acceptance compares outdoor, building, dungeon, portal
|
||||
exit, translucent lifestone/particles, and UI at the same camera positions.
|
||||
|
||||
The production draw cutover is complete. `GameWindow.OnRender` now performs one
|
||||
value-only handoff to `RenderFrameOrchestrator`; focused resource, world,
|
||||
private-presentation, and diagnostic owners contain the former frame body and
|
||||
failure recovery. The Release suite passes 7,341 tests with five intentional
|
||||
skips. `GameWindow` is 4,666 raw lines / 196 fields / 70 methods: 11,057 lines
|
||||
(70.3%) smaller than the campaign baseline. The connected lifecycle and
|
||||
resource-soak closeout remains before Slice 7 is final.
|
||||
|
||||
#### Slice 8 — composition and shutdown cleanup
|
||||
|
||||
Keep GL/window construction in `GameWindow.OnLoad`, but group creation into
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; shared frame drain in `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveEffectFrameController`) | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order |
|
||||
| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveObjectFrameController` + `LiveEffectFrameController` shared `_particles.Tick` / `_scripts.Tick` tail); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order |
|
||||
| TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 |
|
||||
| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slice 6 preserves the accepted host order as an ownership-only extraction. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/GameWindow.cs` (`_retailUiRuntime.Tick` in `OnRender`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison |
|
||||
| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ port, or resource-lifetime redesign.
|
|||
`RetailPViewPassExecutor` and data-only inputs.
|
||||
- [x] F — extract `WorldSceneRenderer`, including both shared-alpha scopes,
|
||||
PView/fallback world branches, particles, debug world draw, and completion.
|
||||
- [ ] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one
|
||||
- [x] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one
|
||||
handoff, and delete obsolete frame bodies, fields, helpers, and callbacks.
|
||||
- [ ] H — corrected-diff reviews, full Release suite, connected lifecycle and
|
||||
soak gates, framebuffer comparison, documentation, memory, and metrics.
|
||||
|
|
@ -231,12 +231,15 @@ contract requires separate approval and evidence.
|
|||
|
||||
### 4.2 Borrowed resources and shutdown
|
||||
|
||||
Slice 7 collaborators borrow the already-created GL/render owners. They do not
|
||||
dispose them. The existing retryable shutdown transaction keeps its proven
|
||||
dependency order: wait for submitted work; withdraw UI/render publications;
|
||||
release textures and mesh owners; then dispose GL backing stores, with
|
||||
`GpuFrameFlightController` last among render resources. Slice 8 may group
|
||||
construction/disposal after the render cutover is stable.
|
||||
The frame orchestrator and phase owners borrow the already-created GL/render
|
||||
owners and do not dispose them. The developer-tools backend is itself the
|
||||
canonical owner of its ImGui bootstrap and disposes that bootstrap when the
|
||||
existing retryable shutdown transaction retires the backend; the frame
|
||||
presenter only borrows it. Shutdown keeps its proven dependency order: wait for
|
||||
submitted work; withdraw UI/render publications; release textures and mesh
|
||||
owners; then dispose GL backing stores, with `GpuFrameFlightController` last
|
||||
among render resources. Slice 8 may group construction/disposal after the
|
||||
render cutover is stable.
|
||||
|
||||
`DisplayFramePacingController` is the focused shared owner for requested VSync,
|
||||
live display preview, monitor/window callbacks, and the separate
|
||||
|
|
@ -354,7 +357,8 @@ Completed 2026-07-22. Paperdoll clone/re-dress/pose/FBO work now belongs to
|
|||
`PaperdollFramePresenter`; retained surfaces are narrow and optional, while
|
||||
live identity and DAT pose application resolve through focused adapters.
|
||||
`DevToolsFramePresenter` owns the ImGui frame, menus, panels, layout, and input
|
||||
actions without taking shutdown ownership of the borrowed bootstrap.
|
||||
actions while borrowing the bootstrap; `ImGuiDevToolsFrameBackend` explicitly
|
||||
owns and disposes that bootstrap at shutdown.
|
||||
`WorldRenderDiagnostics`, `DebugVmRenderFactsPublisher`, and
|
||||
`RenderFrameDiagnosticsController` own the former render probes, debug facts,
|
||||
title/resource cadence, and public snapshot. The production handoff now reports
|
||||
|
|
@ -471,12 +475,29 @@ corrected-diff reviews are clean.
|
|||
|
||||
- compose concrete resource, world, private-presentation, and diagnostic owners;
|
||||
- replace `OnRender` with one `RenderFrameOrchestrator.Render` handoff;
|
||||
- remove obsolete fields/helpers/callbacks and prove the transitive render graph
|
||||
has no substantial path back to `GameWindow`;
|
||||
- preserve the existing shutdown resource list without transferring disposal;
|
||||
- remove obsolete fields/helpers/callbacks; prove immediate frame owners retain
|
||||
no direct `GameWindow`/anonymous-delegate back-reference, and document rather
|
||||
than deny recursive paths through borrowed canonical UI/input owners;
|
||||
- preserve the existing shutdown dependency order while making the
|
||||
developer-tools backend's pre-existing bootstrap ownership explicit;
|
||||
- measure line/field/method counts. Ownership decides completion; the expected
|
||||
signal is roughly 5,000–5,500 lines before Slice 8 composition cleanup.
|
||||
|
||||
Completed 2026-07-22. `GameWindow.OnRender` now creates one value-only
|
||||
`RenderFrameInput` and hands the complete frame to `RenderFrameOrchestrator`.
|
||||
Focused preparation, world-scene, private-presentation, and diagnostic owners
|
||||
preserve the accepted resource → ImGui begin → weather → world → portal →
|
||||
paperdoll → retained UI → ImGui submit → screenshot → diagnostics → GPU close
|
||||
order. Recovery now closes an active ImGui frame through the owning Silk
|
||||
controller and restores the exact text-render GL state after success or
|
||||
failure. Screenshot dimensions travel in the frame input rather than through a
|
||||
window callback, and immediate frame owners have no direct `GameWindow` or
|
||||
anonymous-delegate back-reference. `GameWindow.cs` is 4,666 raw lines / 196
|
||||
fields / 70 methods: 11,057 lines (70.3%) below the 15,723-line campaign
|
||||
baseline. The Release build and complete suite pass (7,341 passed / 5 skipped),
|
||||
and retail-conformance, architecture, and adversarial corrected-diff reviews
|
||||
are clean.
|
||||
|
||||
### H — release and connected gates
|
||||
|
||||
After three clean corrected-diff reviews:
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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", () =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
148
src/AcDream.App/Rendering/PrivatePresentationRenderer.cs
Normal file
148
src/AcDream.App/Rendering/PrivatePresentationRenderer.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
156
src/AcDream.App/Rendering/TextRenderGlStateScope.cs
Normal file
156
src/AcDream.App/Rendering/TextRenderGlStateScope.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
];
|
||||
var logs = new List<string>();
|
||||
var controller = new FrameScreenshotController(
|
||||
() => (2, 2),
|
||||
(_, _) => rgba,
|
||||
directory,
|
||||
logs.Add);
|
||||
|
|
@ -29,7 +28,7 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
Assert.True(controller.TryRequest("login_stable", out string error), error);
|
||||
Assert.False(controller.IsComplete("login_stable"));
|
||||
|
||||
Assert.True(controller.CapturePending());
|
||||
Assert.True(controller.CapturePending(2, 2));
|
||||
|
||||
Assert.True(controller.IsComplete("login_stable"));
|
||||
string path = Path.Combine(directory, "login_stable.png");
|
||||
|
|
@ -53,15 +52,14 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
{
|
||||
string directory = NewDirectory();
|
||||
var controller = new FrameScreenshotController(
|
||||
() => (0, 0),
|
||||
(_, _) => [],
|
||||
directory);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.False(controller.CapturePending());
|
||||
Assert.False(controller.CapturePending(0, 0));
|
||||
Assert.True(controller.TryRequest("bad_frame", out string error), error);
|
||||
Assert.False(controller.CapturePending());
|
||||
Assert.False(controller.CapturePending(0, 0));
|
||||
Assert.False(controller.IsComplete("bad_frame"));
|
||||
}
|
||||
finally
|
||||
|
|
@ -109,7 +107,6 @@ public sealed class WorldLifecycleAutomationControllerTests
|
|||
TrackedGpuBytes = 1234,
|
||||
};
|
||||
var screenshots = new FrameScreenshotController(
|
||||
() => (1, 1),
|
||||
(_, _) => [0, 0, 0, 255],
|
||||
Path.Combine(directory, "screenshots"));
|
||||
var controller = new WorldLifecycleAutomationController(
|
||||
|
|
|
|||
|
|
@ -42,8 +42,10 @@ public sealed class DevToolsFramePresenterTests
|
|||
var commands = new RecordingCommandSource { Current = first };
|
||||
var presenter = Create(backend, commands: commands);
|
||||
|
||||
presenter.BeginFrame(0.1f);
|
||||
presenter.Render(0.1, 800, 600);
|
||||
commands.Current = second;
|
||||
presenter.BeginFrame(0.2f);
|
||||
presenter.Render(0.2, 800, 600);
|
||||
|
||||
Assert.Same(first, backend.Contexts[0].Commands);
|
||||
|
|
@ -60,6 +62,7 @@ public sealed class DevToolsFramePresenterTests
|
|||
var panels = new RecordingPanels();
|
||||
var presenter = Create(backend, panels: panels);
|
||||
|
||||
presenter.BeginFrame(0.1f);
|
||||
presenter.Render(0.1, 1920, 1080);
|
||||
|
||||
Assert.Equal(
|
||||
|
|
@ -93,6 +96,7 @@ public sealed class DevToolsFramePresenterTests
|
|||
var camera = new RecordingCamera { IsFlyMode = true, CollideCamera = true };
|
||||
var presenter = Create(backend, camera: camera);
|
||||
|
||||
presenter.BeginFrame(0.1f);
|
||||
presenter.Render(0.1, 1280, 720);
|
||||
|
||||
Assert.Equal(1, camera.ToggleCount);
|
||||
|
|
@ -113,6 +117,7 @@ public sealed class DevToolsFramePresenterTests
|
|||
|
||||
presenter.ToggleSettingsPanel();
|
||||
presenter.ResetLayout(1280, 720, DevToolsPanelLayoutCondition.FirstUseEver);
|
||||
presenter.BeginFrame(0.1f);
|
||||
presenter.Render(0.1, 1280, 720);
|
||||
|
||||
Assert.DoesNotContain(DevToolsPanelKind.Settings, panels.Toggles);
|
||||
|
|
@ -156,6 +161,68 @@ public sealed class DevToolsFramePresenterTests
|
|||
call.WithoutCondition()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbortFrame_ClosesAnOpenBackendFrameAndAllowsTheNextFrame()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var backend = new RecordingBackend(calls);
|
||||
var presenter = Create(backend);
|
||||
|
||||
presenter.BeginFrame(0.1f);
|
||||
presenter.AbortFrame();
|
||||
presenter.AbortFrame();
|
||||
presenter.BeginFrame(0.2f);
|
||||
presenter.Render(0.2, 800, 600);
|
||||
|
||||
Assert.Equal(1, calls.Count(call => call == "abort"));
|
||||
Assert.Equal(2, calls.Count(call => call.StartsWith("begin:")));
|
||||
Assert.Equal(1, calls.Count(call => call == "draw-data"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var backend = new RecordingBackend(calls)
|
||||
{
|
||||
DrawFailure = new InvalidOperationException("draw"),
|
||||
};
|
||||
var presenter = Create(backend);
|
||||
|
||||
presenter.BeginFrame(0.1f);
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
presenter.Render(0.1, 800, 600));
|
||||
presenter.AbortFrame();
|
||||
|
||||
backend.DrawFailure = null;
|
||||
presenter.BeginFrame(0.2f);
|
||||
presenter.Render(0.2, 800, 600);
|
||||
|
||||
Assert.Equal(0, calls.Count(call => call == "abort"));
|
||||
Assert.Equal(2, calls.Count(call => call == "draw-data"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginFailure_RemainsAbortableAndDoesNotPoisonTheNextFrame()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var backend = new RecordingBackend(calls)
|
||||
{
|
||||
BeginFailure = new InvalidOperationException("begin"),
|
||||
};
|
||||
var presenter = Create(backend);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => presenter.BeginFrame(0.1f));
|
||||
presenter.AbortFrame();
|
||||
|
||||
backend.BeginFailure = null;
|
||||
presenter.BeginFrame(0.2f);
|
||||
presenter.Render(0.2, 800, 600);
|
||||
|
||||
Assert.Equal(1, calls.Count(call => call == "abort"));
|
||||
Assert.Equal(2, calls.Count(call => call.StartsWith("begin:")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputFacingActionsDelegateToTheTypedPanelOwner()
|
||||
{
|
||||
|
|
@ -198,7 +265,7 @@ public sealed class DevToolsFramePresenterTests
|
|||
field => field.FieldType == typeof(Silk.NET.Windowing.IWindow));
|
||||
}
|
||||
|
||||
Assert.False(typeof(IDisposable).IsAssignableFrom(
|
||||
Assert.True(typeof(IDisposable).IsAssignableFrom(
|
||||
typeof(ImGuiDevToolsFrameBackend)));
|
||||
}
|
||||
|
||||
|
|
@ -221,8 +288,29 @@ public sealed class DevToolsFramePresenterTests
|
|||
public List<(string Label, string? Shortcut, bool Selected)> MenuItems { get; } = [];
|
||||
public List<PanelContext> Contexts { get; } = [];
|
||||
public List<LayoutCallWithCondition> Layouts { get; } = [];
|
||||
public Exception? BeginFailure { get; set; }
|
||||
public Exception? DrawFailure { get; set; }
|
||||
|
||||
public void BeginFrame(float deltaSeconds) => calls.Add($"begin:{deltaSeconds}");
|
||||
public bool ControllerFrameOpen { get; private set; }
|
||||
|
||||
public void BeginFrame(float deltaSeconds)
|
||||
{
|
||||
calls.Add($"begin:{deltaSeconds}");
|
||||
ControllerFrameOpen = true;
|
||||
if (BeginFailure is not null)
|
||||
throw BeginFailure;
|
||||
}
|
||||
|
||||
public void AbortFrame()
|
||||
{
|
||||
// Silk ImGuiController.Render is idempotent. It closes an active
|
||||
// controller frame, but is a no-op if Render already cleared its
|
||||
// private _frameBegun flag before a GL upload failure.
|
||||
if (!ControllerFrameOpen)
|
||||
return;
|
||||
ControllerFrameOpen = false;
|
||||
calls.Add("abort");
|
||||
}
|
||||
|
||||
public bool BeginMainMenuBar()
|
||||
{
|
||||
|
|
@ -254,7 +342,14 @@ public sealed class DevToolsFramePresenterTests
|
|||
calls.Add($"panels:{context.DeltaSeconds}");
|
||||
}
|
||||
|
||||
public void RenderDrawData() => calls.Add("draw-data");
|
||||
public void RenderDrawData()
|
||||
{
|
||||
Assert.True(ControllerFrameOpen);
|
||||
ControllerFrameOpen = false;
|
||||
calls.Add("draw-data");
|
||||
if (DrawFailure is not null)
|
||||
throw DrawFailure;
|
||||
}
|
||||
|
||||
public void SetWindowLayout(
|
||||
string title,
|
||||
|
|
|
|||
|
|
@ -7,32 +7,33 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
[Fact]
|
||||
public void ProductionRender_PreservesPrivatePresentationAndCaptureOrder()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
string presentation = Source("PrivatePresentationRenderer.cs");
|
||||
string orchestrator = Source("RenderFrameOrchestrator.cs");
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_localPlayerTeleport?.DrawPortalViewport(",
|
||||
"_paperdollFramePresenter?.Render();",
|
||||
"_retailUiRuntime.Tick(deltaSeconds);",
|
||||
"_devToolsFramePresenter?.Render(",
|
||||
"_frameScreenshots?.CapturePending() == true;",
|
||||
"_renderFrameDiagnostics?.Publish(");
|
||||
presentation,
|
||||
"_portal.Draw(",
|
||||
"_paperdoll?.Render();",
|
||||
"_gameplayUi?.Render(",
|
||||
"_devTools?.Render(",
|
||||
"_screenshots?.CapturePending(");
|
||||
AssertAppearsInOrder(
|
||||
orchestrator,
|
||||
"_world.Render(input);",
|
||||
"_presentation.Render(input, world);",
|
||||
"_diagnostics.Publish(input, outcome);");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionRender_Prepares_resources_then_devtools_weather_and_world()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
string source = Source("RenderFramePreparationController.cs");
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_renderFrameResources!.Prepare(frameInput);",
|
||||
"_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);",
|
||||
"_renderWeatherFrame!.Tick(deltaSeconds);",
|
||||
"_worldSceneRenderer!.Render(frameInput);");
|
||||
Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source);
|
||||
Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source);
|
||||
Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source);
|
||||
"_resources.Prepare(input);",
|
||||
"_devTools?.BeginFrame((float)input.DeltaSeconds);",
|
||||
"_weather.Tick(input.DeltaSeconds);");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -46,7 +47,9 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
"_portalTunnel = null;",
|
||||
"_localPlayerTeleportSink.Bind(_localPlayerTeleport);",
|
||||
"new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(",
|
||||
"new AcDream.App.Rendering.RenderFrameResourceController(");
|
||||
"new AcDream.App.Rendering.RenderFrameResourceController(",
|
||||
"new AcDream.App.Rendering.PrivatePresentationRenderer(",
|
||||
"new AcDream.App.Rendering.RenderFrameOrchestrator(");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -78,6 +81,13 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
"ApplyFramePacingPreference",
|
||||
"RefreshActiveMonitorFramePacing",
|
||||
"private void OnFrameRendered(",
|
||||
"private AcDream.App.Rendering.WorldRenderDiagnostics? _worldRenderDiagnostics",
|
||||
"private AcDream.App.Rendering.WorldRenderFrameBuilder?",
|
||||
"private AcDream.App.Rendering.SkyPesFrameController?",
|
||||
"private AcDream.App.Rendering.RetailPViewRenderer?",
|
||||
"private AcDream.App.Rendering.RetailPViewPassExecutor?",
|
||||
"private AcDream.App.Rendering.RetailPViewCellSource?",
|
||||
"private AcDream.App.Rendering.TerrainDrawDiagnosticsController?",
|
||||
];
|
||||
foreach (string identifier in removed)
|
||||
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
|
||||
|
|
@ -86,6 +96,8 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
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 AcDream.App.Rendering.PrivatePresentationRenderer(", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source);
|
||||
Assert.Contains("new DisplayFramePacingController(", source);
|
||||
Assert.Contains("_inputCapture.WantCaptureMouse", source);
|
||||
}
|
||||
|
|
@ -99,40 +111,70 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
source,
|
||||
"new ResourceShutdownStage(\"submitted GPU work\"",
|
||||
"new ResourceShutdownStage(\"render frontends\"",
|
||||
"new(\"frame composition\"",
|
||||
"new(\"developer tools\"",
|
||||
"_devToolsBackend?.Dispose()",
|
||||
"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 ResourceShutdownStage(\"frame borrowers\"",
|
||||
"_retailPViewPassExecutor = null;",
|
||||
"_retailPViewCells = null;",
|
||||
"_terrainDrawDiagnostics = null;",
|
||||
"_retailPViewRenderer = null;",
|
||||
"_renderFrameOrchestrator = null;",
|
||||
"new ResourceShutdownStage(\"session dependents\"");
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"new(\"frame pacing\", _displayFramePacing.Dispose)",
|
||||
"new(\"frame profiler\", _frameProfiler.Dispose)");
|
||||
Assert.DoesNotContain("_devToolsBackend?.Dispose()", source);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_renderFrameOrchestrator = null;",
|
||||
"_devToolsBackend?.Dispose()",
|
||||
"new ResourceShutdownStage(\"input\"",
|
||||
"_input?.Dispose();",
|
||||
"new ResourceShutdownStage(\"OpenGL context\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionOutcome_UsesObservedWorldAndScreenshotFacts()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
string presentation = Source("PrivatePresentationRenderer.cs");
|
||||
string orchestrator = Source("RenderFrameOrchestrator.cs");
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_worldSceneRenderer!.Render(frameInput);",
|
||||
"worldOutcome.NormalWorldDrawn;",
|
||||
"bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;",
|
||||
"normalWorldDrawn),",
|
||||
"screenshotCaptured)));");
|
||||
presentation,
|
||||
"_foundation.Foundation.PortalViewportVisible;",
|
||||
"_portal.Draw(",
|
||||
"bool screenshotCaptured = _screenshots?.CapturePending(",
|
||||
"portalViewportVisible,",
|
||||
"screenshotCaptured);");
|
||||
AssertAppearsInOrder(
|
||||
orchestrator,
|
||||
"WorldRenderFrameOutcome world = _world.Render(input);",
|
||||
"_presentation.Render(input, world);",
|
||||
"new RenderFrameOutcome(world, presentation);",
|
||||
"_diagnostics.Publish(input, outcome);");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameWindow_OnRenderIsOneImmutableOrchestratorHandoff()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
int start = source.IndexOf(
|
||||
"private void OnRender(double deltaSeconds)",
|
||||
StringComparison.Ordinal);
|
||||
int end = source.IndexOf(
|
||||
"private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot",
|
||||
start,
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(start >= 0 && end > start);
|
||||
string body = source[start..end];
|
||||
|
||||
Assert.Equal(1, CountOccurrences(body, "_renderFrameOrchestrator!.Render("));
|
||||
Assert.DoesNotContain("_gpuFrameFlights", body);
|
||||
Assert.DoesNotContain("_worldScene", body);
|
||||
Assert.DoesNotContain("_devToolsFramePresenter", body);
|
||||
Assert.DoesNotContain("_retailUiRuntime", body);
|
||||
Assert.DoesNotContain("_frameScreenshots", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -168,6 +210,25 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
"Rendering",
|
||||
"GameWindow.cs"));
|
||||
|
||||
private static string Source(string fileName) => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
fileName));
|
||||
|
||||
private static int CountOccurrences(string source, string value)
|
||||
{
|
||||
int count = 0;
|
||||
int cursor = 0;
|
||||
while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
cursor += value.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] needles)
|
||||
{
|
||||
int cursor = -1;
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ public sealed class PaperdollFramePresenterTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void PresenterAndProductionHelpersHaveNoWindowOrDelegateBackReference()
|
||||
public void PresenterAndProductionHelpersHaveNoDirectWindowOrDelegateBackReference()
|
||||
{
|
||||
Type[] owners =
|
||||
[
|
||||
|
|
|
|||
|
|
@ -0,0 +1,255 @@
|
|||
using System.Reflection;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class PrivatePresentationRendererTests
|
||||
{
|
||||
private static readonly RenderFrameInput Input = new(0.125d, 1600, 900);
|
||||
private static readonly WorldRenderFrameOutcome World = new(3, 12, true);
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, true)]
|
||||
public void Render_PreservesPortalPaperdollGameplayDevToolsScreenshotOrder(
|
||||
bool portalVisible,
|
||||
bool screenshotCaptured)
|
||||
{
|
||||
List<string> calls = [];
|
||||
var portal = new Portal(calls);
|
||||
var paperdoll = new Paperdoll(calls);
|
||||
var gameplay = new GameplayUi(calls);
|
||||
var devTools = new DevTools(calls);
|
||||
var screenshots = new Screenshots(calls, screenshotCaptured);
|
||||
var presentation = new PrivatePresentationRenderer(
|
||||
portal,
|
||||
new FoundationSource(portalVisible),
|
||||
paperdoll,
|
||||
gameplay,
|
||||
devTools,
|
||||
screenshots);
|
||||
|
||||
PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World);
|
||||
|
||||
Assert.Equal(
|
||||
["portal", "paperdoll", "gameplay-ui", "devtools-render", "screenshot"],
|
||||
calls);
|
||||
Assert.Equal(new PrivatePresentationFrameOutcome(
|
||||
portalVisible,
|
||||
screenshotCaptured), outcome);
|
||||
Assert.Equal((Input.ViewportWidth, Input.ViewportHeight), portal.Size);
|
||||
Assert.Equal((Input.ViewportWidth, Input.ViewportHeight), screenshots.Size);
|
||||
Assert.Equal(Input, gameplay.Input);
|
||||
Assert.Equal(Input, devTools.Input);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_OptionalLayersMayBeAbsentButPortalOutcomeRemainsObserved()
|
||||
{
|
||||
List<string> calls = [];
|
||||
var presentation = new PrivatePresentationRenderer(
|
||||
new Portal(calls),
|
||||
new FoundationSource(portalVisible: true),
|
||||
paperdoll: null,
|
||||
gameplayUi: null,
|
||||
devTools: null,
|
||||
screenshots: null);
|
||||
|
||||
PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World);
|
||||
|
||||
Assert.Equal(["portal"], calls);
|
||||
Assert.True(outcome.PortalViewportDrawn);
|
||||
Assert.False(outcome.ScreenshotCaptured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ReportsThePreparedPortalSnapshotWhenDrawMutatesTheSource()
|
||||
{
|
||||
List<string> calls = [];
|
||||
var foundation = new MutableFoundationSource(portalVisible: true);
|
||||
var presentation = new PrivatePresentationRenderer(
|
||||
new MutatingPortal(calls, () => foundation.PortalVisible = false),
|
||||
foundation,
|
||||
paperdoll: null,
|
||||
gameplayUi: null,
|
||||
devTools: null,
|
||||
screenshots: null);
|
||||
|
||||
PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World);
|
||||
|
||||
Assert.Equal(["portal"], calls);
|
||||
Assert.False(foundation.PortalVisible);
|
||||
Assert.True(outcome.PortalViewportDrawn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_RequiresTheCanonicalPortalOwner()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new PrivatePresentationRenderer(null!, new FoundationSource(false), null, null, null, null));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new PrivatePresentationRenderer(new Portal([]), null!, null, null, null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractedPresentationOwner_RetainsNoDirectWindowOrDelegate()
|
||||
{
|
||||
FieldInfo[] fields = typeof(PrivatePresentationRenderer).GetFields(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
|
||||
Assert.DoesNotContain(
|
||||
fields,
|
||||
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedUiAdapter_UsesTheDocumentedRuntimeBindingBoundary()
|
||||
{
|
||||
FieldInfo[] adapterFields = typeof(RetainedGameplayUiFrame).GetFields(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
typeof(AcDream.App.UI.RetailUiRuntime),
|
||||
typeof(Silk.NET.Input.IInputContext),
|
||||
],
|
||||
adapterFields.Select(field => field.FieldType).OrderBy(type => type.FullName));
|
||||
Assert.DoesNotContain(
|
||||
adapterFields,
|
||||
field => field.FieldType == typeof(GameWindow)
|
||||
|| typeof(Delegate).IsAssignableFrom(field.FieldType));
|
||||
|
||||
FieldInfo bindings = typeof(AcDream.App.UI.RetailUiRuntime).GetField(
|
||||
"_bindings",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
Assert.Equal(typeof(AcDream.App.UI.RetailUiRuntimeBindings), bindings.FieldType);
|
||||
Assert.Contains(
|
||||
typeof(AcDream.App.UI.RetailUiRuntimeBindings).GetProperties(),
|
||||
property => property.PropertyType.Name.EndsWith("Bindings", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KnownSharedOwnerPaths_ArePinnedWithoutClaimingRecursivePurity()
|
||||
{
|
||||
static Type[] FieldTypes(Type owner) => owner.GetFields(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.Select(field => field.FieldType)
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(
|
||||
typeof(AcDream.App.UI.RetailUiRuntime),
|
||||
FieldTypes(typeof(RetainedGameplayUiFrame)));
|
||||
Assert.Contains(
|
||||
typeof(AcDream.App.UI.UiViewport),
|
||||
FieldTypes(typeof(RetailPaperdollFrameView)));
|
||||
Assert.Contains(
|
||||
typeof(AcDream.App.UI.UiElement),
|
||||
FieldTypes(typeof(PaperdollInventoryVisibility)));
|
||||
Assert.Contains(
|
||||
typeof(AcDream.UI.Abstractions.Panels.Debug.DebugVM),
|
||||
FieldTypes(typeof(DevToolsPanelSet)));
|
||||
Assert.Contains(
|
||||
typeof(AcDream.UI.Abstractions.Panels.Settings.SettingsVM),
|
||||
FieldTypes(typeof(RuntimeWorldFrameSettingsPreview)));
|
||||
Assert.Contains(
|
||||
typeof(AcDream.App.Streaming.LocalPlayerTeleportController),
|
||||
FieldTypes(typeof(LocalPlayerPortalViewport)));
|
||||
Assert.Contains(
|
||||
typeof(AcDream.App.Streaming.ILocalPlayerTeleportInputLifetime),
|
||||
FieldTypes(typeof(AcDream.App.Streaming.LocalPlayerTeleportController)));
|
||||
|
||||
Assert.DoesNotContain(
|
||||
FieldTypes(typeof(PrivateFrameScreenshot)),
|
||||
type => typeof(Delegate).IsAssignableFrom(type));
|
||||
Assert.Null(typeof(AcDream.App.Diagnostics.FrameScreenshotController).GetField(
|
||||
"_getSize",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic));
|
||||
}
|
||||
|
||||
private sealed class Portal(List<string> calls) : IPrivatePortalViewport
|
||||
{
|
||||
public (int Width, int Height) Size { get; private set; }
|
||||
|
||||
public void Draw(int width, int height)
|
||||
{
|
||||
Size = (width, height);
|
||||
calls.Add("portal");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FoundationSource(bool portalVisible) : IRenderFrameFoundationSource
|
||||
{
|
||||
public RenderFrameFoundation Foundation { get; } = new(
|
||||
portalVisible,
|
||||
default,
|
||||
default);
|
||||
}
|
||||
|
||||
private sealed class MutableFoundationSource(bool portalVisible) :
|
||||
IRenderFrameFoundationSource
|
||||
{
|
||||
public bool PortalVisible { get; set; } = portalVisible;
|
||||
|
||||
public RenderFrameFoundation Foundation => new(
|
||||
PortalVisible,
|
||||
default,
|
||||
default);
|
||||
}
|
||||
|
||||
private sealed class MutatingPortal(List<string> calls, Action mutate) :
|
||||
IPrivatePortalViewport
|
||||
{
|
||||
public void Draw(int width, int height)
|
||||
{
|
||||
calls.Add("portal");
|
||||
mutate();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Paperdoll(List<string> calls) : IPrivatePaperdollFrame
|
||||
{
|
||||
public void Render() => calls.Add("paperdoll");
|
||||
}
|
||||
|
||||
private sealed class GameplayUi(List<string> calls) : IRetainedGameplayUiFrame
|
||||
{
|
||||
public RenderFrameInput Input { get; private set; }
|
||||
|
||||
public void Render(double deltaSeconds, int width, int height)
|
||||
{
|
||||
Input = new RenderFrameInput(deltaSeconds, width, height);
|
||||
calls.Add("gameplay-ui");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DevTools(List<string> calls) : IDevToolsFrameLifecycle
|
||||
{
|
||||
public RenderFrameInput Input { get; private set; }
|
||||
|
||||
public void BeginFrame(float deltaSeconds) => calls.Add("devtools-begin");
|
||||
|
||||
public void AbortFrame() => calls.Add("devtools-abort");
|
||||
|
||||
public void Render(double deltaSeconds, int viewportWidth, int viewportHeight)
|
||||
{
|
||||
Input = new RenderFrameInput(deltaSeconds, viewportWidth, viewportHeight);
|
||||
calls.Add("devtools-render");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Screenshots(List<string> calls, bool result) :
|
||||
IPrivateFrameScreenshot
|
||||
{
|
||||
public (int Width, int Height) Size { get; private set; }
|
||||
|
||||
public bool CapturePending(int width, int height)
|
||||
{
|
||||
Size = (width, height);
|
||||
calls.Add("screenshot");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -148,21 +148,77 @@ public sealed class RenderFrameOrchestratorTests
|
|||
phases.ObservedInputs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderAndRecoveryFailure_AreAggregatedBeforeGpuClose()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var renderFailure = new InvalidOperationException("render");
|
||||
var recoveryFailure = new InvalidOperationException("recovery");
|
||||
var phases = new RecordingPhases(calls)
|
||||
{
|
||||
FailurePoint = "world",
|
||||
Failure = renderFailure,
|
||||
RecoveryFailure = recoveryFailure,
|
||||
};
|
||||
|
||||
AggregateException actual = Assert.Throws<AggregateException>(
|
||||
() => Create(phases).Render(Input));
|
||||
|
||||
Assert.Equal(2, actual.InnerExceptions.Count);
|
||||
Assert.Same(renderFailure, actual.InnerExceptions[0]);
|
||||
Assert.Same(recoveryFailure, actual.InnerExceptions[1]);
|
||||
Assert.Equal(
|
||||
["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"],
|
||||
calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderRecoveryAndCloseFailures_AllRemainObservableInCausalOrder()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var renderFailure = new InvalidOperationException("render");
|
||||
var recoveryFailure = new InvalidOperationException("recovery");
|
||||
var closeFailure = new InvalidOperationException("close");
|
||||
var phases = new RecordingPhases(calls)
|
||||
{
|
||||
FailurePoint = "presentation",
|
||||
Failure = renderFailure,
|
||||
RecoveryFailure = recoveryFailure,
|
||||
CloseFailure = closeFailure,
|
||||
};
|
||||
|
||||
AggregateException actual = Assert.Throws<AggregateException>(
|
||||
() => Create(phases).Render(Input));
|
||||
|
||||
Assert.Equal(3, actual.InnerExceptions.Count);
|
||||
Assert.Same(renderFailure, actual.InnerExceptions[0]);
|
||||
Assert.Same(recoveryFailure, actual.InnerExceptions[1]);
|
||||
Assert.Same(closeFailure, actual.InnerExceptions[2]);
|
||||
Assert.Equal(
|
||||
[
|
||||
"gpu-begin", "resources", "world", "presentation",
|
||||
"recovery-abort", "gpu-end",
|
||||
],
|
||||
calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_RejectsEveryMissingRequiredOwner()
|
||||
{
|
||||
var phases = new RecordingPhases([]);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
null!, phases, phases, phases, phases));
|
||||
null!, phases, phases, phases, phases, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, null!, phases, phases, phases));
|
||||
phases, null!, phases, phases, phases, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, phases, null!, phases, phases));
|
||||
phases, phases, null!, phases, phases, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, phases, phases, null!, phases));
|
||||
phases, phases, phases, null!, phases, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, phases, phases, phases, null!));
|
||||
phases, phases, phases, phases, null!, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, phases, phases, phases, phases, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -175,6 +231,7 @@ public sealed class RenderFrameOrchestratorTests
|
|||
typeof(IWorldSceneFramePhase),
|
||||
typeof(IPrivatePresentationFramePhase),
|
||||
typeof(IRenderFrameDiagnosticsPhase),
|
||||
typeof(IRenderFrameFailureRecovery),
|
||||
];
|
||||
FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
|
@ -239,14 +296,17 @@ public sealed class RenderFrameOrchestratorTests
|
|||
|
||||
private static string[] ExpectedFailureCalls(string failurePoint) => failurePoint switch
|
||||
{
|
||||
"resources" => ["gpu-begin", "resources", "gpu-end"],
|
||||
"world" => ["gpu-begin", "resources", "world", "gpu-end"],
|
||||
"resources" => ["gpu-begin", "resources", "recovery-abort", "gpu-end"],
|
||||
"world" => ["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"],
|
||||
"presentation" =>
|
||||
["gpu-begin", "resources", "world", "presentation", "gpu-end"],
|
||||
[
|
||||
"gpu-begin", "resources", "world", "presentation",
|
||||
"recovery-abort", "gpu-end",
|
||||
],
|
||||
"diagnostics" =>
|
||||
[
|
||||
"gpu-begin", "resources", "world", "presentation", "diagnostics",
|
||||
"gpu-end",
|
||||
"recovery-abort", "gpu-end",
|
||||
],
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(failurePoint)),
|
||||
};
|
||||
|
|
@ -266,14 +326,15 @@ public sealed class RenderFrameOrchestratorTests
|
|||
}
|
||||
|
||||
private static RenderFrameOrchestrator Create(RecordingPhases phases) =>
|
||||
new(phases, phases, phases, phases, phases);
|
||||
new(phases, phases, phases, phases, phases, phases);
|
||||
|
||||
private sealed class RecordingPhases :
|
||||
IRenderFrameLifetime,
|
||||
IRenderFrameResourcePhase,
|
||||
IWorldSceneFramePhase,
|
||||
IPrivatePresentationFramePhase,
|
||||
IRenderFrameDiagnosticsPhase
|
||||
IRenderFrameDiagnosticsPhase,
|
||||
IRenderFrameFailureRecovery
|
||||
{
|
||||
private readonly List<string> _calls;
|
||||
|
||||
|
|
@ -285,6 +346,7 @@ public sealed class RenderFrameOrchestratorTests
|
|||
public string? FailurePoint { get; init; }
|
||||
public Exception? Failure { get; init; }
|
||||
public Exception? CloseFailure { get; init; }
|
||||
public Exception? RecoveryFailure { get; init; }
|
||||
public WorldRenderFrameOutcome World { get; init; } = new(7, 19, true);
|
||||
public PrivatePresentationFrameOutcome Presentation { get; init; } = new(false, false);
|
||||
public List<(string Phase, RenderFrameInput Input)> ObservedInputs { get; } = [];
|
||||
|
|
@ -300,6 +362,13 @@ public sealed class RenderFrameOrchestratorTests
|
|||
throw CloseFailure;
|
||||
}
|
||||
|
||||
public void AbortFrame()
|
||||
{
|
||||
_calls.Add("recovery-abort");
|
||||
if (RecoveryFailure is not null)
|
||||
throw RecoveryFailure;
|
||||
}
|
||||
|
||||
public void Prepare(RenderFrameInput input)
|
||||
{
|
||||
ObservedInputs.Add(("resources", input));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class RenderFramePreparationControllerTests
|
||||
{
|
||||
private static readonly RenderFrameInput Input = new(0.25d, 1280, 720);
|
||||
|
||||
[Fact]
|
||||
public void Prepare_PreservesResourcesThenDevToolsThenWeatherOrder()
|
||||
{
|
||||
List<string> calls = [];
|
||||
var resources = new Resources(calls);
|
||||
var devTools = new DevTools(calls);
|
||||
var weather = new Weather(calls);
|
||||
var preparation = new RenderFramePreparationController(
|
||||
resources,
|
||||
devTools,
|
||||
weather);
|
||||
|
||||
preparation.Prepare(Input);
|
||||
|
||||
Assert.Equal(["resources", "devtools-begin", "weather"], calls);
|
||||
Assert.Equal(Input, resources.Input);
|
||||
Assert.Equal((float)Input.DeltaSeconds, devTools.DeltaSeconds);
|
||||
Assert.Equal(Input.DeltaSeconds, weather.DeltaSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prepare_DevToolsAreOptionalWithoutChangingRequiredOrder()
|
||||
{
|
||||
List<string> calls = [];
|
||||
var preparation = new RenderFramePreparationController(
|
||||
new Resources(calls),
|
||||
devTools: null,
|
||||
new Weather(calls));
|
||||
|
||||
preparation.Prepare(Input);
|
||||
|
||||
Assert.Equal(["resources", "weather"], calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_RejectsMissingRequiredOwners()
|
||||
{
|
||||
var resources = new Resources([]);
|
||||
var weather = new Weather([]);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new RenderFramePreparationController(null!, null, weather));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new RenderFramePreparationController(resources, null, null!));
|
||||
}
|
||||
|
||||
private sealed class Resources(List<string> calls) : IRenderFrameResourcePhase
|
||||
{
|
||||
public RenderFrameInput Input { get; private set; }
|
||||
|
||||
public void Prepare(RenderFrameInput input)
|
||||
{
|
||||
Input = input;
|
||||
calls.Add("resources");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DevTools(List<string> calls) : IDevToolsFrameLifecycle
|
||||
{
|
||||
public float DeltaSeconds { get; private set; }
|
||||
|
||||
public void BeginFrame(float deltaSeconds)
|
||||
{
|
||||
DeltaSeconds = deltaSeconds;
|
||||
calls.Add("devtools-begin");
|
||||
}
|
||||
|
||||
public void AbortFrame() => calls.Add("devtools-abort");
|
||||
|
||||
public void Render(double deltaSeconds, int viewportWidth, int viewportHeight) =>
|
||||
calls.Add("devtools-render");
|
||||
}
|
||||
|
||||
private sealed class Weather(List<string> calls) : IRenderWeatherFramePhase
|
||||
{
|
||||
public double DeltaSeconds { get; private set; }
|
||||
|
||||
public void Tick(double deltaSeconds)
|
||||
{
|
||||
DeltaSeconds = deltaSeconds;
|
||||
calls.Add("weather");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class RenderFrameRecoveryIntegrationTests
|
||||
{
|
||||
private static readonly RenderFrameInput Input = new(1d / 60d, 1280, 720);
|
||||
|
||||
[Theory]
|
||||
[InlineData("devtools-begin")]
|
||||
[InlineData("weather")]
|
||||
[InlineData("world")]
|
||||
[InlineData("portal")]
|
||||
[InlineData("paperdoll")]
|
||||
[InlineData("gameplay-ui")]
|
||||
[InlineData("devtools-render")]
|
||||
public void DownstreamFailure_AbortsDevToolsBeforeGpuClose_ThenNextFrameSucceeds(
|
||||
string failurePoint)
|
||||
{
|
||||
var failure = new FailureSwitch { Point = failurePoint };
|
||||
var gpu = new GpuLifetime(failure);
|
||||
var devTools = new DevTools(failure);
|
||||
var preparation = new RenderFramePreparationController(
|
||||
new Resources(),
|
||||
devTools,
|
||||
new Weather(failure));
|
||||
var presentation = new PrivatePresentationRenderer(
|
||||
new Portal(failure),
|
||||
new FoundationSource(),
|
||||
new Paperdoll(failure),
|
||||
new GameplayUi(failure),
|
||||
devTools,
|
||||
new Screenshots());
|
||||
var orchestrator = new RenderFrameOrchestrator(
|
||||
gpu,
|
||||
preparation,
|
||||
new World(failure),
|
||||
presentation,
|
||||
new Diagnostics(),
|
||||
devTools);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => orchestrator.Render(Input));
|
||||
|
||||
Assert.False(devTools.IsOpen);
|
||||
Assert.Equal(1, devTools.AbortCount);
|
||||
Assert.Equal(1, gpu.EndCount);
|
||||
Assert.True(devTools.AbortSequence < gpu.LastEndSequence);
|
||||
|
||||
failure.Point = null;
|
||||
RenderFrameOutcome outcome = orchestrator.Render(Input);
|
||||
|
||||
Assert.False(devTools.IsOpen);
|
||||
Assert.Equal(2, devTools.BeginCount);
|
||||
Assert.Equal(2, gpu.EndCount);
|
||||
Assert.True(outcome.World.NormalWorldDrawn);
|
||||
}
|
||||
|
||||
private sealed class FailureSwitch
|
||||
{
|
||||
private int _sequence;
|
||||
|
||||
public string? Point { get; set; }
|
||||
|
||||
public int NextSequence() => ++_sequence;
|
||||
|
||||
public void ThrowIf(string point)
|
||||
{
|
||||
if (Point == point)
|
||||
throw new InvalidOperationException(point);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class GpuLifetime(FailureSwitch failure) : IRenderFrameLifetime
|
||||
{
|
||||
public int EndCount { get; private set; }
|
||||
public int LastEndSequence { get; private set; }
|
||||
|
||||
public void BeginFrame()
|
||||
{
|
||||
}
|
||||
|
||||
public void EndFrame()
|
||||
{
|
||||
EndCount++;
|
||||
LastEndSequence = failure.NextSequence();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DevTools(FailureSwitch failure) : IDevToolsFrameLifecycle
|
||||
{
|
||||
public bool IsOpen { get; private set; }
|
||||
public int BeginCount { get; private set; }
|
||||
public int AbortCount { get; private set; }
|
||||
public int AbortSequence { get; private set; }
|
||||
|
||||
public void BeginFrame(float deltaSeconds)
|
||||
{
|
||||
Assert.False(IsOpen);
|
||||
IsOpen = true;
|
||||
BeginCount++;
|
||||
failure.ThrowIf("devtools-begin");
|
||||
}
|
||||
|
||||
public void Render(double deltaSeconds, int viewportWidth, int viewportHeight)
|
||||
{
|
||||
Assert.True(IsOpen);
|
||||
failure.ThrowIf("devtools-render");
|
||||
IsOpen = false;
|
||||
}
|
||||
|
||||
public void AbortFrame()
|
||||
{
|
||||
if (!IsOpen)
|
||||
return;
|
||||
|
||||
AbortCount++;
|
||||
AbortSequence = failure.NextSequence();
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Resources : IRenderFrameResourcePhase
|
||||
{
|
||||
public void Prepare(RenderFrameInput input)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Weather(FailureSwitch failure) : IRenderWeatherFramePhase
|
||||
{
|
||||
public void Tick(double deltaSeconds) => failure.ThrowIf("weather");
|
||||
}
|
||||
|
||||
private sealed class World(FailureSwitch failure) : IWorldSceneFramePhase
|
||||
{
|
||||
public WorldRenderFrameOutcome Render(RenderFrameInput input)
|
||||
{
|
||||
failure.ThrowIf("world");
|
||||
return new WorldRenderFrameOutcome(1, 1, NormalWorldDrawn: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Portal(FailureSwitch failure) : IPrivatePortalViewport
|
||||
{
|
||||
public void Draw(int width, int height) => failure.ThrowIf("portal");
|
||||
}
|
||||
|
||||
private sealed class Paperdoll(FailureSwitch failure) : IPrivatePaperdollFrame
|
||||
{
|
||||
public void Render() => failure.ThrowIf("paperdoll");
|
||||
}
|
||||
|
||||
private sealed class GameplayUi(FailureSwitch failure) : IRetainedGameplayUiFrame
|
||||
{
|
||||
public void Render(double deltaSeconds, int width, int height) =>
|
||||
failure.ThrowIf("gameplay-ui");
|
||||
}
|
||||
|
||||
private sealed class FoundationSource : IRenderFrameFoundationSource
|
||||
{
|
||||
public RenderFrameFoundation Foundation { get; } = new(
|
||||
PortalViewportVisible: false,
|
||||
default,
|
||||
default);
|
||||
}
|
||||
|
||||
private sealed class Screenshots : IPrivateFrameScreenshot
|
||||
{
|
||||
public bool CapturePending(int width, int height) => false;
|
||||
}
|
||||
|
||||
private sealed class Diagnostics : IRenderFrameDiagnosticsPhase
|
||||
{
|
||||
public void Publish(RenderFrameInput input, RenderFrameOutcome outcome)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
using System.Reflection;
|
||||
using AcDream.App.Rendering;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class TextRendererFailureSafetyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Flush_CompilesCompleteGlStateScopeAsFinallyAroundBothDrawLayers()
|
||||
{
|
||||
MethodInfo flush = typeof(TextRenderer).GetMethod(nameof(TextRenderer.Flush))!;
|
||||
MethodBody body = flush.GetMethodBody()!;
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"TextRenderer.cs"));
|
||||
|
||||
Assert.Contains(
|
||||
body.ExceptionHandlingClauses,
|
||||
clause => clause.Flags == ExceptionHandlingClauseOptions.Finally);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"using var stateScope = new TextRenderGlStateScope(_glState);",
|
||||
"_gl.Disable(EnableCap.Multisample);",
|
||||
"DrawLayer(_spriteSegs,",
|
||||
"DrawLayer(_overlaySpriteSegs,");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedDraw_RestoresEveryGlValueMutatedByTheTextPass()
|
||||
{
|
||||
var gl = new RecordingGlState
|
||||
{
|
||||
DepthWrite = false,
|
||||
BlendSourceRgb = BlendingFactor.DstAlpha,
|
||||
BlendDestinationRgb = BlendingFactor.OneMinusDstAlpha,
|
||||
BlendSourceAlpha = BlendingFactor.One,
|
||||
BlendDestinationAlpha = BlendingFactor.Zero,
|
||||
Program = 17,
|
||||
VertexArray = 23,
|
||||
ArrayBuffer = 31,
|
||||
ActiveUnit = TextureUnit.Texture2,
|
||||
};
|
||||
gl.SetCapability(EnableCap.DepthTest, enabled: true);
|
||||
gl.SetCapability(EnableCap.Blend, enabled: false);
|
||||
gl.SetCapability(EnableCap.CullFace, enabled: true);
|
||||
gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: true);
|
||||
gl.SetCapability(EnableCap.Multisample, enabled: false);
|
||||
gl.TextureBindings[TextureUnit.Texture0] = 41;
|
||||
gl.TextureBindings[TextureUnit.Texture2] = 43;
|
||||
StateSnapshot expected = gl.Capture();
|
||||
|
||||
Action failedDraw = () =>
|
||||
{
|
||||
using var stateScope = new TextRenderGlStateScope(gl);
|
||||
gl.SetCapability(EnableCap.DepthTest, enabled: false);
|
||||
gl.SetCapability(EnableCap.Blend, enabled: true);
|
||||
gl.SetCapability(EnableCap.CullFace, enabled: false);
|
||||
gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false);
|
||||
gl.SetCapability(EnableCap.Multisample, enabled: true);
|
||||
gl.DepthMask(true);
|
||||
gl.BlendFuncSeparate(
|
||||
BlendingFactor.SrcAlpha,
|
||||
BlendingFactor.OneMinusSrcAlpha,
|
||||
BlendingFactor.SrcAlpha,
|
||||
BlendingFactor.OneMinusSrcAlpha);
|
||||
gl.UseProgram(101);
|
||||
gl.BindVertexArray(103);
|
||||
gl.BindBuffer(BufferTargetARB.ArrayBuffer, 107);
|
||||
gl.ActiveTexture(TextureUnit.Texture0);
|
||||
gl.BindTexture(TextureTarget.Texture2D, 109);
|
||||
throw new InvalidOperationException("draw upload");
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(failedDraw);
|
||||
|
||||
Assert.Equal(expected, gl.Capture());
|
||||
}
|
||||
|
||||
private readonly record struct StateSnapshot(
|
||||
bool DepthTest,
|
||||
bool Blend,
|
||||
bool Cull,
|
||||
bool AlphaToCoverage,
|
||||
bool Multisample,
|
||||
bool DepthWrite,
|
||||
BlendingFactor BlendSourceRgb,
|
||||
BlendingFactor BlendDestinationRgb,
|
||||
BlendingFactor BlendSourceAlpha,
|
||||
BlendingFactor BlendDestinationAlpha,
|
||||
uint Program,
|
||||
uint VertexArray,
|
||||
uint ArrayBuffer,
|
||||
TextureUnit ActiveUnit,
|
||||
uint Texture0,
|
||||
uint Texture2);
|
||||
|
||||
private sealed class RecordingGlState : ITextRenderGlStateApi
|
||||
{
|
||||
private readonly Dictionary<EnableCap, bool> _capabilities = [];
|
||||
|
||||
public bool DepthWrite { get; set; }
|
||||
public BlendingFactor BlendSourceRgb { get; set; }
|
||||
public BlendingFactor BlendDestinationRgb { get; set; }
|
||||
public BlendingFactor BlendSourceAlpha { get; set; }
|
||||
public BlendingFactor BlendDestinationAlpha { get; set; }
|
||||
public uint Program { get; set; }
|
||||
public uint VertexArray { get; set; }
|
||||
public uint ArrayBuffer { get; set; }
|
||||
public TextureUnit ActiveUnit { get; set; }
|
||||
public Dictionary<TextureUnit, uint> TextureBindings { get; } = [];
|
||||
|
||||
public StateSnapshot Capture() => new(
|
||||
IsEnabled(EnableCap.DepthTest),
|
||||
IsEnabled(EnableCap.Blend),
|
||||
IsEnabled(EnableCap.CullFace),
|
||||
IsEnabled(EnableCap.SampleAlphaToCoverage),
|
||||
IsEnabled(EnableCap.Multisample),
|
||||
DepthWrite,
|
||||
BlendSourceRgb,
|
||||
BlendDestinationRgb,
|
||||
BlendSourceAlpha,
|
||||
BlendDestinationAlpha,
|
||||
Program,
|
||||
VertexArray,
|
||||
ArrayBuffer,
|
||||
ActiveUnit,
|
||||
TextureBindings.GetValueOrDefault(TextureUnit.Texture0),
|
||||
TextureBindings.GetValueOrDefault(TextureUnit.Texture2));
|
||||
|
||||
public bool IsEnabled(EnableCap capability) =>
|
||||
_capabilities.GetValueOrDefault(capability);
|
||||
|
||||
public int GetInteger(GetPName parameter) => parameter switch
|
||||
{
|
||||
GetPName.BlendSrcRgb => (int)BlendSourceRgb,
|
||||
GetPName.BlendDstRgb => (int)BlendDestinationRgb,
|
||||
GetPName.BlendSrcAlpha => (int)BlendSourceAlpha,
|
||||
GetPName.BlendDstAlpha => (int)BlendDestinationAlpha,
|
||||
GetPName.CurrentProgram => (int)Program,
|
||||
GetPName.VertexArrayBinding => (int)VertexArray,
|
||||
GetPName.ArrayBufferBinding => (int)ArrayBuffer,
|
||||
GetPName.ActiveTexture => (int)ActiveUnit,
|
||||
GetPName.TextureBinding2D =>
|
||||
(int)TextureBindings.GetValueOrDefault(ActiveUnit),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(parameter)),
|
||||
};
|
||||
|
||||
public bool GetBoolean(GetPName parameter) =>
|
||||
parameter == GetPName.DepthWritemask
|
||||
? DepthWrite
|
||||
: throw new ArgumentOutOfRangeException(nameof(parameter));
|
||||
|
||||
public void SetCapability(EnableCap capability, bool enabled) =>
|
||||
_capabilities[capability] = enabled;
|
||||
|
||||
public void DepthMask(bool enabled) => DepthWrite = enabled;
|
||||
|
||||
public void BlendFuncSeparate(
|
||||
BlendingFactor sourceRgb,
|
||||
BlendingFactor destinationRgb,
|
||||
BlendingFactor sourceAlpha,
|
||||
BlendingFactor destinationAlpha)
|
||||
{
|
||||
BlendSourceRgb = sourceRgb;
|
||||
BlendDestinationRgb = destinationRgb;
|
||||
BlendSourceAlpha = sourceAlpha;
|
||||
BlendDestinationAlpha = destinationAlpha;
|
||||
}
|
||||
|
||||
public void UseProgram(uint program) => Program = program;
|
||||
|
||||
public void BindVertexArray(uint vertexArray) => VertexArray = vertexArray;
|
||||
|
||||
public void BindBuffer(BufferTargetARB target, uint buffer)
|
||||
{
|
||||
Assert.Equal(BufferTargetARB.ArrayBuffer, target);
|
||||
ArrayBuffer = buffer;
|
||||
}
|
||||
|
||||
public void ActiveTexture(TextureUnit unit) => ActiveUnit = unit;
|
||||
|
||||
public void BindTexture(TextureTarget target, uint texture)
|
||||
{
|
||||
Assert.Equal(TextureTarget.Texture2D, target);
|
||||
TextureBindings[ActiveUnit] = texture;
|
||||
}
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -325,7 +325,7 @@ public sealed class WorldRenderFrameBuilderTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void World_scene_uses_the_typed_builder_and_game_window_releases_it_before_render_owners()
|
||||
public void World_scene_uses_the_typed_builder_and_orchestrator_owns_its_local_composition()
|
||||
{
|
||||
string gameWindow = GameWindowSource();
|
||||
string worldScene = File.ReadAllText(Path.Combine(
|
||||
|
|
@ -341,6 +341,14 @@ public sealed class WorldRenderFrameBuilderTests
|
|||
Assert.DoesNotContain("private void UpdateSunFromSky(", gameWindow, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private void UpdateSkyPes(", gameWindow, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private static float ParseEnvFloat(", gameWindow, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"private AcDream.App.Rendering.WorldRenderFrameBuilder?",
|
||||
gameWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"private AcDream.App.Rendering.SkyPesFrameController?",
|
||||
gameWindow,
|
||||
StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
worldScene,
|
||||
"_alpha.BeginFrame();",
|
||||
|
|
@ -350,9 +358,10 @@ public sealed class WorldRenderFrameBuilderTests
|
|||
"NormalWorldDrawn: true");
|
||||
AssertAppearsInOrder(
|
||||
gameWindow,
|
||||
"_worldSceneRenderer = null;",
|
||||
"_worldRenderFrameBuilder = null;",
|
||||
"_skyPesFrame = null;",
|
||||
"var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(",
|
||||
"var worldRenderFrameBuilder =",
|
||||
"new AcDream.App.Rendering.RenderFrameOrchestrator(",
|
||||
"_renderFrameOrchestrator = null;",
|
||||
"new(\"equipped children\"",
|
||||
"new(\"effect network state\"",
|
||||
"new(\"audio\"",
|
||||
|
|
|
|||
|
|
@ -283,7 +283,8 @@ public sealed class WorldSceneRendererTests
|
|||
"GameWindow.cs"));
|
||||
|
||||
Assert.Contains("new AcDream.App.Rendering.WorldSceneRenderer(", source);
|
||||
Assert.Equal(1, CountOccurrences(source, "_worldSceneRenderer!.Render(frameInput);"));
|
||||
Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source);
|
||||
Assert.DoesNotContain("_worldSceneRenderer", source);
|
||||
Assert.DoesNotContain("_retailPViewRenderer.DrawInside(", source);
|
||||
Assert.DoesNotContain("_retailAlphaQueue.BeginFrame();", source);
|
||||
Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue