refactor(render): extract typed retail pview passes
This commit is contained in:
parent
6d6e5b5fa5
commit
85239fb373
13 changed files with 1888 additions and 841 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
576
src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
Normal file
576
src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
148
src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs
Normal file
148
src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue