refactor(render): extract typed retail pview passes

This commit is contained in:
Erik 2026-07-22 06:40:09 +02:00
parent 6d6e5b5fa5
commit 85239fb373
13 changed files with 1888 additions and 841 deletions

View file

@ -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-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-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-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-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-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-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 | | 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 |

View file

@ -24,7 +24,7 @@ port, or resource-lifetime redesign.
- [x] D — extract frame-data classification and reusable world scratch without - [x] D — extract frame-data classification and reusable world scratch without
duplicating live identity, spatial, visibility, or resource ownership; then duplicating live identity, spatial, visibility, or resource ownership; then
run the dependent audio, sky, lighting, and listener preparation. 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. `RetailPViewPassExecutor` and data-only inputs.
- [ ] F — extract `WorldSceneRenderer`, including both shared-alpha scopes, - [ ] F — extract `WorldSceneRenderer`, including both shared-alpha scopes,
PView/fallback world branches, particles, debug world draw, and completion. PView/fallback world branches, particles, debug world draw, and completion.
@ -426,6 +426,22 @@ Retail, architecture, and adversarial corrected-diff reviews are clean.
- verify no executor operation rebuilds portal visibility or retains a borrowed - verify no executor operation rebuilds portal visibility or retains a borrowed
frame result across calls. 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 ### F — world scene owner
- extract alpha/selection/particle-visibility begin/end and the normal-world - extract alpha/selection/particle-visibility begin/end and the normal-world

View file

@ -83,7 +83,6 @@ public sealed class GameWindow : IDisposable
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"),
"1", "1",
System.StringComparison.Ordinal); System.StringComparison.Ordinal);
private long _frameDiagLastPublicationMilliseconds;
// MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call // MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call
// per OnRender + three stage scopes. All logic lives in // 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 // _interiorRenderer is constructed once both renderers exist; _interiorPartition is rebuilt
// each frame on an indoor root (null on the outdoor root). // each frame on an indoor root (null on the outdoor root).
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor;
private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells;
private AcDream.App.Rendering.TerrainDrawDiagnosticsController?
_terrainDrawDiagnostics;
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel; private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition; 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. // U.4 replaces the NoClip() frame with one built from the portal-visibility result.
private ClipFrame? _clipFrame; private ClipFrame? _clipFrame;
// PView pass-local particle classifications move with the pass executor in // Flat-world fallback particle scratch. PView classifications belong to
// Checkpoint E; GameWindow still owns them until that cutover. // RetailPViewPassExecutor.
private readonly HashSet<uint> _outdoorSceneParticleEntityIds = new(); private readonly HashSet<uint> _visibleSceneParticleEntityIds = [];
private readonly HashSet<uint> _visibleSceneParticleEntityIds = new(); private readonly HashSet<uint> _noSceneParticleEntityIds = [];
private readonly HashSet<uint> _noSceneParticleEntityIds = new();
/// <summary> /// <summary>
/// Phase 6.4: per-entity animation playback state for entities whose /// Phase 6.4: per-entity animation playback state for entities whose
@ -2311,8 +2313,7 @@ public sealed class GameWindow : IDisposable
landblockRenderPublisher.PrepareAfterRenderPins); landblockRenderPublisher.PrepareAfterRenderPins);
_clipFrame ??= ClipFrame.NoClip(); _clipFrame ??= ClipFrame.NoClip();
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer();
_gl, _clipFrame, _envCellRenderer!, _wbDrawDispatcher!);
// T1: invisible portal depth writes (seal/punch) — retail // T1: invisible portal depth writes (seal/punch) — retail
// DrawPortalPolyInternal (Ghidra 0x0059bc90). // DrawPortalPolyInternal (Ghidra 0x0059bc90).
@ -2892,6 +2893,37 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource( new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource(
_landblockPresentationPipeline!, _landblockPresentationPipeline!,
_cellVisibility)); _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( var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame, liveObjectFrame,
_worldState, _worldState,
@ -3527,7 +3559,6 @@ public sealed class GameWindow : IDisposable
int sigOutdoorRootObjectCount = 0; int sigOutdoorRootObjectCount = 0;
int sigLiveDynamicDrawnCount = 0; int sigLiveDynamicDrawnCount = 0;
string sigSceneParticles = "none"; string sigSceneParticles = "none";
_outdoorSceneParticleEntityIds.Clear();
_visibleSceneParticleEntityIds.Clear(); _visibleSceneParticleEntityIds.Clear();
// Retail entry ownership: GameWindow never builds a second indoor PView product. // 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 // 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. // Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup.
EnableClipDistances(); EnableClipDistances();
_worldRenderDiagnostics?.BeginTerrainDraw(); _terrainDrawDiagnostics!.Begin();
sigTerrainDrawn = true; sigTerrainDrawn = true;
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
PublishTerrainDrawDiagnostics(); _terrainDrawDiagnostics.Complete();
} }
@ -3617,7 +3648,7 @@ public sealed class GameWindow : IDisposable
if (_retailPViewRenderer is null) if (_retailPViewRenderer is null)
throw new InvalidOperationException("Retail PView renderer is required for indoor frames."); 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, RootCell = clipRoot,
// R-A2: outdoor root floods each nearby building per-building (not via the root). // 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, NearbyBuildingCells = nearbyBuildingCells,
ViewerEyePos = viewerEyePos, ViewerEyePos = viewerEyePos,
ViewProjection = envCellViewProj, ViewProjection = envCellViewProj,
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, Cells = _retailPViewCells!,
Camera = camera, Camera = camera,
CameraWorldPosition = camPos, CameraWorldPosition = camPos,
// (#176 correction: the former RebuildScopedLights callback — // (#176 correction: the former RebuildScopedLights callback —
@ -3640,94 +3671,20 @@ public sealed class GameWindow : IDisposable
RenderCenterLbY = renderCenterLbY, RenderCenterLbY = renderCenterLbY,
RenderRadius = _nearRadius, RenderRadius = _nearRadius,
LandblockEntries = _worldState.LandblockEntries, LandblockEntries = _worldState.LandblockEntries,
SetTerrainClipUbo = binding => _terrain?.SetClipUbo(binding), RenderSky = renderSky,
DrawLandscapeSlice = sliceCtx => RenderWeather = playerSeenOutside,
DrawRetailPViewLandscapeSlice( DayFraction = (float)WorldTime.DayFraction,
sliceCtx, ActiveDayGroup = _activeDayGroup,
camera, SkyKeyframe = kf,
frustum, EnvironOverrideActive = environOverrideActive,
camPos, ViewerCellId = viewerCellId,
playerLb, PlayerCellId = playerCellId,
animatedIds, PlayerViewPosition = playerViewPos,
renderSky, CameraView = camera.View,
renderWeather: playerSeenOutside, CameraCellResolution = _cellVisibility.LastCameraCellResolution,
kf, }, _retailPViewPassExecutor
environOverrideActive), ?? throw new InvalidOperationException(
// #131/#132: the late phase — dynamics meshes + scene "Retail PView pass executor is required for indoor frames."));
// 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),
});
pvFrame = pviewResult.PortalFrame; pvFrame = pviewResult.PortalFrame;
clipAssembly = pviewResult.ClipAssembly; clipAssembly = pviewResult.ClipAssembly;
envCellShellFilter = pviewResult.DrawableCells; envCellShellFilter = pviewResult.DrawableCells;
@ -3833,7 +3790,7 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Vfx.ParticleRenderPass.Scene, AcDream.Core.Vfx.ParticleRenderPass.Scene,
_visibleSceneParticleEntityIds, _visibleSceneParticleEntityIds,
includeUnattached: true, includeUnattached: true,
excludedAttachedOwnerIds: _outdoorSceneParticleEntityIds); excludedAttachedOwnerIds: _noSceneParticleEntityIds);
} }
else else
{ {
@ -3865,13 +3822,13 @@ public sealed class GameWindow : IDisposable
camera, camera,
camPos, camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene, AcDream.Core.Vfx.ParticleRenderPass.Scene,
_outdoorSceneParticleEntityIds, _retailPViewPassExecutor!.OutdoorSceneParticleEntityIds,
includeUnattached: true); includeUnattached: true);
} }
// Bug A fix (post-#26 worktree, 2026-04-26): weather sky // Bug A fix (post-#26 worktree, 2026-04-26): weather sky
// Outdoor LScape post-scene weather. Indoor weather through an exit portal is // 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) if (clipRoot is null && drawSkyThisFrame)
{ {
sigSkyDrawn = true; 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<uint>? 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<uint, AcDream.Core.World.WorldEntity>?)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<uint>? 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<uint, AcDream.Core.World.WorldEntity>?)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<System.Numerics.Vector3> 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<uint> _dynamicsSceneParticleEntityIds = new();
private void DrawRetailPViewDynamicsParticles(
IReadOnlyList<AcDream.Core.World.WorldEntity> 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() private void EnableClipDistances()
{ {
for (int i = 0; i < ClipFrame.MaxPlanes; i++) for (int i = 0; i < ClipFrame.MaxPlanes; i++)
@ -4571,23 +4226,6 @@ public sealed class GameWindow : IDisposable
_gl!.Disable(EnableCap.ClipDistance0 + i); _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 ──────────────────────────────── // ── Phase I.2 — DebugPanel helpers ────────────────────────────────
// //
// The ImGui DebugPanel reads through DebugVM closures that ask // 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() private void OnClosing()
=> CompleteShutdown(); => CompleteShutdown();
@ -5521,6 +5105,10 @@ public sealed class GameWindow : IDisposable
{ {
_worldRenderFrameBuilder = null; _worldRenderFrameBuilder = null;
_skyPesFrame = null; _skyPesFrame = null;
_retailPViewPassExecutor = null;
_retailPViewCells = null;
_terrainDrawDiagnostics = null;
_retailPViewRenderer = null;
}), }),
]), ]),
new ResourceShutdownStage("session dependents", new ResourceShutdownStage("session dependents",

View file

@ -12,7 +12,7 @@ namespace AcDream.App.Rendering;
/// (Ghidra 0x0059bc90, pc:424490). /// (Ghidra 0x0059bc90, pc:424490).
/// ///
/// <para><b>Wired by T1 (BR-3, `579c8b0`):</b> seal on interior roots, punch /// <para><b>Wired by T1 (BR-3, `579c8b0`):</b> seal on interior roots, punch
/// on outdoor / look-in roots, via <c>GameWindow.DrawRetailPViewPortalDepthWrite</c> /// on outdoor / look-in roots, via <c>RetailPViewPassExecutor.DrawPortalDepthWrite</c>
/// (the <c>DrawExitPortalMasks</c> slice callback) — safe alongside the /// (the <c>DrawExitPortalMasks</c> slice callback) — safe alongside the
/// dynamics-drawn-LAST frame order (the first BR-2 attempt punched after /// dynamics-drawn-LAST frame order (the first BR-2 attempt punched after
/// dynamics and erased the player; reverted 88be519). #117 (2026-06-11) /// dynamics and erased the player; reverted 88be519). #117 (2026-06-11)

View file

@ -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<uint> _outdoor = [];
private readonly HashSet<uint> _visible = [];
private readonly HashSet<uint> _dynamics = [];
public IReadOnlySet<uint> Outdoor => _outdoor;
public HashSet<uint> Visible => _visible;
public HashSet<uint> Dynamics => _dynamics;
public void BeginFrame()
{
_outdoor.Clear();
_visible.Clear();
_dynamics.Clear();
}
public void ReplaceOutdoor(IReadOnlyList<WorldEntity> owners)
{
_outdoor.Clear();
foreach (WorldEntity owner in owners)
_outdoor.Add(owner.Id);
}
}
/// <summary>
/// Concrete GL implementation of the named passes ordered by
/// <see cref="RetailPViewRenderer"/>. It owns reusable pass-local particle
/// classifications but borrows every renderer and world source.
/// The order it implements is retail <c>PView::DrawCells @ 0x005A4840</c>:
/// landscape, delayed-alpha flush, optional interior depth clear, exit masks,
/// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather
/// placement follows <c>LScape::draw @ 0x00506330</c>.
/// </summary>
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<uint> _noSceneParticleEntityIds = [];
/// <summary>
/// Borrowed until the next late landscape pass. The outdoor-root post-world
/// particle pass consumes this synchronously before another PView frame.
/// </summary>
public IReadOnlySet<uint> 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<Vector4> 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<uint> visibleCellIds) =>
_envCells.PrepareRenderBatches(
frame.ViewProjection,
frame.CameraWorldPosition,
filter: visibleCellIds,
centerLbX: frame.RenderCenterLbX,
centerLbY: frame.RenderCenterLbY,
renderRadius: frame.RenderRadius);
public void DrawOpaqueCellShells(HashSet<uint> cellIds) =>
_envCells.Render(WbRenderPass.Opaque, cellIds);
public bool CellHasTransparentShell(uint cellId) =>
_envCells.CellHasTransparent(cellId);
public void DrawTransparentCellShells(HashSet<uint> cellIds) =>
_envCells.Render(WbRenderPass.Transparent, cellIds);
public void DrawTransparentCellShellsOrdered(IReadOnlyList<uint> cellIds) =>
_envCells.RenderTransparentOrdered(cellIds);
public void DrawEntityBucket(
RetailPViewFrameInput frame,
IReadOnlyList<WorldEntity> entities,
HashSet<uint>? visibleCellIds)
{
uint landblockId = frame.PlayerLandblockId ?? 0u;
var entry = (
landblockId,
Vector3.Zero,
Vector3.Zero,
entities,
(IReadOnlyDictionary<uint, WorldEntity>?)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<WorldEntity> 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<uint, WorldEntity>?)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<uint, WorldEntity>?)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<uint> 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<WorldEntity> survivors)
{
if (_particles is null || _particleRenderer is null || survivors.Count == 0)
return;
HashSet<uint> 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<Vector3> 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);
}
}

View file

@ -1,9 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World; using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -14,10 +12,6 @@ namespace AcDream.App.Rendering;
/// </summary> /// </summary>
public sealed class RetailPViewRenderer 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 PortalVisibilityFrame _mainPortalFrameScratch = new();
private readonly ClipFrameAssembly _clipAssemblyScratch = new(); private readonly ClipFrameAssembly _clipAssemblyScratch = new();
private readonly ViewconeCuller _viewconeScratch = new(); private readonly ViewconeCuller _viewconeScratch = new();
@ -76,28 +70,20 @@ public sealed class RetailPViewRenderer
// gather); seeds themselves are unbounded. // gather); seeds themselves are unbounded.
private const float OutdoorBuildingSeedDistance = float.PositiveInfinity; private const float OutdoorBuildingSeedDistance = float.PositiveInfinity;
public RetailPViewRenderer( public RetailPViewFrameResult DrawInside(
GL gl, RetailPViewFrameInput ctx,
ClipFrame clipFrame, IRetailPViewPassExecutor passes)
EnvCellRenderer envCells,
WbDrawDispatcher entities)
{
_gl = gl;
_clipFrame = clipFrame;
_envCells = envCells;
_entities = entities;
}
public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx)
{ {
ArgumentNullException.ThrowIfNull(ctx); ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(passes);
passes.BeginFrame();
RecycleLookInFrames(); RecycleLookInFrames();
ResetBuildingGroups(); ResetBuildingGroups();
var pvFrame = PortalVisibilityBuilder.Build( var pvFrame = PortalVisibilityBuilder.Build(
ctx.RootCell, ctx.RootCell,
ctx.ViewerEyePos, ctx.ViewerEyePos,
ctx.CellLookup, ctx.Cells.Find,
ctx.ViewProjection, ctx.ViewProjection,
buildingMembership: null, buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ,
@ -131,12 +117,11 @@ public sealed class RetailPViewRenderer
&& pvFrame.OutsideView.Polygons.Count > 0) && pvFrame.OutsideView.Polygons.Count > 0)
BuildInteriorRootLookIns(ctx, pvFrame); BuildInteriorRootLookIns(ctx, pvFrame);
var clipAssembly = ClipFrameAssembler.Assemble( var clipAssembly = passes.AssembleClipFrame(
_clipFrame,
pvFrame, pvFrame,
_clipAssemblyScratch); _clipAssemblyScratch);
int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2); 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 // 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, // assembler handed a clip-slot. This feeds the Prepare filter + entity partition,
@ -146,7 +131,7 @@ public sealed class RetailPViewRenderer
_drawableCellsScratch.Clear(); _drawableCellsScratch.Clear();
_drawableCellsScratch.UnionWith(pvFrame.OrderedVisibleCells); _drawableCellsScratch.UnionWith(pvFrame.OrderedVisibleCells);
var drawableCells = _drawableCellsScratch; var drawableCells = _drawableCellsScratch;
UseIndoorMembershipOnlyRouting(); passes.UseIndoorMembershipOnlyRouting();
// #124: look-in cells need prepared shell batches + their statics routed // #124: look-in cells need prepared shell batches + their statics routed
// into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main // 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 // the RESIDENT-cell registry, not the frame flood — and is deleted. The pool
// is built once per frame in GameWindow, player-anchored.) // is built once per frame in GameWindow, player-anchored.)
_envCells.PrepareRenderBatches( passes.PrepareCellBatches(ctx, prepareCells);
ctx.ViewProjection,
ctx.CameraWorldPosition,
filter: prepareCells,
centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries); InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries);
var partition = _partitionResult; var partition = _partitionResult;
@ -185,7 +164,7 @@ public sealed class RetailPViewRenderer
drawableCells, drawableCells,
partition); partition);
ctx.EmitDiagnostics?.Invoke(result); passes.EmitDiagnostics(ctx, result);
// T1 (fused BR-2/3): retail's frame order — static world, then the // T1 (fused BR-2/3): retail's frame order — static world, then the
// aperture depth writes, then interior cells WHOLE far→near, then // aperture depth writes, then interior cells WHOLE far→near, then
@ -226,17 +205,17 @@ public sealed class RetailPViewRenderer
foreach (var e in partition.Dynamics) foreach (var e in partition.Dynamics)
{ {
EntitySphere(e, out var c, out float r); 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); _outsideStageDynamics.Add(e);
} }
} }
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone); DrawLandscapeThroughOutsideView(ctx, passes, clipAssembly, partition, viewcone);
UseIndoorMembershipOnlyRouting(); passes.UseIndoorMembershipOnlyRouting();
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(pvFrame); DrawEnvCellShells(passes, pvFrame);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone); DrawCellObjectLists(ctx, passes, pvFrame, clipAssembly, drawableCells, partition, viewcone);
DrawDynamicsLast(ctx, partition, viewcone, ctx.RootCell.IsOutdoorNode); DrawDynamicsLast(ctx, passes, partition, viewcone, ctx.RootCell.IsOutdoorNode);
return result; 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 // 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 // (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. // 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!); RebuildBuildingGroups(ctx.NearbyBuildingCells!);
@ -255,7 +234,7 @@ public sealed class RetailPViewRenderer
var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding( var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding(
group, group,
ctx.ViewerEyePos, ctx.ViewerEyePos,
ctx.CellLookup, ctx.Cells.Find,
ctx.ViewProjection, ctx.ViewProjection,
OutdoorBuildingSeedDistance, OutdoorBuildingSeedDistance,
reuseFrame: _outdoorBuildingFrameScratch); reuseFrame: _outdoorBuildingFrameScratch);
@ -304,7 +283,7 @@ public sealed class RetailPViewRenderer
// doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip // doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip
// 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own // 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own
// building self-excludes via the seed eye-side test. // 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!); RebuildBuildingGroups(ctx.NearbyBuildingCells!);
@ -316,7 +295,7 @@ public sealed class RetailPViewRenderer
? _lookInFramePool.Pop() ? _lookInFramePool.Pop()
: new PortalVisibilityFrame(); : new PortalVisibilityFrame();
var frame = PortalVisibilityBuilder.ConstructViewBuilding( var frame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, group, ctx.ViewerEyePos, ctx.Cells.Find, ctx.ViewProjection,
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons, OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons,
reuseFrame: frameScratch); reuseFrame: frameScratch);
if (frame.OrderedVisibleCells.Count > 0) 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 // here is color-safe; statics draw whole (the main viewcone has no entry
// for look-in cells; over-include is the safe direction). // for look-in cells; over-include is the safe direction).
private void DrawBuildingLookIns( private void DrawBuildingLookIns(
RetailPViewDrawContext ctx, RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly, ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition) InteriorEntityPartition.Result partition)
{ {
@ -375,30 +355,27 @@ public sealed class RetailPViewRenderer
foreach (var frame in _lookInFrames) foreach (var frame in _lookInFrames)
{ {
// Pass 1: far-Z punch every aperture of this building. // 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; continue;
foreach (var poly in view.Polygons) passes.DrawLookInPortalPunch(ctx, new RetailPViewCellSliceContext(
{ cellId,
var cps = ClipPlaneSet.From(poly); new ClipViewSlice(
if (cps.IsNothingVisible) 0,
continue; new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY),
ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext( cps.PlaneArray),
cellId, Array.Empty<WorldEntity>()));
new ClipViewSlice(
0,
new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY),
cps.PlaneArray),
Array.Empty<WorldEntity>()));
}
} }
} }
// Pass 2: shells + statics, far→near. // Pass 2: shells + statics, far→near.
UseIndoorMembershipOnlyRouting(); passes.UseIndoorMembershipOnlyRouting();
// Opaque shells batched per building into ONE Render (this building's // Opaque shells batched per building into ONE Render (this building's
// aperture punches above already ran; z-buffer handles order and // aperture punches above already ran; z-buffer handles order and
@ -408,7 +385,7 @@ public sealed class RetailPViewRenderer
foreach (uint cid in frame.OrderedVisibleCells) foreach (uint cid in frame.OrderedVisibleCells)
_shellBatch.Add(cid); _shellBatch.Add(cid);
if (_shellBatch.Count > 0) if (_shellBatch.Count > 0)
_envCells.Render(WbRenderPass.Opaque, _shellBatch); passes.DrawOpaqueCellShells(_shellBatch);
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{ {
@ -417,8 +394,8 @@ public sealed class RetailPViewRenderer
_oneCell.Add(cellId); _oneCell.Add(cellId);
// Opaque shell batched above. Transparent stays per-cell (far→near) // Opaque shell batched above. Transparent stays per-cell (far→near)
// for correct compositing; skipped for opaque-only cells. // for correct compositing; skipped for opaque-only cells.
if (_envCells.CellHasTransparent(cellId)) if (passes.CellHasTransparentShell(cellId))
_envCells.Render(WbRenderPass.Transparent, _oneCell); passes.DrawTransparentCellShells(_oneCell);
_cellStaticScratch.Clear(); _cellStaticScratch.Clear();
if (partition.ByCell.TryGetValue(cellId, out var bucket)) if (partition.ByCell.TryGetValue(cellId, out var bucket))
@ -443,12 +420,12 @@ public sealed class RetailPViewRenderer
if (_cellStaticScratch.Count > 0) if (_cellStaticScratch.Count > 0)
{ {
DrawEntityBucket(ctx, _cellStaticScratch, _oneCell); passes.DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
// The cell-particles pass for look-in cells — retail's // The cell-particles pass for look-in cells — retail's
// nested DrawCells draws objects WITH their emitters. // nested DrawCells draws objects WITH their emitters.
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext( passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
cellId, slice, _cellStaticScratch)); cellId, slice, _cellStaticScratch));
} }
} }
@ -456,7 +433,8 @@ public sealed class RetailPViewRenderer
} }
private void DrawLandscapeThroughOutsideView( private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx, RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly, ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition, InteriorEntityPartition.Result partition,
ViewconeCuller viewcone) ViewconeCuller viewcone)
@ -479,16 +457,15 @@ public sealed class RetailPViewRenderer
int probeSliceIndex = 0; int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices) foreach (var slice in clipAssembly.OutsideViewSlices)
{ {
_clipFrame.SetTerrainClip(slice.Planes); passes.SetTerrainClip(slice.Planes);
UploadTerrainClip(ctx.SetTerrainClipUbo);
// T3 (BR-5): entities are never hard-clipped — retail viewcone- // T3 (BR-5): entities are never hard-clipped — retail viewcone-
// CHECKS each mesh's sphere against the view (Ghidra 0x0054c250) // CHECKS each mesh's sphere against the view (Ghidra 0x0054c250)
// and draws it whole. The old per-slice entity clip routing // and draws it whole. The old per-slice entity clip routing
// (gl_ClipDistance via SetClipRouting) is replaced by the sphere // (gl_ClipDistance via SetClipRouting) is replaced by the sphere
// pre-filter below; terrain/sky keep their per-slice plane clip. // pre-filter below; terrain/sky keep their per-slice plane clip.
_entities.ClearClipRouting(); passes.ClearClipRouting();
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled) if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled)
EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex); passes.EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex);
_outdoorStaticScratch.Clear(); _outdoorStaticScratch.Clear();
foreach (var e in partition.OutdoorStatic) foreach (var e in partition.OutdoorStatic)
@ -498,14 +475,14 @@ public sealed class RetailPViewRenderer
_outdoorStaticScratch.Add(e); _outdoorStaticScratch.Add(e);
} }
probeSliceIndex++; 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 // #124: far-building look-ins draw HERE — still inside the landscape
// stage (their punches mark against the terrain/exterior depth just // stage (their punches mark against the terrain/exterior depth just
// drawn), strictly BEFORE the depth clear + seals below, matching // drawn), strictly BEFORE the depth clear + seals below, matching
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785). // 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 // LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
// pre-clear so the seal protects their aperture pixels; AFTER the // pre-clear so the seal protects their aperture pixels; AFTER the
@ -515,9 +492,8 @@ public sealed class RetailPViewRenderer
probeSliceIndex = 0; probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices) foreach (var slice in clipAssembly.OutsideViewSlices)
{ {
_clipFrame.SetTerrainClip(slice.Planes); passes.SetTerrainClip(slice.Planes);
UploadTerrainClip(ctx.SetTerrainClipUbo); passes.ClearClipRouting();
_entities.ClearClipRouting();
_outdoorStaticScratch.Clear(); // late: dynamics survivors _outdoorStaticScratch.Clear(); // late: dynamics survivors
_lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors _lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors
@ -530,14 +506,12 @@ public sealed class RetailPViewRenderer
// #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids // #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids
// double as an ENTITY-id watchlist here — one line per watched // double as an ENTITY-id watchlist here — one line per watched
// outdoor-static owner per CHANGE of its cone verdict. // outdoor-static owner per CHANGE of its cone verdict.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled passes.EmitOutStageOwner(
&& AcDream.Core.Rendering.RenderingDiagnostics.DumpEntitySourceIds.Contains(e.Id) e,
&& (!_outStageOwnerVerdicts.TryGetValue(e.Id, out bool prev) || prev != ownerPass)) c,
{ r,
_outStageOwnerVerdicts[e.Id] = ownerPass; probeSliceIndex,
Console.WriteLine(System.FormattableString.Invariant( ownerPass);
$"[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")}"));
}
} }
foreach (var e in _outsideStageDynamics) foreach (var e in _outsideStageDynamics)
{ {
@ -548,10 +522,12 @@ public sealed class RetailPViewRenderer
_lateParticleOwnerScratch.Add(e); _lateParticleOwnerScratch.Add(e);
} }
} }
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled) passes.EmitOutStageRouting(
EmitOutStageProbe(probeSliceIndex, viewcone); probeSliceIndex,
_outsideStageDynamics,
viewcone);
probeSliceIndex++; probeSliceIndex++;
ctx.DrawLandscapeSliceLate?.Invoke(new RetailPViewLandscapeLateSliceContext( passes.DrawLandscapeSliceLate(ctx, new RetailPViewLandscapeLateSliceContext(
slice, _outdoorStaticScratch, _lateParticleOwnerScratch)); slice, _outdoorStaticScratch, _lateParticleOwnerScratch));
} }
@ -563,12 +539,12 @@ public sealed class RetailPViewRenderer
// must not double-draw, the #121 lesson), at the END of the landscape // 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. // stage: after the clear they would z-fail against the doorway seal.
if (!ctx.RootCell.IsOutdoorNode) if (!ctx.RootCell.IsOutdoorNode)
ctx.DrawUnattachedSceneParticles?.Invoke(); passes.DrawUnattachedSceneParticles(ctx);
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list // Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
// immediately after LScape::draw and before the optional depth clear. // immediately after LScape::draw and before the optional depth clear.
// The queue remains active for the post-clear/final-world scope. // 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 // T1: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 — // 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, // every outside-leading portal's TRUE depth (the seals,
// DrawExitPortalMasks). Replaces the old per-slice scissored AABB // DrawExitPortalMasks). Replaces the old per-slice scissored AABB
// clear (wrong shape, no seal after it). // clear (wrong shape, no seal after it).
if (clipAssembly.OutsideViewSlices.Length > 0) if (clipAssembly.OutsideViewSlices.Length > 0 && !ctx.RootCell.IsOutdoorNode)
ctx.ClearDepthForInterior?.Invoke(); passes.ClearInteriorDepth();
UseIndoorMembershipOnlyRouting(); passes.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<uint, bool> _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<uint> _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}");
} }
private void DrawExitPortalMasks( private void DrawExitPortalMasks(
IRetailPViewCellDrawCallbacks ctx, RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
PortalVisibilityFrame pvFrame, PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly, ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells) HashSet<uint> drawableCells)
{ {
if (ctx.DrawExitPortalMasks is null)
return;
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{ {
uint cellId = pvFrame.OrderedVisibleCells[i]; uint cellId = pvFrame.OrderedVisibleCells[i];
@ -711,11 +573,15 @@ public sealed class RetailPViewRenderer
continue; continue;
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawExitPortalMasks(new RetailPViewCellSliceContext(cellId, slice, Array.Empty<WorldEntity>())); passes.DrawExitPortalMask(
ctx,
new RetailPViewCellSliceContext(cellId, slice, Array.Empty<WorldEntity>()));
} }
} }
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 // T1 (fused BR-2/3): retail DrawCells Loop 2 — every visible cell's
// shell drawn WHOLE, reverse cell_draw_list (far→near), drawn once. // 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. // (927fd8f/9ce335e, #114) is deleted with this rewrite.
// Per-cell opaque+transparent keeps the far→near transparent // Per-cell opaque+transparent keeps the far→near transparent
// compositing the per-cell loop already provided. // compositing the per-cell loop already provided.
UseIndoorMembershipOnlyRouting(); passes.UseIndoorMembershipOnlyRouting();
// Opaque: ONE batched Render for all shell cells (was one heavy per-frame // 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 // 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)) foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
_shellBatch.Add(entry.CellId); _shellBatch.Add(entry.CellId);
if (_shellBatch.Count > 0) if (_shellBatch.Count > 0)
_envCells.Render(WbRenderPass.Opaque, _shellBatch); passes.DrawOpaqueCellShells(_shellBatch);
// Transparent: far-to-near order matters for compositing. The ordered // Transparent: far-to-near order matters for compositing. The ordered
// list retains ShellPass cell boundaries while EnvCellRenderer shares // list retains ShellPass cell boundaries while EnvCellRenderer shares
@ -747,11 +613,11 @@ public sealed class RetailPViewRenderer
_orderedTransparentShellCells.Clear(); _orderedTransparentShellCells.Clear();
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{ {
if (_envCells.CellHasTransparent(entry.CellId)) if (passes.CellHasTransparentShell(entry.CellId))
_orderedTransparentShellCells.Add(entry.CellId); _orderedTransparentShellCells.Add(entry.CellId);
} }
if (_orderedTransparentShellCells.Count > 0) if (_orderedTransparentShellCells.Count > 0)
_envCells.RenderTransparentOrdered(_orderedTransparentShellCells); passes.DrawTransparentCellShellsOrdered(_orderedTransparentShellCells);
} }
// T1: the frame's single LAST entity pass — ALL server-spawned dynamics // 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 // the draw list; the partition keeps routing it so the CULL (not the
// visibility set) drops it, exactly retail's shape. // visibility set) drops it, exactly retail's shape.
private void DrawDynamicsLast( private void DrawDynamicsLast(
IRetailPViewCellDrawContext ctx, RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
InteriorEntityPartition.Result partition, InteriorEntityPartition.Result partition,
ViewconeCuller viewcone, ViewconeCuller viewcone,
bool rootIsOutdoor) bool rootIsOutdoor)
@ -811,8 +678,8 @@ public sealed class RetailPViewRenderer
if (_dynamicsScratch.Count == 0) if (_dynamicsScratch.Count == 0)
return; return;
UseIndoorMembershipOnlyRouting(); passes.UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null); passes.DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null);
// #121: dynamics' attached emitters (portal swirls, creature effects) // #121: dynamics' attached emitters (portal swirls, creature effects)
// gate through the SAME cone-surviving owner set as their meshes — // 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: // portals went invisible. Outside-stage dynamics are excluded here:
// their emitters already drew in the landscape slice (alpha-blended // their emitters already drew in the landscape slice (alpha-blended
// particles must not double-draw, unlike the depth-idempotent meshes). // particles must not double-draw, unlike the depth-idempotent meshes).
if (ctx.DrawDynamicsParticles is not null) _dynamicsParticleScratch.Clear();
{ foreach (var e in _dynamicsScratch)
_dynamicsParticleScratch.Clear(); if (!_outsideStageDynamics.Contains(e))
foreach (var e in _dynamicsScratch) _dynamicsParticleScratch.Add(e);
if (!_outsideStageDynamics.Contains(e)) if (_dynamicsParticleScratch.Count > 0)
_dynamicsParticleScratch.Add(e); passes.DrawDynamicsParticles(ctx, _dynamicsParticleScratch);
if (_dynamicsParticleScratch.Count > 0)
ctx.DrawDynamicsParticles(_dynamicsParticleScratch);
}
} }
private void DrawCellObjectLists( private void DrawCellObjectLists(
IRetailPViewCellDrawContext ctx, RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
PortalVisibilityFrame pvFrame, PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly, ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells, HashSet<uint> drawableCells,
@ -889,8 +754,7 @@ public sealed class RetailPViewRenderer
_cellObjCells.Add(cellId); _cellObjCells.Add(cellId);
// BR-2 phantom-site probe (T3-updated): post-viewcone survivors. // BR-2 phantom-site probe (T3-updated): post-viewcone survivors.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled) passes.EmitPhantomObjects(cellId, survivors);
EmitPhantomObjsProbe(cellId, survivors);
} }
// ONE batched static-object draw for every visible cell (was N per-cell // 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). // cull; only the draw is batched).
if (_allCellStatics.Count > 0) if (_allCellStatics.Count > 0)
{ {
UseIndoorMembershipOnlyRouting(); passes.UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, _allCellStatics, _cellObjCells); passes.DrawEntityBucket(ctx, _allCellStatics, _cellObjCells);
} }
// Cell-particle pass — consolidated across ALL visible cells into ONE // Cell-particle pass — consolidated across ALL visible cells into ONE
// draw. Was per-cell, and each call re-walked the ENTIRE live particle set // 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 // 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 // sink (~5 ms at Arwic). Static owners are disjoint per cell, so the UNION
// (= _allCellStatics, already accumulated above for the batched draw) draws // (= _allCellStatics, already accumulated above for the batched draw) draws
// EXACTLY the same emitters: the callback gates on owner id (the cone- // EXACTLY the same emitters: the callback gates on owner id (the cone-
// surviving set), the renderer sorts globally back-to-front, and the per- // 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 // 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 batched static draw so emitters depth-test against the statics now in
// the buffer (the statics-before-particles order). cellId/slice are unused // the buffer (the statics-before-particles order). cellId/slice are unused
// by the particle pass — pass NoClipSlice + the union owner list. This also // by the particle pass — pass NoClipSlice + the union owner list. This also
// drops the per-cell BuildDrawList allocations (N → 1). // drops the per-cell BuildDrawList allocations (N → 1).
if (_allCellStatics.Count > 0) if (_allCellStatics.Count > 0)
ctx.DrawCellParticles?.Invoke( passes.DrawCellParticles(
ctx,
new RetailPViewCellSliceContext(0u, NoClipSlice, _allCellStatics)); new RetailPViewCellSliceContext(0u, NoClipSlice, _allCellStatics));
} }
@ -960,7 +825,7 @@ public sealed class RetailPViewRenderer
Vector3 sphereCenter, Vector3 sphereCenter,
float sphereRadius, float sphereRadius,
HashSet<uint> drawableCells, HashSet<uint> drawableCells,
Func<uint, LoadedCell?> cellLookup) IRetailPViewCellSource cells)
{ {
if (!InteriorEntityPartition.IsIndoorCellId(parentCellId)) if (!InteriorEntityPartition.IsIndoorCellId(parentCellId))
return true; return true;
@ -968,7 +833,7 @@ public sealed class RetailPViewRenderer
uint cellId = parentCellId!.Value; uint cellId = parentCellId!.Value;
if (!drawableCells.Contains(cellId)) if (!drawableCells.Contains(cellId))
return false; // not in the flood — the last-pass cone cull owns it 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) if (cell is null)
return false; return false;
@ -998,20 +863,6 @@ public sealed class RetailPViewRenderer
radius = (e.AabbMax - e.AabbMin).Length() * 0.5f; 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<uint, int> _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( private static ClipViewSlice[] GetCellSlicesOrNoClip(
ClipFrameAssembly clipAssembly, ClipFrameAssembly clipAssembly,
uint cellId) uint cellId)
@ -1023,80 +874,65 @@ public sealed class RetailPViewRenderer
return NoClipSlices; 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<WorldEntity> bucket,
HashSet<uint>? visibleCellIds)
{
uint lbId = ctx.PlayerLandblockId ?? 0u;
var entry = (lbId, Vector3.Zero, Vector3.Zero,
(IReadOnlyList<WorldEntity>)bucket,
(IReadOnlyDictionary<uint, WorldEntity>?)null);
_entities.Draw(
ctx.Camera,
new[] { entry },
ctx.Frustum,
neverCullLandblockId: ctx.PlayerLandblockId,
visibleCellIds: visibleCellIds,
animatedEntityIds: ctx.AnimatedEntityIds);
}
private void PrepareClipFrame(
Action<TerrainClipBufferBinding> 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<TerrainClipBufferBinding> setTerrainClipUbo)
{
TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl);
setTerrainClipUbo(binding);
}
} }
public interface IRetailPViewCellDrawCallbacks public interface IRetailPViewCellSource
{ {
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; } LoadedCell? Find(uint cellId);
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; }
/// <summary>#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).</summary>
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; }
} }
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks /// <summary>
/// Typed execution seam for the GL passes ordered by
/// <see cref="RetailPViewRenderer"/>. 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.
/// </summary>
public interface IRetailPViewPassExecutor
{ {
public ICamera Camera { get; } void BeginFrame();
public FrustumPlanes? Frustum { get; } ClipFrameAssembly AssembleClipFrame(
public uint? PlayerLandblockId { get; } PortalVisibilityFrame portalFrame,
public HashSet<uint>? AnimatedEntityIds { get; } ClipFrameAssembly reuseAssembly);
void PrepareClipFrame(int terrainUploadCount);
/// <summary>#121: draw the Scene-pass emitters attached to the frame's void SetTerrainClip(ReadOnlySpan<Vector4> planes);
/// cone-surviving dynamics (portal swirls, creature effects). Invoked once void ClearClipRouting();
/// per frame after the last entity pass with the survivor list.</summary> void UseIndoorMembershipOnlyRouting();
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; } void PrepareCellBatches(
RetailPViewFrameInput frame,
HashSet<uint> visibleCellIds);
void DrawOpaqueCellShells(HashSet<uint> cellIds);
bool CellHasTransparentShell(uint cellId);
void DrawTransparentCellShells(HashSet<uint> cellIds);
void DrawTransparentCellShellsOrdered(IReadOnlyList<uint> cellIds);
void DrawEntityBucket(
RetailPViewFrameInput frame,
IReadOnlyList<WorldEntity> entities,
HashSet<uint>? 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<WorldEntity> 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<WorldEntity> survivors);
void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result);
} }
/// <summary> /// <summary>
@ -1261,7 +1097,7 @@ internal sealed class BuildingGroupScratch
} }
} }
public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext public sealed class RetailPViewFrameInput
{ {
public required LoadedCell RootCell { get; init; } public required LoadedCell RootCell { get; init; }
@ -1271,7 +1107,7 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public required Vector3 ViewerEyePos { get; init; } public required Vector3 ViewerEyePos { get; init; }
public required Matrix4x4 ViewProjection { get; init; } public required Matrix4x4 ViewProjection { get; init; }
public required Func<uint, LoadedCell?> CellLookup { get; init; } public required IRetailPViewCellSource Cells { get; init; }
public required ICamera Camera { get; init; } public required ICamera Camera { get; init; }
public required Vector3 CameraWorldPosition { get; init; } public required Vector3 CameraWorldPosition { get; init; }
public required FrustumPlanes? Frustum { 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, public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities, IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; } IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<TerrainClipBufferBinding> SetTerrainClipUbo { get; init; }
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
/// <summary>#131/#132: the LATE landscape phase, per slice, after the #124 // Pass-presentation and diagnostic values consumed synchronously by the
/// look-ins — outside-stage dynamics' meshes + all scene particles + // typed executor. This input is data-only and is never retained.
/// weather (the FlushAlphaList deferral; see DrawLandscapeThroughOutsideView).</summary> public required bool RenderSky { get; init; }
public Action<RetailPViewLandscapeLateSliceContext>? DrawLandscapeSliceLate { get; init; } public required bool RenderWeather { get; init; }
/// <summary>T1: one full-buffer depth clear between the outside stage and the public required float DayFraction { get; init; }
/// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor public required DayGroupData? ActiveDayGroup { get; init; }
/// roots — outdoors the interiors must depth-test against terrain + exteriors and public required SkyKeyframe SkyKeyframe { get; init; }
/// appear only through punched apertures.</summary> public required bool EnvironOverrideActive { get; init; }
public Action? ClearDepthForInterior { get; init; } public required uint ViewerCellId { get; init; }
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; } public required uint PlayerCellId { get; init; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; } public required Vector3 PlayerViewPosition { get; init; }
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; init; } public required Matrix4x4 CameraView { get; init; }
public required CameraCellResolution CameraCellResolution { get; init; }
/// <summary>#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.</summary>
public Action? DrawUnattachedSceneParticles { get; init; }
/// <summary>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.</summary>
public Action? FlushLandscapeAlpha { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
/// <summary>
/// Synchronous observation of the borrowed current-frame result. The
/// callback must copy anything it needs to retain beyond this invocation.
/// </summary>
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
} }
/// <summary> /// <summary>

View file

@ -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();
}
/// <summary>
/// Samples the existing canonical runtime owners only when the five-second
/// frame diagnostic cadence publishes. It owns no world or render resource.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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}";
}
}

View file

@ -2,6 +2,7 @@ using System.Numerics;
using System.Diagnostics; using System.Diagnostics;
using System.Text; using System.Text;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
using AcDream.Core.World;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -123,6 +124,12 @@ internal sealed class WorldRenderDiagnostics
private string? _lastScissorSignature; private string? _lastScissorSignature;
private long _scissorSequence; private long _scissorSequence;
private string? _lastOutStageSignature; private string? _lastOutStageSignature;
private string? _lastOutStageRoutingSignature;
private readonly Dictionary<uint, bool> _outStageOwnerVerdicts = [];
private readonly Dictionary<uint, int> _phantomObjectSignatures = [];
private string? _lastClipRouteSignature;
private long _clipRouteSequence;
private readonly List<uint> _clipRouteCellKeys = [];
public WorldRenderDiagnostics( public WorldRenderDiagnostics(
IRenderGlStateReader gl, IRenderGlStateReader gl,
@ -192,6 +199,177 @@ internal sealed class WorldRenderDiagnostics
_log.WriteLine($"[clip-route-scis] n={_scissorSequence} {signature}"); _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<byte> 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<byte> 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<uint> 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<WorldEntity> 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<uint> targetCells,
uint cellId,
int portalIndex,
bool forceFarZ,
ReadOnlySpan<Vector3> 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( public void EmitPViewInput(
bool enabled, bool enabled,
PortalVisibilityFrame portalFrame, PortalVisibilityFrame portalFrame,
@ -590,4 +768,15 @@ internal sealed class WorldRenderDiagnostics
.Append(" live=").Append(partition.Dynamics.Count) .Append(" live=").Append(partition.Dynamics.Count)
.ToString(); .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;
}
} }

View file

@ -192,7 +192,7 @@ public static class RenderingDiagnostics
/// slot, and the terrain-UBO head as uploaded; WbDrawDispatcher.Draw emits one /// slot, and the terrain-UBO head as uploaded; WbDrawDispatcher.Draw emits one
/// <c>[clip-route-disp]</c> line (print-on-change, routed draws only) with the per-slot /// <c>[clip-route-disp]</c> 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 /// instance histogram exactly as uploaded to binding=3 plus the culled-entity count; and
/// GameWindow.DrawRetailPViewLandscapeSlice emits one <c>[clip-route-scis]</c> line /// RetailPViewPassExecutor.DrawLandscapeSlice emits one <c>[clip-route-scis]</c> line
/// (print-on-change) with the ACTUAL GL scissor enable + box the landscape pass draws under. /// (print-on-change) with the ACTUAL GL scissor enable + box the landscape pass draws under.
/// Throwaway apparatus — strip once §4 ships. Initial state from <c>ACDREAM_PROBE_CLIPROUTE=1</c>. /// Throwaway apparatus — strip once §4 ships. Initial state from <c>ACDREAM_PROBE_CLIPROUTE=1</c>.
/// </summary> /// </summary>
@ -325,7 +325,7 @@ public static class RenderingDiagnostics
/// parented to a target cell — position, culled/slot, resolved light set. A /// 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; /// floor-coincident entity (plate/static) would be the z-fight's second draw;
/// the player entity doubles as the probe's positive control.</description></item> /// the player entity doubles as the probe's positive control.</description></item>
/// <item><description><c>[seam-mask]</c> — <c>GameWindow.DrawRetailPViewPortalDepthWrite</c>: /// <item><description><c>[seam-mask]</c> — <c>RetailPViewPassExecutor.DrawPortalDepthWrite</c>:
/// every portal depth fan drawn in a target cell. A sealed dungeon must show /// 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.</description></item> /// ZERO (seals fire only for OtherCellId==0xFFFF) — any line is a finding.</description></item>
/// </list> /// </list>

View file

@ -106,6 +106,14 @@ public sealed class GameWindowRenderLeafCompositionTests
Assert.Contains("_renderFrameResources = null;", source); Assert.Contains("_renderFrameResources = null;", source);
Assert.Contains("_renderFrameLivePreparation = null;", source); Assert.Contains("_renderFrameLivePreparation = null;", source);
Assert.Contains("_renderWeatherFrame = 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( AssertAppearsInOrder(
source, source,
"new(\"frame pacing\", _displayFramePacing.Dispose)", "new(\"frame pacing\", _displayFramePacing.Dispose)",
@ -143,15 +151,15 @@ public sealed class GameWindowRenderLeafCompositionTests
} }
[Fact] [Fact]
public void TerrainAndFrameDiagnostics_CommitOneSharedCadenceAfterBothWrites() public void TerrainAndFrameDiagnostics_AreComposedAsOneFocusedOwner()
{ {
string source = GameWindowSource(); string source = GameWindowSource();
AssertAppearsInOrder( Assert.Contains(
source, "new AcDream.App.Rendering.TerrainDrawDiagnosticsController(",
"_worldRenderDiagnostics?.PublishTerrainDiagnostics(", source);
"PublishFrameDiagnostics();", Assert.Contains("_terrainDrawDiagnostics!.Begin();", source);
"_frameDiagLastPublicationMilliseconds = now;"); Assert.Contains("_terrainDrawDiagnostics.Complete();", source);
} }
private static string GameWindowSource() => File.ReadAllText(Path.Combine( private static string GameWindowSource() => File.ReadAllText(Path.Combine(

View file

@ -22,7 +22,7 @@ namespace AcDream.App.Tests.Rendering;
/// <see cref="ViewconeCuller.Build"/> → the exact DrawDynamicsLast visibility predicate /// <see cref="ViewconeCuller.Build"/> → the exact DrawDynamicsLast visibility predicate
/// (RetailPViewRenderer.cs:375-401), PLUS the depth relationship DrawDynamicsLast is /// (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 /// 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. /// depth clear, and dynamics draw depth-tested AFTER it.
/// ///
/// The four candidates this pins (handoff §5 + this session's read): /// The four candidates this pins (handoff §5 + this session's read):
@ -261,7 +261,11 @@ public class HouseExitWalkReplayTests
var drawable = new HashSet<uint>(pv.OrderedVisibleCells); var drawable = new HashSet<uint>(pv.OrderedVisibleCells);
bool outsideStage = rootCellId != 0u bool outsideStage = rootCellId != 0u
&& RetailPViewRenderer.DynamicDrawsInOutsideStage( && 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 // Candidate 4: the seal depth check. Applies when the root is INTERIOR
// (ClearDepthForInterior + the TRUE-depth seal run, GameWindow:7719-7729), // (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}")); $"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<uint, LoadedCell?> find) :
IRetailPViewCellSource
{
public LoadedCell? Find(uint cellId) => find(cellId);
}
} }

View file

@ -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<WorldEntity>? entities = null,
IReadOnlyList<LoadedCell>? nearbyBuildingCells = null,
IReadOnlyList<LoadedCell>? additionalCells = null)
{
var cells = new Dictionary<uint, LoadedCell> { [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<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? 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<uint, LoadedCell> 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<string> 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<Vector4> 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<uint> visibleCellIds) =>
Operations.Add("prepare-cells");
public void DrawOpaqueCellShells(HashSet<uint> cellIds) => Operations.Add("opaque-shells");
public bool CellHasTransparentShell(uint cellId) => HasTransparentShells;
public void DrawTransparentCellShells(HashSet<uint> cellIds) => Operations.Add("transparent-shells");
public void DrawTransparentCellShellsOrdered(IReadOnlyList<uint> cellIds) =>
Operations.Add("transparent-shells-ordered");
public void DrawEntityBucket(
RetailPViewFrameInput frame,
IReadOnlyList<WorldEntity> entities,
HashSet<uint>? 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<WorldEntity> 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<WorldEntity> survivors) => Operations.Add("dynamics-particles");
public void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result) => Operations.Add("diagnostics");
public void Dispose() => _clipFrame.Dispose();
}
}

View file

@ -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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<string> Messages { get; } = [];
public void WriteLine(string message) => Messages.Add(message);
}
private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog
{
private bool _throw = true;
public List<string> 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<string> 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);
}
}
}