refactor(render): extract world scene frame owner

Move fallback and PView world drawing, shared alpha, particles, visibility, selection, and diagnostics behind focused frame owners. Make exceptional frames failure-atomic by restoring the shared GL baseline and preserving primary alpha failures through cleanup.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 07:22:09 +02:00
parent 85239fb373
commit 28e1cf8029
25 changed files with 2679 additions and 844 deletions

View file

@ -82,7 +82,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) | | AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
| AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw``DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 | | AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw``DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 |
| AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 | | AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 |
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameRootSource`, `WorldRenderFrame.ClipRoot`); `src/AcDream.App/Rendering/GameWindow.cs` (null-root safety draw) | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 | | AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameRootSource`, `WorldRenderFrame.ClipRoot`); `src/AcDream.App/Rendering/WorldSceneRenderer.cs` (null-root safety draw) | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
| AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams | | AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams |
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal | | AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) | | AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |

View file

@ -26,7 +26,7 @@ port, or resource-lifetime redesign.
run the dependent audio, sky, lighting, and listener preparation. run the dependent audio, sky, lighting, and listener preparation.
- [x] E — replace `RetailPViewDrawContext`'s callback bag with a typed - [x] E — replace `RetailPViewDrawContext`'s callback bag with a typed
`RetailPViewPassExecutor` and data-only inputs. `RetailPViewPassExecutor` and data-only inputs.
- [ ] F — extract `WorldSceneRenderer`, including both shared-alpha scopes, - [x] F — extract `WorldSceneRenderer`, including both shared-alpha scopes,
PView/fallback world branches, particles, debug world draw, and completion. PView/fallback world branches, particles, debug world draw, and completion.
- [ ] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one - [ ] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one
handoff, and delete obsolete frame bodies, fields, helpers, and callbacks. handoff, and delete obsolete frame bodies, fields, helpers, and callbacks.
@ -451,6 +451,22 @@ corrected-diff reviews are clean.
- return a small `WorldRenderFrameOutcome` for diagnostics/presentation; - return a small `WorldRenderFrameOutcome` for diagnostics/presentation;
- delete the world draw body and helper closure from `GameWindow`. - delete the world draw body and helper closure from `GameWindow`.
Completed 2026-07-22. `WorldSceneRenderer` now owns the normal-world
transaction, one shared-alpha frame, retail selection accumulation, PView versus
null-root fallback branching, post-world particles/weather, visibility
publication, and world diagnostics. `WorldScenePassExecutor`, focused runtime
sources, and `WorldSceneDiagnosticsController` carry the concrete GL and data
seams without retaining `GameWindow`, delegates, or borrowed PView products.
Failed frames abort PView, pass routing, particle visibility, alpha, and
selection in reverse ownership order; one shared `RenderFrameGlStateController`
restores scissor/stencil/blend/clip/mask/depth/cull/program/buffer state before
the next clear. Normal portal-depth exits now restore the same cull-off
convention, and alpha cleanup preserves the original draw failure while
attempting every source reset. `GameWindow.cs` is 4,765 raw lines, 69.7% below
the 15,723-line campaign baseline. Release build and the complete suite pass
(7,313 passed / 5 skipped). Retail, architecture, and adversarial
corrected-diff reviews are clean.
### G — final orchestration cutover ### G — final orchestration cutover
- compose concrete resource, world, private-presentation, and diagnostic owners; - compose concrete resource, world, private-presentation, and diagnostic owners;

View file

@ -65,8 +65,8 @@ public sealed class GameWindow : IDisposable
// wireframes are noisy outdoors and confuse first-time users into // wireframes are noisy outdoors and confuse first-time users into
// thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel // thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel
// → Diagnostics → "Toggle collision wires" button toggles too. // → Diagnostics → "Toggle collision wires" button toggles too.
private bool _debugCollisionVisible = false; private readonly AcDream.App.Rendering.WorldSceneDebugState
private int _debugDrawLogOnce = 0; _worldSceneDebugState = new();
// Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in // Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in
// favor of the ImGui-backed DebugPanel (see _debugVm below). The // favor of the ImGui-backed DebugPanel (see _debugVm below). The
@ -107,6 +107,8 @@ public sealed class GameWindow : IDisposable
_renderWeatherFrame; _renderWeatherFrame;
private AcDream.App.Rendering.WorldRenderFrameBuilder? private AcDream.App.Rendering.WorldRenderFrameBuilder?
_worldRenderFrameBuilder; _worldRenderFrameBuilder;
private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState;
private AcDream.App.Rendering.WorldSceneRenderer? _worldSceneRenderer;
private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame; private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame;
private ResourceShutdownTransaction? _shutdown; private ResourceShutdownTransaction? _shutdown;
private readonly DisplayFramePacingController _displayFramePacing; private readonly DisplayFramePacingController _displayFramePacing;
@ -219,9 +221,8 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer; private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer;
private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum; private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum;
// R1 (render redesign): the per-cell DrawInside flood + its per-frame entity partition. // R1 (render redesign): the per-cell DrawInside flood and concrete pass
// _interiorRenderer is constructed once both renderers exist; _interiorPartition is rebuilt // executor are borrowed by WorldSceneRenderer for each normal-world frame.
// each frame on an indoor root (null on the outdoor root).
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor; private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor;
private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells; private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells;
@ -229,7 +230,6 @@ public sealed class GameWindow : IDisposable
_terrainDrawDiagnostics; _terrainDrawDiagnostics;
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel; private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain // Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
// UBO). In U.3 a single ClipFrame.NoClip() instance is created lazily (??=) and // UBO). In U.3 a single ClipFrame.NoClip() instance is created lazily (??=) and
@ -240,11 +240,6 @@ public sealed class GameWindow : IDisposable
// U.4 replaces the NoClip() frame with one built from the portal-visibility result. // U.4 replaces the NoClip() frame with one built from the portal-visibility result.
private ClipFrame? _clipFrame; private ClipFrame? _clipFrame;
// Flat-world fallback particle scratch. PView classifications belong to
// RetailPViewPassExecutor.
private readonly HashSet<uint> _visibleSceneParticleEntityIds = [];
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
/// <summary> /// <summary>
/// Phase 6.4: per-entity animation playback state for entities whose /// Phase 6.4: per-entity animation playback state for entities whose
/// MotionTable resolved to a real cycle. The render loop ticks each /// MotionTable resolved to a real cycle. The render loop ticks each
@ -470,7 +465,6 @@ public sealed class GameWindow : IDisposable
// unconditionally installs a provider. See r12 §11 for the roller // unconditionally installs a provider. See r12 §11 for the roller
// semantics. // semantics.
private long _loadedSkyDayIndex = long.MinValue; private long _loadedSkyDayIndex = long.MinValue;
private AcDream.Core.World.DayGroupData? _activeDayGroup;
// F7 / F10 debug-cycle steps for time + weather. Initialized out of // F7 / F10 debug-cycle steps for time + weather. Initialized out of
// range of the real values so the first press hits index 0 of the // range of the real values so the first press hits index 0 of the
@ -589,31 +583,6 @@ public sealed class GameWindow : IDisposable
// single-flag reads instead of env-var lookups. True iff // single-flag reads instead of env-var lookups. True iff
// ACDREAM_LIVE=1 was set when the window came up. // ACDREAM_LIVE=1 was set when the window came up.
// Backed by RuntimeOptions.LiveMode via the _options field. // Backed by RuntimeOptions.LiveMode via the _options field.
private bool LiveModeEnabled => _options.LiveMode;
/// <summary>
/// K-fix1 (2026-04-26): true iff live mode is configured AND we have
/// NOT yet handed control to the chase camera. Gates initial
/// streaming + scene rendering so the user never sees Holtburg flash
/// before login completes — the screen stays at the sky/fog clear
/// color until the player entity has spawned + the auto-entry guard
/// has triggered the player-mode controller's auto-entry edge.
/// Offline (LiveModeEnabled false) returns false → unchanged
/// orbit-camera Holtburg view stays the foreground. Once chase mode
/// is active the property latches false for the rest of the
/// session, even if the user later toggles into fly mode (we don't
/// want to re-blank the world after they've seen it).
/// </summary>
private bool IsLiveModeWaitingForLogin =>
LiveModeEnabled
&& !_chaseModeEverEntered;
// Latches true the first time chase mode becomes active. Used by
// IsLiveModeWaitingForLogin to suppress the pre-login render gate
// for subsequent fly-mode toggles.
private bool _chaseModeEverEntered
=> _localPlayerMode.ChaseModeEverEntered;
/// <summary> /// <summary>
/// Phase 6.6/6.7: server-guid → local WorldEntity lookup so /// Phase 6.6/6.7: server-guid → local WorldEntity lookup so
/// UpdateMotion and UpdatePosition handlers can find the entity the /// UpdateMotion and UpdatePosition handlers can find the entity the
@ -769,6 +738,8 @@ public sealed class GameWindow : IDisposable
private void OnLoad() private void OnLoad()
{ {
_worldSceneSkyState ??= new AcDream.App.Rendering.WorldSceneSkyState(
WorldTime);
// Task 7: wire the physics data cache into the engine so Transition can // Task 7: wire the physics data cache into the engine so Transition can
// run narrow-phase BSP tests during FindObjCollisions. // run narrow-phase BSP tests during FindObjCollisions.
_physicsEngine.DataCache = _physicsDataCache; _physicsEngine.DataCache = _physicsDataCache;
@ -1115,7 +1086,8 @@ public sealed class GameWindow : IDisposable
_debugVmRenderFacts.DebugVmFacts.NearestObjectLabel, _debugVmRenderFacts.DebugVmFacts.NearestObjectLabel,
getColliding: () => getColliding: () =>
_debugVmRenderFacts.DebugVmFacts.Colliding, _debugVmRenderFacts.DebugVmFacts.Colliding,
getDebugWireframes: () => _debugCollisionVisible, getDebugWireframes: () =>
_worldSceneDebugState.CollisionWireframesVisible,
getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier
getMouseSensitivity: () => GetActiveSensitivity(), getMouseSensitivity: () => GetActiveSensitivity(),
@ -2398,8 +2370,8 @@ public sealed class GameWindow : IDisposable
_farRadius = System.Math.Max(sr, _farRadius); _farRadius = System.Math.Max(sr, _farRadius);
} }
Console.WriteLine( Console.WriteLine(
$"streaming: nearRadius={_nearRadius} (window={2*_nearRadius+1}x{2*_nearRadius+1})" + $"streaming: nearRadius={_nearRadius} (window={2 * _nearRadius + 1}x{2 * _nearRadius + 1})" +
$" farRadius={_farRadius} (window={2*_farRadius+1}x{2*_farRadius+1})"); $" farRadius={_farRadius} (window={2 * _farRadius + 1}x{2 * _farRadius + 1})");
// Phase A.5 T11+: the streamer now runs on a dedicated worker thread. // Phase A.5 T11+: the streamer now runs on a dedicated worker thread.
// loadLandblock acquires _datLock (T10) before touching DatCollection. // loadLandblock acquires _datLock (T10) before touching DatCollection.
@ -2805,15 +2777,18 @@ public sealed class GameWindow : IDisposable
var teleportRenderState = var teleportRenderState =
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource( new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
_localPlayerTeleport); _localPlayerTeleport);
var renderLoginState = new AcDream.App.Rendering.RenderLoginStateSource(
_options.LiveMode,
_localPlayerMode);
var renderFrameGlState = new AcDream.App.Rendering.RenderFrameGlStateController(
new AcDream.App.Rendering.SilkRenderFrameGlStateApi(_gl!));
_renderFrameLivePreparation = _renderFrameLivePreparation =
new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation( new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation(
_textureCache, _textureCache,
_wbMeshAdapter, _wbMeshAdapter,
_worldReveal, _worldReveal,
teleportRenderState, teleportRenderState,
new AcDream.App.Rendering.RenderLoginStateSource( renderLoginState,
_options.LiveMode,
_localPlayerMode),
new AcDream.App.Rendering.LiveLoginRevealCellSource( new AcDream.App.Rendering.LiveLoginRevealCellSource(
_liveEntities!, _liveEntities!,
_localPlayerIdentity), _localPlayerIdentity),
@ -2841,7 +2816,8 @@ public sealed class GameWindow : IDisposable
Weather, Weather,
teleportRenderState, teleportRenderState,
_particleVisibility, _particleVisibility,
_worldRenderDiagnostics!), _worldRenderDiagnostics!,
renderFrameGlState),
_renderFrameLivePreparation); _renderFrameLivePreparation);
_renderWeatherFrame = _renderWeatherFrame =
new AcDream.App.Rendering.RenderWeatherFrameController( new AcDream.App.Rendering.RenderWeatherFrameController(
@ -2911,6 +2887,7 @@ public sealed class GameWindow : IDisposable
_retailPViewPassExecutor = _retailPViewPassExecutor =
new AcDream.App.Rendering.RetailPViewPassExecutor( new AcDream.App.Rendering.RetailPViewPassExecutor(
_gl!, _gl!,
renderFrameGlState,
new AcDream.App.Rendering.SilkRetailPViewFramebufferSource( new AcDream.App.Rendering.SilkRetailPViewFramebufferSource(
_window!), _window!),
_clipFrame!, _clipFrame!,
@ -2924,6 +2901,48 @@ public sealed class GameWindow : IDisposable
_retailAlphaQueue, _retailAlphaQueue,
_worldRenderDiagnostics!, _worldRenderDiagnostics!,
_terrainDrawDiagnostics); _terrainDrawDiagnostics);
var worldSceneDiagnostics =
new AcDream.App.Rendering.WorldSceneDiagnosticsController(
_worldRenderDiagnostics!,
new AcDream.App.Rendering.RuntimeWorldScenePViewDiagnosticSource(
_playerControllerSlot,
_physicsEngine,
_cellVisibility),
_worldSceneDebugState,
_debugLines,
_physicsEngine,
_localPlayerMode,
_playerControllerSlot,
_debugVmRenderFacts,
_debugVm is not null);
var worldScenePasses = new AcDream.App.Rendering.WorldScenePassExecutor(
_gl!,
renderFrameGlState,
_clipFrame!,
_wbDrawDispatcher!,
_envCellRenderer!,
_terrain,
_terrainDrawDiagnostics,
_skyRenderer,
_particleSystem,
_particleRenderer);
_worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer(
_renderFrameResources,
renderLoginState,
_worldSceneSkyState!,
_worldRenderFrameBuilder,
new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState),
_retailSelectionScene,
_retailAlphaQueue,
_particleVisibility,
new AcDream.App.Rendering.WorldScenePViewRenderer(
_retailPViewRenderer!,
_retailPViewPassExecutor,
_retailPViewPassExecutor),
_retailPViewCells,
worldScenePasses,
_renderRange,
worldSceneDiagnostics);
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame, liveObjectFrame,
_worldState, _worldState,
@ -3400,7 +3419,6 @@ public sealed class GameWindow : IDisposable
Exception? renderFailure = null; Exception? renderFailure = null;
try try
{ {
GL gl = _gl!;
var frameInput = new AcDream.App.Rendering.RenderFrameInput( var frameInput = new AcDream.App.Rendering.RenderFrameInput(
deltaSeconds, deltaSeconds,
_window!.Size.X, _window!.Size.X,
@ -3414,10 +3432,6 @@ public sealed class GameWindow : IDisposable
// CreatureMode; it does not redraw the world behind portal space. // CreatureMode; it does not redraw the world behind portal space.
bool portalViewportVisible = foundation.PortalViewportVisible; bool portalViewportVisible = foundation.PortalViewportVisible;
AcDream.Core.World.SkyKeyframe kf = foundation.Sky;
AcDream.Core.World.AtmosphereSnapshot atmo = foundation.Atmosphere;
bool environOverrideActive = foundation.EnvironOverrideActive;
// Phase D.2a — begin ImGui frame. Paired with the Render() call // Phase D.2a — begin ImGui frame. Paired with the Render() call
// after the scene draws (below). ImGuiController.Update() // after the scene draws (below). ImGuiController.Update()
// consumes buffered Silk.NET input events and calls ImGui.NewFrame. // consumes buffered Silk.NET input events and calls ImGui.NewFrame.
@ -3441,558 +3455,11 @@ public sealed class GameWindow : IDisposable
// and the SkyRenderer.RenderWeather pass both pick up snow // and the SkyRenderer.RenderWeather pass both pick up snow
// weather meshes for free.) // weather meshes for free.)
int visibleLandblocks = 0; AcDream.App.Rendering.WorldRenderFrameOutcome worldOutcome =
int totalLandblocks = 0; _worldSceneRenderer!.Render(frameInput);
bool normalWorldDrawn = false; int visibleLandblocks = worldOutcome.VisibleLandblocks;
int totalLandblocks = worldOutcome.TotalLandblocks;
_retailSelectionScene?.BeginFrame(); bool normalWorldDrawn = worldOutcome.NormalWorldDrawn;
if (_cameraController is not null && !portalViewportVisible)
{
_retailAlphaQueue.BeginFrame();
AcDream.App.Rendering.WorldRenderFrame worldFrame =
_worldRenderFrameBuilder!.Build(
in foundation,
IsLiveModeWaitingForLogin,
_activeDayGroup);
AcDream.App.Rendering.WorldCameraFrame cameraFrame = worldFrame.Camera;
AcDream.App.Rendering.WorldRootFrame rootFrame = worldFrame.Roots;
var camera = cameraFrame.Camera;
var worldProjection = cameraFrame.Projection;
var frustum = cameraFrame.Frustum;
var camPos = cameraFrame.Position;
// The frame builder resolves retail's player-lighting root and
// collided-viewer render root before any draw decision is made.
LoadedCell? playerRoot = rootFrame.PlayerRoot;
bool playerSeenOutside = rootFrame.PlayerSeenOutside;
uint viewerCellId = rootFrame.ViewerCellId;
var viewerEyePos = rootFrame.ViewerEyePosition;
var playerViewPos = rootFrame.PlayerViewPosition;
LoadedCell? viewerRoot = rootFrame.ViewerRoot;
bool cameraInsideCell = rootFrame.CameraInsideCell;
// Never cull the landblock the player is currently on.
uint? playerLb = rootFrame.PlayerLandblockId;
int renderCenterLbX = rootFrame.RenderCenterLandblockX;
int renderCenterLbY = rootFrame.RenderCenterLandblockY;
var envCellViewProj = cameraFrame.ViewProjection;
HashSet<uint> animatedIds = worldFrame.AnimatedEntityIds;
// Phase G.1: sky renderer — draws the far-plane-infinity
// celestial meshes FIRST so the rest of the scene z-tests
// on top of them (depth mask off, no depth writes).
//
// Suppressed inside cells (the camera is in a sealed interior;
// no sky is visible). Mirrors retail's LScape::draw at 0x00506330
// which calls GameSky::Draw(0) (sky pass) BEFORE the landblock
// DrawBlock loop and GameSky::Draw(1) (weather pass) AFTER. The
// split matters because weather meshes (the 815m-tall rain
// cylinder 0x01004C42/0x01004C44) need to overlay terrain and
// entities to look volumetric — see the post-scene RenderWeather
// call further below.
// Stage 3 (2026-06-02): sky gate uses seen_outside per retail RenderNormalMode:92649.
// Outdoor root (cameraInsideCell=false): always render sky.
// Building interior (cameraInsideCell=true, rootSeenOutside=true): render sky — clipped
// to the doorway via the OutsideView (Stage 4, below).
// Sealed dungeon (cameraInsideCell=true, rootSeenOutside=false): no sky.
bool renderSky = rootFrame.RenderSky;
// Phase W Stage 4 (2026-06-02): the sky/weather DRAW moved DOWN to its retail LScape
// position — AFTER the portal-visibility ClipFrame is assembled — so it can be clipped to
// the doorway (OutsideView) by sky.vert's gl_ClipDistance. See the "[Stage 4] sky
// pre-scene" block after UploadShared. renderSky is the seen_outside policy gate; the draw
// additionally requires an exit portal in view when indoors (drawSkyThisFrame, below).
// K-fix1 (2026-04-26): the pre-login world-suppression gate (goto SkipWorldGeometry)
// moved DOWN — below the sky pre-scene draw (Phase W Stage 4) — so the live sky still
// draws during the connection + EnterWorld handshake while the world geometry is skipped.
// See the gate just before the world-geometry clip bracket.
// Phase U.4: build the SHARED per-frame clip data from the portal-
// visibility result, ahead of both terrain and entity draws.
//
// Root: a non-null CameraCell means the camera is INSIDE a cell (indoor
// root) — run the portal-frame BFS (PortalVisibilityBuilder) and assemble
// a real ClipFrame (slot 0 no-clip, slot 1.. per visible cell + the
// OutsideView) + a cellId→slot map. A null CameraCell is the OUTDOOR root:
// no pvFrame, the frame stays no-clip, every instance is slot 0 and terrain
// draws normally — bit-identical to U.3 (outdoor→building peering is U.5).
//
// The single _clipFrame instance is RESET + repacked in place each frame.
// One SSBO and one range-addressed terrain UBO arena are reused per
// GPU-fenced frame slot; each renderer re-binds binding=2 defensively.
_clipFrame ??= ClipFrame.NoClip();
IReadOnlyList<LoadedCell> nearbyBuildingCells =
worldFrame.Buildings.NearbyBuildingCells;
bool playerIndoorGate = rootFrame.PlayerIndoorGate;
uint playerCellId = rootFrame.PlayerCellId;
// Render unification (outdoor-as-cell, 2026-06-07 cutover): ONE render path rooted at the
// VIEWER cell. Eye indoors -> its interior EnvCell (viewerRoot); eye outdoors -> the
// frame builder's synthetic outdoor node, built from nearby building entrances. The
// result is null ONLY when neither exists (pre-spawn / login / legacy non-chase camera) ->
// the outdoor LScape block below still runs as the safety path (and login still shows the
// live sky). There is no inside/outside branch to TOGGLE as the chase eye crosses the
// doorway boundary, so the indoor FLAP dies by construction. playerIndoorGate stays
// computed for the [render-sig] probe but no longer selects the path (handoff
// docs/research/2026-06-07-render-unification-cutover-flip-handoff.md section 4 Step B).
var clipRoot = worldFrame.ClipRoot;
string renderBranch = clipRoot is null
? "OutdoorRoot"
: "RetailPViewInside";
ClipFrameAssembly? clipAssembly = null;
PortalVisibilityFrame? pvFrame = null; // R1: hoisted so the binary decision below reads OrderedVisibleCells
var terrainClipMode = TerrainClipMode.Planes; // overwritten below for indoor root
HashSet<uint>? envCellShellFilter = null; // drawable visible cells (cellIdToSlot keys)
PortalVisibilityFrame? sigPvFrame = null;
ClipFrameAssembly? sigClipAssembly = null;
IReadOnlySet<uint>? sigDrawableCells = null;
AcDream.App.Rendering.InteriorEntityPartition.Result? sigPartition = null;
PortalVisibilityFrame? sigExteriorPvFrame = null;
ClipFrameAssembly? sigExteriorClipAssembly = null;
IReadOnlySet<uint>? sigExteriorDrawableCells = null;
AcDream.App.Rendering.InteriorEntityPartition.Result? sigExteriorPartition = null;
bool sigTerrainDrawn = false;
bool sigSkyDrawn = false;
bool sigDepthClear = false;
bool sigOutdoorPortalDrawn = false;
bool sigOutdoorSceneryDrawn = false;
int sigOutdoorRootObjectCount = 0;
int sigLiveDynamicDrawnCount = 0;
string sigSceneParticles = "none";
_visibleSceneParticleEntityIds.Clear();
// Retail entry ownership: GameWindow never builds a second indoor PView product.
// Outdoor frames begin no-clip; indoor frames skip the global landscape block and let
// RetailPViewRenderer.DrawInside own ConstructView -> DrawCells.
_clipFrame.Reset();
_wbDrawDispatcher?.ClearClipRouting();
_envCellRenderer?.SetClipRouting(null);
_interiorPartition = null;
if (clipRoot is not null)
{
clipAssembly = null;
pvFrame = null;
terrainClipMode = TerrainClipMode.Skip;
envCellShellFilter = null;
_interiorPartition = null;
}
if (clipRoot is null)
{
// Outdoor frames have one no-clip terrain state. Indoor frames
// defer both allocations to RetailPViewRenderer, which knows the
// exact slice count and packs every terrain state into one arena.
_clipFrame.ReserveTerrainUploads(gl, 1);
_clipFrame.UploadRegions(gl);
TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(gl);
_wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_terrain?.SetClipUbo(terrainBinding);
}
bool drawSkyThisFrame = false;
if (clipRoot is null)
{
// ── Outdoor LScape entry ─────────────────────────────────────────────────
// Retail indoor frames do not pass through this block. If the player is indoors,
// PView::DrawInside owns landscape drawing through outside_view and the depth-only
// clear. This outdoor-only block is the LScape half of RenderNormalMode.
drawSkyThisFrame = renderSky;
sigSkyDrawn = drawSkyThisFrame;
if (drawSkyThisFrame)
{
_clipFrame.BindTerrainClip(gl);
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
gl.Enable(EnableCap.ClipDistance0 + _cp);
_skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf, environOverrideActive);
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
gl.Disable(EnableCap.ClipDistance0 + _cp);
if (_particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(camera, camPos,
AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene);
}
// K-fix1 (2026-04-26): suppress terrain + entity rendering while live mode is configured
// but the chase camera hasn't engaged yet. The sky (above) still draws during login so the
// user sees a live, time-of-day-correct sky through the connection + EnterWorld handshake.
if (IsLiveModeWaitingForLogin)
goto SkipWorldGeometry;
// Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup.
EnableClipDistances();
_terrainDrawDiagnostics!.Begin();
sigTerrainDrawn = true;
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
_terrainDrawDiagnostics.Complete();
}
// Reaching the retail normal-world decision below proves this is
// neither a portal replacement nor the sky-only login-wait path.
normalWorldDrawn = true;
// R1 — the binary render decision (retail RenderNormalMode @ 0x453aa0):
// INDOOR root (clipRoot != null): run ONLY the per-cell DrawInside flood. The global
// entity pass + global shell pass are NOT issued — visibility IS the cull, so the
// outdoor world cannot bleed (it is never iterated; outdoor scenery entered above,
// clipped to the doorway). DrawInside follows retail DrawCells order: reverse
// cell_draw_list shell stage, then reverse object-list stage, per portal_view slice.
// OUTDOOR root: draw the landscape/outdoor bucket first, then seed a reciprocal
// portal frame from exterior-facing cell portals so peering through an open door
// draws the indoor SHELL + its statics together. The old global pass drew indoor
// statics without the EnvCell shells, which made walls look transparent from outside.
if (clipRoot is not null)
{
if (_retailPViewRenderer is null)
throw new InvalidOperationException("Retail PView renderer is required for indoor frames.");
var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewFrameInput
{
RootCell = clipRoot,
// R-A2: outdoor root floods each nearby building per-building (not via the root).
// #124: interior roots get the gather too — the renderer routes them to the
// landscape-stage look-in sub-pass instead of the merge.
NearbyBuildingCells = nearbyBuildingCells,
ViewerEyePos = viewerEyePos,
ViewProjection = envCellViewProj,
Cells = _retailPViewCells!,
Camera = camera,
CameraWorldPosition = camPos,
// (#176 correction: the former RebuildScopedLights callback —
// rebuilding the light pool from the frame's FLOOD — was the
// flicker mechanism and is deleted. The pool is built once per
// frame above, player-anchored, from all resident lights.)
Frustum = frustum,
PlayerLandblockId = playerLb,
AnimatedEntityIds = animatedIds,
RenderCenterLbX = renderCenterLbX,
RenderCenterLbY = renderCenterLbY,
RenderRadius = _nearRadius,
LandblockEntries = _worldState.LandblockEntries,
RenderSky = renderSky,
RenderWeather = playerSeenOutside,
DayFraction = (float)WorldTime.DayFraction,
ActiveDayGroup = _activeDayGroup,
SkyKeyframe = kf,
EnvironOverrideActive = environOverrideActive,
ViewerCellId = viewerCellId,
PlayerCellId = playerCellId,
PlayerViewPosition = playerViewPos,
CameraView = camera.View,
CameraCellResolution = _cellVisibility.LastCameraCellResolution,
}, _retailPViewPassExecutor
?? throw new InvalidOperationException(
"Retail PView pass executor is required for indoor frames."));
pvFrame = pviewResult.PortalFrame;
clipAssembly = pviewResult.ClipAssembly;
envCellShellFilter = pviewResult.DrawableCells;
_interiorPartition = pviewResult.Partition;
_particleVisibility.MarkVisibleCells(pviewResult.DrawableCells);
// A7.L1: snapshot this frame's drawn cell set for NEXT frame's light-
// pool scoping. DrawableCells is the renderer's own reused scratch set
// (cleared/rebuilt every DrawInside call) — copy it, don't hold a
// reference to it.
_worldRenderFrameBuilder!.ObserveDrawableCells(pviewResult.DrawableCells);
// Flap root-cause apparatus (2026-06-07): per-frame, the EXACT Build inputs at 6 dp +
// the resulting flood count. The live flap shows flood flipping 2↔6 at an eye/player
// identical to cm; this answers whether the inputs differ sub-cm (jitter) or are
// byte-identical (nondeterminism). See RenderingDiagnostics.ProbePvInputEnabled.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePvInputEnabled
&& pvFrame is not null)
{
// 2026-06-08: disambiguate the idle flap. eye=camera eye-point (drives the flood);
// player=RenderPosition (Lerp of physics, what the eye chases); rawPlayer=raw physics
// body Position; yaw=camera/player heading (F8 rad to catch micro-drift). If the flood
// flickers while idle, exactly one of {eye, player, rawPlayer, yaw} is the varying input.
var pvRawPlayer = _playerController?.Position ?? playerViewPos;
float pvYaw = _playerController?.Yaw ?? 0f;
// §4 outdoor flap (2026-06-09): terrain height under the EYE — if the
// camera boom is buried in a hillside (eyeAbove < 0), every nearby
// terrain triangle backface-culls and the view punches through the
// world to the clear color. Pin or refute eye-under-terrain.
float? pvTerrZ = _physicsEngine.SampleTerrainZ(camPos.X, camPos.Y);
_worldRenderDiagnostics?.EmitPViewInput(
enabled: true,
pvFrame,
envCellViewProj,
clipRoot.IsOutdoorNode,
camPos,
playerViewPos,
pvRawPlayer,
pvYaw,
pvTerrZ);
}
sigPvFrame = pviewResult.PortalFrame;
sigClipAssembly = pviewResult.ClipAssembly;
sigDrawableCells = pviewResult.DrawableCells;
sigPartition = pviewResult.Partition;
sigTerrainDrawn = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
sigSkyDrawn = renderSky && pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
sigDepthClear = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
sigSceneParticles = (pviewResult.Partition.ByCell.Count > 0
|| pviewResult.ClipAssembly.OutsideViewSlices.Length > 0)
? "pviewScoped"
: sigSceneParticles;
sigOutdoorSceneryDrawn = pviewResult.Partition.OutdoorStatic.Count > 0
&& pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
// T1: DrawInside now draws ALL dynamics itself in its single
// last entity pass (DrawDynamicsLast) — the old LiveDynamic
// top-up draw is gone.
sigLiveDynamicDrawnCount = pviewResult.Partition.Dynamics.Count;
}
else
{
// T4 (BR-6): the old clipRoot==null mini-pipeline (outdoor
// partition + Chebyshev look-in gather + DrawPortal + dynamics
// fallback) is DELETED — it was the SECOND render path the
// one-gate rule forbids (legacy-outdoor-branch-remnant,
// adjusted-confirmed). clipRoot is null only when NO viewer
// cell exists at all (pre-login, fly/debug cameras, transient
// streaming gaps — the outdoor node covers every normal outdoor
// frame): draw the world flat through the dispatcher; floods
// resume the moment a viewer cell resolves.
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: null,
animatedEntityIds: animatedIds);
// A7.L1: no indoor draw this frame — fail open (unscoped) rather than
// scoping next frame's light pool by a stale dungeon's cell ids.
_worldRenderFrameBuilder!.ClearDrawableCells();
}
// Phase U.3: close the world-geometry clip bracket opened above. From here down the
// scene particles, debug lines, and UI use shaders that do NOT write gl_ClipDistance, so
// the planes must be OFF to avoid the undefined-behavior clip. (The weather/rain pass
// below DOES use sky.vert — it re-enables the planes in its OWN local bracket; the sky
// pre-scene pass above already did the same. Both are scissored to the doorway too.)
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
gl.Disable(EnableCap.ClipDistance0 + _cp);
// Phase G.1 / E.3: draw all live particles after opaque
// scene geometry so alpha blending composites correctly.
// Runs with depth test on (particles occluded by walls)
// but depth write off (no self-occlusion sorting needed).
if (clipRoot is null && _particleSystem is not null && _particleRenderer is not null)
{
if (clipAssembly is not null)
{
sigSceneParticles = sigSceneParticles == "none" ? "filtered" : sigSceneParticles + "+filtered";
_particleRenderer.DrawForOwners(
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
_visibleSceneParticleEntityIds,
includeUnattached: true,
excludedAttachedOwnerIds: _noSceneParticleEntityIds);
}
else
{
sigSceneParticles = sigSceneParticles == "none" ? "global" : sigSceneParticles + "+global";
_particleRenderer.Draw(
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene);
}
}
else if (clipRoot is { IsOutdoorNode: true }
&& _particleSystem is not null && _particleRenderer is not null)
{
// T3 (BR-5): unattached emitters (campfires, ground effects —
// AttachedObjectId == 0) under the OUTDOOR root. The outdoor
// root's outside view is full-screen (cone pass-all); depth
// test composites them against the world.
// #132 outdoor sibling: ATTACHED outdoor-static scene emitters
// (lantern/candle flames) moved here too — drawn in the
// landscape slice they were overpainted by merged building
// interiors (drawn later) whenever a punched aperture sat
// behind them. Post-frame, depth is complete and the flames
// composite correctly. The owner-id set is the late slice's
// (full-screen cone outdoors). Cell-pass and dynamics-pass
// emitters keep their own passes (no double-draw: their owners
// are never in the outdoor-static id set).
sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached";
_particleRenderer.DrawForOwners(
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
_retailPViewPassExecutor!.OutdoorSceneParticleEntityIds,
includeUnattached: true);
}
// Bug A fix (post-#26 worktree, 2026-04-26): weather sky
// Outdoor LScape post-scene weather. Indoor weather through an exit portal is
// drawn by RetailPViewRenderer.DrawInside via RetailPViewPassExecutor.
if (clipRoot is null && drawSkyThisFrame)
{
sigSkyDrawn = true;
_clipFrame.BindTerrainClip(gl);
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
gl.Enable(EnableCap.ClipDistance0 + _cp);
_skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf, environOverrideActive);
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
gl.Disable(EnableCap.ClipDistance0 + _cp);
if (_particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(camera, camPos,
AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene);
}
_worldRenderDiagnostics?.EmitRenderSignatureIfChanged(
AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled,
renderBranch,
clipRoot,
viewerRoot,
playerRoot,
viewerCellId,
playerCellId,
playerIndoorGate,
cameraInsideCell,
renderSky,
drawSkyThisFrame,
sigTerrainDrawn,
terrainClipMode,
sigSkyDrawn,
sigDepthClear,
sigOutdoorSceneryDrawn,
sigOutdoorPortalDrawn,
sigOutdoorRootObjectCount,
sigLiveDynamicDrawnCount,
sigSceneParticles,
sigPvFrame,
sigClipAssembly,
sigDrawableCells,
sigPartition,
sigExteriorPvFrame,
sigExteriorClipAssembly,
sigExteriorDrawableCells,
sigExteriorPartition,
camPos,
playerViewPos);
// Debug: draw collision shapes as wireframe cylinders around the
// player so we can visually verify alignment with scenery meshes.
if (_debugCollisionVisible && _debugLines is not null)
{
_debugLines.Begin();
// Pick the center for the debug radius. Prefer player
// position in player mode, otherwise use camPos.
System.Numerics.Vector3 center;
if (_playerMode && _playerController is not null)
center = _playerController.Position;
else
center = camPos;
// Draw ALL registered shadow objects regardless of distance —
// if it has collision, it gets a wireframe. This lets the user
// see exactly what's in the collision registry at any moment.
int drawn = 0;
foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug())
{
var dx = obj.Position.X - center.X;
var dy = obj.Position.Y - center.Y;
if (obj.CollisionType == AcDream.Core.Physics.ShadowCollisionType.Cylinder)
{
float h = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 2f;
_debugLines.AddCylinder(
obj.Position, obj.Radius, h,
new System.Numerics.Vector3(0f, 1f, 0f)); // green cylinders
}
else
{
// BSP: show a bounding sphere as a cylinder for visibility
_debugLines.AddCylinder(
obj.Position - new System.Numerics.Vector3(0, 0, obj.Radius),
obj.Radius, obj.Radius * 2f,
new System.Numerics.Vector3(1f, 0.5f, 0f)); // orange BSP
}
drawn++;
}
// Draw the player's collision sphere as a red cylinder (0.48m radius, 1.8m tall)
if (_playerMode && _playerController is not null)
{
var pp = _playerController.Position;
_debugLines.AddCylinder(
new System.Numerics.Vector3(pp.X, pp.Y, pp.Z - 0.0f),
0.48f, 1.8f,
new System.Numerics.Vector3(1f, 0f, 0f)); // red player
}
if (_debugDrawLogOnce < 5 && _playerMode && _playerController is not null)
{
var pp = _playerController.Position;
Console.WriteLine(
$"debug frame {_debugDrawLogOnce}: player=({pp.X:F1},{pp.Y:F1},{pp.Z:F1}) drew={drawn} " +
$"totalReg={_physicsEngine.ShadowObjects.TotalRegistered}");
// Sample 3 nearest shadow objects
int logged = 0;
foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug())
{
var dx = o.Position.X - pp.X;
var dy = o.Position.Y - pp.Y;
float dh = MathF.Sqrt(dx * dx + dy * dy);
if (dh < 10f)
{
Console.WriteLine($" near id=0x{o.EntityId:X8} type={o.CollisionType} pos=({o.Position.X:F1},{o.Position.Y:F1},{o.Position.Z:F1}) r={o.Radius:F2} h={o.CylHeight:F2} dh={dh:F2}");
if (++logged >= 5) break;
}
}
_debugDrawLogOnce++;
}
_debugLines.Flush(camera.View, worldProjection);
}
// Count visible vs total for the perf overlay.
foreach (var entry in _worldState.LandblockBounds)
{
totalLandblocks++;
if (AcDream.App.Rendering.FrustumCuller.IsAabbVisible(frustum, entry.AabbMin, entry.AabbMax))
visibleLandblocks++;
}
// Phase I.2: refresh per-frame fields that DebugVM closures
// can't compute lazily (frustum-derived counters + nearest-
// object scan). Every other DebugVM field reads through to
// the live source via its closure. Skipped entirely when
// devtools are off — avoids the nearest-object O(N) scan in
// the hot path of an offline render.
if (_debugVm is not null)
{
System.Numerics.Vector3 debugNearestOrigin =
_playerMode && _playerController is not null
? _playerController.Position
: camPos;
_debugVmRenderFacts.PublishDebugVmFacts(
consumerActive: true,
visibleLandblocks,
totalLandblocks,
debugNearestOrigin,
_physicsEngine.ShadowObjects.AllEntriesForDebug());
}
// K-fix1 (2026-04-26): jump target for IsLiveModeWaitingForLogin —
// skips the world geometry pass before login. ImGui (chat,
// debug, settings panels) and the menu bar still render
// below. Sky has already drawn before this label so the
// pre-login screen shows a live, correctly-tinted sky and
// nothing else.
SkipWorldGeometry:
_retailAlphaQueue.EndFrame();
if (_terrain is not null)
_particleVisibility.MarkVisibleCells(_terrain.VisibleCellIds);
_particleVisibility.CompleteFrame();
}
_retailSelectionScene?.CompleteFrame();
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a // Retail gmSmartBoxUI swaps the world SmartBox viewport for a
// CreatureMode portal-space viewport. The world block above is skipped // CreatureMode portal-space viewport. The world block above is skipped
@ -4139,7 +3606,8 @@ public sealed class GameWindow : IDisposable
/// within the same server-day. Swaps both the /// within the same server-day. Swaps both the
/// <see cref="AcDream.Core.World.SkyStateProvider"/> feeding /// <see cref="AcDream.Core.World.SkyStateProvider"/> feeding
/// <see cref="WorldTime"/> (for lighting interp) and the cached /// <see cref="WorldTime"/> (for lighting interp) and the cached
/// <see cref="_activeDayGroup"/> (for the sky-object render loop). /// <see cref="WorldSceneSkyState.ActiveDayGroup"/> (for the sky-object
/// render loop).
/// ///
/// <para> /// <para>
/// Honors <c>ACDREAM_DAY_GROUP=N</c> — when set, every call picks /// Honors <c>ACDREAM_DAY_GROUP=N</c> — when set, every call picks
@ -4186,12 +3654,16 @@ public sealed class GameWindow : IDisposable
: null; : null;
bool dayChanged = dayIndex != _loadedSkyDayIndex; bool dayChanged = dayIndex != _loadedSkyDayIndex;
bool groupChanged = !ReferenceEquals(grp, _activeDayGroup); bool groupChanged = !ReferenceEquals(
grp,
_worldSceneSkyState?.ActiveDayGroup);
if (!dayChanged && !groupChanged) return; if (!dayChanged && !groupChanged) return;
_loadedSkyDayIndex = dayIndex; _loadedSkyDayIndex = dayIndex;
_activeDayGroup = grp; (_worldSceneSkyState
??= new AcDream.App.Rendering.WorldSceneSkyState(WorldTime))
.ActiveDayGroup = grp;
if (grp is not null && grp.SkyTimes.Count > 0) if (grp is not null && grp.SkyTimes.Count > 0)
{ {
@ -4214,18 +3686,6 @@ public sealed class GameWindow : IDisposable
} }
} }
private void EnableClipDistances()
{
for (int i = 0; i < ClipFrame.MaxPlanes; i++)
_gl!.Enable(EnableCap.ClipDistance0 + i);
}
private void DisableClipDistances()
{
for (int i = 0; i < ClipFrame.MaxPlanes; i++)
_gl!.Disable(EnableCap.ClipDistance0 + i);
}
// ── Phase I.2 — DebugPanel helpers ──────────────────────────────── // ── Phase I.2 — DebugPanel helpers ────────────────────────────────
// //
// The ImGui DebugPanel reads through DebugVM closures that ask // The ImGui DebugPanel reads through DebugVM closures that ask
@ -4343,8 +3803,8 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private void ToggleCollisionWires() private void ToggleCollisionWires()
{ {
_debugCollisionVisible = !_debugCollisionVisible; bool visible = _worldSceneDebugState.ToggleCollisionWireframes();
_debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}"); _debugVm?.AddToast($"Collision wireframes {(visible ? "ON" : "OFF")}");
} }
// Phase K.3 settings state remains runtime-owned; the presenter owns the // Phase K.3 settings state remains runtime-owned; the presenter owns the
@ -4983,7 +4443,7 @@ public sealed class GameWindow : IDisposable
Console.WriteLine( Console.WriteLine(
$"=== F3 DEBUG DUMP ===\n" + $"=== F3 DEBUG DUMP ===\n" +
$" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" + $" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" +
$" landblock=0x{(uint)((lbX<<24)|(lbY<<16)|0xFFFF):X8} local=({pos.X - (lbX-_liveCenterX)*192f:F2},{pos.Y - (lbY-_liveCenterY)*192f:F2})\n" + $" landblock=0x{(uint)((lbX << 24) | (lbY << 16) | 0xFFFF):X8} local=({pos.X - (lbX - _liveCenterX) * 192f:F2},{pos.Y - (lbY - _liveCenterY) * 192f:F2})\n" +
$" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}"); $" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}");
var visibleNearby = new List<AcDream.Core.World.WorldEntity>(); var visibleNearby = new List<AcDream.Core.World.WorldEntity>();
@ -5103,7 +4563,9 @@ public sealed class GameWindow : IDisposable
[ [
new("world frame composition", () => new("world frame composition", () =>
{ {
_worldSceneRenderer = null;
_worldRenderFrameBuilder = null; _worldRenderFrameBuilder = null;
_worldSceneSkyState = null;
_skyPesFrame = null; _skyPesFrame = null;
_retailPViewPassExecutor = null; _retailPViewPassExecutor = null;
_retailPViewCells = null; _retailPViewCells = null;

View file

@ -377,7 +377,9 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.ColorMask(true, true, true, true); _gl.ColorMask(true, true, true, true);
_gl.DepthMask(true); _gl.DepthMask(true);
_gl.DepthFunc(DepthFunction.Less); _gl.DepthFunc(DepthFunction.Less);
_gl.Enable(EnableCap.CullFace); // Renderers opt into culling locally; the shared frame convention is
// cull-off. Keep this success exit identical to frame-abort recovery.
_gl.Disable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back); _gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.CW); _gl.FrontFace(FrontFaceDirection.CW);
_gl.UseProgram(0); _gl.UseProgram(0);

View file

@ -0,0 +1,113 @@
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
internal interface IRenderFrameGlState
{
void RestoreFrameDefaults();
}
internal interface IRenderFrameGlStateApi
{
void SetCapability(EnableCap capability, bool enabled);
void ColorMask(bool red, bool green, bool blue, bool alpha);
void StencilMask(uint mask);
void DepthMask(bool enabled);
void DepthFunc(DepthFunction function);
void CullFace(TriangleFace face);
void FrontFace(FrontFaceDirection direction);
void UseProgram(uint program);
void BindVertexArray(uint vertexArray);
void BindBuffer(BufferTargetARB target, uint buffer);
}
internal sealed class SilkRenderFrameGlStateApi : IRenderFrameGlStateApi
{
private readonly GL _gl;
public SilkRenderFrameGlStateApi(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
}
public void SetCapability(EnableCap capability, bool enabled)
{
if (enabled)
_gl.Enable(capability);
else
_gl.Disable(capability);
}
public void ColorMask(bool red, bool green, bool blue, bool alpha) =>
_gl.ColorMask(red, green, blue, alpha);
public void StencilMask(uint mask) => _gl.StencilMask(mask);
public void DepthMask(bool enabled) => _gl.DepthMask(enabled);
public void DepthFunc(DepthFunction function) => _gl.DepthFunc(function);
public void CullFace(TriangleFace face) => _gl.CullFace(face);
public void FrontFace(FrontFaceDirection direction) => _gl.FrontFace(direction);
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);
}
/// <summary>
/// Restores the frame-global OpenGL convention shared by frame clear and
/// exceptional world-pass rollback. This prevents a failed doorway scissor or
/// portal depth mask from clipping/color-masking the next frame's clear.
/// </summary>
internal sealed class RenderFrameGlStateController : IRenderFrameGlState
{
private readonly IRenderFrameGlStateApi _gl;
public RenderFrameGlStateController(IRenderFrameGlStateApi gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
}
public void RestoreFrameDefaults()
{
_gl.SetCapability(EnableCap.ScissorTest, enabled: false);
_gl.SetCapability(EnableCap.StencilTest, enabled: false);
_gl.SetCapability(EnableCap.Blend, enabled: false);
_gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false);
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
{
_gl.SetCapability(
EnableCap.ClipDistance0 + index,
enabled: false);
}
_gl.ColorMask(true, true, true, true);
_gl.StencilMask(0xFF);
_gl.DepthMask(true);
_gl.DepthFunc(DepthFunction.Less);
_gl.SetCapability(EnableCap.DepthTest, enabled: true);
// Renderers that need face culling establish it locally. The shared
// frame convention is culling off; SkyRenderer restores the state it
// observed, so enabling it here would leak culling into later passes.
_gl.SetCapability(EnableCap.CullFace, enabled: false);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.UseProgram(0);
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
}
}

View file

@ -33,6 +33,11 @@ internal interface IRenderFrameClearPhase
RenderFrameFoundation Clear(); RenderFrameFoundation Clear();
} }
internal interface IRenderFrameFoundationSource
{
RenderFrameFoundation Foundation { get; }
}
internal interface IRenderFrameLivePreparation internal interface IRenderFrameLivePreparation
{ {
void Prepare(int gpuSlot); void Prepare(int gpuSlot);
@ -43,7 +48,9 @@ internal interface IRenderFrameLivePreparation
/// typed phases preserve resource begin, clear/state, then live publication /// typed phases preserve resource begin, clear/state, then live publication
/// order without exposing a renderer service bag. /// order without exposing a renderer service bag.
/// </summary> /// </summary>
internal sealed class RenderFrameResourceController : IRenderFrameResourcePhase internal sealed class RenderFrameResourceController :
IRenderFrameResourcePhase,
IRenderFrameFoundationSource
{ {
private readonly IRenderFrameSlotSource _frameSlots; private readonly IRenderFrameSlotSource _frameSlots;
private readonly IRenderFrameBeginResources _resources; private readonly IRenderFrameBeginResources _resources;
@ -162,6 +169,7 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
private readonly IRenderFramePortalStateSource _portal; private readonly IRenderFramePortalStateSource _portal;
private readonly ParticleVisibilityController _particleVisibility; private readonly ParticleVisibilityController _particleVisibility;
private readonly WorldRenderDiagnostics _diagnostics; private readonly WorldRenderDiagnostics _diagnostics;
private readonly IRenderFrameGlState _frameGlState;
public RuntimeRenderFrameClearPhase( public RuntimeRenderFrameClearPhase(
GL gl, GL gl,
@ -169,7 +177,8 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
WeatherSystem weather, WeatherSystem weather,
IRenderFramePortalStateSource portal, IRenderFramePortalStateSource portal,
ParticleVisibilityController particleVisibility, ParticleVisibilityController particleVisibility,
WorldRenderDiagnostics diagnostics) WorldRenderDiagnostics diagnostics,
IRenderFrameGlState frameGlState)
{ {
_gl = gl ?? throw new ArgumentNullException(nameof(gl)); _gl = gl ?? throw new ArgumentNullException(nameof(gl));
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
@ -178,6 +187,8 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
_particleVisibility = particleVisibility _particleVisibility = particleVisibility
?? throw new ArgumentNullException(nameof(particleVisibility)); ?? throw new ArgumentNullException(nameof(particleVisibility));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_frameGlState = frameGlState
?? throw new ArgumentNullException(nameof(frameGlState));
} }
public RenderFrameFoundation Clear() public RenderFrameFoundation Clear()
@ -203,13 +214,11 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
1f); 1f);
} }
_gl.DepthMask(true); _frameGlState.RestoreFrameDefaults();
_gl.Clear( _gl.Clear(
ClearBufferMask.ColorBufferBit ClearBufferMask.ColorBufferBit
| ClearBufferMask.DepthBufferBit | ClearBufferMask.DepthBufferBit
| ClearBufferMask.StencilBufferBit); | ClearBufferMask.StencilBufferBit);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.CW);
_diagnostics.EmitGlStateTripwireIfChanged( _diagnostics.EmitGlStateTripwireIfChanged(
AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled); AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled);

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Runtime.ExceptionServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -56,7 +57,16 @@ internal readonly record struct RetailAlphaSubmission(
/// material/renderer sort.</item> /// material/renderer sort.</item>
/// </list> /// </list>
/// </summary> /// </summary>
internal sealed class RetailAlphaQueue internal interface IWorldSceneAlphaFrame
{
void BeginFrame();
void EndFrame();
void AbortFrame();
}
internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
{ {
private readonly List<RetailAlphaSubmission> _submissions = new(256); private readonly List<RetailAlphaSubmission> _submissions = new(256);
private readonly List<IRetailAlphaDrawSource> _sources = new(4); private readonly List<IRetailAlphaDrawSource> _sources = new(4);
@ -108,11 +118,12 @@ internal sealed class RetailAlphaQueue
if (!IsCollecting) if (!IsCollecting)
throw new InvalidOperationException("Retail alpha flush requires an active frame."); throw new InvalidOperationException("Retail alpha flush requires an active frame.");
Exception? drawFailure = null;
List<Exception>? resetFailures = null;
try try
{ {
if (_submissions.Count == 0) if (_submissions.Count > 0)
return; {
SortRetailOrder(); SortRetailOrder();
EnsureTokenCapacity(_submissions.Count); EnsureTokenCapacity(_submissions.Count);
EnsureSourceCapacity(_sources.Count); EnsureSourceCapacity(_sources.Count);
@ -153,13 +164,47 @@ internal sealed class RetailAlphaQueue
start = end; start = end;
} }
} }
}
catch (Exception error)
{
drawFailure = error;
}
finally finally
{ {
for (int i = 0; i < _sources.Count; i++) for (int i = 0; i < _sources.Count; i++)
{
try
{
_sources[i].ResetAlphaSubmissions(); _sources[i].ResetAlphaSubmissions();
}
catch (Exception error)
{
(resetFailures ??= []).Add(error);
}
}
_sources.Clear(); _sources.Clear();
_submissions.Clear(); _submissions.Clear();
} }
if (drawFailure is not null)
{
if (resetFailures is { Count: > 0 })
{
resetFailures.Insert(0, drawFailure);
throw new AggregateException(
"Retail alpha drawing failed and its submissions could not be fully reset.",
resetFailures);
}
ExceptionDispatchInfo.Capture(drawFailure).Throw();
}
if (resetFailures is { Count: > 0 })
{
throw new AggregateException(
"Retail alpha submissions could not be fully reset.",
resetFailures);
}
} }
public void EndFrame() public void EndFrame()
@ -177,6 +222,39 @@ internal sealed class RetailAlphaQueue
} }
} }
/// <summary>
/// Discards an incomplete frame without drawing it. Every source is still
/// told to release its retained submission payload so the next frame begins
/// from the same empty invariant as a successful flush.
/// </summary>
public void AbortFrame()
{
List<Exception>? failures = null;
try
{
for (int i = 0; i < _sources.Count; i++)
{
try
{
_sources[i].ResetAlphaSubmissions();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
}
finally
{
_sources.Clear();
_submissions.Clear();
IsCollecting = false;
}
if (failures is { Count: > 0 })
throw new AggregateException("Retail alpha frame abort failed.", failures);
}
private void SortRetailOrder() private void SortRetailOrder()
{ {
int count = _submissions.Count; int count = _submissions.Count;

View file

@ -74,9 +74,17 @@ internal sealed class RetailPViewParticleClassifications
/// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather /// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather
/// placement follows <c>LScape::draw @ 0x00506330</c>. /// placement follows <c>LScape::draw @ 0x00506330</c>.
/// </summary> /// </summary>
internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor internal interface IOutdoorSceneParticleOwnerSource
{
IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; }
}
internal sealed class RetailPViewPassExecutor :
IRetailPViewPassExecutor,
IOutdoorSceneParticleOwnerSource
{ {
private readonly GL _gl; private readonly GL _gl;
private readonly IRenderFrameGlState _frameGlState;
private readonly IRetailPViewFramebufferSource _framebuffer; private readonly IRetailPViewFramebufferSource _framebuffer;
private readonly ClipFrame _clipFrame; private readonly ClipFrame _clipFrame;
private readonly TerrainModernRenderer? _terrain; private readonly TerrainModernRenderer? _terrain;
@ -101,6 +109,7 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor
public RetailPViewPassExecutor( public RetailPViewPassExecutor(
GL gl, GL gl,
IRenderFrameGlState frameGlState,
IRetailPViewFramebufferSource framebuffer, IRetailPViewFramebufferSource framebuffer,
ClipFrame clipFrame, ClipFrame clipFrame,
TerrainModernRenderer? terrain, TerrainModernRenderer? terrain,
@ -115,6 +124,8 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor
TerrainDrawDiagnosticsController terrainDiagnostics) TerrainDrawDiagnosticsController terrainDiagnostics)
{ {
_gl = gl ?? throw new ArgumentNullException(nameof(gl)); _gl = gl ?? throw new ArgumentNullException(nameof(gl));
_frameGlState = frameGlState
?? throw new ArgumentNullException(nameof(frameGlState));
_framebuffer = framebuffer _framebuffer = framebuffer
?? throw new ArgumentNullException(nameof(framebuffer)); ?? throw new ArgumentNullException(nameof(framebuffer));
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame)); _clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
@ -136,6 +147,28 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor
_particleClassifications.BeginFrame(); _particleClassifications.BeginFrame();
} }
public void AbortFrame()
{
List<Exception>? failures = null;
TryAbort(_frameGlState.RestoreFrameDefaults);
TryAbort(_particleClassifications.BeginFrame);
TryAbort(_noSceneParticleEntityIds.Clear);
if (failures is { Count: > 0 })
throw new AggregateException("Retail PView pass abort failed.", failures);
void TryAbort(Action operation)
{
try
{
operation();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
}
public ClipFrameAssembly AssembleClipFrame( public ClipFrameAssembly AssembleClipFrame(
PortalVisibilityFrame portalFrame, PortalVisibilityFrame portalFrame,
ClipFrameAssembly reuseAssembly) => ClipFrameAssembly reuseAssembly) =>

View file

@ -889,6 +889,7 @@ public interface IRetailPViewCellSource
/// </summary> /// </summary>
public interface IRetailPViewPassExecutor public interface IRetailPViewPassExecutor
{ {
void AbortFrame();
void BeginFrame(); void BeginFrame();
ClipFrameAssembly AssembleClipFrame( ClipFrameAssembly AssembleClipFrame(
PortalVisibilityFrame portalFrame, PortalVisibilityFrame portalFrame,
@ -1118,7 +1119,8 @@ public sealed class RetailPViewFrameInput
public required int RenderRadius { get; init; } public required int RenderRadius { get; init; }
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities, IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; } IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
{ get; init; }
// Pass-presentation and diagnostic values consumed synchronously by the // Pass-presentation and diagnostic values consumed synchronously by the
// typed executor. This input is data-only and is never retained. // typed executor. This input is data-only and is never retained.

View file

@ -9,7 +9,19 @@ namespace AcDream.App.Rendering.Selection;
/// The renderer builds one frame while input queries the previously completed /// The renderer builds one frame while input queries the previously completed
/// frame, avoiding partial visibility state during multi-slice portal drawing. /// frame, avoiding partial visibility state during multi-slice portal drawing.
/// </summary> /// </summary>
internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetailSelectionLightingSource internal interface IWorldSceneSelectionFrame
{
void BeginFrame();
void CompleteFrame();
void AbortFrame();
}
internal sealed class RetailSelectionScene :
IRetailSelectionRenderSink,
IRetailSelectionLightingSource,
IWorldSceneSelectionFrame
{ {
private readonly IRetailSelectionGeometrySource _geometry; private readonly IRetailSelectionGeometrySource _geometry;
private readonly RetailSelectionLightingPulse _lightingPulse; private readonly RetailSelectionLightingPulse _lightingPulse;
@ -100,6 +112,15 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail
(_published, _building) = (_building, _published); (_published, _building) = (_building, _published);
} }
/// <summary>Discards the incomplete building frame and preserves the last
/// completely published selection product.</summary>
public void AbortFrame()
{
_building.Clear();
_buildingKeys.Clear();
_viewFrustum = null;
}
public RetailSelectionHit? Pick( public RetailSelectionHit? Pick(
float mouseX, float mouseX,
float mouseY, float mouseY,

View file

@ -3,13 +3,22 @@ using AcDream.Core.Vfx;
namespace AcDream.App.Rendering.Vfx; namespace AcDream.App.Rendering.Vfx;
internal interface IWorldSceneParticleVisibility
{
void MarkVisibleCells(HashSet<uint> cellIds);
void CompleteFrame();
void AbortFrame();
}
/// <summary> /// <summary>
/// Bridges the retained retail PView result into the next physics update's /// Bridges the retained retail PView result into the next physics update's
/// <c>CObjCell::IsInView</c> particle gate. The controller owns only immutable /// <c>CObjCell::IsInView</c> particle gate. The controller owns only immutable
/// frame meaning: one completed viewer position plus the AC cells admitted by /// frame meaning: one completed viewer position plus the AC cells admitted by
/// that completed view. It neither creates emitters nor performs rendering. /// that completed view. It neither creates emitters nor performs rendering.
/// </summary> /// </summary>
public sealed class ParticleVisibilityController public sealed class ParticleVisibilityController : IWorldSceneParticleVisibility
{ {
public const float ExtendedRangeMultiplier = 2f; public const float ExtendedRangeMultiplier = 2f;
@ -70,6 +79,15 @@ public sealed class ParticleVisibilityController
_hasCompletedWorldView = true; _hasCompletedWorldView = true;
} }
/// <summary>Discards the in-progress visibility product while preserving
/// the last completed view consumed by the update thread.</summary>
public void AbortFrame()
{
_buildingCellIds.Clear();
_frameUsesWorldView = false;
_frameOpen = false;
}
public void Apply(ParticleSystem particles, float rangeMultiplier) public void Apply(ParticleSystem particles, float rangeMultiplier)
{ {
ArgumentNullException.ThrowIfNull(particles); ArgumentNullException.ThrowIfNull(particles);

View file

@ -61,6 +61,18 @@ internal readonly record struct WorldRenderFrame(
public LoadedCell? ClipRoot => Roots.ViewerRoot ?? Buildings.OutdoorNode; public LoadedCell? ClipRoot => Roots.ViewerRoot ?? Buildings.OutdoorNode;
} }
internal interface IWorldRenderFrameBuilder
{
WorldRenderFrame Build(
in RenderFrameFoundation foundation,
bool waitingForLogin,
DayGroupData? activeDayGroup);
void ObserveDrawableCells(IReadOnlySet<uint> drawableCells);
void ClearDrawableCells();
}
internal interface IWorldFrameCameraSource internal interface IWorldFrameCameraSource
{ {
WorldCameraFrame Resolve(); WorldCameraFrame Resolve();
@ -113,7 +125,7 @@ internal interface IWorldFrameBuildingSource
/// Orders typed world-frame fact sources without owning their borrowed render /// Orders typed world-frame fact sources without owning their borrowed render
/// resources. It makes no draw decision and retains no result from a prior call. /// resources. It makes no draw decision and retains no result from a prior call.
/// </summary> /// </summary>
internal sealed class WorldRenderFrameBuilder internal sealed class WorldRenderFrameBuilder : IWorldRenderFrameBuilder
{ {
private readonly IWorldFrameCameraSource _camera; private readonly IWorldFrameCameraSource _camera;
private readonly IWorldFrameVisibilityPreparation _visibility; private readonly IWorldFrameVisibilityPreparation _visibility;

View file

@ -0,0 +1,274 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
internal readonly record struct WorldSceneDiagnosticOutcome(
int VisibleLandblocks,
int TotalLandblocks);
internal interface IWorldSceneDiagnostics
{
CameraCellResolution CameraCellResolution { get; }
void EmitPViewInput(
PortalVisibilityFrame portalFrame,
Matrix4x4 viewProjection,
LoadedCell clipRoot,
Vector3 cameraPosition,
Vector3 playerPosition);
void EmitRenderSignature(
string branch,
LoadedCell? clipRoot,
LoadedCell? viewerRoot,
LoadedCell? playerRoot,
uint viewerCellId,
uint playerCellId,
bool playerIndoorGate,
bool cameraInsideCell,
bool renderSky,
bool drawSkyThisFrame,
bool terrainDrawn,
TerrainClipMode terrainClipMode,
bool skyDrawn,
bool depthClear,
bool outdoorSceneryDrawn,
int liveDynamicDrawnCount,
string sceneParticles,
RetailPViewFrameResult? pviewResult,
Vector3 cameraPosition,
Vector3 playerPosition);
WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds);
}
/// <summary>
/// Owns world-pass probes, collision wireframes, and the DebugVM facts derived
/// from one completed normal-world draw. None of these diagnostics influence
/// visibility or pass ordering.
/// </summary>
internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
{
private readonly WorldRenderDiagnostics _diagnostics;
private readonly IWorldScenePViewDiagnosticSource _pview;
private readonly IWorldSceneDebugStateSource _state;
private readonly DebugLineRenderer? _lines;
private readonly PhysicsEngine _physics;
private readonly ILocalPlayerModeSource _mode;
private readonly ILocalPlayerControllerSource _player;
private readonly DebugVmRenderFactsPublisher _debugVm;
private readonly bool _debugVmConsumerActive;
private int _debugDrawLogCount;
public WorldSceneDiagnosticsController(
WorldRenderDiagnostics diagnostics,
IWorldScenePViewDiagnosticSource pview,
IWorldSceneDebugStateSource state,
DebugLineRenderer? lines,
PhysicsEngine physics,
ILocalPlayerModeSource mode,
ILocalPlayerControllerSource player,
DebugVmRenderFactsPublisher debugVm,
bool debugVmConsumerActive)
{
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_pview = pview ?? throw new ArgumentNullException(nameof(pview));
_state = state ?? throw new ArgumentNullException(nameof(state));
_lines = lines;
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
_player = player ?? throw new ArgumentNullException(nameof(player));
_debugVm = debugVm ?? throw new ArgumentNullException(nameof(debugVm));
_debugVmConsumerActive = debugVmConsumerActive;
}
public CameraCellResolution CameraCellResolution => _pview.CameraCellResolution;
public void EmitPViewInput(
PortalVisibilityFrame portalFrame,
Matrix4x4 viewProjection,
LoadedCell clipRoot,
Vector3 cameraPosition,
Vector3 playerPosition)
{
if (!RenderingDiagnostics.ProbePvInputEnabled)
return;
_diagnostics.EmitPViewInput(
enabled: true,
portalFrame,
viewProjection,
clipRoot.IsOutdoorNode,
cameraPosition,
playerPosition,
_pview.RawPlayerPositionOr(playerPosition),
_pview.PlayerYaw,
_pview.SampleTerrainZ(cameraPosition.X, cameraPosition.Y));
}
public void EmitRenderSignature(
string branch,
LoadedCell? clipRoot,
LoadedCell? viewerRoot,
LoadedCell? playerRoot,
uint viewerCellId,
uint playerCellId,
bool playerIndoorGate,
bool cameraInsideCell,
bool renderSky,
bool drawSkyThisFrame,
bool terrainDrawn,
TerrainClipMode terrainClipMode,
bool skyDrawn,
bool depthClear,
bool outdoorSceneryDrawn,
int liveDynamicDrawnCount,
string sceneParticles,
RetailPViewFrameResult? pviewResult,
Vector3 cameraPosition,
Vector3 playerPosition)
{
_diagnostics.EmitRenderSignatureIfChanged(
RenderingDiagnostics.ProbeFlapEnabled,
branch,
clipRoot,
viewerRoot,
playerRoot,
viewerCellId,
playerCellId,
playerIndoorGate,
cameraInsideCell,
renderSky,
drawSkyThisFrame,
terrainDrawn,
terrainClipMode,
skyDrawn,
depthClear,
outdoorSceneryDrawn,
outdoorPortalDrawn: false,
outdoorRootObjectCount: 0,
liveDynamicDrawnCount,
sceneParticles,
pviewResult?.PortalFrame,
pviewResult?.ClipAssembly,
pviewResult?.DrawableCells,
pviewResult?.Partition,
exteriorPortalFrame: null,
exteriorClipAssembly: null,
exteriorDrawableCells: null,
exteriorPartition: null,
cameraPosition,
playerPosition);
}
public WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
{
ArgumentNullException.ThrowIfNull(bounds);
DrawCollisionWireframes(in camera);
int visible = 0;
int total = 0;
foreach (var entry in bounds)
{
total++;
if (FrustumCuller.IsAabbVisible(
camera.Frustum,
entry.AabbMin,
entry.AabbMax))
{
visible++;
}
}
Vector3 nearestOrigin =
_mode.IsPlayerMode && _player.Controller is { } controller
? controller.Position
: camera.Position;
_debugVm.PublishDebugVmFacts(
_debugVmConsumerActive,
visible,
total,
nearestOrigin,
_physics.ShadowObjects.AllEntriesForDebug());
return new WorldSceneDiagnosticOutcome(visible, total);
}
private void DrawCollisionWireframes(in WorldCameraFrame camera)
{
if (!_state.CollisionWireframesVisible || _lines is null)
return;
_lines.Begin();
int drawn = 0;
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
{
if (shadow.CollisionType == ShadowCollisionType.Cylinder)
{
float height = shadow.CylHeight > 0f
? shadow.CylHeight
: shadow.Radius * 2f;
_lines.AddCylinder(
shadow.Position,
shadow.Radius,
height,
new Vector3(0f, 1f, 0f));
}
else
{
_lines.AddCylinder(
shadow.Position - new Vector3(0f, 0f, shadow.Radius),
shadow.Radius,
shadow.Radius * 2f,
new Vector3(1f, 0.5f, 0f));
}
drawn++;
}
if (_mode.IsPlayerMode && _player.Controller is { } localPlayer)
{
Vector3 position = localPlayer.Position;
_lines.AddCylinder(
new Vector3(position.X, position.Y, position.Z),
DebugVmRenderFactsPublisher.PlayerCollisionRadius,
1.8f,
new Vector3(1f, 0f, 0f));
LogNearbyCollisionObjects(position, drawn);
}
_lines.Flush(camera.Camera.View, camera.Projection);
}
private void LogNearbyCollisionObjects(Vector3 playerPosition, int drawn)
{
if (_debugDrawLogCount >= 5)
return;
Console.WriteLine(
$"debug frame {_debugDrawLogCount}: player=({playerPosition.X:F1},{playerPosition.Y:F1},{playerPosition.Z:F1}) drew={drawn} "
+ $"totalReg={_physics.ShadowObjects.TotalRegistered}");
int logged = 0;
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
{
float dx = shadow.Position.X - playerPosition.X;
float dy = shadow.Position.Y - playerPosition.Y;
float horizontalDistance = MathF.Sqrt(dx * dx + dy * dy);
if (horizontalDistance >= 10f)
continue;
Console.WriteLine(
$" near id=0x{shadow.EntityId:X8} type={shadow.CollisionType} "
+ $"pos=({shadow.Position.X:F1},{shadow.Position.Y:F1},{shadow.Position.Z:F1}) "
+ $"r={shadow.Radius:F2} h={shadow.CylHeight:F2} dh={horizontalDistance:F2}");
if (++logged >= 5)
break;
}
_debugDrawLogCount++;
}
}

View file

@ -0,0 +1,328 @@
using AcDream.App.Rendering.Sky;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Rendering;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
internal interface IWorldScenePassExecutor
{
HashSet<uint>? TerrainVisibleCellIds { get; }
void BeginFrame();
void PrepareFlatWorldClip();
void DrawFlatSky(
in WorldCameraFrame camera,
in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup,
float dayFraction);
void DrawFlatTerrain(in WorldCameraFrame camera, uint? playerLandblockId);
void DrawFlatEntities(
in WorldCameraFrame camera,
IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin,
System.Numerics.Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries,
uint? playerLandblockId,
HashSet<uint> animatedEntityIds);
string DrawPostWorldParticles(
LoadedCell? clipRoot,
ClipFrameAssembly? clipAssembly,
in WorldCameraFrame camera,
IReadOnlySet<uint> outdoorOwnerIds,
string currentSignature);
void DrawFlatWeather(
in WorldCameraFrame camera,
in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup,
float dayFraction);
void DisableClipDistances();
void AbortFrame();
}
/// <summary>
/// Concrete GL leaf for the flat-world safety path and the post-world particle
/// and weather passes. Retail PView frames remain owned by
/// <see cref="RetailPViewRenderer"/> and <see cref="RetailPViewPassExecutor"/>.
/// </summary>
internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
{
private readonly GL _gl;
private readonly IRenderFrameGlState _frameGlState;
private readonly ClipFrame _clipFrame;
private readonly WbDrawDispatcher _entities;
private readonly EnvCellRenderer _environmentCells;
private readonly TerrainModernRenderer? _terrain;
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
private readonly SkyRenderer? _sky;
private readonly ParticleSystem? _particles;
private readonly ParticleRenderer? _particleRenderer;
private readonly HashSet<uint> _visibleParticleOwners = [];
private readonly HashSet<uint> _noExcludedParticleOwners = [];
public WorldScenePassExecutor(
GL gl,
IRenderFrameGlState frameGlState,
ClipFrame clipFrame,
WbDrawDispatcher entities,
EnvCellRenderer environmentCells,
TerrainModernRenderer? terrain,
TerrainDrawDiagnosticsController terrainDiagnostics,
SkyRenderer? sky,
ParticleSystem? particles,
ParticleRenderer? particleRenderer)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_frameGlState = frameGlState
?? throw new ArgumentNullException(nameof(frameGlState));
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
_environmentCells = environmentCells
?? throw new ArgumentNullException(nameof(environmentCells));
_terrain = terrain;
_terrainDiagnostics = terrainDiagnostics
?? throw new ArgumentNullException(nameof(terrainDiagnostics));
_sky = sky;
_particles = particles;
_particleRenderer = particleRenderer;
}
public HashSet<uint>? TerrainVisibleCellIds => _terrain?.VisibleCellIds;
public void BeginFrame()
{
_visibleParticleOwners.Clear();
_clipFrame.Reset();
_entities.ClearClipRouting();
_environmentCells.SetClipRouting(null);
}
public void PrepareFlatWorldClip()
{
_clipFrame.ReserveTerrainUploads(_gl, 1);
_clipFrame.UploadRegions(_gl);
TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(_gl);
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_environmentCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_terrain?.SetClipUbo(terrainBinding);
}
public void DrawFlatSky(
in WorldCameraFrame camera,
in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup,
float dayFraction)
{
_clipFrame.BindTerrainClip(_gl);
EnableClipDistances();
Exception? drawFailure = null;
try
{
_sky?.RenderSky(
camera.Camera,
camera.Position,
dayFraction,
activeDayGroup,
foundation.Sky,
foundation.EnvironOverrideActive);
}
catch (Exception error)
{
drawFailure = error;
throw;
}
finally
{
try
{
DisableClipDistances();
}
catch (Exception closeFailure) when (drawFailure is not null)
{
throw new AggregateException(
"Sky drawing failed and its clip-distance bracket could not be closed.",
drawFailure,
closeFailure);
}
}
if (_particles is not null && _particleRenderer is not null)
{
_particleRenderer.Draw(
camera.Camera,
camera.Position,
ParticleRenderPass.SkyPreScene);
}
}
public void DrawFlatTerrain(
in WorldCameraFrame camera,
uint? playerLandblockId)
{
EnableClipDistances();
_terrainDiagnostics.Begin();
_terrain?.Draw(
camera.Camera,
camera.Frustum,
neverCullLandblockId: playerLandblockId);
_terrainDiagnostics.Complete();
}
public void DrawFlatEntities(
in WorldCameraFrame camera,
IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin,
System.Numerics.Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries,
uint? playerLandblockId,
HashSet<uint> animatedEntityIds) =>
_entities.Draw(
camera.Camera,
entries,
camera.Frustum,
neverCullLandblockId: playerLandblockId,
visibleCellIds: null,
animatedEntityIds: animatedEntityIds);
public string DrawPostWorldParticles(
LoadedCell? clipRoot,
ClipFrameAssembly? clipAssembly,
in WorldCameraFrame camera,
IReadOnlySet<uint> outdoorOwnerIds,
string currentSignature)
{
if (_particles is null || _particleRenderer is null)
return currentSignature;
if (clipRoot is null)
{
if (clipAssembly is not null)
{
_particleRenderer.DrawForOwners(
camera.Camera,
camera.Position,
ParticleRenderPass.Scene,
_visibleParticleOwners,
includeUnattached: true,
excludedAttachedOwnerIds: _noExcludedParticleOwners);
return AppendSignature(currentSignature, "filtered");
}
_particleRenderer.Draw(
camera.Camera,
camera.Position,
ParticleRenderPass.Scene);
return AppendSignature(currentSignature, "global");
}
if (clipRoot.IsOutdoorNode)
{
_particleRenderer.DrawForOwners(
camera.Camera,
camera.Position,
ParticleRenderPass.Scene,
outdoorOwnerIds,
includeUnattached: true);
return AppendSignature(currentSignature, "unattached");
}
return currentSignature;
}
public void DrawFlatWeather(
in WorldCameraFrame camera,
in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup,
float dayFraction)
{
_clipFrame.BindTerrainClip(_gl);
EnableClipDistances();
Exception? drawFailure = null;
try
{
_sky?.RenderWeather(
camera.Camera,
camera.Position,
dayFraction,
activeDayGroup,
foundation.Sky,
foundation.EnvironOverrideActive);
}
catch (Exception error)
{
drawFailure = error;
throw;
}
finally
{
try
{
DisableClipDistances();
}
catch (Exception closeFailure) when (drawFailure is not null)
{
throw new AggregateException(
"Weather drawing failed and its clip-distance bracket could not be closed.",
drawFailure,
closeFailure);
}
}
if (_particles is not null && _particleRenderer is not null)
{
_particleRenderer.Draw(
camera.Camera,
camera.Position,
ParticleRenderPass.SkyPostScene);
}
}
public void DisableClipDistances()
{
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
_gl.Disable(EnableCap.ClipDistance0 + index);
}
public void AbortFrame()
{
List<Exception>? failures = null;
TryAbort(_frameGlState.RestoreFrameDefaults);
TryAbort(_clipFrame.Reset);
TryAbort(_entities.ClearClipRouting);
TryAbort(() => _environmentCells.SetClipRouting(null));
_visibleParticleOwners.Clear();
if (failures is { Count: > 0 })
throw new AggregateException("World scene pass abort failed.", failures);
void TryAbort(Action operation)
{
try
{
operation();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
}
private void EnableClipDistances()
{
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
_gl.Enable(EnableCap.ClipDistance0 + index);
}
private static string AppendSignature(string current, string value) =>
current == "none" ? value : current + "+" + value;
}

View file

@ -0,0 +1,345 @@
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
internal interface IWorldScenePViewRenderer
{
IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; }
RetailPViewFrameResult DrawInside(RetailPViewFrameInput input);
void AbortFrame();
}
internal sealed class WorldScenePViewRenderer : IWorldScenePViewRenderer
{
private readonly RetailPViewRenderer _renderer;
private readonly IRetailPViewPassExecutor _passes;
private readonly IOutdoorSceneParticleOwnerSource _particles;
public WorldScenePViewRenderer(
RetailPViewRenderer renderer,
IRetailPViewPassExecutor passes,
IOutdoorSceneParticleOwnerSource particles)
{
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
}
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds =>
_particles.OutdoorSceneParticleEntityIds;
public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input) =>
_renderer.DrawInside(input, _passes);
public void AbortFrame() => _passes.AbortFrame();
}
/// <summary>
/// Owns the normal-world render transaction between shared frame-resource
/// preparation and private presentation. It preserves retail's one PView
/// decision: a resolved viewer root uses DrawInside; only an unresolved root
/// uses the flat safety path.
/// </summary>
internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
{
private readonly IRenderFrameFoundationSource _foundation;
private readonly IRenderLoginStateSource _login;
private readonly IWorldSceneSkyStateSource _sky;
private readonly IWorldRenderFrameBuilder _frames;
private readonly IWorldSceneEntitySource _entities;
private readonly IWorldSceneSelectionFrame? _selection;
private readonly IWorldSceneAlphaFrame _alpha;
private readonly IWorldSceneParticleVisibility _particleVisibility;
private readonly IWorldScenePViewRenderer _pview;
private readonly IRetailPViewCellSource _pviewCells;
private readonly IWorldScenePassExecutor _passes;
private readonly IWorldRenderRangeSource _renderRange;
private readonly IWorldSceneDiagnostics _diagnostics;
public WorldSceneRenderer(
IRenderFrameFoundationSource foundation,
IRenderLoginStateSource login,
IWorldSceneSkyStateSource sky,
IWorldRenderFrameBuilder frames,
IWorldSceneEntitySource entities,
IWorldSceneSelectionFrame? selection,
IWorldSceneAlphaFrame alpha,
IWorldSceneParticleVisibility particleVisibility,
IWorldScenePViewRenderer pview,
IRetailPViewCellSource pviewCells,
IWorldScenePassExecutor passes,
IWorldRenderRangeSource renderRange,
IWorldSceneDiagnostics diagnostics)
{
_foundation = foundation ?? throw new ArgumentNullException(nameof(foundation));
_login = login ?? throw new ArgumentNullException(nameof(login));
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
_selection = selection;
_alpha = alpha ?? throw new ArgumentNullException(nameof(alpha));
_particleVisibility = particleVisibility
?? throw new ArgumentNullException(nameof(particleVisibility));
_pview = pview ?? throw new ArgumentNullException(nameof(pview));
_pviewCells = pviewCells ?? throw new ArgumentNullException(nameof(pviewCells));
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
}
public WorldRenderFrameOutcome Render(RenderFrameInput input)
{
_ = input;
bool selectionFrameStarted = _selection is not null;
bool worldFrameStarted = false;
bool pviewFrameStarted = false;
try
{
_selection?.BeginFrame();
RenderFrameFoundation foundation = _foundation.Foundation;
if (foundation.PortalViewportVisible)
{
_selection?.CompleteFrame();
selectionFrameStarted = false;
return default;
}
// Set before BeginFrame so an already-poisoned queue is recovered by
// the same abort path instead of making every later frame fail.
worldFrameStarted = true;
_alpha.BeginFrame();
WorldRenderFrame world = _frames.Build(
in foundation,
_login.IsWaitingForLogin,
_sky.ActiveDayGroup);
_passes.BeginFrame();
WorldCameraFrame camera = world.Camera;
WorldRootFrame roots = world.Roots;
LoadedCell? clipRoot = world.ClipRoot;
bool renderSky = roots.RenderSky;
bool drawSkyThisFrame = false;
bool terrainDrawn = false;
bool skyDrawn = false;
bool depthClear = false;
bool outdoorSceneryDrawn = false;
int liveDynamicDrawnCount = 0;
string sceneParticles = "none";
TerrainClipMode terrainClipMode = TerrainClipMode.Planes;
RetailPViewFrameResult? pviewResult = null;
if (clipRoot is null)
{
_passes.PrepareFlatWorldClip();
drawSkyThisFrame = renderSky;
skyDrawn = drawSkyThisFrame;
if (drawSkyThisFrame)
{
_passes.DrawFlatSky(
in camera,
in foundation,
_sky.ActiveDayGroup,
_sky.DayFraction);
}
// Retail keeps the live sky during EnterWorld while suppressing
// terrain and object geometry until chase mode has engaged.
if (_login.IsWaitingForLogin)
return CompleteSkippedWorld();
_passes.DrawFlatTerrain(in camera, roots.PlayerLandblockId);
terrainDrawn = true;
}
else
{
terrainClipMode = TerrainClipMode.Skip;
}
if (clipRoot is not null)
{
pviewFrameStarted = true;
pviewResult = _pview.DrawInside(
new RetailPViewFrameInput
{
RootCell = clipRoot,
NearbyBuildingCells = world.Buildings.NearbyBuildingCells,
ViewerEyePos = roots.ViewerEyePosition,
ViewProjection = camera.ViewProjection,
Cells = _pviewCells,
Camera = camera.Camera,
CameraWorldPosition = camera.Position,
Frustum = camera.Frustum,
PlayerLandblockId = roots.PlayerLandblockId,
AnimatedEntityIds = world.AnimatedEntityIds,
RenderCenterLbX = roots.RenderCenterLandblockX,
RenderCenterLbY = roots.RenderCenterLandblockY,
RenderRadius = _renderRange.NearRadius,
LandblockEntries = _entities.LandblockEntries,
RenderSky = renderSky,
RenderWeather = roots.PlayerSeenOutside,
DayFraction = _sky.DayFraction,
ActiveDayGroup = _sky.ActiveDayGroup,
SkyKeyframe = foundation.Sky,
EnvironOverrideActive = foundation.EnvironOverrideActive,
ViewerCellId = roots.ViewerCellId,
PlayerCellId = roots.PlayerCellId,
PlayerViewPosition = roots.PlayerViewPosition,
CameraView = camera.Camera.View,
CameraCellResolution = _diagnostics.CameraCellResolution,
});
_particleVisibility.MarkVisibleCells(pviewResult.DrawableCells);
_frames.ObserveDrawableCells(pviewResult.DrawableCells);
_diagnostics.EmitPViewInput(
pviewResult.PortalFrame,
camera.ViewProjection,
clipRoot,
camera.Position,
roots.PlayerViewPosition);
bool hasOutsideSlice = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0;
terrainDrawn = hasOutsideSlice;
skyDrawn = renderSky && hasOutsideSlice;
// This mirrors the established diagnostic meaning, including
// outdoor roots whose PView executor does not issue an interior
// depth clear.
depthClear = hasOutsideSlice;
if (pviewResult.Partition.ByCell.Count > 0 || hasOutsideSlice)
sceneParticles = "pviewScoped";
outdoorSceneryDrawn = pviewResult.Partition.OutdoorStatic.Count > 0
&& hasOutsideSlice;
liveDynamicDrawnCount = pviewResult.Partition.Dynamics.Count;
}
else
{
_passes.DrawFlatEntities(
in camera,
_entities.LandblockEntries,
roots.PlayerLandblockId,
world.AnimatedEntityIds);
_frames.ClearDrawableCells();
}
_passes.DisableClipDistances();
sceneParticles = _passes.DrawPostWorldParticles(
clipRoot,
pviewResult?.ClipAssembly,
in camera,
_pview.OutdoorSceneParticleEntityIds,
sceneParticles);
if (clipRoot is null && drawSkyThisFrame)
{
skyDrawn = true;
_passes.DrawFlatWeather(
in camera,
in foundation,
_sky.ActiveDayGroup,
_sky.DayFraction);
}
_diagnostics.EmitRenderSignature(
clipRoot is null ? "OutdoorRoot" : "RetailPViewInside",
clipRoot,
roots.ViewerRoot,
roots.PlayerRoot,
roots.ViewerCellId,
roots.PlayerCellId,
roots.PlayerIndoorGate,
roots.CameraInsideCell,
renderSky,
drawSkyThisFrame,
terrainDrawn,
terrainClipMode,
skyDrawn,
depthClear,
outdoorSceneryDrawn,
liveDynamicDrawnCount,
sceneParticles,
pviewResult,
camera.Position,
roots.PlayerViewPosition);
WorldSceneDiagnosticOutcome diagnostic = _diagnostics.DrawAndPublish(
in camera,
_entities.LandblockBounds);
CompleteWorldFrame();
worldFrameStarted = false;
pviewFrameStarted = false;
_selection?.CompleteFrame();
selectionFrameStarted = false;
return new WorldRenderFrameOutcome(
diagnostic.VisibleLandblocks,
diagnostic.TotalLandblocks,
NormalWorldDrawn: true);
WorldRenderFrameOutcome CompleteSkippedWorld()
{
CompleteWorldFrame();
worldFrameStarted = false;
pviewFrameStarted = false;
_selection?.CompleteFrame();
selectionFrameStarted = false;
return default;
}
}
catch (Exception renderFailure)
{
List<Exception>? abortFailures = AbortFrame(
worldFrameStarted,
pviewFrameStarted,
selectionFrameStarted);
if (abortFailures is { Count: > 0 })
{
abortFailures.Insert(0, renderFailure);
throw new AggregateException(
"World rendering failed and the incomplete frame could not be fully aborted.",
abortFailures);
}
throw;
}
}
private void CompleteWorldFrame()
{
_alpha.EndFrame();
if (_passes.TerrainVisibleCellIds is { } visibleTerrainCells)
_particleVisibility.MarkVisibleCells(visibleTerrainCells);
_particleVisibility.CompleteFrame();
}
private List<Exception>? AbortFrame(
bool worldFrameStarted,
bool pviewFrameStarted,
bool selectionFrameStarted)
{
List<Exception>? failures = null;
if (worldFrameStarted)
{
if (pviewFrameStarted)
TryAbort(_pview.AbortFrame);
TryAbort(_passes.AbortFrame);
TryAbort(_particleVisibility.AbortFrame);
TryAbort(_alpha.AbortFrame);
}
if (selectionFrameStarted && _selection is not null)
TryAbort(_selection.AbortFrame);
return failures;
void TryAbort(Action abort)
{
try
{
abort();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
}
}

View file

@ -0,0 +1,117 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Streaming;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
internal interface IWorldSceneSkyStateSource
{
DayGroupData? ActiveDayGroup { get; }
float DayFraction { get; }
}
/// <summary>
/// Holds the selected retail day group beside the world clock that provides
/// its current interpolation point. Day-group selection remains an update-time
/// concern; render owners receive this read-only seam.
/// </summary>
internal sealed class WorldSceneSkyState : IWorldSceneSkyStateSource
{
private readonly WorldTimeService _worldTime;
public WorldSceneSkyState(WorldTimeService worldTime)
{
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
}
public DayGroupData? ActiveDayGroup { get; set; }
public float DayFraction => (float)_worldTime.DayFraction;
}
internal interface IWorldSceneEntitySource
{
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
{ get; }
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds { get; }
}
internal sealed class RuntimeWorldSceneEntitySource : IWorldSceneEntitySource
{
private readonly GpuWorldState _world;
public RuntimeWorldSceneEntitySource(GpuWorldState world)
{
_world = world ?? throw new ArgumentNullException(nameof(world));
}
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries =>
_world.LandblockEntries;
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
_world.LandblockBounds;
}
internal interface IWorldScenePViewDiagnosticSource
{
Vector3 RawPlayerPositionOr(Vector3 fallback);
float PlayerYaw { get; }
float? SampleTerrainZ(float worldX, float worldY);
CameraCellResolution CameraCellResolution { get; }
}
internal sealed class RuntimeWorldScenePViewDiagnosticSource :
IWorldScenePViewDiagnosticSource
{
private readonly ILocalPlayerControllerSource _player;
private readonly PhysicsEngine _physics;
private readonly CellVisibility _visibility;
public RuntimeWorldScenePViewDiagnosticSource(
ILocalPlayerControllerSource player,
PhysicsEngine physics,
CellVisibility visibility)
{
_player = player ?? throw new ArgumentNullException(nameof(player));
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
_visibility = visibility ?? throw new ArgumentNullException(nameof(visibility));
}
public Vector3 RawPlayerPositionOr(Vector3 fallback) =>
_player.Controller?.Position ?? fallback;
public float PlayerYaw => _player.Controller?.Yaw ?? 0f;
public float? SampleTerrainZ(float worldX, float worldY) =>
_physics.SampleTerrainZ(worldX, worldY);
public CameraCellResolution CameraCellResolution =>
_visibility.LastCameraCellResolution;
}
internal interface IWorldSceneDebugStateSource
{
bool CollisionWireframesVisible { get; }
}
internal sealed class WorldSceneDebugState : IWorldSceneDebugStateSource
{
public bool CollisionWireframesVisible { get; private set; }
public bool ToggleCollisionWireframes()
{
CollisionWireframesVisible = !CollisionWireframesVisible;
return CollisionWireframesVisible;
}
}

View file

@ -29,7 +29,7 @@ public sealed class GameWindowRenderLeafCompositionTests
"_renderFrameResources!.Prepare(frameInput);", "_renderFrameResources!.Prepare(frameInput);",
"_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);", "_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);",
"_renderWeatherFrame!.Tick(deltaSeconds);", "_renderWeatherFrame!.Tick(deltaSeconds);",
"_retailSelectionScene?.BeginFrame();"); "_worldSceneRenderer!.Render(frameInput);");
Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source); Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source);
Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source); Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source);
Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source); Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source);
@ -126,12 +126,10 @@ public sealed class GameWindowRenderLeafCompositionTests
{ {
string source = GameWindowSource(); string source = GameWindowSource();
Assert.Contains("bool normalWorldDrawn = false;", source);
AssertAppearsInOrder( AssertAppearsInOrder(
source, source,
"if (IsLiveModeWaitingForLogin)", "_worldSceneRenderer!.Render(frameInput);",
"goto SkipWorldGeometry;", "worldOutcome.NormalWorldDrawn;",
"normalWorldDrawn = true;",
"bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;", "bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;",
"normalWorldDrawn),", "normalWorldDrawn),",
"screenshotCaptured)));"); "screenshotCaptured)));");
@ -158,8 +156,9 @@ public sealed class GameWindowRenderLeafCompositionTests
Assert.Contains( Assert.Contains(
"new AcDream.App.Rendering.TerrainDrawDiagnosticsController(", "new AcDream.App.Rendering.TerrainDrawDiagnosticsController(",
source); source);
Assert.Contains("_terrainDrawDiagnostics!.Begin();", source); Assert.Contains("new AcDream.App.Rendering.WorldScenePassExecutor(", source);
Assert.Contains("_terrainDrawDiagnostics.Complete();", source); Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source);
Assert.DoesNotContain("_terrainDrawDiagnostics.Complete();", source);
} }
private static string GameWindowSource() => File.ReadAllText(Path.Combine( private static string GameWindowSource() => File.ReadAllText(Path.Combine(

View file

@ -0,0 +1,106 @@
using AcDream.App.Rendering;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering;
public sealed class RenderFrameGlStateControllerTests
{
[Fact]
public void RestoreFrameDefaults_ReestablishesTheCompleteFrameGlobalContract()
{
var api = new RecordingApi();
var state = new RenderFrameGlStateController(api);
state.RestoreFrameDefaults();
Assert.Equal(
[
"cap:ScissorTest:False",
"cap:StencilTest:False",
"cap:Blend:False",
"cap:SampleAlphaToMaskSgis:False",
"cap:ClipDistance0:False",
"cap:ClipDistance1:False",
"cap:ClipDistance2:False",
"cap:ClipDistance3:False",
"cap:ClipDistance4:False",
"cap:ClipDistance5:False",
"cap:ClipDistance6:False",
"cap:ClipDistance7:False",
"color-mask:True:True:True:True",
"stencil-mask:255",
"depth-mask:True",
"depth-func:Less",
"cap:DepthTest:True",
"cap:CullFace:False",
"cull:Back",
"front:CW",
"program:0",
"vao:0",
"buffer:ArrayBuffer:0",
],
api.Calls);
}
[Fact]
public void PortalDepthMask_SuccessExitRestoresTheSameCullOffConvention()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"PortalDepthMaskRenderer.cs"));
int restore = source.IndexOf(
"// ---- restore the frame-global convention ----",
StringComparison.Ordinal);
Assert.True(restore >= 0, "Missing portal-depth state restore block.");
string restoreBlock = source[restore..];
Assert.Contains("_gl.Disable(EnableCap.CullFace);", restoreBlock);
Assert.DoesNotContain("_gl.Enable(EnableCap.CullFace);", restoreBlock);
}
private static string FindRepoRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
private sealed class RecordingApi : IRenderFrameGlStateApi
{
public List<string> Calls { get; } = [];
public void SetCapability(EnableCap capability, bool enabled) =>
Calls.Add($"cap:{capability}:{enabled}");
public void ColorMask(bool red, bool green, bool blue, bool alpha) =>
Calls.Add($"color-mask:{red}:{green}:{blue}:{alpha}");
public void StencilMask(uint mask) => Calls.Add($"stencil-mask:{mask}");
public void DepthMask(bool enabled) => Calls.Add($"depth-mask:{enabled}");
public void DepthFunc(DepthFunction function) =>
Calls.Add($"depth-func:{function}");
public void CullFace(TriangleFace face) => Calls.Add($"cull:{face}");
public void FrontFace(FrontFaceDirection direction) =>
Calls.Add($"front:{direction}");
public void UseProgram(uint program) => Calls.Add($"program:{program}");
public void BindVertexArray(uint vertexArray) => Calls.Add($"vao:{vertexArray}");
public void BindBuffer(BufferTargetARB target, uint buffer) =>
Calls.Add($"buffer:{target}:{buffer}");
}
}

View file

@ -87,10 +87,8 @@ public sealed class RenderFrameResourceControllerTests
"_particleVisibility.Reset();", "_particleVisibility.Reset();",
"SkyKeyframe sky = _worldTime.CurrentSky;", "SkyKeyframe sky = _worldTime.CurrentSky;",
"AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky);", "AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky);",
"_gl.DepthMask(true);", "_frameGlState.RestoreFrameDefaults();",
"_gl.Clear(", "_gl.Clear(",
"_gl.CullFace(TriangleFace.Back);",
"_gl.FrontFace(FrontFaceDirection.CW);",
"_diagnostics.EmitGlStateTripwireIfChanged("); "_diagnostics.EmitGlStateTripwireIfChanged(");
} }

View file

@ -179,6 +179,76 @@ public sealed class RetailAlphaQueueTests
log); log);
} }
[Fact]
public void AbortFrame_DiscardsPayloadAndAllowsTheNextFrameToRender()
{
var log = new List<string>();
var source = new RecordingSource("alpha", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
queue.Submit(source, 1, 12f);
queue.AbortFrame();
Assert.False(queue.IsCollecting);
Assert.Equal(0, queue.PendingCount);
Assert.Empty(log);
Assert.Equal(1, source.ResetCount);
queue.BeginFrame();
queue.Submit(source, 2, 12f);
queue.EndFrame();
Assert.Equal(["alpha:2"], log);
Assert.Equal(2, source.ResetCount);
}
[Fact]
public void EndFrame_DrawAndResetFailuresPreserveThePrimaryFailureAndClearTheFrame()
{
var drawSource = new FailureSource("draw failed", "first reset failed");
var secondSource = new FailureSource(null, null);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
queue.Submit(drawSource, 1, 20f);
queue.Submit(secondSource, 2, 10f);
AggregateException failure = Assert.Throws<AggregateException>(queue.EndFrame);
Assert.Collection(
failure.InnerExceptions,
error => Assert.Equal("draw failed", error.Message),
error => Assert.Equal("first reset failed", error.Message));
Assert.Equal(1, drawSource.ResetCount);
Assert.Equal(1, secondSource.ResetCount);
Assert.Equal(0, queue.PendingCount);
Assert.False(queue.IsCollecting);
}
[Fact]
public void EndFrame_MultipleResetFailuresAttemptEverySourceAndClearTheFrame()
{
var first = new FailureSource(null, "first reset failed");
var second = new FailureSource(null, "second reset failed");
var queue = new RetailAlphaQueue();
queue.BeginFrame();
queue.Submit(first, 1, 20f);
queue.Submit(second, 2, 10f);
AggregateException failure = Assert.Throws<AggregateException>(queue.EndFrame);
Assert.Collection(
failure.InnerExceptions,
error => Assert.Equal("first reset failed", error.Message),
error => Assert.Equal("second reset failed", error.Message));
Assert.Equal(1, first.ResetCount);
Assert.Equal(1, second.ResetCount);
Assert.Equal(0, queue.PendingCount);
Assert.False(queue.IsCollecting);
}
[Fact] [Fact]
public void ComputeViewerDistance_UsesTransformedGfxSortCenter() public void ComputeViewerDistance_UsesTransformedGfxSortCenter()
{ {
@ -224,4 +294,28 @@ public sealed class RetailAlphaQueueTests
public void ResetAlphaSubmissions() => ResetCount++; public void ResetAlphaSubmissions() => ResetCount++;
} }
private sealed class FailureSource(
string? drawFailure,
string? resetFailure) : IRetailAlphaDrawSource
{
public int ResetCount { get; private set; }
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
{
}
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
{
if (drawFailure is not null)
throw new InvalidOperationException(drawFailure);
}
public void ResetAlphaSubmissions()
{
ResetCount++;
if (resetFailure is not null)
throw new InvalidOperationException(resetFailure);
}
}
} }

View file

@ -229,7 +229,8 @@ public sealed class RetailPViewPassExecutorTests
"GameWindow.cs")); "GameWindow.cs"));
Assert.Contains("new AcDream.App.Rendering.RetailPViewPassExecutor(", source); Assert.Contains("new AcDream.App.Rendering.RetailPViewPassExecutor(", source);
Assert.Contains("new AcDream.App.Rendering.RetailPViewFrameInput", source); Assert.Contains("new AcDream.App.Rendering.WorldScenePViewRenderer(", source);
Assert.DoesNotContain("new AcDream.App.Rendering.RetailPViewFrameInput", source);
Assert.DoesNotContain("RetailPViewDrawContext", source); Assert.DoesNotContain("RetailPViewDrawContext", source);
Assert.DoesNotContain("DrawLandscapeSlice =", source); Assert.DoesNotContain("DrawLandscapeSlice =", source);
Assert.DoesNotContain("DrawExitPortalMasks =", source); Assert.DoesNotContain("DrawExitPortalMasks =", source);
@ -475,6 +476,8 @@ public sealed class RetailPViewPassExecutorTests
private sealed class RecordingExecutor : IRetailPViewPassExecutor, IDisposable private sealed class RecordingExecutor : IRetailPViewPassExecutor, IDisposable
{ {
public void AbortFrame() => Operations.Add("abort");
private readonly ClipFrame _clipFrame = ClipFrame.NoClip(); private readonly ClipFrame _clipFrame = ClipFrame.NoClip();
public List<string> Operations { get; } = []; public List<string> Operations { get; } = [];

View file

@ -45,6 +45,29 @@ public sealed class RetailSelectionSceneTests
Assert.False(scene.TryGetLighting(entity.ServerGuid, entity.Id, out _)); Assert.False(scene.TryGetLighting(entity.ServerGuid, entity.Id, out _));
} }
[Fact]
public void AbortFrame_DiscardsBuildingSelectionAndPreservesPublishedFrame()
{
var scene = CreateScene();
var published = Entity(localId: 46u, serverGuid: 0x5000_0046u);
Publish(scene, published);
var rejected = Entity(localId: 47u, serverGuid: 0x5000_0047u);
scene.BeginFrame();
(Matrix4x4 view, Matrix4x4 projection) = Camera();
scene.SetViewFrustum(FrustumPlanes.FromViewProjection(view * projection));
scene.AddVisiblePart(
rejected,
partIndex: 0,
gfxObjId: 0x0100_0001u,
Matrix4x4.CreateTranslation(0f, 0f, -5f));
scene.AbortFrame();
RetailSelectionHit? hit = PickCenter(scene);
Assert.NotNull(hit);
Assert.Equal(published.Id, hit.Value.LocalEntityId);
}
private static RetailSelectionScene CreateScene( private static RetailSelectionScene CreateScene(
RetailSelectionLightingPulse? pulse = null) RetailSelectionLightingPulse? pulse = null)
{ {

View file

@ -64,4 +64,35 @@ public sealed class ParticleVisibilityControllerTests
controller.Apply(particles, 1f); controller.Apply(particles, 1f);
Assert.False(Assert.Single(particles.EnumerateEmitters()).ViewEligible); Assert.False(Assert.Single(particles.EnumerateEmitters()).ViewEligible);
} }
[Fact]
public void AbortedFrame_PreservesTheLastCompletedWorldView()
{
var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(1));
int handle = particles.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000003u,
Type = ParticleType.Still,
MaxDegradeDistance = 100f,
MaxParticles = 1,
},
Vector3.Zero,
attachedObjectId: 42u);
particles.UpdateEmitterOwnerCell(handle, 0x01010001u);
var controller = new ParticleVisibilityController();
controller.BeginFrame(Vector3.Zero);
controller.UseWorldView();
controller.MarkVisibleCells([0x01010001u]);
controller.CompleteFrame();
controller.BeginFrame(new Vector3(1000f, 1000f, 0f));
controller.UseWorldView();
controller.MarkVisibleCells([0x02020001u]);
controller.AbortFrame();
controller.Apply(particles, 1f);
Assert.True(Assert.Single(particles.EnumerateEmitters()).ViewEligible);
}
} }

