diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index df6dddc3..5f8ee709 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -141,9 +141,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 | | AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1110` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | -| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | +| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (`ShellDrawLiftZ`); `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs` (`DrawPortalDepthWrite`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | -| AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewRenderer.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 | +| AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewPassExecutor.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression) + `src/AcDream.App/World/LiveEntityHydrationPorts.cs` (`LiveEntityWorldOriginCoordinator.TryInitialize` — login pre-collapse) + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition` — first accepted canonical Position) + `GameWindow:AimTeleportDestination`/`IsSealedDungeonCell` (teleport pre-collapse and DAT predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 | | AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.UpdateSunFromSky`) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) | | AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | 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 c99749cf..cfbaaa50 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 @@ -24,7 +24,7 @@ port, or resource-lifetime redesign. - [x] D — extract frame-data classification and reusable world scratch without duplicating live identity, spatial, visibility, or resource ownership; then run the dependent audio, sky, lighting, and listener preparation. -- [ ] E — replace `RetailPViewDrawContext`'s callback bag with a typed +- [x] E — replace `RetailPViewDrawContext`'s callback bag with a typed `RetailPViewPassExecutor` and data-only inputs. - [ ] F — extract `WorldSceneRenderer`, including both shared-alpha scopes, PView/fallback world branches, particles, debug world draw, and completion. @@ -426,6 +426,22 @@ Retail, architecture, and adversarial corrected-diff reviews are clean. - verify no executor operation rebuilds portal visibility or retains a borrowed frame result across calls. +Completed 2026-07-22. `RetailPViewRenderer` is now the retail visibility and +ordering owner only; `RetailPViewPassExecutor` owns the concrete clip upload, +EnvCell/entity, landscape, portal depth, particle, alpha, and diagnostic pass +implementations. The old callback bag and broad window closures are gone. +`WorldRenderDiagnostics` now owns every PView print-on-change signature and +probe sink, while a focused terrain-diagnostics controller preserves the +shared five-second terrain/frame publication transaction across fallback and +PView draws. Real `DrawInside` fixtures cover outdoor, interior-exit, +no-outside, entity/static/dynamic particle, transparent-shell, look-in, and +second-call scratch-reset behavior; structural tests pin concrete forwarding, +no delegate/back-reference leakage, no borrowed frame/result retention, and +early shutdown withdrawal. `GameWindow.cs` is 5,303 raw lines, 66.3% below the +15,723-line campaign baseline. Release build and the complete suite pass +(7,295 passed / 5 skipped). Retail, architecture, and adversarial +corrected-diff reviews are clean. + ### F — world scene owner - extract alpha/selection/particle-visibility begin/end and the normal-world diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e3bc6f5c..85c3458f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -83,7 +83,6 @@ public sealed class GameWindow : IDisposable System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", System.StringComparison.Ordinal); - private long _frameDiagLastPublicationMilliseconds; // MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call // per OnRender + three stage scopes. All logic lives in @@ -224,6 +223,10 @@ public sealed class GameWindow : IDisposable // _interiorRenderer is constructed once both renderers exist; _interiorPartition is rebuilt // each frame on an indoor root (null on the outdoor root). private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; + private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor; + private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells; + private AcDream.App.Rendering.TerrainDrawDiagnosticsController? + _terrainDrawDiagnostics; private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel; private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition; @@ -237,11 +240,10 @@ public sealed class GameWindow : IDisposable // U.4 replaces the NoClip() frame with one built from the portal-visibility result. private ClipFrame? _clipFrame; - // PView pass-local particle classifications move with the pass executor in - // Checkpoint E; GameWindow still owns them until that cutover. - private readonly HashSet _outdoorSceneParticleEntityIds = new(); - private readonly HashSet _visibleSceneParticleEntityIds = new(); - private readonly HashSet _noSceneParticleEntityIds = new(); + // 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 @@ -2311,8 +2313,7 @@ public sealed class GameWindow : IDisposable landblockRenderPublisher.PrepareAfterRenderPins); _clipFrame ??= ClipFrame.NoClip(); - _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( - _gl, _clipFrame, _envCellRenderer!, _wbDrawDispatcher!); + _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(); // T1: invisible portal depth writes (seal/punch) — retail // DrawPortalPolyInternal (Ghidra 0x0059bc90). @@ -2892,6 +2893,37 @@ public sealed class GameWindow : IDisposable new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource( _landblockPresentationPipeline!, _cellVisibility)); + _terrainDrawDiagnostics = + new AcDream.App.Rendering.TerrainDrawDiagnosticsController( + _frameDiag, + _worldRenderDiagnostics!, + new AcDream.App.Rendering.RuntimeFramePipelineDiagnosticFactsSource( + _terrain, + _landblockPresentationPipeline, + _renderFrameLivePreparation, + _wbDrawDispatcher, + _streamingController, + _liveEntities!, + _worldState), + _renderDiagnosticLog); + _retailPViewCells = new AcDream.App.Rendering.RetailPViewCellSource( + _cellVisibility); + _retailPViewPassExecutor = + new AcDream.App.Rendering.RetailPViewPassExecutor( + _gl!, + new AcDream.App.Rendering.SilkRetailPViewFramebufferSource( + _window!), + _clipFrame!, + _terrain, + _envCellRenderer!, + _wbDrawDispatcher!, + _skyRenderer, + _particleSystem, + _particleRenderer, + _portalDepthMask, + _retailAlphaQueue, + _worldRenderDiagnostics!, + _terrainDrawDiagnostics); var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( liveObjectFrame, _worldState, @@ -3527,7 +3559,6 @@ public sealed class GameWindow : IDisposable int sigOutdoorRootObjectCount = 0; int sigLiveDynamicDrawnCount = 0; string sigSceneParticles = "none"; - _outdoorSceneParticleEntityIds.Clear(); _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 @@ -3591,10 +3622,10 @@ public sealed class GameWindow : IDisposable // Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup. EnableClipDistances(); - _worldRenderDiagnostics?.BeginTerrainDraw(); + _terrainDrawDiagnostics!.Begin(); sigTerrainDrawn = true; _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); - PublishTerrainDrawDiagnostics(); + _terrainDrawDiagnostics.Complete(); } @@ -3617,7 +3648,7 @@ public sealed class GameWindow : IDisposable if (_retailPViewRenderer is null) throw new InvalidOperationException("Retail PView renderer is required for indoor frames."); - var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext + 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). @@ -3626,7 +3657,7 @@ public sealed class GameWindow : IDisposable NearbyBuildingCells = nearbyBuildingCells, ViewerEyePos = viewerEyePos, ViewProjection = envCellViewProj, - CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, + Cells = _retailPViewCells!, Camera = camera, CameraWorldPosition = camPos, // (#176 correction: the former RebuildScopedLights callback — @@ -3640,94 +3671,20 @@ public sealed class GameWindow : IDisposable RenderCenterLbY = renderCenterLbY, RenderRadius = _nearRadius, LandblockEntries = _worldState.LandblockEntries, - SetTerrainClipUbo = binding => _terrain?.SetClipUbo(binding), - DrawLandscapeSlice = sliceCtx => - DrawRetailPViewLandscapeSlice( - sliceCtx, - camera, - frustum, - camPos, - playerLb, - animatedIds, - renderSky, - renderWeather: playerSeenOutside, - kf, - environOverrideActive), - // #131/#132: the late phase — dynamics meshes + scene - // particles + weather AFTER the look-ins (FlushAlphaList - // deferral). - DrawLandscapeSliceLate = lateCtx => - DrawRetailPViewLandscapeSliceLate( - lateCtx, - camera, - frustum, - camPos, - playerLb, - animatedIds, - renderSky, - renderWeather: playerSeenOutside, - kf, - environOverrideActive, - isOutdoorRoot: clipRoot.IsOutdoorNode), - // T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840). - // INTERIOR roots: one FULL depth clear between the outside stage and - // the interior stage, then SEALS re-stamp every outside-leading - // portal's TRUE depth (#108's protective mechanism). OUTDOOR roots: - // no clear (the world's depth must survive) — instead each flooded - // building's entry aperture gets a far-Z PUNCH so its interior shows - // through the doorway. Both are safe ONLY because dynamics draw LAST - // (DrawDynamicsLast) — the first BR-2 attempt punched after dynamics - // and erased the player (reverted 88be519). - ClearDepthForInterior = clipRoot.IsOutdoorNode - ? null - : () => - { - gl.Disable(EnableCap.ScissorTest); - gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson) - gl.Clear(ClearBufferMask.DepthBufferBit); - }, - DrawExitPortalMasks = sliceCtx => - DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, - forceFarZ: clipRoot.IsOutdoorNode), - // #124: look-in apertures are ALWAYS the punch (retail - // maxZ1), independent of the root-keyed selector above. - DrawLookInPortalPunch = sliceCtx => - DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, - forceFarZ: true), - // #131: unattached emitters under an interior root — the - // landscape-stage pass (the outdoor T3 pass below is gated - // IsOutdoorNode, so the two never both run). - DrawUnattachedSceneParticles = () => - { - if (_particleSystem is null || _particleRenderer is null) - return; - DisableClipDistances(); - _particleRenderer.DrawForOwners( - camera, - camPos, - AcDream.Core.Vfx.ParticleRenderPass.Scene, - _noSceneParticleEntityIds, - includeUnattached: true); - }, - FlushLandscapeAlpha = _retailAlphaQueue.Flush, - DrawCellParticles = sliceCtx => - DrawRetailPViewCellParticles(sliceCtx, camera, camPos), - DrawDynamicsParticles = survivors => - DrawRetailPViewDynamicsParticles(survivors, camera, camPos), - EmitDiagnostics = result => - _worldRenderDiagnostics?.EmitRetailPViewDiagnostics( - AcDream.Core.Rendering.RenderingDiagnostics.ProbeViewerEnabled, - AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled, - AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled, - result, - clipRoot, - viewerCellId, - playerCellId, - camPos, - playerViewPos, - camera.View, - _cellVisibility.LastCameraCellResolution), - }); + 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; @@ -3833,7 +3790,7 @@ public sealed class GameWindow : IDisposable AcDream.Core.Vfx.ParticleRenderPass.Scene, _visibleSceneParticleEntityIds, includeUnattached: true, - excludedAttachedOwnerIds: _outdoorSceneParticleEntityIds); + excludedAttachedOwnerIds: _noSceneParticleEntityIds); } else { @@ -3865,13 +3822,13 @@ public sealed class GameWindow : IDisposable camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, - _outdoorSceneParticleEntityIds, + _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 DrawRetailPViewLandscapeSlice. + // drawn by RetailPViewRenderer.DrawInside via RetailPViewPassExecutor. if (clipRoot is null && drawSkyThisFrame) { sigSkyDrawn = true; @@ -4257,308 +4214,6 @@ public sealed class GameWindow : IDisposable } } - private void DrawRetailPViewLandscapeSlice( - AcDream.App.Rendering.RetailPViewLandscapeSliceContext sliceCtx, - ICamera camera, - FrustumPlanes? frustum, - System.Numerics.Vector3 camPos, - uint? playerLb, - HashSet? animatedIds, - bool renderSky, - bool renderWeather, - AcDream.Core.World.SkyKeyframe kf, - bool environOverrideActive) - { - var slice = sliceCtx.Slice; - bool scissor = BeginDoorwayScissor(true, slice.NdcAabb); - _worldRenderDiagnostics?.EmitClipRouteScissorProbe( - AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled, - scissor, - slice.NdcAabb); - - _clipFrame!.BindTerrainClip(_gl!); - - EnableClipDistances(); - if (renderSky) - _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, - _activeDayGroup, kf, environOverrideActive); - - DisableClipDistances(); - if (renderSky && _particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(camera, camPos, - AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); - - EnableClipDistances(); - _worldRenderDiagnostics?.BeginTerrainDraw(); - _terrain?.Draw( - camera, - frustum, - neverCullLandblockId: playerLb, - clipPlanes: slice.Planes, - ndcClipAabb: slice.NdcAabb); - PublishTerrainDrawDiagnostics(); - - // T3 (BR-5): entities draw OUTSIDE the clip bracket — retail meshes - // are viewcone-CHECKED, never hard-clipped (Ghidra 0x0054c250); the - // sphere pre-filter already ran in RetailPViewRenderer (OutdoorEntities - // is the per-slice survivor set). - DisableClipDistances(); - if (sliceCtx.OutdoorEntities.Count > 0) - { - var sceneryEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero, - sliceCtx.OutdoorEntities, - (IReadOnlyDictionary?)null); - _wbDrawDispatcher!.Draw(camera, new[] { sceneryEntry }, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: null, - animatedEntityIds: animatedIds); - } - - // #131/#132: scene particles + weather MOVED to the LATE phase - // (DrawRetailPViewLandscapeSliceLate) — they must composite AFTER the - // #124 look-ins (retail's FlushAlphaList deferral, DrawCells - // pc:432722); drawn here they were overpainted by far-building - // interiors wherever a look-in aperture sat behind them. - - if (scissor) - _gl!.Disable(EnableCap.ScissorTest); - - DisableClipDistances(); - } - - // #131/#132: the LATE landscape phase — per slice, invoked by the renderer - // AFTER the #124 look-in sub-pass, still pre-clear. Outside-stage - // dynamics' meshes (a translucent portal swirl blends over a far interior - // instead of being overpainted by it — translucents write no depth to - // protect themselves) + ALL attached scene particles (statics' flames - // included — the #132 candle) + weather. Retail equivalent: alpha draws - // collected during LScape::draw flush ONCE after it - // (D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722). - private void DrawRetailPViewLandscapeSliceLate( - AcDream.App.Rendering.RetailPViewLandscapeLateSliceContext lateCtx, - ICamera camera, - FrustumPlanes? frustum, - System.Numerics.Vector3 camPos, - uint? playerLb, - HashSet? animatedIds, - bool renderSky, - bool renderWeather, - AcDream.Core.World.SkyKeyframe kf, - bool environOverrideActive, - bool isOutdoorRoot) - { - var slice = lateCtx.Slice; - bool scissor = BeginDoorwayScissor(true, slice.NdcAabb); - - _clipFrame!.BindTerrainClip(_gl!); - - // Outside-stage dynamics' meshes — viewcone pre-filtered by the - // renderer, never hard-clipped (T3). - DisableClipDistances(); - if (lateCtx.Dynamics.Count > 0) - { - var dynamicsEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero, - lateCtx.Dynamics, - (IReadOnlyDictionary?)null); - _wbDrawDispatcher!.Draw(camera, new[] { dynamicsEntry }, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: null, - animatedEntityIds: animatedIds); - } - - _outdoorSceneParticleEntityIds.Clear(); - foreach (var entity in lateCtx.ParticleOwners) - _outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity)); - - // #131 [outstage-pt] probe: the slice Scene-particle id set + how many - // live emitters the filter would actually match, plus the distinct - // UNMATCHED attached owner ids (the portal-identification handle — - // an emitter whose owner never lands in the set draws nowhere - // indoors). Print-on-change. - _worldRenderDiagnostics?.EmitOutStageParticles( - AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled, - _particleSystem, - _outdoorSceneParticleEntityIds); - - // #132 outdoor sibling: under an OUTDOOR root the merged building - // interiors draw AFTER this stage (DrawEnvCellShells) — a flame drawn - // here is overpainted whenever a punched aperture sits behind it - // (user-confirmed at the outdoor candle). Outdoor roots therefore - // SKIP the slice Scene pass and draw attached scene particles in the - // post-frame pass alongside the T3 unattached pass (the id set built - // above carries over — the outdoor root has a single full-screen - // slice). Interior roots draw here: the look-ins already ran and the - // post-clear seal discipline owns the rest of the frame. - if (!isOutdoorRoot - && _outdoorSceneParticleEntityIds.Count > 0 - && _particleSystem is not null - && _particleRenderer is not null) - { - _particleRenderer.DrawForOwners( - camera, - camPos, - AcDream.Core.Vfx.ParticleRenderPass.Scene, - _outdoorSceneParticleEntityIds); - } - - // T3 (BR-5): weather gates on the PLAYER being outside, not the viewer - // root — retail draws weather only when is_player_outside (the rain - // cylinder rides the player; an indoor player gets NO rain even while - // looking out a doorway). Closes the rain-through-doorways divergence - // (weather-gate-player-vs-viewer, adjusted-confirmed). - EnableClipDistances(); - if (renderSky && renderWeather) - { - _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, - _activeDayGroup, kf, environOverrideActive); - DisableClipDistances(); - if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(camera, camPos, - AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); - } - else - { - DisableClipDistances(); - } - - if (scissor) - _gl!.Disable(EnableCap.ScissorTest); - - DisableClipDistances(); - } - - // T1: retail's invisible portal depth writes on every outside-leading - // portal (other_cell_id==0xFFFF) of this cell, clipped to the slice's view - // region — D3DPolyRender::DrawPortalPolyInternal (Ghidra 0x0059bc90), - // dispatched by PView::DrawCells (Ghidra 0x005a4840). forceFarZ is - // retail's maxZ1(true)/maxZ2(false) selector: - // • INTERIOR root (false → SEAL, true depth): after the full depth clear, - // stamp the door plane so interior geometry beyond the door z-fails - // inside the aperture and the terrain drawn through the outside view - // keeps its pixels (#108's protective mechanism). - // • OUTDOOR root / look-in (true → PUNCH, far depth): erase the world's - // depth inside a flooded building's entry aperture so the interior - // drawn next shows THROUGH the doorway. - // Both are safe ONLY because dynamics draw last (DrawDynamicsLast) — the - // first BR-2 attempt punched after dynamics and erased the player - // (reverted 88be519). Wiring only — the draw lives in - // PortalDepthMaskRenderer. - private void DrawRetailPViewPortalDepthWrite( - AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx, - System.Numerics.Matrix4x4 viewProjection, - bool forceFarZ) - { - if (_portalDepthMask is null) - return; - if (!_cellVisibility.TryGetCell(sliceCtx.CellId, out var cell) || cell is null) - return; - - Span world = stackalloc System.Numerics.Vector3[32]; - for (int i = 0; i < cell.Portals.Count; i++) - { - if (cell.Portals[i].OtherCellId != 0xFFFF) - continue; // depth writes apply to portals leading OUTSIDE only - if (i >= cell.PortalPolygons.Count) - break; - var localVerts = cell.PortalPolygons[i]; - if (localVerts.Length < 3) - continue; - - // cell.WorldTransform is the PHYSICS (unlifted) transform (f35cb8b); - // the shell that rasterizes this aperture draws +ShellDrawLiftZ - // higher. The seal/punch is a DRAW — stamp depth in the same lifted - // space or the stamp sits 2 cm below the drawn hole (#130 family). - int n = System.Math.Min(localVerts.Length, world.Length); - for (int v = 0; v < n; v++) - { - world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform); - world[v].Z += AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ; - } - - // #176 seam-draw probe: a sealed dungeon must emit ZERO depth fans - // (only OtherCellId==0xFFFF portals reach here) — any line in a - // target cell is a finding (a depth stamp fighting the shell floor). - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled - && AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells.Contains(sliceCtx.CellId)) - { - float seamZMin = float.MaxValue, seamZMax = float.MinValue; - for (int v = 0; v < n; v++) - { - seamZMin = System.Math.Min(seamZMin, world[v].Z); - seamZMax = System.Math.Max(seamZMax, world[v].Z); - } - Console.WriteLine(System.FormattableString.Invariant( - $"[seam-mask] t={System.Environment.TickCount64} cell=0x{sliceCtx.CellId:X8} portal={i} far={forceFarZ} n={n} z=[{seamZMin:F3},{seamZMax:F3}]")); - } - - _portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ); - } - } - - private void DrawRetailPViewCellParticles( - AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx, - ICamera camera, - System.Numerics.Vector3 camPos) - { - if (_particleSystem is null || _particleRenderer is null || sliceCtx.CellEntities.Count == 0) - return; - - _visibleSceneParticleEntityIds.Clear(); - foreach (var entity in sliceCtx.CellEntities) - _visibleSceneParticleEntityIds.Add(ParticleEntityKey(entity)); - - if (_visibleSceneParticleEntityIds.Count == 0) - return; - - // T3 (BR-5): the scissor-AABB gate is DELETED — retail gates particles - // like meshes (viewcone on the owner; depth does the pixels). The - // CellEntities set is already the cone-surviving owner list, so the - // id-predicate below IS the cone gate; the punch/seal depth discipline - // composites the pixels. - DisableClipDistances(); - _particleRenderer.DrawForOwners( - camera, - camPos, - AcDream.Core.Vfx.ParticleRenderPass.Scene, - _visibleSceneParticleEntityIds); - DisableClipDistances(); - } - - // #121: the dynamics-owner particle pass — Scene-pass emitters attached to - // the frame's cone-surviving dynamics (portal swirls on server-spawned - // portal entities, creature effects). Retail draws emitters with their - // owner object; before this pass, dynamics' emitters fell through every - // pview particle filter (landscape slice = outdoor statics + #118 - // outside-stage dynamics; cell callback = cell statics) once T4 deleted - // the clipRoot==null global pass from normal frames — every world portal - // went invisible. Mirror of DrawRetailPViewCellParticles with the - // survivors' ids as the filter. - private readonly HashSet _dynamicsSceneParticleEntityIds = new(); - - private void DrawRetailPViewDynamicsParticles( - IReadOnlyList survivors, - ICamera camera, - System.Numerics.Vector3 camPos) - { - if (_particleSystem is null || _particleRenderer is null || survivors.Count == 0) - return; - - _dynamicsSceneParticleEntityIds.Clear(); - foreach (var entity in survivors) - _dynamicsSceneParticleEntityIds.Add(ParticleEntityKey(entity)); - - if (_dynamicsSceneParticleEntityIds.Count == 0) - return; - - DisableClipDistances(); - _particleRenderer.DrawForOwners( - camera, - camPos, - AcDream.Core.Vfx.ParticleRenderPass.Scene, - _dynamicsSceneParticleEntityIds); - DisableClipDistances(); - } - private void EnableClipDistances() { for (int i = 0; i < ClipFrame.MaxPlanes; i++) @@ -4571,23 +4226,6 @@ public sealed class GameWindow : IDisposable _gl!.Disable(EnableCap.ClipDistance0 + i); } - // Phase W Stage 4: set a glScissor to an NDC AABB (the doorway / OutsideView region) in - // framebuffer pixels and enable the scissor test; returns true iff applied (the caller then - // disables EnableCap.ScissorTest after its draw/clear). Used to bracket the landscape slice - // (sky, terrain, statics, weather — particle.vert has no gl_ClipDistance). Returns false - // (no scissor) when not applied (outdoor / no window). The box is the CONSERVATIVE outer - // bound (NdcScissorRect): the previous Floor(origin)+Ceiling(size) form cut up to one pixel - // off the TOP/RIGHT edges at unlucky alignments — the #130 doorway top-edge background strip. - private bool BeginDoorwayScissor(bool apply, System.Numerics.Vector4 ndcAabb) - { - if (!apply || _window is null) return false; - var fb = _window.FramebufferSize; - var box = NdcScissorRect.ToPixels(ndcAabb, fb.X, fb.Y); - _gl!.Enable(EnableCap.ScissorTest); - _gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height); - return true; - } - // ── Phase I.2 — DebugPanel helpers ──────────────────────────────── // // The ImGui DebugPanel reads through DebugVM closures that ask @@ -5414,60 +5052,6 @@ public sealed class GameWindow : IDisposable } } - private void PublishTerrainDrawDiagnostics() - { - long now = _frameDiag ? Environment.TickCount64 : 0L; - _worldRenderDiagnostics?.EndTerrainDraw(); - if (!_frameDiag || now - _frameDiagLastPublicationMilliseconds <= 5000) - return; - - // Preserve the original one-timestamp diagnostic transaction. A - // failure in either write leaves the shared cadence uncommitted, so - // the next terrain draw retries both TERRAIN and FRAME diagnostics. - _worldRenderDiagnostics?.PublishTerrainDiagnostics( - new AcDream.App.Rendering.TerrainRenderDiagnosticFacts( - _terrain?.VisibleSlots ?? 0, - _terrain?.LoadedSlots ?? 0, - _terrain?.CapacitySlots ?? 0)); - PublishFrameDiagnostics(); - _frameDiagLastPublicationMilliseconds = now; - } - - private void PublishFrameDiagnostics() - { - AcDream.App.Streaming.LandblockPresentationDiagnostics presentation = - _landblockPresentationPipeline?.Diagnostics ?? default; - AcDream.App.Streaming.LandblockRenderPublisherDiagnostics render = - presentation.Render; - AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physics = - presentation.Physics; - AcDream.App.Streaming.LandblockStaticPresentationDiagnostics statics = - presentation.Statics; - double ticksToMicros = - 1_000_000.0 / System.Diagnostics.Stopwatch.Frequency; - AcDream.App.Rendering.RollingTimingPercentiles upload = - _renderFrameLivePreparation?.UploadTiming ?? default; - double uploadMedian = upload.MedianHundredthsMicroseconds / 100.0; - double uploadP95 = upload.Percentile95HundredthsMicroseconds / 100.0; - int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0; - _renderDiagnosticLog.WriteLine( - $"[FRAME-DIAG] publish={render.BeginCount}/{render.CompleteCount} " + - $"render_total_us=[terrain={render.TerrainPublishTicks * ticksToMicros:F1} " + - $"begin={render.BeginPublishTicks * ticksToMicros:F1} " + - $"complete={render.CompletePublishTicks * ticksToMicros:F1}] " + - $"physics_total_us=[base={physics.BasePublishTicks * ticksToMicros:F1} " + - $"gfx={physics.GfxCacheTicks * ticksToMicros:F1} " + - $"complete={physics.CompletePublishTicks * ticksToMicros:F1}] " + - $"static={statics.BeginCount}/{statics.CompleteCount}" + - $"(active={statics.ActiveEntityCount}) " + - $"entUpl_us={uploadMedian:F1}m/{uploadP95:F1}p95 " + - $"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " + - $"fullRetire={_streamingController?.FullWindowRetirementCount ?? 0}" + - $"(landblocks={_streamingController?.LastFullWindowRetirementLandblockCount ?? 0}) " + - $"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " + - $"resident={_worldState.Entities.Count} walked={walked}"); - } - private void OnClosing() => CompleteShutdown(); @@ -5521,6 +5105,10 @@ public sealed class GameWindow : IDisposable { _worldRenderFrameBuilder = null; _skyPesFrame = null; + _retailPViewPassExecutor = null; + _retailPViewCells = null; + _terrainDrawDiagnostics = null; + _retailPViewRenderer = null; }), ]), new ResourceShutdownStage("session dependents", diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs index d9c20c7f..00c4426c 100644 --- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs @@ -12,7 +12,7 @@ namespace AcDream.App.Rendering; /// (Ghidra 0x0059bc90, pc:424490). /// /// Wired by T1 (BR-3, `579c8b0`): seal on interior roots, punch -/// on outdoor / look-in roots, via GameWindow.DrawRetailPViewPortalDepthWrite +/// on outdoor / look-in roots, via RetailPViewPassExecutor.DrawPortalDepthWrite /// (the DrawExitPortalMasks slice callback) — safe alongside the /// dynamics-drawn-LAST frame order (the first BR-2 attempt punched after /// dynamics and erased the player; reverted 88be519). #117 (2026-06-11) diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs new file mode 100644 index 00000000..632fcd74 --- /dev/null +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -0,0 +1,576 @@ +using System.Numerics; +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; +using Silk.NET.Windowing; + +namespace AcDream.App.Rendering; + +internal readonly record struct RetailPViewFramebufferSize(int Width, int Height); + +internal interface IRetailPViewFramebufferSource +{ + RetailPViewFramebufferSize Capture(); +} + +internal sealed class SilkRetailPViewFramebufferSource(IWindow window) : + IRetailPViewFramebufferSource +{ + private readonly IWindow _window = window + ?? throw new ArgumentNullException(nameof(window)); + + public RetailPViewFramebufferSize Capture() + { + var size = _window.FramebufferSize; + return new RetailPViewFramebufferSize(size.X, size.Y); + } +} + +internal sealed class RetailPViewCellSource : IRetailPViewCellSource +{ + private readonly CellVisibility _cells; + + public RetailPViewCellSource(CellVisibility cells) => + _cells = cells ?? throw new ArgumentNullException(nameof(cells)); + + public LoadedCell? Find(uint cellId) => + _cells.TryGetCell(cellId, out LoadedCell? cell) ? cell : null; +} + +internal sealed class RetailPViewParticleClassifications +{ + private readonly HashSet _outdoor = []; + private readonly HashSet _visible = []; + private readonly HashSet _dynamics = []; + + public IReadOnlySet Outdoor => _outdoor; + public HashSet Visible => _visible; + public HashSet Dynamics => _dynamics; + + public void BeginFrame() + { + _outdoor.Clear(); + _visible.Clear(); + _dynamics.Clear(); + } + + public void ReplaceOutdoor(IReadOnlyList owners) + { + _outdoor.Clear(); + foreach (WorldEntity owner in owners) + _outdoor.Add(owner.Id); + } +} + +/// +/// Concrete GL implementation of the named passes ordered by +/// . It owns reusable pass-local particle +/// classifications but borrows every renderer and world source. +/// The order it implements is retail PView::DrawCells @ 0x005A4840: +/// landscape, delayed-alpha flush, optional interior depth clear, exit masks, +/// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather +/// placement follows LScape::draw @ 0x00506330. +/// +internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor +{ + private readonly GL _gl; + private readonly IRetailPViewFramebufferSource _framebuffer; + private readonly ClipFrame _clipFrame; + private readonly TerrainModernRenderer? _terrain; + private readonly EnvCellRenderer _envCells; + private readonly WbDrawDispatcher _entities; + private readonly SkyRenderer? _sky; + private readonly ParticleSystem? _particles; + private readonly ParticleRenderer? _particleRenderer; + private readonly PortalDepthMaskRenderer? _portalDepthMask; + private readonly RetailAlphaQueue _alpha; + private readonly WorldRenderDiagnostics _diagnostics; + private readonly TerrainDrawDiagnosticsController _terrainDiagnostics; + private readonly RetailPViewParticleClassifications _particleClassifications = new(); + private readonly HashSet _noSceneParticleEntityIds = []; + + /// + /// Borrowed until the next late landscape pass. The outdoor-root post-world + /// particle pass consumes this synchronously before another PView frame. + /// + public IReadOnlySet OutdoorSceneParticleEntityIds => + _particleClassifications.Outdoor; + + public RetailPViewPassExecutor( + GL gl, + IRetailPViewFramebufferSource framebuffer, + ClipFrame clipFrame, + TerrainModernRenderer? terrain, + EnvCellRenderer envCells, + WbDrawDispatcher entities, + SkyRenderer? sky, + ParticleSystem? particles, + ParticleRenderer? particleRenderer, + PortalDepthMaskRenderer? portalDepthMask, + RetailAlphaQueue alpha, + WorldRenderDiagnostics diagnostics, + TerrainDrawDiagnosticsController terrainDiagnostics) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _framebuffer = framebuffer + ?? throw new ArgumentNullException(nameof(framebuffer)); + _clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame)); + _terrain = terrain; + _envCells = envCells ?? throw new ArgumentNullException(nameof(envCells)); + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + _sky = sky; + _particles = particles; + _particleRenderer = particleRenderer; + _portalDepthMask = portalDepthMask; + _alpha = alpha ?? throw new ArgumentNullException(nameof(alpha)); + _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _terrainDiagnostics = terrainDiagnostics + ?? throw new ArgumentNullException(nameof(terrainDiagnostics)); + } + + public void BeginFrame() + { + _particleClassifications.BeginFrame(); + } + + public ClipFrameAssembly AssembleClipFrame( + PortalVisibilityFrame portalFrame, + ClipFrameAssembly reuseAssembly) => + ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly); + + public void PrepareClipFrame(int terrainUploadCount) + { + // Allocate every terrain record before issuing the first draw. BufferData + // must not replace the arena while an earlier slice can reference it. + _clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount); + _clipFrame.UploadRegions(_gl); + _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); + _envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); + UploadTerrainClip(); + } + + public void SetTerrainClip(ReadOnlySpan planes) + { + _clipFrame.SetTerrainClip(planes); + UploadTerrainClip(); + } + + public void ClearClipRouting() => _entities.ClearClipRouting(); + + public void UseIndoorMembershipOnlyRouting() + { + // Retail viewcone-checks meshes and draws whole cell shells. This clears + // any terrain-slice routing before the indoor membership passes. + _envCells.SetClipRouting(null); + _entities.ClearClipRouting(); + } + + public void PrepareCellBatches( + RetailPViewFrameInput frame, + HashSet visibleCellIds) => + _envCells.PrepareRenderBatches( + frame.ViewProjection, + frame.CameraWorldPosition, + filter: visibleCellIds, + centerLbX: frame.RenderCenterLbX, + centerLbY: frame.RenderCenterLbY, + renderRadius: frame.RenderRadius); + + public void DrawOpaqueCellShells(HashSet cellIds) => + _envCells.Render(WbRenderPass.Opaque, cellIds); + + public bool CellHasTransparentShell(uint cellId) => + _envCells.CellHasTransparent(cellId); + + public void DrawTransparentCellShells(HashSet cellIds) => + _envCells.Render(WbRenderPass.Transparent, cellIds); + + public void DrawTransparentCellShellsOrdered(IReadOnlyList cellIds) => + _envCells.RenderTransparentOrdered(cellIds); + + public void DrawEntityBucket( + RetailPViewFrameInput frame, + IReadOnlyList entities, + HashSet? visibleCellIds) + { + uint landblockId = frame.PlayerLandblockId ?? 0u; + var entry = ( + landblockId, + Vector3.Zero, + Vector3.Zero, + entities, + (IReadOnlyDictionary?)null); + + _entities.Draw( + frame.Camera, + new[] { entry }, + frame.Frustum, + neverCullLandblockId: frame.PlayerLandblockId, + visibleCellIds: visibleCellIds, + animatedEntityIds: frame.AnimatedEntityIds); + } + + public void EmitClipRouteProbe( + ClipFrameAssembly clipAssembly, + ClipViewSlice slice, + int sliceIndex) => + _diagnostics.EmitClipRouteProbe( + RenderingDiagnostics.ProbeClipRouteEnabled, + _clipFrame, + clipAssembly, + slice, + sliceIndex); + + public void EmitOutStageOwner( + WorldEntity entity, + Vector3 sphereCenter, + float sphereRadius, + int sliceIndex, + bool passed) => + _diagnostics.EmitOutStageOwner( + RenderingDiagnostics.ProbeOutStageEnabled, + RenderingDiagnostics.DumpEntitySourceIds, + entity, + sphereCenter, + sphereRadius, + sliceIndex, + passed); + + public void EmitOutStageRouting( + int sliceIndex, + IReadOnlyList entities, + ViewconeCuller viewcone) => + _diagnostics.EmitOutStageRouting( + RenderingDiagnostics.ProbeOutStageEnabled, + sliceIndex, + entities, + viewcone); + + public void EmitPhantomObjects(uint cellId, int survivorCount) => + _diagnostics.EmitPhantomObjects( + RenderingDiagnostics.ProbePhantomEnabled, + cellId, + survivorCount); + + public void DrawLandscapeSlice( + RetailPViewFrameInput frame, + RetailPViewLandscapeSliceContext context) + { + ClipViewSlice slice = context.Slice; + bool scissor = BeginDoorwayScissor(slice.NdcAabb); + _diagnostics.EmitClipRouteScissorProbe( + RenderingDiagnostics.ProbeClipRouteEnabled, + scissor, + slice.NdcAabb); + + _clipFrame.BindTerrainClip(_gl); + EnableClipDistances(); + if (frame.RenderSky) + { + _sky?.RenderSky( + frame.Camera, + frame.CameraWorldPosition, + frame.DayFraction, + frame.ActiveDayGroup, + frame.SkyKeyframe, + frame.EnvironOverrideActive); + } + + DisableClipDistances(); + if (frame.RenderSky && _particles is not null && _particleRenderer is not null) + { + _particleRenderer.Draw( + frame.Camera, + frame.CameraWorldPosition, + ParticleRenderPass.SkyPreScene); + } + + EnableClipDistances(); + _terrainDiagnostics.Begin(); + _terrain?.Draw( + frame.Camera, + frame.Frustum, + neverCullLandblockId: frame.PlayerLandblockId, + clipPlanes: slice.Planes, + ndcClipAabb: slice.NdcAabb); + _terrainDiagnostics.Complete(); + + DisableClipDistances(); + if (context.OutdoorEntities.Count > 0) + { + var sceneryEntry = ( + frame.PlayerLandblockId ?? 0u, + Vector3.Zero, + Vector3.Zero, + context.OutdoorEntities, + (IReadOnlyDictionary?)null); + _entities.Draw( + frame.Camera, + new[] { sceneryEntry }, + frame.Frustum, + neverCullLandblockId: frame.PlayerLandblockId, + visibleCellIds: null, + animatedEntityIds: frame.AnimatedEntityIds); + } + + if (scissor) + _gl.Disable(EnableCap.ScissorTest); + DisableClipDistances(); + } + + public void DrawLandscapeSliceLate( + RetailPViewFrameInput frame, + RetailPViewLandscapeLateSliceContext context) + { + ClipViewSlice slice = context.Slice; + bool scissor = BeginDoorwayScissor(slice.NdcAabb); + _clipFrame.BindTerrainClip(_gl); + + DisableClipDistances(); + if (context.Dynamics.Count > 0) + { + var dynamicsEntry = ( + frame.PlayerLandblockId ?? 0u, + Vector3.Zero, + Vector3.Zero, + context.Dynamics, + (IReadOnlyDictionary?)null); + _entities.Draw( + frame.Camera, + new[] { dynamicsEntry }, + frame.Frustum, + neverCullLandblockId: frame.PlayerLandblockId, + visibleCellIds: null, + animatedEntityIds: frame.AnimatedEntityIds); + } + + _particleClassifications.ReplaceOutdoor(context.ParticleOwners); + + _diagnostics.EmitOutStageParticles( + RenderingDiagnostics.ProbeOutStageEnabled, + _particles, + _particleClassifications.Outdoor); + + if (!frame.RootCell.IsOutdoorNode + && _particleClassifications.Outdoor.Count > 0 + && _particles is not null + && _particleRenderer is not null) + { + _particleRenderer.DrawForOwners( + frame.Camera, + frame.CameraWorldPosition, + ParticleRenderPass.Scene, + _particleClassifications.Outdoor); + } + + EnableClipDistances(); + if (frame.RenderSky && frame.RenderWeather) + { + _sky?.RenderWeather( + frame.Camera, + frame.CameraWorldPosition, + frame.DayFraction, + frame.ActiveDayGroup, + frame.SkyKeyframe, + frame.EnvironOverrideActive); + DisableClipDistances(); + if (_particles is not null && _particleRenderer is not null) + { + _particleRenderer.Draw( + frame.Camera, + frame.CameraWorldPosition, + ParticleRenderPass.SkyPostScene); + } + } + else + { + DisableClipDistances(); + } + + if (scissor) + _gl.Disable(EnableCap.ScissorTest); + DisableClipDistances(); + } + + public void ClearInteriorDepth() + { + _gl.Disable(EnableCap.ScissorTest); + _gl.DepthMask(true); + _gl.Clear(ClearBufferMask.DepthBufferBit); + } + + public void DrawExitPortalMask( + RetailPViewFrameInput frame, + RetailPViewCellSliceContext context) => + DrawPortalDepthWrite(context, frame, forceFarZ: frame.RootCell.IsOutdoorNode); + + public void DrawLookInPortalPunch( + RetailPViewFrameInput frame, + RetailPViewCellSliceContext context) => + DrawPortalDepthWrite(context, frame, forceFarZ: true); + + public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame) + { + if (_particles is null || _particleRenderer is null) + return; + + DisableClipDistances(); + _particleRenderer.DrawForOwners( + frame.Camera, + frame.CameraWorldPosition, + ParticleRenderPass.Scene, + _noSceneParticleEntityIds, + includeUnattached: true); + } + + public void FlushLandscapeAlpha() => _alpha.Flush(); + + public void DrawCellParticles( + RetailPViewFrameInput frame, + RetailPViewCellSliceContext context) + { + if (_particles is null + || _particleRenderer is null + || context.CellEntities.Count == 0) + { + return; + } + + HashSet visible = _particleClassifications.Visible; + visible.Clear(); + foreach (WorldEntity entity in context.CellEntities) + visible.Add(entity.Id); + if (visible.Count == 0) + return; + + DisableClipDistances(); + _particleRenderer.DrawForOwners( + frame.Camera, + frame.CameraWorldPosition, + ParticleRenderPass.Scene, + visible); + DisableClipDistances(); + } + + public void DrawDynamicsParticles( + RetailPViewFrameInput frame, + IReadOnlyList survivors) + { + if (_particles is null || _particleRenderer is null || survivors.Count == 0) + return; + + HashSet dynamics = _particleClassifications.Dynamics; + dynamics.Clear(); + foreach (WorldEntity entity in survivors) + dynamics.Add(entity.Id); + if (dynamics.Count == 0) + return; + + DisableClipDistances(); + _particleRenderer.DrawForOwners( + frame.Camera, + frame.CameraWorldPosition, + ParticleRenderPass.Scene, + dynamics); + DisableClipDistances(); + } + + public void EmitDiagnostics( + RetailPViewFrameInput frame, + RetailPViewFrameResult result) => + _diagnostics.EmitRetailPViewDiagnostics( + RenderingDiagnostics.ProbeViewerEnabled, + RenderingDiagnostics.ProbeVisibilityEnabled, + RenderingDiagnostics.ProbeFlapEnabled, + result, + frame.RootCell, + frame.ViewerCellId, + frame.PlayerCellId, + frame.CameraWorldPosition, + frame.PlayerViewPosition, + frame.CameraView, + frame.CameraCellResolution); + + private void UploadTerrainClip() + { + TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl); + _terrain?.SetClipUbo(binding); + } + + private void DrawPortalDepthWrite( + RetailPViewCellSliceContext context, + RetailPViewFrameInput frame, + bool forceFarZ) + { + // Retail D3DPolyRender::DrawPortalPolyInternal @ 0x0059BC90. + // Main interior roots stamp true depth (seal); outdoor and look-in + // apertures stamp far depth (punch). The renderer owns that choice. + if (_portalDepthMask is null) + return; + LoadedCell? cell = frame.Cells.Find(context.CellId); + if (cell is null) + return; + + Span world = stackalloc Vector3[32]; + for (int index = 0; index < cell.Portals.Count; index++) + { + if (cell.Portals[index].OtherCellId != 0xFFFF) + continue; + if (index >= cell.PortalPolygons.Count) + break; + Vector3[] localVertices = cell.PortalPolygons[index]; + if (localVertices.Length < 3) + continue; + + int count = Math.Min(localVertices.Length, world.Length); + for (int vertex = 0; vertex < count; vertex++) + { + world[vertex] = Vector3.Transform( + localVertices[vertex], + cell.WorldTransform); + world[vertex].Z += PortalVisibilityBuilder.ShellDrawLiftZ; + } + + _diagnostics.EmitSeamMask( + RenderingDiagnostics.ProbeSeamDrawEnabled, + RenderingDiagnostics.SeamDrawTargetCells, + context.CellId, + index, + forceFarZ, + world[..count]); + + _portalDepthMask.DrawDepthFan( + world[..count], + frame.ViewProjection, + context.Slice.Planes, + forceFarZ); + } + } + + private bool BeginDoorwayScissor(Vector4 ndcAabb) + { + RetailPViewFramebufferSize framebuffer = _framebuffer.Capture(); + var box = NdcScissorRect.ToPixels( + ndcAabb, + framebuffer.Width, + framebuffer.Height); + _gl.Enable(EnableCap.ScissorTest); + _gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height); + return true; + } + + private void EnableClipDistances() + { + for (int index = 0; index < ClipFrame.MaxPlanes; index++) + _gl.Enable(EnableCap.ClipDistance0 + index); + } + + private void DisableClipDistances() + { + for (int index = 0; index < ClipFrame.MaxPlanes; index++) + _gl.Disable(EnableCap.ClipDistance0 + index); + } +} diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 0191ed01..9f79896a 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; -using AcDream.App.Rendering.Wb; using AcDream.Core.World; -using Silk.NET.OpenGL; namespace AcDream.App.Rendering; @@ -14,10 +12,6 @@ namespace AcDream.App.Rendering; /// public sealed class RetailPViewRenderer { - private readonly GL _gl; - private readonly ClipFrame _clipFrame; - private readonly EnvCellRenderer _envCells; - private readonly WbDrawDispatcher _entities; private readonly PortalVisibilityFrame _mainPortalFrameScratch = new(); private readonly ClipFrameAssembly _clipAssemblyScratch = new(); private readonly ViewconeCuller _viewconeScratch = new(); @@ -76,28 +70,20 @@ public sealed class RetailPViewRenderer // gather); seeds themselves are unbounded. private const float OutdoorBuildingSeedDistance = float.PositiveInfinity; - public RetailPViewRenderer( - GL gl, - ClipFrame clipFrame, - EnvCellRenderer envCells, - WbDrawDispatcher entities) - { - _gl = gl; - _clipFrame = clipFrame; - _envCells = envCells; - _entities = entities; - } - - public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx) + public RetailPViewFrameResult DrawInside( + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes) { ArgumentNullException.ThrowIfNull(ctx); + ArgumentNullException.ThrowIfNull(passes); + passes.BeginFrame(); RecycleLookInFrames(); ResetBuildingGroups(); var pvFrame = PortalVisibilityBuilder.Build( ctx.RootCell, ctx.ViewerEyePos, - ctx.CellLookup, + ctx.Cells.Find, ctx.ViewProjection, buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ, @@ -131,12 +117,11 @@ public sealed class RetailPViewRenderer && pvFrame.OutsideView.Polygons.Count > 0) BuildInteriorRootLookIns(ctx, pvFrame); - var clipAssembly = ClipFrameAssembler.Assemble( - _clipFrame, + var clipAssembly = passes.AssembleClipFrame( pvFrame, _clipAssemblyScratch); int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2); - PrepareClipFrame(ctx.SetTerrainClipUbo, terrainUploadCount); + passes.PrepareClipFrame(terrainUploadCount); // R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the // assembler handed a clip-slot. This feeds the Prepare filter + entity partition, @@ -146,7 +131,7 @@ public sealed class RetailPViewRenderer _drawableCellsScratch.Clear(); _drawableCellsScratch.UnionWith(pvFrame.OrderedVisibleCells); var drawableCells = _drawableCellsScratch; - UseIndoorMembershipOnlyRouting(); + passes.UseIndoorMembershipOnlyRouting(); // #124: look-in cells need prepared shell batches + their statics routed // into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main @@ -169,13 +154,7 @@ public sealed class RetailPViewRenderer // the RESIDENT-cell registry, not the frame flood — and is deleted. The pool // is built once per frame in GameWindow, player-anchored.) - _envCells.PrepareRenderBatches( - ctx.ViewProjection, - ctx.CameraWorldPosition, - filter: prepareCells, - centerLbX: ctx.RenderCenterLbX, - centerLbY: ctx.RenderCenterLbY, - renderRadius: ctx.RenderRadius); + passes.PrepareCellBatches(ctx, prepareCells); InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries); var partition = _partitionResult; @@ -185,7 +164,7 @@ public sealed class RetailPViewRenderer drawableCells, partition); - ctx.EmitDiagnostics?.Invoke(result); + passes.EmitDiagnostics(ctx, result); // T1 (fused BR-2/3): retail's frame order — static world, then the // aperture depth writes, then interior cells WHOLE far→near, then @@ -226,17 +205,17 @@ public sealed class RetailPViewRenderer foreach (var e in partition.Dynamics) { EntitySphere(e, out var c, out float r); - if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.CellLookup)) + if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.Cells)) _outsideStageDynamics.Add(e); } } - DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone); - UseIndoorMembershipOnlyRouting(); - DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); - DrawEnvCellShells(pvFrame); - DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone); - DrawDynamicsLast(ctx, partition, viewcone, ctx.RootCell.IsOutdoorNode); + DrawLandscapeThroughOutsideView(ctx, passes, clipAssembly, partition, viewcone); + passes.UseIndoorMembershipOnlyRouting(); + DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells); + DrawEnvCellShells(passes, pvFrame); + DrawCellObjectLists(ctx, passes, pvFrame, clipAssembly, drawableCells, partition, viewcone); + DrawDynamicsLast(ctx, passes, partition, viewcone, ctx.RootCell.IsOutdoorNode); return result; } @@ -244,7 +223,7 @@ public sealed class RetailPViewRenderer // R-A2: group the nearby building cells by BuildingId and run one per-building flood per group // (retail's per-building ConstructView(CBldPortal)), merging each small view into the frame. The // grouping dict contains only this frame's keys; lists are pooled across frames. - private void MergeNearbyBuildingFloods(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame) + private void MergeNearbyBuildingFloods(RetailPViewFrameInput ctx, PortalVisibilityFrame pvFrame) { RebuildBuildingGroups(ctx.NearbyBuildingCells!); @@ -255,7 +234,7 @@ public sealed class RetailPViewRenderer var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding( group, ctx.ViewerEyePos, - ctx.CellLookup, + ctx.Cells.Find, ctx.ViewProjection, OutdoorBuildingSeedDistance, reuseFrame: _outdoorBuildingFrameScratch); @@ -304,7 +283,7 @@ public sealed class RetailPViewRenderer // doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip // 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own // building self-excludes via the seed eye-side test. - private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame) + private void BuildInteriorRootLookIns(RetailPViewFrameInput ctx, PortalVisibilityFrame pvFrame) { RebuildBuildingGroups(ctx.NearbyBuildingCells!); @@ -316,7 +295,7 @@ public sealed class RetailPViewRenderer ? _lookInFramePool.Pop() : new PortalVisibilityFrame(); var frame = PortalVisibilityBuilder.ConstructViewBuilding( - group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, + group, ctx.ViewerEyePos, ctx.Cells.Find, ctx.ViewProjection, OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons, reuseFrame: frameScratch); if (frame.OrderedVisibleCells.Count > 0) @@ -365,7 +344,8 @@ public sealed class RetailPViewRenderer // here is color-safe; statics draw whole (the main viewcone has no entry // for look-in cells; over-include is the safe direction). private void DrawBuildingLookIns( - RetailPViewDrawContext ctx, + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result partition) { @@ -375,30 +355,27 @@ public sealed class RetailPViewRenderer foreach (var frame in _lookInFrames) { // Pass 1: far-Z punch every aperture of this building. - if (ctx.DrawLookInPortalPunch is not null) + foreach (uint cellId in frame.OrderedVisibleCells) { - foreach (uint cellId in frame.OrderedVisibleCells) + if (!frame.CellViews.TryGetValue(cellId, out var view)) + continue; + foreach (var poly in view.Polygons) { - if (!frame.CellViews.TryGetValue(cellId, out var view)) + var cps = ClipPlaneSet.From(poly); + if (cps.IsNothingVisible) continue; - foreach (var poly in view.Polygons) - { - var cps = ClipPlaneSet.From(poly); - if (cps.IsNothingVisible) - continue; - ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext( - cellId, - new ClipViewSlice( - 0, - new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), - cps.PlaneArray), - Array.Empty())); - } + passes.DrawLookInPortalPunch(ctx, new RetailPViewCellSliceContext( + cellId, + new ClipViewSlice( + 0, + new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), + cps.PlaneArray), + Array.Empty())); } } // Pass 2: shells + statics, far→near. - UseIndoorMembershipOnlyRouting(); + passes.UseIndoorMembershipOnlyRouting(); // Opaque shells batched per building into ONE Render (this building's // aperture punches above already ran; z-buffer handles order and @@ -408,7 +385,7 @@ public sealed class RetailPViewRenderer foreach (uint cid in frame.OrderedVisibleCells) _shellBatch.Add(cid); if (_shellBatch.Count > 0) - _envCells.Render(WbRenderPass.Opaque, _shellBatch); + passes.DrawOpaqueCellShells(_shellBatch); for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) { @@ -417,8 +394,8 @@ public sealed class RetailPViewRenderer _oneCell.Add(cellId); // Opaque shell batched above. Transparent stays per-cell (far→near) // for correct compositing; skipped for opaque-only cells. - if (_envCells.CellHasTransparent(cellId)) - _envCells.Render(WbRenderPass.Transparent, _oneCell); + if (passes.CellHasTransparentShell(cellId)) + passes.DrawTransparentCellShells(_oneCell); _cellStaticScratch.Clear(); if (partition.ByCell.TryGetValue(cellId, out var bucket)) @@ -443,12 +420,12 @@ public sealed class RetailPViewRenderer if (_cellStaticScratch.Count > 0) { - DrawEntityBucket(ctx, _cellStaticScratch, _oneCell); + passes.DrawEntityBucket(ctx, _cellStaticScratch, _oneCell); // The cell-particles pass for look-in cells — retail's // nested DrawCells draws objects WITH their emitters. foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) - ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext( + passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext( cellId, slice, _cellStaticScratch)); } } @@ -456,7 +433,8 @@ public sealed class RetailPViewRenderer } private void DrawLandscapeThroughOutsideView( - RetailPViewDrawContext ctx, + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result partition, ViewconeCuller viewcone) @@ -479,16 +457,15 @@ public sealed class RetailPViewRenderer int probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { - _clipFrame.SetTerrainClip(slice.Planes); - UploadTerrainClip(ctx.SetTerrainClipUbo); + passes.SetTerrainClip(slice.Planes); // T3 (BR-5): entities are never hard-clipped — retail viewcone- // CHECKS each mesh's sphere against the view (Ghidra 0x0054c250) // and draws it whole. The old per-slice entity clip routing // (gl_ClipDistance via SetClipRouting) is replaced by the sphere // pre-filter below; terrain/sky keep their per-slice plane clip. - _entities.ClearClipRouting(); + passes.ClearClipRouting(); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled) - EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex); + passes.EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex); _outdoorStaticScratch.Clear(); foreach (var e in partition.OutdoorStatic) @@ -498,14 +475,14 @@ public sealed class RetailPViewRenderer _outdoorStaticScratch.Add(e); } probeSliceIndex++; - ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); + passes.DrawLandscapeSlice(ctx, new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); } // #124: far-building look-ins draw HERE — still inside the landscape // stage (their punches mark against the terrain/exterior depth just // drawn), strictly BEFORE the depth clear + seals below, matching // retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785). - DrawBuildingLookIns(ctx, clipAssembly, partition); + DrawBuildingLookIns(ctx, passes, clipAssembly, partition); // LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn // pre-clear so the seal protects their aperture pixels; AFTER the @@ -515,9 +492,8 @@ public sealed class RetailPViewRenderer probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { - _clipFrame.SetTerrainClip(slice.Planes); - UploadTerrainClip(ctx.SetTerrainClipUbo); - _entities.ClearClipRouting(); + passes.SetTerrainClip(slice.Planes); + passes.ClearClipRouting(); _outdoorStaticScratch.Clear(); // late: dynamics survivors _lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors @@ -530,14 +506,12 @@ public sealed class RetailPViewRenderer // #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids // double as an ENTITY-id watchlist here — one line per watched // outdoor-static owner per CHANGE of its cone verdict. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled - && AcDream.Core.Rendering.RenderingDiagnostics.DumpEntitySourceIds.Contains(e.Id) - && (!_outStageOwnerVerdicts.TryGetValue(e.Id, out bool prev) || prev != ownerPass)) - { - _outStageOwnerVerdicts[e.Id] = ownerPass; - Console.WriteLine(System.FormattableString.Invariant( - $"[outstage-own] id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} pos=({e.Position.X:F1},{e.Position.Y:F1},{e.Position.Z:F1}) c=({c.X:F1},{c.Y:F1},{c.Z:F1}) r={r:F1} slice={probeSliceIndex} {(ownerPass ? "PASS" : "CULL")}")); - } + passes.EmitOutStageOwner( + e, + c, + r, + probeSliceIndex, + ownerPass); } foreach (var e in _outsideStageDynamics) { @@ -548,10 +522,12 @@ public sealed class RetailPViewRenderer _lateParticleOwnerScratch.Add(e); } } - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled) - EmitOutStageProbe(probeSliceIndex, viewcone); + passes.EmitOutStageRouting( + probeSliceIndex, + _outsideStageDynamics, + viewcone); probeSliceIndex++; - ctx.DrawLandscapeSliceLate?.Invoke(new RetailPViewLandscapeLateSliceContext( + passes.DrawLandscapeSliceLate(ctx, new RetailPViewLandscapeLateSliceContext( slice, _outdoorStaticScratch, _lateParticleOwnerScratch)); } @@ -563,12 +539,12 @@ public sealed class RetailPViewRenderer // must not double-draw, the #121 lesson), at the END of the landscape // stage: after the clear they would z-fail against the doorway seal. if (!ctx.RootCell.IsOutdoorNode) - ctx.DrawUnattachedSceneParticles?.Invoke(); + passes.DrawUnattachedSceneParticles(ctx); // Retail PView::DrawCells 0x005A4872 drains the landscape alpha list // immediately after LScape::draw and before the optional depth clear. // The queue remains active for the post-clear/final-world scope. - ctx.FlushLandscapeAlpha?.Invoke(); + passes.FlushLandscapeAlpha(); // T1: retail clears the FULL depth buffer ONCE between the outside // stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 — @@ -577,133 +553,19 @@ public sealed class RetailPViewRenderer // every outside-leading portal's TRUE depth (the seals, // DrawExitPortalMasks). Replaces the old per-slice scissored AABB // clear (wrong shape, no seal after it). - if (clipAssembly.OutsideViewSlices.Length > 0) - ctx.ClearDepthForInterior?.Invoke(); + if (clipAssembly.OutsideViewSlices.Length > 0 && !ctx.RootCell.IsOutdoorNode) + passes.ClearInteriorDepth(); - UseIndoorMembershipOnlyRouting(); - } - - // #131 [outstage] probe state (2026-06-12, throwaway): print-on-change — - // which outdoor dynamics were routed to the outside stage and which - // survived the slice viewcone. Strip with the probe when #131 closes. - private string? _lastOutStageSig; - private readonly Dictionary _outStageOwnerVerdicts = new(); - - private void EmitOutStageProbe(int sliceIndex, ViewconeCuller viewcone) - { - var sb = new System.Text.StringBuilder(192); - sb.Append("slice=").Append(sliceIndex) - .Append(" outStage=").Append(_outsideStageDynamics.Count).Append(" ["); - for (int i = 0; i < _outsideStageDynamics.Count; i++) - { - var e = _outsideStageDynamics[i]; - EntitySphere(e, out var c, out float r); - bool pass = viewcone.SphereVisibleInOutsideSlice(sliceIndex, c, r); - if (i > 0) sb.Append(' '); - sb.Append(System.FormattableString.Invariant( - $"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}(s{e.SourceGfxObjOrSetupId:X8}):{(pass ? "PASS" : "CULL")}:r={r:F1}")); - } - sb.Append(']'); - string sig = sb.ToString(); - if (sig == _lastOutStageSig) return; - _lastOutStageSig = sig; - Console.WriteLine("[outstage] " + sig); - } - - // §4 flap [clip-route] probe state (2026-06-10, throwaway): print-on-change signature + - // monotonic sequence so held-flap vs healthy frames diff cleanly in one capture. - private string? _lastClipRouteSig; - private long _clipRouteSeq; - private readonly List _clipRouteCellKeys = new(); - - // §4 flap apparatus (2026-06-10): the decisive probe between the surviving suspects - // (handoff 2026-06-09 §1). Emits the EXACT clip inputs the landscape pass draws under: - // the outside slice's slot + NDC AABB + planes (CPU side), the region-SSBO bytes decoded - // at that slot (what mesh_modern.vert reads for routed instances), the terrain-UBO head - // (what terrain/sky gate against), and the CellIdToSlot routing table. Fires AFTER - // SetTerrainClip + UploadTerrainClip + SetClipRouting, BEFORE DrawLandscapeSlice — so the - // printed bytes are exactly what this slice's draws consume. - private void EmitClipRouteProbe(ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex) - { - var sb = new System.Text.StringBuilder(256); - sb.Append(System.FormattableString.Invariant( - $"slice={sliceIndex}/{clipAssembly.OutsideViewSlices.Length} slot={slice.Slot}")); - sb.Append(System.FormattableString.Invariant( - $" ndc=({slice.NdcAabb.X:F3},{slice.NdcAabb.Y:F3},{slice.NdcAabb.Z:F3},{slice.NdcAabb.W:F3})")); - sb.Append(System.FormattableString.Invariant($" planes={slice.Planes.Length}[")); - for (int i = 0; i < slice.Planes.Length; i++) - { - var p = slice.Planes[i]; - if (i > 0) sb.Append(' '); - sb.Append(System.FormattableString.Invariant($"({p.X:F3},{p.Y:F3},{p.Z:F3},{p.W:F3})")); - } - - // CellIdToSlot sorted by cell id so dictionary enumeration order can't fake a change. - sb.Append("] cells={"); - _clipRouteCellKeys.Clear(); - foreach (uint key in clipAssembly.CellIdToSlot.Keys) - _clipRouteCellKeys.Add(key); - _clipRouteCellKeys.Sort(); - for (int i = 0; i < _clipRouteCellKeys.Count; i++) - { - if (i > 0) sb.Append(','); - sb.Append(System.FormattableString.Invariant( - $"0x{_clipRouteCellKeys[i]:X8}:{clipAssembly.CellIdToSlot[_clipRouteCellKeys[i]]}")); - } - sb.Append('}'); - - // Region-SSBO content decoded at the routed slot, from the packed bytes UploadRegions - // just uploaded — slot stride 144: count uint at +0, planes[8] at +16. - var rb = _clipFrame.RegionBytesForTest; - int off = slice.Slot * ClipFrame.CellClipStrideBytes; - if (off >= 0 && off + ClipFrame.CellClipStrideBytes <= rb.Length) - { - uint ssboCount = System.BitConverter.ToUInt32(rb.Slice(off, 4)); - sb.Append(System.FormattableString.Invariant($" ssbo[{slice.Slot}]: n={ssboCount}")); - int planeN = (int)System.Math.Min(ssboCount, (uint)ClipFrame.MaxPlanes); - for (int i = 0; i < planeN; i++) - { - int po = off + ClipFrame.CellClipPlanesOffset + i * 16; - float px = System.BitConverter.ToSingle(rb.Slice(po, 4)); - float py = System.BitConverter.ToSingle(rb.Slice(po + 4, 4)); - float pz = System.BitConverter.ToSingle(rb.Slice(po + 8, 4)); - float pw = System.BitConverter.ToSingle(rb.Slice(po + 12, 4)); - sb.Append(System.FormattableString.Invariant($" ({px:F3},{py:F3},{pz:F3},{pw:F3})")); - } - } - else - { - sb.Append(System.FormattableString.Invariant( - $" ssbo[{slice.Slot}]: OUT-OF-RANGE len={rb.Length}")); - } - - // Terrain-UBO head as uploaded (std140: int count at +0, planes[8] at +16). - var tb = _clipFrame.TerrainBytesForTest; - int uboCount = System.BitConverter.ToInt32(tb.Slice(0, 4)); - float u0 = System.BitConverter.ToSingle(tb.Slice(16, 4)); - float u1 = System.BitConverter.ToSingle(tb.Slice(20, 4)); - float u2 = System.BitConverter.ToSingle(tb.Slice(24, 4)); - float u3 = System.BitConverter.ToSingle(tb.Slice(28, 4)); - sb.Append(System.FormattableString.Invariant( - $" ubo: n={uboCount} p0=({u0:F3},{u1:F3},{u2:F3},{u3:F3})")); - - string sig = sb.ToString(); - _clipRouteSeq++; - if (sig == _lastClipRouteSig) - return; - _lastClipRouteSig = sig; - Console.WriteLine($"[clip-route] n={_clipRouteSeq} {sig}"); + passes.UseIndoorMembershipOnlyRouting(); } private void DrawExitPortalMasks( - IRetailPViewCellDrawCallbacks ctx, + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes, PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells) { - if (ctx.DrawExitPortalMasks is null) - return; - for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) { uint cellId = pvFrame.OrderedVisibleCells[i]; @@ -711,11 +573,15 @@ public sealed class RetailPViewRenderer continue; foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) - ctx.DrawExitPortalMasks(new RetailPViewCellSliceContext(cellId, slice, Array.Empty())); + passes.DrawExitPortalMask( + ctx, + new RetailPViewCellSliceContext(cellId, slice, Array.Empty())); } } - private void DrawEnvCellShells(PortalVisibilityFrame pvFrame) + private void DrawEnvCellShells( + IRetailPViewPassExecutor passes, + PortalVisibilityFrame pvFrame) { // T1 (fused BR-2/3): retail DrawCells Loop 2 — every visible cell's // shell drawn WHOLE, reverse cell_draw_list (far→near), drawn once. @@ -727,7 +593,7 @@ public sealed class RetailPViewRenderer // (927fd8f/9ce335e, #114) is deleted with this rewrite. // Per-cell opaque+transparent keeps the far→near transparent // compositing the per-cell loop already provided. - UseIndoorMembershipOnlyRouting(); + passes.UseIndoorMembershipOnlyRouting(); // Opaque: ONE batched Render for all shell cells (was one heavy per-frame // Render call PER cell — the dense-town FPS sink, ~94 calls/24.75ms at @@ -739,7 +605,7 @@ public sealed class RetailPViewRenderer foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) _shellBatch.Add(entry.CellId); if (_shellBatch.Count > 0) - _envCells.Render(WbRenderPass.Opaque, _shellBatch); + passes.DrawOpaqueCellShells(_shellBatch); // Transparent: far-to-near order matters for compositing. The ordered // list retains ShellPass cell boundaries while EnvCellRenderer shares @@ -747,11 +613,11 @@ public sealed class RetailPViewRenderer _orderedTransparentShellCells.Clear(); foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) { - if (_envCells.CellHasTransparent(entry.CellId)) + if (passes.CellHasTransparentShell(entry.CellId)) _orderedTransparentShellCells.Add(entry.CellId); } if (_orderedTransparentShellCells.Count > 0) - _envCells.RenderTransparentOrdered(_orderedTransparentShellCells); + passes.DrawTransparentCellShellsOrdered(_orderedTransparentShellCells); } // T1: the frame's single LAST entity pass — ALL server-spawned dynamics @@ -767,7 +633,8 @@ public sealed class RetailPViewRenderer // the draw list; the partition keeps routing it so the CULL (not the // visibility set) drops it, exactly retail's shape. private void DrawDynamicsLast( - IRetailPViewCellDrawContext ctx, + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes, InteriorEntityPartition.Result partition, ViewconeCuller viewcone, bool rootIsOutdoor) @@ -811,8 +678,8 @@ public sealed class RetailPViewRenderer if (_dynamicsScratch.Count == 0) return; - UseIndoorMembershipOnlyRouting(); - DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null); + passes.UseIndoorMembershipOnlyRouting(); + passes.DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null); // #121: dynamics' attached emitters (portal swirls, creature effects) // gate through the SAME cone-surviving owner set as their meshes — @@ -824,19 +691,17 @@ public sealed class RetailPViewRenderer // portals went invisible. Outside-stage dynamics are excluded here: // their emitters already drew in the landscape slice (alpha-blended // particles must not double-draw, unlike the depth-idempotent meshes). - if (ctx.DrawDynamicsParticles is not null) - { - _dynamicsParticleScratch.Clear(); - foreach (var e in _dynamicsScratch) - if (!_outsideStageDynamics.Contains(e)) - _dynamicsParticleScratch.Add(e); - if (_dynamicsParticleScratch.Count > 0) - ctx.DrawDynamicsParticles(_dynamicsParticleScratch); - } + _dynamicsParticleScratch.Clear(); + foreach (var e in _dynamicsScratch) + if (!_outsideStageDynamics.Contains(e)) + _dynamicsParticleScratch.Add(e); + if (_dynamicsParticleScratch.Count > 0) + passes.DrawDynamicsParticles(ctx, _dynamicsParticleScratch); } private void DrawCellObjectLists( - IRetailPViewCellDrawContext ctx, + RetailPViewFrameInput ctx, + IRetailPViewPassExecutor passes, PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, @@ -889,8 +754,7 @@ public sealed class RetailPViewRenderer _cellObjCells.Add(cellId); // BR-2 phantom-site probe (T3-updated): post-viewcone survivors. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled) - EmitPhantomObjsProbe(cellId, survivors); + passes.EmitPhantomObjects(cellId, survivors); } // ONE batched static-object draw for every visible cell (was N per-cell @@ -900,26 +764,27 @@ public sealed class RetailPViewRenderer // cull; only the draw is batched). if (_allCellStatics.Count > 0) { - UseIndoorMembershipOnlyRouting(); - DrawEntityBucket(ctx, _allCellStatics, _cellObjCells); + passes.UseIndoorMembershipOnlyRouting(); + passes.DrawEntityBucket(ctx, _allCellStatics, _cellObjCells); } // Cell-particle pass — consolidated across ALL visible cells into ONE // draw. Was per-cell, and each call re-walked the ENTIRE live particle set - // (DrawRetailPViewCellParticles → ParticleRenderer.Draw enumerates every + // (RetailPViewPassExecutor.DrawCellParticles → ParticleRenderer.Draw enumerates every // live emitter), i.e. O(cells × particles) — the dense-town cellobjects // sink (~5 ms at Arwic). Static owners are disjoint per cell, so the UNION // (= _allCellStatics, already accumulated above for the batched draw) draws // EXACTLY the same emitters: the callback gates on owner id (the cone- // surviving set), the renderer sorts globally back-to-front, and the per- // cell slice was never used for clipping (the scissor gate was deleted in - // T3 — DrawRetailPViewCellParticles disables clip distances). Runs after + // T3 — RetailPViewPassExecutor.DrawCellParticles disables clip distances). Runs after // the batched static draw so emitters depth-test against the statics now in // the buffer (the statics-before-particles order). cellId/slice are unused // by the particle pass — pass NoClipSlice + the union owner list. This also // drops the per-cell BuildDrawList allocations (N → 1). if (_allCellStatics.Count > 0) - ctx.DrawCellParticles?.Invoke( + passes.DrawCellParticles( + ctx, new RetailPViewCellSliceContext(0u, NoClipSlice, _allCellStatics)); } @@ -960,7 +825,7 @@ public sealed class RetailPViewRenderer Vector3 sphereCenter, float sphereRadius, HashSet drawableCells, - Func cellLookup) + IRetailPViewCellSource cells) { if (!InteriorEntityPartition.IsIndoorCellId(parentCellId)) return true; @@ -968,7 +833,7 @@ public sealed class RetailPViewRenderer uint cellId = parentCellId!.Value; if (!drawableCells.Contains(cellId)) return false; // not in the flood — the last-pass cone cull owns it - var cell = cellLookup(cellId); + var cell = cells.Find(cellId); if (cell is null) return false; @@ -998,20 +863,6 @@ public sealed class RetailPViewRenderer radius = (e.AabbMax - e.AabbMin).Length() * 0.5f; } - // BR-2 phantom-site probe state: print-on-change per cell so the log stays - // diffable while the condition persists. Throwaway apparatus — strip when - // the #113 phantom residual closes. (The [phantom-shell] half died with - // the T1 chop deletion — shells draw whole, there is no slice state left - // to report.) - private readonly Dictionary _phantomObjsSig = new(); - - private void EmitPhantomObjsProbe(uint cellId, int bucketCount) - { - if (_phantomObjsSig.TryGetValue(cellId, out var prev) && prev == bucketCount) return; - _phantomObjsSig[cellId] = bucketCount; - Console.WriteLine($"[phantom-objs] cell=0x{cellId:X8} entities={bucketCount} (drawn unclipped, no viewcone)"); - } - private static ClipViewSlice[] GetCellSlicesOrNoClip( ClipFrameAssembly clipAssembly, uint cellId) @@ -1023,80 +874,65 @@ public sealed class RetailPViewRenderer return NoClipSlices; } - private void UseIndoorMembershipOnlyRouting() - { - // T1: NOTHING in the world passes hard-clips geometry anymore — retail - // viewcone-CHECKS meshes (sphere vs view planes, T3) and never clips - // cell shells (DrawEnvCell draws the whole prebuilt mesh, pc:427905). - // This clears any clip routing left by the landscape slices. - _envCells.SetClipRouting(null); - _entities.ClearClipRouting(); - } - - private void DrawEntityBucket( - IRetailPViewCellDrawContext ctx, - IReadOnlyList bucket, - HashSet? visibleCellIds) - { - uint lbId = ctx.PlayerLandblockId ?? 0u; - var entry = (lbId, Vector3.Zero, Vector3.Zero, - (IReadOnlyList)bucket, - (IReadOnlyDictionary?)null); - - _entities.Draw( - ctx.Camera, - new[] { entry }, - ctx.Frustum, - neverCullLandblockId: ctx.PlayerLandblockId, - visibleCellIds: visibleCellIds, - animatedEntityIds: ctx.AnimatedEntityIds); - } - - private void PrepareClipFrame( - Action setTerrainClipUbo, - int terrainUploadCount) - { - // Allocate every terrain record before issuing the first draw. BufferData - // therefore never replaces the arena's backing store while an earlier - // slice can still reference it. The large region table is immutable for - // this assembled PView frame and is uploaded exactly once. - _clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount); - _clipFrame.UploadRegions(_gl); - _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); - _envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); - UploadTerrainClip(setTerrainClipUbo); - } - - private void UploadTerrainClip(Action setTerrainClipUbo) - { - TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl); - setTerrainClipUbo(binding); - } } -public interface IRetailPViewCellDrawCallbacks +public interface IRetailPViewCellSource { - public Action? DrawExitPortalMasks { get; } - public Action? DrawCellParticles { get; } - - /// #124: far-Z punch one look-in aperture (a clipped view polygon - /// of a looked-into building cell) — always the PUNCH variant regardless - /// of root kind (retail maxZ1; the root-keyed forceFarZ selector only - /// governs the MAIN frame's exit-portal masks). - public Action? DrawLookInPortalPunch { get; } + LoadedCell? Find(uint cellId); } -public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks +/// +/// Typed execution seam for the GL passes ordered by +/// . Implementations execute the requested +/// pass only; visibility construction and draw ordering remain renderer-owned. +/// All frame inputs and results are borrowed for the duration of the call. +/// +public interface IRetailPViewPassExecutor { - public ICamera Camera { get; } - public FrustumPlanes? Frustum { get; } - public uint? PlayerLandblockId { get; } - public HashSet? AnimatedEntityIds { get; } - - /// #121: draw the Scene-pass emitters attached to the frame's - /// cone-surviving dynamics (portal swirls, creature effects). Invoked once - /// per frame after the last entity pass with the survivor list. - public Action>? DrawDynamicsParticles { get; } + void BeginFrame(); + ClipFrameAssembly AssembleClipFrame( + PortalVisibilityFrame portalFrame, + ClipFrameAssembly reuseAssembly); + void PrepareClipFrame(int terrainUploadCount); + void SetTerrainClip(ReadOnlySpan planes); + void ClearClipRouting(); + void UseIndoorMembershipOnlyRouting(); + void PrepareCellBatches( + RetailPViewFrameInput frame, + HashSet visibleCellIds); + void DrawOpaqueCellShells(HashSet cellIds); + bool CellHasTransparentShell(uint cellId); + void DrawTransparentCellShells(HashSet cellIds); + void DrawTransparentCellShellsOrdered(IReadOnlyList cellIds); + void DrawEntityBucket( + RetailPViewFrameInput frame, + IReadOnlyList entities, + HashSet? visibleCellIds); + void EmitClipRouteProbe( + ClipFrameAssembly clipAssembly, + ClipViewSlice slice, + int sliceIndex); + void EmitOutStageOwner( + WorldEntity entity, + Vector3 sphereCenter, + float sphereRadius, + int sliceIndex, + bool passed); + void EmitOutStageRouting( + int sliceIndex, + IReadOnlyList entities, + ViewconeCuller viewcone); + void EmitPhantomObjects(uint cellId, int survivorCount); + void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context); + void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context); + void ClearInteriorDepth(); + void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); + void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); + void DrawUnattachedSceneParticles(RetailPViewFrameInput frame); + void FlushLandscapeAlpha(); + void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); + void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlyList survivors); + void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result); } /// @@ -1261,7 +1097,7 @@ internal sealed class BuildingGroupScratch } } -public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext +public sealed class RetailPViewFrameInput { public required LoadedCell RootCell { get; init; } @@ -1271,7 +1107,7 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext public required Vector3 ViewerEyePos { get; init; } public required Matrix4x4 ViewProjection { get; init; } - public required Func CellLookup { get; init; } + public required IRetailPViewCellSource Cells { get; init; } public required ICamera Camera { get; init; } public required Vector3 CameraWorldPosition { get; init; } public required FrustumPlanes? Frustum { get; init; } @@ -1283,37 +1119,20 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; } - public required Action SetTerrainClipUbo { get; init; } - public required Action DrawLandscapeSlice { get; init; } - /// #131/#132: the LATE landscape phase, per slice, after the #124 - /// look-ins — outside-stage dynamics' meshes + all scene particles + - /// weather (the FlushAlphaList deferral; see DrawLandscapeThroughOutsideView). - public Action? DrawLandscapeSliceLate { get; init; } - /// T1: one full-buffer depth clear between the outside stage and the - /// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor - /// roots — outdoors the interiors must depth-test against terrain + exteriors and - /// appear only through punched apertures. - public Action? ClearDepthForInterior { get; init; } - public Action? DrawExitPortalMasks { get; init; } - public Action? DrawCellParticles { get; init; } - public Action? DrawLookInPortalPunch { get; init; } - - /// #131: Scene-pass draw of UNATTACHED emitters - /// (AttachedObjectId == 0) for interior-root frames — invoked once at the - /// end of the landscape stage (pre-clear). Outdoor roots draw them via - /// GameWindow's dedicated post-frame pass instead. - public Action? DrawUnattachedSceneParticles { get; init; } - /// Drains retail's delayed landscape alpha list before the - /// optional outside-to-inside depth clear. The same frame-scoped queue - /// remains open for the final world alpha scope. - public Action? FlushLandscapeAlpha { get; init; } - public Action>? DrawDynamicsParticles { get; init; } - /// - /// Synchronous observation of the borrowed current-frame result. The - /// callback must copy anything it needs to retain beyond this invocation. - /// - public Action? EmitDiagnostics { get; init; } + // Pass-presentation and diagnostic values consumed synchronously by the + // typed executor. This input is data-only and is never retained. + public required bool RenderSky { get; init; } + public required bool RenderWeather { get; init; } + public required float DayFraction { get; init; } + public required DayGroupData? ActiveDayGroup { get; init; } + public required SkyKeyframe SkyKeyframe { get; init; } + public required bool EnvironOverrideActive { get; init; } + public required uint ViewerCellId { get; init; } + public required uint PlayerCellId { get; init; } + public required Vector3 PlayerViewPosition { get; init; } + public required Matrix4x4 CameraView { get; init; } + public required CameraCellResolution CameraCellResolution { get; init; } } /// diff --git a/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs b/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs new file mode 100644 index 00000000..e8a1cd5b --- /dev/null +++ b/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs @@ -0,0 +1,148 @@ +using System.Diagnostics; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Rendering; + +internal readonly record struct FramePipelineDiagnosticFacts( + LandblockPresentationDiagnostics Presentation, + RollingTimingPercentiles UploadTiming, + int EntitiesWalked, + int DeferredApplyBacklog, + int FullWindowRetirementCount, + int LastFullWindowRetirementLandblockCount, + int MaterializedEntityCount, + int SpawnSnapshotCount, + int ResidentEntityCount); + +internal interface IFramePipelineDiagnosticFactsSource +{ + TerrainRenderDiagnosticFacts CaptureTerrain(); + + FramePipelineDiagnosticFacts CaptureFrame(); +} + +/// +/// Samples the existing canonical runtime owners only when the five-second +/// frame diagnostic cadence publishes. It owns no world or render resource. +/// +internal sealed class RuntimeFramePipelineDiagnosticFactsSource : + IFramePipelineDiagnosticFactsSource +{ + private readonly TerrainModernRenderer? _terrain; + private readonly LandblockPresentationPipeline? _presentation; + private readonly RuntimeRenderFrameLivePreparation? _livePreparation; + private readonly WbDrawDispatcher? _dispatcher; + private readonly StreamingController? _streaming; + private readonly LiveEntityRuntime _liveEntities; + private readonly GpuWorldState _world; + + public RuntimeFramePipelineDiagnosticFactsSource( + TerrainModernRenderer? terrain, + LandblockPresentationPipeline? presentation, + RuntimeRenderFrameLivePreparation? livePreparation, + WbDrawDispatcher? dispatcher, + StreamingController? streaming, + LiveEntityRuntime liveEntities, + GpuWorldState world) + { + _terrain = terrain; + _presentation = presentation; + _livePreparation = livePreparation; + _dispatcher = dispatcher; + _streaming = streaming; + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _world = world ?? throw new ArgumentNullException(nameof(world)); + } + + public TerrainRenderDiagnosticFacts CaptureTerrain() => new( + _terrain?.VisibleSlots ?? 0, + _terrain?.LoadedSlots ?? 0, + _terrain?.CapacitySlots ?? 0); + + public FramePipelineDiagnosticFacts CaptureFrame() => new( + _presentation?.Diagnostics ?? default, + _livePreparation?.UploadTiming ?? default, + _dispatcher?.LastDrawStats.EntitiesWalked ?? 0, + _streaming?.DeferredApplyBacklog ?? 0, + _streaming?.FullWindowRetirementCount ?? 0, + _streaming?.LastFullWindowRetirementLandblockCount ?? 0, + _liveEntities.MaterializedWorldEntities.Count, + _liveEntities.Snapshots.Count, + _world.Entities.Count); +} + +/// +/// Keeps the terrain timing sample and the frame-pipeline rollup on one +/// failure-atomic publication cadence. The owner is shared by fallback and +/// PView terrain passes, so extraction cannot silently split their telemetry. +/// +internal sealed class TerrainDrawDiagnosticsController +{ + internal const long PublicationIntervalMilliseconds = 5_000; + + private readonly bool _enabled; + private readonly WorldRenderDiagnostics _world; + private readonly IFramePipelineDiagnosticFactsSource _facts; + private readonly IRenderFrameDiagnosticLog _log; + private long _lastPublicationMilliseconds; + + public TerrainDrawDiagnosticsController( + bool enabled, + WorldRenderDiagnostics world, + IFramePipelineDiagnosticFactsSource facts, + IRenderFrameDiagnosticLog log) + { + _enabled = enabled; + _world = world ?? throw new ArgumentNullException(nameof(world)); + _facts = facts ?? throw new ArgumentNullException(nameof(facts)); + _log = log ?? throw new ArgumentNullException(nameof(log)); + } + + public void Begin() => _world.BeginTerrainDraw(); + + public void Complete() => Complete(_enabled ? Environment.TickCount64 : 0L); + + internal void Complete(long nowMilliseconds) + { + _world.EndTerrainDraw(); + if (!_enabled + || nowMilliseconds - _lastPublicationMilliseconds <= PublicationIntervalMilliseconds) + { + return; + } + + // Both writes are one diagnostic transaction. Any failure leaves the + // cadence uncommitted so a later terrain draw retries both publications. + _world.PublishTerrainDiagnostics(_facts.CaptureTerrain()); + _log.WriteLine(FormatFrame(_facts.CaptureFrame())); + _lastPublicationMilliseconds = nowMilliseconds; + } + + internal static string FormatFrame(FramePipelineDiagnosticFacts facts) + { + LandblockRenderPublisherDiagnostics render = facts.Presentation.Render; + LandblockPhysicsPublisherDiagnostics physics = facts.Presentation.Physics; + LandblockStaticPresentationDiagnostics statics = facts.Presentation.Statics; + double ticksToMicros = 1_000_000.0 / Stopwatch.Frequency; + double uploadMedian = facts.UploadTiming.MedianHundredthsMicroseconds / 100.0; + double uploadP95 = facts.UploadTiming.Percentile95HundredthsMicroseconds / 100.0; + return + $"[FRAME-DIAG] publish={render.BeginCount}/{render.CompleteCount} " + + $"render_total_us=[terrain={render.TerrainPublishTicks * ticksToMicros:F1} " + + $"begin={render.BeginPublishTicks * ticksToMicros:F1} " + + $"complete={render.CompletePublishTicks * ticksToMicros:F1}] " + + $"physics_total_us=[base={physics.BasePublishTicks * ticksToMicros:F1} " + + $"gfx={physics.GfxCacheTicks * ticksToMicros:F1} " + + $"complete={physics.CompletePublishTicks * ticksToMicros:F1}] " + + $"static={statics.BeginCount}/{statics.CompleteCount}" + + $"(active={statics.ActiveEntityCount}) " + + $"entUpl_us={uploadMedian:F1}m/{uploadP95:F1}p95 " + + $"deferred={facts.DeferredApplyBacklog} " + + $"fullRetire={facts.FullWindowRetirementCount}" + + $"(landblocks={facts.LastFullWindowRetirementLandblockCount}) " + + $"esg={facts.MaterializedEntityCount} spawn={facts.SpawnSnapshotCount} " + + $"resident={facts.ResidentEntityCount} walked={facts.EntitiesWalked}"; + } +} diff --git a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs index 57da6334..50cdb85d 100644 --- a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs +++ b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs @@ -2,6 +2,7 @@ using System.Numerics; using System.Diagnostics; using System.Text; using AcDream.Core.Vfx; +using AcDream.Core.World; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; @@ -123,6 +124,12 @@ internal sealed class WorldRenderDiagnostics private string? _lastScissorSignature; private long _scissorSequence; private string? _lastOutStageSignature; + private string? _lastOutStageRoutingSignature; + private readonly Dictionary _outStageOwnerVerdicts = []; + private readonly Dictionary _phantomObjectSignatures = []; + private string? _lastClipRouteSignature; + private long _clipRouteSequence; + private readonly List _clipRouteCellKeys = []; public WorldRenderDiagnostics( IRenderGlStateReader gl, @@ -192,6 +199,177 @@ internal sealed class WorldRenderDiagnostics _log.WriteLine($"[clip-route-scis] n={_scissorSequence} {signature}"); } + public void EmitClipRouteProbe( + bool enabled, + ClipFrame clipFrame, + ClipFrameAssembly clipAssembly, + ClipViewSlice slice, + int sliceIndex) + { + if (!enabled) + return; + + var text = new StringBuilder(256); + text.Append(FormattableString.Invariant( + $"slice={sliceIndex}/{clipAssembly.OutsideViewSlices.Length} slot={slice.Slot}")); + text.Append(FormattableString.Invariant( + $" ndc=({slice.NdcAabb.X:F3},{slice.NdcAabb.Y:F3},{slice.NdcAabb.Z:F3},{slice.NdcAabb.W:F3})")); + text.Append(FormattableString.Invariant($" planes={slice.Planes.Length}[")); + for (int i = 0; i < slice.Planes.Length; i++) + { + Vector4 plane = slice.Planes[i]; + if (i > 0) + text.Append(' '); + text.Append(FormattableString.Invariant( + $"({plane.X:F3},{plane.Y:F3},{plane.Z:F3},{plane.W:F3})")); + } + + text.Append("] cells={"); + _clipRouteCellKeys.Clear(); + foreach (uint key in clipAssembly.CellIdToSlot.Keys) + _clipRouteCellKeys.Add(key); + _clipRouteCellKeys.Sort(); + for (int i = 0; i < _clipRouteCellKeys.Count; i++) + { + if (i > 0) + text.Append(','); + text.Append(FormattableString.Invariant( + $"0x{_clipRouteCellKeys[i]:X8}:{clipAssembly.CellIdToSlot[_clipRouteCellKeys[i]]}")); + } + text.Append('}'); + + ReadOnlySpan regionBytes = clipFrame.RegionBytesForTest; + int offset = slice.Slot * ClipFrame.CellClipStrideBytes; + if (offset >= 0 && offset + ClipFrame.CellClipStrideBytes <= regionBytes.Length) + { + uint count = BitConverter.ToUInt32(regionBytes.Slice(offset, 4)); + text.Append(FormattableString.Invariant($" ssbo[{slice.Slot}]: n={count}")); + int planeCount = (int)Math.Min(count, (uint)ClipFrame.MaxPlanes); + for (int i = 0; i < planeCount; i++) + { + int planeOffset = offset + ClipFrame.CellClipPlanesOffset + i * 16; + float x = BitConverter.ToSingle(regionBytes.Slice(planeOffset, 4)); + float y = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 4, 4)); + float z = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 8, 4)); + float w = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 12, 4)); + text.Append(FormattableString.Invariant($" ({x:F3},{y:F3},{z:F3},{w:F3})")); + } + } + else + { + text.Append(FormattableString.Invariant( + $" ssbo[{slice.Slot}]: OUT-OF-RANGE len={regionBytes.Length}")); + } + + ReadOnlySpan terrainBytes = clipFrame.TerrainBytesForTest; + int terrainCount = BitConverter.ToInt32(terrainBytes[..4]); + float p0 = BitConverter.ToSingle(terrainBytes.Slice(16, 4)); + float p1 = BitConverter.ToSingle(terrainBytes.Slice(20, 4)); + float p2 = BitConverter.ToSingle(terrainBytes.Slice(24, 4)); + float p3 = BitConverter.ToSingle(terrainBytes.Slice(28, 4)); + text.Append(FormattableString.Invariant( + $" ubo: n={terrainCount} p0=({p0:F3},{p1:F3},{p2:F3},{p3:F3})")); + + string signature = text.ToString(); + _clipRouteSequence++; + if (signature == _lastClipRouteSignature) + return; + _lastClipRouteSignature = signature; + _log.WriteLine($"[clip-route] n={_clipRouteSequence} {signature}"); + } + + public void EmitOutStageOwner( + bool enabled, + IReadOnlySet watchedEntityIds, + WorldEntity entity, + Vector3 sphereCenter, + float sphereRadius, + int sliceIndex, + bool passed) + { + if (!enabled + || !watchedEntityIds.Contains(entity.Id) + || (_outStageOwnerVerdicts.TryGetValue(entity.Id, out bool previous) + && previous == passed)) + { + return; + } + + _outStageOwnerVerdicts[entity.Id] = passed; + _log.WriteLine(FormattableString.Invariant( + $"[outstage-own] id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) c=({sphereCenter.X:F1},{sphereCenter.Y:F1},{sphereCenter.Z:F1}) r={sphereRadius:F1} slice={sliceIndex} {(passed ? "PASS" : "CULL")}")); + } + + public void EmitOutStageRouting( + bool enabled, + int sliceIndex, + IReadOnlyList entities, + ViewconeCuller viewcone) + { + if (!enabled) + return; + + var text = new StringBuilder(192); + text.Append("slice=").Append(sliceIndex) + .Append(" outStage=").Append(entities.Count).Append(" ["); + for (int i = 0; i < entities.Count; i++) + { + WorldEntity entity = entities[i]; + EntitySphere(entity, out Vector3 center, out float radius); + bool passed = viewcone.SphereVisibleInOutsideSlice( + sliceIndex, + center, + radius); + if (i > 0) + text.Append(' '); + text.Append(FormattableString.Invariant( + $"0x{(entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id):X8}(s{entity.SourceGfxObjOrSetupId:X8}):{(passed ? "PASS" : "CULL")}:r={radius:F1}")); + } + text.Append(']'); + string signature = text.ToString(); + if (signature == _lastOutStageRoutingSignature) + return; + _lastOutStageRoutingSignature = signature; + _log.WriteLine("[outstage] " + signature); + } + + public void EmitPhantomObjects(bool enabled, uint cellId, int survivorCount) + { + if (!enabled + || (_phantomObjectSignatures.TryGetValue(cellId, out int previous) + && previous == survivorCount)) + { + return; + } + + _phantomObjectSignatures[cellId] = survivorCount; + _log.WriteLine( + $"[phantom-objs] cell=0x{cellId:X8} entities={survivorCount} (drawn unclipped, no viewcone)"); + } + + public void EmitSeamMask( + bool enabled, + IReadOnlySet targetCells, + uint cellId, + int portalIndex, + bool forceFarZ, + ReadOnlySpan vertices) + { + if (!enabled || !targetCells.Contains(cellId)) + return; + + float minimumZ = float.MaxValue; + float maximumZ = float.MinValue; + foreach (Vector3 vertex in vertices) + { + minimumZ = Math.Min(minimumZ, vertex.Z); + maximumZ = Math.Max(maximumZ, vertex.Z); + } + + _log.WriteLine(FormattableString.Invariant( + $"[seam-mask] t={Environment.TickCount64} cell=0x{cellId:X8} portal={portalIndex} far={forceFarZ} n={vertices.Length} z=[{minimumZ:F3},{maximumZ:F3}]")); + } + public void EmitPViewInput( bool enabled, PortalVisibilityFrame portalFrame, @@ -590,4 +768,15 @@ internal sealed class WorldRenderDiagnostics .Append(" live=").Append(partition.Dynamics.Count) .ToString(); } + + private static void EntitySphere( + WorldEntity entity, + out Vector3 center, + out float radius) + { + if (entity.AabbDirty) + entity.RefreshAabb(); + center = (entity.AabbMin + entity.AabbMax) * 0.5f; + radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f; + } } diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 889f5517..20fa9dac 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -192,7 +192,7 @@ public static class RenderingDiagnostics /// slot, and the terrain-UBO head as uploaded; WbDrawDispatcher.Draw emits one /// [clip-route-disp] line (print-on-change, routed draws only) with the per-slot /// instance histogram exactly as uploaded to binding=3 plus the culled-entity count; and - /// GameWindow.DrawRetailPViewLandscapeSlice emits one [clip-route-scis] line + /// RetailPViewPassExecutor.DrawLandscapeSlice emits one [clip-route-scis] line /// (print-on-change) with the ACTUAL GL scissor enable + box the landscape pass draws under. /// Throwaway apparatus — strip once §4 ships. Initial state from ACDREAM_PROBE_CLIPROUTE=1. /// @@ -325,7 +325,7 @@ public static class RenderingDiagnostics /// parented to a target cell — position, culled/slot, resolved light set. A /// floor-coincident entity (plate/static) would be the z-fight's second draw; /// the player entity doubles as the probe's positive control. - /// [seam-mask]GameWindow.DrawRetailPViewPortalDepthWrite: + /// [seam-mask]RetailPViewPassExecutor.DrawPortalDepthWrite: /// every portal depth fan drawn in a target cell. A sealed dungeon must show /// ZERO (seals fire only for OtherCellId==0xFFFF) — any line is a finding. /// diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index b56d9b31..6b1c9628 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -106,6 +106,14 @@ public sealed class GameWindowRenderLeafCompositionTests Assert.Contains("_renderFrameResources = null;", source); Assert.Contains("_renderFrameLivePreparation = null;", source); Assert.Contains("_renderWeatherFrame = null;", source); + AssertAppearsInOrder( + source, + "new ResourceShutdownStage(\"frame borrowers\"", + "_retailPViewPassExecutor = null;", + "_retailPViewCells = null;", + "_terrainDrawDiagnostics = null;", + "_retailPViewRenderer = null;", + "new ResourceShutdownStage(\"session dependents\""); AssertAppearsInOrder( source, "new(\"frame pacing\", _displayFramePacing.Dispose)", @@ -143,15 +151,15 @@ public sealed class GameWindowRenderLeafCompositionTests } [Fact] - public void TerrainAndFrameDiagnostics_CommitOneSharedCadenceAfterBothWrites() + public void TerrainAndFrameDiagnostics_AreComposedAsOneFocusedOwner() { string source = GameWindowSource(); - AssertAppearsInOrder( - source, - "_worldRenderDiagnostics?.PublishTerrainDiagnostics(", - "PublishFrameDiagnostics();", - "_frameDiagLastPublicationMilliseconds = now;"); + Assert.Contains( + "new AcDream.App.Rendering.TerrainDrawDiagnosticsController(", + source); + Assert.Contains("_terrainDrawDiagnostics!.Begin();", source); + Assert.Contains("_terrainDrawDiagnostics.Complete();", source); } private static string GameWindowSource() => File.ReadAllText(Path.Combine( diff --git a/tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs b/tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs index a011beb5..e0c93dd8 100644 --- a/tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs @@ -22,7 +22,7 @@ namespace AcDream.App.Tests.Rendering; /// → the exact DrawDynamicsLast visibility predicate /// (RetailPViewRenderer.cs:375-401), PLUS the depth relationship DrawDynamicsLast is /// subject to: under an INTERIOR root the exit-portal SEAL stamps the door fan at TRUE -/// depth (GameWindow.DrawRetailPViewPortalDepthWrite, forceFarZ=false) after the full +/// depth (RetailPViewPassExecutor.DrawPortalDepthWrite, forceFarZ=false) after the full /// depth clear, and dynamics draw depth-tested AFTER it. /// /// The four candidates this pins (handoff §5 + this session's read): @@ -261,7 +261,11 @@ public class HouseExitWalkReplayTests var drawable = new HashSet(pv.OrderedVisibleCells); bool outsideStage = rootCellId != 0u && RetailPViewRenderer.DynamicDrawsInOutsideStage( - playerCell, sphereC, PlayerSphereRadius, drawable, lookup); + playerCell, + sphereC, + PlayerSphereRadius, + drawable, + new TestCellSource(lookup)); // Candidate 4: the seal depth check. Applies when the root is INTERIOR // (ClearDepthForInterior + the TRUE-depth seal run, GameWindow:7719-7729), @@ -483,4 +487,10 @@ public class HouseExitWalkReplayTests $"eye {d * 100,5:F1} cm OUTSIDE + root still 0x{ExitCellId:X8}: outPolys={pv.OutsideView.Polygons.Count} flood={pv.OrderedVisibleCells.Count} playerConeVisible={playerVisible}")); } } + + private sealed class TestCellSource(Func find) : + IRetailPViewCellSource + { + public LoadedCell? Find(uint cellId) => find(cellId); + } } diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs new file mode 100644 index 00000000..3d1a21d7 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -0,0 +1,540 @@ +using System.Numerics; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RetailPViewPassExecutorTests +{ + [Fact] + public void DrawInside_outdoor_executes_the_real_typed_sequence_without_interior_clear() + { + var renderer = new RetailPViewRenderer(); + using var executor = new RecordingExecutor(); + LoadedCell root = OutdoorCellNode.Build(0xA9B40000u); + + renderer.DrawInside(Frame(root), executor); + + Assert.Equal( + [ + "begin", + "assemble", + "prepare-clip:3", + "indoor-routing", + "prepare-cells", + "diagnostics", + "terrain-clip", + "clear-routing", + "landscape-early", + "terrain-clip", + "clear-routing", + "outstage-routing", + "landscape-late", + "landscape-alpha", + "indoor-routing", + "indoor-routing", + "exit-mask", + "indoor-routing", + "opaque-shells", + ], + executor.Operations); + } + + [Fact] + public void DrawInside_interior_exit_flushes_before_clear_and_resets_borrowed_result() + { + var renderer = new RetailPViewRenderer(); + using var executor = new RecordingExecutor(); + LoadedCell outdoor = OutdoorCellNode.Build(0xA9B40000u); + RetailPViewFrameResult borrowed = renderer.DrawInside(Frame(outdoor), executor); + Assert.Contains(outdoor.CellId, borrowed.DrawableCells); + + executor.Operations.Clear(); + LoadedCell interior = InteriorWithExit(0xA9B40100u); + RetailPViewFrameResult reused = renderer.DrawInside(Frame(interior), executor); + + Assert.Same(borrowed, reused); + Assert.DoesNotContain(outdoor.CellId, reused.DrawableCells); + Assert.Contains(interior.CellId, reused.DrawableCells); + AssertAppearsInOrder( + string.Join('|', executor.Operations), + "landscape-early", + "landscape-late", + "unattached-particles", + "landscape-alpha", + "interior-depth-clear", + "indoor-routing", + "exit-mask"); + } + + [Fact] + public void DrawInside_interior_without_an_outside_slice_skips_landscape_and_depth_clear() + { + var renderer = new RetailPViewRenderer(); + using var executor = new RecordingExecutor(); + var root = new LoadedCell + { + CellId = 0xA9B40100u, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + + renderer.DrawInside(Frame(root), executor); + + Assert.DoesNotContain("terrain-clip", executor.Operations); + Assert.DoesNotContain("landscape-early", executor.Operations); + Assert.DoesNotContain("landscape-late", executor.Operations); + Assert.DoesNotContain("landscape-alpha", executor.Operations); + Assert.DoesNotContain("interior-depth-clear", executor.Operations); + } + + [Fact] + public void Particle_classifications_reset_before_an_empty_following_frame() + { + var classifications = new RetailPViewParticleClassifications(); + classifications.ReplaceOutdoor([Entity(7u)]); + classifications.Visible.Add(8u); + classifications.Dynamics.Add(9u); + + classifications.BeginFrame(); + + Assert.Empty(classifications.Outdoor); + Assert.Empty(classifications.Visible); + Assert.Empty(classifications.Dynamics); + } + + [Fact] + public void DrawInside_real_fixtures_reach_entity_particle_and_transparent_shell_operations() + { + var renderer = new RetailPViewRenderer(); + using var executor = new RecordingExecutor { HasTransparentShells = true }; + LoadedCell interior = InteriorWithExit(0xA9B40100u); + WorldEntity cellStatic = Entity(10u, parentCellId: interior.CellId); + WorldEntity dynamic = Entity( + 11u, + serverGuid: 0x80000011u, + parentCellId: interior.CellId); + + renderer.DrawInside(Frame(interior, [cellStatic, dynamic]), executor); + + Assert.Contains("opaque-shells", executor.Operations); + Assert.Contains("transparent-shells-ordered", executor.Operations); + Assert.Contains("entity-bucket", executor.Operations); + Assert.Contains("cell-particles", executor.Operations); + Assert.Contains("dynamics-particles", executor.Operations); + Assert.Contains("phantom-objects", executor.Operations); + } + + [Fact] + public void DrawInside_interior_root_executes_the_nearby_building_look_in_punch() + { + var renderer = new RetailPViewRenderer(); + using var executor = new RecordingExecutor(); + LoadedCell root = InteriorWithExit(0xA9B40100u); + root.BuildingId = 1u; + LoadedCell[] building = NearbyTwoCellBuilding(); + + renderer.DrawInside( + Frame( + root, + nearbyBuildingCells: building, + additionalCells: building), + executor); + + Assert.Contains("look-in-punch", executor.Operations); + } + + [Fact] + public void Renderer_source_preserves_retail_stage_order_through_typed_operations() + { + string source = RendererSource(); + string drawInside = MethodBody(source, "public RetailPViewFrameResult DrawInside("); + AssertAppearsInOrder( + drawInside, + "passes.BeginFrame();", + "passes.EmitDiagnostics(ctx, result);", + "DrawLandscapeThroughOutsideView(ctx, passes", + "DrawExitPortalMasks(ctx, passes", + "DrawEnvCellShells(passes, pvFrame);", + "DrawCellObjectLists(ctx, passes", + "DrawDynamicsLast(ctx, passes"); + + string landscape = MethodBody( + source, + "private void DrawLandscapeThroughOutsideView("); + AssertAppearsInOrder( + landscape, + "passes.SetTerrainClip(slice.Planes);", + "passes.DrawLandscapeSlice(ctx", + "DrawBuildingLookIns(ctx, passes", + "passes.DrawLandscapeSliceLate(ctx", + "passes.DrawUnattachedSceneParticles(ctx);", + "passes.FlushLandscapeAlpha();", + "passes.ClearInteriorDepth();"); + + string lookIns = MethodBody(source, "private void DrawBuildingLookIns("); + AssertAppearsInOrder( + lookIns, + "passes.DrawLookInPortalPunch(ctx", + "passes.DrawOpaqueCellShells(_shellBatch);", + "passes.DrawCellParticles(ctx"); + } + + [Fact] + public void Extracted_contracts_retain_no_window_callbacks_or_visibility_owner() + { + Assert.DoesNotContain( + typeof(RetailPViewFrameInput).GetProperties(), + property => typeof(Delegate).IsAssignableFrom(property.PropertyType)); + + FieldInfo[] fields = typeof(RetailPViewPassExecutor).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(CellVisibility)); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(RetailPViewFrameInput)); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(RetailPViewFrameResult)); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(ClipFrameAssembly)); + Assert.DoesNotContain( + fields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + [Fact] + public void Concrete_executor_forwards_frame_reset_and_brackets_terrain_diagnostics() + { + string source = ExecutorClassSource(); + string begin = MethodBody(source, "public void BeginFrame()"); + Assert.Contains("_particleClassifications.BeginFrame();", begin); + + string landscape = MethodBody(source, "public void DrawLandscapeSlice("); + AssertAppearsInOrder( + landscape, + "_terrainDiagnostics.Begin();", + "_terrain?.Draw(", + "_terrainDiagnostics.Complete();"); + + Assert.DoesNotContain("Console.WriteLine", source); + Assert.DoesNotContain("Console.WriteLine", RendererSource()); + } + + [Fact] + public void GameWindow_composes_one_executor_instead_of_a_draw_callback_bag() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.Contains("new AcDream.App.Rendering.RetailPViewPassExecutor(", source); + Assert.Contains("new AcDream.App.Rendering.RetailPViewFrameInput", source); + Assert.DoesNotContain("RetailPViewDrawContext", source); + Assert.DoesNotContain("DrawLandscapeSlice =", source); + Assert.DoesNotContain("DrawExitPortalMasks =", source); + } + + private static string RendererSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "RetailPViewRenderer.cs")); + + private static string ExecutorSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "RetailPViewPassExecutor.cs")); + + private static string ExecutorClassSource() + { + string source = ExecutorSource(); + int start = source.IndexOf( + "internal sealed class RetailPViewPassExecutor", + StringComparison.Ordinal); + Assert.True(start >= 0, "Missing concrete RetailPViewPassExecutor."); + return source[start..]; + } + + private static RetailPViewFrameInput Frame( + LoadedCell root, + IReadOnlyList? entities = null, + IReadOnlyList? nearbyBuildingCells = null, + IReadOnlyList? additionalCells = null) + { + var cells = new Dictionary { [root.CellId] = root }; + if (additionalCells is not null) + { + foreach (LoadedCell cell in additionalCells) + cells[cell.CellId] = cell; + } + + var entries = new List<( + uint LandblockId, + Vector3 AabbMin, + Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)>(); + if (entities is not null) + { + entries.Add(( + root.CellId & 0xFFFF0000u, + Vector3.Zero, + Vector3.Zero, + entities, + null)); + } + + return new RetailPViewFrameInput + { + RootCell = root, + NearbyBuildingCells = nearbyBuildingCells, + ViewerEyePos = Vector3.Zero, + ViewProjection = TestCamera.ViewProjection, + Cells = new DictionaryCellSource(cells), + Camera = new TestCamera(), + CameraWorldPosition = Vector3.Zero, + Frustum = null, + PlayerLandblockId = root.CellId & 0xFFFF0000u, + AnimatedEntityIds = null, + RenderCenterLbX = 0, + RenderCenterLbY = 0, + RenderRadius = 1, + LandblockEntries = entries, + RenderSky = true, + RenderWeather = true, + DayFraction = 0f, + ActiveDayGroup = null, + SkyKeyframe = default, + EnvironOverrideActive = false, + ViewerCellId = root.CellId, + PlayerCellId = root.CellId, + PlayerViewPosition = Vector3.Zero, + CameraView = TestCamera.ViewMatrix, + CameraCellResolution = CameraCellResolution.None, + }; + } + + private static LoadedCell InteriorWithExit(uint cellId) + { + var cell = new LoadedCell + { + CellId = cellId, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Portals = [new CellPortalInfo(0xFFFF, 0, 0, 0)], + }; + cell.PortalPolygons.Add( + [ + new Vector3(-1f, -1f, -2f), + new Vector3(1f, -1f, -2f), + new Vector3(1f, 1f, -2f), + new Vector3(-1f, 1f, -2f), + ]); + return cell; + } + + private static LoadedCell[] NearbyTwoCellBuilding() + { + const uint vestibuleId = 0xA9B40170u; + const uint roomId = 0xA9B40171u; + var vestibule = new LoadedCell + { + CellId = vestibuleId, + BuildingId = 2u, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Portals = + [ + new CellPortalInfo(0xFFFF, 0, 0, 0), + new CellPortalInfo(0x0171, 1, 0, 0), + ], + ClipPlanes = + [ + new PortalClipPlane + { + Normal = new Vector3(0, 0, 1), + D = 3f, + InsideSide = 1, + }, + ], + }; + vestibule.PortalPolygons.Add( + [ + new Vector3(-0.5f, -0.5f, -2f), + new Vector3(0.5f, -0.5f, -2f), + new Vector3(0.5f, 0.5f, -2f), + new Vector3(-0.5f, 0.5f, -2f), + ]); + vestibule.PortalPolygons.Add( + [ + new Vector3(-0.6f, -0.6f, -4f), + new Vector3(0.6f, -0.6f, -4f), + new Vector3(0.6f, 0.6f, -4f), + new Vector3(-0.6f, 0.6f, -4f), + ]); + + var room = new LoadedCell + { + CellId = roomId, + BuildingId = 2u, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Portals = [new CellPortalInfo(0x0170, 0, 0, 1)], + }; + room.PortalPolygons.Add( + [ + new Vector3(-0.6f, -0.6f, -4f), + new Vector3(0.6f, -0.6f, -4f), + new Vector3(0.6f, 0.6f, -4f), + new Vector3(-0.6f, 0.6f, -4f), + ]); + return [vestibule, room]; + } + + private static WorldEntity Entity( + 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, + }; + + private static string MethodBody(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, $"Missing method: {signature}"); + int open = source.IndexOf('{', start); + int depth = 0; + for (int index = open; index < source.Length; index++) + { + if (source[index] == '{') + depth++; + else if (source[index] == '}' && --depth == 0) + return source[start..(index + 1)]; + } + + throw new InvalidOperationException($"Unterminated method: {signature}"); + } + + private static void AssertAppearsInOrder(string source, params string[] needles) + { + int cursor = -1; + foreach (string needle in needles) + { + int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal); + Assert.True(next > cursor, $"Missing or out-of-order fragment: {needle}"); + cursor = next; + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } + + private sealed class DictionaryCellSource( + IReadOnlyDictionary cells) : IRetailPViewCellSource + { + public LoadedCell? Find(uint cellId) => + cells.TryGetValue(cellId, out LoadedCell? cell) ? cell : null; + } + + private sealed class TestCamera : ICamera + { + public static Matrix4x4 ViewMatrix { get; } = Matrix4x4.CreateLookAt( + Vector3.Zero, + new Vector3(0, 0, -1), + Vector3.UnitY); + + public static Matrix4x4 ViewProjection { get; } = ViewMatrix + * Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f); + + public Matrix4x4 View => ViewMatrix; + public Matrix4x4 Projection { get; } = + Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f); + public float Aspect { get; set; } = 1f; + } + + private sealed class RecordingExecutor : IRetailPViewPassExecutor, IDisposable + { + private readonly ClipFrame _clipFrame = ClipFrame.NoClip(); + + public List Operations { get; } = []; + public bool HasTransparentShells { get; init; } + + public void BeginFrame() => Operations.Add("begin"); + public ClipFrameAssembly AssembleClipFrame( + PortalVisibilityFrame portalFrame, + ClipFrameAssembly reuseAssembly) + { + Operations.Add("assemble"); + return ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly); + } + + public void PrepareClipFrame(int terrainUploadCount) => + Operations.Add($"prepare-clip:{terrainUploadCount}"); + + public void SetTerrainClip(ReadOnlySpan planes) => + Operations.Add("terrain-clip"); + + public void ClearClipRouting() => Operations.Add("clear-routing"); + public void UseIndoorMembershipOnlyRouting() => Operations.Add("indoor-routing"); + public void PrepareCellBatches(RetailPViewFrameInput frame, HashSet visibleCellIds) => + Operations.Add("prepare-cells"); + public void DrawOpaqueCellShells(HashSet cellIds) => Operations.Add("opaque-shells"); + public bool CellHasTransparentShell(uint cellId) => HasTransparentShells; + public void DrawTransparentCellShells(HashSet cellIds) => Operations.Add("transparent-shells"); + public void DrawTransparentCellShellsOrdered(IReadOnlyList cellIds) => + Operations.Add("transparent-shells-ordered"); + public void DrawEntityBucket( + RetailPViewFrameInput frame, + IReadOnlyList entities, + HashSet? visibleCellIds) => Operations.Add("entity-bucket"); + public void EmitClipRouteProbe( + ClipFrameAssembly clipAssembly, + ClipViewSlice slice, + int sliceIndex) => Operations.Add("clip-probe"); + public void EmitOutStageOwner( + WorldEntity entity, + Vector3 sphereCenter, + float sphereRadius, + int sliceIndex, + bool passed) => Operations.Add("outstage-owner"); + public void EmitOutStageRouting( + int sliceIndex, + IReadOnlyList entities, + ViewconeCuller viewcone) => Operations.Add("outstage-routing"); + public void EmitPhantomObjects(uint cellId, int survivorCount) => + Operations.Add("phantom-objects"); + public void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context) => Operations.Add("landscape-early"); + public void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context) => Operations.Add("landscape-late"); + public void ClearInteriorDepth() => Operations.Add("interior-depth-clear"); + public void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("exit-mask"); + public void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("look-in-punch"); + public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame) => Operations.Add("unattached-particles"); + public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha"); + public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles"); + public void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlyList survivors) => Operations.Add("dynamics-particles"); + public void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result) => Operations.Add("diagnostics"); + + public void Dispose() => _clipFrame.Dispose(); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs b/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs new file mode 100644 index 00000000..e15516a7 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs @@ -0,0 +1,153 @@ +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +public sealed class TerrainDrawDiagnosticsControllerTests +{ + [Fact] + public void Disabled_controller_still_closes_the_terrain_sample_without_publication() + { + var log = new RecordingLog(); + var facts = new RecordingFacts(); + var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var controller = new TerrainDrawDiagnosticsController(false, world, facts, log); + + controller.Begin(); + controller.Complete(10_000); + + Assert.Equal(0, facts.TerrainCaptureCount); + Assert.Equal(0, facts.FrameCaptureCount); + Assert.Empty(log.Messages); + } + + [Fact] + public void Publication_failure_does_not_commit_the_shared_cadence() + { + var log = new ThrowOnceLog(); + var facts = new RecordingFacts(); + var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var controller = new TerrainDrawDiagnosticsController(true, world, facts, log); + + controller.Begin(); + Assert.Throws(() => controller.Complete(10_000)); + + controller.Begin(); + controller.Complete(10_001); + + Assert.Equal(2, facts.TerrainCaptureCount); + Assert.Equal(1, facts.FrameCaptureCount); + Assert.Collection( + log.Messages, + terrain => Assert.StartsWith("[TERRAIN-DIAG]", terrain), + frame => Assert.StartsWith("[FRAME-DIAG]", frame)); + } + + [Fact] + public void Frame_log_failure_retries_both_shared_publications() + { + var log = new ThrowOnFirstFrameLog(); + var facts = new RecordingFacts(); + var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var controller = new TerrainDrawDiagnosticsController(true, world, facts, log); + + controller.Begin(); + Assert.Throws(() => controller.Complete(10_000)); + + controller.Begin(); + controller.Complete(10_001); + + Assert.Equal(2, facts.TerrainCaptureCount); + Assert.Equal(2, facts.FrameCaptureCount); + Assert.Collection( + log.Messages, + firstTerrain => Assert.StartsWith("[TERRAIN-DIAG]", firstTerrain), + retryTerrain => Assert.StartsWith("[TERRAIN-DIAG]", retryTerrain), + frame => Assert.StartsWith("[FRAME-DIAG]", frame)); + } + + [Fact] + public void Successful_publication_waits_for_the_next_interval() + { + var log = new RecordingLog(); + var facts = new RecordingFacts(); + var world = new WorldRenderDiagnostics(new NullGlStateReader(), log); + var controller = new TerrainDrawDiagnosticsController(true, world, facts, log); + + controller.Begin(); + controller.Complete(10_000); + controller.Begin(); + controller.Complete(14_999); + controller.Begin(); + controller.Complete(15_000); + controller.Begin(); + controller.Complete(15_001); + + Assert.Equal(2, facts.TerrainCaptureCount); + Assert.Equal(2, facts.FrameCaptureCount); + Assert.Equal(4, log.Messages.Count); + } + + private sealed class RecordingFacts : IFramePipelineDiagnosticFactsSource + { + public int TerrainCaptureCount { get; private set; } + public int FrameCaptureCount { get; private set; } + + public TerrainRenderDiagnosticFacts CaptureTerrain() + { + TerrainCaptureCount++; + return new TerrainRenderDiagnosticFacts(1, 2, 3); + } + + public FramePipelineDiagnosticFacts CaptureFrame() + { + FrameCaptureCount++; + return default; + } + } + + private sealed class NullGlStateReader : IRenderGlStateReader + { + public RenderGlStateSnapshot CaptureState() => default; + public RenderGlScissorSnapshot CaptureScissor() => default; + } + + private sealed class RecordingLog : IRenderFrameDiagnosticLog + { + public List Messages { get; } = []; + public void WriteLine(string message) => Messages.Add(message); + } + + private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog + { + private bool _throw = true; + public List Messages { get; } = []; + + public void WriteLine(string message) + { + if (_throw) + { + _throw = false; + throw new InvalidOperationException("diagnostic failure"); + } + + Messages.Add(message); + } + } + + private sealed class ThrowOnFirstFrameLog : IRenderFrameDiagnosticLog + { + private bool _throw = true; + public List Messages { get; } = []; + + public void WriteLine(string message) + { + if (_throw && message.StartsWith("[FRAME-DIAG]", StringComparison.Ordinal)) + { + _throw = false; + throw new InvalidOperationException("frame diagnostic failure"); + } + + Messages.Add(message); + } + } +}