diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 5f8ee709..bf61148e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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-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-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-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) | diff --git a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md index cfbaaa50..d10a9126 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md +++ b/docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md @@ -26,7 +26,7 @@ port, or resource-lifetime redesign. run the dependent audio, sky, lighting, and listener preparation. - [x] E — replace `RetailPViewDrawContext`'s callback bag with a typed `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. - [ ] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one 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; - 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 - compose concrete resource, world, private-presentation, and diagnostic owners; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 85c3458f..57f1e7a9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -65,8 +65,8 @@ public sealed class GameWindow : IDisposable // wireframes are noisy outdoors and confuse first-time users into // thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel // → Diagnostics → "Toggle collision wires" button toggles too. - private bool _debugCollisionVisible = false; - private int _debugDrawLogOnce = 0; + private readonly AcDream.App.Rendering.WorldSceneDebugState + _worldSceneDebugState = new(); // Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in // favor of the ImGui-backed DebugPanel (see _debugVm below). The @@ -107,6 +107,8 @@ public sealed class GameWindow : IDisposable _renderWeatherFrame; private AcDream.App.Rendering.WorldRenderFrameBuilder? _worldRenderFrameBuilder; + private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState; + private AcDream.App.Rendering.WorldSceneRenderer? _worldSceneRenderer; private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame; private ResourceShutdownTransaction? _shutdown; private readonly DisplayFramePacingController _displayFramePacing; @@ -219,9 +221,8 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer; private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum; - // R1 (render redesign): the per-cell DrawInside flood + its per-frame entity partition. - // _interiorRenderer is constructed once both renderers exist; _interiorPartition is rebuilt - // each frame on an indoor root (null on the outdoor root). + // R1 (render redesign): the per-cell DrawInside flood and concrete pass + // executor are borrowed by WorldSceneRenderer for each normal-world frame. private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor; private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells; @@ -229,7 +230,6 @@ public sealed class GameWindow : IDisposable _terrainDrawDiagnostics; private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; 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 // 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. private ClipFrame? _clipFrame; - // Flat-world fallback particle scratch. PView classifications belong to - // RetailPViewPassExecutor. - private readonly HashSet _visibleSceneParticleEntityIds = []; - private readonly HashSet _noSceneParticleEntityIds = []; - /// /// Phase 6.4: per-entity animation playback state for entities whose /// 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 // semantics. private long _loadedSkyDayIndex = long.MinValue; - private AcDream.Core.World.DayGroupData? _activeDayGroup; // 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 @@ -589,31 +583,6 @@ public sealed class GameWindow : IDisposable // single-flag reads instead of env-var lookups. True iff // ACDREAM_LIVE=1 was set when the window came up. // Backed by RuntimeOptions.LiveMode via the _options field. - private bool LiveModeEnabled => _options.LiveMode; - - /// - /// 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). - /// - 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; - /// /// Phase 6.6/6.7: server-guid → local WorldEntity lookup so /// UpdateMotion and UpdatePosition handlers can find the entity the @@ -715,10 +684,10 @@ public sealed class GameWindow : IDisposable // Samples (MSAA) is baked into WindowOptions correctly. GL context // sample count cannot change at runtime; all other quality fields are // applied again in OnLoad after the full settings load. - var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( + var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); var startupDisplay = startupStore.LoadDisplay(); - var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); + var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); FramePacingPolicy startupPacing = _displayFramePacing.InitializeStartup(startupDisplay.VSync); @@ -769,6 +738,8 @@ public sealed class GameWindow : IDisposable private void OnLoad() { + _worldSceneSkyState ??= new AcDream.App.Rendering.WorldSceneSkyState( + WorldTime); // Task 7: wire the physics data cache into the engine so Transition can // run narrow-phase BSP tests during FindObjCollisions. _physicsEngine.DataCache = _physicsDataCache; @@ -1080,7 +1051,7 @@ public sealed class GameWindow : IDisposable // delegates, no panel-vs-renderer-vs-state coupling. var chatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat) { - FpsProvider = () => + FpsProvider = () => (float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0), PositionProvider = () => GetDebugPlayerPosition(), }; @@ -1095,52 +1066,53 @@ public sealed class GameWindow : IDisposable // buttons back to the same routines the F2/F7/F10 // keybinds use. _debugVm = new AcDream.UI.Abstractions.Panels.Debug.DebugVM( - getPlayerPosition: () => GetDebugPlayerPosition(), - getPlayerHeadingDeg: () => GetDebugPlayerHeadingDeg(), - getPlayerCellId: () => GetDebugPlayerCellId(), - getPlayerOnGround: () => GetDebugPlayerOnGround(), - getInPlayerMode: () => _playerMode, - getInFlyMode: () => _cameraController?.IsFlyMode ?? false, - getVerticalVelocity: () => _playerController?.VerticalVelocity ?? 0f, - getEntityCount: () => _worldState.Entities.Count, - getAnimatedCount: () => _animatedEntities.Count, + getPlayerPosition: () => GetDebugPlayerPosition(), + getPlayerHeadingDeg: () => GetDebugPlayerHeadingDeg(), + getPlayerCellId: () => GetDebugPlayerCellId(), + getPlayerOnGround: () => GetDebugPlayerOnGround(), + getInPlayerMode: () => _playerMode, + getInFlyMode: () => _cameraController?.IsFlyMode ?? false, + getVerticalVelocity: () => _playerController?.VerticalVelocity ?? 0f, + getEntityCount: () => _worldState.Entities.Count, + getAnimatedCount: () => _animatedEntities.Count, getLandblocksVisible: () => _debugVmRenderFacts.DebugVmFacts.VisibleLandblocks, - getLandblocksTotal: () => + getLandblocksTotal: () => _debugVmRenderFacts.DebugVmFacts.TotalLandblocks, getShadowObjectCount: () => _physicsEngine.ShadowObjects.TotalRegistered, - getNearestObjDist: () => + getNearestObjDist: () => _debugVmRenderFacts.DebugVmFacts.NearestObjectDistance, - getNearestObjLabel: () => + getNearestObjLabel: () => _debugVmRenderFacts.DebugVmFacts.NearestObjectLabel, - getColliding: () => + getColliding: () => _debugVmRenderFacts.DebugVmFacts.Colliding, - getDebugWireframes: () => _debugCollisionVisible, - getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier + getDebugWireframes: () => + _worldSceneDebugState.CollisionWireframesVisible, + getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier - getMouseSensitivity: () => GetActiveSensitivity(), - getChaseDistance: () => _chaseCamera?.Distance ?? 0f, - getRmbOrbit: () => _rmbHeld, - getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(), - getDayFraction: () => (float)WorldTime.DayFraction, - getWeather: () => Weather.Kind.ToString(), - getActiveLights: () => Lighting.ActiveCount, - getRegisteredLights: () => Lighting.RegisteredCount, - getParticleCount: () => _particleSystem?.ActiveParticleCount ?? 0, - getFps: () => + getMouseSensitivity: () => GetActiveSensitivity(), + getChaseDistance: () => _chaseCamera?.Distance ?? 0f, + getRmbOrbit: () => _rmbHeld, + getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(), + getDayFraction: () => (float)WorldTime.DayFraction, + getWeather: () => Weather.Kind.ToString(), + getActiveLights: () => Lighting.ActiveCount, + getRegisteredLights: () => Lighting.RegisteredCount, + getParticleCount: () => _particleSystem?.ActiveParticleCount ?? 0, + getFps: () => (float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0), - getFrameMs: () => + getFrameMs: () => (float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7), - combat: Combat); - _debugVm.CycleTimeOfDay = CycleTimeOfDay; - _debugVm.CycleWeather = CycleWeather; + combat: Combat); + _debugVm.CycleTimeOfDay = CycleTimeOfDay; + _debugVm.CycleWeather = CycleWeather; _debugVm.ToggleCollisionWires = ToggleCollisionWires; // Phase K.2: free-fly toggle button — same routine the // legacy F-key alias hits. Cancels the one-shot // auto-entry if the user opts out of player mode before // it fires, so the chase camera doesn't snap on top of // the fly camera mid-inspection. - _debugVm.ToggleFlyMode = () => + _debugVm.ToggleFlyMode = () => _playerModeController?.ToggleFlyOrChase(); _combatFeedback.ViewModel = _debugVm; var debugPanel = @@ -1497,11 +1469,11 @@ public sealed class GameWindow : IDisposable TerrainTypeToLayer: terrainTypeToLayerBytes, RoadLayer: roadLayer, CornerAlphaLayers: terrainAtlas.CornerAlphaLayers, - SideAlphaLayers: terrainAtlas.SideAlphaLayers, - RoadAlphaLayers: terrainAtlas.RoadAlphaLayers, + SideAlphaLayers: terrainAtlas.SideAlphaLayers, + RoadAlphaLayers: terrainAtlas.RoadAlphaLayers, CornerAlphaTCodes: terrainAtlas.CornerAlphaTCodes, - SideAlphaTCodes: terrainAtlas.SideAlphaTCodes, - RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes); + SideAlphaTCodes: terrainAtlas.SideAlphaTCodes, + RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes); _heightTable = heightTable; _surfaceCache = new System.Collections.Concurrent.ConcurrentDictionary(); @@ -2386,20 +2358,20 @@ public sealed class GameWindow : IDisposable // LoadAndApplyPersistedSettings() above, absorbing all env-var overrides. // Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat. _nearRadius = _resolvedQuality.NearRadius; - _farRadius = _resolvedQuality.FarRadius; + _farRadius = _resolvedQuality.FarRadius; // Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and // ensures farRadius >= streamRadius. Parsed once into // RuntimeOptions.LegacyStreamRadius (null when unset or invalid). if (_options.LegacyStreamRadius is { } sr) { - _nearRadius = sr; + _nearRadius = sr; _streamingRadius = sr; // keep debug overlay in sync - _farRadius = System.Math.Max(sr, _farRadius); + _farRadius = System.Math.Max(sr, _farRadius); } Console.WriteLine( - $"streaming: nearRadius={_nearRadius} (window={2*_nearRadius+1}x{2*_nearRadius+1})" + - $" farRadius={_farRadius} (window={2*_farRadius+1}x{2*_farRadius+1})"); + $"streaming: nearRadius={_nearRadius} (window={2 * _nearRadius + 1}x{2 * _nearRadius + 1})" + + $" farRadius={_farRadius} (window={2 * _farRadius + 1}x{2 * _farRadius + 1})"); // Phase A.5 T11+: the streamer now runs on a dedicated worker thread. // loadLandblock acquires _datLock (T10) before touching DatCollection. @@ -2439,9 +2411,9 @@ public sealed class GameWindow : IDisposable _liveCenterY))), enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation), drainCompletions: _streamer.DrainCompletions, - state: _worldState, - nearRadius: _nearRadius, - farRadius: _farRadius, + state: _worldState, + nearRadius: _nearRadius, + farRadius: _farRadius, clearPendingLoads: _streamer.ClearPendingLoads, // Retained-object recovery runs after each // (dungeon-exit expand or Far→Near promote). ACE won't re-send the @@ -2805,15 +2777,18 @@ public sealed class GameWindow : IDisposable var teleportRenderState = new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource( _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 = new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation( _textureCache, _wbMeshAdapter, _worldReveal, teleportRenderState, - new AcDream.App.Rendering.RenderLoginStateSource( - _options.LiveMode, - _localPlayerMode), + renderLoginState, new AcDream.App.Rendering.LiveLoginRevealCellSource( _liveEntities!, _localPlayerIdentity), @@ -2841,7 +2816,8 @@ public sealed class GameWindow : IDisposable Weather, teleportRenderState, _particleVisibility, - _worldRenderDiagnostics!), + _worldRenderDiagnostics!, + renderFrameGlState), _renderFrameLivePreparation); _renderWeatherFrame = new AcDream.App.Rendering.RenderWeatherFrameController( @@ -2911,6 +2887,7 @@ public sealed class GameWindow : IDisposable _retailPViewPassExecutor = new AcDream.App.Rendering.RetailPViewPassExecutor( _gl!, + renderFrameGlState, new AcDream.App.Rendering.SilkRetailPViewFramebufferSource( _window!), _clipFrame!, @@ -2924,6 +2901,48 @@ public sealed class GameWindow : IDisposable _retailAlphaQueue, _worldRenderDiagnostics!, _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( liveObjectFrame, _worldState, @@ -3352,7 +3371,7 @@ public sealed class GameWindow : IDisposable 0x79u => "Thunder4Sound", 0x7Au => "Thunder5Sound", 0x7Bu => "Thunder6Sound", - _ => $"Unknown(0x{environChangeType:X2})", + _ => $"Unknown(0x{environChangeType:X2})", }; Console.WriteLine( $"live: AdminEnvirons sound cue = {name} " + @@ -3400,653 +3419,101 @@ public sealed class GameWindow : IDisposable Exception? renderFailure = null; try { - GL gl = _gl!; - var frameInput = new AcDream.App.Rendering.RenderFrameInput( - deltaSeconds, - _window!.Size.X, - _window.Size.Y); - _renderFrameResources!.Prepare(frameInput); - AcDream.App.Rendering.RenderFrameFoundation foundation = - _renderFrameResources.Foundation; - - // gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the - // normal SmartBox viewport and UIElement_Viewport's portal - // CreatureMode; it does not redraw the world behind portal space. - bool portalViewportVisible = 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 - // after the scene draws (below). ImGuiController.Update() - // consumes buffered Silk.NET input events and calls ImGui.NewFrame. - _devToolsFramePresenter?.BeginFrame((float)deltaSeconds); - - // Phase G.1: weather state machine — deterministic per-day roll - // + transitions + lightning flash. - _renderWeatherFrame!.Tick(deltaSeconds); - - // (Pre-Bug-A code spawned camera-attached rain/snow particle - // emitters here as a workaround for missing weather-mesh - // rendering. Deleted 2026-04-26 once the retail-faithful world- - // space mesh path landed in SkyRenderer.RenderWeather. Retail - // rain is GfxObj 0x01004C42/0x01004C44 — a hollow octagonal - // cylinder anchored at player_pos + (0, 0, -120m) per - // GameSky::UpdatePosition at 0x00506dd0 — drawn after the - // landblock pass per LScape::draw at 0x00506330. There is no - // server-driven weather event and no camera-attached emitter - // in retail. Snow renders identically when a Snowy DayGroup is - // active in some other Region; the partition by Properties&0x04 - // and the SkyRenderer.RenderWeather pass both pick up snow - // weather meshes for free.) - - int visibleLandblocks = 0; - int totalLandblocks = 0; - bool normalWorldDrawn = false; - - _retailSelectionScene?.BeginFrame(); - 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 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 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? envCellShellFilter = null; // drawable visible cells (cellIdToSlot keys) - PortalVisibilityFrame? sigPvFrame = null; - ClipFrameAssembly? sigClipAssembly = null; - IReadOnlySet? sigDrawableCells = null; - AcDream.App.Rendering.InteriorEntityPartition.Result? sigPartition = null; - PortalVisibilityFrame? sigExteriorPvFrame = null; - ClipFrameAssembly? sigExteriorClipAssembly = null; - IReadOnlySet? 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 - // CreatureMode portal-space viewport. The world block above is skipped - // while this replacement scene is visible. This scene and - // its projection transition draws BEFORE retained UI, so chat, windows, - // cursor, and toolbar remain visible and interactive in transit. - _localPlayerTeleport?.DrawPortalViewport( - _window!.Size.X, - _window.Size.Y, - _cameraController!.Active.Projection); - - // Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the - // viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window - // is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle). - // The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state. - _paperdollFramePresenter?.Render(); - - // Phase D.2b — retail-look UI tree (render-only; input integration deferred). - // Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own - // blend/depth state and restores. Drawn before ImGui so the devtools - // overlay composites on top during development. - if (_options.RetailUi && _retailUiRuntime is not null) - { - _retailUiRuntime.Tick(deltaSeconds); - if (_input is not null) - _retailUiRuntime.UpdateCursor(_input.Mice); - _retailUiRuntime.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); - } - - // Devtools remain above retained gameplay UI. The focused presenter - // owns menu policy, panels, and ImGui draw-data upload. - _devToolsFramePresenter?.Render( - deltaSeconds, - _window!.Size.X, - _window.Size.Y); - - // Explicit diagnostic captures are requested by the retained-UI - // automation tick above and executed only after every presentation - // layer has drawn into the back buffer on this render thread. - bool screenshotCaptured = _frameScreenshots?.CapturePending() == true; - - _renderFrameDiagnostics?.Publish( - new AcDream.App.Rendering.RenderFrameInput( + var frameInput = new AcDream.App.Rendering.RenderFrameInput( deltaSeconds, _window!.Size.X, - _window.Size.Y), - new AcDream.App.Rendering.RenderFrameOutcome( - new AcDream.App.Rendering.WorldRenderFrameOutcome( - visibleLandblocks, - totalLandblocks, - normalWorldDrawn), - new AcDream.App.Rendering.PrivatePresentationFrameOutcome( - portalViewportVisible, - screenshotCaptured))); + _window.Size.Y); + _renderFrameResources!.Prepare(frameInput); + AcDream.App.Rendering.RenderFrameFoundation foundation = + _renderFrameResources.Foundation; + + // gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the + // normal SmartBox viewport and UIElement_Viewport's portal + // CreatureMode; it does not redraw the world behind portal space. + bool portalViewportVisible = foundation.PortalViewportVisible; + + // Phase D.2a — begin ImGui frame. Paired with the Render() call + // after the scene draws (below). ImGuiController.Update() + // consumes buffered Silk.NET input events and calls ImGui.NewFrame. + _devToolsFramePresenter?.BeginFrame((float)deltaSeconds); + + // Phase G.1: weather state machine — deterministic per-day roll + // + transitions + lightning flash. + _renderWeatherFrame!.Tick(deltaSeconds); + + // (Pre-Bug-A code spawned camera-attached rain/snow particle + // emitters here as a workaround for missing weather-mesh + // rendering. Deleted 2026-04-26 once the retail-faithful world- + // space mesh path landed in SkyRenderer.RenderWeather. Retail + // rain is GfxObj 0x01004C42/0x01004C44 — a hollow octagonal + // cylinder anchored at player_pos + (0, 0, -120m) per + // GameSky::UpdatePosition at 0x00506dd0 — drawn after the + // landblock pass per LScape::draw at 0x00506330. There is no + // server-driven weather event and no camera-attached emitter + // in retail. Snow renders identically when a Snowy DayGroup is + // active in some other Region; the partition by Properties&0x04 + // and the SkyRenderer.RenderWeather pass both pick up snow + // weather meshes for free.) + + AcDream.App.Rendering.WorldRenderFrameOutcome worldOutcome = + _worldSceneRenderer!.Render(frameInput); + int visibleLandblocks = worldOutcome.VisibleLandblocks; + int totalLandblocks = worldOutcome.TotalLandblocks; + bool normalWorldDrawn = worldOutcome.NormalWorldDrawn; + + // Retail gmSmartBoxUI swaps the world SmartBox viewport for a + // CreatureMode portal-space viewport. The world block above is skipped + // while this replacement scene is visible. This scene and + // its projection transition draws BEFORE retained UI, so chat, windows, + // cursor, and toolbar remain visible and interactive in transit. + _localPlayerTeleport?.DrawPortalViewport( + _window!.Size.X, + _window.Size.Y, + _cameraController!.Active.Projection); + + // Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the + // viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window + // is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle). + // The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state. + _paperdollFramePresenter?.Render(); + + // Phase D.2b — retail-look UI tree (render-only; input integration deferred). + // Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own + // blend/depth state and restores. Drawn before ImGui so the devtools + // overlay composites on top during development. + if (_options.RetailUi && _retailUiRuntime is not null) + { + _retailUiRuntime.Tick(deltaSeconds); + if (_input is not null) + _retailUiRuntime.UpdateCursor(_input.Mice); + _retailUiRuntime.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); + } + + // Devtools remain above retained gameplay UI. The focused presenter + // owns menu policy, panels, and ImGui draw-data upload. + _devToolsFramePresenter?.Render( + deltaSeconds, + _window!.Size.X, + _window.Size.Y); + + // Explicit diagnostic captures are requested by the retained-UI + // automation tick above and executed only after every presentation + // layer has drawn into the back buffer on this render thread. + bool screenshotCaptured = _frameScreenshots?.CapturePending() == true; + + _renderFrameDiagnostics?.Publish( + new AcDream.App.Rendering.RenderFrameInput( + deltaSeconds, + _window!.Size.X, + _window.Size.Y), + new AcDream.App.Rendering.RenderFrameOutcome( + new AcDream.App.Rendering.WorldRenderFrameOutcome( + visibleLandblocks, + totalLandblocks, + normalWorldDrawn), + new AcDream.App.Rendering.PrivatePresentationFrameOutcome( + portalViewportVisible, + screenshotCaptured))); } catch (Exception ex) @@ -4139,7 +3606,8 @@ public sealed class GameWindow : IDisposable /// within the same server-day. Swaps both the /// feeding /// (for lighting interp) and the cached - /// (for the sky-object render loop). + /// (for the sky-object + /// render loop). /// /// /// Honors ACDREAM_DAY_GROUP=N — when set, every call picks @@ -4186,12 +3654,16 @@ public sealed class GameWindow : IDisposable : null; bool dayChanged = dayIndex != _loadedSkyDayIndex; - bool groupChanged = !ReferenceEquals(grp, _activeDayGroup); + bool groupChanged = !ReferenceEquals( + grp, + _worldSceneSkyState?.ActiveDayGroup); if (!dayChanged && !groupChanged) return; _loadedSkyDayIndex = dayIndex; - _activeDayGroup = grp; + (_worldSceneSkyState + ??= new AcDream.App.Rendering.WorldSceneSkyState(WorldTime)) + .ActiveDayGroup = grp; 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 ──────────────────────────────── // // The ImGui DebugPanel reads through DebugVM closures that ask @@ -4343,8 +3803,8 @@ public sealed class GameWindow : IDisposable /// private void ToggleCollisionWires() { - _debugCollisionVisible = !_debugCollisionVisible; - _debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}"); + bool visible = _worldSceneDebugState.ToggleCollisionWireframes(); + _debugVm?.AddToast($"Collision wireframes {(visible ? "ON" : "OFF")}"); } // Phase K.3 settings state remains runtime-owned; the presenter owns the @@ -4486,10 +3946,10 @@ public sealed class GameWindow : IDisposable { _settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - _persistedDisplay = _settingsStore.LoadDisplay(); - _persistedAudio = _settingsStore.LoadAudio(); - _persistedGameplay = _settingsStore.LoadGameplay(); - _persistedChat = _settingsStore.LoadChat(); + _persistedDisplay = _settingsStore.LoadDisplay(); + _persistedAudio = _settingsStore.LoadAudio(); + _persistedGameplay = _settingsStore.LoadGameplay(); + _persistedChat = _settingsStore.LoadChat(); // _activeToonKey is "default" pre-EnterWorld; the post-login // ApplyLiveSessionEnteredWorld swaps to the chosen toon's // name and re-loads via SettingsVM.LoadCharacterContext. @@ -4511,9 +3971,9 @@ public sealed class GameWindow : IDisposable // harmless — the engine just doesn't read them yet). if (_audioEngine is not null && _audioEngine.IsAvailable) { - _audioEngine.MasterVolume = _persistedAudio.Master; - _audioEngine.MusicVolume = _persistedAudio.Music; - _audioEngine.SfxVolume = _persistedAudio.Sfx; + _audioEngine.MasterVolume = _persistedAudio.Master; + _audioEngine.MusicVolume = _persistedAudio.Music; + _audioEngine.SfxVolume = _persistedAudio.Sfx; _audioEngine.AmbientVolume = _persistedAudio.Ambient; } } @@ -4548,7 +4008,7 @@ public sealed class GameWindow : IDisposable /// public void ReapplyQualityPreset(AcDream.UI.Abstractions.Settings.QualityPreset newPreset) { - var newBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(newPreset); + var newBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(newPreset); var newResolved = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(newBase); Console.WriteLine($"[QUALITY] ReapplyQualityPreset: {newPreset} → {newResolved}"); @@ -4576,7 +4036,7 @@ public sealed class GameWindow : IDisposable if (_streamingController is not null) { _nearRadius = newResolved.NearRadius; - _farRadius = newResolved.FarRadius; + _farRadius = newResolved.FarRadius; _streamingController.ReconfigureRadii(_nearRadius, _farRadius); _streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame; @@ -4952,7 +4412,7 @@ public sealed class GameWindow : IDisposable if (_playerMode && _cameraController?.IsChaseMode == true) { modeLabel = "Chase"; current = _sensChase; } else if (_cameraController?.IsFlyMode == true) - { modeLabel = "Fly"; current = _sensFly; } + { modeLabel = "Fly"; current = _sensFly; } else { modeLabel = "Orbit"; current = _sensOrbit; } @@ -4983,7 +4443,7 @@ public sealed class GameWindow : IDisposable Console.WriteLine( $"=== F3 DEBUG DUMP ===\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}"); var visibleNearby = new List(); @@ -5103,7 +4563,9 @@ public sealed class GameWindow : IDisposable [ new("world frame composition", () => { + _worldSceneRenderer = null; _worldRenderFrameBuilder = null; + _worldSceneSkyState = null; _skyPesFrame = null; _retailPViewPassExecutor = null; _retailPViewCells = null; diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs index 00c4426c..fa93120e 100644 --- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs @@ -377,7 +377,9 @@ void main() { } // depth-only: color writes are masked off by the caller state _gl.ColorMask(true, true, true, true); _gl.DepthMask(true); _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.FrontFace(FrontFaceDirection.CW); _gl.UseProgram(0); diff --git a/src/AcDream.App/Rendering/RenderFrameGlStateController.cs b/src/AcDream.App/Rendering/RenderFrameGlStateController.cs new file mode 100644 index 00000000..b8a68c4e --- /dev/null +++ b/src/AcDream.App/Rendering/RenderFrameGlStateController.cs @@ -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); +} + +/// +/// 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. +/// +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); + } +} diff --git a/src/AcDream.App/Rendering/RenderFrameResourceController.cs b/src/AcDream.App/Rendering/RenderFrameResourceController.cs index 7d98733f..49ed2bf6 100644 --- a/src/AcDream.App/Rendering/RenderFrameResourceController.cs +++ b/src/AcDream.App/Rendering/RenderFrameResourceController.cs @@ -33,6 +33,11 @@ internal interface IRenderFrameClearPhase RenderFrameFoundation Clear(); } +internal interface IRenderFrameFoundationSource +{ + RenderFrameFoundation Foundation { get; } +} + internal interface IRenderFrameLivePreparation { void Prepare(int gpuSlot); @@ -43,7 +48,9 @@ internal interface IRenderFrameLivePreparation /// typed phases preserve resource begin, clear/state, then live publication /// order without exposing a renderer service bag. /// -internal sealed class RenderFrameResourceController : IRenderFrameResourcePhase +internal sealed class RenderFrameResourceController : + IRenderFrameResourcePhase, + IRenderFrameFoundationSource { private readonly IRenderFrameSlotSource _frameSlots; private readonly IRenderFrameBeginResources _resources; @@ -162,6 +169,7 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase private readonly IRenderFramePortalStateSource _portal; private readonly ParticleVisibilityController _particleVisibility; private readonly WorldRenderDiagnostics _diagnostics; + private readonly IRenderFrameGlState _frameGlState; public RuntimeRenderFrameClearPhase( GL gl, @@ -169,7 +177,8 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase WeatherSystem weather, IRenderFramePortalStateSource portal, ParticleVisibilityController particleVisibility, - WorldRenderDiagnostics diagnostics) + WorldRenderDiagnostics diagnostics, + IRenderFrameGlState frameGlState) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); @@ -178,6 +187,8 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase _particleVisibility = particleVisibility ?? throw new ArgumentNullException(nameof(particleVisibility)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _frameGlState = frameGlState + ?? throw new ArgumentNullException(nameof(frameGlState)); } public RenderFrameFoundation Clear() @@ -203,13 +214,11 @@ internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase 1f); } - _gl.DepthMask(true); + _frameGlState.RestoreFrameDefaults(); _gl.Clear( ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit); - _gl.CullFace(TriangleFace.Back); - _gl.FrontFace(FrontFaceDirection.CW); _diagnostics.EmitGlStateTripwireIfChanged( AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled); diff --git a/src/AcDream.App/Rendering/RetailAlphaQueue.cs b/src/AcDream.App/Rendering/RetailAlphaQueue.cs index 672982d5..b4ff1e9e 100644 --- a/src/AcDream.App/Rendering/RetailAlphaQueue.cs +++ b/src/AcDream.App/Rendering/RetailAlphaQueue.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using System.Runtime.ExceptionServices; using System.Runtime.InteropServices; namespace AcDream.App.Rendering; @@ -56,7 +57,16 @@ internal readonly record struct RetailAlphaSubmission( /// material/renderer sort. /// /// -internal sealed class RetailAlphaQueue +internal interface IWorldSceneAlphaFrame +{ + void BeginFrame(); + + void EndFrame(); + + void AbortFrame(); +} + +internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame { private readonly List _submissions = new(256); private readonly List _sources = new(4); @@ -108,58 +118,93 @@ internal sealed class RetailAlphaQueue if (!IsCollecting) throw new InvalidOperationException("Retail alpha flush requires an active frame."); + Exception? drawFailure = null; + List? resetFailures = null; try { - if (_submissions.Count == 0) - return; - - SortRetailOrder(); - EnsureTokenCapacity(_submissions.Count); - EnsureSourceCapacity(_sources.Count); - Array.Clear(_sourceDrawOffsets, 0, _sources.Count); - - // Prepare each renderer once for this alpha scope. The filtered - // token sequence preserves final retail order for that source, so - // every later adjacent source-run maps to one contiguous prepared - // range without another GPU upload. - for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++) + if (_submissions.Count > 0) { - IRetailAlphaDrawSource source = _sources[sourceIndex]; - int sourceCount = 0; - for (int i = 0; i < _submissions.Count; i++) + SortRetailOrder(); + EnsureTokenCapacity(_submissions.Count); + EnsureSourceCapacity(_sources.Count); + Array.Clear(_sourceDrawOffsets, 0, _sources.Count); + + // Prepare each renderer once for this alpha scope. The filtered + // token sequence preserves final retail order for that source, so + // every later adjacent source-run maps to one contiguous prepared + // range without another GPU upload. + for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++) { - RetailAlphaSubmission submission = _submissions[i]; - if (ReferenceEquals(submission.Source, source)) - _tokenScratch[sourceCount++] = submission.Token; + IRetailAlphaDrawSource source = _sources[sourceIndex]; + int sourceCount = 0; + for (int i = 0; i < _submissions.Count; i++) + { + RetailAlphaSubmission submission = _submissions[i]; + if (ReferenceEquals(submission.Source, source)) + _tokenScratch[sourceCount++] = submission.Token; + } + + source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount)); } - source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount)); - } + int start = 0; + while (start < _submissions.Count) + { + IRetailAlphaDrawSource source = _submissions[start].Source; + int end = start + 1; + while (end < _submissions.Count + && ReferenceEquals(_submissions[end].Source, source)) + end++; - int start = 0; - while (start < _submissions.Count) - { - IRetailAlphaDrawSource source = _submissions[start].Source; - int end = start + 1; - while (end < _submissions.Count - && ReferenceEquals(_submissions[end].Source, source)) - end++; - - int count = end - start; - int sourceIndex = FindSourceIndex(source); - int firstPreparedDraw = _sourceDrawOffsets[sourceIndex]; - source.DrawPreparedAlphaBatch(firstPreparedDraw, count); - _sourceDrawOffsets[sourceIndex] += count; - start = end; + int count = end - start; + int sourceIndex = FindSourceIndex(source); + int firstPreparedDraw = _sourceDrawOffsets[sourceIndex]; + source.DrawPreparedAlphaBatch(firstPreparedDraw, count); + _sourceDrawOffsets[sourceIndex] += count; + start = end; + } } } + catch (Exception error) + { + drawFailure = error; + } finally { for (int i = 0; i < _sources.Count; i++) - _sources[i].ResetAlphaSubmissions(); + { + try + { + _sources[i].ResetAlphaSubmissions(); + } + catch (Exception error) + { + (resetFailures ??= []).Add(error); + } + } _sources.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() @@ -177,6 +222,39 @@ internal sealed class RetailAlphaQueue } } + /// + /// 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. + /// + public void AbortFrame() + { + List? 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() { int count = _submissions.Count; diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index 632fcd74..0f6c73f9 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -74,9 +74,17 @@ internal sealed class RetailPViewParticleClassifications /// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather /// placement follows LScape::draw @ 0x00506330. /// -internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor +internal interface IOutdoorSceneParticleOwnerSource +{ + IReadOnlySet OutdoorSceneParticleEntityIds { get; } +} + +internal sealed class RetailPViewPassExecutor : + IRetailPViewPassExecutor, + IOutdoorSceneParticleOwnerSource { private readonly GL _gl; + private readonly IRenderFrameGlState _frameGlState; private readonly IRetailPViewFramebufferSource _framebuffer; private readonly ClipFrame _clipFrame; private readonly TerrainModernRenderer? _terrain; @@ -101,6 +109,7 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor public RetailPViewPassExecutor( GL gl, + IRenderFrameGlState frameGlState, IRetailPViewFramebufferSource framebuffer, ClipFrame clipFrame, TerrainModernRenderer? terrain, @@ -115,6 +124,8 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor TerrainDrawDiagnosticsController terrainDiagnostics) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _frameGlState = frameGlState + ?? throw new ArgumentNullException(nameof(frameGlState)); _framebuffer = framebuffer ?? throw new ArgumentNullException(nameof(framebuffer)); _clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame)); @@ -136,6 +147,28 @@ internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor _particleClassifications.BeginFrame(); } + public void AbortFrame() + { + List? 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( PortalVisibilityFrame portalFrame, ClipFrameAssembly reuseAssembly) => diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 9f79896a..70832eef 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -889,6 +889,7 @@ public interface IRetailPViewCellSource /// public interface IRetailPViewPassExecutor { + void AbortFrame(); void BeginFrame(); ClipFrameAssembly AssembleClipFrame( PortalVisibilityFrame portalFrame, @@ -1118,7 +1119,8 @@ public sealed class RetailPViewFrameInput public required int RenderRadius { get; init; } public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, - IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; } + IReadOnlyDictionary? AnimatedById)> LandblockEntries + { get; init; } // Pass-presentation and diagnostic values consumed synchronously by the // typed executor. This input is data-only and is never retained. diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs index 6a029d55..b0e22c02 100644 --- a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs +++ b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs @@ -9,7 +9,19 @@ namespace AcDream.App.Rendering.Selection; /// The renderer builds one frame while input queries the previously completed /// frame, avoiding partial visibility state during multi-slice portal drawing. /// -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 RetailSelectionLightingPulse _lightingPulse; @@ -100,6 +112,15 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetail (_published, _building) = (_building, _published); } + /// Discards the incomplete building frame and preserves the last + /// completely published selection product. + public void AbortFrame() + { + _building.Clear(); + _buildingKeys.Clear(); + _viewFrustum = null; + } + public RetailSelectionHit? Pick( float mouseX, float mouseY, diff --git a/src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs b/src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs index a0494cdb..f922dd29 100644 --- a/src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs +++ b/src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs @@ -3,13 +3,22 @@ using AcDream.Core.Vfx; namespace AcDream.App.Rendering.Vfx; +internal interface IWorldSceneParticleVisibility +{ + void MarkVisibleCells(HashSet cellIds); + + void CompleteFrame(); + + void AbortFrame(); +} + /// /// Bridges the retained retail PView result into the next physics update's /// CObjCell::IsInView particle gate. The controller owns only immutable /// frame meaning: one completed viewer position plus the AC cells admitted by /// that completed view. It neither creates emitters nor performs rendering. /// -public sealed class ParticleVisibilityController +public sealed class ParticleVisibilityController : IWorldSceneParticleVisibility { public const float ExtendedRangeMultiplier = 2f; @@ -70,6 +79,15 @@ public sealed class ParticleVisibilityController _hasCompletedWorldView = true; } + /// Discards the in-progress visibility product while preserving + /// the last completed view consumed by the update thread. + public void AbortFrame() + { + _buildingCellIds.Clear(); + _frameUsesWorldView = false; + _frameOpen = false; + } + public void Apply(ParticleSystem particles, float rangeMultiplier) { ArgumentNullException.ThrowIfNull(particles); diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index 9e575b07..06e1632b 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -61,6 +61,18 @@ internal readonly record struct WorldRenderFrame( public LoadedCell? ClipRoot => Roots.ViewerRoot ?? Buildings.OutdoorNode; } +internal interface IWorldRenderFrameBuilder +{ + WorldRenderFrame Build( + in RenderFrameFoundation foundation, + bool waitingForLogin, + DayGroupData? activeDayGroup); + + void ObserveDrawableCells(IReadOnlySet drawableCells); + + void ClearDrawableCells(); +} + internal interface IWorldFrameCameraSource { WorldCameraFrame Resolve(); @@ -113,7 +125,7 @@ internal interface IWorldFrameBuildingSource /// 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. /// -internal sealed class WorldRenderFrameBuilder +internal sealed class WorldRenderFrameBuilder : IWorldRenderFrameBuilder { private readonly IWorldFrameCameraSource _camera; private readonly IWorldFrameVisibilityPreparation _visibility; diff --git a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs new file mode 100644 index 00000000..a2e23b92 --- /dev/null +++ b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs @@ -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); +} + +/// +/// 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. +/// +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++; + } +} diff --git a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs new file mode 100644 index 00000000..cb5e9a8a --- /dev/null +++ b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs @@ -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? 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 Entities, + IReadOnlyDictionary? AnimatedById)> entries, + uint? playerLandblockId, + HashSet animatedEntityIds); + + string DrawPostWorldParticles( + LoadedCell? clipRoot, + ClipFrameAssembly? clipAssembly, + in WorldCameraFrame camera, + IReadOnlySet outdoorOwnerIds, + string currentSignature); + + void DrawFlatWeather( + in WorldCameraFrame camera, + in RenderFrameFoundation foundation, + DayGroupData? activeDayGroup, + float dayFraction); + + void DisableClipDistances(); + + void AbortFrame(); +} + +/// +/// Concrete GL leaf for the flat-world safety path and the post-world particle +/// and weather passes. Retail PView frames remain owned by +/// and . +/// +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 _visibleParticleOwners = []; + private readonly HashSet _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? 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 Entities, + IReadOnlyDictionary? AnimatedById)> entries, + uint? playerLandblockId, + HashSet 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 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? 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; +} diff --git a/src/AcDream.App/Rendering/WorldSceneRenderer.cs b/src/AcDream.App/Rendering/WorldSceneRenderer.cs new file mode 100644 index 00000000..0da63a0e --- /dev/null +++ b/src/AcDream.App/Rendering/WorldSceneRenderer.cs @@ -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 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 OutdoorSceneParticleEntityIds => + _particles.OutdoorSceneParticleEntityIds; + + public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input) => + _renderer.DrawInside(input, _passes); + + public void AbortFrame() => _passes.AbortFrame(); +} + +/// +/// 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. +/// +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? 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? AbortFrame( + bool worldFrameStarted, + bool pviewFrameStarted, + bool selectionFrameStarted) + { + List? 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); + } + } + } +} diff --git a/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs new file mode 100644 index 00000000..bd728e53 --- /dev/null +++ b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs @@ -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; } +} + +/// +/// 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. +/// +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 Entities, + IReadOnlyDictionary? 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 Entities, + IReadOnlyDictionary? 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; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index 6b1c9628..f8068059 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -29,7 +29,7 @@ public sealed class GameWindowRenderLeafCompositionTests "_renderFrameResources!.Prepare(frameInput);", "_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);", "_renderWeatherFrame!.Tick(deltaSeconds);", - "_retailSelectionScene?.BeginFrame();"); + "_worldSceneRenderer!.Render(frameInput);"); Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source); Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source); Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source); @@ -126,12 +126,10 @@ public sealed class GameWindowRenderLeafCompositionTests { string source = GameWindowSource(); - Assert.Contains("bool normalWorldDrawn = false;", source); AssertAppearsInOrder( source, - "if (IsLiveModeWaitingForLogin)", - "goto SkipWorldGeometry;", - "normalWorldDrawn = true;", + "_worldSceneRenderer!.Render(frameInput);", + "worldOutcome.NormalWorldDrawn;", "bool screenshotCaptured = _frameScreenshots?.CapturePending() == true;", "normalWorldDrawn),", "screenshotCaptured)));"); @@ -158,8 +156,9 @@ public sealed class GameWindowRenderLeafCompositionTests Assert.Contains( "new AcDream.App.Rendering.TerrainDrawDiagnosticsController(", source); - Assert.Contains("_terrainDrawDiagnostics!.Begin();", source); - Assert.Contains("_terrainDrawDiagnostics.Complete();", source); + Assert.Contains("new AcDream.App.Rendering.WorldScenePassExecutor(", source); + Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source); + Assert.DoesNotContain("_terrainDrawDiagnostics.Complete();", source); } private static string GameWindowSource() => File.ReadAllText(Path.Combine( diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameGlStateControllerTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameGlStateControllerTests.cs new file mode 100644 index 00000000..1f31ef3f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameGlStateControllerTests.cs @@ -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 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}"); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs index f08673d0..208ff93b 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameResourceControllerTests.cs @@ -87,10 +87,8 @@ public sealed class RenderFrameResourceControllerTests "_particleVisibility.Reset();", "SkyKeyframe sky = _worldTime.CurrentSky;", "AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky);", - "_gl.DepthMask(true);", + "_frameGlState.RestoreFrameDefaults();", "_gl.Clear(", - "_gl.CullFace(TriangleFace.Back);", - "_gl.FrontFace(FrontFaceDirection.CW);", "_diagnostics.EmitGlStateTripwireIfChanged("); } diff --git a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs index bad2df97..a6338406 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs @@ -179,6 +179,76 @@ public sealed class RetailAlphaQueueTests log); } + [Fact] + public void AbortFrame_DiscardsPayloadAndAllowsTheNextFrameToRender() + { + var log = new List(); + 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(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(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] public void ComputeViewerDistance_UsesTransformedGfxSortCenter() { @@ -224,4 +294,28 @@ public sealed class RetailAlphaQueueTests public void ResetAlphaSubmissions() => ResetCount++; } + + private sealed class FailureSource( + string? drawFailure, + string? resetFailure) : IRetailAlphaDrawSource + { + public int ResetCount { get; private set; } + + public void PrepareAlphaDraws(ReadOnlySpan 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); + } + } } diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index 3d1a21d7..f771b229 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -229,7 +229,8 @@ public sealed class RetailPViewPassExecutorTests "GameWindow.cs")); 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("DrawLandscapeSlice =", source); Assert.DoesNotContain("DrawExitPortalMasks =", source); @@ -399,15 +400,15 @@ public sealed class RetailPViewPassExecutorTests uint id, uint serverGuid = 0, uint? parentCellId = null) => new() - { - Id = id, - ServerGuid = serverGuid, - SourceGfxObjOrSetupId = 0, - Position = Vector3.Zero, - Rotation = Quaternion.Identity, - MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)], - ParentCellId = parentCellId, - }; + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)], + ParentCellId = parentCellId, + }; private static string MethodBody(string source, string signature) { @@ -475,6 +476,8 @@ public sealed class RetailPViewPassExecutorTests private sealed class RecordingExecutor : IRetailPViewPassExecutor, IDisposable { + public void AbortFrame() => Operations.Add("abort"); + private readonly ClipFrame _clipFrame = ClipFrame.NoClip(); public List Operations { get; } = []; diff --git a/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs b/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs index 9fb24950..5b02b0f3 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailSelectionSceneTests.cs @@ -45,6 +45,29 @@ public sealed class RetailSelectionSceneTests 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( RetailSelectionLightingPulse? pulse = null) { diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/ParticleVisibilityControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/ParticleVisibilityControllerTests.cs index 48b8799f..8d17b725 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/ParticleVisibilityControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/ParticleVisibilityControllerTests.cs @@ -64,4 +64,35 @@ public sealed class ParticleVisibilityControllerTests controller.Apply(particles, 1f); 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); + } } diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs index 498422ff..2af6301d 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -325,25 +325,32 @@ public sealed class WorldRenderFrameBuilderTests } [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( 1, - CountOccurrences(source, "_worldRenderFrameBuilder!.Build(")); - Assert.DoesNotContain("private void UpdateSunFromSky(", source, StringComparison.Ordinal); - Assert.DoesNotContain("private void UpdateSkyPes(", source, StringComparison.Ordinal); - Assert.DoesNotContain("private static float ParseEnvFloat(", source, StringComparison.Ordinal); + CountOccurrences(worldScene, "_frames.Build(")); + Assert.DoesNotContain("private void UpdateSunFromSky(", gameWindow, StringComparison.Ordinal); + Assert.DoesNotContain("private void UpdateSkyPes(", gameWindow, StringComparison.Ordinal); + Assert.DoesNotContain("private static float ParseEnvFloat(", gameWindow, StringComparison.Ordinal); AssertAppearsInOrder( - source, - "if (_cameraController is not null && !portalViewportVisible)", - "_retailAlphaQueue.BeginFrame();", - "_worldRenderFrameBuilder!.Build(", - "if (IsLiveModeWaitingForLogin)", - "normalWorldDrawn = true;"); + worldScene, + "_alpha.BeginFrame();", + "_frames.Build(", + "if (_login.IsWaitingForLogin)", + "_passes.DrawFlatTerrain(", + "NormalWorldDrawn: true"); AssertAppearsInOrder( - source, + gameWindow, + "_worldSceneRenderer = null;", "_worldRenderFrameBuilder = null;", "_skyPesFrame = null;", "new(\"equipped children\"", diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs new file mode 100644 index 00000000..b99d7c1f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs @@ -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(() => 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(() => 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( + () => 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 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 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 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 Entities, + IReadOnlyDictionary? AnimatedById)> LandblockEntries => + Array.Empty<(uint, Vector3, Vector3, IReadOnlyList, + IReadOnlyDictionary?)>(); + + public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds => + Array.Empty<(uint, Vector3, Vector3)>(); + } + + private sealed class SelectionFrame(List 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 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 calls) : + IWorldSceneParticleVisibility + { + public void MarkVisibleCells(HashSet 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 _calls; + private readonly RetailPViewFrameResult _interiorResult; + private readonly RetailPViewFrameResult _outdoorResult; + + public PViewRenderer(List 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 OutdoorSceneParticleEntityIds { get; } = + new HashSet { 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 calls) : IWorldScenePassExecutor + { + public bool ThrowOnTerrain { get; set; } + + public bool ThrowOnAbort { get; set; } + + public HashSet? 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? 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 Entities, + IReadOnlyDictionary? AnimatedById)> entries, + uint? playerLandblockId, + HashSet animatedEntityIds) => calls.Add("flat:entities"); + + public string DrawPostWorldParticles( + LoadedCell? clipRoot, + ClipFrameAssembly? clipAssembly, + in WorldCameraFrame camera, + IReadOnlySet 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 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()), + []); + } + + 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."); + } +}