View file

@ -325,25 +325,32 @@ public sealed class WorldRenderFrameBuilderTests
} }
[Fact] [Fact]
public void Game_window_uses_the_typed_builder_and_releases_it_before_render_owners() public void World_scene_uses_the_typed_builder_and_game_window_releases_it_before_render_owners()
{ {
string source = GameWindowSource(); string gameWindow = GameWindowSource();
string worldScene = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"WorldSceneRenderer.cs"));
Assert.Equal( Assert.Equal(
1, 1,
CountOccurrences(source, "_worldRenderFrameBuilder!.Build(")); CountOccurrences(worldScene, "_frames.Build("));
Assert.DoesNotContain("private void UpdateSunFromSky(", source, StringComparison.Ordinal); Assert.DoesNotContain("private void UpdateSunFromSky(", gameWindow, StringComparison.Ordinal);
Assert.DoesNotContain("private void UpdateSkyPes(", source, StringComparison.Ordinal); Assert.DoesNotContain("private void UpdateSkyPes(", gameWindow, StringComparison.Ordinal);
Assert.DoesNotContain("private static float ParseEnvFloat(", source, StringComparison.Ordinal); Assert.DoesNotContain("private static float ParseEnvFloat(", gameWindow, StringComparison.Ordinal);
AssertAppearsInOrder( AssertAppearsInOrder(
source, worldScene,
"if (_cameraController is not null && !portalViewportVisible)", "_alpha.BeginFrame();",
"_retailAlphaQueue.BeginFrame();", "_frames.Build(",
"_worldRenderFrameBuilder!.Build(", "if (_login.IsWaitingForLogin)",
"if (IsLiveModeWaitingForLogin)", "_passes.DrawFlatTerrain(",
"normalWorldDrawn = true;"); "NormalWorldDrawn: true");
AssertAppearsInOrder( AssertAppearsInOrder(
source, gameWindow,
"_worldSceneRenderer = null;",
"_worldRenderFrameBuilder = null;", "_worldRenderFrameBuilder = null;",
"_skyPesFrame = null;", "_skyPesFrame = null;",
"new(\"equipped children\"", "new(\"equipped children\"",

View file

@ -0,0 +1,744 @@
using System.Numerics;
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Rendering;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class WorldSceneRendererTests
{
[Fact]
public void PortalViewport_PublishesEmptySelectionFrameAndSkipsWorldOwners()
{
var rig = new Rig(portalVisible: true, waitingForLogin: false, clipRoot: null);
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
Assert.Equal(default, result);
Assert.Equal(["selection:begin", "selection:complete"], rig.Calls);
}
[Fact]
public void LoginWait_DrawsFlatSkyThenCompletesFrameWithoutWorldGeometry()
{
var rig = new Rig(portalVisible: false, waitingForLogin: true, clipRoot: null);
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
Assert.False(result.NormalWorldDrawn);
Assert.Equal(0, result.VisibleLandblocks);
Assert.Equal(0, result.TotalLandblocks);
Assert.Equal(
[
"selection:begin",
"alpha:begin",
"frame:build",
"passes:begin",
"flat:clip",
"flat:sky",
"alpha:end",
"visibility:mark",
"visibility:complete",
"selection:complete",
],
rig.Calls);
}
[Fact]
public void FlatWorld_PreservesTerrainEntityParticleWeatherAndCompletionOrder()
{
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
Assert.Equal(new WorldRenderFrameOutcome(3, 8, true), result);
Assert.Equal(
[
"selection:begin",
"alpha:begin",
"frame:build",
"passes:begin",
"flat:clip",
"flat:sky",
"flat:terrain",
"flat:entities",
"frame:clear-cells",
"passes:disable-clip",
"particles:global",
"flat:weather",
"diagnostics:signature:OutdoorRoot",
"diagnostics:draw",
"alpha:end",
"visibility:mark",
"visibility:complete",
"selection:complete",
],
rig.Calls);
Assert.Equal(rig.Foundation, rig.Frames.Foundation);
Assert.False(rig.Frames.WaitingForLogin);
Assert.Same(rig.DayGroup, rig.Frames.ActiveDayGroup);
Assert.Equal(rig.Foundation, rig.Passes.SkyFoundation);
Assert.Equal(rig.Foundation, rig.Passes.WeatherFoundation);
Assert.Same(rig.DayGroup, rig.Passes.SkyDayGroup);
Assert.Same(rig.DayGroup, rig.Passes.WeatherDayGroup);
Assert.Equal(rig.DayFraction, rig.Passes.SkyDayFraction);
Assert.Equal(rig.DayFraction, rig.Passes.WeatherDayFraction);
}
[Fact]
public void PViewWorld_UsesOnePViewProductAndPublishesItsDrawableCells()
{
var root = new LoadedCell
{
CellId = 0x01010001u,
IsOutdoorNode = false,
};
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root);
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
Assert.True(result.NormalWorldDrawn);
Assert.Equal(
[
"selection:begin",
"alpha:begin",
"frame:build",
"passes:begin",
"pview:draw",
"visibility:mark",
"frame:observe-cells",
"diagnostics:pview",
"passes:disable-clip",
"particles:none",
"diagnostics:signature:RetailPViewInside",
"diagnostics:draw",
"alpha:end",
"visibility:mark",
"visibility:complete",
"selection:complete",
],
rig.Calls);
Assert.DoesNotContain("flat:clip", rig.Calls);
Assert.DoesNotContain("flat:terrain", rig.Calls);
Assert.DoesNotContain("flat:entities", rig.Calls);
Assert.NotNull(rig.PView.LastInput);
Assert.Same(rig.DayGroup, rig.PView.LastInput!.ActiveDayGroup);
Assert.Equal(rig.DayFraction, rig.PView.LastInput.DayFraction);
Assert.Equal(rig.Foundation.Sky, rig.PView.LastInput.SkyKeyframe);
Assert.True(rig.PView.LastInput.RenderWeather);
Assert.Equal(root.CellId, rig.PView.LastInput.ViewerCellId);
Assert.Equal(0x01010002u, rig.PView.LastInput.PlayerCellId);
Assert.Equal(4, rig.PView.LastInput.RenderRadius);
}
[Fact]
public void OutdoorPView_UsesPViewOwnersForPostWorldParticlesWithoutFlatWeather()
{
var root = new LoadedCell
{
CellId = 0x01010001u,
IsOutdoorNode = true,
};
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root);
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
Assert.True(result.NormalWorldDrawn);
Assert.Contains("particles:pviewScoped+unattached", rig.Calls);
Assert.Same(rig.PView.OutdoorSceneParticleEntityIds, rig.Passes.ParticleOwners);
Assert.Equal([0xCAFEu], rig.Passes.ParticleOwners);
Assert.DoesNotContain("flat:weather", rig.Calls);
}
[Fact]
public void SealedInteriorPView_DisablesWeatherAndPropagatesEnvironmentOverride()
{
var root = new LoadedCell
{
CellId = 0x01010001u,
IsOutdoorNode = false,
};
var rig = new Rig(
portalVisible: false,
waitingForLogin: false,
clipRoot: root,
playerSeenOutside: false,
environOverride: EnvironOverride.BlueFog);
rig.Renderer.Render(default);
Assert.NotNull(rig.PView.LastInput);
Assert.False(rig.PView.LastInput!.RenderWeather);
Assert.True(rig.PView.LastInput.EnvironOverrideActive);
}
[Fact]
public void PViewFailure_AbortsPortalStateAndRecoversOnTheNextFrame()
{
var root = new LoadedCell
{
CellId = 0x01010001u,
IsOutdoorNode = false,
};
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root);
rig.PView.ThrowOnDraw = true;
Assert.Throws<InvalidOperationException>(() => rig.Renderer.Render(default));
Assert.Equal(
[
"selection:begin",
"alpha:begin",
"frame:build",
"passes:begin",
"pview:draw",
"pview:abort",
"passes:abort",
"visibility:abort",
"alpha:abort",
"selection:abort",
],
rig.Calls);
rig.Calls.Clear();
rig.PView.ThrowOnDraw = false;
Assert.True(rig.Renderer.Render(default).NormalWorldDrawn);
Assert.Contains("selection:complete", rig.Calls);
}
[Fact]
public void DrawFailure_DoesNotPublishPartialAlphaSelectionOrParticleFrames()
{
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
rig.Passes.ThrowOnTerrain = true;
Assert.Throws<InvalidOperationException>(() => rig.Renderer.Render(default));
Assert.Equal(
[
"selection:begin",
"alpha:begin",
"frame:build",
"passes:begin",
"flat:clip",
"flat:sky",
"flat:terrain",
"passes:abort",
"visibility:abort",
"alpha:abort",
"selection:abort",
],
rig.Calls);
rig.Calls.Clear();
rig.Passes.ThrowOnTerrain = false;
WorldRenderFrameOutcome recovered = rig.Renderer.Render(default);
Assert.True(recovered.NormalWorldDrawn);
Assert.Contains("alpha:end", rig.Calls);
Assert.Contains("selection:complete", rig.Calls);
}
[Fact]
public void AbortFailure_IsAggregatedAfterEveryOtherFrameOwnerIsAborted()
{
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
rig.Passes.ThrowOnTerrain = true;
rig.Passes.ThrowOnAbort = true;
AggregateException failure = Assert.Throws<AggregateException>(
() => rig.Renderer.Render(default));
Assert.Contains(
failure.InnerExceptions,
error => error is InvalidOperationException
&& error.Message == "terrain failed");
Assert.Contains(
failure.InnerExceptions,
error => error is InvalidOperationException
&& error.Message == "pass abort failed");
Assert.Contains("visibility:abort", rig.Calls);
Assert.Contains("alpha:abort", rig.Calls);
Assert.Contains("selection:abort", rig.Calls);
rig.Calls.Clear();
rig.Passes.ThrowOnTerrain = false;
rig.Passes.ThrowOnAbort = false;
Assert.True(rig.Renderer.Render(default).NormalWorldDrawn);
}
[Fact]
public void GameWindow_DelegatesWorldRenderingWithoutOwningDrawBranches()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Rendering",
"GameWindow.cs"));
Assert.Contains("new AcDream.App.Rendering.WorldSceneRenderer(", source);
Assert.Equal(1, CountOccurrences(source, "_worldSceneRenderer!.Render(frameInput);"));
Assert.DoesNotContain("_retailPViewRenderer.DrawInside(", source);
Assert.DoesNotContain("_retailAlphaQueue.BeginFrame();", source);
Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source);
Assert.DoesNotContain("SkipWorldGeometry:", source);
}
[Fact]
public void ExtractedWorldOwners_RetainNoWindowDelegateOrBorrowedPViewProduct()
{
Type[] ownerTypes =
[
typeof(WorldSceneRenderer),
typeof(WorldScenePassExecutor),
typeof(WorldSceneDiagnosticsController),
];
Type[] borrowedTypes =
[
typeof(RetailPViewFrameResult),
typeof(PortalVisibilityFrame),
typeof(ClipFrameAssembly),
];
foreach (Type owner in ownerTypes)
{
foreach (FieldInfo field in owner.GetFields(
BindingFlags.Instance
| BindingFlags.Public
| BindingFlags.NonPublic))
{
Assert.False(
typeof(GameWindow).IsAssignableFrom(field.FieldType),
$"{owner.Name}.{field.Name} retains GameWindow.");
Assert.False(
typeof(Delegate).IsAssignableFrom(field.FieldType),
$"{owner.Name}.{field.Name} retains a delegate callback.");
Assert.DoesNotContain(field.FieldType, borrowedTypes);
}
}
}
private sealed class Rig
{
public Rig(
bool portalVisible,
bool waitingForLogin,
LoadedCell? clipRoot,
bool? playerSeenOutside = null,
EnvironOverride environOverride = EnvironOverride.None)
{
Calls = [];
DayGroup = new DayGroupData { Name = "sentinel-day-group" };
DayFraction = 0.375f;
Foundation = new RenderFrameFoundation(
portalVisible,
new SkyKeyframe(
0.25f,
35f,
45f,
new Vector3(0.1f, 0.2f, 0.3f),
0.8f,
new Vector3(0.4f, 0.5f, 0.6f),
0.7f,
new Vector3(0.7f, 0.8f, 0.9f),
0.2f),
new AtmosphereSnapshot(
WeatherKind.Clear,
1f,
new Vector3(0.1f, 0.2f, 0.3f),
1f,
100f,
FogMode.Linear,
0f,
environOverride));
var foundation = new FoundationSource(Foundation);
var login = new LoginSource(waitingForLogin);
var sky = new SkySource(DayGroup, DayFraction);
var frame = CreateFrame(
clipRoot,
playerSeenOutside ?? clipRoot is not null);
Frames = new FrameBuilder(Calls, frame);
var selection = new SelectionFrame(Calls);
var alpha = new AlphaFrame(Calls);
var visibility = new ParticleVisibility(Calls);
PView = new PViewRenderer(Calls);
Passes = new PassExecutor(Calls);
var diagnostics = new Diagnostics(Calls);
Renderer = new WorldSceneRenderer(
foundation,
login,
sky,
Frames,
new EntitySource(),
selection,
alpha,
visibility,
PView,
new PViewCells(),
Passes,
new WorldRenderRangeState(4, 12),
diagnostics);
}
public List<string> Calls { get; }
public RenderFrameFoundation Foundation { get; }
public DayGroupData DayGroup { get; }
public float DayFraction { get; }
public FrameBuilder Frames { get; }
public PViewRenderer PView { get; }
public PassExecutor Passes { get; }
public WorldSceneRenderer Renderer { get; }
}
private sealed class FoundationSource(RenderFrameFoundation foundation) :
IRenderFrameFoundationSource
{
public RenderFrameFoundation Foundation { get; } = foundation;
}
private sealed class LoginSource(bool waiting) : IRenderLoginStateSource
{
public bool IsWaitingForLogin { get; } = waiting;
}
private sealed class SkySource(
DayGroupData activeDayGroup,
float dayFraction) : IWorldSceneSkyStateSource
{
public DayGroupData? ActiveDayGroup { get; } = activeDayGroup;
public float DayFraction { get; } = dayFraction;
}
private sealed class FrameBuilder(List<string> calls, WorldRenderFrame frame) :
IWorldRenderFrameBuilder
{
public RenderFrameFoundation Foundation { get; private set; }
public bool WaitingForLogin { get; private set; }
public DayGroupData? ActiveDayGroup { get; private set; }
public WorldRenderFrame Build(
in RenderFrameFoundation foundation,
bool waitingForLogin,
DayGroupData? activeDayGroup)
{
calls.Add("frame:build");
Foundation = foundation;
WaitingForLogin = waitingForLogin;
ActiveDayGroup = activeDayGroup;
return frame;
}
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells) =>
calls.Add("frame:observe-cells");
public void ClearDrawableCells() => calls.Add("frame:clear-cells");
}
private sealed class EntitySource : IWorldSceneEntitySource
{
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries =>
Array.Empty<(uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)>();
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
Array.Empty<(uint, Vector3, Vector3)>();
}
private sealed class SelectionFrame(List<string> calls) : IWorldSceneSelectionFrame
{
public void BeginFrame() => calls.Add("selection:begin");
public void CompleteFrame() => calls.Add("selection:complete");
public void AbortFrame() => calls.Add("selection:abort");
}
private sealed class AlphaFrame(List<string> calls) : IWorldSceneAlphaFrame
{
public void BeginFrame() => calls.Add("alpha:begin");
public void EndFrame() => calls.Add("alpha:end");
public void AbortFrame() => calls.Add("alpha:abort");
}
private sealed class ParticleVisibility(List<string> calls) :
IWorldSceneParticleVisibility
{
public void MarkVisibleCells(HashSet<uint> cellIds) =>
calls.Add("visibility:mark");
public void CompleteFrame() => calls.Add("visibility:complete");
public void AbortFrame() => calls.Add("visibility:abort");
}
private sealed class PViewRenderer : IWorldScenePViewRenderer
{
private readonly List<string> _calls;
private readonly RetailPViewFrameResult _interiorResult;
private readonly RetailPViewFrameResult _outdoorResult;
public PViewRenderer(List<string> calls)
{
_calls = calls;
_interiorResult = new RetailPViewFrameResult().Reset(
new PortalVisibilityFrame(),
new ClipFrameAssembly(),
[],
new InteriorEntityPartition.Result());
var outdoorPortalFrame = new PortalVisibilityFrame();
outdoorPortalFrame.OutsideView.Add(new ViewPolygon(
[
new Vector2(-1f, -1f),
new Vector2(1f, -1f),
new Vector2(1f, 1f),
new Vector2(-1f, 1f),
]));
_outdoorResult = new RetailPViewFrameResult().Reset(
outdoorPortalFrame,
ClipFrameAssembler.Assemble(ClipFrame.NoClip(), outdoorPortalFrame),
[],
new InteriorEntityPartition.Result());
}
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; } =
new HashSet<uint> { 0xCAFEu };
public RetailPViewFrameInput? LastInput { get; private set; }
public bool ThrowOnDraw { get; set; }
public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input)
{
_calls.Add("pview:draw");
LastInput = input;
if (ThrowOnDraw)
throw new InvalidOperationException("pview failed");
return input.RootCell.IsOutdoorNode ? _outdoorResult : _interiorResult;
}
public void AbortFrame() => _calls.Add("pview:abort");
}
private sealed class PViewCells : IRetailPViewCellSource
{
public LoadedCell? Find(uint cellId) => null;
}
private sealed class PassExecutor(List<string> calls) : IWorldScenePassExecutor
{
public bool ThrowOnTerrain { get; set; }
public bool ThrowOnAbort { get; set; }
public HashSet<uint>? TerrainVisibleCellIds { get; } = [0x01010001u];
public RenderFrameFoundation? SkyFoundation { get; private set; }
public RenderFrameFoundation? WeatherFoundation { get; private set; }
public DayGroupData? SkyDayGroup { get; private set; }
public DayGroupData? WeatherDayGroup { get; private set; }
public float SkyDayFraction { get; private set; }
public float WeatherDayFraction { get; private set; }
public IReadOnlySet<uint>? ParticleOwners { get; private set; }
public void BeginFrame() => calls.Add("passes:begin");
public void PrepareFlatWorldClip() => calls.Add("flat:clip");
public void DrawFlatSky(
in WorldCameraFrame camera,
in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup,
float dayFraction)
{
calls.Add("flat:sky");
SkyFoundation = foundation;
SkyDayGroup = activeDayGroup;
SkyDayFraction = dayFraction;
}
public void DrawFlatTerrain(in WorldCameraFrame camera, uint? playerLandblockId)
{
calls.Add("flat:terrain");
if (ThrowOnTerrain)
throw new InvalidOperationException("terrain failed");
}
public void DrawFlatEntities(
in WorldCameraFrame camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries,
uint? playerLandblockId,
HashSet<uint> animatedEntityIds) => calls.Add("flat:entities");
public string DrawPostWorldParticles(
LoadedCell? clipRoot,
ClipFrameAssembly? clipAssembly,
in WorldCameraFrame camera,
IReadOnlySet<uint> outdoorOwnerIds,
string currentSignature)
{
ParticleOwners = outdoorOwnerIds;
string kind = clipRoot switch
{
null => "global",
{ IsOutdoorNode: true } => currentSignature == "none"
? "unattached"
: currentSignature + "+unattached",
_ => currentSignature,
};
calls.Add($"particles:{kind}");
return kind;
}
public void DrawFlatWeather(
in WorldCameraFrame camera,
in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup,
float dayFraction)
{
calls.Add("flat:weather");
WeatherFoundation = foundation;
WeatherDayGroup = activeDayGroup;
WeatherDayFraction = dayFraction;
}
public void DisableClipDistances() => calls.Add("passes:disable-clip");
public void AbortFrame()
{
calls.Add("passes:abort");
if (ThrowOnAbort)
throw new InvalidOperationException("pass abort failed");
}
}
private sealed class Diagnostics(List<string> calls) : IWorldSceneDiagnostics
{
public CameraCellResolution CameraCellResolution => CameraCellResolution.None;
public void EmitPViewInput(
PortalVisibilityFrame portalFrame,
Matrix4x4 viewProjection,
LoadedCell clipRoot,
Vector3 cameraPosition,
Vector3 playerPosition) => calls.Add("diagnostics:pview");
public void EmitRenderSignature(
string branch,
LoadedCell? clipRoot,
LoadedCell? viewerRoot,
LoadedCell? playerRoot,
uint viewerCellId,
uint playerCellId,
bool playerIndoorGate,
bool cameraInsideCell,
bool renderSky,
bool drawSkyThisFrame,
bool terrainDrawn,
TerrainClipMode terrainClipMode,
bool skyDrawn,
bool depthClear,
bool outdoorSceneryDrawn,
int liveDynamicDrawnCount,
string sceneParticles,
RetailPViewFrameResult? pviewResult,
Vector3 cameraPosition,
Vector3 playerPosition) =>
calls.Add($"diagnostics:signature:{branch}");
public WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
{
calls.Add("diagnostics:draw");
return new WorldSceneDiagnosticOutcome(3, 8);
}
}
private static WorldRenderFrame CreateFrame(
LoadedCell? clipRoot,
bool playerSeenOutside)
{
var camera = new FlyCamera { Position = new Vector3(1f, 2f, 3f) };
var cameraFrame = new WorldCameraFrame(
camera,
camera.Projection,
camera.View * camera.Projection,
default,
Matrix4x4.Identity,
camera.Position);
var roots = new WorldRootFrame(
PlayerRoot: null,
PlayerSeenOutside: playerSeenOutside,
ViewerCellId: clipRoot?.CellId ?? 0u,
ViewerEyePosition: camera.Position,
PlayerViewPosition: camera.Position,
ViewerRoot: clipRoot,
CameraInsideCell: clipRoot is not null,
RootSeenOutside: true,
PlayerInsideCell: false,
PlayerLandblockId: null,
RenderCenterLandblockX: 0,
RenderCenterLandblockY: 0,
PlayerCellId: 0x01010002u,
PlayerIndoorGate: false);
return new WorldRenderFrame(
cameraFrame,
roots,
new WorldBuildingFrame(null, Array.Empty<LoadedCell>()),
[]);
}
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 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.");
}
}