diff --git a/docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md b/docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md new file mode 100644 index 00000000..9b5c16ad --- /dev/null +++ b/docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md @@ -0,0 +1,135 @@ +# Retail PView Indoor Render Pseudocode (2013 EoR) + +This note pins the indoor render port to the named retail decomp. The goal is +behavioral fidelity: modern GL renderers may supply the draw calls, but the +frame ownership, visibility graph, and draw order follow these functions. + +## SmartBox::RenderNormalMode @ 0x00453aa0 + +```text +if render device has open scene: + outside = SmartBox::is_player_outside(player position) + seenOutside = outside || viewer_cell.seen_outside + set FOV/view distance + + if !outside: + if seenOutside: + LScape::update_viewpoint(lscape, Position::get_outside_cell_id(viewer)) + Render::update_viewpoint(viewer) + RenderDeviceD3D::DrawInside(viewer_cell) + else: + LScape::update_viewpoint(lscape, viewer.objcell_id) + Render::update_viewpoint(viewer) + Render::set_default_view() + Render::useSunlightSet(1) + LScape::draw(lscape) + +FlushAlphaList() +run targeting/render callbacks +``` + +Important split: the top-level branch follows `is_player_outside`, while indoor +render calls `DrawInside(viewer_cell)`. + +## RenderDeviceD3D::DrawInside @ 0x0059f0d0 + +```text +PView::DrawInside(RenderDeviceD3D::indoor_pview, viewer_cell) +``` + +This is a thin forwarder. The PView owns the indoor frame. + +## PView::DrawInside @ 0x005a5860 + +```text +reset object scale +CEnvCell::curr_view_push(root_cell) +PView::add_views(root_cell.num_stabs, root_cell.stab_list) +Frame::cache() +Render::positionPush(root identity frame) +Render::copy_view(root_cell.portal_view[last], null, 4) # full-screen root view +forceClear = PView::ConstructView(root_cell, 0xffff) +PView::DrawCells(forceClear) +Render::framePop() +PView::remove_views(root_cell.num_stabs, root_cell.stab_list) +root_cell.num_view-- +``` + +## PView::ConstructView(CEnvCell*) @ 0x005a57b0 + +```text +clear outside_view and cell draw/todo state +insert root cell into distance-priority todo list + +while todo is not empty: + cell = pop nearest + append cell to cell_draw_list + InitCell(cell, otherPortalId) + project/clip each portal against the current cell view + exit portals append clipped polygons to outside_view + interior portals append clipped polygons to neighbor portal_view + newly discovered neighbors enter the todo list once + +return forceClear flag +``` + +`cell_draw_list` is the only indoor membership source. Later growth can add view +polygons to a discovered cell, but does not create a second draw-list entry. + +## PView::DrawCells @ 0x005a4840 + +```text +if outside_view.view_count > 0: + Render::useSunlightSet(1) + Render::PortalList = this + LScape::draw(lscape) # landscape clipped by outside_view + D3DPolyRender::FlushAlphaList(0) + render_device.frameStamp++ + if forceClear || portalsDrawnCount != 0: + render_device.Clear(DepthOnly) + + # Loop 1: exit portal masks, reverse cell_draw_list + for cell in reverse(cell_draw_list): + if cell.structure.drawing_bsp: + push cell frame and surfaces + for each current portal_view slice: + CEnvCell::setup_view(cell, slice) + for each exit portal: + DrawPortalPolyInternal(portal polygon) + pop frame + +Render::useSunlightSet(0) +Render::restore_all_lighting() + +# Loop 2: closed cell shells, reverse cell_draw_list +for cell in reverse(cell_draw_list): + if cell.structure.drawing_bsp: + push cell frame and surfaces + for each current portal_view slice: + CEnvCell::setup_view(cell, slice) + DrawEnvCell(cell) + pop frame + +# Loop 3: cell object lists, reverse cell_draw_list +for cell in reverse(cell_draw_list): + Render::PortalList = cell.portal_view[last] + DrawObjCellForDummies(cell) + +restore object scale +Render::useSunlightSet(1) +``` + +There is no global indoor object, terrain, sky, weather, or particle pass. Every +visible indoor object comes from the cell draw list, and the landscape appears +only through `outside_view`. + +## RenderDeviceD3D::DrawObjCellForDummies @ 0x005a0760 + +```text +for object in cell.object_list: + draw object under Render::PortalList + attached effects/particles follow the owning object visibility +``` + +acdream maps this to per-cell `WorldEntity.ParentCellId` buckets. Parentless +live objects must not bypass the indoor PView graph. diff --git a/docs/research/2026-06-06-indoor-render-hang-rootcause.md b/docs/research/2026-06-06-indoor-render-hang-rootcause.md new file mode 100644 index 00000000..03f72db5 --- /dev/null +++ b/docs/research/2026-06-06-indoor-render-hang-rootcause.md @@ -0,0 +1,163 @@ +# Indoor render HANG — root cause: `PortalVisibilityBuilder.Build` non-termination — 2026-06-06 + +> Report-only investigation (user chose "investigate more first"). **No code changed.** +> Worktree `thirsty-goldberg-51bb9b`. This blocks the verbatim-DrawCells port's Task 2 +> visual gate: every indoor frame can freeze here. + +## Symptom + +Three launches of the client all **froze** (`AppHangB1`, Windows Event Log) within +seconds-to-minutes of the camera being indoors at the Holtburg cottage. Not a crash — +no access violation, no managed exception. The captured managed stack of the frozen +render thread (`hang-stack.txt`, via `dotnet-stack`) shows it **CPU-spinning**: + +``` +CPU_TIME +CellView.Add(ViewPolygon) +PortalVisibilityBuilder.AddRegion(CellView, List) +PortalVisibilityBuilder.Build(...) +RetailPViewRenderer.DrawInside(...) +GameWindow.OnRender(...) +``` + +App.Tests 207/207 and Core 1331/4/1 are green; the bug is invisible to the suite (see §Evidence). + +## Verdict + +**It is NOT Task 2 (the verbatim-DrawCells / grey fix).** `Build(...)` runs at the very +top of `DrawInside` ([RetailPViewRenderer.cs:43](../../src/AcDream.App/Rendering/RetailPViewRenderer.cs)), +**before** any line Task 2 touched, and the call is byte-identical pre/post-change. Task 2's +draw logic was independently confirmed correct in the run-1 log: `[render-sig] draw=[…]` +equalled `ids=[…]` with `miss=[]`, and `[shell]` showed every visible cell drawing textured +(`zh=0`). The grey fix works. + +**Root cause:** `PortalVisibilityBuilder.Build`'s portal BFS does not terminate for real +cottage geometry. It **re-enqueues a popped cell every time that cell's `CellView` grows**: +`queued.Remove(cell.CellId)` on pop ([:122](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)) ++ `if (grew && queued.Add(neighbourId))` on grow ([:289](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)). +Termination therefore depends entirely on growth stopping. Growth is gated only by +`CellView.Add`'s **exact-match dedup** (`SamePolygon`, eps `1e-4`, +[PortalView.cs:79](../../src/AcDream.App/Rendering/PortalView.cs)). The **near-side portal clip** +(`ClipPortalAgainstView` → `PortalProjection.ProjectToClip` → `ClipToRegion`, +[:474/:485](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)) produces a polygon +that is a hair different on each `A↔B` reciprocal round (float drift through the homogeneous +project→clip round-trip with a non-identity cell transform). The dedup never matches the +drifted near-duplicate → the region grows without bound → the cell re-enqueues forever → +`CellView.Polygons` grows to N, and `CellView.Add`'s O(N) dedup scan makes the whole thing +O(N²) → frozen. + +## Evidence + +1. **Captured stack** pins the spin to `CellView.Add ← AddRegion ← Build`, pure managed + `CPU_TIME` (not a GL call, not blocked, not a fault). +2. **The code already documents this exact failure** at + [:694-697](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs): the *reciprocal* + clip deliberately stays on the float-stable `ProjectToNdc` path *because* + "per-round float drift defeated the CellView SamePolygon dedup, inflating a tight A<->B + reciprocal view to ~4x its area." The **near-side** clip ([:474](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)) + did not get the same treatment — it uses `ProjectToClip`. +3. **The only bound was removed this session.** [:74](../../src/AcDream.App/Rendering/PortalVisibilityBuilder.cs): + "Fixpoint termination replacing the old `MaxReprocessPerCell` hard cap." The fixpoint never + converges under drift; with the cap gone there is no other bound (no iteration cap, no + max-polygon cap, no time bound). +4. **It's the dirty-tree rewire the handoff said to KEEP.** `git diff --stat`: + `PortalVisibilityBuilder.cs +426/−45` and `PortalProjection.cs +111` are **uncommitted**. + `ProjectToClip` is part of the new `PortalProjection` lines. The handoff + (`2026-06-06-verbatim-drawcells-port-pickup-handoff.md`) lists this rewire as the faithful + foundation to preserve and says "the clip math is already faithful — do not harden the + w-clip." The clip is faithful in the *picture* it computes; it is the *non-termination* + that is broken. +5. **Why the suite is green:** `PortalVisibilityBuilderTests` build cells with + `WorldTransform = Matrix4x4.Identity` and axis-aligned quads in 2-cell **chains** + (`cam → ground → exit`). No `A↔B` cycle, no transform-induced drift → the project→clip + round-trip is exact → the dedup collapses duplicates → the BFS converges. The real cottage + is a **cyclic** cell cluster (`0x016F–0x0175`, mutual portals) with **non-identity** + transforms → drift + cycle → non-termination. The suite cannot reach the failing case. +6. **Why run 1 survived 113 frames then froze:** `Build` converges at most camera poses; only + specific poses create the non-converging drift cycle. The freeze coincided with the + metastable doorway flip (`[render-sig] stable` went 39→0, visible-cell count 5→4) one frame + before the log ended. + +## Hypotheses (ranked) + +1. **(confirmed)** Non-terminating BFS: re-enqueue-on-grow + `ProjectToClip` drift defeats the + `SamePolygon` dedup → unbounded `CellView` growth. Falsify: a re-process cap, a + drift-tolerant dedup, or `ProjectToNdc` on the near-side clip all make `Build` terminate. +2. *(ruled out)* GPU/driver hang from a malformed draw — the stack is pure managed `CPU_TIME` + in `CellView.Add`, never a GL call; no fault. +3. *(ruled out)* Probe-output stdout saturation — disproven: the probe-free run also hung. +4. *(ruled out)* Task 2 — `Build` is upstream of every Task 2 line and unchanged by it. + +## Fix options (all additive — none reverts the dirty tree) + +| | Fix | Touches | Pro | Con | +|---|---|---|---|---| +| **A** *(rec.)* | **Drift-tolerant dedup**: round clipped polygon vertices to a small grid (≈`1e-3`) before `AddRegion`, or widen/snaps `SamePolygon`'s match, so near-duplicates collapse → growth converges. | `CellView`/`AddRegion` | Fixes the actual root cause ("drift defeats dedup"); keeps the faithful `ProjectToClip`; preserves growth-propagation. ~10 lines. | Tolerance is a tuning constant (pick conservatively; over-merge = minor over-tighten). | +| **B** | **Restore a re-process bound** (`MaxReprocessPerCell`-style cap on the BFS). | `Build` loop | Smallest; guarantees termination; doesn't touch clip. | A guard, not a root fix; may under-include a late-growing view. The user's "no workarounds" rule applies — this is the band-aid. | +| **C** | **Near-side clip on `ProjectToNdc`** (what the reciprocal clip already uses). | `ClipPortalAgainstView` | Removes the drift source directly; consistent with `:694`. | Steps on this session's homogeneous near-eye clip work; the handoff's "don't harden the w-clip" is closest to here. | + +**Recommended next step:** approve **A** (drift-tolerant dedup) — it closes the precise +mechanism the code half-acknowledges at `:694`, terminates structurally, and leaves the +faithful clip path intact. Implement in a follow-up (not report-only) session, then re-run the +Task 2 visual gate (probe-free) at the cottage + cellar. + +## What this is NOT + +- **NOT** Task 2 / the grey fix — that is verified working (`draw==ids`, `miss=[]`, textured shells). +- **NOT** a wrong-pixels / unfaithful-projection bug — it's a **termination** bug. The handoff's + "the clip math is faithful, don't harden the w-clip" is about projection *correctness*; this is + BFS *convergence*. Don't chase the w-clip. +- **NOT** a GPU/shader/driver hang and **NOT** the probe firehose (both ruled out by the stack + and the probe-free repro). + +--- + +## Reassessment — is the dirty-tree builder rewire sound? (post Option A) + +Option A (drift-tolerant `CellView.Add` dedup, `CellViewDedupTests` green) was implemented and the +client relaunched. Result: the hang **moved out of `CellView.Add`** (A worked for its target) but +**relocated to `ScreenPolygonClip.ClipByEdge`** via `ApplyReciprocalClip` (second captured stack, +`hang-stack2.txt`). `ScreenPolygonClip.Intersect`/`ClipByEdge` are **both bounded `for` loops** — +they cannot spin on one call — so the spin is the **outer `Build` BFS** still not terminating and +calling them a runaway number of times. **Option A is necessary but not sufficient.** + +### Git evidence (what the dirty rewire changed re: termination) + +- **HEAD (committed)** near-side portal clip = `PortalProjection.ProjectToNdc` (float-stable; + `git show HEAD:` line 146). **The dirty rewire switched it to `ProjectToClip`** (`ClipPortalAgainstView`, + dirty line 474) — the homogeneous near-eye clip, introduced to fix the near/grazing-doorway flap/void. +- The `MaxReprocessPerCell` **hard cap was removed earlier** (committed Phase U.2a `d880775`), replaced + by "fixpoint termination." **Neither HEAD nor the dirty tree has a hard iteration bound.** +- The dirty rewire's own comment (`PortalVisibilityBuilder.cs:519-522`) documents that + `ProjectToClip` "produced per-round float drift that defeated the CellView SamePolygon dedup" — and + applied that lesson **only to the reciprocal clip** (kept on `ProjectToNdc`), leaving the **near-side** + clip on the drift-prone `ProjectToClip`. + +### Soundness verdict + +The builder's termination model is **unsound by construction.** It relies on the clipped regions +reaching a geometric fixpoint — re-clipping a cell's view reproduces *exactly-equal* polygons that the +dedup recognises — with **no hard iteration bound.** That only holds if the clip is float-stable. +`ProjectToClip` (needed for faithful near-doorway projection) injects per-round drift, so re-clipping +never reproduces an exactly-equal polygon, the dedup never catches it, and the re-enqueue-on-grow flood +never converges → infinite loop. **You cannot have BOTH faithful near-doorway projection (`ProjectToClip`) +AND convergence-via-exact-dedup-without-a-bound.** HEAD got away with it because `ProjectToNdc` was +stable enough to converge (and it sealed — user-verified); the dirty switch tipped it into non-termination. +The rewire fixed the *projection* and, apparently never having been launched, shipped a hang. + +A's drift-tolerant dedup *narrows* the gap but cannot *close* it: for some geometry the per-round drift +exceeds any fixed snap grid, so growth still produces new keys forever. Only a **hard bound** guarantees +termination. + +### Paths (for the user to choose) + +| | Path | Termination | Projection fidelity | Risk | +|---|---|---|---|---| +| **1** *(rec.)* | Keep `ProjectToClip` + add **enqueue-once** bound (D) — the builder's own comment already calls enqueue-once "the hard termination guarantee"; the re-enqueue-on-grow is the bug. Keep A. | Guaranteed (≤N pops) | Full (faithful doorway clip kept) | Minor under-inclusion of late growth → visual-verify; widen to a cap if needed | +| **2** | Keep `ProjectToClip` + add a **re-process cap** (B, restore `MaxReprocessPerCell`). Keep A. | Guaranteed (≤N×K) | Full | Less faithful than enqueue-once; a tuning constant | +| **3** | **Revert** the near-side `ProjectToClip → ProjectToNdc` (back to HEAD). | Restored (HEAD converged) | **Loses** the rewire's near-doorway fix → reintroduces the flap/void (separate bug) | Throws away this session's projection work; contradicts the keep-the-dirty-tree directive | + +A bound (paths 1/2) is the sound fix: it makes termination independent of clip drift, so the faithful +`ProjectToClip` projection AND guaranteed termination coexist. **Recommendation: path 1** (enqueue-once + +keep A), visual-verify for under-inclusion. Reverting (path 3) only trades the hang back for the +flap/void. diff --git a/docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md b/docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md new file mode 100644 index 00000000..02d9e4f1 --- /dev/null +++ b/docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md @@ -0,0 +1,589 @@ +# Handoff - M1.5 Indoor Render / Retail PView Replacement Attempts - 2026-06-06 + +This is a **stop-and-handoff** note for the next agent. It records what was tried, what changed on disk, what the user still sees, and what evidence should drive the next step. + +The user explicitly stopped this thread after repeated visual regressions. Do **not** continue the same patching loop. Treat all current uncommitted render work as suspect until re-audited against named retail. + +## Worktree And Rules + +- Worktree: `C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b` +- Branch: `claude/thirsty-goldberg-51bb9b` +- Starting HEAD called out by the user: `8116d10` +- Do **not** branch or create a new worktree. +- Do **not** push without asking. +- Never run `git stash` or `git gc`. +- PowerShell on Windows. +- Launch logs are UTF-16. +- Build before launching. +- Use `apply_patch` for manual edits. +- Do not revert dirty changes unless the user explicitly asks. + +Current child handoff thread created before this file: + +- Child thread id: `019e9d5c-bb34-7fe3-85cc-6b9065b4e882` +- It was forked same-directory, not a new worktree. +- A follow-up prompt was sent there with the immediate evidence and constraints. + +## User-Visible State At Stop + +The latest user report, after the most recent relaunch: + +- Transition flaps still happen between outdoor/indoor, room/room, and cellar. +- Ground floor became transparent instead of sealed. +- Cellar remains broken. +- Prior screenshots showed grey or black background filling cell openings. +- Prior screenshots showed indoor walls losing texture or drawing as clear/background color. +- Prior screenshots showed character cut in half on the cellar stairs. +- User explicitly says we are back to old bugs and nothing feels solid. + +Important: **do not claim any current code is fixed**. Build/tests passed for some pieces, but visual acceptance failed. + +## Current Dirty State + +`git status --short --branch` showed these tracked files modified: + +- `src/AcDream.App/Rendering/ClipFrameAssembler.cs` +- `src/AcDream.App/Rendering/ClipPlaneSet.cs` +- `src/AcDream.App/Rendering/GameWindow.cs` +- `src/AcDream.App/Rendering/InteriorEntityPartition.cs` +- `src/AcDream.App/Rendering/InteriorRenderer.cs` +- `src/AcDream.App/Rendering/ParticleRenderer.cs` +- `src/AcDream.App/Rendering/PortalView.cs` +- `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` +- `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs` +- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` +- `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` +- `src/AcDream.Core/World/WorldEntity.cs` +- `tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs` +- `tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs` +- `tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs` +- `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs` +- `tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs` +- `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs` +- `tools/TextureDump/Program.cs` + +Important untracked files include: + +- `src/AcDream.App/Rendering/RetailPViewRenderer.cs` +- `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md` +- many probe logs and local scripts/images, including `launch-flap-shell-capture-relaunch.log`, `launch-pview-watermark-probe.log`, `a8-current-room-cellar-audit.txt`, `texture-current-room-surfaces.txt`, `analyze_*.py`, `retail-*-trace.log`, and several screenshots. + +Diff size before this handoff file: + +- 19 tracked files changed. +- About 1593 insertions and 773 deletions. + +## Validation That Passed But Did Not Prove Visual Correctness + +After the last attempted PortalVisibilityBuilder patch: + +```powershell +dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --filter "FullyQualifiedName~PortalVisibilityBuilderTests|FullyQualifiedName~PortalProjectionTests" +``` + +Passed: 29/29. + +```powershell +dotnet build -c Debug --no-restore +``` + +Succeeded, with 9 known warnings. + +```powershell +dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --no-build +``` + +Passed: 196/196. + +These results only prove the pure/tested slices compile and pass. They did **not** solve the live render. + +## Retail PView Reference Already Written + +New pseudocode note exists: + +- `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md` + +It summarizes: + +- `SmartBox::RenderNormalMode @ 0x00453aa0` +- `RenderDeviceD3D::DrawInside @ 0x0059f0d0` +- `PView::DrawInside @ 0x005a5860` +- `PView::ConstructView @ 0x005a57b0` +- `PView::DrawCells @ 0x005a4840` +- `RenderDeviceD3D::DrawObjCellForDummies @ 0x005a0760` + +Core retail model from that note: + +- Outdoor: `LScape::draw`, then portal/interior peering through PView portal paths. +- Indoor: `DrawInside(viewer_cell)`. +- `PView::ConstructView` builds `cell_draw_list`, per-cell `portal_view`, and `outside_view`. +- `PView::DrawCells` draws outside landscape through `outside_view`, then reverse `cell_draw_list` exit masks, reverse shells, reverse object lists. +- No global indoor terrain/entity/particle pass should bypass PView membership. + +## Retail Functions That Still Matter + +Re-read named retail before more code: + +- `PView::AddViewToPortals @ 0x005a52d0` +- `PView::ConstructView @ 0x005a57b0` +- `PView::ClipPortals @ 0x005a5520` +- `PView::FixCellList @ 0x005a5250` +- `PView::AdjustCellView @ 0x005a5770` +- `PView::OtherPortalClip @ 0x005a5400` +- `PView::GetClip` around `0x005a4320` +- `SmartBox::RenderNormalMode @ 0x00453aa0` +- `SmartBox::update_viewer @ 0x00453ce0` +- `RenderDeviceD3D::DrawObjCellForDummies @ 0x005a0760` + +The critical retail detail not faithfully settled yet: + +- Retail tracks `view_count` and `update_count`. +- When a cell view grows after the cell was already processed, retail calls `FixCellList` / `AdjustCellView`. +- Current acdream code only approximates this. It may not match draw-list ordering or downstream propagation. + +## What We Tried + +### 1. Treated symptoms as separate render leaks + +The session started with symptoms that looked separate: + +- dynamic objects and particles visible through ground when looking out from inside; +- outside ground texture covering cellar entrance when looking in; +- grey flaps when crossing cell boundaries; +- missing cellar floor / grey cellar; +- transparent or textureless interior walls. + +The user correctly pushed back that these are probably one render-pipeline failure: indoor/outdoor, cells, shells, terrain, objects, particles, and doors must all agree on one visible-cell graph. + +### 2. Gated dynamic objects and particles by ownership + +Attempt: + +- `WorldEntity.ParentCellId` was populated for player/spawns/teleports/motion updates. +- `InteriorEntityPartition` was changed so live dynamic entities with an indoor `ParentCellId` go into their cell bucket instead of a global live-dynamic overlay. +- `WbDrawDispatcher.ResolveEntitySlot` was changed so `ServerGuid != 0` no longer always means "draw unclipped indoors". +- Particles were moved toward PView-scoped / owner-scoped behavior instead of a global indoor scene pass. + +Effect: + +- User reported this stopped much of the obvious dynamic-object/particle bleeding when looking out. +- It did **not** fix grey/background transition flaps. +- It did **not** fix cellar/floor/walls. + +Current risk: + +- This direction is probably correct, but the exact routing must be audited. A later attempt also cleared clip routing to avoid character/shell cutting, so "PView membership" and "GPU clip slot routing" are currently mixed/confused. + +### 3. Added/used a `RetailPViewRenderer` + +Attempt: + +- Added `src/AcDream.App/Rendering/RetailPViewRenderer.cs`. +- Moved part of indoor draw orchestration into `RetailPViewRenderer.DrawInside`. +- Added `DrawPortal` for outdoor-looking-in through `PortalVisibilityBuilder.BuildFromExterior`. +- The renderer currently does: + - `PortalVisibilityBuilder.Build` + - `ClipFrameAssembler.Assemble` + - `_envCells.PrepareRenderBatches(filter: drawableCells)` + - `InteriorEntityPartition.Partition` + - landscape through outside slices + - exit masks + - EnvCell shells + - object buckets + +Effect: + +- This is not a full retail replacement yet. +- User repeatedly saw unchanged or worse symptoms. +- FPS was reported drastically down after one iteration. +- Subsequent attempts produced missing textures / white or grey wall panels. + +Current risk: + +- `RetailPViewRenderer` is not truly verbatim retail. It keeps modern infrastructure and approximates PView with GPU clip slots and callbacks. +- The user asked "have you ported retail verbatim?" and the honest answer remains no. +- `GameWindow` still has a lot of orchestration, diagnostics, and render routing around this. It is not a small caller yet. + +### 4. Reworked `ClipFrameAssembler` from one clip per cell to per-slice clip slots + +Attempt: + +- `ClipFrameAssembler` was rewritten toward per-polygon/slice output: + - `CellIdToViewSlices` + - `OutsideViewSlices` + - per-slice `ClipViewSlice` + - `TerrainClipMode` for outside-view landscape +- The goal was to represent retail `portal_view` slices more closely. + +Effect: + +- The code is plausible as a draw assist, but it is not retail membership. +- User saw regressions including black covers during transitions. + +Current risk: + +- The next agent must ensure `ClipFrameAssembler` never decides PView membership. +- It should be draw-assist only. +- Several failures looked like GPU clip slots cutting shells or characters at door/stair boundaries. + +### 5. Disabled clip routing for shells/entities to stop character/stair cutting + +Attempt: + +- `RetailPViewRenderer.UseIndoorMembershipOnlyRouting` clears `_envCells.SetClipRouting(null)` and `_entities.ClearClipRouting()`. +- Comment says retail portal views decide eligibility, but feeding those 2D views into GL clip distances slices characters and shells at stair/door boundaries. + +Effect: + +- This was a reaction to user screenshots where the character was cut in half on stairs. +- It may reduce character slicing. +- It may also mean shells/objects are currently only membership-gated, not portal-view clipped. + +Current risk: + +- This is not a settled retail copy. It is an emergency compromise. +- Retail does use per-view setup (`CEnvCell::setup_view`) around shell/object drawing. We need to know whether our GL clip-plane model is simply the wrong mechanism for that setup. + +### 6. Tried EnvCell / DAT polygon side handling changes + +Attempt: + +- `ObjectMeshManager` changed CellStruct polygon side handling: + - DAT `CullMode` interpreted as retail `CPolygon::sides_type`. + - `0 = pos` + - `1 = pos twice with reversed winding` + - `2 = pos + neg surface` + - `NoPos` / `NoNeg` still suppress faces. +- Added explicit normal inversion / winding reversal logic. + +Effect: + +- User saw missing textures/white/grey interior panels after some launches. +- The attempt did not fix the cellar or transition flaps. + +Current risk: + +- This may be correct retail interpretation or may be partially wrong. +- Audit with DAT dumps and retail/ACME references before keeping. +- `a8-current-room-cellar-audit.txt` and `texture-current-room-surfaces.txt` may contain useful surface/cell evidence. + +### 7. Tried outside-looking-in via `BuildFromExterior` + +Attempt: + +- `PortalVisibilityBuilder.BuildFromExterior` seeds interior cell views through outside-facing exit portals. +- `RetailPViewRenderer.DrawPortal` calls it from outdoor branch. +- Tests were added: + - seeds interior cell through outside portal; + - does not seed when camera is on interior side; + - traverses deeper interior portals; + - max seed distance skips distant exit portal. + +Effect: + +- User initially reported walls became visible looking in from outside, but ground/cellar entrance composition stayed wrong. +- Later launches regressed to transparent/grey panels and missing textures. + +Current risk: + +- This is probably needed, but the exterior portal path is not proven retail-faithful. +- `BuildFromExterior` may now have duplicated-looking test diff context; inspect file carefully. + +### 8. Tried broad "no hybrid" render routing in `GameWindow` + +Attempt: + +- `GameWindow` was changed so indoor path should call `RetailPViewRenderer.DrawInside`. +- Outdoor path should draw world and call `DrawPortal`. +- Global indoor terrain/entity/particle passes were reduced or bypassed. +- New render signature diagnostics log: + - `branch` + - `root` + - `viewerRoot` + - `playerRoot` + - `viewerCell` + - `playerCell` + - `gate` + - `terrain` + - `skyGate` + - `zclear` + - `sceneParticles` + - `outSlices` + - `outPolys` + - `ids` + - `draw` + - object partition counts + +Effect: + +- User explicitly asked whether the hybrid was totally gone. +- It is not safe to answer "yes" without auditing `GameWindow`. +- Symptoms persisted, so either the routing is still hybrid or the PView graph/draw setup is wrong enough that "no hybrid" alone does not solve it. + +Current risk: + +- `GameWindow.cs` has a very large diff, around 1000 lines touched. +- Next agent should not blindly keep it. +- Audit all remaining global passes while `clipRoot != null`. + +### 9. Tried PView `update_count`-style reprocessing + +Attempt in the last aborted step: + +- `PortalView.CellView.Add` now returns `bool` and deduplicates near-identical polygons. +- `PortalVisibilityBuilder.Build` replaced `seen` with: + - `queued` + - `drawListed` + - `processedViewCounts` +- A cell can be requeued when its view grows. +- Each processing pass clips portals against only newly added view polygons. +- Similar logic was added to `BuildFromExterior`. +- Added tests: + - `Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour` + - `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` + +Effect: + +- Focused tests passed. +- Live probe after patch still showed `outPolys` toggling near root `0xA9B40172`. +- User then reported transition flaps still there and now ground floor transparent. + +Current risk: + +- This patch is **unaccepted** and may be wrong. +- It approximates retail `update_count`, but does not necessarily implement `FixCellList`, `AdjustCellPlace`, or retail draw-list ordering correctly. + +### 10. Widened eye-standing-in-portal fallback + +Attempt: + +- `EyeStandingPerpDist` widened from `0.5f` to `1.75f`. +- Motivation: live cellar capture had `0174 -> 0175` traversable with `D=-1.41` but `ProjectToNdc` returned zero vertices. +- The fallback still requires the perpendicular projection to land inside the portal opening. + +Effect: + +- It made one unit test pass for the cellar-style collapsed portal. +- It did not solve live transitions. + +Current risk: + +- This may be a bandaid, not retail. +- It should be validated against `OtherPortalClip` / `GetClip` in named retail before keeping. + +### 11. Tried reciprocal clip fallback for eye-in-opening + +Attempt: + +- Before `ApplyReciprocalClip`, code clones `clippedRegion` when `eyeInsideOpening`. +- If reciprocal clipping empties the region, it restores the pre-reciprocal region. + +Effect: + +- Tests passed. +- Live visual did not. + +Current risk: + +- This may over-include. +- It is not proven retail-faithful. + +## Critical Evidence From Logs + +### Cellar startup: root 0174 only sees itself + +From `launch-flap-shell-capture-relaunch.log`: + +```text +[flap] root=0xA9B40174 eye=(154.50,4.99,92.25) localEye=(7.43,2.51,-1.77) | +p0->0x0175 D=-1.41 TRV proj=0 clip=-1 || outPolys=0 vis=1 + +[flap-cam] root=0xA9B40174 viewerCell=0xA9B40174 playerCell=0xA9B40174 +... terrain=Skip outVisible=False + +[render-sig] frame=49 branch=RetailPViewInside root=0xA9B40174 +... terrain=Skip/skip sky=n zclear=n sceneParticles=none +outSlices=0 outPolys=0 ids=[0xA9B40174] draw=[0xA9B40174] +``` + +Meaning: + +- The player/viewer/root are in cellar cell `0174`. +- The only visible portal to stair connector `0175` is traversable. +- Projection produces zero vertices. +- The PView flood stops at the cellar. +- Only the cellar draws; stair/main-floor cells are not in the visible set. + +This is a direct candidate cause for missing floor/grey composition. + +### Root 0172: outside view toggles on/off + +From `launch-pview-watermark-probe.log`: + +```text +frame=3625 root=0xA9B40172 +p0->0x0173 D=... TRV proj=4 clip=4 +p1->0x016F D=5.28 TRV proj=0 clip=-1 +outPolys=1 vis=6 +ids include 0xA9B40170 +terrain=Skip/draw sky=Y zclear=Y + +frame=3626 root=0xA9B40172 +p0->0x0173 D=... TRV proj=5 clip=5 +p1->0x016F D=5.39 TRV proj=0 clip=-1 +outPolys=0 vis=5 +ids missing 0xA9B40170 +terrain=Skip/skip sky=n zclear=n + +frame=3647 root=0xA9B40172 +outPolys=1 ids include 0xA9B40170 terrain draw + +frame=3648/3649 root=0xA9B40172 +outPolys=0 ids missing 0xA9B40170 terrain skip +``` + +Meaning: + +- The same root cell can alternate between seeing outside and not seeing outside. +- `0x016F` is involved in the root flap line but projects to zero. +- Sometimes `0x0170` becomes reachable and outside terrain/sky/depth clear run; sometimes it disappears. +- The visible-cell list and outside-view list are not stable. + +Open question: + +- Is `0x016F` an outdoor/land cell, an env cell lookup miss, or a portal that retail handles differently? +- Is the toggling caused by projection/clip degeneracy, wrong portal reciprocal handling, update-count propagation, or camera/viewer-cell root? + +### Earlier known evidence: root 0171 vs player 0174 contradiction + +From the older `2026-06-05-shell-sealing-cellar-floor-handoff.md`: + +```text +[flap-cam] root=0xA9B40171 viewerCell=0xA9B40171 playerCell=0xA9B40174 +... +[flap] root=0xA9B40171 ... p1->0173 proj=0 ... +``` + +Meaning: + +- Earlier, camera/root was the room while player was cellar. +- The flood did not seal the player's cell. +- Later, after branch/viewer changes, there are also frames where root/player/viewer are all `0174` but the flood still fails on `0174 -> 0175`. + +This means the problem is probably not only "wrong root"; it also includes projection/portal traversal/flood propagation or mesh-shell handling. + +## What Not To Retry Blindly + +Do not simply: + +- switch the root to player cell as a workaround; +- widen `EyeStandingPerpDist` further; +- globally draw all indoor shells; +- globally draw terrain/entities/particles while inside; +- turn off all clipping and hope depth sorts it; +- keep adding `if cellar` or Holtburg-cottage-specific handling; +- claim "no hybrid" without auditing all `GameWindow` indoor/outdoor passes; +- equate unit-test pass with visual correctness. + +The user has explicitly asked for retail smoothness, not a new patch stack. + +## Likely Root Problem Space + +The next fix probably lives in one of these, but evidence must decide: + +1. **PView graph construction is not retail-faithful.** + - Missing or wrong `update_count` / `FixCellList` / `AdjustCellView`. + - Wrong draw-list ordering when a processed cell receives new views. + - Downstream portal propagation incomplete. + +2. **Portal projection/clip behavior differs from retail.** + - `0174 -> 0175` traversable but `proj=0`. + - `0172 -> 016F` traversable but `proj=0`. + - `OtherPortalClip` / `GetClip` may not match retail. + +3. **Outdoor/exit portal classification is wrong.** + - `OtherCellId=0xFFFF` is treated as exit/outside, but `0x016F` may be another kind of outside/land portal or missing env cell. + - OutsideView may be created through a downstream path that acdream sometimes drops. + +4. **Renderer draw setup is still hybrid or ordered wrong.** + - `GameWindow` may still draw or skip global passes inconsistently. + - Sky/terrain/depth clear decisions are visibly flapping with `outside_view`. + +5. **EnvCell shell mesh/surface handling is wrong.** + - Missing/transparent/white walls and floors may be mesh/surface/cull-side regressions. + - Audit `ObjectMeshManager` side handling against retail and DAT dumps. + +6. **GPU clip slots are being used as membership or hard clipping when retail uses view setup differently.** + - Character cut in half on stairs strongly suggests hard clip-plane use on avatars/shells is wrong or applied at wrong pass. + +## Suggested Next Procedure + +1. Stop patching. Inspect the dirty diff first. +2. Read `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md`. +3. Re-read named retail functions listed above. +4. Parse `launch-flap-shell-capture-relaunch.log` and `launch-pview-watermark-probe.log` around the cited frames. +5. Add better probes if needed: + - cell id; + - portal index; + - other cell id; + - portal flags; + - other portal id; + - traversable decision; + - standing distance; + - projection vertex count; + - clip vertex count; + - reciprocal clip result; + - outside-view add/skip reason; + - cell view count / processed count / update count; + - queue/requeue reason; + - draw-list insertion/reorder. +6. Decide from evidence whether `0174 -> 0175` and `0172 -> 016F` fail because of projection, reciprocal clip, cell lookup/classification, or update propagation. +7. Patch only the retail mismatch. +8. Build/test before launch. +9. Launch with probes once. +10. Then launch clean for FPS/visual feel. +11. Do not call it done until the user visually confirms retail smoothness. + +## Launch Command + +Use PowerShell: + +```powershell +Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Sleep -Seconds 3 + +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_PROBE_FLAP = "1" +$env:ACDREAM_PROBE_SHELL = "1" +$env:ACDREAM_PROBE_VIS = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | + Tee-Object -FilePath "launch-next-pview.log" +``` + +For clean visual/FPS run, remove the probe env vars. + +## Minimal Prompt For Next Agent + +```text +Continue acdream M1.5 indoor render in SAME worktree: +C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b +branch claude/thirsty-goldberg-51bb9b. Do NOT branch/worktree. Do NOT push. NEVER stash/gc. + +The current dirty render code is not visually accepted. The user stopped the prior agent after repeated regressions. +Read docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md first. + +Current symptoms: transition flaps still happen indoor/outdoor, room/room, cellar; ground floor is now transparent; cellar broken; prior runs showed grey/black clear color, missing wall textures, and character cut on stairs. + +Do not patch first. Audit dirty diff, read named retail PView, parse launch-flap-shell-capture-relaunch.log and launch-pview-watermark-probe.log. Determine exactly why: +1) cellar root 0174 fails to traverse 0174 -> 0175 when proj=0; +2) root 0172 toggles outside_view/0170 reachability while 0172 -> 016F has proj=0; +3) shell/object/terrain/depth-clear decisions disagree. + +Patch only the retail mismatch. Build/test before relaunch. Do not claim success before user visual confirmation. +``` + diff --git a/docs/research/2026-06-07-indoor-render-session-handoff.md b/docs/research/2026-06-07-indoor-render-session-handoff.md new file mode 100644 index 00000000..628e7d39 --- /dev/null +++ b/docs/research/2026-06-07-indoor-render-session-handoff.md @@ -0,0 +1,160 @@ +# Indoor Render — Session Handoff: HANG fixed + interior SEALS; the FLAP is next — 2026-06-07 + +> Worktree `thirsty-goldberg-51bb9b`, branch `claude/thirsty-goldberg-51bb9b`. PowerShell on Windows; +> launch logs UTF-16; build before launch; acceptance is the user's eyes. Live ACE `127.0.0.1:9000`, +> `testaccount`/`testpassword`, char `+Acdream` (spawns near the Holtburg / "Arcanum" cottage — +> landblock `0xA9B4`, cottage cells `0xA9B4016F–0175`). Do NOT branch/worktree, push, or `git stash`/`gc`. + +## TL;DR + +The two-week indoor-render **HANG is FIXED** and the interior **SEALS** (walls/floor/ceiling draw, +textured) — both committed this session and live-verified by the user ("Ok now it runs!"). A +structured live test pinned the remaining dominant visible issue, the **FLAP at transitions**, as +**viewer-cell metastability**: the render roots at the camera-eye cell, which oscillates +outdoor↔indoor as the 3rd-person boom drifts across the doorway plane. **The flap is a SEPARATE, +already-designed fix — it is NOT the verbatim DrawCells port; finishing the port will not fix it.** +Next session: **fix the flap** (camera-boom stability + viewer-cell dead-zone). Tracked follow-ups: +#78 terrain gating, look-in-from-inside sealing, look-in FPS, L-spotlight. + +## What shipped this session (committed — see `git log` on this branch) + +### 1. The HANG fix (the blocker) +Indoor frames froze (`AppHangB1`; not a crash — captured the spinning managed stack via a +`dotnet-stack` hang-watcher). Root cause: `PortalVisibilityBuilder.Build`'s portal-visibility flood +**did not terminate** for real cottage geometry. Two layers, two fixes (both kept): +- **A — drift-tolerant `CellView.Add` dedup** (`src/AcDream.App/Rendering/PortalView.cs`). The flood + re-queues a cell every time its view GROWS; growth only stops when the dedup recognises a re-clipped + region as a duplicate. The faithful `ProjectToClip` near-side clip drifts per round, so the old + exact index-by-index match (eps 1e-4) never caught the near-duplicate → unbounded growth → O(n²) + CPU-spin in `CellView.Add`. Fix: key each polygon by its vertices **snapped to a 1e-3 NDC grid**, + consecutive-dedup'd, **canonically rotated** to a lex-min start → finite key space → convergence. + Tests: `tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs` (3). +- **B — bounded re-enqueue** (`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`). A alone did not + fully converge (the spin relocated to `ScreenPolygonClip.ClipByEdge` — bounded loops — inside the + still-non-terminating BFS). Restored the **`MaxReprocessPerCell = 16`** hard cap that Phase U.2a + deleted ("fixpoint termination" left the loop with NO bound). **Kept the re-enqueue** — it is + load-bearing for late-slice propagation (`Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit`). + Pure enqueue-once was tried and **broke that test**, so re-enqueue is kept and merely bounded. +- Deep diagnosis + the reassessment that led to B: `docs/research/2026-06-06-indoor-render-hang-rootcause.md`. +- **Verified:** clean exit (255→0); runs indoors with no freeze; the indoor flood converges in ~1 + round/cell at normal positions (measured 3–5 pops/frame, 1 view-poly/cell). The cap only bites at + the metastable doorway. + +### 2. The SEAL (verbatim DrawCells port — Task 2) +`RetailPViewRenderer.DrawEnvCellShells` now iterates `IndoorDrawPlan.ShellPass(pvFrame)` — **every** +visible cell's shell draws (was gated on `ClipFrameAssembler`'s slot filter → cells without a slot +were silently dropped → grey clear-color void). Verified: interior seals + textured. (Task 1 +`IndoorDrawPlan` + its test committed earlier as `bff1955`.) + +### 3. Look-in FPS +`GameWindow` exterior-look-in candidate cells limited to the player's landblock **±1** (was **all +~81 loaded landblocks** iterated every outdoor frame just to discard them via the 48 m seed cutoff). +Provably no behavior change (excluded cells are >48 m, already culled). Outdoor FPS improved but +still **~110 fps / ~9 ms (was ~200)** — `DrawPortal` still draws ~12 building interiors/frame (see +follow-up). + +## Baselines (must hold at next session start) +- `dotnet build -c Debug` **0 errors**. +- App.Tests **210/210** (205 baseline + IndoorDrawPlanTests 2 + CellViewDedupTests 3). +- Core.Tests **1331 pass / 4 fail / 1 skip** — the 4 are pre-existing Physics door/step-up, unrelated. + +## Structured live test — findings (Holtburg/Arcanum cottage, 2026-06-07) +User walked a 6-step protocol (inside-still → camera-pan → doorway-threshold → just-outside → +looking-at-cottage → cellar) and reported 8 behaviours; `ACDREAM_PROBE_FLAP` `[render-sig]` +correlated each. + +| # | Observed | Cause | Bucket | +|---|---|---|---| +| 2,3,6,8 | walls briefly transparent / window+entrance "covered by the world background" / abrupt "teleport" through the doorway — all **at transitions (camera crossing a threshold)** | **THE FLAP** | viewer-cell stability (NEXT) | +| 1 | outdoor grass covers the cellar-entrance hole (steady, looking in from outside) | outdoor terrain not gated over the indoor floor opening | **#78** terrain gating | +| 7 | from inside, a building seen through the doorway has transparent walls (world-bg shows); pops back when you step outside | look-out shows other buildings unsealed | look-in/look-out completeness | +| 5 | spotlight blobs on textures from the ceiling lamp (always been there) | point-light artifact | **L-spotlight** (separate) | +| FPS | inside very high; outside **110 fps / ~9 ms** (was ~200) | `DrawPortal` draws ~12 interiors/frame | look-in cost | +| 4 | cellar transitions **stable** ✓ | vertical transition doesn't cross the outdoor boundary | — | + +### The FLAP — pinned (render-sig evidence) +`[render-sig]` over the doorway shows the render branch + the cell it roots at flip-flopping while the +**player cell stays inside**: +``` +50× branch=OutdoorRoot viewer=0xA9B40031 (outdoor) player=0xA9B40171 (indoor) gate=in +16× branch=RetailPViewInside viewer=0xA9B40170 (indoor) player=0xA9B40171 gate=in +113× branch=RetailPViewInside viewer=0xA9B40171 (indoor) player=0xA9B40171 gate=in + ... oscillates 0x0031 ↔ 0x0170 ↔ 0x0171 frame-to-frame ... +``` +**Mechanism:** the render roots at the **viewer (camera-eye) cell** (`clipRoot = viewerRoot`, Phase W +"one viewpoint"). The 3rd-person boom drifts the eye across the doorway plane; acdream re-resolves the +viewer cell fresh each frame with **no hysteresis** → it flips between outdoor `0x0031` and indoor +`0x0170/0x0171` → the render flips `OutdoorRoot`↔`RetailPViewInside` → the indoor seal drops (walls +transparent, outdoor world/grass shows) then re-seals → **flapping**. This is exactly the 2026-06-05 +viewer-cell-flicker diagnosis, now confirmed against the live render branch. + +## RECOMMENDED NEXT WORK — fix the FLAP (separate, already-designed) +Per `docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md`, 3 retail-faithful parts: +1. **Viewer-cell dead-zone (do this first)** — ±0.2 mm cell hysteresis so a sub-mm eye drift can't flip + the cell (`PhysicsCameraCollisionProbe.SweepEye`; retail `point_inside_cell_bsp` 0x53c1f0). Highest + leverage — likely kills most of the flap on its own. +2. **Camera-boom stability** — stop the boom drifting at rest (`RetailChaseCamera.UpdateCamera`; retail + `UpdateCamera` 0x456660). +3. **w-space (w=0) portal clip** — close-portal projection degeneracy (`PortalProjection` / + `PortalVisibilityBuilder`; retail `GetClip` 0x5a4320 / `polyClipFinish` 0x6b6d00). Lower priority. + +Apparatus ready: `ACDREAM_PROBE_FLAP` emits `[render-sig]` (branch/viewer/player/gate per frame), +`[flap]`, `[flap-cam]`, `[flap-sweep]` — light enough to launch with (the heavy `ACDREAM_PROBE_SHELL` +firehose is what previously caused an I/O stall; avoid it). + +## Tracked follow-ups (logged; not yet fixed) +- **#78 terrain gating** — outdoor terrain (grass) draws over the indoor cellar-entrance hole (and likely + other indoor floors). Decomp anchor `CEnvCell::find_visible_child_cell` (`acclient_2013_pseudo_c.txt:311397`). +- **Look-in-from-inside** — buildings seen through your door/window from inside render unsealed + (transparent walls); the look-out pass doesn't draw other buildings' shells. DrawCells port Task 5/7 + territory (or R2 "outside-looking-in"). +- **Look-in FPS** — `DrawPortal` draws ~12 building interiors every outdoor frame (~110 vs ~200 fps). + Optimize: only look into buildings whose exit portals are frustum-visible; skip when no door is in view. +- **L-spotlight** — ceiling-lamp point light makes spotlight blobs on textures. Pre-existing, separate. + +## verbatim DrawCells port — remaining tasks (deferred) +Plan: `docs/superpowers/plans/2026-06-06-verbatim-retail-indoor-render-port.md`. Task 1 + Task 2 done. +**Task 3** (objects no-clip) is effectively already satisfied (objects draw membership-gated with no +clip; no half-characters observed). **Tasks 4–8** (per-slice trim, look-out, delete `ClipFrameAssembler`, +look-in, final) are **cleanup with no current visible payoff** — the seal works and there is **no visible +bleed** (the "glitches between cells" were the FLAP, not bleed). **Task 4 (trim) is intricate** (its +per-slice `_clipFrame.Reset()` is coupled with the landscape/particle passes that still read +`clipAssembly` slots) and **risks re-slicing the working seal** — do it carefully, fresh, and only when +clean architecture is the priority. + +## DO NOT re-litigate +- The HANG fix (A drift-dedup + B bounded re-enqueue) is correct + verified. **Do NOT try pure + enqueue-once** — it breaks `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` (late-slice + propagation needs the re-enqueue; the cap, not removal, is the termination guarantee). +- The grey was the `drawableCells` / `ClipFrameAssembler` slot filter; Task 2 fixed it. The clip math is + faithful — do not "harden the w-clip". +- **The FLAP is NOT the DrawCells port.** It is viewer-cell metastability (camera/membership). Tasks 4–8 + will NOT fix it. +- The render roots at the VIEWER (camera-eye) cell intentionally (Phase W "one viewpoint"). The flap fix + is to STABILISE the viewer cell (dead-zone + boom), NOT to re-root at the player cell (superseded). + +## Copy-paste pickup prompt (next session) +``` +Pick up the indoor-render work in worktree thirsty-goldberg-51bb9b (branch +claude/thirsty-goldberg-51bb9b). PowerShell; launch logs UTF-16; build before launch; acceptance is +the user's eyes. Do NOT branch/worktree, push, git stash/gc, or revert the dirty tree. + +Read first: docs/research/2026-06-07-indoor-render-session-handoff.md (state, what shipped, the FLAP +diagnosis, do-not-relitigate). Then docs/research/2026-06-05-viewer-cell-flicker-rootcause-and-fix-plan-handoff.md +(the flap fix plan). + +Confirm baselines: build 0 errors; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip. + +The indoor HANG is fixed and the interior SEALS (shipped + committed last session). The remaining +dominant visible issue is the FLAP at transitions — viewer-cell metastability: the render roots at the +camera-eye cell, which oscillates outdoor↔indoor as the 3rd-person boom drifts across the doorway (no +hysteresis), confirmed in [render-sig]. FIX THE FLAP, starting with the viewer-cell dead-zone +(PhysicsCameraCollisionProbe.SweepEye; retail point_inside_cell_bsp 0x53c1f0), then camera-boom +stability (RetailChaseCamera.UpdateCamera; retail UpdateCamera 0x456660). Launch with ACDREAM_PROBE_FLAP +only (NOT ACDREAM_PROBE_SHELL — it stalls on I/O). Gate on the user's eyes at the cottage doorway. + +Do NOT: retry pure enqueue-once (breaks late-slice propagation); re-root render at the player cell +(viewer-cell rooting is intentional); finish DrawCells port Tasks 4-8 expecting it to fix the flap (it +won't). Tracked follow-ups (not the flap): #78 terrain gating (grass over cellar hole), look-in-from- +inside sealing, look-in FPS (DrawPortal ~12 interiors/frame), L-spotlight. +``` diff --git a/src/AcDream.App/Rendering/ClipFrameAssembler.cs b/src/AcDream.App/Rendering/ClipFrameAssembler.cs index cff57a00..3545bee7 100644 --- a/src/AcDream.App/Rendering/ClipFrameAssembler.cs +++ b/src/AcDream.App/Rendering/ClipFrameAssembler.cs @@ -1,251 +1,224 @@ // ClipFrameAssembler.cs // -// Phase U.4: assemble a per-frame ClipFrame (the GPU-side shared clip data) + -// a cellId→slot map from a PortalVisibilityFrame. This is the CPU policy that -// turns the portal-visibility BFS result into the slot indices the mesh shader -// (binding=2 CellClip + binding=3 per-instance slot) and the terrain UBO read. +// Retail PView assembly policy. PortalVisibilityBuilder produces a retail-like +// view graph: one portal_view list per visible cell plus an outside_view list. +// This assembler packs each visible polygon as an individual GPU clip slot so +// the renderer can draw the exact PView order: // -// GL-free: ClipFrame's CPU byte-packing (AppendSlot / SetTerrainClip) runs here; -// the GL upload (ClipFrame.UploadShared) happens at the call site. That keeps the -// whole slot/gate policy unit-testable without a GPU context — see -// ClipFrameAssemblerTests. +// outside_view landscape slices +// reverse cell_draw_list exit masks +// reverse cell_draw_list EnvCell shells +// reverse cell_draw_list object lists // -// === The slot/gate policy (implemented EXACTLY as the U.4 spec dictates) ====== -// slot 0 = no-clip (count 0). ALWAYS present (ClipFrame.NoClip seeds it). -// -// Per visible interior cell (PortalVisibilityFrame.OrderedVisibleCells): -// ClipPlaneSet.From(CellView) has THREE Count==0 meanings (see ClipPlaneSet): -// • IsNothingVisible ⇒ DO NOT map the cell. Its instances/shell won't draw -// (the cull is deliberate — retail culls it too). -// • Count > 0 ⇒ append a real planes slot; cellIdToSlot[cell] = slot. -// • UseScissorFallback⇒ cellIdToSlot[cell] = 0 (no-clip / over-include). -// Per-cell glScissor would break MDI batching, and -// over-inclusion is the SAFE direction; counted in -// ScissorFallbacks for the probe. -// -// OutsideView feeds TWO consumers: -// • mesh "outdoor slot" (outdoor scenery / building shells drawn while the -// camera is indoors): Count>0 ⇒ planes slot (OutdoorSlot); scissor ⇒ slot 0 -// (no-clip, counted); IsNothingVisible ⇒ OutdoorVisible=false (CULL these -// instances — the camera can't see outdoors through any portal chain). -// • terrain UBO: Count>0 ⇒ SetTerrainClip(planes); scissor ⇒ TerrainScissor -// (the call site sets glScissor around ONLY the terrain draw) + UBO count 0; -// IsNothingVisible ⇒ SKIP the terrain draw entirely (THIS is the bleed fix). -// -// Outdoor root (pvFrame == null) is handled by the caller, not here: terrain -// draws normally (UBO count 0, no scissor), every instance is slot 0. The caller -// only invokes Assemble when there IS an indoor root. +// Slot 0 is always no-clip. A slice whose polygon cannot be represented by the +// <=8 plane budget uses slot 0 and its NDC AABB; the renderer uses scissor for +// passes that need that fallback. Empty regions are omitted entirely. using System.Collections.Generic; using System.Numerics; namespace AcDream.App.Rendering; /// -/// How the terrain (single OutsideView region) should be drawn this frame. +/// How the landscape-through-outside_view pass should be interpreted. /// public enum TerrainClipMode { - /// OutsideView reduced to convex planes — terrain gated via the UBO - /// ( already applied by the assembler). + /// All outside_view slices have convex plane clips. Planes, - /// OutsideView exceeded the convex budget — the call site sets a - /// glScissor to around ONLY - /// the terrain draw; the UBO is left at count 0 (ungated). + /// At least one outside_view slice requires scissor fallback. Scissor, - /// OutsideView is empty (no exit portal visible through any chain) — - /// the call site SKIPS the terrain draw entirely. This is the bleed fix: an - /// interior with no view outdoors draws no terrain. + /// No outside_view slice is visible; skip landscape indoors. Skip, } /// -/// Result of : the populated -/// (CPU bytes ready; caller does UploadShared) plus -/// the per-instance routing data the renderers + the terrain draw consume. +/// One retail portal_view slice mapped to a GPU clip slot. The AABB is retained +/// for passes that cannot write gl_ClipDistance and must use scissor. +/// +public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes); + +/// +/// Result of : populated clip buffers +/// plus routing data consumed by the render orchestration. /// public sealed class ClipFrameAssembly { - /// The per-frame clip data. Caller uploads it via - /// then hands its - /// / to the - /// renderers. public required ClipFrame Frame { get; init; } - /// Maps a visible cell id to its CellClip slot index. A cell that is - /// NOT a key (IsNothingVisible, or never visible) must NOT be drawn — its mesh - /// instances / shell are culled. A scissor-fallback cell maps to slot 0. + /// First drawable slice slot per visible cell. Compatibility map + /// for renderer APIs that can accept only one slot at a time. public required Dictionary CellIdToSlot { get; init; } - /// Slot for outdoor scenery / building-shell instances (ParentCellId - /// == null) while the camera is indoors. Meaningful only when - /// is true. 0 ⇒ no-clip (scissor fallback or trivial). + /// Slot-only cell slices, retained for older renderer APIs. + public required Dictionary CellIdToViewSlots { get; init; } + + /// Full retail portal_view slices per visible cell. + public required Dictionary CellIdToViewSlices { get; init; } + + /// Full retail outside_view slices. + public required ClipViewSlice[] OutsideViewSlices { get; init; } + public required int OutdoorSlot { get; init; } - - /// False ⇒ the OutsideView is empty; outdoor scenery / shells are - /// CULLED this frame (camera sees no outdoors through any portal chain). public required bool OutdoorVisible { get; init; } - - /// How to draw terrain (planes already applied to the UBO / scissor / - /// skip). See . public required TerrainClipMode TerrainMode { get; init; } - - /// NDC AABB (minX,minY,maxX,maxY) for the terrain glScissor when - /// is . Unused otherwise. public required Vector4 TerrainScissorNdcAabb { get; init; } - - /// True ⇒ the OutsideView (the exit-portal screen region) is meaningfully visible this - /// frame — the camera can see outdoors through a portal chain ( is - /// or ). False ⇒ a - /// sealed interior with no exit portal in view (). Drives the - /// Stage 4 sky/weather draw + the conditional doorway Z-clear. Always false on the outdoor root - /// (the caller does not invoke there). public required bool HasOutsideView { get; init; } - - /// NDC AABB (minX,minY,maxX,maxY) of the OutsideView screen region — the doorway - /// opening's bounding box. Computed whenever is true, for BOTH the - /// Planes and Scissor terrain modes (unlike , which is valid - /// only in Scissor mode). Stage 4 scissors the conditional doorway depth-only Z-clear (retail - /// PView::DrawCells:432731) and the sky/weather particle passes to this region. Degenerate - /// () when is false. public required Vector4 OutsideViewNdcAabb { get; init; } - // ---- Probe data (ACDREAM_PROBE_VIS / RenderingDiagnostics.EmitVis) -------- - - /// Plane count the OutsideView reduced to (0 ⇒ scissor or empty). + // Probe data. public required int OutsidePlaneCount { get; init; } - - /// Per-cell clip-plane count (cell id → plane count) for the probe. - /// A scissor-fallback cell records 0 here (it maps to slot 0). public required Dictionary PerCellPlaneCounts { get; init; } - - /// Number of regions (cells + OutsideView) that fell back to a scissor - /// AABB → no-clip this frame. public required int ScissorFallbacks { get; init; } } -/// -/// Builds a from a . -/// Pure CPU; no GL. The single entry point implements the U.4 -/// slot/gate policy (file header). -/// public static class ClipFrameAssembler { - /// - /// Assemble the per-frame clip data + routing from a portal-visibility frame - /// INTO an existing — the long-lived GameWindow frame is - /// -and-repacked here every frame so its GL buffers - /// are reused (no per-frame buffer churn). The returned assembly's - /// is the same instance passed in. - /// public static ClipFrameAssembly Assemble(ClipFrame frame, PortalVisibilityFrame pvFrame) { System.ArgumentNullException.ThrowIfNull(frame); System.ArgumentNullException.ThrowIfNull(pvFrame); - frame.Reset(); // slot 0 = no-clip + frame.Reset(); + var cellIdToSlot = new Dictionary(); + var cellIdToViewSlots = new Dictionary(); + var cellIdToViewSlices = new Dictionary(); var perCellPlaneCounts = new Dictionary(); int scissorFallbacks = 0; - // ── Interior cells ─────────────────────────────────────────────────── foreach (uint cellId in pvFrame.OrderedVisibleCells) { if (!pvFrame.CellViews.TryGetValue(cellId, out var view)) - continue; // defensive — OrderedVisibleCells is derived from CellViews - - var cps = ClipPlaneSet.From(view); - - if (cps.IsNothingVisible) - { - // Cell culled — do NOT map it; its instances/shell won't draw. continue; + + var slices = new List(view.Polygons.Count); + int maxPlaneCount = 0; + + foreach (var poly in view.Polygons) + { + var cps = ClipPlaneSet.From(ViewOf(poly)); + if (cps.IsNothingVisible) + continue; + + int slot; + Vector4[] planes; + if (cps.Count > 0) + { + planes = ToPlaneSpan(cps); + slot = frame.AppendSlot(planes); + if (cps.Count > maxPlaneCount) + maxPlaneCount = cps.Count; + } + else + { + planes = System.Array.Empty(); + slot = 0; + scissorFallbacks++; + } + + slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes)); } + + if (slices.Count == 0) + continue; + + var sliceArray = slices.ToArray(); + cellIdToViewSlices[cellId] = sliceArray; + cellIdToViewSlots[cellId] = ToSlots(sliceArray); + cellIdToSlot[cellId] = sliceArray[0].Slot; + perCellPlaneCounts[cellId] = maxPlaneCount; + } + + var outsideSlicesList = new List(pvFrame.OutsideView.Polygons.Count); + int outsideMaxPlaneCount = 0; + bool outsideHasScissorFallback = false; + + foreach (var poly in pvFrame.OutsideView.Polygons) + { + var cps = ClipPlaneSet.From(ViewOf(poly)); + if (cps.IsNothingVisible) + continue; + + int slot; + Vector4[] planes; if (cps.Count > 0) { - int slot = frame.AppendSlot(cps); - cellIdToSlot[cellId] = slot; - perCellPlaneCounts[cellId] = cps.Count; + planes = ToPlaneSpan(cps); + slot = frame.AppendSlot(planes); + if (cps.Count > outsideMaxPlaneCount) + outsideMaxPlaneCount = cps.Count; } - else // UseScissorFallback (Count == 0, not nothing-visible) + else { - // Over-include via no-clip (slot 0). Per-cell glScissor would break - // MDI batching; over-inclusion is the safe direction for M1.5. - cellIdToSlot[cellId] = 0; - perCellPlaneCounts[cellId] = 0; + planes = System.Array.Empty(); + slot = 0; + outsideHasScissorFallback = true; scissorFallbacks++; } + + outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes)); } - // ── OutsideView ────────────────────────────────────────────────────── - var ov = ClipPlaneSet.From(pvFrame.OutsideView); + var outsideViewSlices = outsideSlicesList.ToArray(); + bool outdoorVisible = outsideViewSlices.Length > 0; + int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0; + TerrainClipMode terrainMode = !outdoorVisible + ? TerrainClipMode.Skip + : (outsideHasScissorFallback ? TerrainClipMode.Scissor : TerrainClipMode.Planes); - int outdoorSlot; - bool outdoorVisible; - TerrainClipMode terrainMode; - Vector4 terrainScissor = Vector4.Zero; - - if (ov.IsNothingVisible) - { - // No outdoors visible through any portal chain. - outdoorSlot = 0; - outdoorVisible = false; // mesh: CULL outdoor scenery / shells. - terrainMode = TerrainClipMode.Skip; // terrain: the bleed fix. - } - else if (ov.Count > 0) - { - // Convex planes — gate both the outdoor mesh slot and the terrain UBO. - outdoorSlot = frame.AppendSlot(ov); - outdoorVisible = true; - frame.SetTerrainClip(ToPlaneSpan(ov)); - terrainMode = TerrainClipMode.Planes; - } - else // UseScissorFallback - { - // Mesh: no-clip over-include (slot 0), still visible. Terrain: scissor - // around the single terrain batch + UBO ungated (count 0 left as-is). - outdoorSlot = 0; - outdoorVisible = true; - terrainMode = TerrainClipMode.Scissor; - terrainScissor = ov.ScissorNdcAabb; - scissorFallbacks++; - } - - // Stage 4: the doorway screen-space AABB (the OutsideView union bounds), available for - // BOTH Planes and Scissor modes — the sky/weather particle scissor + the conditional - // doorway Z-clear need it regardless of how the OutsideView reduced to a gate. - // TerrainScissorNdcAabb above is only valid in Scissor mode; the OutsideView CellView - // always tracks its Min/Max as polygons accumulate, so it is the single source here. - bool hasOutsideView = terrainMode != TerrainClipMode.Skip; - Vector4 outsideViewNdcAabb = (hasOutsideView && !pvFrame.OutsideView.IsEmpty) + Vector4 outsideViewNdcAabb = outdoorVisible ? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY, pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY) : Vector4.Zero; + Vector4 terrainScissor = terrainMode == TerrainClipMode.Scissor + ? outsideViewNdcAabb + : Vector4.Zero; return new ClipFrameAssembly { Frame = frame, CellIdToSlot = cellIdToSlot, + CellIdToViewSlots = cellIdToViewSlots, + CellIdToViewSlices = cellIdToViewSlices, + OutsideViewSlices = outsideViewSlices, OutdoorSlot = outdoorSlot, OutdoorVisible = outdoorVisible, TerrainMode = terrainMode, TerrainScissorNdcAabb = terrainScissor, - HasOutsideView = hasOutsideView, + HasOutsideView = outdoorVisible, OutsideViewNdcAabb = outsideViewNdcAabb, - OutsidePlaneCount = ov.Count, + OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0, PerCellPlaneCounts = perCellPlaneCounts, ScissorFallbacks = scissorFallbacks, }; } - // Copy a ClipPlaneSet's planes into a heap array for SetTerrainClip's span - // parameter (the set exposes IReadOnlyList, not a contiguous span). + private static CellView ViewOf(ViewPolygon poly) + { + var view = new CellView(); + view.Add(poly); + return view; + } + + private static Vector4 AabbOf(ViewPolygon poly) => + new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY); + + private static int[] ToSlots(ClipViewSlice[] slices) + { + var slots = new int[slices.Length]; + for (int i = 0; i < slices.Length; i++) + slots[i] = slices[i].Slot; + return slots; + } + private static Vector4[] ToPlaneSpan(ClipPlaneSet set) { int n = set.Count; var planes = new Vector4[n]; - for (int i = 0; i < n; i++) planes[i] = set.Planes[i]; + for (int i = 0; i < n; i++) + planes[i] = set.Planes[i]; return planes; } } diff --git a/src/AcDream.App/Rendering/ClipPlaneSet.cs b/src/AcDream.App/Rendering/ClipPlaneSet.cs index a4824eb7..47d4e2f6 100644 --- a/src/AcDream.App/Rendering/ClipPlaneSet.cs +++ b/src/AcDream.App/Rendering/ClipPlaneSet.cs @@ -66,7 +66,6 @@ public readonly struct ClipPlaneSet // or point — zero screen coverage ⇒ nothing visible. A real portal opening has area far // above this (e.g. the sliver-clip test region is 0.4); only an edge-on projection gets here. private const float MinPolygonArea = 1e-7f; - private readonly Vector4[] _planes; private ClipPlaneSet(Vector4[] planes, bool useScissorFallback, bool isNothingVisible, Vector4 scissorNdcAabb) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 93f1e58f..99527c81 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -170,6 +170,7 @@ public sealed class GameWindow : IDisposable // _interiorRenderer is constructed once both renderers exist; _interiorPartition is rebuilt // each frame on an indoor root (null on the outdoor root). private AcDream.App.Rendering.InteriorRenderer? _interiorRenderer; + private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition; // Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain @@ -180,6 +181,14 @@ public sealed class GameWindow : IDisposable // three renderers so each re-binds binding=2 immediately before its own draw. // U.4 replaces the NoClip() frame with one built from the portal-visibility result. private ClipFrame? _clipFrame; + private readonly HashSet _outdoorRootNoCells = new(0); + private readonly HashSet _exteriorPortalLandblocks = new(); + private readonly List _exteriorPortalCandidateCells = new(); + private readonly HashSet _outdoorSceneParticleEntityIds = new(); + private readonly HashSet _visibleSceneParticleEntityIds = new(); + private string? _lastRenderSignature; + private int _renderSignatureFrame; + private int _renderSignatureStableFrames; /// /// Phase 6.4: per-entity animation playback state for entities whose @@ -1805,6 +1814,9 @@ public sealed class GameWindow : IDisposable // R1: the per-cell DrawInside flood. Both renderers exist here (just constructed). _interiorRenderer = new AcDream.App.Rendering.InteriorRenderer(_envCellRenderer!, _wbDrawDispatcher!); + _clipFrame ??= ClipFrame.NoClip(); + _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( + _gl, _clipFrame, _envCellRenderer!, _wbDrawDispatcher!); } // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) @@ -2793,6 +2805,7 @@ public sealed class GameWindow : IDisposable MeshRefs = meshRefs, PaletteOverride = paletteOverride, PartOverrides = entityPartOverrides, + ParentCellId = spawn.Position!.Value.LandblockId, }; var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( @@ -4437,6 +4450,7 @@ public sealed class GameWindow : IDisposable // for one frame before the residual decay kicks in on the next tick). System.Numerics.Vector3 preSnapPos = entity.Position; entity.SetPosition(worldPos); + entity.ParentCellId = p.LandblockId; entity.Rotation = rot; // Commit B 2026-04-29 — keep the shadow registry in sync with @@ -4852,6 +4866,7 @@ public sealed class GameWindow : IDisposable // 3. Snap player entity + controller. entity.SetPosition(snappedPos); + entity.ParentCellId = resolved.CellId; entity.Rotation = rot; _playerController.SetPosition(snappedPos, resolved.CellId); @@ -5008,6 +5023,9 @@ public sealed class GameWindow : IDisposable return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu); } + private static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) + => entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id; + private static System.Numerics.Vector3 SkyPesAnchor( AcDream.Core.World.SkyObjectData obj, System.Numerics.Vector3 cameraWorldPos) @@ -6702,6 +6720,7 @@ public sealed class GameWindow : IDisposable if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) { pe.SetPosition(result.RenderPosition); // A.5 T18: SetPosition propagates AabbDirty + pe.ParentCellId = result.CellId; pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f); @@ -7175,20 +7194,20 @@ public sealed class GameWindow : IDisposable && AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera) ? _retailChaseCamera.ViewerCellId : (playerRoot?.CellId ?? 0u); - var viewerEyePos = camPos; // the collided eye drives the side-test AND the projection + var viewerEyePos = camPos; // the collided eye drives the projection + var playerViewPos = _playerController?.RenderPosition + ?? _playerController?.Position + ?? camPos; LoadedCell? viewerRoot = null; - if ((viewerCellId & 0xFFFFu) >= 0x0100u - && _cellVisibility.TryGetCell(viewerCellId, out var viewerRegCell)) + if (viewerCellId != 0u && _cellVisibility.TryGetCell(viewerCellId, out var viewerRegCell)) viewerRoot = viewerRegCell; var visibility = _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos); bool cameraInsideCell = visibility?.CameraCell is not null; - // Stage 3 (2026-06-02): the RENDER's seen_outside (gates terrain/sky through the - // doorway) comes from the VIEWER root cell. Retail CellManager::ChangePosition - // @ 0x004559B0 (pseudo_c:94649): keep landscape+terrain iff seen_outside else release. - // Outdoor viewer (viewerRoot==null) → always seen_outside=true. - // Building interior with exit portal → seen_outside=true (terrain clipped to the door). - // Pure dungeon (no exit portal reachable) → seen_outside=false (sky suppressed). + // Retail render routing is owned by the collided camera/viewer cell. + // The player cell still owns lighting state, but it must not force an + // indoor draw while the camera is outside; that drops the outdoor pass + // and leaves clear color around a floating doorway slice. bool rootSeenOutside = viewerRoot?.SeenOutside ?? true; // Phase U.4 (2026-05-30): the [vis] probe moved DOWN to the unified @@ -7262,17 +7281,18 @@ public sealed class GameWindow : IDisposable int renderCenterLbX = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f); int renderCenterLbY = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f); - // Phase A8: prepare EnvCellRenderer's per-frame visibility snapshot. - // Always called — cheap when no cells loaded, cheap when frustum culls all. + // Phase A8: update EnvCellRenderer's frustum. The per-frame shell snapshot + // is prepared after the portal-visible cell filter is known. var envCellViewProj = camera.View * camera.Projection; _envCellFrustum?.Update(envCellViewProj); - _envCellRenderer?.PrepareRenderBatches( - envCellViewProj, - camPos, - filter: null, - centerLbX: renderCenterLbX, - centerLbY: renderCenterLbY, - renderRadius: _nearRadius); + + HashSet? animatedIds = null; + if (_animatedEntities.Count > 0) + { + animatedIds = new HashSet(_animatedEntities.Count); + foreach (var k in _animatedEntities.Keys) + animatedIds.Add(k); + } // Phase G.1: sky renderer — draws the far-plane-infinity // celestial meshes FIRST so the rest of the scene z-tests @@ -7291,7 +7311,7 @@ public sealed class GameWindow : IDisposable // Building interior (cameraInsideCell=true, rootSeenOutside=true): render sky — clipped // to the doorway via the OutsideView (Stage 4, below). // Sealed dungeon (cameraInsideCell=true, rootSeenOutside=false): no sky. - bool renderSky = !cameraInsideCell || rootSeenOutside; + bool renderSky = viewerRoot is null || rootSeenOutside; // Phase W Stage 4 (2026-06-02): the sky/weather DRAW moved DOWN to its retail LScape // position — AFTER the portal-visibility ClipFrame is assembled — so it can be clipped to // the doorway (OutsideView) by sky.vert's gl_ClipDistance. See the "[Stage 4] sky @@ -7319,94 +7339,50 @@ public sealed class GameWindow : IDisposable // (mesh SSBO) + binding=2 (terrain UBO); each renderer re-binds its // binding=2 defensively from the ids we hand it. _clipFrame ??= ClipFrame.NoClip(); - // Retail RenderNormalMode (0x453aa0:92665) branches inside/outside on is_player_outside - // — the PLAYER's cell (0x451e80), NOT the camera cell — then roots DrawInside at the - // VIEWER cell (this->viewer_cell) when inside. The 3rd-person chase camera LAGS the - // player, so keying the branch off the camera (the old `visibility?.CameraCell`) made - // the camera lingering in a doorway AFTER the player had stepped outside take the - // DrawInside path rooted at the threshold cell, where the exit-portal flood degenerates - // → terrain Skipped + sparse shells → grey world with only entities showing through. - // Branch on the player; keep the viewer cell as the indoor root (handoff invariant). uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; - var clipRoot = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor( - playerCellId, visibility?.CameraCell is not null) - ? visibility!.CameraCell - : null; + bool playerIndoorGate = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor( + playerCellId, + playerRoot is not null); + var clipRoot = playerIndoorGate && viewerRoot is not null ? viewerRoot : null; + string renderBranch = clipRoot is null + ? "OutdoorRoot" + : "RetailPViewInside"; ClipFrameAssembly? clipAssembly = null; PortalVisibilityFrame? pvFrame = null; // R1: hoisted so the binary decision below reads OrderedVisibleCells var terrainClipMode = TerrainClipMode.Planes; // overwritten below for indoor root - System.Numerics.Vector4 terrainScissorNdc = default; HashSet? envCellShellFilter = null; // drawable visible cells (cellIdToSlot keys) + PortalVisibilityFrame? sigPvFrame = null; + ClipFrameAssembly? sigClipAssembly = null; + IReadOnlySet? sigDrawableCells = null; + AcDream.App.Rendering.InteriorEntityPartition.Result? sigPartition = null; + PortalVisibilityFrame? sigExteriorPvFrame = null; + ClipFrameAssembly? sigExteriorClipAssembly = null; + IReadOnlySet? sigExteriorDrawableCells = null; + AcDream.App.Rendering.InteriorEntityPartition.Result? sigExteriorPartition = null; + bool sigTerrainDrawn = false; + bool sigSkyDrawn = false; + bool sigDepthClear = false; + bool sigOutdoorPortalDrawn = false; + bool sigOutdoorSceneryDrawn = false; + int sigOutdoorRootObjectCount = 0; + int sigLiveDynamicDrawnCount = 0; + string sigSceneParticles = "none"; + _outdoorSceneParticleEntityIds.Clear(); + _visibleSceneParticleEntityIds.Clear(); + // Retail entry ownership: GameWindow never builds a second indoor PView product. + // Outdoor frames begin no-clip; indoor frames skip the global landscape block and let + // RetailPViewRenderer.DrawInside own ConstructView -> DrawCells. + _clipFrame.Reset(); + _wbDrawDispatcher?.ClearClipRouting(); + _envCellRenderer?.SetClipRouting(null); + _interiorPartition = null; if (clipRoot is not null) { - // Phase W single-viewpoint V1 (2026-06-03): the portal side test + distance ordering - // use the VIEWER eye (the collided camera) — same viewpoint as the projection - // (envCellViewProj) and the render root (clipRoot = the viewer cell). ONE viewpoint, - // retail InitCell side-test vs viewer.viewpoint (pc:432991). No more player/eye split. - pvFrame = PortalVisibilityBuilder.Build( - clipRoot, - viewerEyePos, - id => _cellVisibility.TryGetCell(id, out var c) ? c : null, - envCellViewProj); - - clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame); - terrainClipMode = clipAssembly.TerrainMode; - terrainScissorNdc = clipAssembly.TerrainScissorNdcAabb; - - // Per-instance routing for the entity dispatcher + the cell shells. - _wbDrawDispatcher?.SetClipRouting( - clipAssembly.CellIdToSlot, clipAssembly.OutdoorSlot, clipAssembly.OutdoorVisible); - _envCellRenderer?.SetClipRouting(clipAssembly.CellIdToSlot); - - // The cell SHELLS render only for drawable visible cells (the slot - // map's keys; IsNothingVisible cells were excluded by the assembler). - envCellShellFilter = new HashSet(clipAssembly.CellIdToSlot.Keys); - - // R1: partition this frame's entities into per-cell / outdoor / live-dynamic buckets - // for the DrawInside flood + the outdoor-scenery-through-door draw. Keyed by the SAME - // visible-cell set the shells use (cellIdToSlot.Keys). - _interiorPartition = AcDream.App.Rendering.InteriorEntityPartition.Partition( - envCellShellFilter, _worldState.LandblockEntries); - - // [vis] probe (ACDREAM_PROBE_VIS=1) — the real PortalVisibilityFrame - // numbers, replacing the old camera-state-only spike. Cell-change - // throttled inside EmitVis so launch.log stays readable under motion. - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) - AcDream.Core.Rendering.RenderingDiagnostics.EmitVis( - clipRoot.CellId, - pvFrame.OrderedVisibleCells, - pvFrame.OutsideView.Polygons.Count, - clipAssembly.OutsidePlaneCount, - clipAssembly.PerCellPlaneCounts, - clipAssembly.ScissorFallbacks); - - // Phase U.4c flap probe (ACDREAM_PROBE_FLAP) — paired with the builder's - // per-frame [flap] line. res = which FindCameraCell branch chose the root; - // eyeInRoot = is the EYE actually inside clipRoot's AABB (n ⇒ stale root via - // cache/grace, the leading flap hypothesis); terrain/outVisible = the frame's - // outcome (Skip/false ⇒ terrain+shells flapped off this frame). - if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) - { - var flapPlayer = _playerController?.Position ?? camPos; - bool eyeInRoot = CellVisibility.PointInCell(camPos, clipRoot); - uint flapPlayerCell = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; - Console.WriteLine( - $"[flap-cam] root=0x{clipRoot.CellId:X8} viewerCell=0x{viewerCellId:X8} playerCell=0x{flapPlayerCell:X8} " + - $"res={_cellVisibility.LastCameraCellResolution} " + - $"eyeInRoot={(eyeInRoot ? "Y" : "n")} eye=({camPos.X:F2},{camPos.Y:F2},{camPos.Z:F2}) " + - $"player=({flapPlayer.X:F2},{flapPlayer.Y:F2},{flapPlayer.Z:F2}) " + - $"terrain={clipAssembly.TerrainMode} outVisible={clipAssembly.OutdoorVisible}"); - } - } - else - { - // Outdoor root: no portal frame. Keep the frame no-clip and revert the - // renderers to U.3 behavior (every instance slot 0, nothing culled, - // terrain ungated). Reset so a prior indoor frame's slots don't leak. - _clipFrame.Reset(); - _wbDrawDispatcher?.ClearClipRouting(); - _envCellRenderer?.SetClipRouting(null); - _interiorPartition = null; // R1: no indoor flood on the outdoor root + clipAssembly = null; + pvFrame = null; + terrainClipMode = TerrainClipMode.Skip; + envCellShellFilter = null; + _interiorPartition = null; } _clipFrame.UploadShared(_gl); @@ -7414,207 +7390,235 @@ public sealed class GameWindow : IDisposable _envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo); _terrain?.SetClipUbo(_clipFrame.TerrainUbo); - // ── [Stage 4] sky pre-scene (LScape, drawn through the doorway) ───────────── - // Phase W Stage 4 (2026-06-02): the sky + (post-scene) weather are retail's LScape — - // "the outside seen through the exit portal." They draw clipped to the OutsideView via - // sky.vert's gl_ClipDistance against the SAME binding=2 TerrainClip UBO the terrain reads - // (just uploaded by UploadShared above). Retail PView::DrawCells (pseudo_c:432709) draws - // LScape first when outside_view.view_count > 0; RenderNormalMode (92649) gates it on - // seen_outside. drawSkyThisFrame = the seen_outside policy (renderSky) AND somewhere to - // draw it: outdoors (clipAssembly == null → full-screen) OR indoors with an exit portal in - // view (HasOutsideView). An interior with no exit portal in the current view draws no sky - // (no full-screen bleed). skyDoorwayClip drives the doorway scissor for the particle - // passes (particle.vert has no gl_ClipDistance) and the conditional Z-clear below. - bool skyDoorwayClip = clipAssembly is not null && clipAssembly.HasOutsideView; - bool drawSkyThisFrame = renderSky && (clipAssembly is null || clipAssembly.HasOutsideView); - System.Numerics.Vector4 skyDoorwayNdc = clipAssembly?.OutsideViewNdcAabb ?? default; - if (drawSkyThisFrame) + bool drawSkyThisFrame = false; + + if (clipRoot is null) { - // Scissor the WHOLE sky pre-scene block (mesh + particles) to the doorway AABB when - // indoors. The sky MESH is precisely clipped by sky.vert's gl_ClipDistance in PLANES - // mode (the scissor is then a harmless over-include — the planes are tighter); but in - // SCISSOR mode the OutsideView exceeded the convex-plane budget so the assembler left - // the binding=2 UBO at count 0 (no planes) — there the scissor is the ONLY confinement, - // exactly mirroring the terrain Scissor path. Without this, a multi-exit interior would - // bleed full-screen sky/rain (sky.vert with count 0 writes all +1 = no clip). The - // SkyPreScene particles (particle.vert, no gl_ClipDistance) rely on the scissor in BOTH - // modes. Outdoors (skyDoorwayClip=false) → no scissor → full-screen, bit-identical. - bool skySc = BeginDoorwayScissor(skyDoorwayClip, skyDoorwayNdc); + // ── Outdoor LScape entry ───────────────────────────────────────────────── + // Retail indoor frames do not pass through this block. If the player is indoors, + // PView::DrawInside owns landscape drawing through outside_view and the depth-only + // clear. This outdoor-only block is the LScape half of RenderNormalMode. + drawSkyThisFrame = renderSky; + sigSkyDrawn = drawSkyThisFrame; + if (drawSkyThisFrame) + { + _gl.BindBufferBase(BufferTargetARB.UniformBuffer, + ClipFrame.TerrainClipUboBinding, _clipFrame.TerrainUbo); + for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) + _gl.Enable(EnableCap.ClipDistance0 + _cp); + _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, + _activeDayGroup, kf, environOverrideActive); + for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) + _gl.Disable(EnableCap.ClipDistance0 + _cp); - // Sky MESH: re-bind binding=2 (the OutsideView UBO) defensively — SkyRenderer does not - // own it and we must not inherit whatever was last bound (memory: - // render-self-contained-gl-state) — then enable the 8 clip planes so sky.vert clips - // precisely in Planes mode (count>0). count==0 (outdoor / Scissor-mode) → all +1. - _gl.BindBufferBase(BufferTargetARB.UniformBuffer, - ClipFrame.TerrainClipUboBinding, _clipFrame.TerrainUbo); - for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - _gl.Enable(EnableCap.ClipDistance0 + _cp); - _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, - _activeDayGroup, kf, environOverrideActive); - for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - _gl.Disable(EnableCap.ClipDistance0 + _cp); + if (_particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); + } - // SkyPreScene particles (particle.vert, no gl_ClipDistance) — confined by the scissor. - if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(_particleSystem, camera, camPos, - AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); + // K-fix1 (2026-04-26): suppress terrain + entity rendering while live mode is configured + // but the chase camera hasn't engaged yet. The sky (above) still draws during login so the + // user sees a live, time-of-day-correct sky through the connection + EnterWorld handshake. + if (IsLiveModeWaitingForLogin) + goto SkipWorldGeometry; - if (skySc) _gl.Disable(EnableCap.ScissorTest); - } - - // K-fix1 (2026-04-26): suppress terrain + entity rendering while live mode is configured - // but the chase camera hasn't engaged yet. The sky (above) still draws during login so the - // user sees a live, time-of-day-correct sky through the connection + EnterWorld handshake; - // the world geometry below is skipped. (Phase W Stage 4: moved BELOW the sky draw — the sky - // now needs the assembled ClipFrame, which is harmless/no-clip pre-login.) - if (IsLiveModeWaitingForLogin) - goto SkipWorldGeometry; - - // Phase U.3: enable the 8 hardware clip planes for the world-geometry - // block ONLY. All gl_ClipDistance-writing draws (terrain, entities, and - // U.4's EnvCellRenderer.Render) MUST be inside this enable/disable - // bracket; everything else (particles, weather, debug, UI) renders with - // clip DISABLED. The sky/weather drew/draws above + below in their OWN - // local clip brackets (sky.vert now writes gl_ClipDistance); the - // particles/weather-particles/debug/UI draw with clip OFF. Scoping - // the enable here (instead of a permanent init-time enable) avoids the - // undefined behavior of leaving GL_CLIP_DISTANCE_i on for shaders that - // never write gl_ClipDistance[i] — a driver is free to clip those away. - // (EnableCap.ClipDistance0 == GL_CLIP_DISTANCE0 0x3000; +i selects plane i.) - for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) - _gl.Enable(EnableCap.ClipDistance0 + _cp); - - // Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup - // (gated on ACDREAM_WB_DIAG=1, same env var as [WB-DIAG]). Stopwatch - // is cheap; only the periodic Console.WriteLine is gated. - // - // Phase U.4 OutsideView gating (indoor root only; outdoor root uses - // TerrainClipMode.Planes with a count-0 UBO = ungated, the U.3 path): - // Skip ⇒ the camera sees no outdoors through any portal chain → - // draw NO terrain. THIS is the bleed fix (empty OutsideView - // ⇒ outdoor terrain stops leaking into interiors). - // Scissor ⇒ OutsideView exceeded the convex-plane budget → glScissor - // around ONLY the terrain draw (NDC AABB → framebuffer px), - // UBO left ungated. Disabled again immediately after so the - // rest of the frame is unscissored. - // Planes ⇒ UBO carries the OutsideView planes (already set by the - // assembler) → terrain gated per-vertex, draw normally. - _terrainCpuStopwatch.Restart(); - if (terrainClipMode == TerrainClipMode.Skip) - { - // No terrain this frame — bleed fix. - } - else if (terrainClipMode == TerrainClipMode.Scissor) - { - var fb = _window!.FramebufferSize; - // NDC [-1,1] → window pixels. Clamp to the framebuffer so a portal - // opening that extends past the screen edge yields a valid box. - float nx0 = System.Math.Clamp(terrainScissorNdc.X, -1f, 1f); - float ny0 = System.Math.Clamp(terrainScissorNdc.Y, -1f, 1f); - float nx1 = System.Math.Clamp(terrainScissorNdc.Z, -1f, 1f); - float ny1 = System.Math.Clamp(terrainScissorNdc.W, -1f, 1f); - int px = (int)System.MathF.Floor((nx0 * 0.5f + 0.5f) * fb.X); - int py = (int)System.MathF.Floor((ny0 * 0.5f + 0.5f) * fb.Y); - int pw = (int)System.MathF.Ceiling((nx1 - nx0) * 0.5f * fb.X); - int ph = (int)System.MathF.Ceiling((ny1 - ny0) * 0.5f * fb.Y); - _gl.Enable(EnableCap.ScissorTest); - _gl.Scissor(px, py, (uint)System.Math.Max(0, pw), (uint)System.Math.Max(0, ph)); + // Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup. + EnableClipDistances(); + _terrainCpuStopwatch.Restart(); + sigTerrainDrawn = true; _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); - _gl.Disable(EnableCap.ScissorTest); - } - else - { - _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); - } - _terrainCpuStopwatch.Stop(); - // Multiply by 100 then divide by 100 in the diag print to keep - // 0.01 µs precision in the long-typed sample buffer. Terrain Draw - // is sub-microsecond on simple scenes; truncating to integer µs - // would round nearly every sample to 0. - _terrainCpuSamples[_terrainCpuSampleCursor] = (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0); - _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length; - MaybeFlushTerrainDiag(); + _terrainCpuStopwatch.Stop(); + _terrainCpuSamples[_terrainCpuSampleCursor] = + (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0); + _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length; + MaybeFlushTerrainDiag(); - // L-fix1 (2026-04-28): pass the set of animated-entity ids so - // the renderer keeps remote players / NPCs / monsters - // visible even when their landblock rotates out of the - // frustum. Without this, other characters wink in/out as - // the camera turns. The set is rebuilt per-frame from - // _animatedEntities — it's small (<100 entities typically) - // so HashSet allocation is cheap. Static scenery still - // respects landblock-level cull. - HashSet? animatedIds = null; - if (_animatedEntities.Count > 0) - { - animatedIds = new HashSet(_animatedEntities.Count); - foreach (var k in _animatedEntities.Keys) - animatedIds.Add(k); - } - - // R1: outdoor scenery (ParentCellId == null) is part of the landscape seen through the - // doorway (retail LScape::draw draws the exterior, clipped to OutsideView). Drawn here — - // after terrain, BEFORE the Z-clear — only on an indoor root, scoped to the outdoor bucket. - // ResolveEntitySlot routes these (ParentCellId == null) to OutdoorSlot when OutdoorVisible, - // else CULLs them, via the SetClipRouting installed above. visibleCellIds: null ⇒ they pass - // the membership gate (no cell filter) and are gated purely by the clip slot. - if (clipAssembly is not null && _interiorPartition is not null - && _interiorPartition.Outdoor.Count > 0 && clipAssembly.OutdoorVisible) - { - var sceneryEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero, - (IReadOnlyList)_interiorPartition.Outdoor, - (IReadOnlyDictionary?)null); - _wbDrawDispatcher!.Draw(camera, new[] { sceneryEntry }, frustum, - neverCullLandblockId: playerLb, visibleCellIds: null, animatedEntityIds: animatedIds); - } - - // ── [Stage 4] conditional doorway Z-clear ─────────────────────────────────── - // Retail PView::DrawCells @ pseudo_c:432731: after the landscape (sky + terrain) is drawn - // through the exit portal, RenderDevice->Clear(flag 4 = Z-BUFFER ONLY, NOT color) resets - // depth so the indoor walls / entities draw cleanly on top without z-fighting at the portal - // plane. Depth ONLY — never color — so there is NO blue clear-color hole: the sky / terrain - // color already written through the doorway stays, and the opaque cell shells overpaint the - // doorway-bbox corners. Scissored to the OutsideView AABB so only the doorway region's depth - // is cleared. Fires only for an indoor root with an exit portal in view (skyDoorwayClip). - if (skyDoorwayClip) - { - bool _zc = BeginDoorwayScissor(true, skyDoorwayNdc); - _gl.Clear(ClearBufferMask.DepthBufferBit); - if (_zc) _gl.Disable(EnableCap.ScissorTest); } // R1 — the binary render decision (retail RenderNormalMode @ 0x453aa0): // INDOOR root (clipRoot != null): run ONLY the per-cell DrawInside flood. The global // entity pass + global shell pass are NOT issued — visibility IS the cull, so the // outdoor world cannot bleed (it is never iterated; outdoor scenery entered above, - // clipped to the doorway). DrawInside draws per-cell shells (opaque + transparent) + - // per-cell objects + live-dynamics, closest-first over the drawable visible cells. - // OUTDOOR root: the existing global entity pass (no shells, no DrawInside). - if (clipRoot is not null && _interiorRenderer is not null - && _interiorPartition is not null && envCellShellFilter is not null) + // clipped to the doorway). DrawInside follows retail DrawCells order: reverse + // cell_draw_list shell stage, then reverse object-list stage, per portal_view slice. + // OUTDOOR root: draw the landscape/outdoor bucket first, then seed a reciprocal + // portal frame from exterior-facing cell portals so peering through an open door + // draws the indoor SHELL + its statics together. The old global pass drew indoor + // statics without the EnvCell shells, which made walls look transparent from outside. + if (clipRoot is not null) { - var interiorCtx = new AcDream.App.Rendering.InteriorRenderContext + if (_retailPViewRenderer is null) + throw new InvalidOperationException("Retail PView renderer is required for indoor frames."); + + var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext { - OrderedVisibleCells = pvFrame!.OrderedVisibleCells, - DrawableCells = envCellShellFilter, - Partition = _interiorPartition, + RootCell = clipRoot, + ViewerEyePos = viewerEyePos, + ViewProjection = envCellViewProj, + CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, Camera = camera, + CameraWorldPosition = camPos, Frustum = frustum, PlayerLandblockId = playerLb, AnimatedEntityIds = animatedIds, - }; - _interiorRenderer.DrawInside(interiorCtx); + RenderCenterLbX = renderCenterLbX, + RenderCenterLbY = renderCenterLbY, + RenderRadius = _nearRadius, + LandblockEntries = _worldState.LandblockEntries, + SetTerrainClipUbo = uboId => _terrain?.SetClipUbo(uboId), + DrawLandscapeSlice = sliceCtx => + DrawRetailPViewLandscapeSlice( + sliceCtx, + camera, + frustum, + camPos, + playerLb, + animatedIds, + renderSky, + kf, + environOverrideActive), + ClearDepthSlice = slice => + { + bool zc = BeginDoorwayScissor(true, slice.NdcAabb); + _gl.Clear(ClearBufferMask.DepthBufferBit); + if (zc) + _gl.Disable(EnableCap.ScissorTest); + }, + DrawCellParticles = sliceCtx => + DrawRetailPViewCellParticles(sliceCtx, camera, camPos), + EmitDiagnostics = result => + EmitRetailPViewDiagnostics( + result, + clipRoot, + viewerCellId, + playerCellId, + camPos, + playerViewPos), + }); + pvFrame = pviewResult.PortalFrame; + clipAssembly = pviewResult.ClipAssembly; + envCellShellFilter = pviewResult.DrawableCells; + _interiorPartition = pviewResult.Partition; + sigPvFrame = pviewResult.PortalFrame; + sigClipAssembly = pviewResult.ClipAssembly; + sigDrawableCells = pviewResult.DrawableCells; + sigPartition = pviewResult.Partition; + sigTerrainDrawn = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; + sigSkyDrawn = renderSky && pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; + sigDepthClear = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; + sigSceneParticles = (pviewResult.Partition.ByCell.Count > 0 + || pviewResult.ClipAssembly.OutsideViewSlices.Length > 0) + ? "pviewScoped" + : sigSceneParticles; + sigOutdoorSceneryDrawn = pviewResult.Partition.Outdoor.Count > 0 + && pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; } else { - // Outdoor root: draw the full outdoor world. No cell filter — outdoors there is no - // portal-cell scoping (ClearClipRouting made every instance slot 0). R1 retires - // visibility.VisibleCellIds as a render gate (peering into buildings is R5, a - // separate pass). On the outdoor root visibility is null anyway, so this is the - // same set the old code passed; null makes that explicit + gate-change-safe. - _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, - neverCullLandblockId: playerLb, - visibleCellIds: null, - animatedEntityIds: animatedIds); + bool liveDynamicsDrawn = false; + + if (_interiorRenderer is not null) + { + _outdoorRootNoCells.Clear(); + var outdoorPartition = AcDream.App.Rendering.InteriorEntityPartition.Partition( + _outdoorRootNoCells, _worldState.LandblockEntries); + sigOutdoorRootObjectCount = outdoorPartition.Outdoor.Count; + + if (outdoorPartition.Outdoor.Count > 0) + { + _interiorRenderer.DrawEntityBucket( + camera, + frustum, + playerLb, + animatedIds, + outdoorPartition.Outdoor, + visibleCellIds: null); + } + + _exteriorPortalLandblocks.Clear(); + _exteriorPortalCandidateCells.Clear(); + // FPS (2026-06-07): the outdoor look-in (DrawPortal -> BuildFromExterior) seeds only + // from exit portals within MaxSeedDistance (48 m) of the camera. A landblock is 192 m, + // so any cell that could seed is in the player's landblock or an immediate neighbour; + // cells further out are already discarded by BuildFromExterior's per-portal cutoff. + // Iterating EVERY cell in EVERY loaded landblock (near radius 4 = up to 81 LBs) just to + // discard them is an O(all loaded cells) sweep every outdoor frame — the cause of the + // "FPS drops as soon as I look out" report. Restrict candidates to the 1-ring around the + // player (Chebyshev <= 1 in landblock grid). No behaviour change: the excluded cells are + // all > 48 m away and were already culled by the seed-distance cutoff. + int playerGridX = playerLb.HasValue ? (int)((playerLb.Value >> 24) & 0xFFu) : -1; + int playerGridY = playerLb.HasValue ? (int)((playerLb.Value >> 16) & 0xFFu) : -1; + foreach (var entry in _worldState.LandblockEntries) + { + uint lbPrefix = (entry.LandblockId >> 16) & 0xFFFFu; + if (playerLb.HasValue) + { + int gX = (int)((lbPrefix >> 8) & 0xFFu); + int gY = (int)(lbPrefix & 0xFFu); + if (Math.Max(Math.Abs(gX - playerGridX), Math.Abs(gY - playerGridY)) > 1) + continue; + } + if (!_exteriorPortalLandblocks.Add(lbPrefix)) + continue; + + foreach (var cell in _cellVisibility.GetCellsForLandblock(lbPrefix)) + _exteriorPortalCandidateCells.Add(cell); + } + + if (_exteriorPortalCandidateCells.Count > 0 && _retailPViewRenderer is not null) + { + var portalResult = _retailPViewRenderer.DrawPortal( + new AcDream.App.Rendering.RetailPViewPortalDrawContext + { + CandidateCells = _exteriorPortalCandidateCells, + ViewerEyePos = viewerEyePos, + ViewProjection = envCellViewProj, + CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, + Camera = camera, + CameraWorldPosition = camPos, + Frustum = frustum, + PlayerLandblockId = playerLb, + AnimatedEntityIds = animatedIds, + RenderCenterLbX = renderCenterLbX, + RenderCenterLbY = renderCenterLbY, + RenderRadius = _nearRadius, + MaxSeedDistance = 48f, + LandblockEntries = _worldState.LandblockEntries, + SetTerrainClipUbo = uboId => _terrain?.SetClipUbo(uboId), + }); + + if (portalResult is not null) + { + sigOutdoorPortalDrawn = true; + sigExteriorPvFrame = portalResult.PortalFrame; + sigExteriorClipAssembly = portalResult.ClipAssembly; + sigExteriorDrawableCells = portalResult.DrawableCells; + sigExteriorPartition = portalResult.Partition; + liveDynamicsDrawn = portalResult.Partition.LiveDynamic.Count > 0; + if (liveDynamicsDrawn) + sigLiveDynamicDrawnCount = portalResult.Partition.LiveDynamic.Count; + } + } + + if (!liveDynamicsDrawn && outdoorPartition.LiveDynamic.Count > 0) + { + sigLiveDynamicDrawnCount = outdoorPartition.LiveDynamic.Count; + _interiorRenderer.DrawEntityBucket( + camera, + frustum, + playerLb, + animatedIds, + outdoorPartition.LiveDynamic, + visibleCellIds: null); + } + } + else + { + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: null, + animatedEntityIds: animatedIds); + } } // Phase U.3: close the world-geometry clip bracket opened above. From here down the @@ -7629,33 +7633,37 @@ public sealed class GameWindow : IDisposable // scene geometry so alpha blending composites correctly. // Runs with depth test on (particles occluded by walls) // but depth write off (no self-occlusion sorting needed). - if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(_particleSystem, camera, camPos, - AcDream.Core.Vfx.ParticleRenderPass.Scene); + if (clipRoot is null && _particleSystem is not null && _particleRenderer is not null) + { + if (clipAssembly is not null) + { + sigSceneParticles = sigSceneParticles == "none" ? "filtered" : sigSceneParticles + "+filtered"; + _particleRenderer.Draw( + _particleSystem, + camera, + camPos, + AcDream.Core.Vfx.ParticleRenderPass.Scene, + emitter => emitter.AttachedObjectId == 0 + || (!_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId) + && _visibleSceneParticleEntityIds.Contains(emitter.AttachedObjectId))); + } + else + { + sigSceneParticles = sigSceneParticles == "none" ? "global" : sigSceneParticles + "+global"; + _particleRenderer.Draw( + _particleSystem, + camera, + camPos, + AcDream.Core.Vfx.ParticleRenderPass.Scene); + } + } // Bug A fix (post-#26 worktree, 2026-04-26): weather sky - // meshes (Properties & 0x04, e.g. the 815m-tall rain - // cylinder 0x01004C42/0x01004C44) render AFTER the scene so - // the additive rain streaks overlay terrain and entities - // instead of being painted over by them. This is the second - // half of retail's LScape::draw split — GameSky::Draw(1) - // fires after the DrawBlock loop. Same indoor gate as the - // sky pass: weather follows the same drawSkyThisFrame gate (seen_outside policy AND an - // exit portal in view when indoors), and — Phase W Stage 4 — draws inside its OWN local - // clip bracket so sky.vert clips the rain cylinder to the doorway indoors (full-screen - // outdoors). Suppressed in sealed dungeons / interiors with no exit portal in view. - if (drawSkyThisFrame) + // Outdoor LScape post-scene weather. Indoor weather through an exit portal is + // drawn by RetailPViewRenderer.DrawInside via DrawRetailPViewLandscapeSlice. + if (clipRoot is null && drawSkyThisFrame) { - // Scissor the WHOLE weather post-scene block (rain mesh + particles) to the doorway - // AABB when indoors — symmetric with the sky pre-scene block. The rain cylinder MESH - // is precisely clipped by sky.vert in Planes mode (scissor a harmless over-include); - // in Scissor mode (UBO count 0, no planes) the scissor is the ONLY confinement — else - // the 815m rain cylinder bleeds full-screen indoors. Outdoors → no scissor → unchanged. - bool wxSc = BeginDoorwayScissor(skyDoorwayClip, skyDoorwayNdc); - - // Weather MESH (rain cylinder): re-bind binding=2 (the OutsideView UBO) defensively, - // enable the 8 clip planes around RenderWeather, disable after. count==0 outdoors ⇒ - // full-screen rain, unchanged. + sigSkyDrawn = true; _gl.BindBufferBase(BufferTargetARB.UniformBuffer, ClipFrame.TerrainClipUboBinding, _clipFrame.TerrainUbo); for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) @@ -7665,14 +7673,42 @@ public sealed class GameWindow : IDisposable for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) _gl.Disable(EnableCap.ClipDistance0 + _cp); - // SkyPostScene particles (particle.vert, no gl_ClipDistance) — confined by the scissor. if (_particleSystem is not null && _particleRenderer is not null) _particleRenderer.Draw(_particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); - - if (wxSc) _gl.Disable(EnableCap.ScissorTest); } + EmitRenderSignatureIfChanged( + renderBranch, + clipRoot, + viewerRoot, + playerRoot, + viewerCellId, + playerCellId, + playerIndoorGate, + cameraInsideCell, + renderSky, + drawSkyThisFrame, + sigTerrainDrawn, + terrainClipMode, + sigSkyDrawn, + sigDepthClear, + sigOutdoorSceneryDrawn, + sigOutdoorPortalDrawn, + sigOutdoorRootObjectCount, + sigLiveDynamicDrawnCount, + sigSceneParticles, + sigPvFrame, + sigClipAssembly, + sigDrawableCells, + sigPartition, + sigExteriorPvFrame, + sigExteriorClipAssembly, + sigExteriorDrawableCells, + sigExteriorPartition, + camPos, + playerViewPos); + // Debug: draw collision shapes as wireframe cylinders around the // player so we can visually verify alignment with scenery meshes. if (_debugCollisionVisible && _debugLines is not null) @@ -8176,6 +8212,8 @@ public sealed class GameWindow : IDisposable } ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty + if (rm.CellId != 0) + ae.Entity.ParentCellId = rm.CellId; ae.Entity.Rotation = rm.Body.Orientation; } else @@ -8504,6 +8542,8 @@ public sealed class GameWindow : IDisposable } ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty + if (rm.CellId != 0) + ae.Entity.ParentCellId = rm.CellId; ae.Entity.Rotation = rm.Body.Orientation; } } @@ -8993,6 +9033,367 @@ public sealed class GameWindow : IDisposable } } + private void EmitRenderSignatureIfChanged( + string branch, + LoadedCell? clipRoot, + LoadedCell? viewerRoot, + LoadedCell? playerRoot, + uint viewerCellId, + uint playerCellId, + bool playerIndoorGate, + bool cameraInsideCell, + bool renderSkyGate, + bool drawSkyThisFrame, + bool terrainDrawn, + TerrainClipMode terrainClipMode, + bool skyDrawn, + bool depthClear, + bool outdoorSceneryDrawn, + bool outdoorPortalDrawn, + int outdoorRootObjectCount, + int liveDynamicDrawnCount, + string sceneParticles, + PortalVisibilityFrame? pvFrame, + ClipFrameAssembly? clipAssembly, + IReadOnlySet? drawableCells, + AcDream.App.Rendering.InteriorEntityPartition.Result? partition, + PortalVisibilityFrame? exteriorPvFrame, + ClipFrameAssembly? exteriorClipAssembly, + IReadOnlySet? exteriorDrawableCells, + AcDream.App.Rendering.InteriorEntityPartition.Result? exteriorPartition, + System.Numerics.Vector3 camPos, + System.Numerics.Vector3 playerViewPos) + { + if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) + return; + + _renderSignatureFrame++; + + bool eyeInRoot = clipRoot is not null && CellVisibility.PointInCell(camPos, clipRoot); + bool playerInRoot = clipRoot is not null && CellVisibility.PointInCell(playerViewPos, clipRoot); + + var sb = new System.Text.StringBuilder(512); + sb.Append("branch=").Append(branch); + sb.Append(" root=0x").Append((clipRoot?.CellId ?? 0u).ToString("X8")); + sb.Append(" viewerRoot=0x").Append((viewerRoot?.CellId ?? 0u).ToString("X8")); + sb.Append(" playerRoot=0x").Append((playerRoot?.CellId ?? 0u).ToString("X8")); + sb.Append(" viewerCell=0x").Append(viewerCellId.ToString("X8")); + sb.Append(" playerCell=0x").Append(playerCellId.ToString("X8")); + sb.Append(" gate=").Append(playerIndoorGate ? "in" : "out"); + sb.Append(" camIn=").Append(cameraInsideCell ? 'Y' : 'n'); + sb.Append(" eyeInRoot=").Append(eyeInRoot ? 'Y' : 'n'); + sb.Append(" playerInRoot=").Append(playerInRoot ? 'Y' : 'n'); + sb.Append(" eye=").Append(FormatVecForSignature(camPos)); + sb.Append(" player=").Append(FormatVecForSignature(playerViewPos)); + sb.Append(" terrain=").Append(terrainClipMode); + sb.Append('/').Append(terrainDrawn ? "draw" : "skip"); + sb.Append(" skyGate=").Append(renderSkyGate ? 'Y' : 'n'); + sb.Append(" sky=").Append(skyDrawn ? 'Y' : 'n'); + sb.Append(" skyFrame=").Append(drawSkyThisFrame ? 'Y' : 'n'); + sb.Append(" zclear=").Append(depthClear ? 'Y' : 'n'); + sb.Append(" sceneParticles=").Append(sceneParticles); + + if (clipAssembly is not null) + { + sb.Append(" outSlices=").Append(clipAssembly.OutsideViewSlices.Length); + sb.Append(" outPolys=").Append(pvFrame?.OutsideView.Polygons.Count ?? 0); + sb.Append(" outMode=").Append(clipAssembly.TerrainMode); + } + else + { + sb.Append(" outSlices=0 outPolys=0 outMode=none"); + } + + sb.Append(" ids=").Append(FormatIds(pvFrame?.OrderedVisibleCells, preserveOrder: true)); + sb.Append(" draw=").Append(FormatIds(drawableCells, preserveOrder: false)); + sb.Append(" miss=").Append(FormatMissingDrawableCells(pvFrame, drawableCells)); + sb.Append(" obj=").Append(FormatPartitionCounts(partition)); + sb.Append(" outdoorDoor=").Append(outdoorSceneryDrawn ? 'Y' : 'n'); + sb.Append(" outdoorRootObjs=").Append(outdoorRootObjectCount); + sb.Append(" liveDynDraw=").Append(liveDynamicDrawnCount); + + if (outdoorPortalDrawn || exteriorPvFrame is not null || exteriorClipAssembly is not null) + { + sb.Append(" extPortal=").Append(outdoorPortalDrawn ? 'Y' : 'n'); + sb.Append(" extSlices=").Append(exteriorClipAssembly?.OutsideViewSlices.Length ?? 0); + sb.Append(" extIds=").Append(FormatIds(exteriorPvFrame?.OrderedVisibleCells, preserveOrder: true)); + sb.Append(" extDraw=").Append(FormatIds(exteriorDrawableCells, preserveOrder: false)); + sb.Append(" extMiss=").Append(FormatMissingDrawableCells(exteriorPvFrame, exteriorDrawableCells)); + sb.Append(" extObj=").Append(FormatPartitionCounts(exteriorPartition)); + } + + string signature = sb.ToString(); + if (signature == _lastRenderSignature) + { + _renderSignatureStableFrames++; + return; + } + + Console.WriteLine( + $"[render-sig] frame={_renderSignatureFrame} stable={_renderSignatureStableFrames} {signature}"); + _lastRenderSignature = signature; + _renderSignatureStableFrames = 0; + } + + private static string FormatVecForSignature(System.Numerics.Vector3 value) + { + static float Q(float v) => System.MathF.Round(v * 20f) / 20f; + return $"({Q(value.X):F2},{Q(value.Y):F2},{Q(value.Z):F2})"; + } + + private static string FormatIds(IEnumerable? ids, bool preserveOrder) + { + if (ids is null) + return "[]"; + + var values = new List(); + foreach (uint id in ids) + values.Add(id); + + if (!preserveOrder) + values.Sort(); + + var sb = new System.Text.StringBuilder(96); + sb.Append('['); + const int MaxIds = 12; + for (int i = 0; i < values.Count && i < MaxIds; i++) + { + if (i > 0) + sb.Append(','); + sb.Append("0x").Append(values[i].ToString("X8")); + } + if (values.Count > MaxIds) + sb.Append(",..."); + sb.Append(']'); + return sb.ToString(); + } + + private static string FormatMissingDrawableCells( + PortalVisibilityFrame? pvFrame, + IReadOnlySet? drawableCells) + { + if (pvFrame is null || drawableCells is null) + return "[]"; + + var sb = new System.Text.StringBuilder(96); + sb.Append('['); + int written = 0; + const int MaxCells = 8; + foreach (uint id in pvFrame.OrderedVisibleCells) + { + if (drawableCells.Contains(id)) + continue; + + if (written > 0) + sb.Append(','); + sb.Append("0x").Append(id.ToString("X8")); + if (pvFrame.CellViews.TryGetValue(id, out var view)) + { + sb.Append(":p").Append(view.Polygons.Count); + if (view.IsEmpty) + sb.Append(":empty"); + } + else + { + sb.Append(":noView"); + } + + written++; + if (written >= MaxCells) + { + sb.Append(",..."); + break; + } + } + + sb.Append(']'); + return sb.ToString(); + } + + private static string FormatPartitionCounts( + AcDream.App.Rendering.InteriorEntityPartition.Result? partition) + { + if (partition is null) + return "cell=[] out=0 live=0"; + + var keys = new List(partition.ByCell.Keys); + keys.Sort(); + + var sb = new System.Text.StringBuilder(128); + sb.Append("cell=["); + const int MaxCells = 10; + for (int i = 0; i < keys.Count && i < MaxCells; i++) + { + uint id = keys[i]; + if (i > 0) + sb.Append(','); + sb.Append("0x").Append(id.ToString("X8")).Append(':').Append(partition.ByCell[id].Count); + } + if (keys.Count > MaxCells) + sb.Append(",..."); + sb.Append("] out=").Append(partition.Outdoor.Count) + .Append(" live=").Append(partition.LiveDynamic.Count); + return sb.ToString(); + } + + private void DrawRetailPViewLandscapeSlice( + AcDream.App.Rendering.RetailPViewLandscapeSliceContext sliceCtx, + ICamera camera, + FrustumPlanes? frustum, + System.Numerics.Vector3 camPos, + uint? playerLb, + HashSet? animatedIds, + bool renderSky, + AcDream.Core.World.SkyKeyframe kf, + bool environOverrideActive) + { + var slice = sliceCtx.Slice; + bool scissor = BeginDoorwayScissor(true, slice.NdcAabb); + + _gl!.BindBufferBase(BufferTargetARB.UniformBuffer, + ClipFrame.TerrainClipUboBinding, _clipFrame!.TerrainUbo); + + 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(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); + + EnableClipDistances(); + _terrainCpuStopwatch.Restart(); + _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); + _terrainCpuStopwatch.Stop(); + _terrainCpuSamples[_terrainCpuSampleCursor] = + (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0); + _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length; + MaybeFlushTerrainDiag(); + + if (sliceCtx.OutdoorEntities.Count > 0) + { + var sceneryEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero, + sliceCtx.OutdoorEntities, + (IReadOnlyDictionary?)null); + _wbDrawDispatcher!.Draw(camera, new[] { sceneryEntry }, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: null, + animatedEntityIds: animatedIds); + } + + _outdoorSceneParticleEntityIds.Clear(); + foreach (var entity in sliceCtx.OutdoorEntities) + _outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity)); + + DisableClipDistances(); + if (_outdoorSceneParticleEntityIds.Count > 0 + && _particleSystem is not null + && _particleRenderer is not null) + { + _particleRenderer.Draw( + _particleSystem, + camera, + camPos, + AcDream.Core.Vfx.ParticleRenderPass.Scene, + emitter => emitter.AttachedObjectId != 0 + && _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); + } + + EnableClipDistances(); + if (renderSky) + { + _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, + _activeDayGroup, kf, environOverrideActive); + DisableClipDistances(); + if (_particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); + } + else + { + DisableClipDistances(); + } + + if (scissor) + _gl!.Disable(EnableCap.ScissorTest); + + DisableClipDistances(); + } + + 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; + + DisableClipDistances(); + bool scissor = BeginDoorwayScissor(true, sliceCtx.Slice.NdcAabb); + _particleRenderer.Draw( + _particleSystem, + camera, + camPos, + AcDream.Core.Vfx.ParticleRenderPass.Scene, + emitter => emitter.AttachedObjectId != 0 + && _visibleSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); + if (scissor) + _gl!.Disable(EnableCap.ScissorTest); + DisableClipDistances(); + } + + private void EmitRetailPViewDiagnostics( + AcDream.App.Rendering.RetailPViewFrameResult result, + LoadedCell clipRoot, + uint viewerCellId, + uint playerCellId, + System.Numerics.Vector3 camPos, + System.Numerics.Vector3 playerViewPos) + { + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) + AcDream.Core.Rendering.RenderingDiagnostics.EmitVis( + clipRoot.CellId, + result.PortalFrame.OrderedVisibleCells, + result.PortalFrame.OutsideView.Polygons.Count, + result.ClipAssembly.OutsidePlaneCount, + result.ClipAssembly.PerCellPlaneCounts, + result.ClipAssembly.ScissorFallbacks); + + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) + { + bool eyeInRoot = CellVisibility.PointInCell(camPos, clipRoot); + bool playerInRoot = CellVisibility.PointInCell(playerViewPos, clipRoot); + Console.WriteLine( + $"[flap-cam] root=0x{clipRoot.CellId:X8} viewerCell=0x{viewerCellId:X8} playerCell=0x{playerCellId:X8} " + + $"res={_cellVisibility.LastCameraCellResolution} " + + $"eyeInRoot={(eyeInRoot ? "Y" : "n")} playerInRoot={(playerInRoot ? "Y" : "n")} " + + $"eye=({camPos.X:F2},{camPos.Y:F2},{camPos.Z:F2}) " + + $"player=({playerViewPos.X:F2},{playerViewPos.Y:F2},{playerViewPos.Z:F2}) " + + $"terrain={result.ClipAssembly.TerrainMode} outVisible={result.ClipAssembly.OutdoorVisible}"); + } + } + + private void EnableClipDistances() + { + for (int i = 0; i < ClipFrame.MaxPlanes; i++) + _gl!.Enable(EnableCap.ClipDistance0 + i); + } + + private void DisableClipDistances() + { + for (int i = 0; i < ClipFrame.MaxPlanes; i++) + _gl!.Disable(EnableCap.ClipDistance0 + i); + } + // Phase 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). Mirrors the terrain Scissor-mode diff --git a/src/AcDream.App/Rendering/InteriorEntityPartition.cs b/src/AcDream.App/Rendering/InteriorEntityPartition.cs index 9d2ef05b..300abcd5 100644 --- a/src/AcDream.App/Rendering/InteriorEntityPartition.cs +++ b/src/AcDream.App/Rendering/InteriorEntityPartition.cs @@ -5,18 +5,10 @@ using AcDream.Core.World; namespace AcDream.App.Rendering; /// -/// Splits a frame's landblock entities into the three draw buckets the per-cell -/// needs, using the SAME precedence as -/// : -/// -/// ServerGuid != 0 (player / NPCs / items / doors) ⇒ -/// — drawn unclipped (depth only). These have no ParentCellId so they MUST be tested first. -/// ParentCellId in the visible set ⇒ [cell] — per-cell, portal-clipped. -/// ParentCellId == null (outdoor scenery / building shell) ⇒ -/// — drawn through the doorway, clipped to OutsideView. -/// -/// A static whose ParentCellId is NOT in is dropped (its cell -/// isn't drawn this frame). Entities with no MeshRefs are skipped. Pure; GL-free; unit-tested. +/// Splits a frame's landblock entities into the draw buckets used by the +/// retail-style DrawInside flood. Indoor ownership wins for live dynamics too: +/// a player, NPC, door, or item with a current indoor ParentCellId belongs to +/// that cell's portal-clipped object list, not a global overlay pass. /// public static class InteriorEntityPartition { @@ -40,18 +32,18 @@ public static class InteriorEntityPartition { if (e.MeshRefs.Count == 0) continue; - if (e.ServerGuid != 0) // live-dynamic — precedence first (no ParentCellId) + if (e.ServerGuid != 0) { - result.LiveDynamic.Add(e); + if (e.ParentCellId is uint liveCell) + AddByCellOrOutdoor(e, liveCell, visibleCells, result); + else + result.LiveDynamic.Add(e); } else if (e.ParentCellId is uint cell) { - if (!visibleCells.Contains(cell)) continue; // its cell isn't drawn this frame - if (!result.ByCell.TryGetValue(cell, out var list)) - result.ByCell[cell] = list = new List(); - list.Add(e); + AddByCellOrOutdoor(e, cell, visibleCells, result); } - else // outdoor scenery / building shell + else { result.Outdoor.Add(e); } @@ -59,4 +51,30 @@ public static class InteriorEntityPartition } return result; } + + private static void AddByCellOrOutdoor( + WorldEntity entity, + uint cellId, + HashSet visibleCells, + Result result) + { + if (!IsIndoorCellId(cellId)) + { + result.Outdoor.Add(entity); + return; + } + + if (!visibleCells.Contains(cellId)) + return; + + if (!result.ByCell.TryGetValue(cellId, out var list)) + result.ByCell[cellId] = list = new List(); + list.Add(entity); + } + + private static bool IsIndoorCellId(uint cellId) + { + uint low = cellId & 0xFFFFu; + return low >= 0x0100u && low != 0xFFFFu; + } } diff --git a/src/AcDream.App/Rendering/InteriorRenderer.cs b/src/AcDream.App/Rendering/InteriorRenderer.cs index 110f8986..5de0ffc6 100644 --- a/src/AcDream.App/Rendering/InteriorRenderer.cs +++ b/src/AcDream.App/Rendering/InteriorRenderer.cs @@ -17,6 +17,13 @@ public sealed class InteriorRenderContext /// membership filter; supplies the draw ORDER. public required IReadOnlySet DrawableCells { get; init; } + /// Per-cell portal_view slots, in the same order retail setup_view(cell, i) + /// selects them inside PView::DrawCells. + public required IReadOnlyDictionary CellClipSlots { get; init; } + + public required int OutdoorSlot { get; init; } + public required bool OutdoorVisible { get; init; } + /// The 3-bucket entity split (). Only ByCell + /// LiveDynamic are used here; Outdoor scenery is drawn by the caller's landscape-through-door /// step (clipped to OutsideView). @@ -34,12 +41,11 @@ public sealed class InteriorRenderContext } /// -/// The per-cell interior render flood — a faithful port of retail PView::DrawCells' per-cell loops -/// (decomp 0x5a4840). Iterates the visible cells closest-first; per cell draws the closed shell + -/// that cell's static objects (portal-clipped via the clip routing the caller installed), then the -/// live-dynamics unclipped, then the transparent shells. The landscape-through-door (sky/terrain/ -/// scenery) + the conditional Z-clear are the caller's responsibility, run BEFORE this. GL state is -/// self-contained inside each renderer (EnvCellRenderer / WbDrawDispatcher set their own). +/// The interior render flood, matching retail PView::DrawCells @ 0x005a4840: +/// after the caller handles outside_view terrain + the depth-only clear, DrawCells +/// walks cell_draw_list from the end back to zero in separate stages: cell shells, +/// then each cell's object_list. The transparent shell pass is split out because +/// the modern renderer batches opaque/transparent surfaces separately. /// public sealed class InteriorRenderer { @@ -48,7 +54,6 @@ public sealed class InteriorRenderer // Reused single-cell filter set — cleared + repopulated per cell to avoid per-frame allocs. private readonly HashSet _oneCell = new(1); - public InteriorRenderer(EnvCellRenderer envCells, WbDrawDispatcher entities) { _envCells = envCells; @@ -57,54 +62,103 @@ public sealed class InteriorRenderer public void DrawInside(InteriorRenderContext ctx) { - // Loop A — per-cell OPAQUE shell + that cell's static objects (closest-first). - foreach (uint cellId in ctx.OrderedVisibleCells) + // Retail Loop 2: DrawEnvCell for each drawable cell, farthest-to-nearest + // (cell_draw_list[cell_draw_num - 1] down to 0). + for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--) { - if (!ctx.DrawableCells.Contains(cellId)) continue; // no clip slot ⇒ assembler culled it + uint cellId = ctx.OrderedVisibleCells[i]; + if (!TryBeginCell(ctx, cellId, out _)) continue; _oneCell.Clear(); _oneCell.Add(cellId); + ApplyMembershipOnlyRouting(); _envCells.Render(WbRenderPass.Opaque, _oneCell); - - if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0) - DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell); } - // Live-dynamics (player / NPCs): unclipped (serverGuid != 0 → clip slot 0), depth-tested. - // Drawn AFTER opaque shells so wall depth occludes them correctly. - if (ctx.Partition.LiveDynamic.Count > 0) - DrawEntityBucket(ctx, ctx.Partition.LiveDynamic, visibleCellIds: null); - - // Loop B — per-cell TRANSPARENT shells (stained glass / additive cell surfaces). - foreach (uint cellId in ctx.OrderedVisibleCells) + // Retail Loop 3: Render::PortalList = cell->portal_view; DrawObjCellForDummies(cell). + for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--) { - if (!ctx.DrawableCells.Contains(cellId)) continue; + uint cellId = ctx.OrderedVisibleCells[i]; + if (!TryBeginCell(ctx, cellId, out _)) continue; _oneCell.Clear(); _oneCell.Add(cellId); + if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0) + { + ApplyMembershipOnlyRouting(); + DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell); + } + } + + // Modern split of DrawEnvCell's transparent/additive batches, same reverse cell order. + for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--) + { + uint cellId = ctx.OrderedVisibleCells[i]; + if (!TryBeginCell(ctx, cellId, out _)) continue; + _oneCell.Clear(); + _oneCell.Add(cellId); + ApplyMembershipOnlyRouting(); _envCells.Render(WbRenderPass.Transparent, _oneCell); } } + private bool TryBeginCell(InteriorRenderContext ctx, uint cellId, out int[] slots) + { + if (ctx.DrawableCells.Contains(cellId)) + { + ctx.CellClipSlots.TryGetValue(cellId, out slots!); + slots ??= System.Array.Empty(); + return true; + } + + slots = System.Array.Empty(); + return false; + } + + private void ApplyMembershipOnlyRouting() + { + // PView membership controls which cell shell/object bucket is visited. + // Do not turn the 2D portal view into gl_ClipDistance for indoor meshes: + // that slices avatars and shell triangles at stairs/doorways instead of + // matching retail's DrawMesh view-check-then-draw behavior. + _envCells.SetClipRouting(null); + _entities.ClearClipRouting(); + } + // Draws one bucket of entities via the existing dispatcher, scoped to a synthetic single-entry // landblock list. visibleCellIds gates which entities pass the cell-membership walk (a single-cell - // set for per-cell statics; null for live-dynamics — they pass the gate and resolve to slot 0). + // set for per-cell objects; null only for fallback/outdoor buckets where clip-slot routing owns cull). // The clip slot per entity comes from the SetClipRouting the caller installed (cellIdToSlot + // outdoorSlot + outdoorVisible) via ResolveEntitySlot. private void DrawEntityBucket( InteriorRenderContext ctx, IReadOnlyList bucket, HashSet? visibleCellIds) + => DrawEntityBucket( + ctx.Camera, + ctx.Frustum, + ctx.PlayerLandblockId, + ctx.AnimatedEntityIds, + bucket, + visibleCellIds); + + public void DrawEntityBucket( + ICamera camera, + FrustumPlanes? frustum, + uint? playerLandblockId, + HashSet? animatedEntityIds, + IReadOnlyList bucket, + HashSet? visibleCellIds) { // LandblockId == neverCullLandblockId (PlayerLandblockId) ⇒ the degenerate (zero) AABB is // never landblock-frustum-culled; per-entity AABB culling inside Draw still applies. - uint lbId = ctx.PlayerLandblockId ?? 0u; + uint lbId = playerLandblockId ?? 0u; var entry = (lbId, Vector3.Zero, Vector3.Zero, (IReadOnlyList)bucket, (IReadOnlyDictionary?)null); _entities.Draw( - ctx.Camera, + camera, new[] { entry }, - ctx.Frustum, - neverCullLandblockId: ctx.PlayerLandblockId, + frustum, + neverCullLandblockId: playerLandblockId, visibleCellIds: visibleCellIds, - animatedEntityIds: ctx.AnimatedEntityIds); + animatedEntityIds: animatedEntityIds); } } diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 61ef0bd5..e47fc338 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -120,7 +120,8 @@ public sealed unsafe class ParticleRenderer : IDisposable ParticleSystem particles, ICamera camera, Vector3 cameraWorldPos, - ParticleRenderPass renderPass = ParticleRenderPass.Scene) + ParticleRenderPass renderPass = ParticleRenderPass.Scene, + Func? emitterFilter = null) { if (particles is null || camera is null) return; @@ -128,7 +129,7 @@ public sealed unsafe class ParticleRenderer : IDisposable Matrix4x4.Invert(camera.View, out var invView); Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13)); Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23)); - var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp); + var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp, emitterFilter); if (draws.Count == 0) return; draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq)); @@ -174,13 +175,16 @@ public sealed unsafe class ParticleRenderer : IDisposable Vector3 cameraWorldPos, ParticleRenderPass renderPass, Vector3 cameraRight, - Vector3 cameraUp) + Vector3 cameraUp, + Func? emitterFilter) { var draws = new List(Math.Max(64, particles.ActiveParticleCount)); foreach (var (em, idx) in particles.EnumerateLive()) { if (em.RenderPass != renderPass) continue; + if (emitterFilter is not null && !emitterFilter(em)) + continue; ref var p = ref em.Particles[idx]; // `p.Position` is already in world coordinates: AttachLocal diff --git a/src/AcDream.App/Rendering/PortalProjection.cs b/src/AcDream.App/Rendering/PortalProjection.cs index 53f1c0d1..3250f3c2 100644 --- a/src/AcDream.App/Rendering/PortalProjection.cs +++ b/src/AcDream.App/Rendering/PortalProjection.cs @@ -70,6 +70,117 @@ public static class PortalProjection return ndc; } + /// Faithful homogeneous projection (retail PrimD3DRender::xformStart + the w=0 clip of + /// ACRender::polyClipFinish, decomp 424310 / 702749): transform the portal to clip space and clip + /// ONLY the eye plane (w >= ), keeping homogeneous coords — NO perspective + /// divide, NO frustum side-plane clamp. The screen bound is applied later by + /// against the view region (the root region is the full screen), exactly as retail clips the portal + /// against the accumulated portal_view rather than fixed side planes. Keeping w means a near/grazing + /// portal never collapses to a zero-area edge sliver (the flap) nor blows up under an early divide + /// (the void). Returns <3 verts when the portal is entirely behind the eye. + public static Vector4[] ProjectToClip(IReadOnlyList localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj) + { + if (localPoly == null || localPoly.Count < 3) return System.Array.Empty(); + + Matrix4x4 m = cellToWorld * viewProj; + var clip = new List(localPoly.Count); + foreach (var lp in localPoly) + clip.Add(Vector4.Transform(new Vector4(lp, 1f), m)); + + // Eye plane ONLY (w >= EyePlaneW), in clip space, homogeneous — no side planes, no divide. + // Retail's polyClipFinish clips at w = 0; EyePlaneW is a hair above 0 so the later divide in + // ClipToRegion never hits the w = 0 singularity. Everything in front of the eye is kept, + // including a portal the camera is standing in (it covers the screen) — the screen bound comes + // from ClipToRegion against the view region, not from a near plane here. + clip = ClipPlane(clip, v => v.W - EyePlaneW); + return clip.Count >= 3 ? clip.ToArray() : System.Array.Empty(); + } + + /// Clip a homogeneous (clip-space) portal polygon against an NDC view region + /// (CCW convex) with w-aware Sutherland-Hodgman edge tests, then divide the survivors to NDC and + /// normalize to CCW. Ports retail ACRender::polyClipFinish's view-region clip (decomp 702749): the + /// edge test multiplies through w (which is > 0 after the eye-plane clip) so it never divides a + /// near-eye vertex, and the final divide runs only on survivors already bounded to the region — + /// stable by construction. Returns <3 verts when the portal does not intersect the region. + public static Vector2[] ClipToRegion(IReadOnlyList subjectClip, IReadOnlyList regionCcwNdc) + { + if (subjectClip == null || regionCcwNdc == null || subjectClip.Count < 3 || regionCcwNdc.Count < 3) + return System.Array.Empty(); + + // Homogeneous Sutherland-Hodgman: clip the (w > 0) subject against each CCW edge of the NDC + // region. f(P) below is the NDC inside test cross(edge, P_ndc - a) multiplied through P.W, + // which is > 0 after the eye-plane clip — so the sign is the NDC sign yet no near-eye vertex + // is ever divided (retail polyClipFinish, decomp 702749). + var poly = new List(subjectClip); + int n = regionCcwNdc.Count; + for (int e = 0; e < n; e++) + { + if (poly.Count < 3) return System.Array.Empty(); + poly = ClipHomogeneousEdge(poly, regionCcwNdc[e], regionCcwNdc[(e + 1) % n]); + } + if (poly.Count < 3) return System.Array.Empty(); + + // Divide survivors → NDC. They are inside the region now, so |x| ≤ |w| and |y| ≤ |w|: the + // divide is bounded by construction (this is why the homogeneous clip avoids the early-divide + // blow-up). Normalize to CCW so the result is a valid clip region for the next portal hop. + var ndc = new Vector2[poly.Count]; + for (int i = 0; i < poly.Count; i++) + { + float w = poly[i].W; + ndc[i] = new Vector2(poly[i].X / w, poly[i].Y / w); + } + EnsureCcw(ndc); + return ndc; + } + + // One Sutherland-Hodgman half-plane against the directed NDC edge a→b, keeping the CCW-inside + // (left) part of a HOMOGENEOUS polygon. Inside test for vertex P (clip space): the NDC cross + // product cross(b-a, P/P.W - a) scaled by P.W (> 0): ex·(P.Y - P.W·a.Y) - ey·(P.X - P.W·a.X) ≥ 0. + // Crossings interpolate in homogeneous coords (perspective-correct), via the shared Lerp. + private static List ClipHomogeneousEdge(List poly, Vector2 a, Vector2 b) + { + var result = new List(poly.Count + 1); + float ex = b.X - a.X, ey = b.Y - a.Y; + for (int i = 0; i < poly.Count; i++) + { + Vector4 cur = poly[i]; + Vector4 prev = poly[(i + poly.Count - 1) % poly.Count]; + float dCur = ex * (cur.Y - cur.W * a.Y) - ey * (cur.X - cur.W * a.X); + float dPrev = ex * (prev.Y - prev.W * a.Y) - ey * (prev.X - prev.W * a.X); + bool curIn = dCur >= 0f; + bool prevIn = dPrev >= 0f; + + if (curIn) + { + if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur)); + result.Add(cur); + } + else if (prevIn) + { + result.Add(Lerp(prev, cur, dPrev, dCur)); + } + } + return result; + } + + // Reverse vertex order in place if wound clockwise (signed area < 0). Mirrors the builder's + // EnsureCcw so a clipped region is always CCW for the next hop's ClipToRegion edge test. + private static void EnsureCcw(Vector2[] poly) + { + float area2 = 0f; + for (int i = 0; i < poly.Length; i++) + { + var p = poly[i]; var q = poly[(i + 1) % poly.Length]; + area2 += p.X * q.Y - q.X * p.Y; + } + if (area2 < 0f) System.Array.Reverse(poly); + } + + // Eye plane for the homogeneous clip — a hair above retail's w = 0 so the post-region divide in + // ClipToRegion never divides by zero. Far closer than any near plane: a portal the eye is standing + // in is kept (it covers the screen), so the cell behind it stays visible. + private const float EyePlaneW = 1e-4f; + // Minimum clip-space w (≈ metres in front of the eye) to keep a vertex. Excludes the eye // (w=0) singularity and the ~5 cm right at it (bounding the perspective divide), but is // INTENTIONALLY far closer than the projection's 1.0 m near plane so a doorway the camera is diff --git a/src/AcDream.App/Rendering/PortalView.cs b/src/AcDream.App/Rendering/PortalView.cs index 80fa721d..b48a38f9 100644 --- a/src/AcDream.App/Rendering/PortalView.cs +++ b/src/AcDream.App/Rendering/PortalView.cs @@ -5,6 +5,7 @@ // a cell's clip region is a SET of convex polygons in normalized device coords. using System.Collections.Generic; using System.Numerics; +using System.Text; namespace AcDream.App.Rendering; @@ -40,6 +41,10 @@ public readonly struct ViewPolygon public sealed class CellView { public readonly List Polygons = new(); + + // Canonical (snapped) keys of the polygons in , backing the drift-tolerant + // dedup in . One entry per stored polygon; HashSet membership IS the dedup. + private readonly HashSet _polygonKeys = new(); public float MinX { get; private set; } = float.MaxValue; public float MinY { get; private set; } = float.MaxValue; public float MaxX { get; private set; } = float.MinValue; @@ -59,13 +64,82 @@ public sealed class CellView return v; } - public void Add(ViewPolygon p) + public bool Add(ViewPolygon p) { - if (p.IsEmpty) return; + if (p.IsEmpty) return false; + + // Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). PortalVisibilityBuilder.Build + // re-queues a cell every time its CellView GROWS, so the flood only terminates when Add + // recognises a re-clipped region as a duplicate. Across BFS rounds the SAME region returns + // float-drifted, vertex-rotated, and/or with a ±1 vertex count (homogeneous Sutherland-Hodgman + + // EnsureCcw); the old exact index-by-index match (eps 1e-4) caught none of those, so the region + // grew without bound -> O(n^2) CPU-spin hang in this method. We instead key each polygon by its + // vertices SNAPPED to a small NDC grid, consecutive snap-duplicates removed, rotated to a + // canonical start. The snapped key space is finite, so a monotonically-growing CellView is + // bounded and the flood is GUARANTEED to converge. The stored polygon keeps full precision (only + // the key is snapped), so downstream clip geometry is unchanged, and the grid (1e-3 NDC ~ sub- + // pixel) is far finer than the gap between genuinely distinct openings, so real regions never merge. + string? key = CanonicalKey(p.Vertices); + if (key is null) return false; // degenerate after snap (< 3 distinct vertices) + if (!_polygonKeys.Add(key)) return false; // duplicate region (drift / rotation / count tolerant) + Polygons.Add(p); if (p.MinX < MinX) MinX = p.MinX; if (p.MinY < MinY) MinY = p.MinY; if (p.MaxX > MaxX) MaxX = p.MaxX; if (p.MaxY > MaxY) MaxY = p.MaxY; + return true; + } + + // NDC dedup grid. 1e-3 is ~0.5 px at 1080p — finer than the gap between distinct portal openings + // (so real regions stay distinct) yet far coarser than the per-round float drift of a re-clipped + // region (so a drifted duplicate snaps onto its predecessor). The finite grid is what bounds growth. + private const float DedupGridNdc = 1e-3f; + + // Canonical key for a view polygon: vertices snapped to the NDC grid, consecutive snap-duplicates + // removed (including wrap-around), then rotated to start at the lexicographically smallest vertex so + // a rotated emission of the same cycle yields the same key. Returns null when fewer than 3 distinct + // snapped vertices survive (a degenerate sliver, not a real region). Winding is already CCW for every + // builder input (ClipToRegion / EnsureCcw), so the cyclic order is canonical without a reversal step. + private static string? CanonicalKey(Vector2[]? verts) + { + if (verts is null || verts.Length < 3) return null; + + var pts = new List<(int X, int Y)>(verts.Length); + foreach (var v in verts) + { + var q = ((int)System.MathF.Round(v.X / DedupGridNdc), (int)System.MathF.Round(v.Y / DedupGridNdc)); + if (pts.Count == 0 || pts[^1] != q) pts.Add(q); + } + if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1); + if (pts.Count < 3) return null; + + int n = pts.Count; + int best = 0; + for (int s = 1; s < n; s++) + if (RotationLess(pts, s, best, n)) best = s; + + var sb = new StringBuilder(n * 10); + for (int i = 0; i < n; i++) + { + var q = pts[(best + i) % n]; + sb.Append(q.X).Append(',').Append(q.Y).Append(';'); + } + return sb.ToString(); + } + + // True when the rotation of `pts` starting at index a is lexicographically less than the rotation + // starting at b (compare X then Y, vertex by vertex around the cycle). Gives a unique canonical + // start even when two vertices share the minimum snapped coordinate. + private static bool RotationLess(List<(int X, int Y)> pts, int a, int b, int n) + { + for (int i = 0; i < n; i++) + { + var pa = pts[(a + i) % n]; + var pb = pts[(b + i) % n]; + if (pa.X != pb.X) return pa.X < pb.X; + if (pa.Y != pb.Y) return pa.Y < pb.Y; + } + return false; } } diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index b1fac06b..461d1145 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -37,6 +37,19 @@ public static class PortalVisibilityBuilder { private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon + // Bounded re-enqueue cap (restored 2026-06-07). The distance-priority portal flood re-enqueues a + // cell whenever its accumulated view GROWS, which is load-bearing — it propagates a late-discovered + // portal_view slice to that cell's exit portals (Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit). + // But the faithful near-side clip (ProjectToClip) drifts per round, so re-clipping a cell's view yields + // ever-smaller distinct sub-regions / drifted near-duplicates the dedup can't always collapse -> the + // grow flag never settles and the flood spins forever (the indoor hang). This cap bounds each cell to + // at most this many pops, so the flood terminates in <= N*cap pops regardless of drift while still + // allowing the few re-processes that legitimate late-slice propagation needs. The old hard cap removed + // in U.2a was 4; widened here because ProjectToClip drifts more than the old ProjectToNdc and Option A's + // CellView dedup already collapses most spurious growth, so the cap rarely binds. Tune from the visual + // gate if an interior view under-includes a slice. + private const int MaxReprocessPerCell = 16; + // TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the // local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell. private static readonly bool s_pvDump = @@ -81,7 +94,11 @@ public static class PortalVisibilityBuilder // the instant a cell is popped). Enqueue-once across the cell set is the hard termination // guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The // camera cell is pre-marked so a portal looping back to it can never re-enqueue it. - var seen = new HashSet { cameraCell.CellId }; + var queued = new HashSet { cameraCell.CellId }; + var drawListed = new HashSet(); + var processedViewCounts = new Dictionary(); + var popCounts = new Dictionary(); // per-cell pop count for the MaxReprocessPerCell cap + var trace = PortalBuildTrace.Start(cameraCell, cameraPos); bool pvDump = false; if (s_pvDump) @@ -116,46 +133,81 @@ public static class PortalVisibilityBuilder while (todo.Count > 0) { var cell = todo.PopNearest(); + queued.Remove(cell.CellId); + // Bounded re-enqueue (2026-06-07 termination fix): count this pop. The re-enqueue gate below + // refuses to re-add a cell already popped MaxReprocessPerCell times, so the flood terminates + // even when ProjectToClip drift keeps a view growing forever. Re-enqueue itself is KEPT — it + // propagates late-discovered slices to exit portals (see MaxReprocessPerCell); only its count + // is capped. + popCounts.TryGetValue(cell.CellId, out int popsSoFar); + popCounts[cell.CellId] = popsSoFar + 1; if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty) + { + trace?.Add($"pop cell=0x{cell.CellId:X8} skip=no-view"); continue; + } // `seen` guarantees each cell is inserted into the todo list exactly once, so this single // pop IS the cell's closest-first draw position (retail appends to cell_draw_list once per // pop, 433783) — no per-pop dedup needed, OrderedVisibleCells stays distinct by construction. - frame.OrderedVisibleCells.Add(cell.CellId); + if (drawListed.Add(cell.CellId)) + frame.OrderedVisibleCells.Add(cell.CellId); + + processedViewCounts.TryGetValue(cell.CellId, out int processedCount); + int endCount = currentView.Polygons.Count; + if (processedCount >= endCount) + { + trace?.Add($"pop cell=0x{cell.CellId:X8} skip=processed processed={processedCount} views={endCount}"); + continue; + } + trace?.Add($"pop cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} drawPos={frame.OrderedVisibleCells.Count - 1}"); + + var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount); + processedViewCounts[cell.CellId] = endCount; for (int i = 0; i < cell.Portals.Count; i++) { - if (i >= cell.PortalPolygons.Count) continue; + var portal = cell.Portals[i]; + if (i >= cell.PortalPolygons.Count) + { + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=no-poly-slot"); + continue; + } var poly = cell.PortalPolygons[i]; - if (poly == null || poly.Length < 3) continue; + if (poly == null || poly.Length < 3) + { + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=degenerate-poly len={(poly?.Length ?? -1)}"); + continue; + } bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF; + bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos); + bool sideAllowed = true; // Portal-side test: only traverse a portal the camera is on the interior side of // (mirrors CellVisibility.GetVisibleCells + retail's 'seen' flag). Culls back-facing // portals so we never feed a degenerate/wrong-facing projection downstream. - if (i < cell.ClipPlanes.Count && !CameraOnInteriorSide(cell, i, cameraPos)) + if (i < cell.ClipPlanes.Count + && !CameraOnInteriorSide(cell, i, cameraPos) + && !eyeInsideOpening) { + sideAllowed = false; + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side eyeIn={eyeInsideOpening}"); if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}"); continue; } - // Project to NDC, then normalize to CCW for the CCW-only ScreenPolygonClip - // (ProjectToNdc preserves input winding; portal dat polygons may be CW). - Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj); - if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2}) ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F2},{v.Y:F2})"))}]"); - var clippedRegion = new List(); - if (portalNdc.Length >= 3) - { - EnsureCcw(portalNdc); - // Intersect the portal opening with every polygon of the current cell's view. - foreach (var vp in currentView.Polygons) - { - var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices); - if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped)); - } - } + // Retail PView::ClipPortals calls GetClip(..., finish=1): transform to + // homogeneous clip space, clip at the eye, then clip against the current + // portal_view region before the divide. Do the same here; the old early + // ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways. + var clippedRegion = ClipPortalAgainstView( + poly, + cell.WorldTransform, + viewProj, + activeViewPolygons, + out int clipVerts); + if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})"); if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}"); // R1 void fix (2026-06-05): the projected+clipped region is empty — normally we cull the @@ -171,25 +223,26 @@ public static class PortalVisibilityBuilder if (clippedRegion.Count == 0) { if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos)) + { + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty side={sideAllowed} eyeIn={eyeInsideOpening} clipVerts={clipVerts}"); continue; // portal not visible through this chain, and the eye is not standing in it - foreach (var vp in currentView.Polygons) + } + foreach (var vp in activeViewPolygons) clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone())); } - var portal = cell.Portals[i]; - if (portal.OtherCellId == 0xFFFF) { if (pvDump) { - Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} clipPolys={clippedRegion.Count}"); + Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipVerts={clipVerts} clipPolys={clippedRegion.Count}"); Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]"); - Console.WriteLine($"[pv-dump] ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F3},{v.Y:F3})"))}]"); foreach (var cp in clippedRegion) Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]"); } // Exit portal -> outdoors visible through this (clipped) opening. - foreach (var cp in clippedRegion) frame.OutsideView.Add(cp); + AddRegion(frame.OutsideView, clippedRegion); + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts} eyeIn={eyeInsideOpening}"); continue; } @@ -202,12 +255,17 @@ public static class PortalVisibilityBuilder if (buildingMembership != null && !buildingMembership(neighbourId)) { var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId); - foreach (var cp in clippedRegion) xview.Add(cp); + bool grewCross = AddRegion(xview, clippedRegion); + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}"); continue; } var neighbour = lookup(neighbourId); - if (neighbour == null) continue; + if (neighbour == null) + { + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=lookup-miss polys={clippedRegion.Count}"); + continue; + } // Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip // decomp:433524). The portal opening seen from THIS cell may be wider than the @@ -222,12 +280,24 @@ public static class PortalVisibilityBuilder // direct index is what lets a cell with TWO portals to the same neighbour clip each // opening against its OWN reciprocal instead of the first one. Mutates clippedRegion // in place before the union below. + var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null; + int preReciprocalCount = clippedRegion.Count; ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj); - if (clippedRegion.Count == 0) continue; // reciprocal opening doesn't overlap → not visible + if (clippedRegion.Count == 0) + { + if (preReciprocalClip is null) + { + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=reciprocal-empty pre={preReciprocalCount} otherPortal={portal.OtherPortalId}"); + continue; + } + clippedRegion.AddRange(preReciprocalClip); + } // Union the clipped region into the neighbour's accumulated view. var nview = GetOrCreate(frame.CellViews, neighbourId); - foreach (var cp in clippedRegion) nview.Add(cp); + bool grew = AddRegion(nview, clippedRegion); + bool inserted = false; + float dist = float.NaN; // Insert the neighbour into the distance-priority list — but ONLY on first discovery // (retail enqueues via InsCellTodoList solely in the ecx_5==0 branch; growth into an @@ -237,11 +307,13 @@ public static class PortalVisibilityBuilder // portal-opening vertex in world space (retail InitCell min-vertex distance, // 432988-433004); derived from the portal geometry, so it works even when the cell's // WorldPosition was never populated. - if (seen.Add(neighbourId)) + if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId)) { - float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); + dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); todo.Insert(neighbour, dist); + inserted = true; } + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} addCell polys={clippedRegion.Count} clipVerts={clipVerts} recip={preReciprocalCount}->{clippedRegion.Count} grew={grew} queued={inserted} dist={(float.IsNaN(dist) ? "na" : dist.ToString("F2"))}"); } } @@ -252,6 +324,161 @@ public static class PortalVisibilityBuilder // root cell's per-portal side-test + projection + the frame's exit/visible counts. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) EmitFlapProbe(cameraCell, cameraPos, viewProj, frame); + trace?.Emit(frame); + + return frame; + } + + /// + /// Build a portal visibility frame for an OUTDOOR viewer looking into one or more + /// outside-facing cell portals. This is the reciprocal of : + /// the seed view is the projected exit-portal opening instead of a full-screen + /// camera cell. It keeps the same retail distance-priority traversal and + /// neighbour reciprocal clipping once inside the building. + /// + public static PortalVisibilityFrame BuildFromExterior( + IEnumerable candidateCells, + Vector3 cameraPos, + Func lookup, + Matrix4x4 viewProj, + float maxSeedDistance = float.PositiveInfinity) + { + var frame = new PortalVisibilityFrame(); + var todo = new CellTodoList(); + var queued = new HashSet(); + var drawListed = new HashSet(); + var processedViewCounts = new Dictionary(); + var popCounts = new Dictionary(); // per-cell pop count for the MaxReprocessPerCell cap + + foreach (var cell in candidateCells) + { + if (cell is null) continue; + + for (int i = 0; i < cell.Portals.Count; i++) + { + var portal = cell.Portals[i]; + if (portal.OtherCellId != 0xFFFF) + continue; + if (i >= cell.PortalPolygons.Count) + continue; + + var poly = cell.PortalPolygons[i]; + if (poly == null || poly.Length < 3) + continue; + + // Exterior peering starts from the OUTSIDE face of an exit portal. + // If the camera is on the cell-interior side, the normal indoor + // DrawInside path owns this portal instead. + if (i < cell.ClipPlanes.Count && CameraOnInteriorSide(cell, i, cameraPos)) + continue; + + float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); + if (seedDistance > maxSeedDistance) + continue; + + var clippedRegion = ClipPortalAgainstView( + poly, + cell.WorldTransform, + viewProj, + FullScreenRegion, + out _); + + if (clippedRegion.Count == 0) + { + if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos)) + continue; + clippedRegion.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone())); + } + + var seedView = GetOrCreate(frame.CellViews, cell.CellId); + bool grew = AddRegion(seedView, clippedRegion); + + if (grew && queued.Add(cell.CellId)) + todo.Insert(cell, seedDistance); + } + } + + while (todo.Count > 0) + { + var cell = todo.PopNearest(); + queued.Remove(cell.CellId); + // Bounded re-enqueue — see the matching note in Build(). Count this pop; the gate below caps + // re-enqueues at MaxReprocessPerCell so the look-in flood terminates under ProjectToClip drift. + popCounts.TryGetValue(cell.CellId, out int popsSoFar); + popCounts[cell.CellId] = popsSoFar + 1; + if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty) + continue; + + if (drawListed.Add(cell.CellId)) + frame.OrderedVisibleCells.Add(cell.CellId); + + processedViewCounts.TryGetValue(cell.CellId, out int processedCount); + int endCount = currentView.Polygons.Count; + if (processedCount >= endCount) + continue; + + var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount); + processedViewCounts[cell.CellId] = endCount; + uint lbMask = cell.CellId & 0xFFFF0000u; + + for (int i = 0; i < cell.Portals.Count; i++) + { + if (i >= cell.PortalPolygons.Count) + continue; + + var poly = cell.PortalPolygons[i]; + if (poly == null || poly.Length < 3) + continue; + + var portal = cell.Portals[i]; + if (portal.OtherCellId == 0xFFFF) + continue; // already outdoors; exterior terrain was drawn by the caller. + + bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos); + if (i < cell.ClipPlanes.Count + && !CameraOnInteriorSide(cell, i, cameraPos) + && !eyeInsideOpening) + continue; + + var clippedRegion = ClipPortalAgainstView( + poly, + cell.WorldTransform, + viewProj, + activeViewPolygons, + out _); + + if (clippedRegion.Count == 0) + { + if (!eyeInsideOpening) + continue; + foreach (var vp in activeViewPolygons) + clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone())); + } + + uint neighbourId = lbMask | portal.OtherCellId; + var neighbour = lookup(neighbourId); + if (neighbour == null) + continue; + + var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null; + ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj); + if (clippedRegion.Count == 0) + { + if (preReciprocalClip is null) + continue; + clippedRegion.AddRange(preReciprocalClip); + } + + var nview = GetOrCreate(frame.CellViews, neighbourId); + bool grew = AddRegion(nview, clippedRegion); + + if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId)) + { + float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); + todo.Insert(neighbour, dist); + } + } + } return frame; } @@ -260,6 +487,117 @@ public static class PortalVisibilityBuilder private static readonly Vector2[] FullScreenQuad = { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) }; + private static readonly ViewPolygon[] FullScreenRegion = + { new ViewPolygon(FullScreenQuad) }; + + private static List ClipPortalAgainstView( + Vector3[] localPoly, + Matrix4x4 cellToWorld, + Matrix4x4 viewProj, + IReadOnlyList viewPolygons, + out int clipVertexCount) + { + var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj); + clipVertexCount = portalClip.Length; + var clippedRegion = new List(); + if (portalClip.Length < 3) + return clippedRegion; + + foreach (var vp in viewPolygons) + { + if (vp.IsEmpty) + continue; + + var clipped = PortalProjection.ClipToRegion(portalClip, vp.Vertices); + if (clipped.Length >= 3) + clippedRegion.Add(new ViewPolygon(clipped)); + } + + return clippedRegion; + } + + private const int PortalTraceEmitLimit = 160; + private static readonly object s_portalTraceLock = new(); + private static readonly Dictionary s_portalTraceLastSignature = new(); + private static int s_portalTraceEmits; + + private sealed class PortalBuildTrace + { + private readonly uint _rootCellId; + private readonly Vector3 _eye; + private readonly List _lines = new(); + + private PortalBuildTrace(uint rootCellId, Vector3 eye) + { + _rootCellId = rootCellId; + _eye = eye; + } + + public static PortalBuildTrace? Start(LoadedCell root, Vector3 eye) + { + if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) + return null; + if (!IsHoltburgIndoorProbeCell(root.CellId)) + return null; + return new PortalBuildTrace(root.CellId, eye); + } + + public void Add(string line) + { + if (_lines.Count < 96) + _lines.Add(line); + } + + public void Emit(PortalVisibilityFrame frame) + { + string signature = BuildSignature(frame); + lock (s_portalTraceLock) + { + if (s_portalTraceEmits >= PortalTraceEmitLimit) + return; + if (s_portalTraceLastSignature.TryGetValue(_rootCellId, out var last) && + string.Equals(last, signature, StringComparison.Ordinal)) + return; + s_portalTraceLastSignature[_rootCellId] = signature; + s_portalTraceEmits++; + } + + Console.WriteLine($"[pv-trace] root=0x{_rootCellId:X8} eye=({_eye.X:F2},{_eye.Y:F2},{_eye.Z:F2}) {signature}"); + foreach (var line in _lines) + Console.WriteLine("[pv-trace] " + line); + } + } + + private static bool IsHoltburgIndoorProbeCell(uint cellId) + { + if ((cellId & 0xFFFF0000u) != 0xA9B40000u) + return false; + uint low = cellId & 0xFFFFu; + return low >= 0x016F && low <= 0x0175; + } + + private static string BuildSignature(PortalVisibilityFrame frame) + { + var sb = new System.Text.StringBuilder(160); + sb.Append("outPolys=").Append(frame.OutsideView.Polygons.Count); + sb.Append(" cells=["); + for (int i = 0; i < frame.OrderedVisibleCells.Count; i++) + { + if (i != 0) sb.Append(','); + sb.Append("0x").Append((frame.OrderedVisibleCells[i] & 0xFFFFu).ToString("X4")); + } + sb.Append("] views=["); + bool first = true; + foreach (var kvp in frame.CellViews) + { + if (!first) sb.Append(','); + first = false; + sb.Append("0x").Append((kvp.Key & 0xFFFFu).ToString("X4")).Append(':').Append(kvp.Value.Polygons.Count); + } + sb.Append(']'); + return sb.ToString(); + } + // Phase U.4c flap probe. One [flap] line per Build: the root cell's per-portal // signed distance D (eye→portal plane), traverse/cull decision, and NDC projection // vertex count, plus the frame's OutsideView polygon count + visible-cell count. @@ -288,10 +626,10 @@ public static class PortalVisibilityBuilder d = Vector3.Dot(pl.Normal, localEye) + pl.D; side = CameraOnInteriorSide(cameraCell, i, cameraPos); } - // Replicate the walk's project → EnsureCcw → Intersect(FullScreen) exactly, so a - // portal that PROJECTS (proj>=3) but still fails to ADD its neighbour shows WHY: - // clip=0 with ndc inside [-1,1] ⇒ winding/self-intersection degeneracy; clip=0 with - // ndc outside [-1,1] ⇒ genuinely off-screen. The ndc coords expose a near-plane bowtie. + // Replicate the walk's faithful path exactly (ProjectToClip → ClipToRegion(FullScreen)) so + // proj/clip mean the same as production: proj = clip-space verts in front of the eye, + // clip = verts surviving the screen-region clip. clip=0 with proj>=3 ⇒ the portal is + // genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands. int projN = -1, clipN = -1; string ndcText = ""; if (i < cameraCell.PortalPolygons.Count) @@ -299,12 +637,12 @@ public static class PortalVisibilityBuilder var poly = cameraCell.PortalPolygons[i]; if (poly != null && poly.Length >= 3) { - var ndc = PortalProjection.ProjectToNdc(poly, cameraCell.WorldTransform, viewProj); - projN = ndc.Length; - if (ndc.Length >= 3) + var clip = PortalProjection.ProjectToClip(poly, cameraCell.WorldTransform, viewProj); + projN = clip.Length; + if (clip.Length >= 3) { - EnsureCcw(ndc); - clipN = ScreenPolygonClip.Intersect(ndc, FullScreenQuad).Length; + var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad); + clipN = ndc.Length; var ns = new System.Text.StringBuilder(48); foreach (var v in ndc) ns.Append('(').Append(v.X.ToString("F1")).Append(',').Append(v.Y.ToString("F1")).Append(')'); ndcText = ns.ToString(); @@ -376,6 +714,13 @@ public static class PortalVisibilityBuilder // Project the reciprocal opening through the NEIGHBOUR's transform (retail positionPush(3, // &other_cell_ptr->pos) at 005a54d2), then normalize winding for the CCW-only clipper. + // NOTE: this stays on the divide-then-clip ProjectToNdc path on purpose. The reciprocal is a + // back-portal one hop away — never near the eye — so the homogeneous clip buys nothing here, + // and ProjectToNdc is float-stable across the BFS re-enqueue rounds. Routing it through + // ProjectToClip+ClipToRegion produced per-round float drift that defeated the CellView + // SamePolygon dedup, inflating a tight A<->B reciprocal view to ~4x its area + // (Build_AppliesReciprocalOtherPortalClip). The near-side clip (ClipPortalAgainstView) IS the + // homogeneous path; this secondary tightening is not. Vector2[] reciprocalNdc = PortalProjection.ProjectToNdc(reciprocalPoly, neighbour.WorldTransform, viewProj); if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op EnsureCcw(reciprocalNdc); @@ -395,11 +740,27 @@ public static class PortalVisibilityBuilder return v; } + private static bool AddRegion(CellView view, List region) + { + bool grew = false; + foreach (var poly in region) + grew |= view.Add(poly); + return grew; + } + // Camera→nearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal // min-distance loop retail runs in PView::InitCell (decomp:432988-433004) to key the todo list: // it walks the portal's vertices, transforms each to world space, and keeps the smallest // straight-line distance to the camera viewpoint. Keying on the portal opening (not the cell // origin) is both retail-faithful and robust to cells whose WorldPosition was never populated. + private static List CloneViewPolygons(List source) + { + var clone = new List(source.Count); + foreach (var poly in source) + clone.Add(new ViewPolygon((Vector2[])poly.Vertices.Clone())); + return clone; + } + private static float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos) { float best = float.MaxValue; @@ -413,10 +774,11 @@ public static class PortalVisibilityBuilder } // "Eye standing in the opening": the eye is within this perpendicular distance of a portal's - // plane. At the cottage doorway the live capture measured D=0.16 m for the portal the chase - // camera was standing in; 0.5 m comfortably covers a doorway-standing eye while excluding portals - // the eye is merely facing from across a room (their projection is non-degenerate anyway). - private const float EyeStandingPerpDist = 0.5f; + // plane. Live captures hit two retail-valid degenerate cases: cottage doorway D=0.16 m and + // cellar->stair portal D=1.41 m, both traversable but ProjectToNdc returned zero vertices. We still + // require the perpendicular projection to land inside the opening, so side/offscreen portals stay + // culled; this only covers active portals whose 2D projection collapses near the chase camera. + private const float EyeStandingPerpDist = 1.75f; /// /// True when the camera eye is "standing in" 's opening: within diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs new file mode 100644 index 00000000..024ec3d9 --- /dev/null +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// App-layer port of the retail indoor render orchestration: +/// SmartBox::RenderNormalMode -> RenderDeviceD3D::DrawInside -> +/// PView::DrawInside -> ConstructView -> DrawCells. +/// +public sealed class RetailPViewRenderer +{ + private readonly GL _gl; + private readonly ClipFrame _clipFrame; + private readonly EnvCellRenderer _envCells; + private readonly WbDrawDispatcher _entities; + + private static readonly ClipViewSlice NoClipSlice = + new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty()); + + private readonly HashSet _oneCell = new(1); + private readonly Dictionary _oneCellSlot = new(1); + public RetailPViewRenderer( + GL gl, + ClipFrame clipFrame, + EnvCellRenderer envCells, + WbDrawDispatcher entities) + { + _gl = gl; + _clipFrame = clipFrame; + _envCells = envCells; + _entities = entities; + } + + public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx) + { + ArgumentNullException.ThrowIfNull(ctx); + + var pvFrame = PortalVisibilityBuilder.Build( + ctx.RootCell, + ctx.ViewerEyePos, + ctx.CellLookup, + ctx.ViewProjection); + + var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame); + UploadClipFrame(ctx.SetTerrainClipUbo); + + // 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, + // so every visible cell's shell has a prepared batch and seals — killing the grey + // (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells). + // Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained). + var drawableCells = new HashSet(pvFrame.OrderedVisibleCells); + UseIndoorMembershipOnlyRouting(); + + _envCells.PrepareRenderBatches( + ctx.ViewProjection, + ctx.CameraWorldPosition, + filter: drawableCells, + centerLbX: ctx.RenderCenterLbX, + centerLbY: ctx.RenderCenterLbY, + renderRadius: ctx.RenderRadius); + + var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries); + var result = new RetailPViewFrameResult + { + PortalFrame = pvFrame, + ClipAssembly = clipAssembly, + DrawableCells = drawableCells, + Partition = partition, + }; + + ctx.EmitDiagnostics?.Invoke(result); + + DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition); + UseIndoorMembershipOnlyRouting(); + DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); + DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells); + DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); + + return result; + } + + public RetailPViewFrameResult? DrawPortal(RetailPViewPortalDrawContext ctx) + { + ArgumentNullException.ThrowIfNull(ctx); + + var pvFrame = PortalVisibilityBuilder.BuildFromExterior( + ctx.CandidateCells, + ctx.ViewerEyePos, + ctx.CellLookup, + ctx.ViewProjection, + ctx.MaxSeedDistance); + + if (pvFrame.OrderedVisibleCells.Count == 0) + { + RestoreNoClip(ctx.SetTerrainClipUbo); + return null; + } + + var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame); + UploadClipFrame(ctx.SetTerrainClipUbo); + + var drawableCells = new HashSet(clipAssembly.CellIdToSlot.Keys); + UseIndoorMembershipOnlyRouting(); + + _envCells.PrepareRenderBatches( + ctx.ViewProjection, + ctx.CameraWorldPosition, + filter: drawableCells, + centerLbX: ctx.RenderCenterLbX, + centerLbY: ctx.RenderCenterLbY, + renderRadius: ctx.RenderRadius); + + var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries); + var result = new RetailPViewFrameResult + { + PortalFrame = pvFrame, + ClipAssembly = clipAssembly, + DrawableCells = drawableCells, + Partition = partition, + }; + + ctx.EmitDiagnostics?.Invoke(result); + + DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); + DrawEnvCellShells(ctx, pvFrame, clipAssembly, drawableCells); + DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); + RestoreNoClip(ctx.SetTerrainClipUbo); + + return result; + } + + private void DrawLandscapeThroughOutsideView( + RetailPViewDrawContext ctx, + ClipFrameAssembly clipAssembly, + InteriorEntityPartition.Result partition) + { + if (clipAssembly.OutsideViewSlices.Length == 0) + return; + + foreach (var slice in clipAssembly.OutsideViewSlices) + { + _clipFrame.SetTerrainClip(slice.Planes); + UploadClipFrame(ctx.SetTerrainClipUbo); + _entities.SetClipRouting(clipAssembly.CellIdToSlot, slice.Slot, outdoorVisible: true); + ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, partition.Outdoor)); + } + + foreach (var slice in clipAssembly.OutsideViewSlices) + ctx.ClearDepthSlice?.Invoke(slice); + + UseIndoorMembershipOnlyRouting(); + } + + private void DrawExitPortalMasks( + IRetailPViewCellDrawCallbacks ctx, + PortalVisibilityFrame pvFrame, + ClipFrameAssembly clipAssembly, + HashSet drawableCells) + { + if (ctx.DrawExitPortalMasks is null) + return; + + for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) + { + uint cellId = pvFrame.OrderedVisibleCells[i]; + if (!drawableCells.Contains(cellId)) + continue; + + foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) + ctx.DrawExitPortalMasks(new RetailPViewCellSliceContext(cellId, slice, Array.Empty())); + } + } + + private void DrawEnvCellShells( + IRetailPViewCellDrawCallbacks ctx, + PortalVisibilityFrame pvFrame, + ClipFrameAssembly clipAssembly, + HashSet drawableCells) // param kept this task; removed in Task 4 + { + // Retail DrawCells Loop 2: every visible cell's shell, reverse cell_draw_list + // (far→near), per portal_view slice. No drawableCells filter — a cell without a + // clip-slot falls through GetCellSlicesOrNoClip to NoClipSlice and draws unclipped + // (sealed; per-slice trim returns in Task 4). + foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) + { + uint cellId = entry.CellId; + _oneCell.Clear(); + _oneCell.Add(cellId); + + foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) + { + UseShellClipRouting(cellId, slice); + _envCells.Render(WbRenderPass.Opaque, _oneCell); + _envCells.Render(WbRenderPass.Transparent, _oneCell); + } + } + } + + private void DrawCellObjectLists( + IRetailPViewCellDrawContext ctx, + PortalVisibilityFrame pvFrame, + ClipFrameAssembly clipAssembly, + HashSet drawableCells, + InteriorEntityPartition.Result partition) + { + for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) + { + uint cellId = pvFrame.OrderedVisibleCells[i]; + if (!drawableCells.Contains(cellId)) + continue; + + if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0) + continue; + + _oneCell.Clear(); + _oneCell.Add(cellId); + + UseIndoorMembershipOnlyRouting(); + DrawEntityBucket(ctx, bucket, _oneCell); + + foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) + ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, bucket)); + } + } + + private static ClipViewSlice[] GetCellSlicesOrNoClip( + ClipFrameAssembly clipAssembly, + uint cellId) + { + if (clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices) + && slices.Length > 0) + return slices; + + return new[] { NoClipSlice }; + } + + private void UseIndoorMembershipOnlyRouting() + { + // Retail's PView portal views decide which cells/objects are eligible, + // but DrawMesh only performs portal-view visibility checks before drawing + // the mesh. Feeding those 2D views into gl_ClipDistance slices characters + // and cell shells at stair/door boundaries, which retail does not do. + _envCells.SetClipRouting(null); + _entities.ClearClipRouting(); + } + + private void UseShellClipRouting(uint cellId, ClipViewSlice slice) + { + _oneCellSlot.Clear(); + _oneCellSlot[cellId] = slice.Slot; + _envCells.SetClipRouting(_oneCellSlot); + _entities.ClearClipRouting(); + } + + private void DrawEntityBucket( + IRetailPViewCellDrawContext ctx, + IReadOnlyList bucket, + HashSet? visibleCellIds) + { + uint lbId = ctx.PlayerLandblockId ?? 0u; + var entry = (lbId, Vector3.Zero, Vector3.Zero, + (IReadOnlyList)bucket, + (IReadOnlyDictionary?)null); + + _entities.Draw( + ctx.Camera, + new[] { entry }, + ctx.Frustum, + neverCullLandblockId: ctx.PlayerLandblockId, + visibleCellIds: visibleCellIds, + animatedEntityIds: ctx.AnimatedEntityIds); + } + + private void RestoreNoClip(Action setTerrainClipUbo) + { + _clipFrame.Reset(); + UploadClipFrame(setTerrainClipUbo); + UseIndoorMembershipOnlyRouting(); + } + + private void UploadClipFrame(Action setTerrainClipUbo) + { + _clipFrame.UploadShared(_gl); + _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); + _envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); + setTerrainClipUbo(_clipFrame.TerrainUbo); + } +} + +public interface IRetailPViewCellDrawCallbacks +{ + public Action? DrawExitPortalMasks { get; } + public Action? DrawCellParticles { get; } +} + +public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks +{ + public ICamera Camera { get; } + public FrustumPlanes? Frustum { get; } + public uint? PlayerLandblockId { get; } + public HashSet? AnimatedEntityIds { get; } +} + +public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext +{ + public required LoadedCell RootCell { get; init; } + public required Vector3 ViewerEyePos { get; init; } + public required Matrix4x4 ViewProjection { get; init; } + public required Func CellLookup { get; init; } + public required ICamera Camera { get; init; } + public required Vector3 CameraWorldPosition { get; init; } + public required FrustumPlanes? Frustum { get; init; } + public required uint? PlayerLandblockId { get; init; } + public required HashSet? AnimatedEntityIds { get; init; } + public required int RenderCenterLbX { get; init; } + public required int RenderCenterLbY { get; init; } + public required int RenderRadius { get; init; } + public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; } + public required Action SetTerrainClipUbo { get; init; } + public required Action DrawLandscapeSlice { get; init; } + public Action? ClearDepthSlice { get; init; } + public Action? DrawExitPortalMasks { get; init; } + public Action? DrawCellParticles { get; init; } + public Action? EmitDiagnostics { get; init; } +} + +public sealed class RetailPViewPortalDrawContext : IRetailPViewCellDrawContext +{ + public required IEnumerable CandidateCells { get; init; } + public required Vector3 ViewerEyePos { get; init; } + public required Matrix4x4 ViewProjection { get; init; } + public required Func CellLookup { get; init; } + public required ICamera Camera { get; init; } + public required Vector3 CameraWorldPosition { get; init; } + public required FrustumPlanes? Frustum { get; init; } + public required uint? PlayerLandblockId { get; init; } + public required HashSet? AnimatedEntityIds { get; init; } + public required int RenderCenterLbX { get; init; } + public required int RenderCenterLbY { get; init; } + public required int RenderRadius { get; init; } + public required float MaxSeedDistance { get; init; } + public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> LandblockEntries { get; init; } + public required Action SetTerrainClipUbo { get; init; } + public Action? DrawExitPortalMasks { get; init; } + public Action? DrawCellParticles { get; init; } + public Action? EmitDiagnostics { get; init; } +} + +public sealed class RetailPViewFrameResult +{ + public required PortalVisibilityFrame PortalFrame { get; init; } + public required ClipFrameAssembly ClipAssembly { get; init; } + public required HashSet DrawableCells { get; init; } + public required InteriorEntityPartition.Result Partition { get; init; } +} + +public readonly record struct RetailPViewLandscapeSliceContext( + ClipViewSlice Slice, + IReadOnlyList OutdoorEntities); + +public readonly record struct RetailPViewCellSliceContext( + uint CellId, + ClipViewSlice Slice, + IReadOnlyList CellEntities); diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index fa6f225b..ddb819c5 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -1291,21 +1291,24 @@ namespace AcDream.App.Rendering.Wb { ct.ThrowIfCancellationRequested(); if (poly.VertexIds.Count < 3) continue; - // Handle Positive Surface - if (!poly.Stippling.HasFlag(StipplingType.NoPos)) { - AddSurfaceToBatch(poly, poly.PosSurface, false); + // Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this + // DatReaderWriter "CullMode" as CPolygon::sides_type, not as a + // GL cull enum: 0 = pos, 1 = pos twice with reversed winding, + // 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still + // suppress hidden portal/cap faces before they reach our mesh. + bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos); + bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg); + + if (hasPos) + AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: false, reverseWinding: false); + if (hasPos && poly.SidesType == CullMode.None) { + AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: true, reverseWinding: true); + } + else if (hasNeg && poly.SidesType == CullMode.Clockwise) { + AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false); } - // Handle Negative Surface - bool hasNeg = poly.Stippling.HasFlag(StipplingType.Negative) || - poly.Stippling.HasFlag(StipplingType.Both) || - (!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise); - - if (hasNeg) { - AddSurfaceToBatch(poly, poly.NegSurface, true); - } - - void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool isNeg) { + void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) { if (surfaceIdx < 0) return; uint surfaceId; @@ -1499,7 +1502,17 @@ namespace AcDream.App.Rendering.Wb { // Helper for CellStruct vertices bool batchHasWrappingUVs = batch.HasWrappingUVs; - BuildCellStructPolygonIndices(poly, cellStruct, UVLookup, vertices, batch.Indices, isNeg, transform, ref batchHasWrappingUVs); + BuildCellStructPolygonIndices( + poly, + cellStruct, + UVLookup, + vertices, + batch.Indices, + useNegUv, + invertNormal, + reverseWinding, + transform, + ref batchHasWrappingUVs); batch.HasWrappingUVs = batchHasWrappingUVs; } } @@ -1516,8 +1529,10 @@ namespace AcDream.App.Rendering.Wb { } private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct, - Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup, - List vertices, List indices, bool useNegSurface, Matrix4x4 transform, ref bool hasWrappingUVs) { + Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup, + List vertices, List indices, + bool useNegUv, bool invertNormal, bool reverseWinding, + Matrix4x4 transform, ref bool hasWrappingUVs) { var polyIndices = new List(); @@ -1525,9 +1540,9 @@ namespace AcDream.App.Rendering.Wb { ushort vertId = (ushort)poly.VertexIds[i]; ushort uvIdx = 0; - if (useNegSurface && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) + if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) uvIdx = poly.NegUVIndices[i]; - else if (!useNegSurface && poly.PosUVIndices != null && i < poly.PosUVIndices.Count) + else if (poly.PosUVIndices != null && i < poly.PosUVIndices.Count) uvIdx = poly.PosUVIndices[i]; if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue; @@ -1536,7 +1551,7 @@ namespace AcDream.App.Rendering.Wb { uvIdx = 0; } - var key = (vertId, uvIdx, useNegSurface); + var key = (vertId, uvIdx, invertNormal); if (!hasWrappingUVs) { var uvCheck = vertex.UVs.Count > 0 @@ -1553,7 +1568,7 @@ namespace AcDream.App.Rendering.Wb { : Vector2.Zero; var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform)); - if (useNegSurface) { + if (invertNormal) { normal = -normal; } @@ -1568,18 +1583,18 @@ namespace AcDream.App.Rendering.Wb { polyIndices.Add(idx); } - if (useNegSurface) { + if (reverseWinding) { for (int i = 2; i < polyIndices.Count; i++) { - indices.Add(polyIndices[0]); - indices.Add(polyIndices[i - 1]); indices.Add(polyIndices[i]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[0]); } } else { for (int i = 2; i < polyIndices.Count; i++) { - indices.Add(polyIndices[i]); - indices.Add(polyIndices[i - 1]); indices.Add(polyIndices[0]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[i]); } } } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 5efa8008..c2f3fbba 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -144,8 +144,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // each Draw. When _clipRoutingActive is false (the U.3 path / outdoor root / // no portal frame), every instance maps to slot 0 (no-clip) and no instance is // culled — identical to U.3. When active, each instance's slot is resolved by - // ResolveEntitySlot per the U.4 policy (live-dynamic unclipped; cell statics to - // their cell slot; outdoor scenery to the OutsideView slot; non-visible culled). + // ResolveEntitySlot per the U.4 policy (cell-owned entities to their cell slot; + // outdoor-owned entities to OutsideView; non-visible/unresolved indoors culled). private bool _clipRoutingActive; private IReadOnlyDictionary? _cellIdToSlot; private int _outdoorSlot; @@ -310,8 +310,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// Phase U.4: install the per-frame clip-slot routing for an INDOOR root. /// Call once per frame BEFORE when the camera's root cell is /// non-null; the next resolves each instance's binding=3 - /// clip slot via the U.4 policy (live-dynamic unclipped, cell statics to their - /// cell slot, outdoor scenery to the OutsideView slot, non-visible culled). + /// clip slot via the U.4 policy (cell-owned entities to their cell slot, + /// outdoor-owned entities to OutsideView, non-visible/unresolved indoors culled). /// Pair with on outdoor-root frames so the /// dispatcher reverts to the U.3 no-clip-everything behavior. /// @@ -354,12 +354,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// Phase U.4: resolve the clip slot for one entity per the slot/gate policy. /// Returns to drop the entity's instances entirely. /// - /// ServerGuid != 0 (live dynamic: player / NPC / items / doors) ⇒ slot 0 - /// (UNCLIPPED — retail draws live-dynamic unclipped; depth only). - /// ParentCellId != null (cell static) ⇒ the cell's slot, or CULL when the - /// cell isn't in (not visible / nothing-visible). - /// ParentCellId == null (outdoor scenery / building shell) ⇒ the OutsideView - /// slot when , else CULL. + /// Indoor ParentCellId: the cell's slot, or CULL when hidden. + /// Outdoor ParentCellId or ParentCellId == null static scenery: the OutsideView slot + /// when , else CULL. + /// ServerGuid != 0 with ParentCellId == null: CULL while routing is active. /// /// Only called when _clipRoutingActive (indoor root). On the U.3 / outdoor /// path every instance is slot 0 and nothing is culled — see @@ -385,20 +383,37 @@ public sealed unsafe class WbDrawDispatcher : IDisposable int outdoorSlot, bool outdoorVisible) { - // Live-dynamic entities render unclipped regardless of cell — retail draws - // the player / NPCs / dropped items through the depth buffer without portal - // clipping. ServerGuid is the live-dynamic marker (0 for dat-hydrated). - if (serverGuid != 0) - return 0; - + // Live-dynamic entities are not a global indoor overlay. When they + // have current cell ownership, route them through the same visible + // cell/OutsideView graph as every other object. Parentless live objects + // are unresolved indoors, so cull them while clip routing is active. if (parentCellId is uint parentCell) - return cellIdToSlot.TryGetValue(parentCell, out int slot) ? slot : ClipSlotCull; + { + if (IsIndoorCellId(parentCell)) + { + if (!cellIdToSlot.ContainsKey(parentCell)) + return ClipSlotCull; + + return cellIdToSlot[parentCell]; + } + + return outdoorVisible ? outdoorSlot : ClipSlotCull; + } + + if (serverGuid != 0) + return ClipSlotCull; // Outdoor scenery / building shell (no ParentCellId). Indoor root: gate to // the OutsideView slot, or cull when nothing outdoors is visible. return outdoorVisible ? outdoorSlot : ClipSlotCull; } + private static bool IsIndoorCellId(uint cellId) + { + uint low = cellId & 0xFFFFu; + return low >= 0x0100u && low != 0xFFFFu; + } + /// /// Phase U.4: the call-site clip-slot decision for one entity, returning the /// (Slot, Culled) pair the per-entity loop body consumes. Wraps diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index e44a920f..ad6b7793 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -280,9 +280,9 @@ public static class RenderingDiagnostics /// DrawInside vs the outdoor LScape::draw on is_player_outside — the /// PLAYER's cell ((player->m_position.objcell_id & 0xFFFF) < 0x100, /// SmartBox::is_player_outside 0x451e80) — NOT the camera/viewer cell. When the - /// player is inside it then roots the flood at the viewer cell - /// (this->viewer_cell). So the inside/outside decision follows the player; - /// only the indoor root follows the camera. + /// player is inside, acdream roots the portal flood at the player's transition-owned + /// physics cell and projects from the camera eye, so the shell around the player remains + /// sealed during chase-camera cell transitions. /// /// acdream historically branched on the camera cell (a non-null /// visibility.CameraCell). A 3rd-person chase camera lags the player, so when the @@ -292,9 +292,9 @@ public static class RenderingDiagnostics /// only entities (which bypass the gate) showing through. Branching on the player removes it. /// /// The player's current cell id (0 if unresolved → outside). - /// Whether a viewer/camera cell is available to root - /// DrawInside at. Indoor render needs both: the player inside AND a cell to root at. + /// Whether the player's indoor render root is loaded and + /// available to DrawInside. /// - public static bool ShouldRenderIndoor(uint playerCellId, bool viewerCellResolved) - => viewerCellResolved && IsEnvCellId(playerCellId); + public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved) + => renderRootResolved && IsEnvCellId(playerCellId); } diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs index e4fa670d..6121720a 100644 --- a/src/AcDream.Core/World/WorldEntity.cs +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -37,12 +37,13 @@ public sealed class WorldEntity public PaletteOverride? PaletteOverride { get; init; } /// - /// EnvCell ID that owns this entity (room geometry or static object inside + /// EnvCell or outdoor cell ID that owns this entity (room geometry, static + /// object, or live object inside/outside a cell). /// the cell). Used by portal visibility to filter interior entities — only /// entities whose ParentCellId appears in the visible set are rendered. - /// Null for outdoor entities (stabs, scenery, live server spawns). + /// Null for outdoor dat scenery/building stabs or unresolved live entities. /// - public uint? ParentCellId { get; init; } + public uint? ParentCellId { get; set; } /// /// True when this entity originates from LandBlockInfo.Buildings[] diff --git a/tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs b/tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs new file mode 100644 index 00000000..1e545007 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs @@ -0,0 +1,64 @@ +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +// Regression tests for the indoor-render HANG (2026-06-06): the portal-visibility flood +// re-queues a cell whenever its CellView grows, so it only terminates when CellView.Add's +// dedup catches a duplicate. Across BFS rounds the same region comes back float-drifted, +// vertex-rotated, or with a ±1 vertex count; the old exact index-by-index SamePolygon +// (eps 1e-4) missed all three, so the region grew forever -> CPU-spin hang in CellView.Add. +// A drift-tolerant, rotation-invariant dedup makes the key space finite, so the flood +// converges (and these duplicates collapse). +public class CellViewDedupTests +{ + private static ViewPolygon Quad(float ox, float oy) => new(new[] + { + new Vector2(ox - 0.5f, oy - 0.5f), new Vector2(ox + 0.5f, oy - 0.5f), + new Vector2(ox + 0.5f, oy + 0.5f), new Vector2(ox - 0.5f, oy + 0.5f), + }); + + [Fact] + public void Add_DropsSubGridDriftDuplicate() + { + var v = new CellView(); + Assert.True(v.Add(Quad(0f, 0f))); + // Same quad, every vertex nudged 3e-4 — beyond the old 1e-4 SamePolygon eps, + // within the 1e-3 dedup grid. This is the per-round float drift that caused the hang. + var drifted = new ViewPolygon(new[] + { + new Vector2(-0.5f + 3e-4f, -0.5f - 3e-4f), new Vector2(0.5f + 3e-4f, -0.5f - 3e-4f), + new Vector2(0.5f + 3e-4f, 0.5f - 3e-4f), new Vector2(-0.5f + 3e-4f, 0.5f - 3e-4f), + }); + Assert.False(v.Add(drifted)); + Assert.Single(v.Polygons); + } + + [Fact] + public void Add_DropsRotatedStartDuplicate() + { + var v = new CellView(); + Assert.True(v.Add(Quad(0f, 0f))); + // Same 4 corners (same CCW cycle), but emitted starting at the 2nd vertex — what + // Sutherland-Hodgman can do when the subject order differs across rounds. + var rotated = new ViewPolygon(new[] + { + new Vector2(0.5f, -0.5f), new Vector2(0.5f, 0.5f), + new Vector2(-0.5f, 0.5f), new Vector2(-0.5f, -0.5f), + }); + Assert.False(v.Add(rotated)); + Assert.Single(v.Polygons); + } + + [Fact] + public void Add_KeepsGenuinelyDistinctPolygons() + { + // The fix must NOT over-merge: two regions 0.4 NDC apart (far beyond the 1e-3 grid) + // remain distinct, so a real second portal opening is not silently dropped. + var v = new CellView(); + Assert.True(v.Add(Quad(0f, 0f))); + Assert.True(v.Add(Quad(0.4f, 0f))); + Assert.Equal(2, v.Polygons.Count); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs index 1f1cbfed..8962529a 100644 --- a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs @@ -5,36 +5,27 @@ using Xunit; namespace AcDream.App.Tests.Rendering; -/// -/// Phase U.4: GL-free proof that implements the -/// slot/gate policy — slot 0 no-clip, one CellClip slot per visible cell with a -/// convex region, OutsideView routed to the terrain decision + the outdoor mesh -/// slot, and the three Count==0 dispositions (nothing-visible cull, scissor -/// fallback → no-clip, planes). Hand-built s -/// drive the assembler directly (no portal BFS needed) so each disposition is -/// exercised in isolation. -/// public class ClipFrameAssemblerTests { - // A convex NDC square → ClipPlaneSet.From yields 4 planes (Count > 0). private static ViewPolygon Square(float cx, float cy, float half) => new(new[] { - new Vector2(cx - half, cy - half), new Vector2(cx + half, cy - half), - new Vector2(cx + half, cy + half), new Vector2(cx - half, cy + half), + new Vector2(cx - half, cy - half), + new Vector2(cx + half, cy - half), + new Vector2(cx + half, cy + half), + new Vector2(cx - half, cy + half), }); private static CellView ViewOf(params ViewPolygon[] polys) { - var v = new CellView(); - foreach (var p in polys) v.Add(p); - return v; + var view = new CellView(); + foreach (var p in polys) + view.Add(p); + return view; } [Fact] public void TwoVisibleCells_PlusOutsideView_ProducesCorrectSlotMapAndCounts() { - // Two cells with single convex regions (→ planes, mapped to slots 1 and 2) - // and a single-convex OutsideView (→ planes, the outdoor slot 3). const uint cellA = 0xA9B40100; const uint cellB = 0xA9B40101; @@ -45,199 +36,154 @@ public class ClipFrameAssemblerTests pv.OrderedVisibleCells.Add(cellB); pv.OutsideView.Add(Square(0f, 0.5f, 0.25f)); - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - // slot 0 reserved (no-clip) + 2 cells + 1 outdoor = 4 slots. - Assert.Equal(4, asm.Frame.SlotCount); - - // Both cells mapped to NON-zero slots (real plane regions), distinct. - Assert.True(asm.CellIdToSlot.ContainsKey(cellA)); - Assert.True(asm.CellIdToSlot.ContainsKey(cellB)); + Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cells + one outside slice + Assert.Contains(cellA, asm.CellIdToSlot.Keys); + Assert.Contains(cellB, asm.CellIdToSlot.Keys); Assert.NotEqual(0, asm.CellIdToSlot[cellA]); Assert.NotEqual(0, asm.CellIdToSlot[cellB]); Assert.NotEqual(asm.CellIdToSlot[cellA], asm.CellIdToSlot[cellB]); - - // Per-cell plane counts recorded (a convex square reduces to 4 planes). Assert.Equal(4, asm.PerCellPlaneCounts[cellA]); Assert.Equal(4, asm.PerCellPlaneCounts[cellB]); - // OutsideView: visible, convex → planes, a non-zero outdoor slot, terrain - // gated via planes. Assert.True(asm.OutdoorVisible); Assert.NotEqual(0, asm.OutdoorSlot); + Assert.Single(asm.OutsideViewSlices); + Assert.Equal(asm.OutdoorSlot, asm.OutsideViewSlices[0].Slot); Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); Assert.Equal(4, asm.OutsidePlaneCount); Assert.Equal(0, asm.ScissorFallbacks); - - // The outdoor slot differs from both cell slots and from slot 0. - Assert.NotEqual(asm.CellIdToSlot[cellA], asm.OutdoorSlot); - Assert.NotEqual(asm.CellIdToSlot[cellB], asm.OutdoorSlot); } [Fact] public void NothingVisibleCell_IsExcludedFromSlotMap_AndNotAppended() { - // cellA is a real convex region; cellB's CellView is EMPTY → ClipPlaneSet - // .IsNothingVisible → it must NOT be mapped and NOT consume a slot. const uint cellA = 0xA9B40100; const uint cellB = 0xA9B40101; var pv = new PortalVisibilityFrame(); pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f)); - pv.CellViews[cellB] = new CellView(); // empty ⇒ nothing visible + pv.CellViews[cellB] = new CellView(); pv.OrderedVisibleCells.Add(cellA); pv.OrderedVisibleCells.Add(cellB); - // OutsideView empty ⇒ terrain Skip (the bleed fix), outdoor not visible. - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - // slot 0 + cellA only = 2 slots. cellB consumed none. Assert.Equal(2, asm.Frame.SlotCount); - Assert.True(asm.CellIdToSlot.ContainsKey(cellA)); - Assert.False(asm.CellIdToSlot.ContainsKey(cellB)); // culled — not drawable - - // Empty OutsideView ⇒ outdoor culled + terrain skipped (the bleed fix). + Assert.Contains(cellA, asm.CellIdToSlot.Keys); + Assert.DoesNotContain(cellB, asm.CellIdToSlot.Keys); Assert.False(asm.OutdoorVisible); + Assert.Empty(asm.OutsideViewSlices); Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode); Assert.Equal(0, asm.OutsidePlaneCount); } [Fact] - public void OutsideViewScissorFallback_MapsTerrainToScissor_AndCountsFallback() + public void OutsideViewMultiPolygon_PreservesRetailSlices() { - // A MULTI-polygon OutsideView is a union (non-convex) ⇒ ClipPlaneSet falls - // back to the union AABB scissor: mesh outdoor slot 0 (no-clip over-include), - // terrain → Scissor, one fallback counted. const uint cellA = 0xA9B40100; var pv = new PortalVisibilityFrame(); pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f)); pv.OrderedVisibleCells.Add(cellA); pv.OutsideView.Add(Square(-0.5f, 0f, 0.1f)); - pv.OutsideView.Add(Square(0.5f, 0f, 0.1f)); // 2 polys ⇒ scissor fallback + pv.OutsideView.Add(Square(0.5f, 0f, 0.1f)); - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); Assert.True(asm.OutdoorVisible); - Assert.Equal(0, asm.OutdoorSlot); // no-clip over-include - Assert.Equal(TerrainClipMode.Scissor, asm.TerrainMode); - Assert.Equal(0, asm.OutsidePlaneCount); // scissor ⇒ 0 planes - Assert.Equal(1, asm.ScissorFallbacks); - - // The terrain scissor AABB is a valid (min <= max) NDC box spanning both - // OutsideView squares: minX <= -0.6, maxX >= 0.6. - Assert.True(asm.TerrainScissorNdcAabb.X <= asm.TerrainScissorNdcAabb.Z); - Assert.True(asm.TerrainScissorNdcAabb.Y <= asm.TerrainScissorNdcAabb.W); - Assert.True(asm.TerrainScissorNdcAabb.X <= -0.59f); - Assert.True(asm.TerrainScissorNdcAabb.Z >= 0.59f); + Assert.NotEqual(0, asm.OutdoorSlot); + Assert.Equal(2, asm.OutsideViewSlices.Length); + Assert.NotEqual(asm.OutsideViewSlices[0].Slot, asm.OutsideViewSlices[1].Slot); + Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); + Assert.Equal(4, asm.OutsidePlaneCount); + Assert.Equal(0, asm.ScissorFallbacks); + Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + cell + two outside slices + Assert.Equal(Vector4.Zero, asm.TerrainScissorNdcAabb); } [Fact] - public void CellScissorFallback_MapsCellToSlotZero_AndCountsFallback() + public void CellMultiPolygonView_PreservesRetailViewSlices() { - // A cell with a MULTI-polygon region → scissor fallback → mapped to slot 0 - // (no-clip over-include), recorded with 0 planes, one fallback counted. The - // OutsideView is a single convex region (planes) so only the CELL counts. const uint cellA = 0xA9B40100; var pv = new PortalVisibilityFrame(); - pv.CellViews[cellA] = ViewOf(Square(-0.4f, 0f, 0.1f), Square(0.4f, 0f, 0.1f)); + pv.CellViews[cellA] = ViewOf( + Square(-0.4f, 0f, 0.1f), + Square(0.4f, 0f, 0.1f)); pv.OrderedVisibleCells.Add(cellA); pv.OutsideView.Add(Square(0f, 0f, 0.3f)); - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); - // cellA → slot 0 (no-clip). slot 0 + the OutsideView's planes slot = 2. - Assert.Equal(0, asm.CellIdToSlot[cellA]); - Assert.Equal(0, asm.PerCellPlaneCounts[cellA]); - Assert.Equal(2, asm.Frame.SlotCount); // slot0 + OutsideView - Assert.Equal(1, asm.ScissorFallbacks); // the cell fallback + Assert.True(asm.CellIdToSlot[cellA] > 0); + Assert.Equal(2, asm.CellIdToViewSlots[cellA].Length); + Assert.Equal(2, asm.CellIdToViewSlices[cellA].Length); + Assert.NotEqual(asm.CellIdToViewSlots[cellA][0], asm.CellIdToViewSlots[cellA][1]); + Assert.Equal(4, asm.PerCellPlaneCounts[cellA]); + Assert.Single(asm.OutsideViewSlices); + Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cell slices + outside slice + Assert.Equal(0, asm.ScissorFallbacks); Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); } - // ----------------------------------------------------------------------- - // Phase W Stage 4: HasOutsideView + OutsideViewNdcAabb - // ----------------------------------------------------------------------- - [Fact] public void Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds() { - // A single convex quad in OutsideView reduces to Planes. HasOutsideView must - // be true and OutsideViewNdcAabb must match the polygon's own Min/Max values. var pv = new PortalVisibilityFrame(); - var poly = Square(-0.3f, 0.2f, 0.25f); - pv.OutsideView.Add(poly); - // No interior cells needed for this assertion. + pv.OutsideView.Add(Square(-0.3f, 0.2f, 0.25f)); - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); Assert.True(asm.HasOutsideView); Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); + Assert.Single(asm.OutsideViewSlices); - // The OutsideViewNdcAabb must equal the CellView's accumulated Min/Max. - var expected = new System.Numerics.Vector4( + var expected = new Vector4( pv.OutsideView.MinX, pv.OutsideView.MinY, pv.OutsideView.MaxX, pv.OutsideView.MaxY); Assert.Equal(expected, asm.OutsideViewNdcAabb); } [Fact] - public void Assemble_OutsideViewMultiPolygon_ScissorMode_HasOutsideViewTrue_AabbValid() + public void Assemble_OutsideViewMultiPolygon_PreservesSlicesAndUnionAabb() { - // Two polygons in OutsideView → union (non-convex) → ClipPlaneSet forces - // scissor fallback. HasOutsideView must still be true, TerrainMode must be - // Scissor, and OutsideViewNdcAabb must equal the union bounds (same values - // as TerrainScissorNdcAabb in this mode). var pv = new PortalVisibilityFrame(); pv.OutsideView.Add(Square(-0.6f, 0f, 0.15f)); pv.OutsideView.Add(Square(0.6f, 0f, 0.15f)); - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); Assert.True(asm.HasOutsideView); - Assert.Equal(TerrainClipMode.Scissor, asm.TerrainMode); + Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); + Assert.Equal(2, asm.OutsideViewSlices.Length); - // Union bounds from the CellView (spans both squares). - var expectedAabb = new System.Numerics.Vector4( + var expected = new Vector4( pv.OutsideView.MinX, pv.OutsideView.MinY, pv.OutsideView.MaxX, pv.OutsideView.MaxY); - Assert.Equal(expectedAabb, asm.OutsideViewNdcAabb); - - // In Scissor mode OutsideViewNdcAabb and TerrainScissorNdcAabb are the same - // value (both are the union CellView bounds). - Assert.Equal(asm.TerrainScissorNdcAabb, asm.OutsideViewNdcAabb); + Assert.Equal(expected, asm.OutsideViewNdcAabb); + Assert.Equal(Vector4.Zero, asm.TerrainScissorNdcAabb); } [Fact] public void Assemble_EmptyOutsideView_HasOutsideViewFalse_AabbZero() { - // An empty OutsideView means no exit portal was in view → TerrainMode.Skip, - // HasOutsideView false, OutsideViewNdcAabb degenerate zero. const uint cellA = 0xA9B40100; var pv = new PortalVisibilityFrame(); pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f)); pv.OrderedVisibleCells.Add(cellA); - // OutsideView left empty (no exit portal). - var frame = ClipFrame.NoClip(); - var asm = ClipFrameAssembler.Assemble(frame, pv); + var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); Assert.False(asm.HasOutsideView); Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode); - Assert.Equal(System.Numerics.Vector4.Zero, asm.OutsideViewNdcAabb); + Assert.Equal(Vector4.Zero, asm.OutsideViewNdcAabb); } [Fact] public void Reset_ReusesFrame_NoSlotLeakAcrossAssemblies() { - // First assembly packs 2 cells + outdoor (4 slots). Re-assembling the SAME - // frame from a smaller pvFrame must Reset back to slot 0 — no leak. var frame = ClipFrame.NoClip(); var pv1 = new PortalVisibilityFrame(); @@ -249,17 +195,15 @@ public class ClipFrameAssemblerTests var asm1 = ClipFrameAssembler.Assemble(frame, pv1); Assert.Equal(4, asm1.Frame.SlotCount); - // Second assembly: a single cell, no OutsideView. var pv2 = new PortalVisibilityFrame(); pv2.CellViews[0xA9B40200] = ViewOf(Square(0f, 0f, 0.25f)); pv2.OrderedVisibleCells.Add(0xA9B40200); var asm2 = ClipFrameAssembler.Assemble(frame, pv2); - // slot 0 + 1 cell = 2 — the prior 4-slot state did not leak. Assert.Equal(2, asm2.Frame.SlotCount); - Assert.True(asm2.CellIdToSlot.ContainsKey(0xA9B40200)); - Assert.False(asm2.CellIdToSlot.ContainsKey(0xA9B40100)); // gone after Reset - Assert.False(asm2.OutdoorVisible); // no OutsideView this time + Assert.Contains(0xA9B40200, asm2.CellIdToSlot.Keys); + Assert.DoesNotContain(0xA9B40100, asm2.CellIdToSlot.Keys); + Assert.False(asm2.OutdoorVisible); Assert.Equal(TerrainClipMode.Skip, asm2.TerrainMode); } } diff --git a/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs b/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs index 1c2a82ab..52bca483 100644 --- a/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs @@ -252,4 +252,5 @@ public class ClipPlaneSetTests // need a real AABB; a zero-area line has none). Assert.True(cps.IsNothingVisible); } + } diff --git a/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs b/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs index 507f4d04..213fe451 100644 --- a/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs @@ -10,6 +10,8 @@ public class InteriorEntityPartitionTests { private const uint CellA = 0xA9B40170; private const uint CellB = 0xA9B40171; + private const uint HiddenCell = 0xA9B40199; + private const uint OutdoorCell = 0xA9B40020; private static WorldEntity Ent(uint id, uint serverGuid, uint? parentCell) => new() { @@ -28,39 +30,44 @@ public class InteriorEntityPartitionTests (IReadOnlyDictionary?)null) }; [Fact] - public void Partitions_ByServerGuidThenParentCell_IntoThreeBuckets() + public void Partitions_LiveAndStaticEntities_ByCellOutdoorAndFallback() { - var livePlayer = Ent(1, serverGuid: 0x5000000A, parentCell: null); // live-dynamic - var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA); // live-dynamic WINS over cell - var staticA = Ent(3, serverGuid: 0, parentCell: CellA); // per-cell static - var staticB = Ent(4, serverGuid: 0, parentCell: CellB); // per-cell static - var scenery = Ent(5, serverGuid: 0, parentCell: null); // outdoor scenery + var unresolvedLive = Ent(1, serverGuid: 0x5000000A, parentCell: null); + var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA); + var staticA = Ent(3, serverGuid: 0, parentCell: CellA); + var staticB = Ent(4, serverGuid: 0, parentCell: CellB); + var scenery = Ent(5, serverGuid: 0, parentCell: null); + var liveOutdoor = Ent(6, serverGuid: 0x80005678, parentCell: OutdoorCell); var visible = new HashSet { CellA, CellB }; var result = InteriorEntityPartition.Partition( - visible, OneLb(0xA9B4FFFF, livePlayer, liveNpcInCell, staticA, staticB, scenery)); + visible, OneLb(0xA9B4FFFF, unresolvedLive, liveNpcInCell, staticA, staticB, scenery, liveOutdoor)); - Assert.Equal(2, result.LiveDynamic.Count); // player + npc (serverGuid != 0) - Assert.Contains(livePlayer, result.LiveDynamic); - Assert.Contains(liveNpcInCell, result.LiveDynamic); + Assert.Single(result.LiveDynamic); + Assert.Contains(unresolvedLive, result.LiveDynamic); - Assert.Single(result.ByCell[CellA]); // only staticA (npc went live-dynamic) + Assert.Equal(2, result.ByCell[CellA].Count); + Assert.Contains(liveNpcInCell, result.ByCell[CellA]); Assert.Contains(staticA, result.ByCell[CellA]); Assert.Single(result.ByCell[CellB]); Assert.Contains(staticB, result.ByCell[CellB]); - Assert.Single(result.Outdoor); + Assert.Equal(2, result.Outdoor.Count); Assert.Contains(scenery, result.Outdoor); + Assert.Contains(liveOutdoor, result.Outdoor); } [Fact] - public void Static_InNonVisibleCell_IsDropped() + public void IndoorEntity_InNonVisibleCell_IsDropped() { - var staticHidden = Ent(3, serverGuid: 0, parentCell: 0xA9B40199); // not in the visible set + var staticHidden = Ent(3, serverGuid: 0, parentCell: HiddenCell); + var liveHidden = Ent(4, serverGuid: 0x80001234, parentCell: HiddenCell); var visible = new HashSet { CellA }; - var result = InteriorEntityPartition.Partition(visible, OneLb(0xA9B4FFFF, staticHidden)); - Assert.False(result.ByCell.ContainsKey(0xA9B40199)); + var result = InteriorEntityPartition.Partition( + visible, OneLb(0xA9B4FFFF, staticHidden, liveHidden)); + + Assert.False(result.ByCell.ContainsKey(HiddenCell)); Assert.Empty(result.Outdoor); Assert.Empty(result.LiveDynamic); } diff --git a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs index c5e1e2bb..56b29c27 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs @@ -169,4 +169,175 @@ public class PortalProjectionTests Assert.True(onScreen.Length >= 3, "the cell behind a doorway you're standing in must stay visible (the void bug)"); } + + // --------------------------------------------------------------------------- + // Faithful homogeneous (w-space) portal clip — port of retail PView::GetClip + + // PrimD3DRender::xformStart + ACRender::polyClipFinish (decomp 432344 / 424310 / + // 702749). The early divide + fixed side-plane clamp (ProjectToNdc) collapsed + // grazing/near portals to zero-area edge slivers (-> the flap) and near doorways + // to empty (-> the void/fallback). The faithful pipeline keeps homogeneous clip + // coords (ProjectToClip — eye-plane clip only, no divide) and runs Sutherland- + // Hodgman against the view region with w-aware edge tests, dividing the survivors + // only after they are bounded to the region (ClipToRegion). 2026-06-06. + // --------------------------------------------------------------------------- + + private static Vector2[] FullScreenCcw() => new[] + { + new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f), + }; + + [Fact] + public void ProjectToClip_QuadInFront_KeepsVertsWithPositiveW() + { + var poly = new[] + { + new Vector3(-1, -1, -5), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, -5), + }; + var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()); + Assert.True(clip.Length >= 3); + foreach (var v in clip) + Assert.True(v.W > 0f, $"an in-front portal vertex must keep w>0 (homogeneous), got w={v.W}"); + } + + [Fact] + public void ProjectToClip_QuadFullyBehind_ReturnsEmpty() + { + var poly = new[] + { + new Vector3(-1, -1, 5), new Vector3(1, -1, 5), new Vector3(1, 1, 5), new Vector3(-1, 1, 5), + }; + Assert.True(PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()).Length < 3); + } + + [Fact] + public void ClipToRegion_OnScreenQuad_ReturnsBoundedNdc() + { + // A small 2x2 quad at z=-5 is fully on-screen; clipping against the full screen + // returns it bounded to [-1,1]. + var poly = new[] + { + new Vector3(-1, -1, -5), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, -5), + }; + var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()); + var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw()); + Assert.True(ndc.Length >= 3); + foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); } + } + + [Fact] + public void ClipToRegion_FullyOffScreen_ReturnsEmpty_NotSliver() + { + // The flap's root: a portal entirely off one screen edge. The old early-divide + + // side-plane clamp collapsed it to a zero-area sliver pinned to x=1.0 (proj=3) that + // propagated a degenerate region one hop and then died; the faithful clip returns + // EMPTY so the flood stops cleanly. Quad at z=-5, x in [3,5] -> ndc x ~[1.1,1.8], off right. + var poly = new[] + { + new Vector3(3, -1, -5), new Vector3(5, -1, -5), new Vector3(5, 1, -5), new Vector3(3, 1, -5), + }; + var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()); + var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw()); + Assert.True(ndc.Length < 3, $"fully off-screen portal must clip to empty, got {ndc.Length} verts (sliver)"); + } + + [Fact] + public void ClipToRegion_PartlyOffScreen_ClipsToBoundedNonEmpty() + { + // A quad straddling the right edge -> clipped to the on-screen part, bounded, non-empty. + var poly = new[] + { + new Vector3(0, -1, -5), new Vector3(4, -1, -5), new Vector3(4, 1, -5), new Vector3(0, 1, -5), + }; + var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()); + var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw()); + Assert.True(ndc.Length >= 3, "a partly-on-screen portal must produce a non-empty clipped region"); + foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); } + } + + [Fact] + public void ClipToRegion_DoorwayEyeLooksThrough_CoversScreen_WithoutFallback() + { + // The void frame: chase eye 0.28 m from a 2x2 m doorway, looking through it. The doorway + // subtends the whole screen. The faithful clip keeps the homogeneous verts (no early-divide + // blow-up) and clips to the screen quad -> covers the screen (non-empty). This is what makes + // the EyeInsidePortalOpening *fallback* unnecessary for an in-front doorway. + var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1.0f, 5000f); + var viewProj = view * proj; + var doorway = new[] + { + new Vector3(-1f, -1f, -0.28f), new Vector3(1f, -1f, -0.28f), + new Vector3(1f, 1f, -0.28f), new Vector3(-1f, 1f, -0.28f), + }; + var clip = PortalProjection.ProjectToClip(doorway, Matrix4x4.Identity, viewProj); + var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw()); + Assert.True(ndc.Length >= 3, "a doorway the eye looks through must cover the screen, not collapse to the void"); + foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); } + } + + [Fact] + public void ClipToRegion_StraddlingEye_OnScreenBounded_NoBlowup() + { + // A portal spanning from behind (z=+2) to in front (z=-5). The faithful clip keeps the + // in-front part and bounds it to the screen — no perspective-inversion blow-up, non-empty. + var poly = new[] + { + new Vector3(-1, -1, 2), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, 2), + }; + var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()); + var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw()); + Assert.True(ndc.Length >= 3); + foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); } + } + + private static float AbsArea(Vector2[] p) + { + if (p == null || p.Length < 3) return 0f; + float a2 = 0f; + for (int i = 0; i < p.Length; i++) { var u = p[i]; var w = p[(i + 1) % p.Length]; a2 += u.X * w.Y - w.X * u.Y; } + return MathF.Abs(a2) * 0.5f; + } + + [Fact] + public void ClipToRegion_SubjectFullyInsideRegion_ReturnsSubjectNotRegion() + { + // Regression for Build_AppliesReciprocalOtherPortalClip: a NARROW subject fully inside a WIDE + // region must return the narrow (subject ∩ region = subject), NOT the wide region. The builder's + // reciprocal clip is exactly this shape (reciprocal opening ∩ near-side region). + var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f); + var vp = view * proj; + var narrow = new[] { new Vector3(-0.3f, -0.9f, -3f), new Vector3(0.3f, -0.9f, -3f), new Vector3(0.3f, 0.9f, -3f), new Vector3(-0.3f, 0.9f, -3f) }; + var wide = new[] { new Vector3(-0.9f, -0.9f, -3f), new Vector3(0.9f, -0.9f, -3f), new Vector3(0.9f, 0.9f, -3f), new Vector3(-0.9f, 0.9f, -3f) }; + + var narrowClip = PortalProjection.ProjectToClip(narrow, Matrix4x4.Identity, vp); + // Build the region exactly as the builder does (clip wide against the full screen → CCW region). + var wideRegion = PortalProjection.ClipToRegion(PortalProjection.ProjectToClip(wide, Matrix4x4.Identity, vp), FullScreenCcw()); + + var clipped = PortalProjection.ClipToRegion(narrowClip, wideRegion); + float narrowArea = AbsArea(PortalProjection.ClipToRegion(narrowClip, FullScreenCcw())); + float wideArea = AbsArea(wideRegion); + float clippedArea = AbsArea(clipped); + Assert.True(clippedArea <= narrowArea + 1e-3f, + $"subject∩region must be the narrow subject (area {narrowArea}), not the wide region (area {wideArea}); got {clippedArea}"); + } + + [Fact] + public void ClipToRegion_AgainstSubRegion_TightensToIntersection() + { + // The region clip is the propagation step: clipping a wide on-screen portal against a + // narrower view region must yield the intersection (the narrow region), not the wide portal. + var wide = new[] + { + new Vector3(-2, -2, -5), new Vector3(2, -2, -5), new Vector3(2, 2, -5), new Vector3(-2, 2, -5), + }; + var clip = PortalProjection.ProjectToClip(wide, Matrix4x4.Identity, ViewProj()); + var narrow = new[] + { + new Vector2(-0.3f, -0.3f), new Vector2(0.3f, -0.3f), new Vector2(0.3f, 0.3f), new Vector2(-0.3f, 0.3f), + }; + var ndc = PortalProjection.ClipToRegion(clip, narrow); + Assert.True(ndc.Length >= 3); + foreach (var v in ndc) { Assert.InRange(v.X, -0.301f, 0.301f); Assert.InRange(v.Y, -0.301f, 0.301f); } + } } diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index 18aae05e..746a6f44 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -98,6 +98,61 @@ public class PortalVisibilityBuilderTests "a degenerate portal the eye is NOT standing in must stay culled (no over-inclusion / #95 blowup)"); } + [Fact] + public void Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour() + { + // Live cellar capture (2026-06-06): 0174->0175 was traversable, but the portal projected to + // zero vertices while the chase camera was about 1.4 m from the opening plane. The flood must + // still reach the stair connector; otherwise the main-floor shell/floor disappears. + var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); + cam.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, 1.4f)); // behind eye: ProjectToNdc collapses + var stairs = Cell(0x0002); + var all = new Dictionary { [0x0001] = cam, [0x0002] = stairs }; + var vp = ViewProj(); + + Assert.True(PortalProjection.ProjectToNdc(cam.PortalPolygons[0], Matrix4x4.Identity, vp).Length < 3); + + var frame = PortalVisibilityBuilder.Build( + cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp); + + Assert.Contains(0x0002u, frame.OrderedVisibleCells); + } + + [Fact] + public void Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit() + { + // Retail PView tracks update_count vs view_count. If B is processed through a LEFT slice, then + // a later path reaches B through a RIGHT slice, B must propagate that new RIGHT slice to its + // exit portal. Enqueue-once builders flap here: OutsideView stays empty until the camera moves + // enough to discover B in the other order. + const uint A = 0x0001, B = 0x0002, D = 0x0003; + + var a = Cell(A, + new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 0), + new CellPortalInfo((ushort)D, PolygonId: 1, Flags: 0, OtherPortalId: 0)); + a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // nearer LEFT path to B + a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // farther RIGHT path to D + + var b = Cell(B, + new CellPortalInfo((ushort)A, PolygonId: 0, Flags: 0, OtherPortalId: 0), + new CellPortalInfo(0xFFFF, PolygonId: 1, Flags: 0, OtherPortalId: 0), + new CellPortalInfo((ushort)D, PolygonId: 2, Flags: 0, OtherPortalId: 0)); + b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // reciprocal LEFT back to A + b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // RIGHT exit, invisible from LEFT slice + b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // reciprocal RIGHT back to D + + var d = Cell(D, new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 2)); + d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // later RIGHT path into B + + var all = new Dictionary { [A] = a, [B] = b, [D] = d }; + + var frame = Build(a, all); + + Assert.Contains(B, frame.OrderedVisibleCells); + Assert.Contains(D, frame.OrderedVisibleCells); + Assert.False(frame.OutsideView.IsEmpty); + } + [Fact] public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty() { @@ -454,6 +509,117 @@ public class PortalVisibilityBuilderTests "No exit portal in any reachable cell must leave OutsideView empty"); } + [Fact] + public void BuildFromExterior_SeedsInteriorCellThroughOutsidePortal() + { + var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); + room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); + room.ClipPlanes.Add(new PortalClipPlane + { + Normal = new Vector3(0f, 0f, 1f), + D = 3f, + InsideSide = 1, + }); + + var frame = PortalVisibilityBuilder.BuildFromExterior( + new[] { room }, + Vector3.Zero, + id => id == room.CellId ? room : null, + ViewProj()); + + Assert.Contains(room.CellId, frame.OrderedVisibleCells); + Assert.True(frame.CellViews.TryGetValue(room.CellId, out var view)); + Assert.False(view!.IsEmpty); + Assert.True(view.MaxX - view.MinX < 1.0f, + "exterior seed should be clipped to the door opening, not full-screen"); + } + + [Fact] + public void BuildFromExterior_DoesNotSeedWhenCameraIsOnInteriorSide() + { + var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); + room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); + room.ClipPlanes.Add(new PortalClipPlane + { + Normal = new Vector3(0f, 0f, 1f), + D = -1f, + InsideSide = 1, + }); + + var frame = PortalVisibilityBuilder.BuildFromExterior( + new[] { room }, + Vector3.Zero, + id => id == room.CellId ? room : null, + ViewProj()); + + Assert.Empty(frame.OrderedVisibleCells); + Assert.Empty(frame.CellViews); + } + + [Fact] + public void BuildFromExterior_TraversesDeeperInteriorPortals() + { + var entry = Cell(0x0001, + new CellPortalInfo(0xFFFF, 0, 0, 0), + new CellPortalInfo(0x0002, 1, 0, 0)); + entry.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -3f)); + entry.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, -5f)); + entry.ClipPlanes.Add(new PortalClipPlane + { + Normal = new Vector3(0f, 0f, 1f), + D = 2f, + InsideSide = 1, + }); + + var backRoom = Cell(0x0002); + var all = new Dictionary + { + [entry.CellId] = entry, + [backRoom.CellId] = backRoom, + }; + + var frame = PortalVisibilityBuilder.BuildFromExterior( + new[] { entry }, + Vector3.Zero, + id => all.TryGetValue(id, out var c) ? c : null, + ViewProj()); + + Assert.Equal(new uint[] { 0x0001, 0x0002 }, frame.OrderedVisibleCells.ToArray()); + Assert.True(frame.CellViews.ContainsKey(0x0002)); + } + + [Fact] + public void BuildFromExterior_MaxSeedDistanceSkipsDistantExitPortal() + { + var nearby = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); + nearby.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); + nearby.ClipPlanes.Add(new PortalClipPlane + { + Normal = new Vector3(0f, 0f, 1f), + D = 3f, + InsideSide = 1, + }); + + var distant = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0)); + distant.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -200f)); + distant.ClipPlanes.Add(new PortalClipPlane + { + Normal = new Vector3(0f, 0f, 1f), + D = 199f, + InsideSide = 1, + }); + + var frame = PortalVisibilityBuilder.BuildFromExterior( + new[] { nearby, distant }, + Vector3.Zero, + id => id == nearby.CellId ? nearby : id == distant.CellId ? distant : null, + ViewProj(), + maxSeedDistance: 48f); + + Assert.Contains(nearby.CellId, frame.OrderedVisibleCells); + Assert.DoesNotContain(distant.CellId, frame.OrderedVisibleCells); + } + [Fact] public void Build_RootCellAlwaysFirstInOrderedVisibleCells() { diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs index 9a258119..a596f2cc 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs @@ -1,20 +1,3 @@ -// Tests for WbDrawDispatcher's Phase U.4 per-instance clip-slot resolution -// (ResolveEntitySlot / ResolveSlotForFrame). Code review of the U.4 commit -// (7993e06) flagged this gate-critical routing as untested: if it breaks, -// every indoor instance is sent to the wrong clip slot (or wrongly culled), -// producing total visual garbage at the portal-visibility gate. The logic is -// a pure function of (ServerGuid, ParentCellId, the clip-routing state), so we -// extract it to internal static helpers and test the branches directly — no GL -// context required. -// -// Branch map (ResolveSlotForFrame, the call-site policy): -// routing inactive (outdoor root) → slot 0, NOT culled (≡ U.3) -// ServerGuid != 0 (live dynamic) → slot 0, NOT culled (unclipped) -// ParentCellId in cellIdToSlot → that cell's slot -// ParentCellId NOT in cellIdToSlot → CULL -// ParentCellId == null, outdoorVisible → outdoorSlot -// ParentCellId == null, !outdoorVisible → CULL - using System.Collections.Generic; using AcDream.App.Rendering.Wb; using Xunit; @@ -23,13 +6,10 @@ namespace AcDream.App.Tests.Rendering.Wb; public sealed class WbDrawDispatcherClipSlotTests { - // Full cell-id space keys (lbMask | OtherCellId). 0xA9B4 is the Holtburg - // landblock prefix used throughout the indoor-walking work; the low word is - // the EnvCell index. ParentCellId on a cell static is the SAME full id — see - // the L.2e bare-low-byte finding (a 0x29 low-byte key would cull everything). private const uint VisibleCellA = 0xA9B4_0164u; private const uint VisibleCellB = 0xA9B4_0165u; private const uint NotVisibleCell = 0xA9B4_0999u; + private const uint OutdoorCell = 0xA9B4_0020u; private const int SlotA = 3; private const int SlotB = 7; @@ -41,30 +21,44 @@ public sealed class WbDrawDispatcherClipSlotTests [VisibleCellB] = SlotB, }; - // ── Raw resolver (ResolveEntitySlot): only reached when routing is active ── - [Fact] - public void RawResolve_LiveEntity_IsUnclippedSlot0_WhenParentCellNull() + public void RawResolve_LiveEntity_WithVisibleIndoorParent_GetsThatCellSlot() { - // ServerGuid != 0 ⇒ unclipped (slot 0) regardless of cell state. - int slot = WbDrawDispatcher.ResolveEntitySlot( - serverGuid: 0x5000_000Au, parentCellId: null, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - - Assert.Equal(0, slot); - } - - [Fact] - public void RawResolve_LiveEntity_IsUnclippedSlot0_EvenWhenParentCellVisible() - { - // A live entity whose ParentCellId IS a visible cell still goes to slot 0, - // NOT SlotA — the live-dynamic check must precede the cell lookup. int slot = WbDrawDispatcher.ResolveEntitySlot( serverGuid: 0x5000_000Au, parentCellId: VisibleCellA, cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - Assert.Equal(0, slot); - Assert.NotEqual(SlotA, slot); // guards against ordering regression + Assert.Equal(SlotA, slot); + } + + [Fact] + public void RawResolve_LiveEntity_WithHiddenIndoorParent_IsCulled() + { + int slot = WbDrawDispatcher.ResolveEntitySlot( + serverGuid: 0x5000_000Au, parentCellId: NotVisibleCell, + cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); + + Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot); + } + + [Fact] + public void RawResolve_LiveEntity_WithOutdoorParent_UsesOutsideViewWhenVisible() + { + int slot = WbDrawDispatcher.ResolveEntitySlot( + serverGuid: 0x5000_000Au, parentCellId: OutdoorCell, + cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); + + Assert.Equal(OutsideViewSlot, slot); + } + + [Fact] + public void RawResolve_LiveEntity_WithParentNull_IsCulledWhenRoutingActive() + { + int slot = WbDrawDispatcher.ResolveEntitySlot( + serverGuid: 0x5000_000Au, parentCellId: null, + cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); + + Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot); } [Fact] @@ -107,19 +101,9 @@ public sealed class WbDrawDispatcherClipSlotTests Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot); } - // ── Call-site policy (ResolveSlotForFrame): adds the clipRoutingActive gate ── - // Cases mirror the raw resolver but return the (slot, culled) pair the loop - // body consumes, and add the routing-inactive (outdoor-root) branch. - [Fact] public void ForFrame_RoutingInactive_EveryEntityIsSlot0AndNotCulled() { - // The bit-identical-to-U.3 property: when the camera is at an outdoor root - // (ClearClipRouting), ResolveEntitySlot is never consulted — every entity - // maps to slot 0 and nothing is clip-culled. Exercised here for BOTH a - // live entity and a cell static that would otherwise cull, with a null - // routing map to prove the resolver is bypassed entirely. - var live = WbDrawDispatcher.ResolveSlotForFrame( clipRoutingActive: false, serverGuid: 0x5000_000Au, parentCellId: null, cellIdToSlot: null, outdoorSlot: OutsideViewSlot, outdoorVisible: true); @@ -134,16 +118,27 @@ public sealed class WbDrawDispatcherClipSlotTests } [Fact] - public void ForFrame_RoutingActive_LiveEntity_Slot0NotCulled() + public void ForFrame_RoutingActive_LiveEntityVisible_GetsCellSlotNotCulled() { var r = WbDrawDispatcher.ResolveSlotForFrame( clipRoutingActive: true, serverGuid: 0x5000_000Au, parentCellId: VisibleCellA, cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - Assert.Equal(0u, r.Slot); + Assert.Equal((uint)SlotA, r.Slot); Assert.False(r.Culled); } + [Fact] + public void ForFrame_RoutingActive_LiveEntityParentNull_Culled() + { + var r = WbDrawDispatcher.ResolveSlotForFrame( + clipRoutingActive: true, serverGuid: 0x5000_000Au, parentCellId: null, + cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); + + Assert.True(r.Culled); + Assert.Equal(0u, r.Slot); + } + [Fact] public void ForFrame_RoutingActive_CellStaticVisible_GetsCellSlotNotCulled() { @@ -163,7 +158,6 @@ public sealed class WbDrawDispatcherClipSlotTests cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); Assert.True(r.Culled); - // When culled the loop body forces slot 0 (the value is never emitted). Assert.Equal(0u, r.Slot); } diff --git a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs index 9fd9232f..fc6e0708 100644 --- a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs @@ -96,40 +96,40 @@ public sealed class RenderingDiagnosticsTests // PLAYER's cell, 0x451e80), NOT the camera cell. acdream previously branched on the // camera cell, so a chase camera lagging in a doorway while the player was already // outside took the DrawInside path and degenerated to a grey world + entities showing - // through walls. These pin the player-keyed branch (DrawInside root stays the viewer cell). + // through walls. These pin the player-keyed branch and loaded player-root requirement. [Fact] public void ShouldRenderIndoor_PlayerOutside_CameraInside_ReturnsFalse() { // THE doorway-grey regression: the player stepped onto a landcell (0x...0031) but the // chase camera still resolves an interior EnvCell. Branch on the PLAYER → outdoor. - Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, viewerCellResolved: true)); + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, renderRootResolved: true)); } [Fact] public void ShouldRenderIndoor_PlayerInside_CameraInside_ReturnsTrue() { - Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, viewerCellResolved: true)); + Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, renderRootResolved: true)); } [Fact] - public void ShouldRenderIndoor_PlayerInside_NoViewerCell_ReturnsFalse() + public void ShouldRenderIndoor_PlayerInside_RootNotLoaded_ReturnsFalse() { // Opposite lag (camera pulled outside while the player is inside): no viewer cell to // root DrawInside at → outdoor. Defensive; matches prior null-CameraCell behavior. - Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, viewerCellResolved: false)); + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, renderRootResolved: false)); } [Fact] public void ShouldRenderIndoor_PlayerOutside_CameraOutside_ReturnsFalse() { - Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, viewerCellResolved: false)); + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, renderRootResolved: false)); } [Fact] public void ShouldRenderIndoor_UnknownPlayerCell_TreatedAsOutside_ReturnsFalse() { // playerCellId == 0 (unresolved) → treat as outside (safe default: outdoor render). - Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0u, viewerCellResolved: true)); + Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0u, renderRootResolved: true)); } } diff --git a/tools/TextureDump/Program.cs b/tools/TextureDump/Program.cs index 86cf43f1..f8ab4db2 100644 --- a/tools/TextureDump/Program.cs +++ b/tools/TextureDump/Program.cs @@ -21,11 +21,19 @@ string outDir = Path.Combine(AppContext.BaseDirectory, "out"); Directory.CreateDirectory(outDir); Console.WriteLine($"outDir = {outDir}"); -// Original ask: 0x050016A4..0x050016A8. A4/A5/A7/A8 don't exist as files; widen -// to 0x050016A0..0x050016AF to catch any related precip textures. -var idList = new System.Collections.Generic.List(); -for (uint i = 0x050016A0; i <= 0x050016AF; i++) idList.Add(i); -uint[] ids = idList.ToArray(); +uint[] ids; +if (args.Length > 0) +{ + ids = args.Select(ParseId).ToArray(); +} +else +{ + // Original ask: 0x050016A4..0x050016A8. A4/A5/A7/A8 don't exist as files; widen + // to 0x050016A0..0x050016AF to catch any related precip textures. + var idList = new System.Collections.Generic.List(); + for (uint i = 0x050016A0; i <= 0x050016AF; i++) idList.Add(i); + ids = idList.ToArray(); +} (uint id, double densityFraction)? best = null; @@ -35,15 +43,25 @@ foreach (var id in ids) Console.WriteLine($"=== 0x{id:X8} ==="); RenderSurface? rs = null; + uint lookupId = id; + if (dats.TryGet(id, out var surface) && surface is not null) + { + lookupId = (uint)surface.OrigTextureId; + var color = surface.ColorValue is null + ? "null" + : $"0x{surface.ColorValue.Alpha:X2}{surface.ColorValue.Red:X2}{surface.ColorValue.Green:X2}{surface.ColorValue.Blue:X2}"; + Console.WriteLine($" Surface descriptor, type={surface.Type}, color={color}, origTexture=0x{lookupId:X8}"); + } + // SurfaceTexture wrapper (0x05xxxxxx) → first inner RenderSurface (0x06xxxxxx). - if (dats.TryGet(id, out var st) && st is not null && st.Textures.Count > 0) + if (dats.TryGet(lookupId, out var st) && st is not null && st.Textures.Count > 0) { uint rsid = (uint)st.Textures[0]; Console.WriteLine($" SurfaceTexture wrapper, {st.Textures.Count} mip(s), first = 0x{rsid:X8}"); if (dats.TryGet(rsid, out var inner) && inner is not null) rs = inner; } - else if (dats.TryGet(id, out var direct) && direct is not null) + else if (dats.TryGet(lookupId, out var direct) && direct is not null) { rs = direct; } @@ -226,3 +244,11 @@ static uint Adler32(byte[] data) } return (b << 16) | a; } + +static uint ParseId(string text) +{ + text = text.Trim(); + if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return Convert.ToUInt32(text[2..], 16); + return Convert.ToUInt32(text, 16); +}