feat(render): indoor render WORKS — terminating portal flood + every-cell seal + look-in FPS
Checkpoint of the unified retail-faithful indoor render. The two-week HANG/grey is fixed and the interior seals (live-verified by the user). Commits the session render-rewrite foundation together with the fixes that made it functional. - HANG fix: PortalVisibilityBuilder.Build portal flood did not terminate (the faithful ProjectToClip near-side clip drifts per round, defeating the CellView dedup; the BFS had no bound after U.2a removed MaxReprocessPerCell). Fix = drift-tolerant snapped/canonical CellView.Add dedup (PortalView.cs) plus restored MaxReprocessPerCell=16 bounded re-enqueue (PortalVisibilityBuilder.cs). Re-enqueue is kept (load-bearing for late-slice propagation, Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit); only its count is capped. CellViewDedupTests added. - Seal (DrawCells Task 2): RetailPViewRenderer.DrawEnvCellShells draws EVERY visible cell via IndoorDrawPlan.ShellPass (was gated on the ClipFrameAssembler slot filter, leaving slot-less cells grey). - Look-in FPS: GameWindow exterior look-in candidates limited to the player landblock +-1 (was all ~81 loaded LBs iterated every outdoor frame). No behaviour change (far cells were >48m, already culled). Remaining dominant issue = the FLAP at transitions: viewer-cell metastability (render roots at the camera-eye cell, which oscillates outdoor-indoor as the 3rd-person boom drifts across the doorway, confirmed in render-sig). SEPARATE fix, NOT the DrawCells port. Full handoff + flap fix plan + tracked follow-ups (#78 terrain, look-in-from-inside, look-in FPS, L-spotlight): docs/research/2026-06-07-indoor-render-session-handoff.md. Baselines: build 0 err; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bff1955066
commit
1405dd8e90
27 changed files with 3635 additions and 814 deletions
|
|
@ -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.
|
||||
163
docs/research/2026-06-06-indoor-render-hang-rootcause.md
Normal file
163
docs/research/2026-06-06-indoor-render-hang-rootcause.md
Normal file
|
|
@ -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<ViewPolygon>)
|
||||
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.
|
||||
|
|
@ -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.
|
||||
```
|
||||
|
||||
160
docs/research/2026-06-07-indoor-render-session-handoff.md
Normal file
160
docs/research/2026-06-07-indoor-render-session-handoff.md
Normal file
|
|
@ -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.
|
||||
```
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// How the terrain (single OutsideView region) should be drawn this frame.
|
||||
/// How the landscape-through-outside_view pass should be interpreted.
|
||||
/// </summary>
|
||||
public enum TerrainClipMode
|
||||
{
|
||||
/// <summary>OutsideView reduced to convex planes — terrain gated via the UBO
|
||||
/// (<see cref="ClipFrame.SetTerrainClip"/> already applied by the assembler).</summary>
|
||||
/// <summary>All outside_view slices have convex plane clips.</summary>
|
||||
Planes,
|
||||
|
||||
/// <summary>OutsideView exceeded the convex budget — the call site sets a
|
||||
/// glScissor to <see cref="ClipFrameAssembly.TerrainScissorNdcAabb"/> around ONLY
|
||||
/// the terrain draw; the UBO is left at count 0 (ungated).</summary>
|
||||
/// <summary>At least one outside_view slice requires scissor fallback.</summary>
|
||||
Scissor,
|
||||
|
||||
/// <summary>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.</summary>
|
||||
/// <summary>No outside_view slice is visible; skip landscape indoors.</summary>
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: the populated
|
||||
/// <see cref="ClipFrame"/> (CPU bytes ready; caller does <c>UploadShared</c>) 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.
|
||||
/// </summary>
|
||||
public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: populated clip buffers
|
||||
/// plus routing data consumed by the render orchestration.
|
||||
/// </summary>
|
||||
public sealed class ClipFrameAssembly
|
||||
{
|
||||
/// <summary>The per-frame clip data. Caller uploads it via
|
||||
/// <see cref="ClipFrame.UploadShared"/> then hands its
|
||||
/// <see cref="ClipFrame.RegionSsbo"/> / <see cref="ClipFrame.TerrainUbo"/> to the
|
||||
/// renderers.</summary>
|
||||
public required ClipFrame Frame { get; init; }
|
||||
|
||||
/// <summary>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.</summary>
|
||||
/// <summary>First drawable slice slot per visible cell. Compatibility map
|
||||
/// for renderer APIs that can accept only one slot at a time.</summary>
|
||||
public required Dictionary<uint, int> CellIdToSlot { get; init; }
|
||||
|
||||
/// <summary>Slot for outdoor scenery / building-shell instances (ParentCellId
|
||||
/// == null) while the camera is indoors. Meaningful only when
|
||||
/// <see cref="OutdoorVisible"/> is true. 0 ⇒ no-clip (scissor fallback or trivial).</summary>
|
||||
/// <summary>Slot-only cell slices, retained for older renderer APIs.</summary>
|
||||
public required Dictionary<uint, int[]> CellIdToViewSlots { get; init; }
|
||||
|
||||
/// <summary>Full retail portal_view slices per visible cell.</summary>
|
||||
public required Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; init; }
|
||||
|
||||
/// <summary>Full retail outside_view slices.</summary>
|
||||
public required ClipViewSlice[] OutsideViewSlices { get; init; }
|
||||
|
||||
public required int OutdoorSlot { get; init; }
|
||||
|
||||
/// <summary>False ⇒ the OutsideView is empty; outdoor scenery / shells are
|
||||
/// CULLED this frame (camera sees no outdoors through any portal chain).</summary>
|
||||
public required bool OutdoorVisible { get; init; }
|
||||
|
||||
/// <summary>How to draw terrain (planes already applied to the UBO / scissor /
|
||||
/// skip). See <see cref="TerrainClipMode"/>.</summary>
|
||||
public required TerrainClipMode TerrainMode { get; init; }
|
||||
|
||||
/// <summary>NDC AABB (minX,minY,maxX,maxY) for the terrain glScissor when
|
||||
/// <see cref="TerrainMode"/> is <see cref="TerrainClipMode.Scissor"/>. Unused otherwise.</summary>
|
||||
public required Vector4 TerrainScissorNdcAabb { get; init; }
|
||||
|
||||
/// <summary>True ⇒ the OutsideView (the exit-portal screen region) is meaningfully visible this
|
||||
/// frame — the camera can see outdoors through a portal chain (<see cref="TerrainMode"/> is
|
||||
/// <see cref="TerrainClipMode.Planes"/> or <see cref="TerrainClipMode.Scissor"/>). False ⇒ a
|
||||
/// sealed interior with no exit portal in view (<see cref="TerrainClipMode.Skip"/>). Drives the
|
||||
/// Stage 4 sky/weather draw + the conditional doorway Z-clear. Always false on the outdoor root
|
||||
/// (the caller does not invoke <see cref="ClipFrameAssembler.Assemble"/> there).</summary>
|
||||
public required bool HasOutsideView { get; init; }
|
||||
|
||||
/// <summary>NDC AABB (minX,minY,maxX,maxY) of the OutsideView screen region — the doorway
|
||||
/// opening's bounding box. Computed whenever <see cref="HasOutsideView"/> is true, for BOTH the
|
||||
/// Planes and Scissor terrain modes (unlike <see cref="TerrainScissorNdcAabb"/>, 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
|
||||
/// (<see cref="Vector4.Zero"/>) when <see cref="HasOutsideView"/> is false.</summary>
|
||||
public required Vector4 OutsideViewNdcAabb { get; init; }
|
||||
|
||||
// ---- Probe data (ACDREAM_PROBE_VIS / RenderingDiagnostics.EmitVis) --------
|
||||
|
||||
/// <summary>Plane count the OutsideView reduced to (0 ⇒ scissor or empty).</summary>
|
||||
// Probe data.
|
||||
public required int OutsidePlaneCount { get; init; }
|
||||
|
||||
/// <summary>Per-cell clip-plane count (cell id → plane count) for the probe.
|
||||
/// A scissor-fallback cell records 0 here (it maps to slot 0).</summary>
|
||||
public required Dictionary<uint, int> PerCellPlaneCounts { get; init; }
|
||||
|
||||
/// <summary>Number of regions (cells + OutsideView) that fell back to a scissor
|
||||
/// AABB → no-clip this frame.</summary>
|
||||
public required int ScissorFallbacks { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="ClipFrameAssembly"/> from a <see cref="PortalVisibilityFrame"/>.
|
||||
/// Pure CPU; no GL. The single entry point <see cref="Assemble"/> implements the U.4
|
||||
/// slot/gate policy (file header).
|
||||
/// </summary>
|
||||
public static class ClipFrameAssembler
|
||||
{
|
||||
/// <summary>
|
||||
/// Assemble the per-frame clip data + routing from a portal-visibility frame
|
||||
/// INTO an existing <see cref="ClipFrame"/> — the long-lived GameWindow frame is
|
||||
/// <see cref="ClipFrame.Reset"/>-and-repacked here every frame so its GL buffers
|
||||
/// are reused (no per-frame buffer churn). The returned assembly's
|
||||
/// <see cref="ClipFrameAssembly.Frame"/> is the same instance passed in.
|
||||
/// </summary>
|
||||
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<uint, int>();
|
||||
var cellIdToViewSlots = new Dictionary<uint, int[]>();
|
||||
var cellIdToViewSlices = new Dictionary<uint, ClipViewSlice[]>();
|
||||
var perCellPlaneCounts = new Dictionary<uint, int>();
|
||||
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<ClipViewSlice>(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<Vector4>();
|
||||
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<ClipViewSlice>(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<Vector4>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,18 +5,10 @@ using AcDream.Core.World;
|
|||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Splits a frame's landblock entities into the three draw buckets the per-cell
|
||||
/// <see cref="InteriorRenderer"/> needs, using the SAME precedence as
|
||||
/// <see cref="Wb.WbDrawDispatcher.ResolveEntitySlot"/>:
|
||||
/// <list type="number">
|
||||
/// <item><b>ServerGuid != 0</b> (player / NPCs / items / doors) ⇒ <see cref="Result.LiveDynamic"/>
|
||||
/// — drawn unclipped (depth only). These have no <c>ParentCellId</c> so they MUST be tested first.</item>
|
||||
/// <item><b>ParentCellId</b> in the visible set ⇒ <see cref="Result.ByCell"/>[cell] — per-cell, portal-clipped.</item>
|
||||
/// <item><b>ParentCellId == null</b> (outdoor scenery / building shell) ⇒ <see cref="Result.Outdoor"/>
|
||||
/// — drawn through the doorway, clipped to OutsideView.</item>
|
||||
/// </list>
|
||||
/// A static whose <c>ParentCellId</c> is NOT in <paramref name="visibleCells"/> is dropped (its cell
|
||||
/// isn't drawn this frame). Entities with no <c>MeshRefs</c> 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.
|
||||
/// </summary>
|
||||
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<WorldEntity>();
|
||||
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<uint> 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<WorldEntity>();
|
||||
list.Add(entity);
|
||||
}
|
||||
|
||||
private static bool IsIndoorCellId(uint cellId)
|
||||
{
|
||||
uint low = cellId & 0xFFFFu;
|
||||
return low >= 0x0100u && low != 0xFFFFu;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ public sealed class InteriorRenderContext
|
|||
/// membership filter; <see cref="OrderedVisibleCells"/> supplies the draw ORDER.</summary>
|
||||
public required IReadOnlySet<uint> DrawableCells { get; init; }
|
||||
|
||||
/// <summary>Per-cell portal_view slots, in the same order retail setup_view(cell, i)
|
||||
/// selects them inside PView::DrawCells.</summary>
|
||||
public required IReadOnlyDictionary<uint, int[]> CellClipSlots { get; init; }
|
||||
|
||||
public required int OutdoorSlot { get; init; }
|
||||
public required bool OutdoorVisible { get; init; }
|
||||
|
||||
/// <summary>The 3-bucket entity split (<see cref="InteriorEntityPartition"/>). Only ByCell +
|
||||
/// LiveDynamic are used here; Outdoor scenery is drawn by the caller's landscape-through-door
|
||||
/// step (clipped to OutsideView).</summary>
|
||||
|
|
@ -34,12 +41,11 @@ public sealed class InteriorRenderContext
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<uint> _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<int>();
|
||||
return true;
|
||||
}
|
||||
|
||||
slots = System.Array.Empty<int>();
|
||||
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<WorldEntity> bucket, HashSet<uint>? visibleCellIds)
|
||||
=> DrawEntityBucket(
|
||||
ctx.Camera,
|
||||
ctx.Frustum,
|
||||
ctx.PlayerLandblockId,
|
||||
ctx.AnimatedEntityIds,
|
||||
bucket,
|
||||
visibleCellIds);
|
||||
|
||||
public void DrawEntityBucket(
|
||||
ICamera camera,
|
||||
FrustumPlanes? frustum,
|
||||
uint? playerLandblockId,
|
||||
HashSet<uint>? animatedEntityIds,
|
||||
IReadOnlyList<WorldEntity> bucket,
|
||||
HashSet<uint>? 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<WorldEntity>)bucket,
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)null);
|
||||
|
||||
_entities.Draw(
|
||||
ctx.Camera,
|
||||
camera,
|
||||
new[] { entry },
|
||||
ctx.Frustum,
|
||||
neverCullLandblockId: ctx.PlayerLandblockId,
|
||||
frustum,
|
||||
neverCullLandblockId: playerLandblockId,
|
||||
visibleCellIds: visibleCellIds,
|
||||
animatedEntityIds: ctx.AnimatedEntityIds);
|
||||
animatedEntityIds: animatedEntityIds);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AcDream.Core.Vfx.ParticleEmitter, bool>? 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<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter)
|
||||
{
|
||||
var draws = new List<ParticleDraw>(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
|
||||
|
|
|
|||
|
|
@ -70,6 +70,117 @@ public static class PortalProjection
|
|||
return ndc;
|
||||
}
|
||||
|
||||
/// <summary>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 >= <see cref="EyePlaneW"/>), keeping homogeneous coords — NO perspective
|
||||
/// divide, NO frustum side-plane clamp. The screen bound is applied later by <see cref="ClipToRegion"/>
|
||||
/// 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.</summary>
|
||||
public static Vector4[] ProjectToClip(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
|
||||
{
|
||||
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector4>();
|
||||
|
||||
Matrix4x4 m = cellToWorld * viewProj;
|
||||
var clip = new List<Vector4>(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<Vector4>();
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static Vector2[] ClipToRegion(IReadOnlyList<Vector4> subjectClip, IReadOnlyList<Vector2> regionCcwNdc)
|
||||
{
|
||||
if (subjectClip == null || regionCcwNdc == null || subjectClip.Count < 3 || regionCcwNdc.Count < 3)
|
||||
return System.Array.Empty<Vector2>();
|
||||
|
||||
// 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<Vector4>(subjectClip);
|
||||
int n = regionCcwNdc.Count;
|
||||
for (int e = 0; e < n; e++)
|
||||
{
|
||||
if (poly.Count < 3) return System.Array.Empty<Vector2>();
|
||||
poly = ClipHomogeneousEdge(poly, regionCcwNdc[e], regionCcwNdc[(e + 1) % n]);
|
||||
}
|
||||
if (poly.Count < 3) return System.Array.Empty<Vector2>();
|
||||
|
||||
// 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<Vector4> ClipHomogeneousEdge(List<Vector4> poly, Vector2 a, Vector2 b)
|
||||
{
|
||||
var result = new List<Vector4>(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
|
||||
|
|
|
|||
|
|
@ -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<ViewPolygon> Polygons = new();
|
||||
|
||||
// Canonical (snapped) keys of the polygons in <see cref="Polygons"/>, backing the drift-tolerant
|
||||
// dedup in <see cref="Add"/>. One entry per stored polygon; HashSet membership IS the dedup.
|
||||
private readonly HashSet<string> _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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<uint> { cameraCell.CellId };
|
||||
var queued = new HashSet<uint> { cameraCell.CellId };
|
||||
var drawListed = new HashSet<uint>();
|
||||
var processedViewCounts = new Dictionary<uint, int>();
|
||||
var popCounts = new Dictionary<uint, int>(); // 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<ViewPolygon>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a portal visibility frame for an OUTDOOR viewer looking into one or more
|
||||
/// outside-facing cell portals. This is the reciprocal of <see cref="Build"/>:
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static PortalVisibilityFrame BuildFromExterior(
|
||||
IEnumerable<LoadedCell> candidateCells,
|
||||
Vector3 cameraPos,
|
||||
Func<uint, LoadedCell?> lookup,
|
||||
Matrix4x4 viewProj,
|
||||
float maxSeedDistance = float.PositiveInfinity)
|
||||
{
|
||||
var frame = new PortalVisibilityFrame();
|
||||
var todo = new CellTodoList();
|
||||
var queued = new HashSet<uint>();
|
||||
var drawListed = new HashSet<uint>();
|
||||
var processedViewCounts = new Dictionary<uint, int>();
|
||||
var popCounts = new Dictionary<uint, int>(); // 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<ViewPolygon> ClipPortalAgainstView(
|
||||
Vector3[] localPoly,
|
||||
Matrix4x4 cellToWorld,
|
||||
Matrix4x4 viewProj,
|
||||
IReadOnlyList<ViewPolygon> viewPolygons,
|
||||
out int clipVertexCount)
|
||||
{
|
||||
var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj);
|
||||
clipVertexCount = portalClip.Length;
|
||||
var clippedRegion = new List<ViewPolygon>();
|
||||
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<uint, string> s_portalTraceLastSignature = new();
|
||||
private static int s_portalTraceEmits;
|
||||
|
||||
private sealed class PortalBuildTrace
|
||||
{
|
||||
private readonly uint _rootCellId;
|
||||
private readonly Vector3 _eye;
|
||||
private readonly List<string> _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<ViewPolygon> 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<ViewPolygon> CloneViewPolygons(List<ViewPolygon> source)
|
||||
{
|
||||
var clone = new List<ViewPolygon>(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;
|
||||
|
||||
/// <summary>
|
||||
/// True when the camera eye is "standing in" <paramref name="localPoly"/>'s opening: within
|
||||
|
|
|
|||
374
src/AcDream.App/Rendering/RetailPViewRenderer.cs
Normal file
374
src/AcDream.App/Rendering/RetailPViewRenderer.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// App-layer port of the retail indoor render orchestration:
|
||||
/// SmartBox::RenderNormalMode -> RenderDeviceD3D::DrawInside ->
|
||||
/// PView::DrawInside -> ConstructView -> DrawCells.
|
||||
/// </summary>
|
||||
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<Vector4>());
|
||||
|
||||
private readonly HashSet<uint> _oneCell = new(1);
|
||||
private readonly Dictionary<uint, int> _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<uint>(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<uint>(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<uint> 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<WorldEntity>()));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawEnvCellShells(
|
||||
IRetailPViewCellDrawCallbacks ctx,
|
||||
PortalVisibilityFrame pvFrame,
|
||||
ClipFrameAssembly clipAssembly,
|
||||
HashSet<uint> 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<uint> 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<WorldEntity> bucket,
|
||||
HashSet<uint>? visibleCellIds)
|
||||
{
|
||||
uint lbId = ctx.PlayerLandblockId ?? 0u;
|
||||
var entry = (lbId, Vector3.Zero, Vector3.Zero,
|
||||
(IReadOnlyList<WorldEntity>)bucket,
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)null);
|
||||
|
||||
_entities.Draw(
|
||||
ctx.Camera,
|
||||
new[] { entry },
|
||||
ctx.Frustum,
|
||||
neverCullLandblockId: ctx.PlayerLandblockId,
|
||||
visibleCellIds: visibleCellIds,
|
||||
animatedEntityIds: ctx.AnimatedEntityIds);
|
||||
}
|
||||
|
||||
private void RestoreNoClip(Action<uint> setTerrainClipUbo)
|
||||
{
|
||||
_clipFrame.Reset();
|
||||
UploadClipFrame(setTerrainClipUbo);
|
||||
UseIndoorMembershipOnlyRouting();
|
||||
}
|
||||
|
||||
private void UploadClipFrame(Action<uint> setTerrainClipUbo)
|
||||
{
|
||||
_clipFrame.UploadShared(_gl);
|
||||
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
|
||||
_envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
|
||||
setTerrainClipUbo(_clipFrame.TerrainUbo);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IRetailPViewCellDrawCallbacks
|
||||
{
|
||||
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; }
|
||||
}
|
||||
|
||||
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
|
||||
{
|
||||
public ICamera Camera { get; }
|
||||
public FrustumPlanes? Frustum { get; }
|
||||
public uint? PlayerLandblockId { get; }
|
||||
public HashSet<uint>? 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<uint, LoadedCell?> 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<uint>? 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<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
|
||||
public required Action<uint> SetTerrainClipUbo { get; init; }
|
||||
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
|
||||
public Action<ClipViewSlice>? ClearDepthSlice { get; init; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
|
||||
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RetailPViewPortalDrawContext : IRetailPViewCellDrawContext
|
||||
{
|
||||
public required IEnumerable<LoadedCell> CandidateCells { get; init; }
|
||||
public required Vector3 ViewerEyePos { get; init; }
|
||||
public required Matrix4x4 ViewProjection { get; init; }
|
||||
public required Func<uint, LoadedCell?> 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<uint>? 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<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
|
||||
public required Action<uint> SetTerrainClipUbo { get; init; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
|
||||
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RetailPViewFrameResult
|
||||
{
|
||||
public required PortalVisibilityFrame PortalFrame { get; init; }
|
||||
public required ClipFrameAssembly ClipAssembly { get; init; }
|
||||
public required HashSet<uint> DrawableCells { get; init; }
|
||||
public required InteriorEntityPartition.Result Partition { get; init; }
|
||||
}
|
||||
|
||||
public readonly record struct RetailPViewLandscapeSliceContext(
|
||||
ClipViewSlice Slice,
|
||||
IReadOnlyList<WorldEntity> OutdoorEntities);
|
||||
|
||||
public readonly record struct RetailPViewCellSliceContext(
|
||||
uint CellId,
|
||||
ClipViewSlice Slice,
|
||||
IReadOnlyList<WorldEntity> CellEntities);
|
||||
|
|
@ -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<VertexPositionNormalTexture> vertices, List<ushort> indices, bool useNegSurface, Matrix4x4 transform, ref bool hasWrappingUVs) {
|
||||
Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup,
|
||||
List<VertexPositionNormalTexture> vertices, List<ushort> indices,
|
||||
bool useNegUv, bool invertNormal, bool reverseWinding,
|
||||
Matrix4x4 transform, ref bool hasWrappingUVs) {
|
||||
|
||||
var polyIndices = new List<ushort>();
|
||||
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<uint, int>? _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 <see cref="Draw"/> when the camera's root cell is
|
||||
/// non-null; the next <see cref="Draw"/> 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 <see cref="ClearClipRouting"/> on outdoor-root frames so the
|
||||
/// dispatcher reverts to the U.3 no-clip-everything behavior.
|
||||
/// </summary>
|
||||
|
|
@ -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 <see cref="ClipSlotCull"/> to drop the entity's instances entirely.
|
||||
/// <list type="bullet">
|
||||
/// <item>ServerGuid != 0 (live dynamic: player / NPC / items / doors) ⇒ slot 0
|
||||
/// (UNCLIPPED — retail draws live-dynamic unclipped; depth only).</item>
|
||||
/// <item>ParentCellId != null (cell static) ⇒ the cell's slot, or CULL when the
|
||||
/// cell isn't in <paramref name="cellIdToSlot"/> (not visible / nothing-visible).</item>
|
||||
/// <item>ParentCellId == null (outdoor scenery / building shell) ⇒ the OutsideView
|
||||
/// slot when <paramref name="outdoorVisible"/>, else CULL.</item>
|
||||
/// <item>Indoor ParentCellId: the cell's slot, or CULL when hidden.</item>
|
||||
/// <item>Outdoor ParentCellId or ParentCellId == null static scenery: the OutsideView slot
|
||||
/// when <paramref name="outdoorVisible"/>, else CULL.</item>
|
||||
/// <item>ServerGuid != 0 with ParentCellId == null: CULL while routing is active.</item>
|
||||
/// </list>
|
||||
/// Only called when <c>_clipRoutingActive</c> (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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase U.4: the call-site clip-slot decision for one entity, returning the
|
||||
/// <c>(Slot, Culled)</c> pair the per-entity loop body consumes. Wraps
|
||||
|
|
|
|||
|
|
@ -280,9 +280,9 @@ public static class RenderingDiagnostics
|
|||
/// DrawInside vs the outdoor <c>LScape::draw</c> on <c>is_player_outside</c> — the
|
||||
/// <b>PLAYER's</b> cell (<c>(player->m_position.objcell_id & 0xFFFF) < 0x100</c>,
|
||||
/// <c>SmartBox::is_player_outside</c> 0x451e80) — NOT the camera/viewer cell. When the
|
||||
/// player is inside it then roots the flood at the <b>viewer</b> cell
|
||||
/// (<c>this->viewer_cell</c>). So the inside/outside <i>decision</i> follows the player;
|
||||
/// only the indoor <i>root</i> follows the camera.</para>
|
||||
/// 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.</para>
|
||||
///
|
||||
/// <para>acdream historically branched on the camera cell (a non-null
|
||||
/// <c>visibility.CameraCell</c>). 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.</para>
|
||||
///
|
||||
/// <param name="playerCellId">The player's current cell id (0 if unresolved → outside).</param>
|
||||
/// <param name="viewerCellResolved">Whether a viewer/camera cell is available to root
|
||||
/// DrawInside at. Indoor render needs both: the player inside AND a cell to root at.</param>
|
||||
/// <param name="renderRootResolved">Whether the player's indoor render root is loaded and
|
||||
/// available to DrawInside.</param>
|
||||
/// </summary>
|
||||
public static bool ShouldRenderIndoor(uint playerCellId, bool viewerCellResolved)
|
||||
=> viewerCellResolved && IsEnvCellId(playerCellId);
|
||||
public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved)
|
||||
=> renderRootResolved && IsEnvCellId(playerCellId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,12 +37,13 @@ public sealed class WorldEntity
|
|||
public PaletteOverride? PaletteOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public uint? ParentCellId { get; init; }
|
||||
public uint? ParentCellId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when this entity originates from <c>LandBlockInfo.Buildings[]</c>
|
||||
|
|
|
|||
64
tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs
Normal file
64
tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,36 +5,27 @@ using Xunit;
|
|||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Phase U.4: GL-free proof that <see cref="ClipFrameAssembler"/> 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 <see cref="PortalVisibilityFrame"/>s
|
||||
/// drive the assembler directly (no portal BFS needed) so each disposition is
|
||||
/// exercised in isolation.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -252,4 +252,5 @@ public class ClipPlaneSetTests
|
|||
// need a real AABB; a zero-area line has none).
|
||||
Assert.True(cps.IsNothingVisible);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<uint, WorldEntity>?)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<uint> { 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<uint> { 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<uint, LoadedCell> { [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<uint, LoadedCell> { [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<uint, LoadedCell>
|
||||
{
|
||||
[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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<uint>();
|
||||
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<uint>();
|
||||
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<Surface>(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<SurfaceTexture>(id, out var st) && st is not null && st.Textures.Count > 0)
|
||||
if (dats.TryGet<SurfaceTexture>(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<RenderSurface>(rsid, out var inner) && inner is not null)
|
||||
rs = inner;
|
||||
}
|
||||
else if (dats.TryGet<RenderSurface>(id, out var direct) && direct is not null)
|
||||
else if (dats.TryGet<RenderSurface>(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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue