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
|
// ClipFrameAssembler.cs
|
||||||
//
|
//
|
||||||
// Phase U.4: assemble a per-frame ClipFrame (the GPU-side shared clip data) +
|
// Retail PView assembly policy. PortalVisibilityBuilder produces a retail-like
|
||||||
// a cellId→slot map from a PortalVisibilityFrame. This is the CPU policy that
|
// view graph: one portal_view list per visible cell plus an outside_view list.
|
||||||
// turns the portal-visibility BFS result into the slot indices the mesh shader
|
// This assembler packs each visible polygon as an individual GPU clip slot so
|
||||||
// (binding=2 CellClip + binding=3 per-instance slot) and the terrain UBO read.
|
// the renderer can draw the exact PView order:
|
||||||
//
|
//
|
||||||
// GL-free: ClipFrame's CPU byte-packing (AppendSlot / SetTerrainClip) runs here;
|
// outside_view landscape slices
|
||||||
// the GL upload (ClipFrame.UploadShared) happens at the call site. That keeps the
|
// reverse cell_draw_list exit masks
|
||||||
// whole slot/gate policy unit-testable without a GPU context — see
|
// reverse cell_draw_list EnvCell shells
|
||||||
// ClipFrameAssemblerTests.
|
// reverse cell_draw_list object lists
|
||||||
//
|
//
|
||||||
// === The slot/gate policy (implemented EXACTLY as the U.4 spec dictates) ======
|
// Slot 0 is always no-clip. A slice whose polygon cannot be represented by the
|
||||||
// slot 0 = no-clip (count 0). ALWAYS present (ClipFrame.NoClip seeds it).
|
// <=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.
|
||||||
// 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.
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering;
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How the terrain (single OutsideView region) should be drawn this frame.
|
/// How the landscape-through-outside_view pass should be interpreted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum TerrainClipMode
|
public enum TerrainClipMode
|
||||||
{
|
{
|
||||||
/// <summary>OutsideView reduced to convex planes — terrain gated via the UBO
|
/// <summary>All outside_view slices have convex plane clips.</summary>
|
||||||
/// (<see cref="ClipFrame.SetTerrainClip"/> already applied by the assembler).</summary>
|
|
||||||
Planes,
|
Planes,
|
||||||
|
|
||||||
/// <summary>OutsideView exceeded the convex budget — the call site sets a
|
/// <summary>At least one outside_view slice requires scissor fallback.</summary>
|
||||||
/// glScissor to <see cref="ClipFrameAssembly.TerrainScissorNdcAabb"/> around ONLY
|
|
||||||
/// the terrain draw; the UBO is left at count 0 (ungated).</summary>
|
|
||||||
Scissor,
|
Scissor,
|
||||||
|
|
||||||
/// <summary>OutsideView is empty (no exit portal visible through any chain) —
|
/// <summary>No outside_view slice is visible; skip landscape indoors.</summary>
|
||||||
/// the call site SKIPS the terrain draw entirely. This is the bleed fix: an
|
|
||||||
/// interior with no view outdoors draws no terrain.</summary>
|
|
||||||
Skip,
|
Skip,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: the populated
|
/// One retail portal_view slice mapped to a GPU clip slot. The AABB is retained
|
||||||
/// <see cref="ClipFrame"/> (CPU bytes ready; caller does <c>UploadShared</c>) plus
|
/// for passes that cannot write gl_ClipDistance and must use scissor.
|
||||||
/// the per-instance routing data the renderers + the terrain draw consume.
|
/// </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>
|
/// </summary>
|
||||||
public sealed class ClipFrameAssembly
|
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; }
|
public required ClipFrame Frame { get; init; }
|
||||||
|
|
||||||
/// <summary>Maps a visible cell id to its CellClip slot index. A cell that is
|
/// <summary>First drawable slice slot per visible cell. Compatibility map
|
||||||
/// NOT a key (IsNothingVisible, or never visible) must NOT be drawn — its mesh
|
/// for renderer APIs that can accept only one slot at a time.</summary>
|
||||||
/// instances / shell are culled. A scissor-fallback cell maps to slot 0.</summary>
|
|
||||||
public required Dictionary<uint, int> CellIdToSlot { get; init; }
|
public required Dictionary<uint, int> CellIdToSlot { get; init; }
|
||||||
|
|
||||||
/// <summary>Slot for outdoor scenery / building-shell instances (ParentCellId
|
/// <summary>Slot-only cell slices, retained for older renderer APIs.</summary>
|
||||||
/// == null) while the camera is indoors. Meaningful only when
|
public required Dictionary<uint, int[]> CellIdToViewSlots { get; init; }
|
||||||
/// <see cref="OutdoorVisible"/> is true. 0 ⇒ no-clip (scissor fallback or trivial).</summary>
|
|
||||||
|
/// <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; }
|
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; }
|
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; }
|
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; }
|
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; }
|
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; }
|
public required Vector4 OutsideViewNdcAabb { get; init; }
|
||||||
|
|
||||||
// ---- Probe data (ACDREAM_PROBE_VIS / RenderingDiagnostics.EmitVis) --------
|
// Probe data.
|
||||||
|
|
||||||
/// <summary>Plane count the OutsideView reduced to (0 ⇒ scissor or empty).</summary>
|
|
||||||
public required int OutsidePlaneCount { get; init; }
|
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; }
|
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; }
|
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
|
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)
|
public static ClipFrameAssembly Assemble(ClipFrame frame, PortalVisibilityFrame pvFrame)
|
||||||
{
|
{
|
||||||
System.ArgumentNullException.ThrowIfNull(frame);
|
System.ArgumentNullException.ThrowIfNull(frame);
|
||||||
System.ArgumentNullException.ThrowIfNull(pvFrame);
|
System.ArgumentNullException.ThrowIfNull(pvFrame);
|
||||||
|
|
||||||
frame.Reset(); // slot 0 = no-clip
|
frame.Reset();
|
||||||
|
|
||||||
var cellIdToSlot = new Dictionary<uint, int>();
|
var cellIdToSlot = new Dictionary<uint, int>();
|
||||||
|
var cellIdToViewSlots = new Dictionary<uint, int[]>();
|
||||||
|
var cellIdToViewSlices = new Dictionary<uint, ClipViewSlice[]>();
|
||||||
var perCellPlaneCounts = new Dictionary<uint, int>();
|
var perCellPlaneCounts = new Dictionary<uint, int>();
|
||||||
int scissorFallbacks = 0;
|
int scissorFallbacks = 0;
|
||||||
|
|
||||||
// ── Interior cells ───────────────────────────────────────────────────
|
|
||||||
foreach (uint cellId in pvFrame.OrderedVisibleCells)
|
foreach (uint cellId in pvFrame.OrderedVisibleCells)
|
||||||
{
|
{
|
||||||
if (!pvFrame.CellViews.TryGetValue(cellId, out var view))
|
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;
|
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)
|
if (cps.Count > 0)
|
||||||
{
|
{
|
||||||
int slot = frame.AppendSlot(cps);
|
planes = ToPlaneSpan(cps);
|
||||||
cellIdToSlot[cellId] = slot;
|
slot = frame.AppendSlot(planes);
|
||||||
perCellPlaneCounts[cellId] = cps.Count;
|
if (cps.Count > maxPlaneCount)
|
||||||
|
maxPlaneCount = cps.Count;
|
||||||
}
|
}
|
||||||
else // UseScissorFallback (Count == 0, not nothing-visible)
|
else
|
||||||
{
|
{
|
||||||
// Over-include via no-clip (slot 0). Per-cell glScissor would break
|
planes = System.Array.Empty<Vector4>();
|
||||||
// MDI batching; over-inclusion is the safe direction for M1.5.
|
slot = 0;
|
||||||
cellIdToSlot[cellId] = 0;
|
|
||||||
perCellPlaneCounts[cellId] = 0;
|
|
||||||
scissorFallbacks++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── OutsideView ──────────────────────────────────────────────────────
|
|
||||||
var ov = ClipPlaneSet.From(pvFrame.OutsideView);
|
|
||||||
|
|
||||||
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++;
|
scissorFallbacks++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 4: the doorway screen-space AABB (the OutsideView union bounds), available for
|
slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
|
||||||
// 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
|
if (slices.Count == 0)
|
||||||
// always tracks its Min/Max as polygons accumulate, so it is the single source here.
|
continue;
|
||||||
bool hasOutsideView = terrainMode != TerrainClipMode.Skip;
|
|
||||||
Vector4 outsideViewNdcAabb = (hasOutsideView && !pvFrame.OutsideView.IsEmpty)
|
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)
|
||||||
|
{
|
||||||
|
planes = ToPlaneSpan(cps);
|
||||||
|
slot = frame.AppendSlot(planes);
|
||||||
|
if (cps.Count > outsideMaxPlaneCount)
|
||||||
|
outsideMaxPlaneCount = cps.Count;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
planes = System.Array.Empty<Vector4>();
|
||||||
|
slot = 0;
|
||||||
|
outsideHasScissorFallback = true;
|
||||||
|
scissorFallbacks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
Vector4 outsideViewNdcAabb = outdoorVisible
|
||||||
? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY,
|
? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY,
|
||||||
pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY)
|
pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY)
|
||||||
: Vector4.Zero;
|
: Vector4.Zero;
|
||||||
|
Vector4 terrainScissor = terrainMode == TerrainClipMode.Scissor
|
||||||
|
? outsideViewNdcAabb
|
||||||
|
: Vector4.Zero;
|
||||||
|
|
||||||
return new ClipFrameAssembly
|
return new ClipFrameAssembly
|
||||||
{
|
{
|
||||||
Frame = frame,
|
Frame = frame,
|
||||||
CellIdToSlot = cellIdToSlot,
|
CellIdToSlot = cellIdToSlot,
|
||||||
|
CellIdToViewSlots = cellIdToViewSlots,
|
||||||
|
CellIdToViewSlices = cellIdToViewSlices,
|
||||||
|
OutsideViewSlices = outsideViewSlices,
|
||||||
OutdoorSlot = outdoorSlot,
|
OutdoorSlot = outdoorSlot,
|
||||||
OutdoorVisible = outdoorVisible,
|
OutdoorVisible = outdoorVisible,
|
||||||
TerrainMode = terrainMode,
|
TerrainMode = terrainMode,
|
||||||
TerrainScissorNdcAabb = terrainScissor,
|
TerrainScissorNdcAabb = terrainScissor,
|
||||||
HasOutsideView = hasOutsideView,
|
HasOutsideView = outdoorVisible,
|
||||||
OutsideViewNdcAabb = outsideViewNdcAabb,
|
OutsideViewNdcAabb = outsideViewNdcAabb,
|
||||||
OutsidePlaneCount = ov.Count,
|
OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0,
|
||||||
PerCellPlaneCounts = perCellPlaneCounts,
|
PerCellPlaneCounts = perCellPlaneCounts,
|
||||||
ScissorFallbacks = scissorFallbacks,
|
ScissorFallbacks = scissorFallbacks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy a ClipPlaneSet's planes into a heap array for SetTerrainClip's span
|
private static CellView ViewOf(ViewPolygon poly)
|
||||||
// parameter (the set exposes IReadOnlyList, not a contiguous span).
|
{
|
||||||
|
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)
|
private static Vector4[] ToPlaneSpan(ClipPlaneSet set)
|
||||||
{
|
{
|
||||||
int n = set.Count;
|
int n = set.Count;
|
||||||
var planes = new Vector4[n];
|
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;
|
return planes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,6 @@ public readonly struct ClipPlaneSet
|
||||||
// or point — zero screen coverage ⇒ nothing visible. A real portal opening has area far
|
// 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.
|
// 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 const float MinPolygonArea = 1e-7f;
|
||||||
|
|
||||||
private readonly Vector4[] _planes;
|
private readonly Vector4[] _planes;
|
||||||
|
|
||||||
private ClipPlaneSet(Vector4[] planes, bool useScissorFallback, bool isNothingVisible, Vector4 scissorNdcAabb)
|
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;
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Splits a frame's landblock entities into the three draw buckets the per-cell
|
/// Splits a frame's landblock entities into the draw buckets used by the
|
||||||
/// <see cref="InteriorRenderer"/> needs, using the SAME precedence as
|
/// retail-style DrawInside flood. Indoor ownership wins for live dynamics too:
|
||||||
/// <see cref="Wb.WbDrawDispatcher.ResolveEntitySlot"/>:
|
/// a player, NPC, door, or item with a current indoor ParentCellId belongs to
|
||||||
/// <list type="number">
|
/// that cell's portal-clipped object list, not a global overlay pass.
|
||||||
/// <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.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class InteriorEntityPartition
|
public static class InteriorEntityPartition
|
||||||
{
|
{
|
||||||
|
|
@ -40,18 +32,18 @@ public static class InteriorEntityPartition
|
||||||
{
|
{
|
||||||
if (e.MeshRefs.Count == 0) continue;
|
if (e.MeshRefs.Count == 0) continue;
|
||||||
|
|
||||||
if (e.ServerGuid != 0) // live-dynamic — precedence first (no ParentCellId)
|
if (e.ServerGuid != 0)
|
||||||
{
|
{
|
||||||
|
if (e.ParentCellId is uint liveCell)
|
||||||
|
AddByCellOrOutdoor(e, liveCell, visibleCells, result);
|
||||||
|
else
|
||||||
result.LiveDynamic.Add(e);
|
result.LiveDynamic.Add(e);
|
||||||
}
|
}
|
||||||
else if (e.ParentCellId is uint cell)
|
else if (e.ParentCellId is uint cell)
|
||||||
{
|
{
|
||||||
if (!visibleCells.Contains(cell)) continue; // its cell isn't drawn this frame
|
AddByCellOrOutdoor(e, cell, visibleCells, result);
|
||||||
if (!result.ByCell.TryGetValue(cell, out var list))
|
|
||||||
result.ByCell[cell] = list = new List<WorldEntity>();
|
|
||||||
list.Add(e);
|
|
||||||
}
|
}
|
||||||
else // outdoor scenery / building shell
|
else
|
||||||
{
|
{
|
||||||
result.Outdoor.Add(e);
|
result.Outdoor.Add(e);
|
||||||
}
|
}
|
||||||
|
|
@ -59,4 +51,30 @@ public static class InteriorEntityPartition
|
||||||
}
|
}
|
||||||
return result;
|
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>
|
/// membership filter; <see cref="OrderedVisibleCells"/> supplies the draw ORDER.</summary>
|
||||||
public required IReadOnlySet<uint> DrawableCells { get; init; }
|
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 +
|
/// <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
|
/// LiveDynamic are used here; Outdoor scenery is drawn by the caller's landscape-through-door
|
||||||
/// step (clipped to OutsideView).</summary>
|
/// step (clipped to OutsideView).</summary>
|
||||||
|
|
@ -34,12 +41,11 @@ public sealed class InteriorRenderContext
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The per-cell interior render flood — a faithful port of retail PView::DrawCells' per-cell loops
|
/// The interior render flood, matching retail PView::DrawCells @ 0x005a4840:
|
||||||
/// (decomp 0x5a4840). Iterates the visible cells closest-first; per cell draws the closed shell +
|
/// after the caller handles outside_view terrain + the depth-only clear, DrawCells
|
||||||
/// that cell's static objects (portal-clipped via the clip routing the caller installed), then the
|
/// walks cell_draw_list from the end back to zero in separate stages: cell shells,
|
||||||
/// live-dynamics unclipped, then the transparent shells. The landscape-through-door (sky/terrain/
|
/// then each cell's object_list. The transparent shell pass is split out because
|
||||||
/// scenery) + the conditional Z-clear are the caller's responsibility, run BEFORE this. GL state is
|
/// the modern renderer batches opaque/transparent surfaces separately.
|
||||||
/// self-contained inside each renderer (EnvCellRenderer / WbDrawDispatcher set their own).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class InteriorRenderer
|
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.
|
// Reused single-cell filter set — cleared + repopulated per cell to avoid per-frame allocs.
|
||||||
private readonly HashSet<uint> _oneCell = new(1);
|
private readonly HashSet<uint> _oneCell = new(1);
|
||||||
|
|
||||||
public InteriorRenderer(EnvCellRenderer envCells, WbDrawDispatcher entities)
|
public InteriorRenderer(EnvCellRenderer envCells, WbDrawDispatcher entities)
|
||||||
{
|
{
|
||||||
_envCells = envCells;
|
_envCells = envCells;
|
||||||
|
|
@ -57,54 +62,103 @@ public sealed class InteriorRenderer
|
||||||
|
|
||||||
public void DrawInside(InteriorRenderContext ctx)
|
public void DrawInside(InteriorRenderContext ctx)
|
||||||
{
|
{
|
||||||
// Loop A — per-cell OPAQUE shell + that cell's static objects (closest-first).
|
// Retail Loop 2: DrawEnvCell for each drawable cell, farthest-to-nearest
|
||||||
foreach (uint cellId in ctx.OrderedVisibleCells)
|
// (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.Clear();
|
||||||
_oneCell.Add(cellId);
|
_oneCell.Add(cellId);
|
||||||
|
ApplyMembershipOnlyRouting();
|
||||||
_envCells.Render(WbRenderPass.Opaque, _oneCell);
|
_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.
|
// Retail Loop 3: Render::PortalList = cell->portal_view; DrawObjCellForDummies(cell).
|
||||||
// Drawn AFTER opaque shells so wall depth occludes them correctly.
|
for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
if (!ctx.DrawableCells.Contains(cellId)) continue;
|
uint cellId = ctx.OrderedVisibleCells[i];
|
||||||
|
if (!TryBeginCell(ctx, cellId, out _)) continue;
|
||||||
_oneCell.Clear();
|
_oneCell.Clear();
|
||||||
_oneCell.Add(cellId);
|
_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);
|
_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
|
// 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
|
// 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 +
|
// The clip slot per entity comes from the SetClipRouting the caller installed (cellIdToSlot +
|
||||||
// outdoorSlot + outdoorVisible) via ResolveEntitySlot.
|
// outdoorSlot + outdoorVisible) via ResolveEntitySlot.
|
||||||
private void DrawEntityBucket(
|
private void DrawEntityBucket(
|
||||||
InteriorRenderContext ctx, IReadOnlyList<WorldEntity> bucket, HashSet<uint>? visibleCellIds)
|
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
|
// LandblockId == neverCullLandblockId (PlayerLandblockId) ⇒ the degenerate (zero) AABB is
|
||||||
// never landblock-frustum-culled; per-entity AABB culling inside Draw still applies.
|
// 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,
|
var entry = (lbId, Vector3.Zero, Vector3.Zero,
|
||||||
(IReadOnlyList<WorldEntity>)bucket,
|
(IReadOnlyList<WorldEntity>)bucket,
|
||||||
(IReadOnlyDictionary<uint, WorldEntity>?)null);
|
(IReadOnlyDictionary<uint, WorldEntity>?)null);
|
||||||
|
|
||||||
_entities.Draw(
|
_entities.Draw(
|
||||||
ctx.Camera,
|
camera,
|
||||||
new[] { entry },
|
new[] { entry },
|
||||||
ctx.Frustum,
|
frustum,
|
||||||
neverCullLandblockId: ctx.PlayerLandblockId,
|
neverCullLandblockId: playerLandblockId,
|
||||||
visibleCellIds: visibleCellIds,
|
visibleCellIds: visibleCellIds,
|
||||||
animatedEntityIds: ctx.AnimatedEntityIds);
|
animatedEntityIds: animatedEntityIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
|
||||||
ParticleSystem particles,
|
ParticleSystem particles,
|
||||||
ICamera camera,
|
ICamera camera,
|
||||||
Vector3 cameraWorldPos,
|
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)
|
if (particles is null || camera is null)
|
||||||
return;
|
return;
|
||||||
|
|
@ -128,7 +129,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
|
||||||
Matrix4x4.Invert(camera.View, out var invView);
|
Matrix4x4.Invert(camera.View, out var invView);
|
||||||
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
|
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
|
||||||
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
|
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)
|
if (draws.Count == 0)
|
||||||
return;
|
return;
|
||||||
draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq));
|
draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq));
|
||||||
|
|
@ -174,13 +175,16 @@ public sealed unsafe class ParticleRenderer : IDisposable
|
||||||
Vector3 cameraWorldPos,
|
Vector3 cameraWorldPos,
|
||||||
ParticleRenderPass renderPass,
|
ParticleRenderPass renderPass,
|
||||||
Vector3 cameraRight,
|
Vector3 cameraRight,
|
||||||
Vector3 cameraUp)
|
Vector3 cameraUp,
|
||||||
|
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter)
|
||||||
{
|
{
|
||||||
var draws = new List<ParticleDraw>(Math.Max(64, particles.ActiveParticleCount));
|
var draws = new List<ParticleDraw>(Math.Max(64, particles.ActiveParticleCount));
|
||||||
foreach (var (em, idx) in particles.EnumerateLive())
|
foreach (var (em, idx) in particles.EnumerateLive())
|
||||||
{
|
{
|
||||||
if (em.RenderPass != renderPass)
|
if (em.RenderPass != renderPass)
|
||||||
continue;
|
continue;
|
||||||
|
if (emitterFilter is not null && !emitterFilter(em))
|
||||||
|
continue;
|
||||||
|
|
||||||
ref var p = ref em.Particles[idx];
|
ref var p = ref em.Particles[idx];
|
||||||
// `p.Position` is already in world coordinates: AttachLocal
|
// `p.Position` is already in world coordinates: AttachLocal
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,117 @@ public static class PortalProjection
|
||||||
return ndc;
|
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
|
// 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
|
// (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
|
// 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.
|
// a cell's clip region is a SET of convex polygons in normalized device coords.
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering;
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
|
@ -40,6 +41,10 @@ public readonly struct ViewPolygon
|
||||||
public sealed class CellView
|
public sealed class CellView
|
||||||
{
|
{
|
||||||
public readonly List<ViewPolygon> Polygons = new();
|
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 MinX { get; private set; } = float.MaxValue;
|
||||||
public float MinY { get; private set; } = float.MaxValue;
|
public float MinY { get; private set; } = float.MaxValue;
|
||||||
public float MaxX { get; private set; } = float.MinValue;
|
public float MaxX { get; private set; } = float.MinValue;
|
||||||
|
|
@ -59,13 +64,82 @@ public sealed class CellView
|
||||||
return v;
|
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);
|
Polygons.Add(p);
|
||||||
if (p.MinX < MinX) MinX = p.MinX;
|
if (p.MinX < MinX) MinX = p.MinX;
|
||||||
if (p.MinY < MinY) MinY = p.MinY;
|
if (p.MinY < MinY) MinY = p.MinY;
|
||||||
if (p.MaxX > MaxX) MaxX = p.MaxX;
|
if (p.MaxX > MaxX) MaxX = p.MaxX;
|
||||||
if (p.MaxY > MaxY) MaxY = p.MaxY;
|
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
|
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
|
// 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.
|
// local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell.
|
||||||
private static readonly bool s_pvDump =
|
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
|
// 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
|
// 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.
|
// 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;
|
bool pvDump = false;
|
||||||
if (s_pvDump)
|
if (s_pvDump)
|
||||||
|
|
@ -116,46 +133,81 @@ public static class PortalVisibilityBuilder
|
||||||
while (todo.Count > 0)
|
while (todo.Count > 0)
|
||||||
{
|
{
|
||||||
var cell = todo.PopNearest();
|
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)
|
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
|
||||||
|
{
|
||||||
|
trace?.Add($"pop cell=0x{cell.CellId:X8} skip=no-view");
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// `seen` guarantees each cell is inserted into the todo list exactly once, so this single
|
// `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 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.
|
// pop, 433783) — no per-pop dedup needed, OrderedVisibleCells stays distinct by construction.
|
||||||
|
if (drawListed.Add(cell.CellId))
|
||||||
frame.OrderedVisibleCells.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++)
|
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];
|
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 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
|
// 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
|
// (mirrors CellVisibility.GetVisibleCells + retail's 'seen' flag). Culls back-facing
|
||||||
// portals so we never feed a degenerate/wrong-facing projection downstream.
|
// 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)}");
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project to NDC, then normalize to CCW for the CCW-only ScreenPolygonClip
|
// Retail PView::ClipPortals calls GetClip(..., finish=1): transform to
|
||||||
// (ProjectToNdc preserves input winding; portal dat polygons may be CW).
|
// homogeneous clip space, clip at the eye, then clip against the current
|
||||||
Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj);
|
// portal_view region before the divide. Do the same here; the old early
|
||||||
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})"))}]");
|
// ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways.
|
||||||
var clippedRegion = new List<ViewPolygon>();
|
var clippedRegion = ClipPortalAgainstView(
|
||||||
if (portalNdc.Length >= 3)
|
poly,
|
||||||
{
|
cell.WorldTransform,
|
||||||
EnsureCcw(portalNdc);
|
viewProj,
|
||||||
// Intersect the portal opening with every polygon of the current cell's view.
|
activeViewPolygons,
|
||||||
foreach (var vp in currentView.Polygons)
|
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})");
|
||||||
var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices);
|
|
||||||
if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
|
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
|
// 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 (clippedRegion.Count == 0)
|
||||||
{
|
{
|
||||||
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
|
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
|
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()));
|
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
var portal = cell.Portals[i];
|
|
||||||
|
|
||||||
if (portal.OtherCellId == 0xFFFF)
|
if (portal.OtherCellId == 0xFFFF)
|
||||||
{
|
{
|
||||||
if (pvDump)
|
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] 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)
|
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})"))}]");
|
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.
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,12 +255,17 @@ public static class PortalVisibilityBuilder
|
||||||
if (buildingMembership != null && !buildingMembership(neighbourId))
|
if (buildingMembership != null && !buildingMembership(neighbourId))
|
||||||
{
|
{
|
||||||
var xview = GetOrCreate(frame.CrossBuildingViews, 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var neighbour = lookup(neighbourId);
|
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
|
// Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip
|
||||||
// decomp:433524). The portal opening seen from THIS cell may be wider than the
|
// 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
|
// 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
|
// opening against its OWN reciprocal instead of the first one. Mutates clippedRegion
|
||||||
// in place before the union below.
|
// in place before the union below.
|
||||||
|
var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null;
|
||||||
|
int preReciprocalCount = clippedRegion.Count;
|
||||||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
|
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.
|
// Union the clipped region into the neighbour's accumulated view.
|
||||||
var nview = GetOrCreate(frame.CellViews, neighbourId);
|
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
|
// 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
|
// (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,
|
// 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
|
// 432988-433004); derived from the portal geometry, so it works even when the cell's
|
||||||
// WorldPosition was never populated.
|
// 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);
|
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.
|
// root cell's per-portal side-test + projection + the frame's exit/visible counts.
|
||||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
||||||
EmitFlapProbe(cameraCell, cameraPos, viewProj, frame);
|
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;
|
return frame;
|
||||||
}
|
}
|
||||||
|
|
@ -260,6 +487,117 @@ public static class PortalVisibilityBuilder
|
||||||
private static readonly Vector2[] FullScreenQuad =
|
private static readonly Vector2[] FullScreenQuad =
|
||||||
{ new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
|
{ 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
|
// 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
|
// signed distance D (eye→portal plane), traverse/cull decision, and NDC projection
|
||||||
// vertex count, plus the frame's OutsideView polygon count + visible-cell count.
|
// 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;
|
d = Vector3.Dot(pl.Normal, localEye) + pl.D;
|
||||||
side = CameraOnInteriorSide(cameraCell, i, cameraPos);
|
side = CameraOnInteriorSide(cameraCell, i, cameraPos);
|
||||||
}
|
}
|
||||||
// Replicate the walk's project → EnsureCcw → Intersect(FullScreen) exactly, so a
|
// Replicate the walk's faithful path exactly (ProjectToClip → ClipToRegion(FullScreen)) so
|
||||||
// portal that PROJECTS (proj>=3) but still fails to ADD its neighbour shows WHY:
|
// proj/clip mean the same as production: proj = clip-space verts in front of the eye,
|
||||||
// clip=0 with ndc inside [-1,1] ⇒ winding/self-intersection degeneracy; clip=0 with
|
// clip = verts surviving the screen-region clip. clip=0 with proj>=3 ⇒ the portal is
|
||||||
// ndc outside [-1,1] ⇒ genuinely off-screen. The ndc coords expose a near-plane bowtie.
|
// genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands.
|
||||||
int projN = -1, clipN = -1;
|
int projN = -1, clipN = -1;
|
||||||
string ndcText = "";
|
string ndcText = "";
|
||||||
if (i < cameraCell.PortalPolygons.Count)
|
if (i < cameraCell.PortalPolygons.Count)
|
||||||
|
|
@ -299,12 +637,12 @@ public static class PortalVisibilityBuilder
|
||||||
var poly = cameraCell.PortalPolygons[i];
|
var poly = cameraCell.PortalPolygons[i];
|
||||||
if (poly != null && poly.Length >= 3)
|
if (poly != null && poly.Length >= 3)
|
||||||
{
|
{
|
||||||
var ndc = PortalProjection.ProjectToNdc(poly, cameraCell.WorldTransform, viewProj);
|
var clip = PortalProjection.ProjectToClip(poly, cameraCell.WorldTransform, viewProj);
|
||||||
projN = ndc.Length;
|
projN = clip.Length;
|
||||||
if (ndc.Length >= 3)
|
if (clip.Length >= 3)
|
||||||
{
|
{
|
||||||
EnsureCcw(ndc);
|
var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad);
|
||||||
clipN = ScreenPolygonClip.Intersect(ndc, FullScreenQuad).Length;
|
clipN = ndc.Length;
|
||||||
var ns = new System.Text.StringBuilder(48);
|
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(')');
|
foreach (var v in ndc) ns.Append('(').Append(v.X.ToString("F1")).Append(',').Append(v.Y.ToString("F1")).Append(')');
|
||||||
ndcText = ns.ToString();
|
ndcText = ns.ToString();
|
||||||
|
|
@ -376,6 +714,13 @@ public static class PortalVisibilityBuilder
|
||||||
|
|
||||||
// Project the reciprocal opening through the NEIGHBOUR's transform (retail positionPush(3,
|
// 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.
|
// &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);
|
Vector2[] reciprocalNdc = PortalProjection.ProjectToNdc(reciprocalPoly, neighbour.WorldTransform, viewProj);
|
||||||
if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op
|
if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op
|
||||||
EnsureCcw(reciprocalNdc);
|
EnsureCcw(reciprocalNdc);
|
||||||
|
|
@ -395,11 +740,27 @@ public static class PortalVisibilityBuilder
|
||||||
return v;
|
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
|
// 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:
|
// 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
|
// 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
|
// 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.
|
// 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)
|
private static float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos)
|
||||||
{
|
{
|
||||||
float best = float.MaxValue;
|
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
|
// "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
|
// plane. Live captures hit two retail-valid degenerate cases: cottage doorway D=0.16 m and
|
||||||
// camera was standing in; 0.5 m comfortably covers a doorway-standing eye while excluding portals
|
// cellar->stair portal D=1.41 m, both traversable but ProjectToNdc returned zero vertices. We still
|
||||||
// the eye is merely facing from across a room (their projection is non-degenerate anyway).
|
// require the perpendicular projection to land inside the opening, so side/offscreen portals stay
|
||||||
private const float EyeStandingPerpDist = 0.5f;
|
// culled; this only covers active portals whose 2D projection collapses near the chase camera.
|
||||||
|
private const float EyeStandingPerpDist = 1.75f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when the camera eye is "standing in" <paramref name="localPoly"/>'s opening: within
|
/// 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();
|
ct.ThrowIfCancellationRequested();
|
||||||
if (poly.VertexIds.Count < 3) continue;
|
if (poly.VertexIds.Count < 3) continue;
|
||||||
|
|
||||||
// Handle Positive Surface
|
// Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this
|
||||||
if (!poly.Stippling.HasFlag(StipplingType.NoPos)) {
|
// DatReaderWriter "CullMode" as CPolygon::sides_type, not as a
|
||||||
AddSurfaceToBatch(poly, poly.PosSurface, false);
|
// 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
|
void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) {
|
||||||
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) {
|
|
||||||
if (surfaceIdx < 0) return;
|
if (surfaceIdx < 0) return;
|
||||||
|
|
||||||
uint surfaceId;
|
uint surfaceId;
|
||||||
|
|
@ -1499,7 +1502,17 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
|
|
||||||
// Helper for CellStruct vertices
|
// Helper for CellStruct vertices
|
||||||
bool batchHasWrappingUVs = batch.HasWrappingUVs;
|
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;
|
batch.HasWrappingUVs = batchHasWrappingUVs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1516,8 +1529,10 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct,
|
private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct,
|
||||||
Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup,
|
Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup,
|
||||||
List<VertexPositionNormalTexture> vertices, List<ushort> indices, bool useNegSurface, Matrix4x4 transform, ref bool hasWrappingUVs) {
|
List<VertexPositionNormalTexture> vertices, List<ushort> indices,
|
||||||
|
bool useNegUv, bool invertNormal, bool reverseWinding,
|
||||||
|
Matrix4x4 transform, ref bool hasWrappingUVs) {
|
||||||
|
|
||||||
var polyIndices = new List<ushort>();
|
var polyIndices = new List<ushort>();
|
||||||
|
|
||||||
|
|
@ -1525,9 +1540,9 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
ushort vertId = (ushort)poly.VertexIds[i];
|
ushort vertId = (ushort)poly.VertexIds[i];
|
||||||
ushort uvIdx = 0;
|
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];
|
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];
|
uvIdx = poly.PosUVIndices[i];
|
||||||
|
|
||||||
if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue;
|
if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue;
|
||||||
|
|
@ -1536,7 +1551,7 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
uvIdx = 0;
|
uvIdx = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var key = (vertId, uvIdx, useNegSurface);
|
var key = (vertId, uvIdx, invertNormal);
|
||||||
|
|
||||||
if (!hasWrappingUVs) {
|
if (!hasWrappingUVs) {
|
||||||
var uvCheck = vertex.UVs.Count > 0
|
var uvCheck = vertex.UVs.Count > 0
|
||||||
|
|
@ -1553,7 +1568,7 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
: Vector2.Zero;
|
: Vector2.Zero;
|
||||||
|
|
||||||
var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform));
|
var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform));
|
||||||
if (useNegSurface) {
|
if (invertNormal) {
|
||||||
normal = -normal;
|
normal = -normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1568,18 +1583,18 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
polyIndices.Add(idx);
|
polyIndices.Add(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useNegSurface) {
|
if (reverseWinding) {
|
||||||
for (int i = 2; i < polyIndices.Count; i++) {
|
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]);
|
||||||
|
indices.Add(polyIndices[i - 1]);
|
||||||
|
indices.Add(polyIndices[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
for (int i = 2; i < polyIndices.Count; i++) {
|
for (int i = 2; i < polyIndices.Count; i++) {
|
||||||
indices.Add(polyIndices[i]);
|
|
||||||
indices.Add(polyIndices[i - 1]);
|
|
||||||
indices.Add(polyIndices[0]);
|
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 /
|
// 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
|
// 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
|
// 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
|
// ResolveEntitySlot per the U.4 policy (cell-owned entities to their cell slot;
|
||||||
// their cell slot; outdoor scenery to the OutsideView slot; non-visible culled).
|
// outdoor-owned entities to OutsideView; non-visible/unresolved indoors culled).
|
||||||
private bool _clipRoutingActive;
|
private bool _clipRoutingActive;
|
||||||
private IReadOnlyDictionary<uint, int>? _cellIdToSlot;
|
private IReadOnlyDictionary<uint, int>? _cellIdToSlot;
|
||||||
private int _outdoorSlot;
|
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.
|
/// 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
|
/// 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
|
/// 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
|
/// clip slot via the U.4 policy (cell-owned entities to their cell slot,
|
||||||
/// cell slot, outdoor scenery to the OutsideView slot, non-visible culled).
|
/// outdoor-owned entities to OutsideView, non-visible/unresolved indoors culled).
|
||||||
/// Pair with <see cref="ClearClipRouting"/> on outdoor-root frames so the
|
/// Pair with <see cref="ClearClipRouting"/> on outdoor-root frames so the
|
||||||
/// dispatcher reverts to the U.3 no-clip-everything behavior.
|
/// dispatcher reverts to the U.3 no-clip-everything behavior.
|
||||||
/// </summary>
|
/// </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.
|
/// 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.
|
/// Returns <see cref="ClipSlotCull"/> to drop the entity's instances entirely.
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item>ServerGuid != 0 (live dynamic: player / NPC / items / doors) ⇒ slot 0
|
/// <item>Indoor ParentCellId: the cell's slot, or CULL when hidden.</item>
|
||||||
/// (UNCLIPPED — retail draws live-dynamic unclipped; depth only).</item>
|
/// <item>Outdoor ParentCellId or ParentCellId == null static scenery: the OutsideView slot
|
||||||
/// <item>ParentCellId != null (cell static) ⇒ the cell's slot, or CULL when the
|
/// when <paramref name="outdoorVisible"/>, else CULL.</item>
|
||||||
/// cell isn't in <paramref name="cellIdToSlot"/> (not visible / nothing-visible).</item>
|
/// <item>ServerGuid != 0 with ParentCellId == null: CULL while routing is active.</item>
|
||||||
/// <item>ParentCellId == null (outdoor scenery / building shell) ⇒ the OutsideView
|
|
||||||
/// slot when <paramref name="outdoorVisible"/>, else CULL.</item>
|
|
||||||
/// </list>
|
/// </list>
|
||||||
/// Only called when <c>_clipRoutingActive</c> (indoor root). On the U.3 / outdoor
|
/// Only called when <c>_clipRoutingActive</c> (indoor root). On the U.3 / outdoor
|
||||||
/// path every instance is slot 0 and nothing is culled — see
|
/// path every instance is slot 0 and nothing is culled — see
|
||||||
|
|
@ -385,20 +383,37 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
int outdoorSlot,
|
int outdoorSlot,
|
||||||
bool outdoorVisible)
|
bool outdoorVisible)
|
||||||
{
|
{
|
||||||
// Live-dynamic entities render unclipped regardless of cell — retail draws
|
// Live-dynamic entities are not a global indoor overlay. When they
|
||||||
// the player / NPCs / dropped items through the depth buffer without portal
|
// have current cell ownership, route them through the same visible
|
||||||
// clipping. ServerGuid is the live-dynamic marker (0 for dat-hydrated).
|
// cell/OutsideView graph as every other object. Parentless live objects
|
||||||
if (serverGuid != 0)
|
// are unresolved indoors, so cull them while clip routing is active.
|
||||||
return 0;
|
|
||||||
|
|
||||||
if (parentCellId is uint parentCell)
|
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
|
// Outdoor scenery / building shell (no ParentCellId). Indoor root: gate to
|
||||||
// the OutsideView slot, or cull when nothing outdoors is visible.
|
// the OutsideView slot, or cull when nothing outdoors is visible.
|
||||||
return outdoorVisible ? outdoorSlot : ClipSlotCull;
|
return outdoorVisible ? outdoorSlot : ClipSlotCull;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsIndoorCellId(uint cellId)
|
||||||
|
{
|
||||||
|
uint low = cellId & 0xFFFFu;
|
||||||
|
return low >= 0x0100u && low != 0xFFFFu;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase U.4: the call-site clip-slot decision for one entity, returning the
|
/// 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
|
/// <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
|
/// 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>,
|
/// <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
|
/// <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
|
/// player is inside, acdream roots the portal flood at the player's transition-owned
|
||||||
/// (<c>this->viewer_cell</c>). So the inside/outside <i>decision</i> follows the player;
|
/// physics cell and projects from the camera eye, so the shell around the player remains
|
||||||
/// only the indoor <i>root</i> follows the camera.</para>
|
/// sealed during chase-camera cell transitions.</para>
|
||||||
///
|
///
|
||||||
/// <para>acdream historically branched on the camera cell (a non-null
|
/// <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
|
/// <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>
|
/// 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="playerCellId">The player's current cell id (0 if unresolved → outside).</param>
|
||||||
/// <param name="viewerCellResolved">Whether a viewer/camera cell is available to root
|
/// <param name="renderRootResolved">Whether the player's indoor render root is loaded and
|
||||||
/// DrawInside at. Indoor render needs both: the player inside AND a cell to root at.</param>
|
/// available to DrawInside.</param>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool ShouldRenderIndoor(uint playerCellId, bool viewerCellResolved)
|
public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved)
|
||||||
=> viewerCellResolved && IsEnvCellId(playerCellId);
|
=> renderRootResolved && IsEnvCellId(playerCellId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,13 @@ public sealed class WorldEntity
|
||||||
public PaletteOverride? PaletteOverride { get; init; }
|
public PaletteOverride? PaletteOverride { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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
|
/// the cell). Used by portal visibility to filter interior entities — only
|
||||||
/// entities whose ParentCellId appears in the visible set are rendered.
|
/// 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>
|
/// </summary>
|
||||||
public uint? ParentCellId { get; init; }
|
public uint? ParentCellId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when this entity originates from <c>LandBlockInfo.Buildings[]</c>
|
/// 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;
|
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
|
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[]
|
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)
|
private static CellView ViewOf(params ViewPolygon[] polys)
|
||||||
{
|
{
|
||||||
var v = new CellView();
|
var view = new CellView();
|
||||||
foreach (var p in polys) v.Add(p);
|
foreach (var p in polys)
|
||||||
return v;
|
view.Add(p);
|
||||||
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TwoVisibleCells_PlusOutsideView_ProducesCorrectSlotMapAndCounts()
|
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 cellA = 0xA9B40100;
|
||||||
const uint cellB = 0xA9B40101;
|
const uint cellB = 0xA9B40101;
|
||||||
|
|
||||||
|
|
@ -45,199 +36,154 @@ public class ClipFrameAssemblerTests
|
||||||
pv.OrderedVisibleCells.Add(cellB);
|
pv.OrderedVisibleCells.Add(cellB);
|
||||||
pv.OutsideView.Add(Square(0f, 0.5f, 0.25f));
|
pv.OutsideView.Add(Square(0f, 0.5f, 0.25f));
|
||||||
|
|
||||||
var frame = ClipFrame.NoClip();
|
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
|
||||||
|
|
||||||
// slot 0 reserved (no-clip) + 2 cells + 1 outdoor = 4 slots.
|
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cells + one outside slice
|
||||||
Assert.Equal(4, asm.Frame.SlotCount);
|
Assert.Contains(cellA, asm.CellIdToSlot.Keys);
|
||||||
|
Assert.Contains(cellB, asm.CellIdToSlot.Keys);
|
||||||
// Both cells mapped to NON-zero slots (real plane regions), distinct.
|
|
||||||
Assert.True(asm.CellIdToSlot.ContainsKey(cellA));
|
|
||||||
Assert.True(asm.CellIdToSlot.ContainsKey(cellB));
|
|
||||||
Assert.NotEqual(0, asm.CellIdToSlot[cellA]);
|
Assert.NotEqual(0, asm.CellIdToSlot[cellA]);
|
||||||
Assert.NotEqual(0, asm.CellIdToSlot[cellB]);
|
Assert.NotEqual(0, asm.CellIdToSlot[cellB]);
|
||||||
Assert.NotEqual(asm.CellIdToSlot[cellA], 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[cellA]);
|
||||||
Assert.Equal(4, asm.PerCellPlaneCounts[cellB]);
|
Assert.Equal(4, asm.PerCellPlaneCounts[cellB]);
|
||||||
|
|
||||||
// OutsideView: visible, convex → planes, a non-zero outdoor slot, terrain
|
|
||||||
// gated via planes.
|
|
||||||
Assert.True(asm.OutdoorVisible);
|
Assert.True(asm.OutdoorVisible);
|
||||||
Assert.NotEqual(0, asm.OutdoorSlot);
|
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(TerrainClipMode.Planes, asm.TerrainMode);
|
||||||
Assert.Equal(4, asm.OutsidePlaneCount);
|
Assert.Equal(4, asm.OutsidePlaneCount);
|
||||||
Assert.Equal(0, asm.ScissorFallbacks);
|
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]
|
[Fact]
|
||||||
public void NothingVisibleCell_IsExcludedFromSlotMap_AndNotAppended()
|
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 cellA = 0xA9B40100;
|
||||||
const uint cellB = 0xA9B40101;
|
const uint cellB = 0xA9B40101;
|
||||||
|
|
||||||
var pv = new PortalVisibilityFrame();
|
var pv = new PortalVisibilityFrame();
|
||||||
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
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(cellA);
|
||||||
pv.OrderedVisibleCells.Add(cellB);
|
pv.OrderedVisibleCells.Add(cellB);
|
||||||
// OutsideView empty ⇒ terrain Skip (the bleed fix), outdoor not visible.
|
|
||||||
|
|
||||||
var frame = ClipFrame.NoClip();
|
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
|
||||||
|
|
||||||
// slot 0 + cellA only = 2 slots. cellB consumed none.
|
|
||||||
Assert.Equal(2, asm.Frame.SlotCount);
|
Assert.Equal(2, asm.Frame.SlotCount);
|
||||||
Assert.True(asm.CellIdToSlot.ContainsKey(cellA));
|
Assert.Contains(cellA, asm.CellIdToSlot.Keys);
|
||||||
Assert.False(asm.CellIdToSlot.ContainsKey(cellB)); // culled — not drawable
|
Assert.DoesNotContain(cellB, asm.CellIdToSlot.Keys);
|
||||||
|
|
||||||
// Empty OutsideView ⇒ outdoor culled + terrain skipped (the bleed fix).
|
|
||||||
Assert.False(asm.OutdoorVisible);
|
Assert.False(asm.OutdoorVisible);
|
||||||
|
Assert.Empty(asm.OutsideViewSlices);
|
||||||
Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
|
Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
|
||||||
Assert.Equal(0, asm.OutsidePlaneCount);
|
Assert.Equal(0, asm.OutsidePlaneCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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;
|
const uint cellA = 0xA9B40100;
|
||||||
|
|
||||||
var pv = new PortalVisibilityFrame();
|
var pv = new PortalVisibilityFrame();
|
||||||
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
||||||
pv.OrderedVisibleCells.Add(cellA);
|
pv.OrderedVisibleCells.Add(cellA);
|
||||||
pv.OutsideView.Add(Square(-0.5f, 0f, 0.1f));
|
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(ClipFrame.NoClip(), pv);
|
||||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
|
||||||
|
|
||||||
Assert.True(asm.OutdoorVisible);
|
Assert.True(asm.OutdoorVisible);
|
||||||
Assert.Equal(0, asm.OutdoorSlot); // no-clip over-include
|
Assert.NotEqual(0, asm.OutdoorSlot);
|
||||||
Assert.Equal(TerrainClipMode.Scissor, asm.TerrainMode);
|
Assert.Equal(2, asm.OutsideViewSlices.Length);
|
||||||
Assert.Equal(0, asm.OutsidePlaneCount); // scissor ⇒ 0 planes
|
Assert.NotEqual(asm.OutsideViewSlices[0].Slot, asm.OutsideViewSlices[1].Slot);
|
||||||
Assert.Equal(1, asm.ScissorFallbacks);
|
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
|
||||||
|
Assert.Equal(4, asm.OutsidePlaneCount);
|
||||||
// The terrain scissor AABB is a valid (min <= max) NDC box spanning both
|
Assert.Equal(0, asm.ScissorFallbacks);
|
||||||
// OutsideView squares: minX <= -0.6, maxX >= 0.6.
|
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + cell + two outside slices
|
||||||
Assert.True(asm.TerrainScissorNdcAabb.X <= asm.TerrainScissorNdcAabb.Z);
|
Assert.Equal(Vector4.Zero, asm.TerrainScissorNdcAabb);
|
||||||
Assert.True(asm.TerrainScissorNdcAabb.Y <= asm.TerrainScissorNdcAabb.W);
|
|
||||||
Assert.True(asm.TerrainScissorNdcAabb.X <= -0.59f);
|
|
||||||
Assert.True(asm.TerrainScissorNdcAabb.Z >= 0.59f);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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;
|
const uint cellA = 0xA9B40100;
|
||||||
|
|
||||||
var pv = new PortalVisibilityFrame();
|
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.OrderedVisibleCells.Add(cellA);
|
||||||
pv.OutsideView.Add(Square(0f, 0f, 0.3f));
|
pv.OutsideView.Add(Square(0f, 0f, 0.3f));
|
||||||
|
|
||||||
var frame = ClipFrame.NoClip();
|
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
|
||||||
|
|
||||||
// cellA → slot 0 (no-clip). slot 0 + the OutsideView's planes slot = 2.
|
Assert.True(asm.CellIdToSlot[cellA] > 0);
|
||||||
Assert.Equal(0, asm.CellIdToSlot[cellA]);
|
Assert.Equal(2, asm.CellIdToViewSlots[cellA].Length);
|
||||||
Assert.Equal(0, asm.PerCellPlaneCounts[cellA]);
|
Assert.Equal(2, asm.CellIdToViewSlices[cellA].Length);
|
||||||
Assert.Equal(2, asm.Frame.SlotCount); // slot0 + OutsideView
|
Assert.NotEqual(asm.CellIdToViewSlots[cellA][0], asm.CellIdToViewSlots[cellA][1]);
|
||||||
Assert.Equal(1, asm.ScissorFallbacks); // the cell fallback
|
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);
|
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Phase W Stage 4: HasOutsideView + OutsideViewNdcAabb
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds()
|
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 pv = new PortalVisibilityFrame();
|
||||||
var poly = Square(-0.3f, 0.2f, 0.25f);
|
pv.OutsideView.Add(Square(-0.3f, 0.2f, 0.25f));
|
||||||
pv.OutsideView.Add(poly);
|
|
||||||
// No interior cells needed for this assertion.
|
|
||||||
|
|
||||||
var frame = ClipFrame.NoClip();
|
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
|
||||||
|
|
||||||
Assert.True(asm.HasOutsideView);
|
Assert.True(asm.HasOutsideView);
|
||||||
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
|
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
|
||||||
|
Assert.Single(asm.OutsideViewSlices);
|
||||||
|
|
||||||
// The OutsideViewNdcAabb must equal the CellView's accumulated Min/Max.
|
var expected = new Vector4(
|
||||||
var expected = new System.Numerics.Vector4(
|
|
||||||
pv.OutsideView.MinX, pv.OutsideView.MinY,
|
pv.OutsideView.MinX, pv.OutsideView.MinY,
|
||||||
pv.OutsideView.MaxX, pv.OutsideView.MaxY);
|
pv.OutsideView.MaxX, pv.OutsideView.MaxY);
|
||||||
Assert.Equal(expected, asm.OutsideViewNdcAabb);
|
Assert.Equal(expected, asm.OutsideViewNdcAabb);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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();
|
var pv = new PortalVisibilityFrame();
|
||||||
pv.OutsideView.Add(Square(-0.6f, 0f, 0.15f));
|
pv.OutsideView.Add(Square(-0.6f, 0f, 0.15f));
|
||||||
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(ClipFrame.NoClip(), pv);
|
||||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
|
||||||
|
|
||||||
Assert.True(asm.HasOutsideView);
|
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 expected = new Vector4(
|
||||||
var expectedAabb = new System.Numerics.Vector4(
|
|
||||||
pv.OutsideView.MinX, pv.OutsideView.MinY,
|
pv.OutsideView.MinX, pv.OutsideView.MinY,
|
||||||
pv.OutsideView.MaxX, pv.OutsideView.MaxY);
|
pv.OutsideView.MaxX, pv.OutsideView.MaxY);
|
||||||
Assert.Equal(expectedAabb, asm.OutsideViewNdcAabb);
|
Assert.Equal(expected, asm.OutsideViewNdcAabb);
|
||||||
|
Assert.Equal(Vector4.Zero, asm.TerrainScissorNdcAabb);
|
||||||
// In Scissor mode OutsideViewNdcAabb and TerrainScissorNdcAabb are the same
|
|
||||||
// value (both are the union CellView bounds).
|
|
||||||
Assert.Equal(asm.TerrainScissorNdcAabb, asm.OutsideViewNdcAabb);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Assemble_EmptyOutsideView_HasOutsideViewFalse_AabbZero()
|
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;
|
const uint cellA = 0xA9B40100;
|
||||||
var pv = new PortalVisibilityFrame();
|
var pv = new PortalVisibilityFrame();
|
||||||
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
||||||
pv.OrderedVisibleCells.Add(cellA);
|
pv.OrderedVisibleCells.Add(cellA);
|
||||||
// OutsideView left empty (no exit portal).
|
|
||||||
|
|
||||||
var frame = ClipFrame.NoClip();
|
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
|
||||||
|
|
||||||
Assert.False(asm.HasOutsideView);
|
Assert.False(asm.HasOutsideView);
|
||||||
Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
|
Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
|
||||||
Assert.Equal(System.Numerics.Vector4.Zero, asm.OutsideViewNdcAabb);
|
Assert.Equal(Vector4.Zero, asm.OutsideViewNdcAabb);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Reset_ReusesFrame_NoSlotLeakAcrossAssemblies()
|
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 frame = ClipFrame.NoClip();
|
||||||
|
|
||||||
var pv1 = new PortalVisibilityFrame();
|
var pv1 = new PortalVisibilityFrame();
|
||||||
|
|
@ -249,17 +195,15 @@ public class ClipFrameAssemblerTests
|
||||||
var asm1 = ClipFrameAssembler.Assemble(frame, pv1);
|
var asm1 = ClipFrameAssembler.Assemble(frame, pv1);
|
||||||
Assert.Equal(4, asm1.Frame.SlotCount);
|
Assert.Equal(4, asm1.Frame.SlotCount);
|
||||||
|
|
||||||
// Second assembly: a single cell, no OutsideView.
|
|
||||||
var pv2 = new PortalVisibilityFrame();
|
var pv2 = new PortalVisibilityFrame();
|
||||||
pv2.CellViews[0xA9B40200] = ViewOf(Square(0f, 0f, 0.25f));
|
pv2.CellViews[0xA9B40200] = ViewOf(Square(0f, 0f, 0.25f));
|
||||||
pv2.OrderedVisibleCells.Add(0xA9B40200);
|
pv2.OrderedVisibleCells.Add(0xA9B40200);
|
||||||
var asm2 = ClipFrameAssembler.Assemble(frame, pv2);
|
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.Equal(2, asm2.Frame.SlotCount);
|
||||||
Assert.True(asm2.CellIdToSlot.ContainsKey(0xA9B40200));
|
Assert.Contains(0xA9B40200, asm2.CellIdToSlot.Keys);
|
||||||
Assert.False(asm2.CellIdToSlot.ContainsKey(0xA9B40100)); // gone after Reset
|
Assert.DoesNotContain(0xA9B40100, asm2.CellIdToSlot.Keys);
|
||||||
Assert.False(asm2.OutdoorVisible); // no OutsideView this time
|
Assert.False(asm2.OutdoorVisible);
|
||||||
Assert.Equal(TerrainClipMode.Skip, asm2.TerrainMode);
|
Assert.Equal(TerrainClipMode.Skip, asm2.TerrainMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -252,4 +252,5 @@ public class ClipPlaneSetTests
|
||||||
// need a real AABB; a zero-area line has none).
|
// need a real AABB; a zero-area line has none).
|
||||||
Assert.True(cps.IsNothingVisible);
|
Assert.True(cps.IsNothingVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ public class InteriorEntityPartitionTests
|
||||||
{
|
{
|
||||||
private const uint CellA = 0xA9B40170;
|
private const uint CellA = 0xA9B40170;
|
||||||
private const uint CellB = 0xA9B40171;
|
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()
|
private static WorldEntity Ent(uint id, uint serverGuid, uint? parentCell) => new()
|
||||||
{
|
{
|
||||||
|
|
@ -28,39 +30,44 @@ public class InteriorEntityPartitionTests
|
||||||
(IReadOnlyDictionary<uint, WorldEntity>?)null) };
|
(IReadOnlyDictionary<uint, WorldEntity>?)null) };
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Partitions_ByServerGuidThenParentCell_IntoThreeBuckets()
|
public void Partitions_LiveAndStaticEntities_ByCellOutdoorAndFallback()
|
||||||
{
|
{
|
||||||
var livePlayer = Ent(1, serverGuid: 0x5000000A, parentCell: null); // live-dynamic
|
var unresolvedLive = Ent(1, serverGuid: 0x5000000A, parentCell: null);
|
||||||
var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA); // live-dynamic WINS over cell
|
var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA);
|
||||||
var staticA = Ent(3, serverGuid: 0, parentCell: CellA); // per-cell static
|
var staticA = Ent(3, serverGuid: 0, parentCell: CellA);
|
||||||
var staticB = Ent(4, serverGuid: 0, parentCell: CellB); // per-cell static
|
var staticB = Ent(4, serverGuid: 0, parentCell: CellB);
|
||||||
var scenery = Ent(5, serverGuid: 0, parentCell: null); // outdoor scenery
|
var scenery = Ent(5, serverGuid: 0, parentCell: null);
|
||||||
|
var liveOutdoor = Ent(6, serverGuid: 0x80005678, parentCell: OutdoorCell);
|
||||||
|
|
||||||
var visible = new HashSet<uint> { CellA, CellB };
|
var visible = new HashSet<uint> { CellA, CellB };
|
||||||
var result = InteriorEntityPartition.Partition(
|
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.Single(result.LiveDynamic);
|
||||||
Assert.Contains(livePlayer, result.LiveDynamic);
|
Assert.Contains(unresolvedLive, result.LiveDynamic);
|
||||||
Assert.Contains(liveNpcInCell, 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.Contains(staticA, result.ByCell[CellA]);
|
||||||
Assert.Single(result.ByCell[CellB]);
|
Assert.Single(result.ByCell[CellB]);
|
||||||
Assert.Contains(staticB, 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(scenery, result.Outdoor);
|
||||||
|
Assert.Contains(liveOutdoor, result.Outdoor);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 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.Outdoor);
|
||||||
Assert.Empty(result.LiveDynamic);
|
Assert.Empty(result.LiveDynamic);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,4 +169,175 @@ public class PortalProjectionTests
|
||||||
Assert.True(onScreen.Length >= 3,
|
Assert.True(onScreen.Length >= 3,
|
||||||
"the cell behind a doorway you're standing in must stay visible (the void bug)");
|
"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)");
|
"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]
|
[Fact]
|
||||||
public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty()
|
public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty()
|
||||||
{
|
{
|
||||||
|
|
@ -454,6 +509,117 @@ public class PortalVisibilityBuilderTests
|
||||||
"No exit portal in any reachable cell must leave OutsideView empty");
|
"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]
|
[Fact]
|
||||||
public void Build_RootCellAlwaysFirstInOrderedVisibleCells()
|
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 System.Collections.Generic;
|
||||||
using AcDream.App.Rendering.Wb;
|
using AcDream.App.Rendering.Wb;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -23,13 +6,10 @@ namespace AcDream.App.Tests.Rendering.Wb;
|
||||||
|
|
||||||
public sealed class WbDrawDispatcherClipSlotTests
|
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 VisibleCellA = 0xA9B4_0164u;
|
||||||
private const uint VisibleCellB = 0xA9B4_0165u;
|
private const uint VisibleCellB = 0xA9B4_0165u;
|
||||||
private const uint NotVisibleCell = 0xA9B4_0999u;
|
private const uint NotVisibleCell = 0xA9B4_0999u;
|
||||||
|
private const uint OutdoorCell = 0xA9B4_0020u;
|
||||||
|
|
||||||
private const int SlotA = 3;
|
private const int SlotA = 3;
|
||||||
private const int SlotB = 7;
|
private const int SlotB = 7;
|
||||||
|
|
@ -41,30 +21,44 @@ public sealed class WbDrawDispatcherClipSlotTests
|
||||||
[VisibleCellB] = SlotB,
|
[VisibleCellB] = SlotB,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Raw resolver (ResolveEntitySlot): only reached when routing is active ──
|
|
||||||
|
|
||||||
[Fact]
|
[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(
|
int slot = WbDrawDispatcher.ResolveEntitySlot(
|
||||||
serverGuid: 0x5000_000Au, parentCellId: VisibleCellA,
|
serverGuid: 0x5000_000Au, parentCellId: VisibleCellA,
|
||||||
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
||||||
|
|
||||||
Assert.Equal(0, slot);
|
Assert.Equal(SlotA, slot);
|
||||||
Assert.NotEqual(SlotA, slot); // guards against ordering regression
|
}
|
||||||
|
|
||||||
|
[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]
|
[Fact]
|
||||||
|
|
@ -107,19 +101,9 @@ public sealed class WbDrawDispatcherClipSlotTests
|
||||||
Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot);
|
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]
|
[Fact]
|
||||||
public void ForFrame_RoutingInactive_EveryEntityIsSlot0AndNotCulled()
|
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(
|
var live = WbDrawDispatcher.ResolveSlotForFrame(
|
||||||
clipRoutingActive: false, serverGuid: 0x5000_000Au, parentCellId: null,
|
clipRoutingActive: false, serverGuid: 0x5000_000Au, parentCellId: null,
|
||||||
cellIdToSlot: null, outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
cellIdToSlot: null, outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
||||||
|
|
@ -134,16 +118,27 @@ public sealed class WbDrawDispatcherClipSlotTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ForFrame_RoutingActive_LiveEntity_Slot0NotCulled()
|
public void ForFrame_RoutingActive_LiveEntityVisible_GetsCellSlotNotCulled()
|
||||||
{
|
{
|
||||||
var r = WbDrawDispatcher.ResolveSlotForFrame(
|
var r = WbDrawDispatcher.ResolveSlotForFrame(
|
||||||
clipRoutingActive: true, serverGuid: 0x5000_000Au, parentCellId: VisibleCellA,
|
clipRoutingActive: true, serverGuid: 0x5000_000Au, parentCellId: VisibleCellA,
|
||||||
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
||||||
|
|
||||||
Assert.Equal(0u, r.Slot);
|
Assert.Equal((uint)SlotA, r.Slot);
|
||||||
Assert.False(r.Culled);
|
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]
|
[Fact]
|
||||||
public void ForFrame_RoutingActive_CellStaticVisible_GetsCellSlotNotCulled()
|
public void ForFrame_RoutingActive_CellStaticVisible_GetsCellSlotNotCulled()
|
||||||
{
|
{
|
||||||
|
|
@ -163,7 +158,6 @@ public sealed class WbDrawDispatcherClipSlotTests
|
||||||
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
||||||
|
|
||||||
Assert.True(r.Culled);
|
Assert.True(r.Culled);
|
||||||
// When culled the loop body forces slot 0 (the value is never emitted).
|
|
||||||
Assert.Equal(0u, r.Slot);
|
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
|
// 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
|
// 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
|
// 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]
|
[Fact]
|
||||||
public void ShouldRenderIndoor_PlayerOutside_CameraInside_ReturnsFalse()
|
public void ShouldRenderIndoor_PlayerOutside_CameraInside_ReturnsFalse()
|
||||||
{
|
{
|
||||||
// THE doorway-grey regression: the player stepped onto a landcell (0x...0031) but the
|
// 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.
|
// 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]
|
[Fact]
|
||||||
public void ShouldRenderIndoor_PlayerInside_CameraInside_ReturnsTrue()
|
public void ShouldRenderIndoor_PlayerInside_CameraInside_ReturnsTrue()
|
||||||
{
|
{
|
||||||
Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, viewerCellResolved: true));
|
Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, renderRootResolved: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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
|
// Opposite lag (camera pulled outside while the player is inside): no viewer cell to
|
||||||
// root DrawInside at → outdoor. Defensive; matches prior null-CameraCell behavior.
|
// 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]
|
[Fact]
|
||||||
public void ShouldRenderIndoor_PlayerOutside_CameraOutside_ReturnsFalse()
|
public void ShouldRenderIndoor_PlayerOutside_CameraOutside_ReturnsFalse()
|
||||||
{
|
{
|
||||||
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, viewerCellResolved: false));
|
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, renderRootResolved: false));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ShouldRenderIndoor_UnknownPlayerCell_TreatedAsOutside_ReturnsFalse()
|
public void ShouldRenderIndoor_UnknownPlayerCell_TreatedAsOutside_ReturnsFalse()
|
||||||
{
|
{
|
||||||
// playerCellId == 0 (unresolved) → treat as outside (safe default: outdoor render).
|
// 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);
|
Directory.CreateDirectory(outDir);
|
||||||
Console.WriteLine($"outDir = {outDir}");
|
Console.WriteLine($"outDir = {outDir}");
|
||||||
|
|
||||||
|
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
|
// Original ask: 0x050016A4..0x050016A8. A4/A5/A7/A8 don't exist as files; widen
|
||||||
// to 0x050016A0..0x050016AF to catch any related precip textures.
|
// to 0x050016A0..0x050016AF to catch any related precip textures.
|
||||||
var idList = new System.Collections.Generic.List<uint>();
|
var idList = new System.Collections.Generic.List<uint>();
|
||||||
for (uint i = 0x050016A0; i <= 0x050016AF; i++) idList.Add(i);
|
for (uint i = 0x050016A0; i <= 0x050016AF; i++) idList.Add(i);
|
||||||
uint[] ids = idList.ToArray();
|
ids = idList.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
(uint id, double densityFraction)? best = null;
|
(uint id, double densityFraction)? best = null;
|
||||||
|
|
||||||
|
|
@ -35,15 +43,25 @@ foreach (var id in ids)
|
||||||
Console.WriteLine($"=== 0x{id:X8} ===");
|
Console.WriteLine($"=== 0x{id:X8} ===");
|
||||||
|
|
||||||
RenderSurface? rs = null;
|
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).
|
// 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];
|
uint rsid = (uint)st.Textures[0];
|
||||||
Console.WriteLine($" SurfaceTexture wrapper, {st.Textures.Count} mip(s), first = 0x{rsid:X8}");
|
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)
|
if (dats.TryGet<RenderSurface>(rsid, out var inner) && inner is not null)
|
||||||
rs = inner;
|
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;
|
rs = direct;
|
||||||
}
|
}
|
||||||
|
|
@ -226,3 +244,11 @@ static uint Adler32(byte[] data)
|
||||||
}
|
}
|
||||||
return (b << 16) | a;
|
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