feat(render): Phase A8 — indoor visibility + streaming fixes batch
Lands the working A8 indoor-rendering and streaming fixes accumulated this session. User has verified these visually to some degree (e.g. lifestone / translucent meshes confirmed fine under the FrontFace flip; bridge / wall / collision regressions confirmed fixed after travel); not every path has been exhaustively gated. The cellar-flap defect remains OPEN and will be solved the retail-faithful way via a dedicated brainstorm (see handoff docs). Rendering core (reviewed, high confidence): - EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of the 80B CPU InstanceData struct the shader never expected — fixes the transform/texture "explosion" for any draw with >1 instance (cells that dedupe to a shared cellGeomId). Real root cause. - WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into same-cull runs with absolute uDrawIDOffset per run). - EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) + WorldEntity.BuildingShellAnchorCellId so building shells scope to their dat-derived building cell instead of rendering everywhere. - RenderOutsideInAcdream (look into buildings from outside) + CollectVisiblePortalBuildings frustum cull of portal bounds. - Sky-when-inside-building + per-cell audit probe + GL-state probe. Streaming / perf (test-covered; not independently code-reviewed this session): - Near/far priority queues so near work wins over far; PromoteToNear carries full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids rebuilding the animated-lookup dict in the hot draw path. Fixes the bridge-not-appearing / missing-walls / broken-collision-after-travel regressions and improves post-transition FPS. Tooling + docs: - tools/A8CellAudit: offline dat cell/portal/building dumper (portals + buildings modes) — reproduces the cellar-flap investigation with no launch. - docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil double-duty finding + the WB-recursive design decision + brainstorm prompt), entity-taxonomy, replan, issue-78 visibility investigation. Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert provisional pos.w clamp, and the probe families are kept (env-var gated, zero cost when off) because the pending option-2 cellar-flap brainstorm needs them. Strip in the option-2 ship commit. Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8 visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e415bb3863
commit
5dc4140c11
38 changed files with 3965 additions and 277 deletions
|
|
@ -1192,7 +1192,7 @@ or +small fix if different. Not blocking M1.
|
|||
## #71 — WorldPicker Stage B — polygon refine for retail-accurate clicks
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (Stage A — screen-rect picker — is sufficient for M1)
|
||||
**Severity:** MEDIUM (Stage A now causes real play mis-picks through open doors/windows)
|
||||
**Filed:** 2026-05-16
|
||||
**Component:** selection / picker
|
||||
|
||||
|
|
@ -1212,6 +1212,15 @@ to the visible mesh — under-pick what looks like empty space inside
|
|||
the rect, catch visible mesh that pokes past the sphere boundary
|
||||
(creature outstretched arm, sign edge).
|
||||
|
||||
**New evidence (2026-05-28 / Phase A8 visual gate):** User stood outside
|
||||
a Holtburg building, saw a vendor through an open doorway/window, clicked
|
||||
the visible vendor, and acdream selected the door instead:
|
||||
`[B.4b] pick guid=0x7A9B4015 name=Door`. This is exactly the Stage A
|
||||
failure mode: the open door's projected `Setup.SelectionSphere` rect is
|
||||
closer than the vendor's rect, even though the visible door polygon is not
|
||||
under the cursor. The fix is polygon refinement against visible GfxObj
|
||||
triangles plus current animated part transforms; do not special-case doors.
|
||||
|
||||
**Acceptance:** Pipe per-part GfxObj visual polygons through a
|
||||
`PickPolygonProvider` interface (don't duplicate mesh decoding —
|
||||
hook the existing `ObjectMeshManager` cached data). Two-tier in
|
||||
|
|
@ -1222,8 +1231,8 @@ frame edges.
|
|||
|
||||
**Estimated scope:** Medium (~4-6 hours). Defer until visual
|
||||
verification surfaces a Stage A miss in real play. The user
|
||||
confirmed 2026-05-16 that "I can click on longer ranges now so
|
||||
good" — Stage A is enough for M1's "click an NPC" demo.
|
||||
confirmed 2026-05-28 that the door/vendor case is now observable in real
|
||||
play, so this should be scheduled soon after A8 rather than left as polish.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
# Issue #78 + cellar-stairs visibility culling — investigation report
|
||||
|
||||
**Date:** 2026-05-25 PM (continuation session)
|
||||
**Status:** REPORT-ONLY. Awaiting user (a) camera-rotation falsification test and (b) approach selection before any code work.
|
||||
**Predecessor handoff:** [`docs/research/2026-05-25-issue-100-shipped-and-culling-handoff.md`](2026-05-25-issue-100-shipped-and-culling-handoff.md)
|
||||
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
Two visible defects share one root cause:
|
||||
|
||||
1. **Cellar-stairs (observed 2026-05-25 PM, evidence for #78):** standing in a Holtburg cottage cellar with the camera at certain angles, the outdoor terrain mesh renders as a sharp-edged grass rectangle covering the cellar stair geometry. **Clears when camera moves closer** (cottage walls + stair treads geometrically occlude). Gameplay unaffected — player can walk up/down normally.
|
||||
2. **Inn-wall stabs (#78, filed 2026-05-19):** standing inside the Holtburg Inn looking at the floor or walls, the user sees other buildings in the distance at their correct world position + scale, visible THROUGH the floor and walls.
|
||||
|
||||
The user has NOT yet run the camera-rotation falsification test (Phase 1a of the handoff). Until they do, the diagnosis below is "high confidence" but not certain.
|
||||
|
||||
Sibling: **#95** (dungeon portal-graph blowup) is the same visibility subsystem but a different specific failure (over-inclusion). scen5 log shows `visibleCells` per cell reaching **295** (worse than the 135-145 filed).
|
||||
|
||||
---
|
||||
|
||||
## Hypotheses (ranked)
|
||||
|
||||
### H1 — Indoor-camera gate missing on outdoor render passes (HIGH confidence)
|
||||
|
||||
**Mechanism:** `TerrainModernRenderer.Draw` and `WbDrawDispatcher` render outdoor geometry unconditionally regardless of whether the camera is inside an EnvCell. Retail and WorldBuilder both gate the outdoor passes by the indoor portal-walk result. acdream does neither.
|
||||
|
||||
**Evidence FOR (strong):**
|
||||
- Retail anchor verified: `PView::DrawCells` at `acclient_2013_pseudo_c.txt:432709` gates `LScape::draw` (outdoor terrain dispatch) by `if (outside_view.view_count > 0)`. `outside_view.view_count` is only incremented during the indoor portal BFS (`PView::ConstructView`) when a portal targets `other_cell_id == 0xFFFFFFFF` (outdoor sentinel). When no portal sees outside, the entire outdoor pass is skipped.
|
||||
- Retail's per-mesh draw (`RenderDeviceD3D::DrawMesh` line 429245) iterates `Render::PortalList->view_count` and skips meshes that straddle 0 sub-views. **No stencil** — retail uses screen-space polygon clipping via `PView::GetClip`.
|
||||
- WB anchor verified: `VisibilityManager.RenderInsideOut` (lines 73-239) uses **stencil**: mark current-building portals stencil=1, punch portal regions to far depth, draw EnvCells unconditionally, then `terrain/scenery/statics` gated by `glStencilFunc(Equal, 1, 0x01)`. The top-level loop already skips the unconditional terrain draw via `if (!isInside) terrainManager.Render(...)` at GameScene.cs:965.
|
||||
- acdream audit verified the gate is missing: `WbDrawDispatcher.cs:360-362` gates by `entity.ParentCellId.HasValue && !visibleCellIds.Contains(...)`. When `ParentCellId == null` (outdoor stabs, scenery, live-spawned entities), the boolean short-circuits to `cellInVis = true` — the entity passes regardless of `visibleCellIds`.
|
||||
- `TerrainModernRenderer.Draw` (lines 191-208) only does per-slot frustum cull. No `visibleCellIds` parameter, no indoor-camera awareness.
|
||||
- Patch geometry size (~24 m × 24 m rectangle) matches a terrain cell footprint — that's a polygon, not a precision artifact.
|
||||
- "Clears when closer" matches geometric occlusion: cottage walls + stair treads come to occlude the offending terrain cells screen-space as the camera approaches. A 1 cm depth-buffer Z-fight (#100's nudge) at 2-5 m camera distance with 24-bit depth has sub-millimeter resolving power; precision is not the bottleneck.
|
||||
|
||||
**Evidence AGAINST:**
|
||||
- User has not yet run the camera-rotation test. If the patch flickers/shimmers when rotating the camera in place, the diagnosis pivots to Z-precision.
|
||||
|
||||
**How to falsify:** Stand at the spot showing the cellar-stairs artifact, look at the grass patch, rotate the camera slowly without moving the character. Polygon-stable edges that track predictably with the view = culling (H1). Flickering / shimmering = Z-precision (H2).
|
||||
|
||||
### H2 — Residual Z-fight from #100's nudge (LOW confidence)
|
||||
|
||||
The 1 cm shader nudge from issue #100 might be insufficient at certain Z values or with shader precision quirks.
|
||||
|
||||
**Evidence FOR:** Same code area was just touched.
|
||||
**Evidence AGAINST:** Predecessor research already established 1 cm @ 24-bit depth has sub-mm resolving at gameplay camera distances. Patch is rectangular polygon, not thin Z-fight strip. "Clears when closer" reverses precision direction.
|
||||
**How to falsify:** Same camera-rotation test.
|
||||
|
||||
### H3 — #95 portal-traversal blowup is independent of H1 (HIGH confidence it IS independent)
|
||||
|
||||
**Mechanism:** `CellVisibility.GetVisibleCells` BFS over portals lacks termination/cap-depth logic. Network hubs expose 100+ outbound portals to disconnected dungeons, all marked visible. scen5 log shows up to 295 cells in one visible set.
|
||||
|
||||
**Evidence FOR independence:**
|
||||
- H1 is an **asymmetric over-render** (outdoor passes ignore indoor state).
|
||||
- H3 is a **symmetric over-inclusion** (BFS doesn't terminate properly).
|
||||
- A fix to H1 would gate WHEN to render outdoor; H3's fix is to bound WHICH indoor cells the BFS includes.
|
||||
- Different code paths: H1 lives in `TerrainModernRenderer.Draw` + `WbDrawDispatcher`; H3 lives in `CellVisibility.GetVisibleCells`.
|
||||
|
||||
**Conclusion:** H1 and H3 should be **separate fixes**. Closing H1 will close cellar-stairs + the outdoor-stab side of #78 but NOT close #95. The next phase should plan H1 in scope and decide whether H3 fits in the same milestone (M1.5).
|
||||
|
||||
---
|
||||
|
||||
## What we've ruled out
|
||||
|
||||
- **It's not the #100 cell-collapse bug returning.** `hiddenTerrainCells` plumbing was fully removed in `a64e6f2`; terrain mesh now correctly renders everywhere on the landblock per retail. The new artifact's mechanism is "outdoor geometry visible at all when indoor," not "incorrect terrain mesh shape."
|
||||
- **It's not a depth-precision issue (high confidence, pending falsification).** Patch shape + "clears closer" both contradict Z-fight.
|
||||
- **It's not a `ParentCellId` propagation bug.** Audit confirmed that interior cell static objects (`GameWindow.BuildInteriorEntitiesForStreaming:5476`) and cell-mesh entities (line 5416) both receive non-null `ParentCellId = envCellId`. The dispatcher's existing filter already correctly culls them when the camera is in a different building. The bug is the OPPOSITE direction (outdoor entities w/ `ParentCellId == null` always pass).
|
||||
- **It's not WB extraction divergence.** Phase O extracted ~33 WB files into `src/AcDream.App/Rendering/Wb/` but the `VisibilityManager` / `RenderInsideOut` pipeline was NOT extracted — that code never existed in our tree.
|
||||
- **It's not a missing camera-cell signal at the render layer.** `cameraInsideCell`, `visibility.VisibleCellIds`, and `visibility.HasExitPortalVisible` are all already computed in `GameWindow.cs:6970-6984` and live in scope at the two `Draw` call sites (lines 7074 + 7110). No new plumbing required.
|
||||
|
||||
---
|
||||
|
||||
## Approach options for the fix
|
||||
|
||||
Three viable approaches, with tradeoffs:
|
||||
|
||||
### Approach A — WB-style stencil (recommended for first ship)
|
||||
|
||||
Port `VisibilityManager.RenderInsideOut`'s stencil pipeline to acdream. Two-pass render: (1) mark current-building portal silhouettes in stencil, (2) gate outdoor passes by `glStencilFunc(Equal, 1, 0x01)`.
|
||||
|
||||
**Pros:**
|
||||
- Closest to acdream's existing modern GL pipeline (we already use stencil for nothing else; adding one stencil bit is cheap).
|
||||
- WB is acdream's documented rendering base (per CLAUDE.md). Cross-reference checked against retail confirms WB's intent matches retail's, just via a different mechanism.
|
||||
- Handles the "see outside through open door" case correctly — terrain renders through portal silhouettes only.
|
||||
- Reusable for both outdoor terrain AND outdoor entities (single stencil gate applies to all subsequent draws).
|
||||
|
||||
**Cons:**
|
||||
- Multi-pass render adds GPU cost (small — one stencil pass per current-building's portals).
|
||||
- Requires a portal-mesh upload pipeline (WB has one in `PortalRenderManager.cs:488-628`; we'd port it).
|
||||
- More LOC than Approach C.
|
||||
|
||||
**Estimated scope:** 4-6 tasks, 1-2 weeks of implementation + verification.
|
||||
|
||||
### Approach B — Retail-faithful polygon-clip sub-views
|
||||
|
||||
Port `PView::ConstructView` + `PView::GetClip` + `Render::PortalList` from retail. Per-mesh viewport set to clipped portal polygon.
|
||||
|
||||
**Pros:**
|
||||
- 100% retail-faithful.
|
||||
|
||||
**Cons:**
|
||||
- Requires per-draw viewport scissor changes — current rendering uses bindless + MDI with one viewport per pass. Wedging per-mesh viewport in would break the modern pipeline's batching.
|
||||
- Multi-week port. Out of scope for one session.
|
||||
|
||||
**Estimated scope:** 8-12 tasks, 4-6 weeks. Defer to a future milestone if needed.
|
||||
|
||||
### Approach C — Ship-now binary gate
|
||||
|
||||
When `cameraInsideCell && !visibility.HasExitPortalVisible`, skip outdoor terrain pass entirely and gate `WbDrawDispatcher` to exclude `ParentCellId == null` entities.
|
||||
|
||||
**Pros:**
|
||||
- Smallest change. ~2-3 tasks. Closes the cellar-stairs symptom and the sealed-interior side of #78 immediately.
|
||||
- All required state already computed (`HasExitPortalVisible` from `CellVisibility.GetVisibleCells` line 404).
|
||||
|
||||
**Cons:**
|
||||
- Under-renders when player can see outside through an open door/window (renders nothing instead of clipping correctly). This is regressive vs. today for the doorway-view case.
|
||||
- Per CLAUDE.md "no workarounds": this *is* a symptom-gate rather than a root-cause fix. **Would need explicit user approval.** Approach A is the correct shape; Approach C is a temporary patch.
|
||||
|
||||
**Estimated scope:** 2-3 tasks, 1-2 days.
|
||||
|
||||
---
|
||||
|
||||
## Recommended next step
|
||||
|
||||
1. **User runs the camera-rotation falsification test (~60 seconds).** Spawn at Holtburg, walk into a cottage cellar, find the angle showing the grass patch, rotate the camera in place without moving. Report what happens.
|
||||
- Polygon-stable → confirms H1, proceed.
|
||||
- Flickering → pivots to H2, this report needs major revision.
|
||||
|
||||
2. **If H1 confirmed: user picks Approach A vs C.** Recommendation: **Approach A (WB-style stencil)**. Per CLAUDE.md's "no workarounds" rule, the right thing is to port the stencil pipeline, not gate at the symptom site. Approach C is offered only if the user wants to close cellar-stairs immediately and defer doorway-view correctness as known-incomplete; that's an explicit workaround that needs user sign-off.
|
||||
|
||||
3. **#95 should NOT be in scope for this work.** Different mechanism, different code path. File continues as separate work in M1.5.
|
||||
|
||||
4. **Phase identifier:** the handoff proposes A8 (visibility) alongside A6 (physics) and A7 (lighting). I'll defer naming to the user.
|
||||
|
||||
5. **CLAUDE.md update for #100 ship:** the handoff calls this out as pending. Recommendation: add a brief #100 ship entry mentioning the cellar-stairs finding linked to #78. Out of scope for investigate mode; will happen at the start of the implementation session.
|
||||
|
||||
---
|
||||
|
||||
## What this is NOT
|
||||
|
||||
This is NOT a #100 regression. The terrain Z-nudge ship works correctly; the new artifact has a different root cause (indoor-camera gate on outdoor passes was already missing pre-#100 — #100 just made it more visible by removing the terrain-cell hide mechanism that incidentally masked it inside building footprints).
|
||||
|
||||
This is NOT a depth-precision fix. The 1cm nudge is correctly sized; larger nudges would break coplanar-floor disambiguation elsewhere.
|
||||
|
||||
This is NOT a `ParentCellId` data fix. Interior entities are correctly tagged.
|
||||
|
||||
This is NOT covered by Phase O's WB extraction. The visibility-management code was deliberately NOT extracted.
|
||||
|
||||
---
|
||||
|
||||
## Reference appendix
|
||||
|
||||
### Retail anchors (acclient_2013_pseudo_c.txt)
|
||||
|
||||
| Line | Symbol | Role |
|
||||
|---|---|---|
|
||||
| 92635 | `SmartBox::RenderNormalMode` | Per-frame top-level dispatcher (indoor vs outdoor branch) |
|
||||
| 267912 | `LScape::draw` | Outdoor terrain dispatch |
|
||||
| 311397 | `CEnvCell::find_visible_child_cell` | Point-in-visible-cell query |
|
||||
| 311878 | `CEnvCell::grab_visible_cells` | Loads outdoor on `seen_outside` |
|
||||
| 427843 | `RenderDeviceD3D::DrawInside` | Indoor entry point |
|
||||
| 429245 | `RenderDeviceD3D::DrawMesh` | **Per-mesh portal-sub-view loop** |
|
||||
| 430027 | `RenderDeviceD3D::DrawBlock` | Outdoor landblock dispatch |
|
||||
| 432709 | **`PView::DrawCells`** | **The `outside_view.view_count > 0` gate** |
|
||||
| 433750 | `PView::ConstructView` | BFS portal walk |
|
||||
|
||||
### WorldBuilder anchors
|
||||
|
||||
| File:Line | Role |
|
||||
|---|---|
|
||||
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239` | `RenderInsideOut` — full stencil pipeline |
|
||||
| Same file:241-359 | `RenderOutsideIn` — outdoor branch |
|
||||
| Same file:47-71 | `PrepareVisibility` — visible cell set |
|
||||
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs:880-1008` | Main render dispatch (lines 965, 988 are the gates) |
|
||||
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:488-628` | Portal mesh upload |
|
||||
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/CameraController.cs:142-174` | Camera-cell tracking (portal raycasts) |
|
||||
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/PortalStencil.frag:7-16` | Stencil shader (writes `gl_FragDepth = 1.0`) |
|
||||
|
||||
### acdream extension points (audit-verified)
|
||||
|
||||
| File:Line | Current behavior | Extension required |
|
||||
|---|---|---|
|
||||
| `src/AcDream.App/Rendering/CellVisibility.cs:222-232` | Returns `VisibilityResult` with `VisibleCellIds`, `HasExitPortalVisible`, `CameraCell` | None — state already in place |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs:6970-6984` | Computes `cameraInsideCell` and `playerInsideCell` per frame | None — values already in scope |
|
||||
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:360-374` | Gates by `ParentCellId ∈ visibleCellIds`; outdoor entities (null) always pass | Add second gate: when `cameraInsideCell == true` and entity is outdoor (`ParentCellId == null`), require stencil pass or skip entirely |
|
||||
| `src/AcDream.App/Rendering/TerrainModernRenderer.cs:191-208` | Frustum-only cull; renders all loaded landblocks | Add parameter for stencil pass / indoor-camera state |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs:7074` | `_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb)` | Add `cameraInsideCell` (or equivalent) parameter |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs:7110` | `WbDrawDispatcher.Draw(... visibleCellIds: visibility?.VisibleCellIds, ...)` | Add `cameraInsideCell` parameter |
|
||||
| `src/AcDream.Core/Rendering/RenderingDiagnostics.cs:75-77` | Existing probe flag registry (mirror of `PhysicsDiagnostics`) | Add `ProbeVisibilityEnabled` from `ACDREAM_PROBE_VIS=1` |
|
||||
|
||||
### Issues family map
|
||||
|
||||
| ID | Symptom | Closes with H1 fix? |
|
||||
|---|---|---|
|
||||
| #78 | Outdoor stabs visible through inn floor/walls | YES (same root cause) |
|
||||
| Cellar-stairs (NEW) | Outdoor terrain visible inside cottage cellar | YES (same root cause; new evidence for #78) |
|
||||
| #95 | Portal-graph visibility blowup (visibleCells up to 295) | NO — independent (different code path) |
|
||||
| #79/#80/#81/#93/#94 | Indoor lighting bugs | Maybe — #93 explicitly suspects "indoor visibility culling for lights" sub-cause; lighting subsystem may share infrastructure with visibility-gate but not directly impacted |
|
||||
|
||||
### Workflow notes (from CLAUDE.md "How to operate")
|
||||
|
||||
- "No workarounds without explicit approval" — Approach C is a workaround; Approach A is the correct shape.
|
||||
- Visual verification is the user's job; can't be automated.
|
||||
- Phase ID for visibility work is undecided. User picks at implementation-session start.
|
||||
- Per the milestones doc, this is M1.5 scope; cellar-stairs is on the M1.5 critical path because it blocks the building/cellar half of the M1.5 demo.
|
||||
137
docs/research/2026-05-26-a8-entity-taxonomy.md
Normal file
137
docs/research/2026-05-26-a8-entity-taxonomy.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Phase A8 re-plan — entity taxonomy investigation
|
||||
|
||||
**Date:** 2026-05-26
|
||||
**Phase:** A8 — Indoor-cell visibility culling RE-PLAN
|
||||
**Predecessor handoff:** [docs/research/2026-05-26-a8-revert-handoff.md](2026-05-26-a8-revert-handoff.md)
|
||||
**Status:** Report-only. Awaiting user approval of recommended fix-shape before Phase 2 (plan writing).
|
||||
**Empirical context (added during investigation):** the bug exists on `main` too — verified by side-by-side launch of `main` vs `HEAD = fef6c61`. Both branches show outdoor buildings/terrain visible through the walls of a cottage when standing inside. The bug is **fundamental**, not a regression in this worktree's 149-commit divergence. The A8 framing in the predecessor handoff stands.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
The retail data model, WorldBuilder's data model, and the comment at `GameWindow.cs:5175-5178` all agree on a single architectural fact: **building shells are tagged distinctly from outdoor scenery at the data layer.** acdream's `LandblockLoader` reads both `LandBlockInfo.Objects` (scenery) and `LandBlockInfo.Buildings` (shells) into the same `WorldEntity` pool with no tag, destroying the distinction. The fix is to add `WorldEntity.IsBuildingShell: bool` at the loader, propagate it through hydration, and use it in the `WbDrawDispatcher.EntitySet` partition. This is **retail-faithful** (matches `BuildInfo` array) and **WB-faithful** (matches `SceneryInstance.IsBuilding`).
|
||||
|
||||
GL state order from the A8 Round 3 learning (MarkAndPunch BEFORE indoor draw) is confirmed correct by reading WorldBuilder's `VisibilityManager.RenderInsideOut`.
|
||||
|
||||
Far-side-portal (WB "Step 5", 3-stencil-bit) is deferred. First-ship approximation: only stencil-mark the **camera's own cell's** portals, not BFS-extended `VisibleCellIds`.
|
||||
|
||||
---
|
||||
|
||||
## The seven entity classes in acdream's runtime
|
||||
|
||||
| # | Class | `ParentCellId` | `Id` prefix | `ServerGuid` | Source field |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Cell mesh | set | `0x40xxxxxx` | 0 | `EnvCell.EnvironmentId` |
|
||||
| 2 | Cell static object | set | `0x40xxxxxx` | 0 | `EnvCell.StaticObjects` |
|
||||
| 3 | **Building shell stab** | **null** | `0xC0xxxxxx` | 0 | **`LandBlockInfo.Buildings`** |
|
||||
| 4 | **Outdoor scenery stab** | **null** | `0xC0xxxxxx` | 0 | **`LandBlockInfo.Objects`** |
|
||||
| 5 | Procedural scenery | null | `0x80xxxxxx` | 0 | `SceneryGenerator` (terrain table) |
|
||||
| 6a | Live animated | null | `0x10xxxxxx` | ≠0 | `CreateObject` packet |
|
||||
| 6b | Live static | null | `0x10xxxxxx` | ≠0 | `CreateObject` packet |
|
||||
|
||||
**Classes 3 and 4 are indistinguishable at runtime today** (identical field shape after hydration). This is the load-bearing wrong assumption from the A8 attempt.
|
||||
|
||||
### Code anchors (acdream)
|
||||
- `src/AcDream.Core/World/LandblockLoader.cs:62-71` — Objects (Class 4) loop
|
||||
- `src/AcDream.Core/World/LandblockLoader.cs:74-87` — Buildings (Class 3) loop, **same `nextId++` counter, same WorldEntity shape**
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs:5129-5137` — hydration pass-through, no distinction preserved
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs:5175-5178` — the comment that proves the distinction is intentional in dat:
|
||||
> *"Only Buildings suppress scenery. Stabs (LandBlockInfo.Objects) are static scenery placeholders themselves (rocks, tree clusters) that retail does NOT use to suppress scenery generation."*
|
||||
|
||||
---
|
||||
|
||||
## How retail tags buildings (cross-reference 1)
|
||||
|
||||
`CLandBlock::init_buildings` (`acclient_2013_pseudo_c.txt:313854-313920`) reads `CLandBlockInfo::buildings[]` — a **separate `BuildInfo**` array**, NOT a flag bit or ID-range scheme.
|
||||
|
||||
- `CLandBlockInfo.num_buildings` + `buildings[]` array (`acclient.h:31893-31905`)
|
||||
- `BuildInfo` struct: `building_id`, `building_frame`, `num_portals`, `CBldPortal** portals` (`acclient.h:32035-32042`)
|
||||
- Buildings hydrate via `CBuildingObj::makeBuilding()` (line 313879) and register into the landblock's `stablist[]` (per-landblock visible-cell set, line 313910)
|
||||
- Visibility uses **stablist (portal PVS)**, NOT AABB-encloses-camera. `CEnvCell::grab_visible` walks `stab_list[i]` directly.
|
||||
|
||||
Conclusion: **retail explicitly distinguishes the two via separate dat arrays.** This is the data-model truth we should match.
|
||||
|
||||
## How WorldBuilder tags buildings (cross-reference 2)
|
||||
|
||||
WB uses **two manager classes** sharing one mesh pool:
|
||||
- `StaticObjectRenderManager` — handles BOTH `LandBlockInfo.Objects` and `LandBlockInfo.Buildings`, tagging each `SceneryInstance.IsBuilding` (`StaticObjectRenderManager.cs:334-400`).
|
||||
- `SceneryRenderManager` — handles ONLY procedural terrain-derived scenery (different class entirely, doesn't share the dat path).
|
||||
|
||||
Tagging happens at **hydration time** in `GenerateForLandblockAsync` (lines 315-427). The instance is then split into separate `StaticPartGroups` vs `BuildingPartGroups` for draw dispatch.
|
||||
|
||||
`BuildingPortalGPU` (`PortalRenderManager.cs:687-701`) holds `EnvCellIds: HashSet<uint>` populated at landblock generation (line 549) — the "this building contains these EnvCells" association. The set is **never re-computed at render time**.
|
||||
|
||||
WB's `RenderInsideOut` GL state order (`VisibilityManager.cs:73-239`):
|
||||
1. Stencil bit 1 ← portal polygons (color/depth masks off)
|
||||
2. `gl_FragDepth = 1.0` ← portal polygons (depth mask on, depth-func = Always)
|
||||
3. **Interior EnvCells render WITHOUT stencil restriction** ← key step
|
||||
4. Stencil-restricted (`Equal, 1`): terrain + scenery + buildings render only at portal silhouettes
|
||||
5. (Step 5) 3-stencil-bit pipeline for cross-building visibility — DEFER
|
||||
|
||||
**WB's order = MarkAndPunch (Step 1 + 2) FIRST, then indoor cells (Step 3).** This matches A8 Round 3's correction. The handoff's GL-state-order conclusion stands.
|
||||
|
||||
---
|
||||
|
||||
## Recommended fix-shape (synthesized)
|
||||
|
||||
### Stage 1: Tag at hydration (`IsBuildingShell` flag)
|
||||
|
||||
Add `WorldEntity.IsBuildingShell: bool` (default false). In `LandblockLoader.cs`:
|
||||
- Objects loop (line 62): `IsBuildingShell = false`
|
||||
- Buildings loop (line 74): `IsBuildingShell = true`
|
||||
|
||||
In `GameWindow.cs:5129-5137` (hydration): copy `IsBuildingShell` from `e` to the hydrated entity. One-line change.
|
||||
|
||||
### Stage 2: Refine `WbDrawDispatcher.EntitySet` partition
|
||||
|
||||
Replace today's binary `IndoorOnly`/`OutdoorOnly` with:
|
||||
- `IndoorPass` — `ParentCellId.HasValue || IsBuildingShell` (Classes 1, 2, 3)
|
||||
- `OutdoorScenery` — `!ParentCellId.HasValue && !IsBuildingShell && (ServerGuid == 0)` (Classes 4, 5)
|
||||
- `LiveDynamic` — `ServerGuid != 0` (Classes 6a, 6b)
|
||||
|
||||
`WalkEntitiesInto` updates one branch (the partition predicate). 26 dispatcher tests will need their fixture entities tagged correctly; otherwise behavior is the same.
|
||||
|
||||
### Stage 3: Re-wire render frame with WB's order
|
||||
|
||||
When camera is inside a cell:
|
||||
1. Draw terrain (color in framebuffer)
|
||||
2. **MarkAndPunch** (stencil = 1 + depth = 1.0 at portal silhouettes)
|
||||
3. `WbDrawDispatcher.Draw(set: IndoorPass)` — cell mesh + cell statics + building shells. Stencil disabled, depth test normal. These write depth ON TOP of the 1.0 punch, correctly occluding the next stencil-gated pass.
|
||||
4. Re-draw terrain (color writes only) with `StencilFunc(Equal, 1)` — terrain visible only at portal silhouettes.
|
||||
5. `WbDrawDispatcher.Draw(set: OutdoorScenery)` with `StencilFunc(Equal, 1)` — outdoor scenery visible only at portal silhouettes.
|
||||
6. `WbDrawDispatcher.Draw(set: LiveDynamic)` — stencil disabled, depth test on. Live entities draw freely; depth occludes them by walls and cell meshes already in the depth buffer.
|
||||
|
||||
When camera is outside: stencil work skipped entirely. Today's all-entities single draw stands (or substitute the three EntitySet calls with stencil disabled — depth still sorts them correctly).
|
||||
|
||||
### Stage 4: Far-side-portal approximation (defer Step 5)
|
||||
|
||||
Stencil-mark **only the camera's own cell's portals** in Step 2, not the BFS-extended `VisibleCellIds`. This trades cross-cell-portal visibility (rare visually) for correctness in the common case (no "see-through-wall on the other side of the room"). Track as a known limitation; revisit if visual gate flags it.
|
||||
|
||||
---
|
||||
|
||||
## Reasons for confidence
|
||||
|
||||
1. **Triple-cited**: retail (`BuildInfo` array), WB (`IsBuilding` flag), acdream's own code comment (5175-5178) all agree on the distinction.
|
||||
2. **Tagging cost is microscopic** — one bool on `WorldEntity`, one branch in `LandblockLoader`. No new types, no new managers, no field migration.
|
||||
3. **`EntitySet` enum is already in place** (dormant from Tasks 1-6). Refactor is reshaping its semantics, not introducing it.
|
||||
4. **GL state order is validated** by both Round 3 of the A8 attempt and WB's reference. No remaining ambiguity.
|
||||
5. **Live-dynamic separation handles the Round 1 character-disappears bug** (handoff §Round 1). They draw last, stencil disabled, depth-tested against everything else.
|
||||
|
||||
## Open questions for user approval
|
||||
|
||||
1. Use `IsBuildingShell` flag (recommended) vs separate `0xC1xxxxxx` ID-namespace? Flag is more explicit, retail-faithful, and trivially greppable. ID-namespace is one less field but invisible at the call site.
|
||||
2. Defer Step 5 (far-side portals) and stencil-mark only camera's own cell? Recommendation: yes — ship simple, file follow-up.
|
||||
3. Live-dynamic entities (Class 6b: dropped items) — draw in `LivePass` or accept "invisible from inside" until a richer flag exists? Recommendation: `LivePass`. They're rare visually, and the player benefits from seeing dropped items through the floor (gameplay nicety, not retail violation).
|
||||
4. Cellar-stairs grass overlay from OUTSIDE: NOT A8 scope (no stencil runs when camera is outside). Open question for a future "deep-cell terrain occlusion" phase. Confirm we file this separately, not bundled.
|
||||
|
||||
---
|
||||
|
||||
## Reference anchors (still valid from predecessor handoff)
|
||||
|
||||
- WB stencil: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239`
|
||||
- WB building-cell association: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:518-551`
|
||||
- Retail building init: `docs/research/named-retail/acclient_2013_pseudo_c.txt:313854-313920`
|
||||
- Retail building struct: `docs/research/named-retail/acclient.h:31893-31905`, `:32035-32042`, `:32094-32103`
|
||||
- acdream LandblockLoader: `src/AcDream.Core/World/LandblockLoader.cs:62-87`
|
||||
- acdream hydration: `src/AcDream.App/Rendering/GameWindow.cs:5093-5148`, `:5175-5178`
|
||||
168
docs/research/2026-05-28-a8-cellar-flap-handoff.md
Normal file
168
docs/research/2026-05-28-a8-cellar-flap-handoff.md
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
# Phase A8 Cellar-Flap Handoff - 2026-05-28
|
||||
|
||||
## Status
|
||||
|
||||
The remaining cottage/cellar artifact is still visible. The user sees a short-lived green terrain-like rectangle/flap over the cellar entrance or floor opening when entering/exiting a building and when the chase camera angle changes.
|
||||
|
||||
This thread hit the project rule: 3 visual-gated fixes failed for this specific artifact. Stop here. Do not ship a fourth speculative renderer change. The next step must be source-provenance instrumentation or an architecture comparison.
|
||||
|
||||
The client was stopped after the failed visual gate to avoid build/file locks.
|
||||
|
||||
## Last Good Context
|
||||
|
||||
The broader A8 indoor renderer is much improved and shippable aside from this artifact:
|
||||
|
||||
- Theory A front-face alignment plus per-batch `CullMode` fixed major missing/inside-out geometry.
|
||||
- The `InstanceData` stride fix explained the texture explosion/distortion root cause.
|
||||
- Building shell scoping, portal bounds, streaming/promotion fixes, and FPS fixes remain important.
|
||||
- The current artifact is narrow: a green terrain-like flap at/near cellar entrance transitions.
|
||||
|
||||
## Failed Fixes In This Micro-Loop
|
||||
|
||||
### 1. Camera branch gate relaxation
|
||||
|
||||
Hypothesis: strict `PointInCell` camera-inside-building gating was causing the renderer to switch branches while the chase camera crossed walls/portals.
|
||||
|
||||
Change tried: allow the inside-out branch to continue based on `CameraCell.BuildingId`, without the strict `PointInCell` check.
|
||||
|
||||
Result: visual gate failed. User still saw the artifact. Change was reverted back to strict `PointInCell`.
|
||||
|
||||
Evidence ruled out: the artifact is not solely caused by the render branch dropping out when the camera is slightly outside the cell.
|
||||
|
||||
### 2. Portal stencil `pos.w` clamp
|
||||
|
||||
Hypothesis: near-zero clip W for portal stencil triangles was exploding the screen-space mask as the camera crossed a portal plane.
|
||||
|
||||
Change tried: restored WorldBuilder-style clamp in `src/AcDream.App/Rendering/Shaders/portal_stencil.vert`:
|
||||
|
||||
```glsl
|
||||
vec4 pos = uViewProjection * vec4(aPosition, 1.0);
|
||||
if (abs(pos.w) < 0.001)
|
||||
pos.w = pos.w < 0.0 ? -0.001 : 0.001;
|
||||
gl_Position = pos;
|
||||
```
|
||||
|
||||
Result: visual gate failed. User still saw the artifact.
|
||||
|
||||
Interpretation: this may still be WB parity and may be worth keeping, but it is not the root cause of the cellar flap.
|
||||
|
||||
### 3. Visible-cell portal mask
|
||||
|
||||
Hypothesis: `RenderInsideOutAcdream` was punching outdoor terrain through all exit portals in the camera building, not just exits reached by the current portal traversal. A window/door portal could project over the cellar opening and let terrain draw through it.
|
||||
|
||||
Changes tried:
|
||||
|
||||
- Added `CellVisibility.TryGetCell(uint, out LoadedCell?)`.
|
||||
- Added `IndoorCellStencilPipeline.DrawUploadedPortalMesh(...)` to draw the already-uploaded visible-cell portal mesh.
|
||||
- Changed `RenderInsideOutAcdream` Step 1/2 to upload portal triangles from `visibleCellIds`, not `camBuildings`.
|
||||
- Wrapped Step 4 terrain/outdoor scenery draw inside `if (didInsideStencil)`.
|
||||
- Added pure math test `BuildTriangles_OnlyIncludesProvidedVisibleCells`.
|
||||
|
||||
Verification before launch:
|
||||
|
||||
```powershell
|
||||
dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --no-restore
|
||||
dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --filter FullyQualifiedName~IndoorCellStencilPipelineTests --no-restore
|
||||
```
|
||||
|
||||
Both passed after shutting down build servers. The first parallel test attempt only failed because `VBCSCompiler` locked `AcDream.App.dll` while a parallel build was running.
|
||||
|
||||
Visual result: failed. User still saw the flap.
|
||||
|
||||
Evidence ruled out: the artifact is not explained only by building-wide vs visible-cell exit portal masking.
|
||||
|
||||
## Current Uncommitted Changes From This Micro-Loop
|
||||
|
||||
These are not visually validated as a fix:
|
||||
|
||||
- `src/AcDream.App/Rendering/Shaders/portal_stencil.vert`
|
||||
- WB-style `pos.w` clamp.
|
||||
- `src/AcDream.App/Rendering/CellVisibility.cs`
|
||||
- `TryGetCell`.
|
||||
- `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs`
|
||||
- `DrawUploadedPortalMesh`.
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs`
|
||||
- visible-cell portal mask in `RenderInsideOutAcdream`.
|
||||
- Step 4 fail-closed when no portal mesh uploaded.
|
||||
- `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs`
|
||||
- visible-cell-only triangle generation test.
|
||||
|
||||
Do not commit these as "the fix" unless a later source-provenance check proves they are correct and harmless. If the next architecture route goes elsewhere, revert or split them honestly.
|
||||
|
||||
## What We Know About The Cellar Entrance
|
||||
|
||||
Prior inspection found:
|
||||
|
||||
- The cellar transition around Holtburg cottage cells `0xA9B40143 -> 0x0146 -> 0x0147` is indoor-to-indoor.
|
||||
- The relevant transition portals are not literal `OtherCellId == 0xFFFF` outside exits.
|
||||
- The portal cap polygons inspected there are `NoPos`.
|
||||
- That made "cell mesh uploads the outdoor floor polygon" unlikely, but not proven impossible for the visible green patch.
|
||||
|
||||
## What The Failed Fixes Suggest
|
||||
|
||||
The green pixels may not be caused by the portal mask at all. We need to prove which render pass writes them before touching behavior again.
|
||||
|
||||
Candidate explanations now:
|
||||
|
||||
1. **The green patch is not Step 4 terrain.**
|
||||
It could be an EnvCell surface with the wrong texture/material, stale texture binding, or an indoor static object surface resolving to a grass texture.
|
||||
|
||||
2. **The patch is Step 4 terrain, but the stencil/depth mask source is not the exit portal list.**
|
||||
It may be a stale stencil/depth state leak, a Step 3/4 depth ordering issue, or a missing retail occlusion/scissor lifecycle detail.
|
||||
|
||||
3. **The cellar opening has a missing or late-loaded indoor object.**
|
||||
If a hatch/stair/floor object should cover or occlude that area and is missing for one or more frames, outdoor/terrain pixels behind it become visible.
|
||||
|
||||
4. **Camera clipping is exposing an intentionally hidden surface.**
|
||||
Retail camera collision is incomplete in acdream. If the camera clips through the wall/floor volume, the renderer may be showing a view retail never permits. This does not explain all cases by itself, because the user also sees it during transitions, but it must be separated from a renderer bug.
|
||||
|
||||
## Next Step - Evidence Only
|
||||
|
||||
Add a provenance diagnostic, not a fix. One visual launch should answer what writes the green flap.
|
||||
|
||||
Recommended diagnostic:
|
||||
|
||||
- Add an env-gated pass tint or disable switch for only the inside-out Step 4 terrain draw.
|
||||
- Add a separate tint/disable for Step 4 `OutdoorScenery`.
|
||||
- Add a tint/disable for Step 3 EnvCell opaque.
|
||||
- Keep default behavior unchanged.
|
||||
|
||||
Example env names:
|
||||
|
||||
```text
|
||||
ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_TERRAIN=1
|
||||
ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_OUTDOOR=1
|
||||
ACDREAM_A8_DIAG_DISABLE_INSIDE_ENVCELL_OPAQUE=1
|
||||
```
|
||||
|
||||
Run only one diagnostic at a time:
|
||||
|
||||
- If disabling Step 4 terrain removes the green flap, the issue is still terrain leaking through the indoor view mask.
|
||||
- If disabling Step 4 terrain does not remove it, stop looking at portal exits and inspect EnvCell/static texture/material assignment for the specific surface.
|
||||
- If disabling EnvCell opaque removes it, dump the exact `CellStruct` polygon/material under the camera/screenshot area.
|
||||
|
||||
Do not ship the diagnostic switches. Strip them after the source is known.
|
||||
|
||||
## Launch/Test Notes
|
||||
|
||||
Last clean visual launch:
|
||||
|
||||
```powershell
|
||||
$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_A8_INDOOR_BRANCH = "1"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug
|
||||
```
|
||||
|
||||
The log files from the last launch were empty because this was a clean no-probe run:
|
||||
|
||||
- `a8-visible-cell-portalmask-cellar-flap-20260528-161702.out.log`
|
||||
- `a8-visible-cell-portalmask-cellar-flap-20260528-161702.err.log`
|
||||
|
||||
## Stop Rule
|
||||
|
||||
This artifact has now consumed three visual failures in this micro-loop. Next session should not make another fix until it has hard evidence identifying the pass/polygon/source texture responsible for the green pixels.
|
||||
176
docs/research/2026-05-28-a8-cellar-flap-option2-handoff.md
Normal file
176
docs/research/2026-05-28-a8-cellar-flap-option2-handoff.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# A8 cellar-flap — option-2 handoff + brainstorming kickoff (2026-05-28 PM)
|
||||
|
||||
## Purpose
|
||||
|
||||
The cellar flap is the **last** A8 indoor-rendering defect. Its root cause is
|
||||
fully understood (offline-confirmed). The targeted fix (option 1) was tried,
|
||||
**failed**, and the failure revealed a deeper architectural coupling. The
|
||||
decision is to fix it the **retail-faithful way (option 2: WB-style recursive
|
||||
portal visibility)** via a fresh `superpowers:brainstorming` session. This doc
|
||||
is the single pickup point for that session.
|
||||
|
||||
## Current tree state (do NOT reset)
|
||||
|
||||
- Worktree: `.claude/worktrees/strange-albattani-3fc83c/`, branch
|
||||
`claude/strange-albattani-3fc83c`, tip `e415bb3` — **all A8 work is
|
||||
uncommitted** in a dirty tree.
|
||||
- Build green. App tests pass (90 baseline; the 3 option-1 tests were removed).
|
||||
- The option-1 code (`PortalMeshBuilder.CollectSameLevelPortalCells` +
|
||||
`IsVerticalPortal` + 3 tests + the GameWindow call-site change) has been
|
||||
**reverted/removed** — tree is back to the working-with-cellar-flap baseline.
|
||||
- `tools/A8CellAudit` gained a `portals` mode this session (offline cell/portal
|
||||
dumper) — **kept**, it's the investigation workhorse.
|
||||
|
||||
### What WORKS in the dirty tree (the valuable A8 batch — keep)
|
||||
- EnvCellRenderer SSBO **stride fix** (mat4 upload, not 80-byte InstanceData).
|
||||
- WB-style global `FrontFace(CW)` + per-batch `CullMode` through MDI.
|
||||
- `EntitySet` partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
|
||||
`BuildingShellAnchorCellId` scoping.
|
||||
- `RenderOutsideInAcdream` (look into buildings from outside).
|
||||
- `CollectVisiblePortalBuildings` frustum cull of portal bounds.
|
||||
- Streaming near/far priority queues + `PromoteToNear` + the
|
||||
`LandblockEntriesWithoutAnimatedIndex` hot-path fix (fixed bridge/wall/collision
|
||||
regressions after travel).
|
||||
- Temporary `ACDREAM_A8_DIAG_*` flags (strip before any commit).
|
||||
|
||||
### What DOESN'T work
|
||||
- **Cellar flap** + the broader inside-out fragility (see the coupling below).
|
||||
|
||||
> **Decision point for the human:** the working A8 batch is large and
|
||||
> uncommitted. Consider committing it (after stripping the `ACDREAM_A8_DIAG_*`
|
||||
> flags) so the option-2 work starts from a clean baseline. Deferred per
|
||||
> "don't commit yet," but flagged.
|
||||
|
||||
## The cellar flap — root cause (confirmed)
|
||||
|
||||
Full evidence: [`docs/research/2026-05-28-a8-cellar-flap-root-cause.md`](2026-05-28-a8-cellar-flap-root-cause.md).
|
||||
|
||||
Short version: the inside-out stencil mask flat-marks the exit portals
|
||||
(windows/doors) of **every** visibility-BFS-reached cell. From the cellar
|
||||
(`0xA9B40171`, **zero** exit portals), the BFS reaches the ground-floor cells
|
||||
(`0x16F`, `0x170`) up the stairwell and marks **their** windows. Step 4 then
|
||||
paints the whole outdoor world through those silhouettes wherever the cellar's
|
||||
stairwell hole leaves them un-occluded. There is no constraint tying a deeper
|
||||
cell's exit portal to the portal chain (the narrow stairwell) it was reached
|
||||
through.
|
||||
|
||||
## ⭐ THE KEY FINDING — `didInsideStencil` double-duty coupling
|
||||
|
||||
This is the expensive lesson; do not re-pay it.
|
||||
|
||||
`RenderInsideOutAcdream` Step 4 (GameWindow.cs ~11167) wraps **both** the
|
||||
terrain draw **and** the entire `OutdoorScenery` dispatcher draw (which includes
|
||||
neighbor **building shells**, scenery, and the depth-repair pass) in:
|
||||
|
||||
```csharp
|
||||
if (didInsideStencil) { ... terrain + OutdoorScenery ... }
|
||||
```
|
||||
|
||||
where `didInsideStencil == (camera-side-filtered exit-portal mask is non-empty)`.
|
||||
|
||||
So the portal mask is doing **two jobs at once**:
|
||||
- **Job A (intended):** gate "paint terrain/sky *through* the portal openings."
|
||||
- **Job B (accidental):** decide "draw exterior geometry (shells/scenery/depth-repair) **at all**."
|
||||
|
||||
**Why option 1 failed:** option 1 correctly shrank the mask (same-level cells
|
||||
only) so the cellar's mask went empty → `didInsideStencil=false` → **Step 4
|
||||
skipped entirely** → exterior shells + terrain vanished → "walls transparent,
|
||||
sky behind, terrain gone." The old flat mask (all visN cells) *papered over*
|
||||
this by almost always keeping the mask non-empty.
|
||||
|
||||
**Consequence for ANY fix:** correctly scoping/clipping the portal mask is not
|
||||
enough on its own — it will empty the mask in legitimate cases (looking at an
|
||||
interior wall, sealed cellar) and kill exterior rendering. **Job A and Job B
|
||||
must be decoupled** so exterior geometry draws regardless of whether any portal
|
||||
is currently visible. This is true for option 2 as much as option 1.
|
||||
|
||||
## Decision: option 2 (WB-faithful recursive portal visibility)
|
||||
|
||||
Chosen over option 1 (decouple-only) because:
|
||||
- The project mandate is faithful WB/retail porting; option 1 is a structural
|
||||
deviation from WB's RenderInsideOut, and prior "cleaner redesign" deviations
|
||||
were reverted.
|
||||
- Option 2 handles every case (cellar, stacked floors, deep dungeons) without
|
||||
per-case special-casing.
|
||||
- It is large enough to deserve design-first (brainstorm), not a mid-session patch.
|
||||
|
||||
Note: option 2 still has to solve the Job-A/Job-B decoupling above — it's not
|
||||
optional.
|
||||
|
||||
## Open design questions for the brainstorm (resolve BEFORE coding)
|
||||
|
||||
1. **Does WB even render a sealed sub-cell (cellar) via inside-out?** Check how
|
||||
WB derives `_buildingsWithCurrentCell` (VisibilityManager.PrepareVisibility +
|
||||
PortalRenderManager.GetBuildingPortalsByCellId). If WB *excludes* a cell with
|
||||
no exit portals from the inside-out path, the "fix" may be a classification
|
||||
change, not recursion. **Verify against WB source — don't assume recursion exists.**
|
||||
2. **How does WB ACTUALLY constrain per-portal visibility?** Re-read
|
||||
`VisibilityManager.cs` (RenderInsideOut/RenderOutsideIn) + `PortalRenderManager.cs`
|
||||
end-to-end. Is the clipping (a) recursive portal traversal, (b) the 3-bit
|
||||
stencil Step-5 pipeline, (c) pure Step-3 depth occlusion, or (d) the BSP
|
||||
portal-graph in PrepareVisibility? Our port copied the *flat* Steps 1-4; the
|
||||
constraint mechanism may live in code we didn't port.
|
||||
3. **Job-A/Job-B decoupling.** Design how exterior geometry (shells + scenery +
|
||||
depth-repair) draws independent of the portal mask, while terrain-through-portal
|
||||
stays stencil-gated. This must land regardless of the recursion design.
|
||||
4. **Stencil-bit budget + occlusion-query lifecycle** if the full WB Step-5
|
||||
cross-building path is adopted (currently gated off via `ACDREAM_A8_STEP5`).
|
||||
|
||||
## Key source references for the brainstorm
|
||||
|
||||
WB (the algorithm being ported):
|
||||
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs`
|
||||
— `PrepareVisibility` (47-71), `RenderInsideOut` (73-239), `RenderOutsideIn` (241+).
|
||||
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs`
|
||||
— `RenderBuildingStencilMask`, `GetVisibleBuildingPortals`, `GetBuildingPortalsByCellId`.
|
||||
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs`.
|
||||
|
||||
acdream (the current port):
|
||||
- `src/AcDream.App/Rendering/GameWindow.cs` — `RenderInsideOutAcdream`
|
||||
(~11012), `RenderOutsideInAcdream` (~196), the Step-4 `didInsideStencil` gate
|
||||
(~11167). **This is where the Job-A/Job-B coupling lives.**
|
||||
- `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` — `PortalMeshBuilder`
|
||||
(camera-side filter), `RenderBuildingStencilMask`, `DrawUploadedPortalMesh`.
|
||||
- `src/AcDream.App/Rendering/Wb/BuildingLoader.cs` — building cell-set BFS
|
||||
(mirrors WB PortalService; cellar IS expanded into building 0xA).
|
||||
|
||||
Retail oracle (if WB is ambiguous):
|
||||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — `CObjCell::find_visible_child_cell`
|
||||
(≈311397), `PView::DrawCells` (≈432709). Retail uses screen-space polygon-clip
|
||||
scissor recursion — the conceptual ancestor of "clip each portal to the chain."
|
||||
|
||||
Offline tooling:
|
||||
- `tools/A8CellAudit` — `dotnet run -- portals <cellId...>` dumps a cell's
|
||||
CellPortals (exit vs interior); `-- buildings <lb> <radius>` dumps
|
||||
building→cell grouping. Reproduces the whole investigation in seconds, no launch.
|
||||
|
||||
## Brainstorming kickoff prompt (copy-paste into a fresh session)
|
||||
|
||||
> Use the `superpowers:brainstorming` skill. We're designing the retail-faithful
|
||||
> fix for the A8 "cellar flap" — the last A8 indoor-rendering defect.
|
||||
>
|
||||
> Read first, in order:
|
||||
> 1. `docs/research/2026-05-28-a8-cellar-flap-option2-handoff.md` (this doc) —
|
||||
> current state, the confirmed root cause, the `didInsideStencil` double-duty
|
||||
> finding, the decision, and the open design questions.
|
||||
> 2. `docs/research/2026-05-28-a8-cellar-flap-root-cause.md` — the offline evidence.
|
||||
> 3. The WB + acdream source references listed in the handoff.
|
||||
>
|
||||
> The goal: design how acdream's indoor visibility should render outside-through-
|
||||
> portals **correctly clipped to the portal chain** (so a sealed cellar shows no
|
||||
> terrain, a windowed room shows its own windows, deep rooms show only the sliver
|
||||
> visible through the doorway chain) **AND** decouple "draw exterior geometry at
|
||||
> all" from "is the portal mask non-empty" (the coupling that made the targeted
|
||||
> fix regress).
|
||||
>
|
||||
> Brainstorm MUST resolve the 4 open design questions in the handoff before any
|
||||
> code — especially Q1 (does WB even render a sealed cellar inside-out?) and Q2
|
||||
> (what is WB's ACTUAL per-portal clipping mechanism — verify against source,
|
||||
> don't assume recursion). Output a written design/plan; do not start coding
|
||||
> until the design is agreed.
|
||||
>
|
||||
> Process rules still in force: no workarounds/band-aids; faithful WB/retail
|
||||
> port; one visual gate only when a complete fix is ready; the broken indoor
|
||||
> branch is behind `ACDREAM_A8_INDOOR_BRANCH=1` (default off = pre-A8 visual).
|
||||
> The dirty tree has valuable uncommitted A8 work — decide whether to commit it
|
||||
> (strip `ACDREAM_A8_DIAG_*` first) before starting.
|
||||
120
docs/research/2026-05-28-a8-cellar-flap-root-cause.md
Normal file
120
docs/research/2026-05-28-a8-cellar-flap-root-cause.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# A8 cellar-flap — structured debugging root cause (2026-05-28 PM)
|
||||
|
||||
## Method
|
||||
|
||||
Systematic-debugging Phases 1-3, all evidence gathered **offline** via the
|
||||
`tools/A8CellAudit` tool (extended with a `portals` mode) — no live launches
|
||||
needed. Deterministic, instant, reproducible.
|
||||
|
||||
## Phase 1 — evidence
|
||||
|
||||
Scenario (from `launch-a8-probe-normal-20260528-194536.out.log`):
|
||||
- Camera in cell `0xA9B40171`, `inside=True really=True`.
|
||||
- `camBldgs=[0xA]`, `visN=7 [0x16F,0x170,0x171,0x172,0x173,0x174,0x175]`.
|
||||
- Portal stencil mask = 12 verts (not the old over-punch case).
|
||||
- Bisection (prior session): writer is **Step 4 content**; disabling Step-2
|
||||
punch does **not** fix it.
|
||||
|
||||
Offline audit findings:
|
||||
|
||||
**Building grouping** (`A8CellAudit buildings 0xA9B40000`):
|
||||
```
|
||||
buildingOrdinal=10 registryId=0xA model=0x01002232 portalCells=[0xA9B4016F,0xA9B40170]
|
||||
```
|
||||
Building `0xA`'s LandBlockInfo seed = `{0x16F, 0x170}`. `BuildingLoader` then
|
||||
BFS-expands through interior portals → all 7 cells (incl. the cellar). The
|
||||
BFS matches WB's `PortalService` (same algorithm), so the grouping is not the
|
||||
divergence.
|
||||
|
||||
**Exit-portal ownership** (`A8CellAudit portals ...`):
|
||||
|
||||
| Cell | exit portals (0xFFFF) | interior | role |
|
||||
|------|----|----|------|
|
||||
| `0x16F` | **1** | 1 | ground floor (window/door) |
|
||||
| `0x170` | **1** | 1 | ground floor (window/door) |
|
||||
| `0x171` (camera) | **0** | 3 | cellar |
|
||||
| `0x172`–`0x175` | **0** | 1–2 | cellar rooms |
|
||||
|
||||
So the 12-vert mask = `0x16F` exit (6v) + `0x170` exit (6v). **The cellar
|
||||
camera (zero exit portals) is marking the two ground-floor windows.**
|
||||
|
||||
**Topology**:
|
||||
```
|
||||
0x171.portal[0] -> 0x170 (stairwell/hatch, polyId 54)
|
||||
0x170.portal[1] -> 0x171 (polyId 5)
|
||||
0x170.portal[0] -> EXIT (window/door to outside, polyId 4)
|
||||
```
|
||||
Cellar connects directly up to ground floor `0x170`; `0x16F` is one further hop.
|
||||
|
||||
**Occluder geometry** (`A8CellAudit 0xA9B40170` / `0xA9B40171`):
|
||||
- `0x170` floor poly `0x0002` (n.Z=+1) **emits** — the cellar's ceiling/occluder exists.
|
||||
- `0x171` has a ceiling `0x0003` (n.Z=-1, emits) AND three `NoPos` polys
|
||||
`0x0036/0x0037/0x0038` (surface `0x080000DF`) that do **not** emit —
|
||||
`0x0038` is a ceiling-plane poly = the **stairwell hole** up to the ground floor.
|
||||
|
||||
## Phase 2 — pattern vs WB
|
||||
|
||||
WB `RenderInsideOut` marks the building's exit portals (flat — same as us) and
|
||||
relies on **Step-3 cell depth** to occlude them: terrain only survives where the
|
||||
punched/cleared far-depth isn't overwritten by rendered cell geometry.
|
||||
|
||||
Our code matches that structure. The difference that produces the visible flap:
|
||||
WB's outside view through a portal is the world geometrically behind that
|
||||
portal; from a cellar, the only un-occluded opening is the **stairwell hole**
|
||||
(`0x0038`, not rendered). Through that hole, stencil=1 (ground-floor window
|
||||
marked) and depth=far → **Step 4 draws the entire outdoor world (terrain +
|
||||
buildings) through the hole**, not a window-sized sliver. The two ground-floor
|
||||
windows are 1–2 BFS hops above the camera and should contribute essentially
|
||||
nothing from the cellar, but their full silhouettes are marked.
|
||||
|
||||
"Disable Step-2 punch doesn't fix it" is explained: the leak pixels are the
|
||||
stairwell hole, which has **cleared (far) depth** regardless of the punch
|
||||
because no cell geometry covers it — terrain passes `DepthFunc.Less` either way.
|
||||
|
||||
## Phase 3 — single hypothesis (root cause)
|
||||
|
||||
**The inside-out exit-portal stencil mask is built by flat-marking the exit
|
||||
portals of every visibility-BFS-reached cell. From the cellar, the BFS reaches
|
||||
the ground-floor cells, whose windows get full-silhouette-marked. Where the
|
||||
cellar's stairwell hole leaves those silhouettes un-occluded, Step 4 paints the
|
||||
whole outdoor world through them. There is no constraint tying a deeper cell's
|
||||
exit portal to the portal chain (here: the narrow stairwell) through which its
|
||||
cell became visible.**
|
||||
|
||||
This is a flat-vs-constrained masking gap. Not a depth bug (occluders emit and
|
||||
render), not the Step-2 punch, not the camera-side filter (the cellar camera is
|
||||
geometrically on the interior side of a ground-floor window's plane, so the
|
||||
per-portal filter passes it).
|
||||
|
||||
## Phase 4 — fix options
|
||||
|
||||
1. **Camera-cell-scoped mask (minimal, conservative).** Mark only the camera
|
||||
cell's own exit portals. Cellar (0 exit portals) → empty mask → no leak;
|
||||
windowed room → marks its own windows. **Risk:** loses daylight through an
|
||||
*adjacent* cell's window seen across a doorway in multi-cell ground-floor
|
||||
rooms (e.g. the inn) — a visible-but-minor regression, and the flat approach
|
||||
was wrong there anyway.
|
||||
|
||||
2. **Vertical-portal-aware scoping (targeted).** Don't propagate exit-portal
|
||||
marking across a floor/ceiling (vertical-normal) portal. The cellar→ground
|
||||
stairwell is a horizontal-plane portal; suppressing inheritance across it
|
||||
stops the cellar from marking ground-floor windows while preserving
|
||||
same-level multi-cell rooms. Needs per-portal polygon-normal classification.
|
||||
|
||||
3. **WB recursive/constrained portal masking (faithful, largest).** Constrain
|
||||
each deeper portal's stencil to the screen region of the portal chain leading
|
||||
to it. Correct for all cases (cellar + multi-cell rooms) but a substantial
|
||||
port of WB's recursive RenderInsideOut.
|
||||
|
||||
**Recommendation:** option 2 is the best correctness/effort trade — it fixes the
|
||||
cellar without the inn regression risk of option 1, and is a principled scoping
|
||||
rule (don't inherit a different vertical level's exterior openings) rather than a
|
||||
band-aid. Option 3 remains the eventual faithful target if cross-level portal
|
||||
visibility ever needs to be exact.
|
||||
|
||||
## Reproduction / verification assets
|
||||
|
||||
- `tools/A8CellAudit` `portals` mode (added this session) dumps any cell's
|
||||
`CellPortals` offline. `A8CellAudit buildings <lb> <radius>` dumps
|
||||
building→cell grouping. These make the whole investigation re-runnable in
|
||||
seconds with zero launches.
|
||||
1081
docs/superpowers/plans/2026-05-26-phase-a8-replan.md
Normal file
1081
docs/superpowers/plans/2026-05-26-phase-a8-replan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -225,6 +225,12 @@ public sealed class CellVisibility
|
|||
: System.Array.Empty<LoadedCell>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a currently loaded cell by full 32-bit cell id.
|
||||
/// </summary>
|
||||
public bool TryGetCell(uint cellId, out LoadedCell? cell)
|
||||
=> _cellLookup.TryGetValue(cellId, out cell);
|
||||
|
||||
/// <summary>
|
||||
/// Removes all cells belonging to <paramref name="lbId"/> (upper 16 bits of
|
||||
/// the landblock key, e.g. 0xA9B4 for landblock 0xA9B40000). Called when a
|
||||
|
|
|
|||
|
|
@ -171,6 +171,159 @@ public sealed class GameWindow : IDisposable
|
|||
// around each RenderBuildingStencilMask call.
|
||||
private AcDream.App.Rendering.IndoorCellStencilPipeline? _indoorStencilPipeline;
|
||||
|
||||
private void CollectVisiblePortalBuildings(
|
||||
System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building> output,
|
||||
int centerLbX,
|
||||
int centerLbY,
|
||||
int radius)
|
||||
{
|
||||
output.Clear();
|
||||
|
||||
foreach (var (landblockId, reg) in _buildingRegistries)
|
||||
{
|
||||
int lbX = (int)((landblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((landblockId >> 16) & 0xFFu);
|
||||
if (System.Math.Abs(lbX - centerLbX) > radius ||
|
||||
System.Math.Abs(lbY - centerLbY) > radius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var b in reg.All())
|
||||
{
|
||||
if (!b.HasPortalBounds)
|
||||
continue;
|
||||
|
||||
// WB PortalRenderManager.GetVisibleBuildingPortals frustum-culls
|
||||
// each building's portal AABB with ignoreNearPlane=true. That
|
||||
// prevents a doorway/window clipped by the camera near plane from
|
||||
// dropping out of the portal visibility list.
|
||||
if (_envCellFrustum is not null &&
|
||||
_envCellFrustum.TestBox(b.PortalBounds, ignoreNearPlane: true)
|
||||
== AcDream.App.Rendering.Wb.FrustumTestResult.Outside)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
output.Add(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderOutsideInAcdream(
|
||||
System.Numerics.Matrix4x4 viewProj,
|
||||
AcDream.App.Rendering.ICamera camera,
|
||||
AcDream.App.Rendering.FrustumPlanes? frustum,
|
||||
uint? playerLb,
|
||||
System.Collections.Generic.HashSet<uint>? animatedIds,
|
||||
System.Collections.Generic.IReadOnlyList<AcDream.App.Rendering.Wb.Building> visibleBuildings)
|
||||
{
|
||||
if (visibleBuildings.Count == 0)
|
||||
return;
|
||||
|
||||
var gl = _gl!;
|
||||
var visibleEnvCellIds = new System.Collections.Generic.HashSet<uint>();
|
||||
foreach (var b in visibleBuildings)
|
||||
{
|
||||
foreach (var id in b.EnvCellIds)
|
||||
visibleEnvCellIds.Add(id);
|
||||
}
|
||||
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[outside-in] portals={visibleBuildings.Count} cells={visibleEnvCellIds.Count}");
|
||||
}
|
||||
|
||||
// WB VisibilityManager.RenderOutsideIn, but fed by the same
|
||||
// frustum-visible portal list prepared above instead of every loaded
|
||||
// building. Terrain/scenery are already drawn by the caller; this pass
|
||||
// opens portal silhouettes, repairs wall depth, then draws EnvCells
|
||||
// through those silhouettes. WB's outside-in EnvCell render passes a
|
||||
// null cell filter; the stencil/depth mask is the visibility gate.
|
||||
gl.Enable(EnableCap.StencilTest);
|
||||
gl.ClearStencil(0);
|
||||
gl.Clear(ClearBufferMask.StencilBufferBit);
|
||||
|
||||
// Step 1: mark visible building portals where the exterior depth test
|
||||
// says the portal surface is actually visible.
|
||||
gl.Disable(EnableCap.CullFace);
|
||||
gl.StencilFunc(StencilFunction.Always, 1, 0xFFu);
|
||||
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
|
||||
gl.StencilMask(0xFFu);
|
||||
gl.ColorMask(false, false, false, false);
|
||||
gl.DepthMask(false);
|
||||
gl.Enable(EnableCap.DepthTest);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
foreach (var b in visibleBuildings)
|
||||
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false);
|
||||
|
||||
// Step 2: punch portal depth to the far plane.
|
||||
gl.StencilFunc(StencilFunction.Equal, 1, 0xFFu);
|
||||
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
|
||||
gl.StencilMask(0x00u);
|
||||
gl.DepthMask(true);
|
||||
gl.DepthFunc(DepthFunction.Always);
|
||||
foreach (var b in visibleBuildings)
|
||||
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true);
|
||||
|
||||
// Step 3: depth-only repair for exterior walls that overlap portal
|
||||
// silhouettes, matching WB's staticObjectManager depth repair pass.
|
||||
// In acdream, the dispatcher can target just building shells here;
|
||||
// walking the full OutdoorScenery set would reprocess every tree and
|
||||
// outdoor static object only to write depth under portal masks.
|
||||
gl.Enable(EnableCap.CullFace);
|
||||
gl.FrontFace(FrontFaceDirection.CW);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
_meshShader!.Use();
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibleEnvCellIds,
|
||||
animatedEntityIds: null,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.BuildingShells);
|
||||
_a8PerfLastOutsideShellStats = _wbDrawDispatcher.LastDrawStats;
|
||||
|
||||
// Step 4/5: render EnvCells through the repaired stencil mask.
|
||||
//
|
||||
// WB EnvCellRenderManager owns BOTH cell geometry and EnvCell static
|
||||
// objects. A8 split that in acdream: EnvCellRenderer owns only the
|
||||
// CellStruct mesh, while static objects remain dispatcher WorldEntity
|
||||
// records with ParentCellId. Mirror WB's combined manager by drawing
|
||||
// the dispatcher IndoorPass through the same portal stencil and the
|
||||
// same WB-derived visible cell set used to prepare EnvCellRenderer.
|
||||
gl.StencilFunc(StencilFunction.Equal, 1, 0xFFu);
|
||||
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
|
||||
gl.StencilMask(0x00u);
|
||||
gl.ColorMask(true, true, true, false);
|
||||
gl.DepthMask(true);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
gl.Disable(EnableCap.Blend);
|
||||
_meshShader!.Use();
|
||||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, filter: null);
|
||||
|
||||
gl.Enable(EnableCap.Blend);
|
||||
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
gl.DepthMask(false);
|
||||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, filter: null);
|
||||
gl.DepthMask(true);
|
||||
gl.Disable(EnableCap.Blend);
|
||||
|
||||
_meshShader!.Use();
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibleEnvCellIds,
|
||||
animatedEntityIds: null,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass);
|
||||
_a8PerfLastOutsideIndoorStats = _wbDrawDispatcher.LastDrawStats;
|
||||
|
||||
gl.Disable(EnableCap.StencilTest);
|
||||
gl.StencilMask(0xFFu);
|
||||
gl.ColorMask(true, true, true, true);
|
||||
gl.DepthMask(true);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
gl.FrontFace(FrontFaceDirection.CW);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.4: per-entity animation playback state for entities whose
|
||||
/// MotionTable resolved to a real cycle. The render loop ticks each
|
||||
|
|
@ -2281,11 +2434,14 @@ public sealed class GameWindow : IDisposable
|
|||
// its value is the live WorldEntity we need to dispose.
|
||||
RemoveLiveEntityByServerGuid(spawn.Guid, logDelete: false);
|
||||
|
||||
// Log every spawn that arrives so we can inventory what the server
|
||||
// When requested, log every spawn that arrives so we can inventory what the server
|
||||
// sends (including the ones we can't render yet). The Name field
|
||||
// is the critical one — we can grep the log for "Nullified Statue
|
||||
// of a Drudge" or similar to find a specific weenie by its
|
||||
// in-game name.
|
||||
bool dumpLiveSpawns = _options.DumpLiveSpawns;
|
||||
if (dumpLiveSpawns)
|
||||
{
|
||||
string posStr = spawn.Position is { } sp
|
||||
? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}"
|
||||
: "no-pos";
|
||||
|
|
@ -2298,6 +2454,7 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine(
|
||||
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
|
||||
$"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
|
||||
}
|
||||
|
||||
_liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo(
|
||||
spawn.Name,
|
||||
|
|
@ -2308,7 +2465,9 @@ public sealed class GameWindow : IDisposable
|
|||
// Target the statue specifically for full diagnostic dump: Name match
|
||||
// is cheap and gives us exactly one entity's worth of log regardless
|
||||
// of arrival order.
|
||||
bool isStatue = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
|
||||
bool isStatue = dumpLiveSpawns
|
||||
&& spawn.Name is not null
|
||||
&& spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
|
||||
if (isStatue)
|
||||
{
|
||||
Console.WriteLine($"live: [STATUE] objScale={spawn.ObjScale?.ToString("F3") ?? "null"}");
|
||||
|
|
@ -2388,6 +2547,7 @@ public sealed class GameWindow : IDisposable
|
|||
if (setup is null)
|
||||
{
|
||||
_liveDropReasonSetupDatMissing++;
|
||||
if (dumpLiveSpawns)
|
||||
Console.WriteLine($"live: DROP setup dat 0x{spawn.SetupTableId.Value:X8} missing " +
|
||||
$"(guid=0x{spawn.Guid:X8})");
|
||||
return;
|
||||
|
|
@ -2627,7 +2787,9 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
// Second pass: resolve each affected part's Surface chain and
|
||||
// build the Surface-id-keyed override map the renderer consumes.
|
||||
bool isStatueDiag = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
|
||||
bool isStatueDiag = dumpLiveSpawns
|
||||
&& spawn.Name is not null
|
||||
&& spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
|
||||
resolvedOverridesByPart = new Dictionary<int, Dictionary<uint, uint>>();
|
||||
for (int pi = 0; pi < parts.Count; pi++)
|
||||
{
|
||||
|
|
@ -2720,6 +2882,7 @@ public sealed class GameWindow : IDisposable
|
|||
if (meshRefs.Count == 0)
|
||||
{
|
||||
_liveDropReasonNoMeshRefs++;
|
||||
if (dumpLiveSpawns)
|
||||
Console.WriteLine($"live: DROP no mesh refs from setup 0x{spawn.SetupTableId.Value:X8} " +
|
||||
$"(guid=0x{spawn.Guid:X8})");
|
||||
return;
|
||||
|
|
@ -3008,7 +3171,7 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
// Dump a summary periodically so we can see drop breakdowns without
|
||||
// waiting for a graceful shutdown.
|
||||
if (_liveSpawnReceived % 20 == 0)
|
||||
if (dumpLiveSpawns && _liveSpawnReceived % 20 == 0)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: animated={_animatedEntities.Count} " +
|
||||
|
|
@ -5173,6 +5336,7 @@ public sealed class GameWindow : IDisposable
|
|||
Rotation = e.Rotation,
|
||||
MeshRefs = meshRefs,
|
||||
IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag
|
||||
BuildingShellAnchorCellId = e.BuildingShellAnchorCellId,
|
||||
};
|
||||
hydrated.Add(entity);
|
||||
}
|
||||
|
|
@ -6415,7 +6579,7 @@ public sealed class GameWindow : IDisposable
|
|||
lbNoneCount++;
|
||||
}
|
||||
}
|
||||
if (scTried > 0)
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && scTried > 0)
|
||||
Console.WriteLine(
|
||||
$"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} registered={scRegistered} " +
|
||||
$"noBounds={scNoBounds} tooThin={scTooThin} (outdoorNone={lbNoneCount})");
|
||||
|
|
@ -6439,7 +6603,7 @@ public sealed class GameWindow : IDisposable
|
|||
sampleMissing.Add(entity.SourceGfxObjOrSetupId);
|
||||
}
|
||||
}
|
||||
if (sceneryNoCache > 0)
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && sceneryNoCache > 0)
|
||||
{
|
||||
string samples = string.Join(",", sampleMissing.Select(s => $"0x{s:X8}"));
|
||||
Console.WriteLine($" → {sceneryNoCache} scenery entities had no visual bounds cached. Samples: {samples}");
|
||||
|
|
@ -6965,8 +7129,56 @@ public sealed class GameWindow : IDisposable
|
|||
private double _perfAccum;
|
||||
private int _perfFrameCount;
|
||||
|
||||
private long _a8PerfLastLogTick;
|
||||
private long _a8PerfFrames;
|
||||
private long _a8PerfInsideFrames;
|
||||
private long _a8PerfOutsideInFrames;
|
||||
private long _a8PerfTickAnimTicks;
|
||||
private long _a8PerfCollectTicks;
|
||||
private long _a8PerfEnvPrepareTicks;
|
||||
private long _a8PerfTerrainTicks;
|
||||
private long _a8PerfStaticTicks;
|
||||
private long _a8PerfOutsideInTicks;
|
||||
private long _a8PerfLiveTicks;
|
||||
private long _a8PerfInsideOutTicks;
|
||||
private long _a8PerfInsideLiveTicks;
|
||||
private int _a8PerfLastPortalBuildings;
|
||||
private int _a8PerfMaxPortalBuildings;
|
||||
private int _a8PerfLastPortalCells;
|
||||
private int _a8PerfMaxPortalCells;
|
||||
private int _a8PerfLastVisibleLandblocks;
|
||||
private int _a8PerfLastTotalLandblocks;
|
||||
private readonly System.Collections.Generic.HashSet<uint> _a8PerfCellScratch = new();
|
||||
private const int A8PerfGpuRingDepth = 4;
|
||||
private const int A8PerfGpuPassCount = 6;
|
||||
private const int A8PerfGpuTerrain = 0;
|
||||
private const int A8PerfGpuStatic = 1;
|
||||
private const int A8PerfGpuOutsideIn = 2;
|
||||
private const int A8PerfGpuLive = 3;
|
||||
private const int A8PerfGpuInsideOut = 4;
|
||||
private const int A8PerfGpuInsideLive = 5;
|
||||
private readonly uint[] _a8PerfGpuQueries = new uint[A8PerfGpuRingDepth * A8PerfGpuPassCount];
|
||||
private bool _a8PerfGpuQueriesInitialized;
|
||||
private int _a8PerfGpuFrameIndex;
|
||||
private readonly bool[] _a8PerfGpuIssued = new bool[A8PerfGpuRingDepth * A8PerfGpuPassCount];
|
||||
private long _a8PerfTerrainGpuNs;
|
||||
private long _a8PerfStaticGpuNs;
|
||||
private long _a8PerfOutsideInGpuNs;
|
||||
private long _a8PerfLiveGpuNs;
|
||||
private long _a8PerfInsideOutGpuNs;
|
||||
private long _a8PerfInsideLiveGpuNs;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastStaticStats;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastLiveStats;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastOutsideShellStats;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastOutsideIndoorStats;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastInsideStats;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastInsideLiveStats;
|
||||
|
||||
private void OnRender(double deltaSeconds)
|
||||
{
|
||||
bool a8Perf = A8PerfEnabled();
|
||||
int a8GpuSlot = A8PerfBeginGpuFrame(a8Perf);
|
||||
|
||||
// Phase G.1: set the clear color from the current sky's fog
|
||||
// tint so the horizon band continues naturally past the
|
||||
// rendered geometry. Fog blends to this color at max distance
|
||||
|
|
@ -6990,6 +7202,13 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit);
|
||||
|
||||
// WB GameScene.cs:830-843 establishes CW as the frame-global
|
||||
// front-face convention; per-batch CullMode changes only the culled
|
||||
// side. A8 indoor/env-cell geometry and setup meshes share that
|
||||
// convention, so keep the GL state aligned before any scene pass.
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.CW);
|
||||
|
||||
// Phase N.6 slice 1: one-shot surface-format histogram dump under
|
||||
// ACDREAM_DUMP_SURFACES=1. Zero cost when off.
|
||||
_textureCache?.TickSurfaceHistogramDumpIfEnabled();
|
||||
|
|
@ -7008,8 +7227,10 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
// Phase 6.4: advance per-entity animation playback before drawing
|
||||
// so the renderer always sees the up-to-date per-part transforms.
|
||||
long a8PerfStart = A8PerfStart(a8Perf);
|
||||
if (_animatedEntities.Count > 0)
|
||||
TickAnimations((float)deltaSeconds);
|
||||
A8PerfStop(a8Perf, ref _a8PerfTickAnimTicks, a8PerfStart);
|
||||
|
||||
// Phase G.1: weather state machine — deterministic per-day roll
|
||||
// + transitions + lightning flash.
|
||||
|
|
@ -7126,6 +7347,9 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
var camBuildings = new System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building>();
|
||||
var otherBuildings = new System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building>();
|
||||
var visiblePortalBuildings = new System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building>();
|
||||
System.Collections.Generic.HashSet<uint>? envCellPrepareFilter = null;
|
||||
int visiblePortalCellCount = 0;
|
||||
|
||||
if (cameraInsideBuilding)
|
||||
{
|
||||
|
|
@ -7135,12 +7359,6 @@ public sealed class GameWindow : IDisposable
|
|||
foreach (var b in reg.GetBuildingsContainingCell(visibility.CameraCell.CellId))
|
||||
camBuildings.Add(b);
|
||||
}
|
||||
|
||||
var camCellId = visibility!.CameraCell!.CellId;
|
||||
foreach (var rr in _buildingRegistries.Values)
|
||||
foreach (var b in rr.All())
|
||||
if (!b.EnvCellIds.Contains(camCellId))
|
||||
otherBuildings.Add(b);
|
||||
}
|
||||
|
||||
// SPIKE 2026-05-26: A8 transition investigation. Lights up the
|
||||
|
|
@ -7249,11 +7467,67 @@ public sealed class GameWindow : IDisposable
|
|||
playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
|
||||
}
|
||||
|
||||
int renderCenterLbX = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f);
|
||||
int renderCenterLbY = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
|
||||
|
||||
// Phase A8: prepare EnvCellRenderer's per-frame visibility snapshot.
|
||||
// Always called — cheap when no cells loaded, cheap when frustum culls all.
|
||||
var envCellViewProj = camera.View * camera.Projection;
|
||||
_envCellFrustum?.Update(envCellViewProj);
|
||||
_envCellRenderer?.PrepareRenderBatches(envCellViewProj, camPos);
|
||||
if (a8IndoorBranchEnabled)
|
||||
{
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
CollectVisiblePortalBuildings(
|
||||
visiblePortalBuildings,
|
||||
renderCenterLbX,
|
||||
renderCenterLbY,
|
||||
_nearRadius);
|
||||
|
||||
// WB VisibilityManager.PrepareVisibility builds the EnvCell
|
||||
// set before EnvCellRenderManager.PrepareRenderBatches, then
|
||||
// RenderOutsideIn calls Render(..., null) against that already-
|
||||
// narrowed snapshot. Keep that two-stage shape: the stencil is
|
||||
// the render gate, but the prepared workload remains limited
|
||||
// to camera-building cells, runtime portal-visible cells, plus
|
||||
// frustum-visible portal cells.
|
||||
envCellPrepareFilter = new System.Collections.Generic.HashSet<uint>();
|
||||
foreach (var b in camBuildings)
|
||||
foreach (var id in b.EnvCellIds)
|
||||
envCellPrepareFilter.Add(id);
|
||||
if (visibility?.VisibleCellIds is not null)
|
||||
foreach (var id in visibility.VisibleCellIds)
|
||||
envCellPrepareFilter.Add(id);
|
||||
foreach (var b in visiblePortalBuildings)
|
||||
foreach (var id in b.EnvCellIds)
|
||||
envCellPrepareFilter.Add(id);
|
||||
visiblePortalCellCount = envCellPrepareFilter.Count;
|
||||
|
||||
if (a8Perf)
|
||||
{
|
||||
_a8PerfCellScratch.Clear();
|
||||
foreach (var id in envCellPrepareFilter)
|
||||
_a8PerfCellScratch.Add(id);
|
||||
}
|
||||
A8PerfStop(a8Perf, ref _a8PerfCollectTicks, a8PerfStart);
|
||||
if (cameraInsideBuilding && visibility?.CameraCell is not null)
|
||||
{
|
||||
var camCellId = visibility.CameraCell.CellId;
|
||||
foreach (var b in visiblePortalBuildings)
|
||||
{
|
||||
if (!b.EnvCellIds.Contains(camCellId))
|
||||
otherBuildings.Add(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
_envCellRenderer?.PrepareRenderBatches(
|
||||
envCellViewProj,
|
||||
camPos,
|
||||
envCellPrepareFilter,
|
||||
centerLbX: renderCenterLbX,
|
||||
centerLbY: renderCenterLbY,
|
||||
renderRadius: _nearRadius);
|
||||
A8PerfStop(a8Perf, ref _a8PerfEnvPrepareTicks, a8PerfStart);
|
||||
|
||||
// Phase G.1: sky renderer — draws the far-plane-infinity
|
||||
// celestial meshes FIRST so the rest of the scene z-tests
|
||||
|
|
@ -7315,8 +7589,12 @@ public sealed class GameWindow : IDisposable
|
|||
// The proper fix is to NOT draw terrain here when indoor; Step 4
|
||||
// is the single, stencil-gated terrain pass.
|
||||
_terrainCpuStopwatch.Restart();
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuTerrain);
|
||||
if (!cameraInsideBuilding)
|
||||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfTerrainTicks, a8PerfStart);
|
||||
_terrainCpuStopwatch.Stop();
|
||||
// Multiply by 100 then divide by 100 in the diag print to keep
|
||||
// 0.01 µs precision in the long-typed sample buffer. Terrain Draw
|
||||
|
|
@ -7357,10 +7635,14 @@ public sealed class GameWindow : IDisposable
|
|||
// but aren't stencil-clipped.
|
||||
if (cameraInsideBuilding)
|
||||
{
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuInsideOut);
|
||||
RenderInsideOutAcdream(envCellViewProj, camPos, visibility!.CameraCell!,
|
||||
camBuildings, otherBuildings,
|
||||
camera, frustum, playerLb, animatedIds,
|
||||
visibility?.VisibleCellIds);
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfInsideOutTicks, a8PerfStart);
|
||||
|
||||
// Phase A8 fix (2026-05-28 visual-gate-#2 follow-up): LiveDynamic
|
||||
// entities (player char, NPCs, dropped items, doors) were missing
|
||||
|
|
@ -7372,19 +7654,63 @@ public sealed class GameWindow : IDisposable
|
|||
// stencil + state restored to defaults at its cleanup block. Same
|
||||
// shape as the outdoor branch's Draw(All) for the LiveDynamic
|
||||
// subset only.
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuInsideLive);
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.LiveDynamic);
|
||||
_a8PerfLastInsideLiveStats = _wbDrawDispatcher.LastDrawStats;
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfInsideLiveTicks, a8PerfStart);
|
||||
}
|
||||
else
|
||||
{
|
||||
// N.5: WbDrawDispatcher is always non-null (modern path mandatory).
|
||||
if (a8IndoorBranchEnabled)
|
||||
{
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuStatic);
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: null,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
|
||||
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfStaticTicks, a8PerfStart);
|
||||
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuOutsideIn);
|
||||
RenderOutsideInAcdream(envCellViewProj, camera, frustum, playerLb,
|
||||
animatedIds, visiblePortalBuildings);
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfOutsideInTicks, a8PerfStart);
|
||||
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuLive);
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.LiveDynamic);
|
||||
_a8PerfLastLiveStats = _wbDrawDispatcher.LastDrawStats;
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfLiveTicks, a8PerfStart);
|
||||
}
|
||||
else
|
||||
{
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuStatic);
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds);
|
||||
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfStaticTicks, a8PerfStart);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase G.1 / E.3: draw all live particles after opaque
|
||||
|
|
@ -7488,12 +7814,19 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
// Count visible vs total for the perf overlay.
|
||||
foreach (var entry in _worldState.LandblockEntries)
|
||||
foreach (var entry in _worldState.LandblockBounds)
|
||||
{
|
||||
totalLandblocks++;
|
||||
if (AcDream.App.Rendering.FrustumCuller.IsAabbVisible(frustum, entry.AabbMin, entry.AabbMax))
|
||||
visibleLandblocks++;
|
||||
}
|
||||
MaybeFlushA8Perf(
|
||||
a8Perf,
|
||||
cameraInsideBuilding,
|
||||
visiblePortalBuildings.Count,
|
||||
visiblePortalCellCount,
|
||||
visibleLandblocks,
|
||||
totalLandblocks);
|
||||
|
||||
// Phase I.2: refresh per-frame fields that DebugVM closures
|
||||
// can't compute lazily (frustum-derived counters + nearest-
|
||||
|
|
@ -7547,6 +7880,8 @@ public sealed class GameWindow : IDisposable
|
|||
// GL state it touches (blend, scissor, VAO, shader, texture); any
|
||||
// state not in its save-list (e.g. GL_FRAMEBUFFER_SRGB, unused
|
||||
// today) would need manual protection.
|
||||
A8PerfEndGpuFrame(a8Perf);
|
||||
|
||||
if (DevToolsEnabled && _imguiBootstrap is not null && _panelHost is not null)
|
||||
{
|
||||
// Phase I.3 — prefer the live command bus when a live session
|
||||
|
|
@ -10691,8 +11026,32 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
EmitBuildingsProbe(visibilityCellId: cameraCell.CellId, camBuildings, otherBuildings);
|
||||
|
||||
// Steps 1+2: stencil bit 1 + far-depth punch at camera-buildings' portals.
|
||||
if (camBuildings.Count > 0)
|
||||
bool diagDisableStep4Terrain = _options.A8DiagDisableInsideStep4Terrain;
|
||||
bool diagDisableStep4Outdoor = _options.A8DiagDisableInsideStep4Outdoor;
|
||||
bool diagDisableStep3EnvCellOpaque = _options.A8DiagDisableInsideStep3EnvCellOpaque;
|
||||
bool diagDisableStep3IndoorPass = _options.A8DiagDisableInsideStep3IndoorPass;
|
||||
bool diagDisableStep2Punch = _options.A8DiagDisableInsideStep2Punch;
|
||||
bool diagDisablePortalDepthClamp = _options.A8DiagDisablePortalDepthClamp;
|
||||
|
||||
var visiblePortalCells = new System.Collections.Generic.List<AcDream.App.Rendering.LoadedCell>();
|
||||
if (visibleCellIds is not null)
|
||||
{
|
||||
foreach (uint cellId in visibleCellIds)
|
||||
{
|
||||
if (_cellVisibility.TryGetCell(cellId, out var cell) && cell is not null)
|
||||
visiblePortalCells.Add(cell);
|
||||
}
|
||||
}
|
||||
|
||||
int insidePortalVertexCount = visiblePortalCells.Count > 0
|
||||
? _indoorStencilPipeline!.UploadPortalMesh(visiblePortalCells, camPos)
|
||||
: 0;
|
||||
|
||||
// Steps 1+2: stencil bit 1 + far-depth punch at portal-visible exits only.
|
||||
// WB builds its outside view from the current portal traversal; using every
|
||||
// exit on the camera building over-punches terrain through indoor openings
|
||||
// when an unrelated window/door portal overlaps them in screen space.
|
||||
if (insidePortalVertexCount > 0)
|
||||
{
|
||||
didInsideStencil = true;
|
||||
gl.Enable(EnableCap.StencilTest);
|
||||
|
|
@ -10711,26 +11070,30 @@ public sealed class GameWindow : IDisposable
|
|||
gl.DepthFunc(DepthFunction.Always);
|
||||
|
||||
EmitDrawOrderProbe(step: 1, sub: ' ');
|
||||
foreach (var b in camBuildings)
|
||||
{
|
||||
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false);
|
||||
EmitStencilProbe(op: "mark");
|
||||
}
|
||||
_indoorStencilPipeline!.DrawUploadedPortalMesh(
|
||||
viewProj,
|
||||
writeFarDepth: false,
|
||||
enableDepthClamp: !diagDisablePortalDepthClamp);
|
||||
EmitStencilProbe(op: "mark-visible");
|
||||
|
||||
if (!diagDisableStep2Punch)
|
||||
{
|
||||
// Step 2: punch depth at portals.
|
||||
// WB VisibilityManager.cs:99-104
|
||||
gl.DepthMask(true);
|
||||
gl.DepthFunc(DepthFunction.Always);
|
||||
|
||||
EmitDrawOrderProbe(step: 2, sub: ' ');
|
||||
foreach (var b in camBuildings)
|
||||
{
|
||||
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true);
|
||||
EmitStencilProbe(op: "punch");
|
||||
_indoorStencilPipeline!.DrawUploadedPortalMesh(
|
||||
viewProj,
|
||||
writeFarDepth: true,
|
||||
enableDepthClamp: !diagDisablePortalDepthClamp);
|
||||
EmitStencilProbe(op: "punch-visible");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: render camera-buildings' cells (stencil off, DepthFunc.Less).
|
||||
// Step 3: render the indoor cells visible from the camera cell
|
||||
// (stencil off, DepthFunc.Less).
|
||||
// WB VisibilityManager.cs:107-127
|
||||
gl.ColorMask(true, true, true, false);
|
||||
gl.DepthMask(true);
|
||||
|
|
@ -10744,12 +11107,27 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
foreach (var b in camBuildings)
|
||||
foreach (var id in b.EnvCellIds) currentEnvCellIds.Add(id);
|
||||
// A8 cellar-flap provenance (2026-05-28): disabling Step 4
|
||||
// terrain removes the green flap, proving terrain is the writer.
|
||||
// The leak happens because indoor-to-indoor portal cells reached
|
||||
// by the runtime visibility BFS can be outside the static
|
||||
// BuildingInfo cell set. Render them in Step 3 so their depth
|
||||
// blocks Step 4 terrain, while real exterior openings still show
|
||||
// terrain through the portal mask.
|
||||
if (visibleCellIds is not null)
|
||||
foreach (var id in visibleCellIds)
|
||||
currentEnvCellIds.Add(id);
|
||||
gl.Disable(EnableCap.Blend);
|
||||
if (!diagDisableStep3EnvCellOpaque)
|
||||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, currentEnvCellIds);
|
||||
|
||||
// Transparency pass.
|
||||
gl.Enable(EnableCap.Blend);
|
||||
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
gl.DepthMask(false);
|
||||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, currentEnvCellIds);
|
||||
gl.DepthMask(true);
|
||||
gl.Disable(EnableCap.Blend);
|
||||
}
|
||||
|
||||
// FIX 2026-05-28 (post-third-visual-gate): render IndoorPass entities.
|
||||
|
|
@ -10764,20 +11142,22 @@ public sealed class GameWindow : IDisposable
|
|||
// Result with the missing call: user reports "house missing lots of
|
||||
// walls" — the cottage's exterior wall slabs aren't drawn.
|
||||
//
|
||||
// Render IndoorPass between Step 3 and Step 4, with the
|
||||
// currentEnvCellIds filter narrowing cell stabs but NOT the building
|
||||
// shells (they have no ParentCellId and pass through). Depth-test
|
||||
// with DepthFunc.Less so cottage-A's near walls occlude cottage-B's
|
||||
// far walls. NO stencil — we want them rendered unconditionally
|
||||
// inside the camera-building.
|
||||
if (camBuildings.Count > 0)
|
||||
// Render IndoorPass between Step 3 and Step 4. The currentEnvCellIds
|
||||
// filter now narrows both cell stabs and building shells: shells have
|
||||
// no ParentCellId, but carry BuildingShellAnchorCellId from
|
||||
// LandBlockInfo.Buildings[]. Depth-test with DepthFunc.Less so the
|
||||
// current cottage shell occludes any farther geometry. NO stencil: we
|
||||
// want the active building shell rendered unconditionally inside the
|
||||
// camera-building.
|
||||
if (camBuildings.Count > 0 && !diagDisableStep3IndoorPass)
|
||||
{
|
||||
_meshShader!.Use();
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: currentEnvCellIds,
|
||||
animatedEntityIds: animatedIds,
|
||||
animatedEntityIds: null,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass);
|
||||
_a8PerfLastInsideStats = _wbDrawDispatcher.LastDrawStats;
|
||||
}
|
||||
|
||||
EmitEnvCellProbe(camBuildings.Count, otherBuildings.Count, currentEnvCellIds.Count);
|
||||
|
|
@ -10794,31 +11174,52 @@ public sealed class GameWindow : IDisposable
|
|||
gl.DepthMask(true);
|
||||
gl.Enable(EnableCap.CullFace);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
}
|
||||
|
||||
EmitDrawOrderProbe(step: 4, sub: ' ');
|
||||
// Terrain (WB line 143).
|
||||
// acdream's retail/ACME terrain mesh is CCW from the visible top side
|
||||
// (see terrain_modern.vert's LandblockMesh order comment), while WB's
|
||||
// editor terrain uses the opposite vertex order under its global CW
|
||||
// convention. Step 4 enables culling before terrain, so temporarily
|
||||
// use terrain's own front-face convention or ground disappears through
|
||||
// indoor portal silhouettes.
|
||||
if (!diagDisableStep4Terrain)
|
||||
{
|
||||
gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||||
gl.FrontFace(FrontFaceDirection.CW);
|
||||
}
|
||||
else
|
||||
{
|
||||
gl.FrontFace(FrontFaceDirection.CW);
|
||||
}
|
||||
|
||||
_meshShader!.Use();
|
||||
// Scenery + static objects via dispatcher (WB lines 148-154).
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
if (!diagDisableStep4Outdoor)
|
||||
{
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibleCellIds, // OK — outdoor cells outside the building
|
||||
animatedEntityIds: animatedIds,
|
||||
visibleCellIds: visibleCellIds, // OK - outdoor cells outside the building
|
||||
animatedEntityIds: null,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
|
||||
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: per-other-building 3-bit stencil pipeline.
|
||||
// WB VisibilityManager.cs:157-232
|
||||
//
|
||||
// FIX 2026-05-28 (post-first-visual-gate): Step 5 is GATED OFF BY DEFAULT.
|
||||
// First visual gate showed perf collapse + texture flicker indoors because
|
||||
// Step 5 iterates EVERY loaded other-building per frame (109 at Holtburg),
|
||||
// each doing 5 GL draws (mark/end-query/punch/render-opaque/render-trans/reset)
|
||||
// = ~545 extra draws/frame with no frustum culling. The driver hit TDR
|
||||
// limits. Set ACDREAM_A8_STEP5=1 to re-enable for cross-building visibility
|
||||
// testing once we add per-building frustum culling.
|
||||
bool step5Enabled = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_A8_STEP5"), "1", StringComparison.Ordinal);
|
||||
// GATED OFF BY DEFAULT. Current acdream does not yet have WB's
|
||||
// portal-manager visibility/occlusion lifecycle; feeding this loop
|
||||
// directly from all loaded building registries causes unrelated
|
||||
// buildings' EnvCells to overwrite exterior walls through stale or
|
||||
// over-broad portal masks. Keep the apparatus, but require an explicit
|
||||
// opt-in until the portal list is proven equivalent to WB's
|
||||
// _visibleBuildingPortals.
|
||||
bool step5Enabled = string.Equals(
|
||||
Environment.GetEnvironmentVariable("ACDREAM_A8_STEP5"), "1",
|
||||
StringComparison.Ordinal);
|
||||
if (step5Enabled && didInsideStencil && otherBuildings.Count > 0)
|
||||
{
|
||||
gl.Enable(EnableCap.StencilTest);
|
||||
|
|
@ -10861,10 +11262,14 @@ public sealed class GameWindow : IDisposable
|
|||
gl.DepthFunc(DepthFunction.Less);
|
||||
gl.Enable(EnableCap.CullFace);
|
||||
_meshShader!.Use();
|
||||
gl.Disable(EnableCap.Blend);
|
||||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, b.EnvCellIds);
|
||||
gl.Enable(EnableCap.Blend);
|
||||
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
gl.DepthMask(false);
|
||||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, b.EnvCellIds);
|
||||
gl.DepthMask(true);
|
||||
gl.Disable(EnableCap.Blend);
|
||||
|
||||
// Step 5d: reset bit 2 (Ref=1, Mask=0x02) for next iteration. WB VisibilityManager.cs:222-228
|
||||
EmitDrawOrderProbe(step: 5, sub: 'd');
|
||||
|
|
@ -10896,6 +11301,13 @@ public sealed class GameWindow : IDisposable
|
|||
gl.DepthFunc(DepthFunction.Less);
|
||||
gl.Enable(EnableCap.CullFace);
|
||||
}
|
||||
|
||||
// If no visible exit portal was uploaded, Step 4 is skipped but Step 3
|
||||
// still leaves alpha writes disabled. Restore the outer-frame defaults.
|
||||
gl.ColorMask(true, true, true, true);
|
||||
gl.DepthMask(true);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
gl.Enable(EnableCap.CullFace);
|
||||
}
|
||||
|
||||
// ── Phase A8 Task 9 (2026-05-28): probe trail for RenderInsideOutAcdream ──
|
||||
|
|
@ -10915,7 +11327,8 @@ public sealed class GameWindow : IDisposable
|
|||
// - [buildings] camBldgs=[0x...] non-empty when inside a cottage
|
||||
// - [envcells] cells>=1 tris>=1 filterCnt>=1 for at least one indoor frame
|
||||
// - [stencil] op=mark verts>0 fires per camera-building
|
||||
// - [draworder] shows steps 1 → 2 → 3 → 4 → 5{a,b,c,d} per indoor frame
|
||||
// - [draworder] shows steps 1 → 2 → 3 → 4 per indoor frame
|
||||
// (and 5{a,b,c,d} only when ACDREAM_A8_STEP5=1)
|
||||
|
||||
private int _phaseA8DrawOrderFrame = 0;
|
||||
|
||||
|
|
@ -10942,6 +11355,7 @@ public sealed class GameWindow : IDisposable
|
|||
gl.GetBoolean(Silk.NET.OpenGL.GLEnum.DepthWritemask, out var depthMask);
|
||||
gl.GetInteger(Silk.NET.OpenGL.GLEnum.CullFace, out int cullEnabled);
|
||||
gl.GetInteger(Silk.NET.OpenGL.GLEnum.CullFaceMode, out int cullMode);
|
||||
gl.GetInteger(Silk.NET.OpenGL.GLEnum.FrontFace, out int frontFace);
|
||||
gl.GetInteger(Silk.NET.OpenGL.GLEnum.BlendSrc, out int blendSrc);
|
||||
gl.GetInteger(Silk.NET.OpenGL.GLEnum.BlendDst, out int blendDst);
|
||||
gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilFunc, out int sFunc);
|
||||
|
|
@ -10971,6 +11385,7 @@ public sealed class GameWindow : IDisposable
|
|||
$"[draworder] frame={_phaseA8DrawOrderFrame} step={step}{subStr} " +
|
||||
$"stencil={(stOn != 0 ? "on" : "off")} depthFn=0x{depthFn:X} depthMask={depthMask} " +
|
||||
$"cull={(cullEnabled != 0 ? "on" : "off")}({(cullMode == (int)Silk.NET.OpenGL.GLEnum.Front ? "front" : cullMode == (int)Silk.NET.OpenGL.GLEnum.Back ? "back" : "f+b")}) " +
|
||||
$"front={(frontFace == (int)Silk.NET.OpenGL.GLEnum.CW ? "cw" : "ccw")} " +
|
||||
$"blend=0x{blendSrc:X}/0x{blendDst:X} " +
|
||||
$"sFunc=0x{sFunc:X}:{sRef}:0x{sValMask:X} " +
|
||||
$"sOp=0x{sFail:X}/0x{sPdFail:X}/0x{sPdPass:X} sMask=0x{sWriteMask:X} " +
|
||||
|
|
@ -11035,6 +11450,152 @@ public sealed class GameWindow : IDisposable
|
|||
/// rolling 256-sample buffer of microseconds, median + p95 reported.
|
||||
/// Sample buffer is NOT cleared on flush — it's a moving window so the
|
||||
/// next 5s window already has 256 frames of recent history.</summary>
|
||||
private static bool A8PerfEnabled()
|
||||
=> string.Equals(Environment.GetEnvironmentVariable("ACDREAM_A8_PERF"), "1", StringComparison.Ordinal);
|
||||
|
||||
private static long A8PerfStart(bool enabled)
|
||||
=> enabled ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
|
||||
private static void A8PerfStop(bool enabled, ref long bucket, long startTick)
|
||||
{
|
||||
if (!enabled || startTick == 0) return;
|
||||
bucket += System.Diagnostics.Stopwatch.GetTimestamp() - startTick;
|
||||
}
|
||||
|
||||
private int A8PerfBeginGpuFrame(bool enabled)
|
||||
{
|
||||
if (!enabled || _gl is null) return -1;
|
||||
|
||||
if (!_a8PerfGpuQueriesInitialized)
|
||||
{
|
||||
for (int i = 0; i < _a8PerfGpuQueries.Length; i++)
|
||||
_a8PerfGpuQueries[i] = _gl.GenQuery();
|
||||
_a8PerfGpuQueriesInitialized = true;
|
||||
}
|
||||
|
||||
int slot = _a8PerfGpuFrameIndex % A8PerfGpuRingDepth;
|
||||
if (_a8PerfGpuFrameIndex >= A8PerfGpuRingDepth)
|
||||
{
|
||||
for (int pass = 0; pass < A8PerfGpuPassCount; pass++)
|
||||
{
|
||||
int queryIndex = slot * A8PerfGpuPassCount + pass;
|
||||
if (!_a8PerfGpuIssued[queryIndex]) continue;
|
||||
uint query = _a8PerfGpuQueries[queryIndex];
|
||||
_gl.GetQueryObject(query, QueryObjectParameterName.ResultAvailable, out int available);
|
||||
if (available == 0) continue;
|
||||
_gl.GetQueryObject(query, QueryObjectParameterName.Result, out ulong elapsedNs);
|
||||
A8PerfAccumulateGpu(pass, elapsedNs);
|
||||
_a8PerfGpuIssued[queryIndex] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return slot;
|
||||
}
|
||||
|
||||
private void A8PerfEndGpuFrame(bool enabled)
|
||||
{
|
||||
if (enabled && _a8PerfGpuQueriesInitialized)
|
||||
_a8PerfGpuFrameIndex++;
|
||||
}
|
||||
|
||||
private void A8PerfBeginGpuQuery(bool enabled, int slot, int pass)
|
||||
{
|
||||
if (!enabled || slot < 0 || _gl is null) return;
|
||||
int queryIndex = slot * A8PerfGpuPassCount + pass;
|
||||
_a8PerfGpuIssued[queryIndex] = true;
|
||||
uint query = _a8PerfGpuQueries[queryIndex];
|
||||
_gl.BeginQuery(QueryTarget.TimeElapsed, query);
|
||||
}
|
||||
|
||||
private void A8PerfEndGpuQuery(bool enabled, int slot)
|
||||
{
|
||||
if (!enabled || slot < 0 || _gl is null) return;
|
||||
_gl.EndQuery(QueryTarget.TimeElapsed);
|
||||
}
|
||||
|
||||
private void A8PerfAccumulateGpu(int pass, ulong elapsedNs)
|
||||
{
|
||||
long ns = elapsedNs > long.MaxValue ? long.MaxValue : (long)elapsedNs;
|
||||
switch (pass)
|
||||
{
|
||||
case A8PerfGpuTerrain: _a8PerfTerrainGpuNs += ns; break;
|
||||
case A8PerfGpuStatic: _a8PerfStaticGpuNs += ns; break;
|
||||
case A8PerfGpuOutsideIn: _a8PerfOutsideInGpuNs += ns; break;
|
||||
case A8PerfGpuLive: _a8PerfLiveGpuNs += ns; break;
|
||||
case A8PerfGpuInsideOut: _a8PerfInsideOutGpuNs += ns; break;
|
||||
case A8PerfGpuInsideLive: _a8PerfInsideLiveGpuNs += ns; break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string A8DrawStats(AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats stats)
|
||||
=> $"{stats.Draws}d/{stats.CullRuns}r/{stats.Instances}i/{stats.Triangles}t";
|
||||
|
||||
private void MaybeFlushA8Perf(
|
||||
bool enabled,
|
||||
bool cameraInsideBuilding,
|
||||
int portalBuildings,
|
||||
int portalCells,
|
||||
int visibleLandblocks,
|
||||
int totalLandblocks)
|
||||
{
|
||||
if (!enabled) return;
|
||||
|
||||
_a8PerfFrames++;
|
||||
if (cameraInsideBuilding) _a8PerfInsideFrames++;
|
||||
if (portalBuildings > 0) _a8PerfOutsideInFrames++;
|
||||
_a8PerfLastPortalBuildings = portalBuildings;
|
||||
_a8PerfMaxPortalBuildings = System.Math.Max(_a8PerfMaxPortalBuildings, portalBuildings);
|
||||
_a8PerfLastPortalCells = portalCells;
|
||||
_a8PerfMaxPortalCells = System.Math.Max(_a8PerfMaxPortalCells, portalCells);
|
||||
_a8PerfLastVisibleLandblocks = visibleLandblocks;
|
||||
_a8PerfLastTotalLandblocks = totalLandblocks;
|
||||
|
||||
long now = Environment.TickCount64;
|
||||
if (now - _a8PerfLastLogTick <= 3000) return;
|
||||
|
||||
double Ms(long ticks) => _a8PerfFrames == 0
|
||||
? 0.0
|
||||
: ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency / _a8PerfFrames;
|
||||
double GpuMs(long ns) => _a8PerfFrames == 0 ? 0.0 : ns / 1_000_000.0 / _a8PerfFrames;
|
||||
|
||||
Console.WriteLine(string.Create(System.Globalization.CultureInfo.InvariantCulture,
|
||||
$"[A8-PERF] frames={_a8PerfFrames} inside={_a8PerfInsideFrames} outsideIn={_a8PerfOutsideInFrames} " +
|
||||
$"portals={_a8PerfLastPortalBuildings}/{_a8PerfMaxPortalBuildings} cells={_a8PerfLastPortalCells}/{_a8PerfMaxPortalCells} " +
|
||||
$"lb={_a8PerfLastVisibleLandblocks}/{_a8PerfLastTotalLandblocks} " +
|
||||
$"avg_ms anim={Ms(_a8PerfTickAnimTicks):F3} collect={Ms(_a8PerfCollectTicks):F3} " +
|
||||
$"envPrep={Ms(_a8PerfEnvPrepareTicks):F3} terrain={Ms(_a8PerfTerrainTicks):F3} " +
|
||||
$"static={Ms(_a8PerfStaticTicks):F3} outsideIn={Ms(_a8PerfOutsideInTicks):F3} " +
|
||||
$"live={Ms(_a8PerfLiveTicks):F3} insideOut={Ms(_a8PerfInsideOutTicks):F3} " +
|
||||
$"insideLive={Ms(_a8PerfInsideLiveTicks):F3} " +
|
||||
$"gpu_ms terrain={GpuMs(_a8PerfTerrainGpuNs):F3} static={GpuMs(_a8PerfStaticGpuNs):F3} " +
|
||||
$"outsideIn={GpuMs(_a8PerfOutsideInGpuNs):F3} live={GpuMs(_a8PerfLiveGpuNs):F3} " +
|
||||
$"insideOut={GpuMs(_a8PerfInsideOutGpuNs):F3} insideLive={GpuMs(_a8PerfInsideLiveGpuNs):F3} " +
|
||||
$"draws static={A8DrawStats(_a8PerfLastStaticStats)} live={A8DrawStats(_a8PerfLastLiveStats)} " +
|
||||
$"outsideShell={A8DrawStats(_a8PerfLastOutsideShellStats)} outsideIndoor={A8DrawStats(_a8PerfLastOutsideIndoorStats)}"));
|
||||
|
||||
_a8PerfFrames = 0;
|
||||
_a8PerfInsideFrames = 0;
|
||||
_a8PerfOutsideInFrames = 0;
|
||||
_a8PerfTickAnimTicks = 0;
|
||||
_a8PerfCollectTicks = 0;
|
||||
_a8PerfEnvPrepareTicks = 0;
|
||||
_a8PerfTerrainTicks = 0;
|
||||
_a8PerfStaticTicks = 0;
|
||||
_a8PerfOutsideInTicks = 0;
|
||||
_a8PerfLiveTicks = 0;
|
||||
_a8PerfInsideOutTicks = 0;
|
||||
_a8PerfInsideLiveTicks = 0;
|
||||
_a8PerfTerrainGpuNs = 0;
|
||||
_a8PerfStaticGpuNs = 0;
|
||||
_a8PerfOutsideInGpuNs = 0;
|
||||
_a8PerfLiveGpuNs = 0;
|
||||
_a8PerfInsideOutGpuNs = 0;
|
||||
_a8PerfInsideLiveGpuNs = 0;
|
||||
_a8PerfMaxPortalBuildings = portalBuildings;
|
||||
_a8PerfMaxPortalCells = portalCells;
|
||||
_a8PerfLastLogTick = now;
|
||||
}
|
||||
|
||||
private void MaybeFlushTerrainDiag()
|
||||
{
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal))
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ public static class PortalMeshBuilder
|
|||
/// — they don't open to outdoors, so stencil-marking them would let
|
||||
/// outdoor geometry bleed into adjacent rooms (incorrect).
|
||||
/// </summary>
|
||||
public static Vector3[] BuildTriangles(IReadOnlyCollection<LoadedCell> cells)
|
||||
public static Vector3[] BuildTriangles(
|
||||
IReadOnlyCollection<LoadedCell> cells,
|
||||
Vector3? cameraWorldPosition = null)
|
||||
{
|
||||
// Pre-count to size the output exactly.
|
||||
int triCount = 0;
|
||||
|
|
@ -49,6 +51,7 @@ public static class PortalMeshBuilder
|
|||
for (int p = 0; p < cell.Portals.Count; p++)
|
||||
{
|
||||
if (cell.Portals[p].OtherCellId != 0xFFFF) continue;
|
||||
if (!ExitPortalPassesCameraSide(cell, p, cameraWorldPosition)) continue;
|
||||
if (p >= cell.PortalPolygons.Count) continue;
|
||||
var poly = cell.PortalPolygons[p];
|
||||
if (poly.Length < 3) continue;
|
||||
|
|
@ -66,6 +69,7 @@ public static class PortalMeshBuilder
|
|||
for (int p = 0; p < cell.Portals.Count; p++)
|
||||
{
|
||||
if (cell.Portals[p].OtherCellId != 0xFFFF) continue;
|
||||
if (!ExitPortalPassesCameraSide(cell, p, cameraWorldPosition)) continue;
|
||||
if (p >= cell.PortalPolygons.Count) continue;
|
||||
var poly = cell.PortalPolygons[p];
|
||||
if (poly.Length < 3) continue;
|
||||
|
|
@ -83,6 +87,29 @@ public static class PortalMeshBuilder
|
|||
|
||||
return output;
|
||||
}
|
||||
|
||||
private static bool ExitPortalPassesCameraSide(
|
||||
LoadedCell cell,
|
||||
int portalIndex,
|
||||
Vector3? cameraWorldPosition)
|
||||
{
|
||||
if (cameraWorldPosition is not Vector3 camera)
|
||||
return true;
|
||||
if (portalIndex >= cell.ClipPlanes.Count)
|
||||
return true;
|
||||
|
||||
var plane = cell.ClipPlanes[portalIndex];
|
||||
if (plane.Normal.LengthSquared() < 1e-8f)
|
||||
return true;
|
||||
|
||||
var localCamera = Vector3.Transform(camera, cell.InverseWorldTransform);
|
||||
float dot = Vector3.Dot(plane.Normal, localCamera) + plane.D;
|
||||
|
||||
return plane.InsideSide == 0
|
||||
? dot >= -0.01f
|
||||
: dot <= 0.01f;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -120,9 +147,11 @@ public sealed unsafe class IndoorCellStencilPipeline : IDisposable
|
|||
/// and uploads it to <see cref="_vbo"/>. Returns the vertex count
|
||||
/// (0 means no exit portals — caller should skip stencil setup entirely).
|
||||
/// </summary>
|
||||
public int UploadPortalMesh(IReadOnlyCollection<LoadedCell> cells)
|
||||
public int UploadPortalMesh(
|
||||
IReadOnlyCollection<LoadedCell> cells,
|
||||
Vector3? cameraWorldPosition = null)
|
||||
{
|
||||
var verts = PortalMeshBuilder.BuildTriangles(cells);
|
||||
var verts = PortalMeshBuilder.BuildTriangles(cells, cameraWorldPosition);
|
||||
_lastVertexCount = verts.Length;
|
||||
if (_lastVertexCount == 0) return 0;
|
||||
|
||||
|
|
@ -142,6 +171,44 @@ public sealed unsafe class IndoorCellStencilPipeline : IDisposable
|
|||
return _lastVertexCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the portal mesh most recently uploaded by <see cref="UploadPortalMesh"/>.
|
||||
/// The caller owns stencil/depth/color/cull state, matching
|
||||
/// <see cref="RenderBuildingStencilMask"/>.
|
||||
/// </summary>
|
||||
public void DrawUploadedPortalMesh(
|
||||
Matrix4x4 viewProjection,
|
||||
bool writeFarDepth,
|
||||
bool enableDepthClamp = true)
|
||||
{
|
||||
if (_lastVertexCount == 0)
|
||||
{
|
||||
LastStencilVertexCount = 0;
|
||||
LastStencilWasFarPunch = writeFarDepth;
|
||||
LastStencilBuildingId = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (enableDepthClamp)
|
||||
_gl.Enable(EnableCap.DepthClamp);
|
||||
|
||||
_shader.Use();
|
||||
var vp = viewProjection;
|
||||
_gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp);
|
||||
_gl.Uniform1(_uWriteFarDepthLoc, writeFarDepth ? 1 : 0);
|
||||
|
||||
_gl.BindVertexArray(_vao);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_lastVertexCount);
|
||||
_gl.BindVertexArray(0);
|
||||
|
||||
if (enableDepthClamp)
|
||||
_gl.Disable(EnableCap.DepthClamp);
|
||||
|
||||
LastStencilVertexCount = _lastVertexCount;
|
||||
LastStencilWasFarPunch = writeFarDepth;
|
||||
LastStencilBuildingId = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steps 1+2 of WB's RenderInsideOut: mark stencil ref=1 wherever
|
||||
/// portal polygons cover, then write gl_FragDepth=1.0 into those
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#version 430 core
|
||||
//
|
||||
// Phase A8 — portal stencil mark + far-depth punch.
|
||||
// Phase A8 - portal stencil mark + far-depth punch.
|
||||
//
|
||||
// Position is in WORLD space (pipeline transforms cell-local portal
|
||||
// polygon vertices through cell.WorldTransform on the CPU before
|
||||
|
|
@ -10,10 +10,16 @@ layout(location = 0) in vec3 aPosition;
|
|||
|
||||
uniform mat4 uViewProjection;
|
||||
|
||||
// Note: no pos.w clamp — coplanar-camera degenerate is accepted per spec.
|
||||
// If stencil artifacts appear when the camera straddles an exit portal plane,
|
||||
// re-introduce the clamp from WB's PortalStencil.vert.
|
||||
void main()
|
||||
{
|
||||
gl_Position = uViewProjection * vec4(aPosition, 1.0);
|
||||
vec4 pos = uViewProjection * vec4(aPosition, 1.0);
|
||||
|
||||
// Match WorldBuilder's PortalStencil.vert: keep portal polygons stable
|
||||
// when the chase camera straddles an exit portal plane. Without this,
|
||||
// near-zero clip W can explode the screen-space portal mask and let the
|
||||
// Step 4 terrain pass punch into indoor floor/wall pixels for a frame.
|
||||
if (abs(pos.w) < 0.001)
|
||||
pos.w = pos.w < 0.0 ? -0.001 : 0.001;
|
||||
|
||||
gl_Position = pos;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// <summary>
|
||||
/// Phase A8 (2026-05-26): a logical building — one or more EnvCells linked
|
||||
/// via the dat-level <c>LandBlockInfo.Buildings</c> entry. Building shells (cottage
|
||||
/// walls, inn walls — <c>IsBuildingShell=true</c> entities) render unconditionally
|
||||
/// when the camera is inside this building's cells. The exit portal polygons
|
||||
/// are stencil-marked so outdoor visibility leaks through portal silhouettes
|
||||
/// only.
|
||||
/// walls, inn walls — <c>IsBuildingShell=true</c> entities) are scoped to this
|
||||
/// building's cells via their dat-derived anchor. The exit portal polygons are
|
||||
/// stencil-marked so outdoor visibility leaks through portal silhouettes only.
|
||||
///
|
||||
/// <para>Step 5 (cross-building visibility via 3-stencil-bit pipeline) uses
|
||||
/// the occlusion-query state to skip rendering when the building's portals
|
||||
|
|
@ -39,6 +38,18 @@ public sealed class Building
|
|||
/// polygon vertices via <see cref="AcDream.App.Rendering.LoadedCell.WorldTransform"/>.</summary>
|
||||
public required IReadOnlyList<Vector3[]> ExitPortalPolygons { get; init; }
|
||||
|
||||
/// <summary>True when <see cref="PortalBounds"/> contains at least one
|
||||
/// exit-portal vertex. Mirrors WB's <c>BuildingPortalGPU.VertexCount > 0</c>
|
||||
/// filter before a building participates in outside-in / Step 5 stencil
|
||||
/// visibility.</summary>
|
||||
public bool HasPortalBounds { get; init; }
|
||||
|
||||
/// <summary>World-space AABB of all exit portal polygons. WB's
|
||||
/// <c>PortalRenderManager.GetVisibleBuildingPortals</c> frustum-culls this
|
||||
/// box with near-plane ignored before adding the building to the portal
|
||||
/// visibility list.</summary>
|
||||
public WbBoundingBox PortalBounds { get; init; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Step 5 occlusion-query state (mutable, per-frame, RR9 scope).
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -117,6 +117,19 @@ public static class BuildingLoader
|
|||
}
|
||||
}
|
||||
|
||||
bool hasPortalBounds = false;
|
||||
var portalMin = new Vector3(float.MaxValue);
|
||||
var portalMax = new Vector3(float.MinValue);
|
||||
foreach (var poly in exitPortalPolys)
|
||||
{
|
||||
foreach (var v in poly)
|
||||
{
|
||||
hasPortalBounds = true;
|
||||
portalMin = Vector3.Min(portalMin, v);
|
||||
portalMax = Vector3.Max(portalMax, v);
|
||||
}
|
||||
}
|
||||
|
||||
// WB PortalService.cs:89: skip buildings with no interior cells.
|
||||
if (envCellIds.Count == 0) continue;
|
||||
|
||||
|
|
@ -125,6 +138,10 @@ public static class BuildingLoader
|
|||
BuildingId = nextId++,
|
||||
EnvCellIds = envCellIds,
|
||||
ExitPortalPolygons = exitPortalPolys,
|
||||
HasPortalBounds = hasPortalBounds,
|
||||
PortalBounds = hasPortalBounds
|
||||
? new WbBoundingBox(portalMin, portalMax)
|
||||
: default,
|
||||
};
|
||||
reg.Add(building);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ using System.Collections.Concurrent;
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DatReaderWriter.Enums;
|
||||
|
|
@ -70,6 +69,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
private int _modernInstanceCapacity = 1024;
|
||||
private uint _modernBatchBuffer;
|
||||
private int _modernBatchCapacity = 1024;
|
||||
// mesh_modern.vert's SSBO InstanceData is only mat4 transform. The CPU
|
||||
// InstanceData below also carries CellId/Flags for filtering, so upload a
|
||||
// packed transform array instead of the 80-byte CPU struct.
|
||||
private Matrix4x4[] _gpuInstanceTransforms = Array.Empty<Matrix4x4>();
|
||||
|
||||
// Reusable scratch arrays — avoid per-frame allocation.
|
||||
// WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>()
|
||||
|
|
@ -193,7 +196,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
_gl.GenBuffers(1, out _modernInstanceBuffer);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_modernInstanceCapacity * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
|
||||
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
|
||||
|
||||
// Per-batch data SSBO (binding=1)
|
||||
_gl.GenBuffers(1, out _modernBatchBuffer);
|
||||
|
|
@ -464,11 +467,30 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
/// Call once per frame, before <see cref="Render"/>.
|
||||
/// Source: WB EnvCellRenderManager.cs:247-373 (verbatim).
|
||||
/// </summary>
|
||||
public void PrepareRenderBatches(Matrix4x4 viewProjection, Vector3 cameraPosition, HashSet<uint>? filter = null)
|
||||
public void PrepareRenderBatches(
|
||||
Matrix4x4 viewProjection,
|
||||
Vector3 cameraPosition,
|
||||
HashSet<uint>? filter = null,
|
||||
int? centerLbX = null,
|
||||
int? centerLbY = null,
|
||||
int? renderRadius = null)
|
||||
{
|
||||
// WB EnvCellRenderManager.cs:249-250:
|
||||
if (!_initialized || cameraPosition.Z > 4000) return;
|
||||
|
||||
if (filter is { Count: 0 })
|
||||
{
|
||||
lock (_renderLock)
|
||||
{
|
||||
_poolIndex = 0;
|
||||
_activeSnapshot = new EnvCellVisibilitySnapshot();
|
||||
_activeSnapshotGlobalGroups = new Dictionary<ulong, List<InstanceData>>();
|
||||
_activeSnapshotGlobalGfxObjIds = new List<ulong>();
|
||||
NeedsPrepare = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// WB EnvCellRenderManager.cs:251-253:
|
||||
lock (_renderLock) { _poolIndex = 0; }
|
||||
|
||||
|
|
@ -479,8 +501,19 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
// Filter loaded landblocks by GpuReady + Instances non-empty.
|
||||
var landblocks = new List<EnvCellLandblock>();
|
||||
foreach (var lb in _landblocks.Values)
|
||||
{
|
||||
if (centerLbX.HasValue && centerLbY.HasValue && renderRadius.HasValue)
|
||||
{
|
||||
if (Math.Abs(lb.GridX - centerLbX.Value) > renderRadius.Value ||
|
||||
Math.Abs(lb.GridY - centerLbY.Value) > renderRadius.Value)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (lb.GpuReady && lb.Instances.Count > 0)
|
||||
landblocks.Add(lb);
|
||||
}
|
||||
if (landblocks.Count == 0) return;
|
||||
|
||||
// WB EnvCellRenderManager.cs:265-267:
|
||||
|
|
@ -815,32 +848,13 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
_gl.BindVertexArray(0);
|
||||
_currentVao = 0;
|
||||
|
||||
// Phase A8 (2026-05-28 visual-gate-#4 follow-up): NO cull-restore
|
||||
// at exit. The Landblock→None override can leave cull DISABLED
|
||||
// if the last batch was Landblock — and that's intentional: the
|
||||
// subsequent `dispatcher.Draw(IndoorPass)` call in
|
||||
// RenderInsideOutAcdream's Step 3 wants cull-off too, because
|
||||
// AC's cottage-shell GfxObj parts (the wooden floor planks +
|
||||
// wall slabs that the player walks on / through) have winding
|
||||
// that gets back-face-culled by the dispatcher's default
|
||||
// FrontFace=CCW. Letting cull stay off through IndoorPass
|
||||
// renders both shell and cell mesh double-sided, so floors are
|
||||
// visible from above (and inverted-front-facing wall slabs are
|
||||
// visible from inside the room). Step 4's
|
||||
// `gl.Enable(EnableCap.CullFace)` (line ~10768) + the cleanup
|
||||
// block's enable (line ~10870) re-establish cull-back before
|
||||
// LiveDynamic chars / NPCs / doors render — so those still
|
||||
// look solid (no see-through head). The static `_currentVao`
|
||||
// is reset because the next Render call's batch loop needs to
|
||||
// re-issue BindVertexArray regardless; `_currentCullMode` is
|
||||
// intentionally left at None so the cache matches actual GL
|
||||
// state until the next Render call's per-batch SetCullMode
|
||||
// either confirms or re-sets it.
|
||||
//
|
||||
// The retail-faithful long-term move is matching WB's
|
||||
// glFrontFace(CW) globally (GameScene.cs:843) so cull-back
|
||||
// selects the correct side for AC's polygon winding without
|
||||
// double-sided rendering — deferred until a wider audit.
|
||||
// No cull restore at exit, matching WB's manager pattern: the
|
||||
// last SetCullMode call reflects actual GL state, and the next
|
||||
// Render call invalidates `_currentCullMode` before issuing its
|
||||
// own per-batch state. The Landblock->None override below can
|
||||
// intentionally leave cull disabled for the following IndoorPass,
|
||||
// preserving the shipped Gate #5 baseline while deeper evidence is
|
||||
// gathered.
|
||||
|
||||
// Update frame stats for probe emission at the call site.
|
||||
_lastFrameStats.CellsRendered = filter?.Count ?? snapshot.BatchedByCell.Count;
|
||||
|
|
@ -932,7 +946,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
_modernInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_modernInstanceCapacity * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
|
||||
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
|
||||
}
|
||||
|
||||
// WB BaseObjectRenderManager.cs:761-762: grow scratch arrays.
|
||||
|
|
@ -977,12 +991,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(uniqueInstanceCount * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
|
||||
var instancesSpan = CollectionsMarshal.AsSpan(allInstances);
|
||||
fixed (InstanceData* ptr = instancesSpan)
|
||||
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
|
||||
if (_gpuInstanceTransforms.Length < uniqueInstanceCount)
|
||||
Array.Resize(ref _gpuInstanceTransforms, Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount));
|
||||
for (int i = 0; i < uniqueInstanceCount; i++)
|
||||
_gpuInstanceTransforms[i] = allInstances[i].Transform;
|
||||
fixed (Matrix4x4* ptr = _gpuInstanceTransforms)
|
||||
{
|
||||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||||
(nuint)(uniqueInstanceCount * sizeof(InstanceData)), ptr);
|
||||
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), ptr);
|
||||
}
|
||||
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer);
|
||||
|
|
@ -1014,23 +1031,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
foreach (var group in batchesByCullMode)
|
||||
{
|
||||
var cullMode = (CullMode)(group.Key % 4);
|
||||
// Phase A8 fix (2026-05-28 visual-gate-#3 evidence): override
|
||||
// CullMode.Landblock to None for cell-mesh batches. WB sets
|
||||
// glFrontFace(CW) globally (GameScene.cs:843) so its CullMode
|
||||
// mapping (Landblock→Back) culls the correct side; we set
|
||||
// glFrontFace(CCW) in WbDrawDispatcher (line 1056) so the
|
||||
// mapping would cull the OPPOSITE side, hiding cell floors.
|
||||
// Cell-mesh polys with CullMode.Landblock represent the floor +
|
||||
// walls + ceiling of a single room — they face different
|
||||
// directions but share one CullMode value, so a single cull
|
||||
// setting can't be correct for all of them. The retail-faithful
|
||||
// approach is double-sided rendering for cell polys (cull off),
|
||||
// matching what the cull-disable A/B diagnostic empirically
|
||||
// confirmed (floor visible with cull off in visual-gate-#3).
|
||||
// CullMode.Landblock is only ever assigned in this codebase by
|
||||
// PrepareCellStructMeshData (cell polys) — terrain has its own
|
||||
// renderer that doesn't go through this code path — so this
|
||||
// override is scoped exactly right.
|
||||
// Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock
|
||||
// uniformly, but the room surfaces need to be visible from inside
|
||||
// under acdream's current global winding state. Render cell polys
|
||||
// double-sided while the architectural cause is isolated.
|
||||
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
|
||||
if (_currentCullMode != cullMode)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.Core.Meshing;
|
||||
using DatReaderWriter.Enums;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
|
|
@ -17,4 +18,5 @@ internal readonly record struct GroupKey(
|
|||
int IndexCount,
|
||||
ulong BindlessTextureHandle,
|
||||
uint TextureLayer,
|
||||
TranslucencyKind Translucency);
|
||||
TranslucencyKind Translucency,
|
||||
CullMode CullMode = CullMode.CounterClockwise);
|
||||
|
|
|
|||
|
|
@ -25,9 +25,12 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Idempotency: a duplicate load for the same landblock is a no-op on
|
||||
/// ref-counting (the snapshot is already present). Defensive guard against
|
||||
/// streaming-controller bugs.
|
||||
/// Idempotency: repeated notifications for the same landblock only register
|
||||
/// newly-seen ids. This matters for two-tier streaming: a far-tier terrain
|
||||
/// load first snapshots an empty entity set, then a later Far-to-Near promotion
|
||||
/// supplies the actual stabs/buildings. Treating the second notification as a
|
||||
/// blanket no-op leaves the world-state entity list populated while the WB
|
||||
/// mesh cache never pins the promoted GfxObj ids.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -53,18 +56,15 @@ public sealed class LandblockSpawnAdapter
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a landblock finishes streaming in.
|
||||
/// Registers a ref-count increment with WB for each unique atlas-tier
|
||||
/// GfxObj id in the landblock. Duplicate loads for the same landblock id
|
||||
/// are silently ignored.
|
||||
/// Called when a landblock finishes streaming in or receives promoted
|
||||
/// atlas-tier entities. Registers a ref-count increment with WB for each
|
||||
/// unique atlas-tier GfxObj id that has not already been registered for
|
||||
/// this landblock.
|
||||
/// </summary>
|
||||
public void OnLandblockLoaded(LoadedLandblock landblock)
|
||||
{
|
||||
System.ArgumentNullException.ThrowIfNull(landblock);
|
||||
|
||||
// Idempotency: already-loaded landblock is a no-op.
|
||||
if (_idsByLandblock.ContainsKey(landblock.LandblockId)) return;
|
||||
|
||||
var unique = new HashSet<ulong>();
|
||||
foreach (var entity in landblock.Entities)
|
||||
{
|
||||
|
|
@ -76,8 +76,18 @@ public sealed class LandblockSpawnAdapter
|
|||
unique.Add((ulong)meshRef.GfxObjId);
|
||||
}
|
||||
|
||||
if (!_idsByLandblock.TryGetValue(landblock.LandblockId, out var registered))
|
||||
{
|
||||
_idsByLandblock[landblock.LandblockId] = unique;
|
||||
foreach (var id in unique) _adapter.IncrementRefCount(id);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var id in unique)
|
||||
{
|
||||
if (registered.Add(id))
|
||||
_adapter.IncrementRefCount(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using AcDream.Core.Meshing;
|
|||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Enums;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
|
@ -76,18 +77,22 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
/// <summary>Cell mesh + cell statics (<see cref="WorldEntity.ParentCellId"/>
|
||||
/// non-null) PLUS building shell stabs (<see cref="WorldEntity.IsBuildingShell"/>
|
||||
/// true, regardless of ParentCellId). These render unconditionally
|
||||
/// when the camera is inside their building — building shells ARE
|
||||
/// the indoor walls. Live-dynamic (<c>ServerGuid != 0</c>) is
|
||||
/// excluded; it flows through <see cref="LiveDynamic"/>.</summary>
|
||||
/// true) whose <see cref="WorldEntity.BuildingShellAnchorCellId"/>
|
||||
/// belongs to the active building cell set. Live-dynamic
|
||||
/// (<c>ServerGuid != 0</c>) is excluded; it flows through
|
||||
/// <see cref="LiveDynamic"/>.</summary>
|
||||
IndoorPass,
|
||||
|
||||
/// <summary>Outdoor scenery stabs (<c>ParentCellId == null</c>,
|
||||
/// <c>!IsBuildingShell</c>) plus procedurally-generated scenery.
|
||||
/// Drawn stencil-gated to portal silhouettes when the camera is
|
||||
/// inside. Live-dynamic excluded.</summary>
|
||||
/// <summary>Outdoor/top-level stabs (<c>ParentCellId == null</c>),
|
||||
/// including building shells. Drawn stencil-gated to portal
|
||||
/// silhouettes when the camera is inside. Live-dynamic excluded.</summary>
|
||||
OutdoorScenery,
|
||||
|
||||
/// <summary>Top-level building shell stabs only, optionally scoped by
|
||||
/// <see cref="WorldEntity.BuildingShellAnchorCellId"/>. Used for
|
||||
/// portal depth repair without walking the full outdoor scenery set.</summary>
|
||||
BuildingShells,
|
||||
|
||||
/// <summary>Server-spawned dynamic entities (<c>ServerGuid != 0</c>):
|
||||
/// player, NPCs, monsters, dropped items, animated and idle doors.
|
||||
/// Drawn last with stencil disabled so they're depth-tested against
|
||||
|
|
@ -103,6 +108,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
private readonly BindlessSupport _bindless;
|
||||
|
||||
public readonly record struct DrawStats(
|
||||
EntitySet Set,
|
||||
int EntitiesWalked,
|
||||
int MeshRefs,
|
||||
int Instances,
|
||||
int Draws,
|
||||
int CullRuns,
|
||||
int OpaqueDraws,
|
||||
int TransparentDraws,
|
||||
long Triangles);
|
||||
|
||||
public DrawStats LastDrawStats { get; private set; }
|
||||
|
||||
// Tier 1 cache (#53): per-entity classification results for static
|
||||
// entities (those NOT in GameWindow._animatedEntities). Wired here in
|
||||
// Task 7 for plumbing only — Tasks 9-10 wire the per-entity
|
||||
|
|
@ -132,6 +150,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
private float[] _instanceData = new float[256 * 16]; // mat4 floats per instance
|
||||
private BatchData[] _batchData = new BatchData[256];
|
||||
private DrawElementsIndirectCommand[] _indirectCommands = new DrawElementsIndirectCommand[256];
|
||||
private CullMode[] _drawCullModes = new CullMode[256];
|
||||
|
||||
private int _opaqueDrawCount;
|
||||
private int _transparentDrawCount;
|
||||
|
|
@ -283,6 +302,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
public struct WalkResult
|
||||
{
|
||||
public int EntitiesWalked;
|
||||
public int BuildingShellAnchorPass;
|
||||
public int BuildingShellAnchorReject;
|
||||
public List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> ToDraw;
|
||||
}
|
||||
|
||||
|
|
@ -375,8 +396,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// Phase A8: EntitySet partition for indoor/outdoor split passes.
|
||||
if (!EntityMatchesSet(entity, set)) continue;
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) continue;
|
||||
bool shellScoped = IsShellScopedSet(set)
|
||||
&& entity.IsBuildingShell
|
||||
&& visibleCellIds is not null;
|
||||
if (!EntityPassesVisibleCellGate(entity, visibleCellIds, set))
|
||||
{
|
||||
if (shellScoped) result.BuildingShellAnchorReject++;
|
||||
continue;
|
||||
}
|
||||
if (shellScoped) result.BuildingShellAnchorPass++;
|
||||
result.EntitiesWalked++;
|
||||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||||
scratch.Add((entity, i, entry.LandblockId));
|
||||
|
|
@ -397,11 +425,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
bool isCellEntity = indoorProbeState is not null
|
||||
&& RenderingDiagnostics.IsEnvCellId(cellProbeId);
|
||||
|
||||
bool cellInVis = !(entity.ParentCellId.HasValue
|
||||
&& visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
|
||||
bool shellScoped = IsShellScopedSet(set)
|
||||
&& entity.IsBuildingShell
|
||||
&& visibleCellIds is not null;
|
||||
bool cellInVis = EntityPassesVisibleCellGate(entity, visibleCellIds, set);
|
||||
if (!cellInVis)
|
||||
{
|
||||
if (shellScoped) result.BuildingShellAnchorReject++;
|
||||
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
|
||||
&& indoorProbeState!.ShouldEmit(cellProbeId))
|
||||
{
|
||||
|
|
@ -412,6 +442,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
}
|
||||
continue;
|
||||
}
|
||||
if (shellScoped) result.BuildingShellAnchorPass++;
|
||||
|
||||
// Per-entity AABB frustum cull (perf #3). Animated entities bypass —
|
||||
// they're tracked at landblock level + need per-frame work regardless.
|
||||
|
|
@ -545,6 +576,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
probeState,
|
||||
set);
|
||||
|
||||
if (set == EntitySet.IndoorPass && RenderingDiagnostics.ProbeVisibilityEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-shells] anchorPass={walkResult.BuildingShellAnchorPass} " +
|
||||
$"anchorReject={walkResult.BuildingShellAnchorReject} walked={walkResult.EntitiesWalked}");
|
||||
}
|
||||
|
||||
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
|
||||
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
|
||||
// a given entity are contiguous. We accumulate ALL of an entity's
|
||||
|
|
@ -855,6 +893,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// Nothing visible — skip the GL pass entirely.
|
||||
if (anyVao == 0)
|
||||
{
|
||||
LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0);
|
||||
_cpuStopwatch.Stop();
|
||||
if (diag) MaybeFlushDiag();
|
||||
return;
|
||||
|
|
@ -865,6 +904,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count;
|
||||
if (totalInstances == 0)
|
||||
{
|
||||
LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0);
|
||||
_cpuStopwatch.Stop();
|
||||
if (diag) MaybeFlushDiag();
|
||||
return;
|
||||
|
|
@ -905,11 +945,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_translucentDraws.Add(grp);
|
||||
}
|
||||
|
||||
// Front-to-back sort for opaque pass: nearer groups draw first so the
|
||||
// depth test rejects fragments hidden behind them, reducing fragment
|
||||
// shader cost from overdraw on dense scenes (Holtburg courtyard,
|
||||
// Foundry interior).
|
||||
_opaqueDraws.Sort(static (a, b) => a.SortDistance.CompareTo(b.SortDistance));
|
||||
// Front-to-back sort within each cull mode. DrawIndirectRange must
|
||||
// split MDI calls whenever CullMode changes because GL state is not
|
||||
// part of an indirect command. Sorting by distance alone can turn a
|
||||
// stable 1k-draw live scene into hundreds of tiny MDI runs after a
|
||||
// landblock transition, which shows up as a GPU-command bottleneck
|
||||
// without a triangle-count spike.
|
||||
_opaqueDraws.Sort(CompareOpaqueSubmissionOrder);
|
||||
_translucentDraws.Sort(CompareTransparentSubmissionOrder);
|
||||
|
||||
// ── Phase 4: build IndirectGroupInput list (opaque sorted, then translucent),
|
||||
// fill via BuildIndirectArrays ──────────────────────────────────
|
||||
|
|
@ -918,6 +961,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_batchData = new BatchData[totalDraws + 64];
|
||||
if (_indirectCommands.Length < totalDraws)
|
||||
_indirectCommands = new DrawElementsIndirectCommand[totalDraws + 64];
|
||||
if (_drawCullModes.Length < totalDraws)
|
||||
_drawCullModes = new CullMode[totalDraws + 64];
|
||||
|
||||
var groupInputs = new List<IndirectGroupInput>(totalDraws);
|
||||
foreach (var g in _opaqueDraws) groupInputs.Add(ToInput(g));
|
||||
|
|
@ -926,7 +971,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// Cast _batchData (private BatchData) to public-mirror BatchDataPublic for BuildIndirectArrays.
|
||||
// Layout is asserted at test time (BatchDataPublic_LayoutMatchesPrivateBatchData test).
|
||||
var batchPublic = new BatchDataPublic[totalDraws];
|
||||
var layout = BuildIndirectArrays(groupInputs, _indirectCommands, batchPublic);
|
||||
var layout = BuildIndirectArrays(groupInputs, _indirectCommands, batchPublic, _drawCullModes);
|
||||
long totalTriangles = 0;
|
||||
foreach (var input in groupInputs)
|
||||
totalTriangles += (long)(input.IndexCount / 3) * input.InstanceCount;
|
||||
int cullRuns =
|
||||
CountCullRuns(_drawCullModes, 0, layout.OpaqueCount) +
|
||||
CountCullRuns(_drawCullModes, layout.OpaqueCount, layout.TransparentCount);
|
||||
|
||||
// Copy back into _batchData
|
||||
for (int i = 0; i < totalDraws; i++)
|
||||
|
|
@ -941,6 +992,16 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_opaqueDrawCount = layout.OpaqueCount;
|
||||
_transparentDrawCount = layout.TransparentCount;
|
||||
_transparentByteOffset = layout.TransparentByteOffset;
|
||||
LastDrawStats = new DrawStats(
|
||||
set,
|
||||
walkResult.EntitiesWalked,
|
||||
_walkScratch.Count,
|
||||
totalInstances,
|
||||
totalDraws,
|
||||
cullRuns,
|
||||
_opaqueDrawCount,
|
||||
_transparentDrawCount,
|
||||
totalTriangles);
|
||||
|
||||
// ── Phase 5: upload three buffers ───────────────────────────────────
|
||||
fixed (float* ip = _instanceData)
|
||||
|
|
@ -1007,12 +1068,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_shader.SetInt("uDrawIDOffset", 0);
|
||||
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
|
||||
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque[gpuQuerySlot]);
|
||||
_gl.MultiDrawElementsIndirect(
|
||||
PrimitiveType.Triangles,
|
||||
DrawElementsType.UnsignedShort,
|
||||
(void*)0,
|
||||
(uint)_opaqueDrawCount,
|
||||
(uint)DrawCommandStride);
|
||||
DrawIndirectRange(0, _opaqueDrawCount);
|
||||
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
|
||||
if (AlphaToCoverage) _gl.Disable(EnableCap.SampleAlphaToCoverage);
|
||||
}
|
||||
|
|
@ -1030,38 +1086,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// flickers to whatever opaque batch sorted first that frame. See
|
||||
// uDrawIDOffset comment in mesh_modern.vert.
|
||||
_shader.SetInt("uDrawIDOffset", _opaqueDrawCount);
|
||||
// Phase Post-A.5 (ISSUE #52, 2026-05-10): re-establish Phase 9.2's
|
||||
// back-face cull setup. The legacy StaticMeshRenderer had this
|
||||
// (commit 6f1971a, 2026-04-11) until the N.5 retirement amendment
|
||||
// (commit dcae2b6, 2026-05-08) deleted that renderer; the new
|
||||
// WbDrawDispatcher never inherited the cull-face state.
|
||||
//
|
||||
// Closed-shell translucent meshes — lifestone crystal, glow gems,
|
||||
// any convex blended mesh — NEED back-face culling in the
|
||||
// translucent pass. Without it, back faces composite OVER front
|
||||
// faces in arbitrary iteration order, because DepthMask(false)
|
||||
// means nothing records depth within the translucent set. The
|
||||
// result is the user-visible "one face missing, see into the
|
||||
// hollow interior" + frame-to-frame color flicker as rotation
|
||||
// shifts the triangle order.
|
||||
//
|
||||
// Our fan triangulation emits pos-side polygons as (0, i, i+1) —
|
||||
// CCW in standard OpenGL conventions — so GL_BACK + CCW-front is
|
||||
// the correct state. Matches WorldBuilder's per-batch CullMode
|
||||
// handling. Neg-side polygons (rare on translucent AC content)
|
||||
// use reversed winding and get culled here, matching the opaque
|
||||
// pass and the original Phase 9.2 fix's known limitation.
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
// Closed-shell translucent meshes still need culling, but the
|
||||
// cull side must come from each dat batch just like the opaque
|
||||
// section. BuildIndirectArrays preserves CullMode in _drawCullModes.
|
||||
_gl.FrontFace(FrontFaceDirection.CW);
|
||||
_shader.SetInt("uRenderPass", 1);
|
||||
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryTransparent[gpuQuerySlot]);
|
||||
_gl.MultiDrawElementsIndirect(
|
||||
PrimitiveType.Triangles,
|
||||
DrawElementsType.UnsignedShort,
|
||||
(void*)_transparentByteOffset,
|
||||
(uint)_transparentDrawCount,
|
||||
(uint)DrawCommandStride);
|
||||
DrawIndirectRange(_opaqueDrawCount, _transparentDrawCount);
|
||||
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
|
||||
_gl.DepthMask(true);
|
||||
_gl.Disable(EnableCap.Blend);
|
||||
|
|
@ -1132,7 +1163,91 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
FirstInstance: g.FirstInstance,
|
||||
TextureHandle: g.BindlessTextureHandle,
|
||||
TextureLayer: g.TextureLayer,
|
||||
Translucency: g.Translucency);
|
||||
Translucency: g.Translucency,
|
||||
CullMode: g.CullMode);
|
||||
|
||||
private static int CompareOpaqueSubmissionOrder(InstanceGroup a, InstanceGroup b)
|
||||
{
|
||||
int cull = a.CullMode.CompareTo(b.CullMode);
|
||||
return cull != 0 ? cull : a.SortDistance.CompareTo(b.SortDistance);
|
||||
}
|
||||
|
||||
private static int CompareTransparentSubmissionOrder(InstanceGroup a, InstanceGroup b)
|
||||
{
|
||||
int cull = a.CullMode.CompareTo(b.CullMode);
|
||||
return cull != 0 ? cull : b.SortDistance.CompareTo(a.SortDistance);
|
||||
}
|
||||
|
||||
private static int CountCullRuns(CullMode[] modes, int startCommand, int commandCount)
|
||||
{
|
||||
if (commandCount <= 0) return 0;
|
||||
|
||||
int end = startCommand + commandCount;
|
||||
int runs = 1;
|
||||
var previous = modes[startCommand];
|
||||
for (int i = startCommand + 1; i < end; i++)
|
||||
{
|
||||
var current = modes[i];
|
||||
if (current == previous) continue;
|
||||
runs++;
|
||||
previous = current;
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
private unsafe void DrawIndirectRange(int startCommand, int commandCount)
|
||||
{
|
||||
int end = startCommand + commandCount;
|
||||
int command = startCommand;
|
||||
while (command < end)
|
||||
{
|
||||
var cullMode = _drawCullModes[command];
|
||||
ApplyCullMode(cullMode);
|
||||
|
||||
int runCount = 1;
|
||||
while (command + runCount < end && _drawCullModes[command + runCount] == cullMode)
|
||||
runCount++;
|
||||
|
||||
// Each glMultiDrawElementsIndirect call restarts gl_DrawID at 0.
|
||||
// Because this method splits one logical opaque/transparent pass
|
||||
// into CullMode runs, the shader must receive the absolute command
|
||||
// index for this run or it will read BatchData[0] again and bind
|
||||
// the wrong texture for later runs.
|
||||
_shader.SetInt("uDrawIDOffset", command);
|
||||
_gl.MultiDrawElementsIndirect(
|
||||
PrimitiveType.Triangles,
|
||||
DrawElementsType.UnsignedShort,
|
||||
(void*)(command * DrawCommandStride),
|
||||
(uint)runCount,
|
||||
(uint)DrawCommandStride);
|
||||
|
||||
command += runCount;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyCullMode(CullMode mode)
|
||||
{
|
||||
// WB BaseObjectRenderManager.cs:850-866 applies CullMode per MDI group.
|
||||
// WB GameScene.cs:843 sets FrontFace(CW) globally; SetCullMode then
|
||||
// only chooses front/back culling. Keep the same convention here so
|
||||
// splitting MDI commands by CullMode cannot resurrect stale CCW state.
|
||||
_gl.FrontFace(FrontFaceDirection.CW);
|
||||
switch (mode)
|
||||
{
|
||||
case CullMode.None:
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
break;
|
||||
case CullMode.Clockwise:
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Front);
|
||||
break;
|
||||
case CullMode.CounterClockwise:
|
||||
case CullMode.Landblock:
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void UploadSsbo(uint ssbo, uint binding, void* data, int byteCount)
|
||||
{
|
||||
|
|
@ -1293,6 +1408,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
BindlessTextureHandle = key.BindlessTextureHandle,
|
||||
TextureLayer = key.TextureLayer,
|
||||
Translucency = key.Translucency,
|
||||
CullMode = key.CullMode,
|
||||
};
|
||||
_groups[key] = grp;
|
||||
}
|
||||
|
|
@ -1335,7 +1451,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
var key = new GroupKey(
|
||||
batch.IBO, batch.FirstIndex, (int)batch.BaseVertex,
|
||||
batch.IndexCount, texHandle, texLayer, translucency);
|
||||
batch.IndexCount, texHandle, texLayer, translucency, batch.CullMode);
|
||||
|
||||
if (!_groups.TryGetValue(key, out var grp))
|
||||
{
|
||||
|
|
@ -1348,6 +1464,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
BindlessTextureHandle = texHandle,
|
||||
TextureLayer = texLayer,
|
||||
Translucency = translucency,
|
||||
CullMode = batch.CullMode,
|
||||
};
|
||||
_groups[key] = grp;
|
||||
}
|
||||
|
|
@ -1403,7 +1520,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
bool isIndoor = entity.ParentCellId.HasValue || entity.IsBuildingShell;
|
||||
if (set == EntitySet.IndoorPass) return isIndoor;
|
||||
if (set == EntitySet.OutdoorScenery) return !isIndoor;
|
||||
if (set == EntitySet.OutdoorScenery) return !entity.ParentCellId.HasValue;
|
||||
if (set == EntitySet.BuildingShells) return entity.IsBuildingShell;
|
||||
|
||||
throw new InvalidOperationException($"Unhandled EntitySet value: {set}");
|
||||
}
|
||||
|
|
@ -1425,10 +1543,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (!EntityMatchesSet(entity, set)) continue;
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
|
||||
bool cellInVis = !(entity.ParentCellId.HasValue
|
||||
&& visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
|
||||
if (!cellInVis) continue;
|
||||
if (!EntityPassesVisibleCellGate(entity, visibleCellIds, set)) continue;
|
||||
|
||||
output.Add(entity.Id);
|
||||
}
|
||||
|
|
@ -1441,8 +1556,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
/// cells (instead of the visibility-derived set).
|
||||
///
|
||||
/// <para>Indoor entities (ParentCellId set) gated by membership in
|
||||
/// <paramref name="cellIds"/>. Building shells (IsBuildingShell) pass
|
||||
/// unconditionally when <paramref name="set"/> == IndoorPass. Outdoor
|
||||
/// <paramref name="cellIds"/>. Building shells are gated by
|
||||
/// BuildingShellAnchorCellId membership in the same cell set. Outdoor
|
||||
/// scenery is excluded by the EntitySet partition (no cell-list gate
|
||||
/// needed — EntityMatchesSet handles it).</para>
|
||||
/// </summary>
|
||||
|
|
@ -1458,11 +1573,40 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (entity.MeshRefs.Count == 0) continue;
|
||||
if (entity.ParentCellId.HasValue && !cellIds.Contains(entity.ParentCellId.Value))
|
||||
continue;
|
||||
if (IsShellScopedSet(set) && entity.IsBuildingShell)
|
||||
{
|
||||
if (entity.BuildingShellAnchorCellId is not uint anchorCellId ||
|
||||
!cellIds.Contains(anchorCellId))
|
||||
continue;
|
||||
}
|
||||
result.Add(entity.Id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool EntityPassesVisibleCellGate(
|
||||
WorldEntity entity,
|
||||
HashSet<uint>? visibleCellIds,
|
||||
EntitySet set)
|
||||
{
|
||||
if (visibleCellIds is null)
|
||||
return true;
|
||||
|
||||
if (entity.ParentCellId.HasValue)
|
||||
return visibleCellIds.Contains(entity.ParentCellId.Value);
|
||||
|
||||
if (IsShellScopedSet(set) && entity.IsBuildingShell)
|
||||
{
|
||||
return entity.BuildingShellAnchorCellId is uint anchorCellId
|
||||
&& visibleCellIds.Contains(anchorCellId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsShellScopedSet(EntitySet set) =>
|
||||
set == EntitySet.IndoorPass || set == EntitySet.BuildingShells;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
|
@ -1503,7 +1647,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
int FirstInstance,
|
||||
ulong TextureHandle,
|
||||
uint TextureLayer,
|
||||
TranslucencyKind Translucency);
|
||||
TranslucencyKind Translucency,
|
||||
CullMode CullMode = CullMode.CounterClockwise);
|
||||
|
||||
/// <summary>
|
||||
/// Public mirror of the per-group <see cref="BatchData"/> uploaded to the SSBO.
|
||||
|
|
@ -1535,7 +1680,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
public static IndirectLayoutResult BuildIndirectArrays(
|
||||
IReadOnlyList<IndirectGroupInput> groups,
|
||||
DrawElementsIndirectCommand[] indirectScratch,
|
||||
BatchDataPublic[] batchScratch)
|
||||
BatchDataPublic[] batchScratch,
|
||||
CullMode[]? cullScratch = null)
|
||||
{
|
||||
int opaqueCount = 0;
|
||||
int transparentCount = 0;
|
||||
|
|
@ -1570,12 +1716,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
{
|
||||
indirectScratch[oi] = dec;
|
||||
batchScratch[oi] = bd;
|
||||
if (cullScratch is not null) cullScratch[oi] = g.CullMode;
|
||||
oi++;
|
||||
}
|
||||
else
|
||||
{
|
||||
indirectScratch[ti] = dec;
|
||||
batchScratch[ti] = bd;
|
||||
if (cullScratch is not null) cullScratch[ti] = g.CullMode;
|
||||
ti++;
|
||||
}
|
||||
}
|
||||
|
|
@ -1639,6 +1787,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
public ulong BindlessTextureHandle; // 64-bit (was uint TextureHandle in N.4)
|
||||
public uint TextureLayer; // 0 for per-instance composites; non-zero when WB atlas is adopted in N.6+
|
||||
public TranslucencyKind Translucency;
|
||||
public CullMode CullMode;
|
||||
public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes)
|
||||
public int InstanceCount;
|
||||
public float SortDistance; // squared distance from camera to first instance, for opaque sort
|
||||
|
|
|
|||
|
|
@ -38,6 +38,13 @@ public sealed record RuntimeOptions(
|
|||
int HidePartIndex,
|
||||
bool RetailCloseDegrades,
|
||||
bool DumpSceneryZ,
|
||||
bool DumpLiveSpawns,
|
||||
bool A8DiagDisableInsideStep4Terrain,
|
||||
bool A8DiagDisableInsideStep4Outdoor,
|
||||
bool A8DiagDisableInsideStep3EnvCellOpaque,
|
||||
bool A8DiagDisableInsideStep3IndoorPass,
|
||||
bool A8DiagDisableInsideStep2Punch,
|
||||
bool A8DiagDisablePortalDepthClamp,
|
||||
int? LegacyStreamRadius)
|
||||
{
|
||||
/// <summary>
|
||||
|
|
@ -77,6 +84,19 @@ public sealed record RuntimeOptions(
|
|||
// only for before/after diagnostic comparisons.
|
||||
RetailCloseDegrades: !string.Equals(env("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal),
|
||||
DumpSceneryZ: IsExactlyOne(env("ACDREAM_DUMP_SCENERY_Z")),
|
||||
DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")),
|
||||
A8DiagDisableInsideStep4Terrain:
|
||||
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_TERRAIN")),
|
||||
A8DiagDisableInsideStep4Outdoor:
|
||||
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_OUTDOOR")),
|
||||
A8DiagDisableInsideStep3EnvCellOpaque:
|
||||
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_ENVCELL_OPAQUE")),
|
||||
A8DiagDisableInsideStep3IndoorPass:
|
||||
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_INDOORPASS")),
|
||||
A8DiagDisableInsideStep2Punch:
|
||||
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP2_PUNCH")),
|
||||
A8DiagDisablePortalDepthClamp:
|
||||
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_PORTAL_DEPTH_CLAMP")),
|
||||
// Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on
|
||||
// top of the quality preset's radii. Null when unset or invalid.
|
||||
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")));
|
||||
|
|
|
|||
|
|
@ -137,15 +137,50 @@ public sealed class GpuWorldState
|
|||
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
|
||||
=> EnumerateLandblockEntries(includeAnimatedIndex: true);
|
||||
|
||||
/// <summary>
|
||||
/// Per-landblock render entries without the animated lookup dictionary.
|
||||
/// Static render passes use this to avoid rebuilding an index they cannot
|
||||
/// consume.
|
||||
/// </summary>
|
||||
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntriesWithoutAnimatedIndex
|
||||
=> EnumerateLandblockEntries(includeAnimatedIndex: false);
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight bounds-only enumeration for overlays and diagnostics.
|
||||
/// Does not walk entity lists.
|
||||
/// </summary>
|
||||
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (var kvp in _loaded)
|
||||
{
|
||||
// Build AnimatedById on the fly — cheap (~132 entities/LB max).
|
||||
var byId = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
|
||||
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
|
||||
yield return (kvp.Key, aabb.Min, aabb.Max);
|
||||
else
|
||||
yield return (kvp.Key, Vector3.Zero, Vector3.Zero);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> EnumerateLandblockEntries(
|
||||
bool includeAnimatedIndex)
|
||||
{
|
||||
foreach (var kvp in _loaded)
|
||||
{
|
||||
Dictionary<uint, WorldEntity>? byId = null;
|
||||
if (includeAnimatedIndex)
|
||||
{
|
||||
byId = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
|
||||
foreach (var e in kvp.Value.Entities)
|
||||
byId[e.Id] = e;
|
||||
}
|
||||
|
||||
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
|
||||
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId);
|
||||
|
|
@ -153,7 +188,6 @@ public sealed class GpuWorldState
|
|||
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Total live entities currently parked in the pending bucket waiting
|
||||
|
|
|
|||
|
|
@ -37,15 +37,21 @@ public abstract record LandblockStreamResult(uint LandblockId)
|
|||
) : LandblockStreamResult(LandblockId);
|
||||
|
||||
/// <summary>
|
||||
/// A previously-Far-resident landblock was promoted to Near. Terrain
|
||||
/// mesh is already on the GPU; the result carries the entity layer
|
||||
/// (stabs, buildings, scenery) to merge into the existing GpuWorldState
|
||||
/// entry.
|
||||
/// A previously-Far-resident landblock was promoted to Near. The result
|
||||
/// carries the full near landblock plus mesh data so the render thread can
|
||||
/// run the same near-tier side effects as a fresh LoadNear: cell visibility,
|
||||
/// building registries, EnvCell finalization, lighting, and static collision.
|
||||
/// GpuWorldState still merges only the entity layer so live entities already
|
||||
/// attached to the landblock are preserved.
|
||||
/// </summary>
|
||||
public sealed record Promoted(
|
||||
uint LandblockId,
|
||||
IReadOnlyList<WorldEntity> Entities
|
||||
) : LandblockStreamResult(LandblockId);
|
||||
LoadedLandblock Landblock,
|
||||
LandblockMeshData MeshData
|
||||
) : LandblockStreamResult(LandblockId)
|
||||
{
|
||||
public IReadOnlyList<WorldEntity> Entities => Landblock.Entities;
|
||||
}
|
||||
|
||||
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId);
|
||||
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId);
|
||||
|
|
|
|||
|
|
@ -159,6 +159,9 @@ public sealed class LandblockStreamer : IDisposable
|
|||
|
||||
private void WorkerLoop()
|
||||
{
|
||||
var highPriority = new Queue<LandblockStreamJob>();
|
||||
var lowPriority = new Queue<LandblockStreamJob>();
|
||||
|
||||
try
|
||||
{
|
||||
// Safe to block: this is a dedicated worker thread with no
|
||||
|
|
@ -169,14 +172,24 @@ public sealed class LandblockStreamer : IDisposable
|
|||
// simple thread-start shape.
|
||||
while (!_cancel.Token.IsCancellationRequested)
|
||||
{
|
||||
if (!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
|
||||
if (highPriority.Count == 0 &&
|
||||
lowPriority.Count == 0 &&
|
||||
!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
while (_inbox.Reader.TryRead(out var job))
|
||||
{
|
||||
EnqueuePrioritized(job, highPriority, lowPriority);
|
||||
|
||||
if (highPriority.Count == 0 && lowPriority.Count == 0)
|
||||
continue;
|
||||
|
||||
if (_cancel.Token.IsCancellationRequested) return;
|
||||
HandleJob(job);
|
||||
}
|
||||
var next = highPriority.Count > 0
|
||||
? highPriority.Dequeue()
|
||||
: lowPriority.Dequeue();
|
||||
HandleJob(next);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { /* graceful shutdown */ }
|
||||
|
|
@ -192,6 +205,55 @@ public sealed class LandblockStreamer : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private static void EnqueuePrioritized(
|
||||
LandblockStreamJob job,
|
||||
Queue<LandblockStreamJob> highPriority,
|
||||
Queue<LandblockStreamJob> lowPriority)
|
||||
{
|
||||
if (job is LandblockStreamJob.Load
|
||||
{
|
||||
Kind: LandblockStreamJobKind.LoadNear or LandblockStreamJobKind.PromoteToNear
|
||||
} high)
|
||||
{
|
||||
// Near-tier jobs are visible-content critical. They supersede an
|
||||
// older queued LoadFar for the same landblock: LoadNear obviously
|
||||
// loads everything, and PromoteToNear now carries mesh data so the
|
||||
// render thread can run the full near-tier apply side effects. If a
|
||||
// LoadFar is already being processed, the single worker naturally
|
||||
// finishes it before the promotion is dequeued.
|
||||
RemoveLowPriorityJobsForLandblock(
|
||||
lowPriority,
|
||||
high.LandblockId,
|
||||
removeLoadFar: true,
|
||||
removeUnload: true);
|
||||
highPriority.Enqueue(job);
|
||||
return;
|
||||
}
|
||||
|
||||
lowPriority.Enqueue(job);
|
||||
}
|
||||
|
||||
private static void RemoveLowPriorityJobsForLandblock(
|
||||
Queue<LandblockStreamJob> queue,
|
||||
uint landblockId,
|
||||
bool removeLoadFar,
|
||||
bool removeUnload)
|
||||
{
|
||||
int count = queue.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var job = queue.Dequeue();
|
||||
bool remove = job.LandblockId == landblockId && job switch
|
||||
{
|
||||
LandblockStreamJob.Load { Kind: LandblockStreamJobKind.LoadFar } => removeLoadFar,
|
||||
LandblockStreamJob.Unload => removeUnload,
|
||||
_ => false
|
||||
};
|
||||
if (!remove)
|
||||
queue.Enqueue(job);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleJob(LandblockStreamJob job)
|
||||
{
|
||||
switch (job)
|
||||
|
|
@ -212,6 +274,19 @@ public sealed class LandblockStreamer : IDisposable
|
|||
load.LandblockId, "LandblockLoader.Load returned null"));
|
||||
break;
|
||||
}
|
||||
if (load.Kind == LandblockStreamJobKind.PromoteToNear)
|
||||
{
|
||||
var promotedMesh = _buildMeshOrNull(load.LandblockId, lb);
|
||||
if (promotedMesh is null)
|
||||
{
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||
load.LandblockId, "buildMeshOrNull returned null"));
|
||||
break;
|
||||
}
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted(
|
||||
load.LandblockId, lb, promotedMesh));
|
||||
break;
|
||||
}
|
||||
var mesh = _buildMeshOrNull(load.LandblockId, lb);
|
||||
if (mesh is null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -103,16 +103,16 @@ public sealed class StreamingController
|
|||
{
|
||||
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||||
var bootstrap = _region.ComputeFirstTickDiff();
|
||||
foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||
foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||||
foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||
_region.MarkResidentFromBootstrap();
|
||||
}
|
||||
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
|
||||
{
|
||||
var diff = _region.RecenterTo(observerCx, observerCy);
|
||||
foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||
foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||||
foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
|
||||
foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||||
foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
|
||||
foreach (var id in diff.ToUnload) _enqueueUnload(id);
|
||||
}
|
||||
|
|
@ -129,6 +129,7 @@ public sealed class StreamingController
|
|||
_state.AddLandblock(loaded.Landblock);
|
||||
break;
|
||||
case LandblockStreamResult.Promoted promoted:
|
||||
_applyTerrain(promoted.Landblock, promoted.MeshData);
|
||||
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
|
||||
break;
|
||||
case LandblockStreamResult.Unloaded unloaded:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.World;
|
||||
|
||||
|
|
@ -83,6 +84,7 @@ public static class LandblockLoader
|
|||
Rotation = building.Frame.Orientation,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
IsBuildingShell = true, // Phase A8: tag at source array boundary
|
||||
BuildingShellAnchorCellId = FirstBuildingAnchorCellId(building, landblockId),
|
||||
};
|
||||
buildingEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction
|
||||
result.Add(buildingEntity);
|
||||
|
|
@ -96,4 +98,20 @@ public static class LandblockLoader
|
|||
var type = id & TypeMask;
|
||||
return type == GfxObjMask || type == SetupMask;
|
||||
}
|
||||
|
||||
private static uint? FirstBuildingAnchorCellId(BuildingInfo building, uint landblockId)
|
||||
{
|
||||
if (landblockId == 0)
|
||||
return null;
|
||||
|
||||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||||
foreach (var portal in building.Portals)
|
||||
{
|
||||
if (portal.OtherCellId == 0xFFFF)
|
||||
continue;
|
||||
return lbPrefix | (uint)portal.OtherCellId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,13 +56,24 @@ public sealed class WorldEntity
|
|||
///
|
||||
/// <para>
|
||||
/// Read at draw time by <c>WbDrawDispatcher</c>'s <c>IndoorPass</c>
|
||||
/// partition so building shells render unconditionally when the camera
|
||||
/// is inside their building (they ARE the indoor walls), not stencil-gated
|
||||
/// as outdoor scenery would be.
|
||||
/// partition so building shells can render when the camera is inside their
|
||||
/// own building (they ARE the indoor walls), not stencil-gated as outdoor
|
||||
/// scenery would be.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool IsBuildingShell { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Dat-derived EnvCell anchor for a building shell. Building shells are
|
||||
/// top-level landblock stabs, so they do not have a real ParentCellId, but
|
||||
/// the LandBlockInfo.Buildings[] portal list names cells owned by the same
|
||||
/// building. The indoor renderer uses this anchor only for draw scoping:
|
||||
/// a shell renders in IndoorPass when its anchor belongs to the camera
|
||||
/// building's EnvCell set. Collision still treats the shell as an outdoor
|
||||
/// stab unless ParentCellId is explicitly set.
|
||||
/// </summary>
|
||||
public uint? BuildingShellAnchorCellId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Uniform scale applied to this entity's mesh by the scenery pipeline.
|
||||
/// For scenery objects this is spawn.Scale (typically 0.8–1.3). For stabs
|
||||
|
|
|
|||
|
|
@ -61,6 +61,87 @@ public class IndoorCellStencilPipelineTests
|
|||
Assert.Equal(new Vector3(1, 1, 0), verts[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTriangles_OnlyIncludesProvidedVisibleCells()
|
||||
{
|
||||
// The render path now feeds BuildTriangles from the portal traversal's
|
||||
// visible cells, not every cell in the building. A hidden room's exit
|
||||
// portal must not punch outdoor terrain into the current view.
|
||||
var visibleInnerCell = new LoadedCell
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
Portals = new() { new CellPortalInfo(0x0102, 100, 0) },
|
||||
ClipPlanes = new() { default },
|
||||
PortalPolygons = new()
|
||||
{
|
||||
new[]
|
||||
{
|
||||
new Vector3(0, 0, 0),
|
||||
new Vector3(1, 0, 0),
|
||||
new Vector3(1, 1, 0),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var hiddenExitCell = new LoadedCell
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
Portals = new() { new CellPortalInfo(0xFFFF, 101, 0) },
|
||||
ClipPlanes = new() { default },
|
||||
PortalPolygons = new()
|
||||
{
|
||||
new[]
|
||||
{
|
||||
new Vector3(10, 0, 0),
|
||||
new Vector3(11, 0, 0),
|
||||
new Vector3(11, 1, 0),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var visibleOnly = PortalMeshBuilder.BuildTriangles(new List<LoadedCell> { visibleInnerCell });
|
||||
var allBuildingCells = PortalMeshBuilder.BuildTriangles(new List<LoadedCell> { visibleInnerCell, hiddenExitCell });
|
||||
|
||||
Assert.Empty(visibleOnly);
|
||||
Assert.Equal(3, allBuildingCells.Length);
|
||||
Assert.Equal(new Vector3(10, 0, 0), allBuildingCells[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTriangles_CameraSideFilterSkipsExitPortalsBehindCamera()
|
||||
{
|
||||
var cell = new LoadedCell
|
||||
{
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = new() { new CellPortalInfo(0xFFFF, 100, 0) },
|
||||
ClipPlanes = new()
|
||||
{
|
||||
new PortalClipPlane
|
||||
{
|
||||
Normal = Vector3.UnitX,
|
||||
D = 0f,
|
||||
InsideSide = 0,
|
||||
},
|
||||
},
|
||||
PortalPolygons = new()
|
||||
{
|
||||
new[]
|
||||
{
|
||||
new Vector3(0, 0, 0),
|
||||
new Vector3(0, 1, 0),
|
||||
new Vector3(0, 0, 1),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var visible = PortalMeshBuilder.BuildTriangles(new List<LoadedCell> { cell }, new Vector3(1, 0, 0));
|
||||
var rejected = PortalMeshBuilder.BuildTriangles(new List<LoadedCell> { cell }, new Vector3(-1, 0, 0));
|
||||
|
||||
Assert.Equal(3, visible.Length);
|
||||
Assert.Empty(rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTriangles_TriangulatesAsFan()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -127,4 +127,43 @@ public class BuildingLoaderTests
|
|||
Assert.Equal(b.BuildingId, cell150.BuildingId);
|
||||
Assert.Equal(b.BuildingId, cell151.BuildingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ComputesExitPortalBounds()
|
||||
{
|
||||
var cell150 = new AcDream.App.Rendering.LoadedCell
|
||||
{
|
||||
CellId = 0xA9B40150u,
|
||||
Portals = new List<AcDream.App.Rendering.CellPortalInfo>
|
||||
{
|
||||
new(0xFFFF, 0, 0),
|
||||
},
|
||||
PortalPolygons = new List<Vector3[]>
|
||||
{
|
||||
new[]
|
||||
{
|
||||
new Vector3(-1, 2, 3),
|
||||
new Vector3(4, 5, 6),
|
||||
new Vector3(7, -8, 9),
|
||||
},
|
||||
},
|
||||
WorldTransform = Matrix4x4.CreateTranslation(10, 20, 30),
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
LocalBoundsMin = new Vector3(-5, -5, -5),
|
||||
LocalBoundsMax = new Vector3(5, 5, 5),
|
||||
ClipPlanes = new List<AcDream.App.Rendering.PortalClipPlane>(),
|
||||
};
|
||||
|
||||
var info = MakeInfo((0x02000123u, new[] { 0x0150u }));
|
||||
var reg = BuildingLoader.Build(info, 0xA9B40000u,
|
||||
new Dictionary<uint, AcDream.App.Rendering.LoadedCell>
|
||||
{
|
||||
{ 0xA9B40150u, cell150 },
|
||||
});
|
||||
|
||||
var b = System.Linq.Enumerable.First(reg.All());
|
||||
Assert.True(b.HasPortalBounds);
|
||||
Assert.Equal(new Vector3(9, 12, 33), b.PortalBounds.Min);
|
||||
Assert.Equal(new Vector3(17, 25, 39), b.PortalBounds.Max);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
// GL context and are visual-verified at the render frame (Task 10).
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -114,6 +116,22 @@ public class EnvCellRendererTests
|
|||
|
||||
// (Render() requires a GL context — visual-verified in Task 10.)
|
||||
|
||||
[Fact]
|
||||
public void GpuInstanceUpload_UsesMeshModernMat4Stride()
|
||||
{
|
||||
// mesh_modern.vert declares SSBO InstanceData as exactly one mat4,
|
||||
// so the GPU array stride is 64 bytes. EnvCellRenderer's CPU
|
||||
// InstanceData also carries CellId/Flags for culling/filtering and
|
||||
// is 80 bytes; uploading that struct corrupts every instance after 0.
|
||||
Assert.Equal(64, Marshal.SizeOf<Matrix4x4>());
|
||||
Assert.Equal(80, Marshal.SizeOf<InstanceData>());
|
||||
|
||||
var field = typeof(EnvCellRenderer).GetField("_gpuInstanceTransforms",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
Assert.NotNull(field);
|
||||
Assert.Equal(typeof(Matrix4x4[]), field!.FieldType);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pool-aliasing regression tests (2026-05-28 audit findings).
|
||||
//
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ public sealed class RuntimeOptionsTests
|
|||
// Default-on: RetailCloseDegrades is true unless explicitly disabled.
|
||||
Assert.True(opts.RetailCloseDegrades);
|
||||
Assert.False(opts.DumpSceneryZ);
|
||||
Assert.False(opts.A8DiagDisableInsideStep4Terrain);
|
||||
Assert.False(opts.A8DiagDisableInsideStep4Outdoor);
|
||||
Assert.False(opts.A8DiagDisableInsideStep3EnvCellOpaque);
|
||||
Assert.False(opts.A8DiagDisableInsideStep3IndoorPass);
|
||||
Assert.False(opts.A8DiagDisableInsideStep2Punch);
|
||||
Assert.False(opts.A8DiagDisablePortalDepthClamp);
|
||||
Assert.Null(opts.LegacyStreamRadius);
|
||||
Assert.False(opts.HasLiveCredentials);
|
||||
}
|
||||
|
|
@ -184,12 +190,24 @@ public sealed class RuntimeOptionsTests
|
|||
["ACDREAM_NO_AUDIO"] = "1",
|
||||
["ACDREAM_ENABLE_SKY_PES"] = "1",
|
||||
["ACDREAM_DUMP_SCENERY_Z"] = "1",
|
||||
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_TERRAIN"] = "1",
|
||||
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_OUTDOOR"] = "1",
|
||||
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_ENVCELL_OPAQUE"] = "1",
|
||||
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_INDOORPASS"] = "1",
|
||||
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP2_PUNCH"] = "1",
|
||||
["ACDREAM_A8_DIAG_DISABLE_PORTAL_DEPTH_CLAMP"] = "1",
|
||||
}));
|
||||
Assert.True(allOn.DevTools);
|
||||
Assert.True(allOn.DumpMoveTruth);
|
||||
Assert.True(allOn.NoAudio);
|
||||
Assert.True(allOn.EnableSkyPesDebug);
|
||||
Assert.True(allOn.DumpSceneryZ);
|
||||
Assert.True(allOn.A8DiagDisableInsideStep4Terrain);
|
||||
Assert.True(allOn.A8DiagDisableInsideStep4Outdoor);
|
||||
Assert.True(allOn.A8DiagDisableInsideStep3EnvCellOpaque);
|
||||
Assert.True(allOn.A8DiagDisableInsideStep3IndoorPass);
|
||||
Assert.True(allOn.A8DiagDisableInsideStep2Punch);
|
||||
Assert.True(allOn.A8DiagDisablePortalDepthClamp);
|
||||
|
||||
// Any non-"1" value leaves them off, matching the
|
||||
// string.Equals(env, "1", StringComparison.Ordinal) check.
|
||||
|
|
@ -200,12 +218,24 @@ public sealed class RuntimeOptionsTests
|
|||
["ACDREAM_NO_AUDIO"] = "2",
|
||||
["ACDREAM_ENABLE_SKY_PES"] = "on",
|
||||
["ACDREAM_DUMP_SCENERY_Z"] = " 1",
|
||||
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_TERRAIN"] = "true",
|
||||
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_OUTDOOR"] = "yes",
|
||||
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_ENVCELL_OPAQUE"] = "2",
|
||||
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_INDOORPASS"] = " 1",
|
||||
["ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP2_PUNCH"] = "true",
|
||||
["ACDREAM_A8_DIAG_DISABLE_PORTAL_DEPTH_CLAMP"] = "yes",
|
||||
}));
|
||||
Assert.False(anyOther.DevTools);
|
||||
Assert.False(anyOther.DumpMoveTruth);
|
||||
Assert.False(anyOther.NoAudio);
|
||||
Assert.False(anyOther.EnableSkyPesDebug);
|
||||
Assert.False(anyOther.DumpSceneryZ);
|
||||
Assert.False(anyOther.A8DiagDisableInsideStep4Terrain);
|
||||
Assert.False(anyOther.A8DiagDisableInsideStep4Outdoor);
|
||||
Assert.False(anyOther.A8DiagDisableInsideStep3EnvCellOpaque);
|
||||
Assert.False(anyOther.A8DiagDisableInsideStep3IndoorPass);
|
||||
Assert.False(anyOther.A8DiagDisableInsideStep2Punch);
|
||||
Assert.False(anyOther.A8DiagDisablePortalDepthClamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -101,8 +101,7 @@ public sealed class LandblockSpawnAdapterTests
|
|||
public void OnLandblockLoaded_SameLandblockTwice_DedupesAtTheLandblockLevel()
|
||||
{
|
||||
// If a landblock load fires twice (e.g. a streaming-controller bug),
|
||||
// we should not double-register. Second load is treated as a no-op
|
||||
// for ref-counting purposes.
|
||||
// we should not double-register ids that were already seen.
|
||||
var captured = new CapturingAdapterMock();
|
||||
var adapter = new LandblockSpawnAdapter(captured);
|
||||
|
||||
|
|
@ -118,6 +117,49 @@ public sealed class LandblockSpawnAdapterTests
|
|||
Assert.Single(captured.IncrementCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnLandblockLoaded_FarEmptyThenNearPromotion_RegistersPromotedIds()
|
||||
{
|
||||
var captured = new CapturingAdapterMock();
|
||||
var adapter = new LandblockSpawnAdapter(captured);
|
||||
|
||||
var far = MakeLandblock(
|
||||
landblockId: 0x12340000u,
|
||||
entities: System.Array.Empty<WorldEntity>());
|
||||
var promoted = MakeLandblock(landblockId: 0x12340000u, entities: new[]
|
||||
{
|
||||
MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u, 0x01000020u }),
|
||||
});
|
||||
|
||||
adapter.OnLandblockLoaded(far);
|
||||
adapter.OnLandblockLoaded(promoted);
|
||||
|
||||
Assert.Equal(2, captured.IncrementCalls.Count);
|
||||
Assert.Contains(0x01000010ul, captured.IncrementCalls);
|
||||
Assert.Contains(0x01000020ul, captured.IncrementCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnLandblockLoaded_PromotionWithPartiallyRegisteredIds_RegistersOnlyNewIds()
|
||||
{
|
||||
var captured = new CapturingAdapterMock();
|
||||
var adapter = new LandblockSpawnAdapter(captured);
|
||||
|
||||
var initial = MakeLandblock(landblockId: 0x12340000u, entities: new[]
|
||||
{
|
||||
MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u }),
|
||||
});
|
||||
var promoted = MakeLandblock(landblockId: 0x12340000u, entities: new[]
|
||||
{
|
||||
MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u, 0x01000020u }),
|
||||
});
|
||||
|
||||
adapter.OnLandblockLoaded(initial);
|
||||
adapter.OnLandblockLoaded(promoted);
|
||||
|
||||
Assert.Equal(new[] { 0x01000010ul, 0x01000020ul }, captured.IncrementCalls);
|
||||
}
|
||||
|
||||
// ── Test helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private sealed class CapturingAdapterMock : IWbMeshAdapter
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
// the pure-data companion to the new Draw(cellIds:) production overload.
|
||||
//
|
||||
// Semantics: indoor entities (ParentCellId.HasValue) are gated by explicit
|
||||
// membership in cellIds. Building shells (IsBuildingShell) always pass.
|
||||
// membership in cellIds. Building shells (IsBuildingShell) pass only when their
|
||||
// BuildingShellAnchorCellId belongs to the same cell set.
|
||||
// Outdoor scenery (no ParentCellId, not a shell) is excluded by EntitySet.IndoorPass.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
|
@ -36,12 +37,13 @@ public class WbDrawDispatcherCellIdsOverloadTests
|
|||
Rotation = Quaternion.Identity,
|
||||
};
|
||||
|
||||
private static WorldEntity BuildingShell(uint id) => new()
|
||||
private static WorldEntity BuildingShell(uint id, uint? anchorCellId) => new()
|
||||
{
|
||||
Id = id,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
ParentCellId = null,
|
||||
IsBuildingShell = true,
|
||||
BuildingShellAnchorCellId = anchorCellId,
|
||||
MeshRefs = new List<MeshRef> { new(0x01000001u, Matrix4x4.Identity) },
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
|
|
@ -55,7 +57,8 @@ public class WbDrawDispatcherCellIdsOverloadTests
|
|||
CellEnt(0x40000001u, 0xA9B40150u), // in listed cells
|
||||
CellEnt(0x40000002u, 0xA9B40151u), // in listed cells
|
||||
CellEnt(0x40000003u, 0xA9B40999u), // OUT — not in list
|
||||
BuildingShell(0xC0000001u), // always included (IsBuildingShell)
|
||||
BuildingShell(0xC0000001u, 0xA9B40150u), // in listed building cells
|
||||
BuildingShell(0xC0000003u, 0xA9B40999u), // OUT — another building shell
|
||||
OutdoorScenery(0xC0000002u), // OUT — not a shell, not in cell list
|
||||
};
|
||||
var cellIds = new HashSet<uint> { 0xA9B40150u, 0xA9B40151u };
|
||||
|
|
@ -68,23 +71,22 @@ public class WbDrawDispatcherCellIdsOverloadTests
|
|||
Assert.Contains(0x40000002u, result);
|
||||
Assert.Contains(0xC0000001u, result);
|
||||
Assert.DoesNotContain(0x40000003u, result);
|
||||
Assert.DoesNotContain(0xC0000003u, result);
|
||||
Assert.DoesNotContain(0xC0000002u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WalkEntitiesByCellIds_EmptyCellList_StillIncludesBuildingShells()
|
||||
public void WalkEntitiesByCellIds_EmptyCellList_ExcludesBuildingShells()
|
||||
{
|
||||
var entities = new List<WorldEntity>
|
||||
{
|
||||
CellEnt(0x40000001u, 0xA9B40150u),
|
||||
BuildingShell(0xC0000001u),
|
||||
BuildingShell(0xC0000001u, 0xA9B40150u),
|
||||
};
|
||||
|
||||
var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds(
|
||||
entities, new HashSet<uint>(), set: WbDrawDispatcher.EntitySet.IndoorPass);
|
||||
|
||||
// Cell entity dropped (no cells in list); building shell still passes.
|
||||
Assert.Single(result);
|
||||
Assert.Contains(0xC0000001u, result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@
|
|||
//
|
||||
// EntitySet.IndoorPass — ParentCellId.HasValue OR IsBuildingShell,
|
||||
// and NOT live-dynamic (ServerGuid == 0).
|
||||
// Building shells render unconditionally indoors;
|
||||
// Building shells are gated by their dat anchor;
|
||||
// live-dynamic flows through LiveDynamic instead.
|
||||
// EntitySet.OutdoorScenery — ParentCellId == null AND !IsBuildingShell
|
||||
// AND not live-dynamic.
|
||||
// EntitySet.OutdoorScenery — ParentCellId == null AND not live-dynamic.
|
||||
// Includes building shells for exterior/depth repair passes.
|
||||
// EntitySet.BuildingShells — IsBuildingShell only, gated by dat anchor when
|
||||
// visibleCellIds are supplied.
|
||||
// EntitySet.LiveDynamic — ServerGuid != 0 (player, NPCs, dropped items,
|
||||
// idle doors after animation). Drawn last with
|
||||
// stencil disabled.
|
||||
|
|
@ -46,12 +48,13 @@ public class WbDrawDispatcherEntitySetTests
|
|||
Rotation = Quaternion.Identity,
|
||||
};
|
||||
|
||||
private static WorldEntity BuildingShell(uint id) => new()
|
||||
private static WorldEntity BuildingShell(uint id, uint? anchorCellId = null) => new()
|
||||
{
|
||||
Id = id,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
ParentCellId = null,
|
||||
IsBuildingShell = true,
|
||||
BuildingShellAnchorCellId = anchorCellId,
|
||||
MeshRefs = new List<MeshRef> { new() { GfxObjId = 0x01000001u } },
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
|
|
@ -90,11 +93,11 @@ public class WbDrawDispatcherEntitySetTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void IndoorPass_IncludesBuildingShells_EvenWithNullParentCellId()
|
||||
public void IndoorPass_IncludesBuildingShells_WhenAnchorCellIsVisible()
|
||||
{
|
||||
var entities = new List<WorldEntity>
|
||||
{
|
||||
BuildingShell(0xC0000001), // cottage wall
|
||||
BuildingShell(0xC0000001, 0xA9B40143u), // cottage wall
|
||||
OutdoorScenery(0xC0000002), // tree
|
||||
CellEnt(0x40000001, 0xA9B40143),
|
||||
};
|
||||
|
|
@ -109,6 +112,47 @@ public class WbDrawDispatcherEntitySetTests
|
|||
Assert.DoesNotContain(0xC0000002u, result); // tree excluded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndoorPass_WithNullCellFilter_UsesEntitySetOnly()
|
||||
{
|
||||
var entities = new List<WorldEntity>
|
||||
{
|
||||
BuildingShell(0xC0000001, 0xA9B40143u),
|
||||
CellEnt(0x40000001, 0xA9B40143),
|
||||
CellEnt(0x40000002, 0xA9B40199),
|
||||
OutdoorScenery(0xC0000002),
|
||||
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
|
||||
};
|
||||
|
||||
var result = WbDrawDispatcher.WalkEntitiesForTest(
|
||||
entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.IndoorPass);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Contains(0xC0000001u, result);
|
||||
Assert.Contains(0x40000001u, result);
|
||||
Assert.Contains(0x40000002u, result);
|
||||
Assert.DoesNotContain(0xC0000002u, result);
|
||||
Assert.DoesNotContain(0x10000001u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndoorPass_ExcludesBuildingShells_WhenAnchorCellIsNotVisible()
|
||||
{
|
||||
var entities = new List<WorldEntity>
|
||||
{
|
||||
BuildingShell(0xC0000001, 0xA9B40150u),
|
||||
CellEnt(0x40000001, 0xA9B40143),
|
||||
};
|
||||
|
||||
var visible = new HashSet<uint> { 0xA9B40143u };
|
||||
var result = WbDrawDispatcher.WalkEntitiesForTest(
|
||||
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Contains(0x40000001u, result);
|
||||
Assert.DoesNotContain(0xC0000001u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndoorPass_ExcludesLiveDynamic()
|
||||
{
|
||||
|
|
@ -128,11 +172,11 @@ public class WbDrawDispatcherEntitySetTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void OutdoorScenery_ExcludesBuildingShells()
|
||||
public void OutdoorScenery_IncludesBuildingShells()
|
||||
{
|
||||
var entities = new List<WorldEntity>
|
||||
{
|
||||
BuildingShell(0xC0000001), // cottage wall — excluded
|
||||
BuildingShell(0xC0000001), // cottage wall — included
|
||||
OutdoorScenery(0xC0000002), // tree — included
|
||||
CellEnt(0x40000001, 0xA9B40143), // cell — excluded
|
||||
};
|
||||
|
|
@ -140,9 +184,9 @@ public class WbDrawDispatcherEntitySetTests
|
|||
var result = WbDrawDispatcher.WalkEntitiesForTest(
|
||||
entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.OutdoorScenery);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(0xC0000002u, result);
|
||||
Assert.DoesNotContain(0xC0000001u, result);
|
||||
Assert.Contains(0xC0000001u, result);
|
||||
Assert.DoesNotContain(0x40000001u, result);
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +207,30 @@ public class WbDrawDispatcherEntitySetTests
|
|||
Assert.DoesNotContain(0x10000001u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildingShells_IncludesOnlyAnchoredShells()
|
||||
{
|
||||
var entities = new List<WorldEntity>
|
||||
{
|
||||
BuildingShell(0xC0000001, 0xA9B40143u),
|
||||
BuildingShell(0xC0000002, 0xA9B40999u),
|
||||
OutdoorScenery(0xC0000003),
|
||||
CellEnt(0x40000001, 0xA9B40143),
|
||||
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
|
||||
};
|
||||
|
||||
var visible = new HashSet<uint> { 0xA9B40143u };
|
||||
var result = WbDrawDispatcher.WalkEntitiesForTest(
|
||||
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.BuildingShells);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Contains(0xC0000001u, result);
|
||||
Assert.DoesNotContain(0xC0000002u, result);
|
||||
Assert.DoesNotContain(0xC0000003u, result);
|
||||
Assert.DoesNotContain(0x40000001u, result);
|
||||
Assert.DoesNotContain(0x10000001u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveDynamic_IncludesOnlyServerSpawned()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Meshing;
|
||||
using DatReaderWriter.Enums;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
|
@ -26,9 +27,10 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
|
|||
|
||||
var indirect = new DrawElementsIndirectCommand[16];
|
||||
var batch = new WbDrawDispatcher.BatchDataPublic[16];
|
||||
var cull = new CullMode[16];
|
||||
|
||||
// Act
|
||||
var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch);
|
||||
var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch, cull);
|
||||
|
||||
// Assert layout
|
||||
Assert.Equal(2, result.OpaqueCount);
|
||||
|
|
@ -58,6 +60,36 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
|
|||
Assert.Equal(0xAAul, batch[0].TextureHandle);
|
||||
Assert.Equal(0xCCul, batch[1].TextureHandle);
|
||||
Assert.Equal(0xBBul, batch[2].TextureHandle);
|
||||
Assert.Equal(CullMode.CounterClockwise, cull[0]);
|
||||
Assert.Equal(CullMode.CounterClockwise, cull[1]);
|
||||
Assert.Equal(CullMode.CounterClockwise, cull[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CullModes_FollowOpaqueTransparentLayout()
|
||||
{
|
||||
var groups = new List<WbDrawDispatcher.IndirectGroupInput>
|
||||
{
|
||||
new(IndexCount: 10, FirstIndex: 0, BaseVertex: 0, InstanceCount: 1, FirstInstance: 0,
|
||||
TextureHandle: 0x1, TextureLayer: 0, Translucency: TranslucencyKind.Opaque,
|
||||
CullMode: CullMode.Clockwise),
|
||||
new(IndexCount: 20, FirstIndex: 10, BaseVertex: 0, InstanceCount: 1, FirstInstance: 1,
|
||||
TextureHandle: 0x2, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend,
|
||||
CullMode: CullMode.None),
|
||||
new(IndexCount: 30, FirstIndex: 30, BaseVertex: 0, InstanceCount: 1, FirstInstance: 2,
|
||||
TextureHandle: 0x3, TextureLayer: 0, Translucency: TranslucencyKind.ClipMap,
|
||||
CullMode: CullMode.Landblock),
|
||||
};
|
||||
var indirect = new DrawElementsIndirectCommand[4];
|
||||
var batch = new WbDrawDispatcher.BatchDataPublic[4];
|
||||
var cull = new CullMode[4];
|
||||
|
||||
var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch, cull);
|
||||
|
||||
Assert.Equal(2, result.OpaqueCount);
|
||||
Assert.Equal(CullMode.Clockwise, cull[0]);
|
||||
Assert.Equal(CullMode.Landblock, cull[1]);
|
||||
Assert.Equal(CullMode.None, cull[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -74,6 +74,36 @@ public class GpuWorldStateTwoTierTests
|
|||
Assert.Equal(2, state.Entities.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LandblockEntriesWithoutAnimatedIndex_LeavesAnimatedLookupNull()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(MakeStubLandblock(0xAAAAFFFFu,
|
||||
MakeStubEntity(1),
|
||||
MakeStubEntity(2)));
|
||||
|
||||
var staticEntry = Assert.Single(state.LandblockEntriesWithoutAnimatedIndex);
|
||||
Assert.Null(staticEntry.AnimatedById);
|
||||
|
||||
var liveEntry = Assert.Single(state.LandblockEntries);
|
||||
Assert.NotNull(liveEntry.AnimatedById);
|
||||
Assert.True(liveEntry.AnimatedById!.ContainsKey(1));
|
||||
Assert.True(liveEntry.AnimatedById.ContainsKey(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LandblockBounds_EnumeratesWithoutEntityPayload()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
state.AddLandblock(MakeStubLandblock(0xAAAAFFFFu,
|
||||
MakeStubEntity(1),
|
||||
MakeStubEntity(2)));
|
||||
|
||||
var bounds = Assert.Single(state.LandblockBounds);
|
||||
|
||||
Assert.Equal(0xAAAAFFFFu, bounds.LandblockId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase Post-A.5 #53 (Task 12): the optional <c>onLandblockUnloaded</c>
|
||||
/// callback fires once when <see cref="GpuWorldState.RemoveEntitiesFromLandblock"/>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,97 @@ public class LandblockStreamerTests
|
|||
Assert.Same(stubLandblock, loaded.Landblock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadNear_OvertakesQueuedFarLoads()
|
||||
{
|
||||
var callOrder = new System.Collections.Generic.List<(uint Id, LandblockStreamJobKind Kind)>();
|
||||
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
|
||||
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||
System.Array.Empty<uint>());
|
||||
|
||||
using var streamer = new LandblockStreamer(
|
||||
loadLandblock: (id, kind) =>
|
||||
{
|
||||
callOrder.Add((id, kind));
|
||||
return new LoadedLandblock(id, new LandBlock(), System.Array.Empty<WorldEntity>());
|
||||
},
|
||||
buildMeshOrNull: (_, _) => stubMesh);
|
||||
|
||||
streamer.EnqueueLoad(0xAAAAFFFFu, LandblockStreamJobKind.LoadFar);
|
||||
streamer.EnqueueLoad(0xBBBBFFFFu, LandblockStreamJobKind.LoadFar);
|
||||
streamer.EnqueueLoad(0xCCCCFFFFu, LandblockStreamJobKind.LoadFar);
|
||||
streamer.EnqueueLoad(0xDDDDFFFFu, LandblockStreamJobKind.LoadNear);
|
||||
streamer.Start();
|
||||
|
||||
var result = await DrainFirstAsync(streamer);
|
||||
|
||||
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(result);
|
||||
Assert.Equal(0xDDDDFFFFu, loaded.LandblockId);
|
||||
Assert.Equal((0xDDDDFFFFu, LandblockStreamJobKind.LoadNear), callOrder[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PromoteToNear_ProducesPromotedWithMeshData()
|
||||
{
|
||||
int meshBuildCalls = 0;
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 7,
|
||||
SourceGfxObjOrSetupId = 0,
|
||||
Position = System.Numerics.Vector3.Zero,
|
||||
Rotation = System.Numerics.Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>()
|
||||
};
|
||||
|
||||
using var streamer = new LandblockStreamer(
|
||||
loadLandblock: (id, kind) => new LoadedLandblock(id, new LandBlock(), new[] { entity }),
|
||||
buildMeshOrNull: (_, _) =>
|
||||
{
|
||||
meshBuildCalls++;
|
||||
return new AcDream.Core.Terrain.LandblockMeshData(
|
||||
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||
System.Array.Empty<uint>());
|
||||
});
|
||||
|
||||
streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear);
|
||||
streamer.Start();
|
||||
|
||||
var result = await DrainFirstAsync(streamer);
|
||||
|
||||
var promoted = Assert.IsType<LandblockStreamResult.Promoted>(result);
|
||||
Assert.Equal(0xA9B4FFFFu, promoted.LandblockId);
|
||||
Assert.Same(entity, promoted.Entities[0]);
|
||||
Assert.NotNull(promoted.MeshData);
|
||||
Assert.Equal(1, meshBuildCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PromoteToNear_OvertakesAndSupersedesQueuedFarLoadForSameLandblock()
|
||||
{
|
||||
var callOrder = new System.Collections.Generic.List<(uint Id, LandblockStreamJobKind Kind)>();
|
||||
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
|
||||
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||
System.Array.Empty<uint>());
|
||||
|
||||
using var streamer = new LandblockStreamer(
|
||||
loadLandblock: (id, kind) =>
|
||||
{
|
||||
callOrder.Add((id, kind));
|
||||
return new LoadedLandblock(id, new LandBlock(), System.Array.Empty<WorldEntity>());
|
||||
},
|
||||
buildMeshOrNull: (_, _) => stubMesh);
|
||||
|
||||
streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.LoadFar);
|
||||
streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear);
|
||||
streamer.Start();
|
||||
|
||||
var result = await DrainFirstAsync(streamer);
|
||||
|
||||
var promoted = Assert.IsType<LandblockStreamResult.Promoted>(result);
|
||||
Assert.Equal(0xA9B4FFFFu, promoted.LandblockId);
|
||||
Assert.Equal((0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear), callOrder[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Load_WhenLoaderReturnsNull_ReportsFailed()
|
||||
{
|
||||
|
|
@ -183,4 +274,16 @@ public class LandblockStreamerTests
|
|||
Assert.NotNull(loaderThreadId);
|
||||
Assert.NotEqual(testThreadId, loaderThreadId.Value);
|
||||
}
|
||||
|
||||
private static async Task<LandblockStreamResult> DrainFirstAsync(LandblockStreamer streamer)
|
||||
{
|
||||
for (int i = 0; i < SpinMaxIterations; i++)
|
||||
{
|
||||
var drained = streamer.DrainCompletions(maxBatchSize: LandblockStreamer.DefaultDrainBatchSize);
|
||||
if (drained.Count > 0) return drained[0];
|
||||
await Task.Delay(SpinStepMs);
|
||||
}
|
||||
|
||||
throw new Xunit.Sdk.XunitException("Timed out waiting for streamer completion.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,16 +99,25 @@ public class StreamingControllerTwoTierTests
|
|||
state.AddLandblock(lb);
|
||||
Assert.Empty(state.Entities);
|
||||
|
||||
// Streamer pushes a Promoted result carrying the entity layer.
|
||||
var promoted = new LandblockStreamResult.Promoted(
|
||||
// Streamer pushes a Promoted result carrying the full near landblock.
|
||||
var promotedLb = new LoadedLandblock(
|
||||
lbId,
|
||||
new[] { new WorldEntity {
|
||||
Heightmap: null!,
|
||||
Entities: new[] { new WorldEntity {
|
||||
Id = 7, SourceGfxObjOrSetupId = 0,
|
||||
Position = System.Numerics.Vector3.Zero,
|
||||
Rotation = System.Numerics.Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>() } });
|
||||
var promotedMesh = new LandblockMeshData(
|
||||
System.Array.Empty<TerrainVertex>(),
|
||||
System.Array.Empty<uint>());
|
||||
var promoted = new LandblockStreamResult.Promoted(
|
||||
lbId,
|
||||
promotedLb,
|
||||
promotedMesh);
|
||||
var queue = new Queue<LandblockStreamResult>();
|
||||
queue.Enqueue(promoted);
|
||||
int applyPromotedCount = 0;
|
||||
|
||||
var ctrl = new StreamingController(
|
||||
enqueueLoad: (id, kind) => loads.Add((id, kind)),
|
||||
|
|
@ -119,7 +128,12 @@ public class StreamingControllerTwoTierTests
|
|||
while (batch.Count < max && queue.Count > 0) batch.Add(queue.Dequeue());
|
||||
return batch;
|
||||
},
|
||||
applyTerrain: (_, _) => { },
|
||||
applyTerrain: (appliedLb, appliedMesh) =>
|
||||
{
|
||||
applyPromotedCount++;
|
||||
Assert.Same(promotedLb, appliedLb);
|
||||
Assert.Same(promotedMesh, appliedMesh);
|
||||
},
|
||||
state: state,
|
||||
nearRadius: 2,
|
||||
farRadius: 2);
|
||||
|
|
@ -128,6 +142,7 @@ public class StreamingControllerTwoTierTests
|
|||
|
||||
// Promoted routes to AddEntitiesToExistingLandblock — the entity is now
|
||||
// merged into the existing LB record.
|
||||
Assert.Equal(1, applyPromotedCount);
|
||||
Assert.Equal(1, state.Entities.Count);
|
||||
Assert.Equal(7u, state.Entities[0].Id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,14 +178,24 @@ public class LandblockLoaderTests
|
|||
Origin = new Vector3(10f, 20f, 30f),
|
||||
Orientation = Quaternion.Identity,
|
||||
},
|
||||
Portals =
|
||||
{
|
||||
new BuildingPortal
|
||||
{
|
||||
OtherCellId = 0x013F,
|
||||
OtherPortalId = 0,
|
||||
Flags = 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var entities = LandblockLoader.BuildEntitiesFromInfo(info);
|
||||
var entities = LandblockLoader.BuildEntitiesFromInfo(info, landblockId: 0xA9B40000u);
|
||||
|
||||
Assert.Single(entities);
|
||||
Assert.True(entities[0].IsBuildingShell);
|
||||
Assert.Equal(0xA9B4013Fu, entities[0].BuildingShellAnchorCellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
12
tools/A8CellAudit/A8CellAudit.csproj
Normal file
12
tools/A8CellAudit/A8CellAudit.csproj
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
311
tools/A8CellAudit/Program.cs
Normal file
311
tools/A8CellAudit/Program.cs
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
using System.Numerics;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Options;
|
||||
using SysEnv = System.Environment;
|
||||
|
||||
string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
|
||||
if (!Directory.Exists(datDir))
|
||||
{
|
||||
Console.Error.WriteLine($"DAT directory not found: {datDir}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
if (args.Length > 0 && string.Equals(args[0], "buildings", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
uint landblockId = args.Length > 1 ? ParseHex(args[1]) : 0xA9B40000u;
|
||||
int radius = args.Length > 2 ? int.Parse(args[2], System.Globalization.CultureInfo.InvariantCulture) : 0;
|
||||
DumpBuildings(dats, landblockId, radius);
|
||||
}
|
||||
else if (args.Length > 0 && string.Equals(args[0], "portals", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var ids = args.Length == 1
|
||||
? new uint[] { 0xA9B40171u }
|
||||
: args.Skip(1).Select(ParseHex).ToArray();
|
||||
foreach (var envCellId in ids)
|
||||
DumpCellPortals(dats, envCellId);
|
||||
}
|
||||
else
|
||||
{
|
||||
var ids = args.Length == 0
|
||||
? [0xA9B4013Fu]
|
||||
: args.Select(ParseHex).ToArray();
|
||||
|
||||
foreach (var envCellId in ids)
|
||||
{
|
||||
DumpCell(dats, envCellId);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
static uint ParseHex(string text)
|
||||
{
|
||||
text = text.Trim();
|
||||
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
return Convert.ToUInt32(text[2..], 16);
|
||||
return Convert.ToUInt32(text, 16);
|
||||
}
|
||||
|
||||
static void DumpCell(DatCollection dats, uint envCellId)
|
||||
{
|
||||
Console.WriteLine($"=== EnvCell 0x{envCellId:X8} ===");
|
||||
var envCell = dats.Get<EnvCell>(envCellId);
|
||||
if (envCell is null)
|
||||
{
|
||||
Console.WriteLine("missing EnvCell");
|
||||
return;
|
||||
}
|
||||
|
||||
var envId = 0x0D000000u | envCell.EnvironmentId;
|
||||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(envId);
|
||||
if (environment is null)
|
||||
{
|
||||
Console.WriteLine($"missing Environment 0x{envId:X8}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct))
|
||||
{
|
||||
Console.WriteLine($"missing CellStruct {envCell.CellStructure}");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"environment=0x{envId:X8} cellStruct={envCell.CellStructure} " +
|
||||
$"surfaces={envCell.Surfaces.Count} verts={cellStruct.VertexArray.Vertices.Count} polys={cellStruct.Polygons.Count}");
|
||||
|
||||
int posSides = 0;
|
||||
int negSides = 0;
|
||||
int skipped = 0;
|
||||
int likelyFloor = 0;
|
||||
int likelyCeiling = 0;
|
||||
|
||||
foreach (var (polyId, poly) in cellStruct.Polygons.OrderBy(p => p.Key))
|
||||
{
|
||||
if (poly.VertexIds.Count < 3)
|
||||
continue;
|
||||
|
||||
bool emitPos = !poly.Stippling.HasFlag(StipplingType.NoPos) && poly.PosSurface >= 0 && poly.PosSurface < envCell.Surfaces.Count;
|
||||
bool emitNeg =
|
||||
(poly.Stippling.HasFlag(StipplingType.Negative) ||
|
||||
poly.Stippling.HasFlag(StipplingType.Both) ||
|
||||
(!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise))
|
||||
&& poly.NegSurface >= 0
|
||||
&& poly.NegSurface < envCell.Surfaces.Count;
|
||||
|
||||
if (emitPos) posSides++;
|
||||
if (emitNeg) negSides++;
|
||||
if (!emitPos && !emitNeg) skipped++;
|
||||
|
||||
var normal = ComputeNormal(cellStruct, poly);
|
||||
var uvRange = UvRange(cellStruct, poly);
|
||||
string planeHint = Math.Abs(normal.Z) > 0.9f
|
||||
? normal.Z > 0 ? "floor/up" : "ceiling/down"
|
||||
: Math.Abs(normal.Z) > 0.15f ? "slope" : "wall";
|
||||
|
||||
if (normal.Z > 0.9f) likelyFloor++;
|
||||
if (normal.Z < -0.9f) likelyCeiling++;
|
||||
|
||||
var posSurf = SurfaceText(envCell, poly.PosSurface);
|
||||
var negSurf = SurfaceText(envCell, poly.NegSurface);
|
||||
Console.WriteLine(
|
||||
$"poly=0x{polyId:X4} pts={poly.VertexIds.Count} n=({normal.X:F3},{normal.Y:F3},{normal.Z:F3}) {planeHint,-12} " +
|
||||
$"stip={poly.Stippling} sides={poly.SidesType} pos={poly.PosSurface}->{posSurf} neg={poly.NegSurface}->{negSurf} " +
|
||||
$"emitPos={emitPos} emitNeg={emitNeg} posUv={poly.PosUVIndices?.Count ?? 0} negUv={poly.NegUVIndices?.Count ?? 0} " +
|
||||
$"uv=({uvRange.Min.X:F3},{uvRange.Min.Y:F3})..({uvRange.Max.X:F3},{uvRange.Max.Y:F3})");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"summary: posSides={posSides} negSides={negSides} skipped={skipped} " +
|
||||
$"likelyFloor={likelyFloor} likelyCeiling={likelyCeiling}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static void DumpCellPortals(DatCollection dats, uint envCellId)
|
||||
{
|
||||
Console.WriteLine($"=== EnvCell 0x{envCellId:X8} portals ===");
|
||||
var envCell = dats.Get<EnvCell>(envCellId);
|
||||
if (envCell is null)
|
||||
{
|
||||
Console.WriteLine("missing EnvCell");
|
||||
return;
|
||||
}
|
||||
|
||||
uint lbPrefix = envCellId & 0xFFFF0000u;
|
||||
int exitCount = 0;
|
||||
int interiorCount = 0;
|
||||
for (int i = 0; i < envCell.CellPortals.Count; i++)
|
||||
{
|
||||
var portal = envCell.CellPortals[i];
|
||||
bool isExit = portal.OtherCellId == 0xFFFF;
|
||||
if (isExit) exitCount++; else interiorCount++;
|
||||
string dest = isExit
|
||||
? "EXIT(outdoor)"
|
||||
: $"0x{(lbPrefix | (uint)portal.OtherCellId):X8}";
|
||||
Console.WriteLine(
|
||||
$" portal[{i}] other=0x{portal.OtherCellId:X4} -> {dest} " +
|
||||
$"flags={portal.Flags} polyId={portal.PolygonId}");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"summary: cell=0x{envCellId:X8} portals={envCell.CellPortals.Count} " +
|
||||
$"exits(0xFFFF)={exitCount} interior={interiorCount} " +
|
||||
$"numCellPortals={envCell.CellPortals.Count} seenOutside={(envCell.Flags.HasFlag(EnvCellFlags.SeenOutside) ? "Y" : "n")}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static void DumpBuildings(DatCollection dats, uint centerLandblockId, int radius)
|
||||
{
|
||||
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
|
||||
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
|
||||
int totalRegistryBuildings = 0;
|
||||
int totalShellEntities = 0;
|
||||
|
||||
for (int dy = -radius; dy <= radius; dy++)
|
||||
for (int dx = -radius; dx <= radius; dx++)
|
||||
{
|
||||
int x = centerX + dx;
|
||||
int y = centerY + dy;
|
||||
if (x < 0 || x > 255 || y < 0 || y > 255)
|
||||
continue;
|
||||
|
||||
uint landblockId = ((uint)x << 24) | ((uint)y << 16);
|
||||
var info = dats.Get<LandBlockInfo>(landblockId | 0xFFFEu);
|
||||
if (info is null)
|
||||
continue;
|
||||
|
||||
var (registryBuildings, shellEntities) = DumpLandblockBuildings(info, landblockId);
|
||||
totalRegistryBuildings += registryBuildings;
|
||||
totalShellEntities += shellEntities;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"radius-summary center=0x{(centerLandblockId & 0xFFFF0000u):X8} radius={radius} " +
|
||||
$"registryBuildings={totalRegistryBuildings} shellEntities={totalShellEntities}");
|
||||
}
|
||||
|
||||
static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlockInfo info, uint landblockId)
|
||||
{
|
||||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||||
uint stabIdBase = 0xC0000000u
|
||||
| (((landblockId >> 24) & 0xFFu) << 16)
|
||||
| (((landblockId >> 16) & 0xFFu) << 8);
|
||||
uint nextEntityId = stabIdBase + 1u;
|
||||
|
||||
int supportedObjects = 0;
|
||||
foreach (var obj in info.Objects)
|
||||
{
|
||||
if (!IsSupported(obj.Id))
|
||||
continue;
|
||||
supportedObjects++;
|
||||
nextEntityId++;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"=== Landblock 0x{lbPrefix:X8} info=0x{(lbPrefix | 0xFFFEu):X8} " +
|
||||
$"objects={info.Objects.Count} supportedObjects={supportedObjects} buildings={info.Buildings.Count} ===");
|
||||
|
||||
int registryBuildingId = 1;
|
||||
int shellEntities = 0;
|
||||
foreach (var (building, zeroBased) in info.Buildings.Select((b, i) => (b, i)))
|
||||
{
|
||||
if (!IsSupported(building.ModelId))
|
||||
continue;
|
||||
|
||||
uint shellEntityId = nextEntityId++;
|
||||
shellEntities++;
|
||||
|
||||
var portalCells = building.Portals
|
||||
.Where(p => p.OtherCellId != 0xFFFF)
|
||||
.Select(p => lbPrefix | (uint)p.OtherCellId)
|
||||
.Distinct()
|
||||
.OrderBy(id => id)
|
||||
.ToArray();
|
||||
string registryText = portalCells.Length == 0
|
||||
? "none"
|
||||
: $"0x{registryBuildingId++:X}";
|
||||
string portalText = portalCells.Length == 0
|
||||
? "[]"
|
||||
: "[" + string.Join(",", portalCells.Select(id => $"0x{id:X8}")) + "]";
|
||||
|
||||
Console.WriteLine(
|
||||
$"buildingOrdinal={zeroBased + 1} registryId={registryText} shellEntity=0x{shellEntityId:X8} " +
|
||||
$"model=0x{building.ModelId:X8} pos=({building.Frame.Origin.X:F2},{building.Frame.Origin.Y:F2},{building.Frame.Origin.Z:F2}) " +
|
||||
$"portalCells={portalText}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
return (registryBuildingId - 1, shellEntities);
|
||||
}
|
||||
|
||||
static bool IsSupported(uint id)
|
||||
{
|
||||
uint type = id & 0xFF000000u;
|
||||
return type == 0x01000000u || type == 0x02000000u;
|
||||
}
|
||||
|
||||
static string SurfaceText(EnvCell envCell, short index)
|
||||
{
|
||||
if (index < 0) return "none";
|
||||
if (index >= envCell.Surfaces.Count) return "out-of-range";
|
||||
return $"0x{(0x08000000u | envCell.Surfaces[index]):X8}";
|
||||
}
|
||||
|
||||
static Vector3 ComputeNormal(DatReaderWriter.Types.CellStruct cellStruct, DatReaderWriter.Types.Polygon poly)
|
||||
{
|
||||
if (poly.VertexIds.Count < 3) return Vector3.Zero;
|
||||
if (!TryGetOrigin(cellStruct, (ushort)poly.VertexIds[0], out var a) ||
|
||||
!TryGetOrigin(cellStruct, (ushort)poly.VertexIds[1], out var b) ||
|
||||
!TryGetOrigin(cellStruct, (ushort)poly.VertexIds[2], out var c))
|
||||
{
|
||||
return Vector3.Zero;
|
||||
}
|
||||
|
||||
var n = Vector3.Cross(b - a, c - a);
|
||||
return n.LengthSquared() > 0f ? Vector3.Normalize(n) : Vector3.Zero;
|
||||
}
|
||||
|
||||
static bool TryGetOrigin(DatReaderWriter.Types.CellStruct cellStruct, ushort id, out Vector3 origin)
|
||||
{
|
||||
if (cellStruct.VertexArray.Vertices.TryGetValue(id, out var vertex))
|
||||
{
|
||||
origin = vertex.Origin;
|
||||
return true;
|
||||
}
|
||||
|
||||
origin = Vector3.Zero;
|
||||
return false;
|
||||
}
|
||||
|
||||
static (Vector2 Min, Vector2 Max) UvRange(DatReaderWriter.Types.CellStruct cellStruct, DatReaderWriter.Types.Polygon poly)
|
||||
{
|
||||
var min = new Vector2(float.MaxValue, float.MaxValue);
|
||||
var max = new Vector2(float.MinValue, float.MinValue);
|
||||
bool any = false;
|
||||
for (int i = 0; i < poly.VertexIds.Count; i++)
|
||||
{
|
||||
if (!cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[i], out var vertex))
|
||||
continue;
|
||||
|
||||
ushort uvIdx = 0;
|
||||
if (poly.PosUVIndices is not null && i < poly.PosUVIndices.Count)
|
||||
uvIdx = poly.PosUVIndices[i];
|
||||
if (uvIdx >= vertex.UVs.Count)
|
||||
uvIdx = 0;
|
||||
if (vertex.UVs.Count == 0)
|
||||
continue;
|
||||
|
||||
var uv = new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V);
|
||||
min = Vector2.Min(min, uv);
|
||||
max = Vector2.Max(max, uv);
|
||||
any = true;
|
||||
}
|
||||
|
||||
return any ? (min, max) : (Vector2.Zero, Vector2.Zero);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue