fix(rendering): bound portal resource lifetime

Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-18 21:35:16 +02:00
parent 3971997689
commit 749e8ceeb1
225 changed files with 29107 additions and 3914 deletions

View file

@ -46,7 +46,7 @@ Copy this block when adding a new issue:
## #225 — Scene particles overpaint translucent world objects ## #225 — Scene particles overpaint translucent world objects
**Status:** IN-PROGRESS — implementation complete; connected visual gate pending **Status:** IN-PROGRESS — implementation/reviews and connected stress gate pass; final visual gate pending
**Severity:** MEDIUM **Severity:** MEDIUM
**Filed:** 2026-07-18 **Filed:** 2026-07-18
**Component:** rendering / world translucency / particles **Component:** rendering / world translucency / particles
@ -79,7 +79,7 @@ order inside one instanced draw, with only DAT blend-mode changes splitting a
run. The identical location recovered to about 153 FPS / 6.6 ms in the run. The identical location recovered to about 153 FPS / 6.6 ms in the
connected Release client. connected Release client.
A second, genuinely cumulative portal regression remained: ACE retains A second, genuinely cumulative portal regression was first isolated: ACE retains
`KnownObjects` across normal teleports, while acdream retained every old `KnownObjects` across normal teleports, while acdream retained every old
`LiveEntityRecord` indefinitely. Each destination therefore left animation, `LiveEntityRecord` indefinitely. Each destination therefore left animation,
effect, and render owners active; after enough trips the render thread blocked effect, and render owners active; after enough trips the render thread blocked
@ -93,6 +93,30 @@ duplicating vertices per material and growing forever. A connected five-region
round trip returned live/animation ownership to baseline, recreated C95B on round trip returned live/animation ownership to baseline, recreated C95B on
revisit, and held its normal 6080 FPS. revisit, and held its normal 6080 FPS.
That shorter route did not close the process-lifetime problem. A subsequent
multi-recall run still climbed to about 3.0 GiB working set / 3.5 GiB private
memory and reproduced the 512 FPS collapse, with effect emitters, composite
textures, decoded DAT objects, texture atlases, and physical GL stores remaining
resident after their owners left. The final integration therefore makes the
whole chain owner-scoped and bounded: exact-incarnation appearance replacement,
retryable live/landblock/UI/portal teardown, emitter retirement indexes,
bounded DAT/decoded/standalone/composite caches, reclaimable mesh/atlas storage,
incremental arena migration, and three-frame GPU-fenced physical reuse. It also
reuses per-frame scratch storage without clearing a legitimate large working set.
No draw distance, texture resolution, particle range, or visual effect was
reduced.
The final connected route (Caul → Sawato → Rynthid → Aerlinthe → Sawato →
Holtburg → Caul, with 2530 second destination dwells and 60 seconds after the
return) passed without an exception, WER report, or AMD display-driver reset.
Peak working set fell from 2,954.5 MiB to 1,493.4 MiB and peak private memory
from 3,502.3 MiB to 1,969.5 MiB versus the failing build. Returned Caul settled
at 1,030.6 MiB working set / 1,638.2 MiB private / 831.6 MiB local GPU; the final
local-display dwell held 125153 FPS (141.8 average). Emitter/binding ownership
balanced at 1,715/1,715 and composite physical residency remained below its
128 MiB ceiling. The older 32 FPS comparison run was RDP-refresh-capped and is
used only for memory comparison. The lifestone/particle visual gate remains.
**Files:** `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; **Files:** `src/AcDream.App/Rendering/RetailAlphaQueue.cs`;
`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`;
`src/AcDream.App/Rendering/ParticleRenderer.cs`; `src/AcDream.App/Rendering/ParticleRenderer.cs`;
@ -100,11 +124,17 @@ revisit, and held its normal 6080 FPS.
`src/AcDream.App/Rendering/Shaders/particle.frag`; `src/AcDream.App/Rendering/Shaders/particle.frag`;
`src/AcDream.App/Rendering/RetailPViewRenderer.cs`; `src/AcDream.App/Rendering/RetailPViewRenderer.cs`;
`src/AcDream.App/World/LiveEntityLivenessController.cs`; `src/AcDream.App/World/LiveEntityLivenessController.cs`;
`src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs`. `src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs`;
`src/AcDream.App/Rendering/GpuFrameFlightController.cs`;
`src/AcDream.App/Rendering/CompositeTextureArrayCache.cs`;
`src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs`;
`src/AcDream.Content/BoundedDatObjectCache.cs`;
`src/AcDream.Content/DecodedTextureCache.cs`.
**Research:** **Research:**
`docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md`; `docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md`;
`docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md`. `docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md`;
`docs/research/2026-07-18-retail-texture-resource-lifetime-pseudocode.md`.
**Acceptance:** At a translucent lifestone, smoke or flame behind the crystal **Acceptance:** At a translucent lifestone, smoke or flame behind the crystal
is attenuated by it while an effect in front remains bright. The lifestone's is attenuated by it while an effect in front remains bright. The lifestone's

View file

@ -488,6 +488,44 @@ identity plus animation, motion, physics, collision, selection, and
dead-reckoning owners. Only a real delete/despawn tears those owners down. This dead-reckoning owners. Only a real delete/despawn tears those owners down. This
matches retail's `CPhysicsObj::DoObjDescChangesFromDefault` behavior. matches retail's `CPhysicsObj::DoObjDescChangesFromDefault` behavior.
### Runtime resource ownership and bounded residency
Logical lifetime, spatial residency, and physical GPU lifetime are separate
contracts. A live entity or landblock owns stable logical references; moving it
between buckets does not reacquire resources. Appearance replacement acquires
the complete new mesh/texture set before publication, then releases the old set.
Despawn and landblock demotion withdraw every public render reference before
their physical resources become reclaimable. All of these transactions are
retryable and generation-scoped, so a failed release cannot silently strand a
half-retired owner or affect a reused server GUID.
Runtime content residency is deliberately bounded rather than proportional to
every region visited:
- `RuntimeDatCollectionFactory` keeps DAT indexes on demand but uses
`FileCachingStrategy.Never`; `DatCollection` remains the sole raw reader.
- Each typed DAT facade has a 256-entry / 64 MiB estimated LRU. Unknown object
graphs are conservatively charged at least 128 KiB. Canonical decoded texture
pixels use a separate 128-entry / 64 MiB cache.
- Standalone bindless textures retain at most 256 unowned entries / 32 MiB and
retire at most one per frame. Owner-scoped composite textures use a 64 MiB
unowned budget and 128 MiB physical budget, admitting at most 16 uploads or
8 MiB per frame.
- `ObjectMeshManager` may retain at most 32 empty texture atlases / 64 MiB.
`GlobalMeshBuffer` owns reclaimable vertex/index ranges capped at 384 MiB and
128 MiB respectively, with an 896 MiB physical ceiling that includes an
in-progress migration and its retired predecessor.
OpenGL deletion and range/slot reuse are not synonymous with logical release.
`GpuFrameFlightController` fences three frames in flight. Mesh-buffer stores,
texture handles, atlas layers, terrain slots, and landblock render records enter
retirement only after they are no longer publishable, and their physical ids are
recycled only after the corresponding fence signals. Shutdown follows the same
dependency order and remains retryable: UI/controllers and render registrations
withdraw first, then owner leases and caches, then GL backing stores. This keeps
drivers from reading freed memory without adding a portal-specific purge or a
visual-distance reduction.
--- ---
## Per-Frame Update Order (current runtime) ## Per-Frame Update Order (current runtime)

View file

@ -4,7 +4,7 @@
"Code Structure Rules" section in `CLAUDE.md`. "Code Structure Rules" section in `CLAUDE.md`.
**Purpose:** Describe the desired structural state of the App layer, **Purpose:** Describe the desired structural state of the App layer,
explain the rules we've adopted, and lay out the safe extraction explain the rules we've adopted, and lay out the safe extraction
sequence from today's reality (one 10,304-line `GameWindow.cs`) to the sequence from today's reality (one 15,288-line `GameWindow.cs`) to the
target (thin `GameWindow`, small focused collaborators). target (thin `GameWindow`, small focused collaborators).
**Companion to:** [`acdream-architecture.md`](acdream-architecture.md) **Companion to:** [`acdream-architecture.md`](acdream-architecture.md)
(the layered architecture) and (the layered architecture) and
@ -20,7 +20,7 @@ layer is wire-compatible, the UI has a stable contract, plugins load.
The structural debt is concentrated in **one file**: The structural debt is concentrated in **one file**:
``` ```
src/AcDream.App/Rendering/GameWindow.cs 10,304 lines src/AcDream.App/Rendering/GameWindow.cs 15,288 lines
``` ```
`GameWindow` is the single object that: `GameWindow` is the single object that:
@ -75,28 +75,13 @@ delegate to a collaborator for the substance.
a GL or windowing namespace, we've lost the ability to test it without a GL or windowing namespace, we've lost the ability to test it without
a graphics context, and the layer split becomes fiction. a graphics context, and the layer split becomes fiction.
**How to apply:** The only currently-allowed seams from Core into the **How to apply:** Phase O removed both external WorldBuilder/backend project
WB / GL world are: references. The only currently allowed seams are the GL-free helpers owned in
our tree under `src/AcDream.Core/Rendering/Wb/`: `TerrainUtils`,
- `WorldBuilder.Shared` — stateless helpers (`TerrainUtils`, `TerrainEntry`, `RegionInfo`, `SceneryHelpers`, and `TextureHelpers`.
`TerrainEntry`, `RegionInfo`). `ObjectMeshManager` and every GL resource owner remain in App. If Core needs a
- `Chorizite.OpenGLSDLBackend.Lib` — stateless helpers new capability, define a narrow Core interface and implement it in App; adding
(`SceneryHelpers`, `TextureHelpers`). a new project reference requires an inventory-doc update explaining why.
Both are leaf namespaces with no GL state. If you need a new seam (e.g.
WB's `ObjectMeshManager` needs to be visible from Core), the change
**must** come with an inventory-doc update justifying it and ideally a
slim interface in Core that the App layer implements.
**Current reality:** `src/AcDream.Core/AcDream.Core.csproj` references
`Chorizite.OpenGLSDLBackend` (not just `OpenGLSDLBackend.Lib`). This is
historical. Two Core files import from it:
- `World/SceneryGenerator.cs``Chorizite.OpenGLSDLBackend.Lib`
- `Textures/SurfaceDecoder.cs``Chorizite.OpenGLSDLBackend.Lib`
Both use the stateless `Lib` namespace only. The full project reference
is wider than it needs to be; tightening it to just `WorldBuilder.Shared`
+ a stateless-helpers shim is a candidate future cut, but not urgent.
### Rule 3: UI panels target `AcDream.UI.Abstractions` only ### Rule 3: UI panels target `AcDream.UI.Abstractions` only
@ -159,11 +144,12 @@ Today:
- `tests/AcDream.Core.Tests/``src/AcDream.Core/` - `tests/AcDream.Core.Tests/``src/AcDream.Core/`
- `tests/AcDream.Core.Net.Tests/``src/AcDream.Core.Net/` - `tests/AcDream.Core.Net.Tests/``src/AcDream.Core.Net/`
- `tests/AcDream.UI.Abstractions.Tests/``src/AcDream.UI.Abstractions/` - `tests/AcDream.UI.Abstractions.Tests/``src/AcDream.UI.Abstractions/`
- `tests/AcDream.App.Tests/``src/AcDream.App/`
`AcDream.App` does **not** yet have a test project. The RuntimeOptions `tests/AcDream.App.Tests/` now exists and owns App-layer controller, streaming,
extraction is the trigger to create `tests/AcDream.App.Tests/`. Future render-resource lifetime, retained-UI, and `RuntimeOptions` tests. New App tests
App-layer tests (LiveSessionController, SelectionInteractionController, belong there; do not place GL-free Core behavior in that project merely because
etc.) go there. App currently wires it.
--- ---

View file

@ -83,7 +83,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw``DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 | | AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw``DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 |
| AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 | | AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 |
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 | | AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
| AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) | | AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams |
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal | | AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) | | AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
| AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | | AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |

View file

@ -186,6 +186,20 @@ rendering and landblock mesh pins while retaining the terrain slot. Core's
matching physics demotion preserves the terrain surface but removes indoor matching physics demotion preserves the terrain surface but removes indoor
cells, portals, buildings, and static shadow registrations. cells, portals, buildings, and static shadow registrations.
**Bounded residency and GPU retirement seam (2026-07-18).** Runtime DAT access
keeps raw file payload caching disabled and layers bounded typed-object and
decoded-pixel LRUs above the single `DatCollection`. `ObjectMeshManager`, the
standalone bindless texture cache, and the owner-scoped composite texture-array
cache all distinguish an active owner from an evictable unowned entry. Appearance
changes and landblock demotion acquire-before-publish and withdraw-before-release;
rebucketing never creates a second owner. `GlobalMeshBuffer` uses reclaimable,
coalescing vertex/index ranges and migrates incrementally within explicit physical
ceilings. Texture layers, terrain slots, mesh ranges, and old backing stores are
returned only after `GpuFrameFlightController` observes the frame fence that can
no longer reference them. This lifetime machinery is acdream-owned integration
around the extracted WB mesh pipeline; it does not add a second DAT decoder or a
reduced-distance rendering path.
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling **Workflow:** Before re-implementing any AC-specific rendering or dat-handling
algorithm, **check this inventory first**. If we already extracted it (🟢 algorithm, **check this inventory first**. If we already extracted it (🟢
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has

View file

@ -561,7 +561,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **Missile/portal VFX campaign Step 8 implemented and independently reviewed 2026-07-14.** `LiveEntityRuntime` now distinguishes raw server PhysicsState from retail's side-effect-derived final state, beginning from constructor state `0x00400C08` and applying `set_state` in Lighting → NoDraw → Hidden order. `LiveEntityPresentationController` keeps Hidden objects logically alive while suppressing root mesh, collision, picking, radar, status, and new target acquisition; typed `PS_Hidden`/`PS_UnHide` plays remain DAT-driven, direct equipped children inherit NoDraw, particles/lights survive, and repeated/stale state cannot replay a transition. A normal visible CreateObject never fabricates UnHide. Effect-owner preparation is separate from pending F754/F755 replay so construction-state effects always run first. Remote fresh-teleport/cell-less placement now executes the exact ordered `teleport_hook` action bundle and a full-cell hard placement before contact/airborne routing, preventing an airborne correction from restoring the old location. Projectile Hidden state pauses the retained body without consuming its active identity or accumulating a clock backlog. TS-43 is retired; AP-69 remains for exact ObjCell-PVS/preview-retention parity after the 2026-07-18 liveness port. - **Missile/portal VFX campaign Step 8 implemented and independently reviewed 2026-07-14.** `LiveEntityRuntime` now distinguishes raw server PhysicsState from retail's side-effect-derived final state, beginning from constructor state `0x00400C08` and applying `set_state` in Lighting → NoDraw → Hidden order. `LiveEntityPresentationController` keeps Hidden objects logically alive while suppressing root mesh, collision, picking, radar, status, and new target acquisition; typed `PS_Hidden`/`PS_UnHide` plays remain DAT-driven, direct equipped children inherit NoDraw, particles/lights survive, and repeated/stale state cannot replay a transition. A normal visible CreateObject never fabricates UnHide. Effect-owner preparation is separate from pending F754/F755 replay so construction-state effects always run first. Remote fresh-teleport/cell-less placement now executes the exact ordered `teleport_hook` action bundle and a full-cell hard placement before contact/airborne routing, preventing an airborne correction from restoring the old location. Projectile Hidden state pauses the retained body without consuming its active identity or accumulating a clock backlog. TS-43 is retired; AP-69 remains for exact ObjCell-PVS/preview-retention parity after the 2026-07-18 liveness port.
- **Missile/portal VFX campaign Step 9 automated hardening implemented and independently reviewed 2026-07-14; final two-client visual gate pending.** A deterministic 96-owner App fixture drives canonical missiles and effect owners through projectile updates, repeated DAT-effect scheduling, loaded↔pending↔loaded landblock churn, light/particle withdrawal and recovery, accepted deletes, same-GUID generation reuse, never-created F754 queues, and session reset. It asserts balanced logical render registration and zero residual records/bodies/projectiles, spatial buckets/rescues, shadows (including suspended registrations), effect profiles/packets, PhysicsScript FIFOs/anchors/delayed calls, particle bindings/logical IDs/render-pass owners, poses, light-controller/sink/manager owners, and stale record component references. A companion twelve-cycle gate drives exact recall motion `0x10000153` through AnimationSequencer/CallPES, Hidden, deferred remote placement, hydration, and UnHide; a second 96-owner `EntitySpawnAdapter` gate balances actual mesh-adapter reference counts without GL. The pass fixed undrained persistent rescue retention, delayed stale visibility edges, GUID-scoped teardown, create resurrection after a nested delete/reset, non-atomic resource registration, double teardown from a re-entrant session-clear callback, observer failure stranding later visibility edges, and stale outer projection transactions overwriting callback-created replacements. Teardown now removes exact projection references and generation/local-ID owners, uses per-GUID/session lifetime epochs plus per-record projection tokens, drains visibility fan-out before aggregating failures, and finishes or supersedes canonical commits before surfacing observer errors. Core remains GL/backend-free, panels remain on UI abstractions, and projectiles still draw through ordinary `WbDrawDispatcher` live-entity submission rather than a global projectile pass. AP-69 and TS-49 remain; AP-83/AP-91 now explicitly describe only a future mover that enables PerfectClip. - **Missile/portal VFX campaign Step 9 automated hardening implemented and independently reviewed 2026-07-14; final two-client visual gate pending.** A deterministic 96-owner App fixture drives canonical missiles and effect owners through projectile updates, repeated DAT-effect scheduling, loaded↔pending↔loaded landblock churn, light/particle withdrawal and recovery, accepted deletes, same-GUID generation reuse, never-created F754 queues, and session reset. It asserts balanced logical render registration and zero residual records/bodies/projectiles, spatial buckets/rescues, shadows (including suspended registrations), effect profiles/packets, PhysicsScript FIFOs/anchors/delayed calls, particle bindings/logical IDs/render-pass owners, poses, light-controller/sink/manager owners, and stale record component references. A companion twelve-cycle gate drives exact recall motion `0x10000153` through AnimationSequencer/CallPES, Hidden, deferred remote placement, hydration, and UnHide; a second 96-owner `EntitySpawnAdapter` gate balances actual mesh-adapter reference counts without GL. The pass fixed undrained persistent rescue retention, delayed stale visibility edges, GUID-scoped teardown, create resurrection after a nested delete/reset, non-atomic resource registration, double teardown from a re-entrant session-clear callback, observer failure stranding later visibility edges, and stale outer projection transactions overwriting callback-created replacements. Teardown now removes exact projection references and generation/local-ID owners, uses per-GUID/session lifetime epochs plus per-record projection tokens, drains visibility fan-out before aggregating failures, and finishes or supersedes canonical commits before surfacing observer errors. Core remains GL/backend-free, panels remain on UI abstractions, and projectiles still draw through ordinary `WbDrawDispatcher` live-entity submission rather than a global projectile pass. AP-69 and TS-49 remain; AP-83/AP-91 now explicitly describe only a future mover that enables PerfectClip.
- **Portal lifetime/performance correction 2026-07-18; user visual gate pending.** `LiveEntityLivenessController` ports retail's 25-second leave-visibility destruction queue over canonical live records, cancels expiry for spatially resident or owned objects, and uses holtburger's conservative 384-unit envelope where ACE supplies no client-visible PVS leave event. Expiry reuses the exact generation-safe F747 teardown. The modern `GlobalMeshBuffer` now uploads vertices once per mesh and owns coalescing vertex/index ranges released by zero-reference LRU eviction. A connected five-region route returned animation ownership to baseline, reclaimed/reused GPU ranges, recreated the starting region on revisit, and prevented the prior persistent 312 FPS collapse. AP-69 is narrowed rather than retired because exact ObjCell PVS and explicit preview lifetimes remain. - **Portal/resource lifetime correction 2026-07-18; connected stress gate passed, final visual gate pending.** `LiveEntityLivenessController` ports retail's 25-second leave-visibility destruction queue over canonical live records, cancels expiry for spatially resident or owned objects, and uses holtburger's conservative 384-unit envelope where ACE supplies no client-visible PVS leave event. The broader integration balances generation-scoped appearance/live/landblock/effect/UI/portal owners, bounds DAT/decoded/standalone/composite/atlas/mesh residency, performs incremental mesh-arena migration, and defers GL deletion or slot reuse through three frames of GPU fences. Render/UI scratch storage is reused with hysteretic trimming. No visual range or quality setting was reduced. The final seven-destination connected route cut peak working/private memory from 2,954.5/3,502.3 MiB to 1,493.4/1,969.5 MiB, returned Caul to 1,030.6/1,638.2 MiB, held 125153 FPS locally through the final dwell, and produced no WER/driver reset. AP-69 is narrowed rather than retired because exact ObjCell PVS and explicit preview lifetimes remain.
**Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation. **Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.
@ -1311,6 +1311,10 @@ port in any phase — no separate listing here.
> number and accepts the rewrite risk. Rust rejected. Parked (resume from the table > number and accepts the rewrite risk. Rust rejected. Parked (resume from the table
> below): MP1b pak/bake (needs the 865 GB EnvCell-dedup slice; loading-speed lever), > below): MP1b pak/bake (needs the 865 GB EnvCell-dedup slice; loading-speed lever),
> MP-Alloc hard sites, MP1c, MP2. Full rationale: `project_mp_track_findings.md`. > MP-Alloc hard sites, MP1c, MP2. Full rationale: `project_mp_track_findings.md`.
> The 2026-07-18 portal/resource-lifetime repair does not resume MP's ECS, pak,
> or throughput redesign. It is corrective ownership/reclamation work for a
> reproduced crash-class regression in the mandatory renderer, using the current
> architecture and preserving pixels/ranges.
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the **Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
umbrella design — read it first). **Goal:** smoothness first (no frame over umbrella design — read it first). **Goal:** smoothness first (no frame over

View file

@ -5,9 +5,12 @@
**Currently working toward:** **M3 — "Cast a spell." M2 — "Kill a drudge" **Currently working toward:** **M3 — "Cast a spell." M2 — "Kill a drudge"
LANDED 2026-07-15.** The complete connected melee/missile, death/loot, LANDED 2026-07-15.** The complete connected melee/missile, death/loot,
inventory, and item-giving demo is user-gated. The shared projectile/effect inventory, and item-giving demo is user-gated. The shared projectile/effect
foundation is hardened through Step 9 and its single-client visual gate passes. foundation is hardened through Step 9. Spellbook/component-book filtering,
Next is the connected F.5 component-book/favorite-bar visual gate, followed by favorite spell bar, connected casting/enchantment effects, and portal-space
cast/enchantment presentation and the two-client portal/observer VFX gate. presentation have passed their single-client visual gates. The corrective bounded
render/resource-lifetime integration passed its seven-destination connected
stress gate without a crash or cumulative collapse. The remaining M3 visual
requirement is the final two-client portal-out/materialize observer gate.
Carried: Carried:
#145-residual, #116 slide-response, R6/TS-42, and Track MP0. #145-residual, #116 slide-response, R6/TS-42, and Track MP0.
@ -285,6 +288,11 @@ as ad-hoc rework. Rule 2's freeze still binds all NON-MP work. MP runs in
dedicated side-track sessions; the M1.5 critical path wins every conflict, dedicated side-track sessions; the M1.5 critical path wins every conflict,
and MP4 is hard-queued behind #137. and MP4 is hard-queued behind #137.
The 2026-07-18 repeated-portal lifetime repair is a freeze-allowed corrective
change, not a resumption of Track MP. It keeps the current renderer and visual
ranges while balancing owners, bounding residency, and fence-delaying physical
GPU reuse; MP's pak/ECS/throughput redesign remains parked.
**Dungeon support (Phase G.3, issue #133) — SHIPPED.** The original **Dungeon support (Phase G.3, issue #133) — SHIPPED.** The original
premise here (terrain-less dungeon landblocks unsupported anywhere in the premise here (terrain-less dungeon landblocks unsupported anywhere in the
streaming/load/render/physics pipeline) was refuted at design time streaming/load/render/physics pipeline) was refuted at design time

View file

@ -0,0 +1,208 @@
# Retail texture and per-object material lifetime
## Scope
This note records the retail ownership rules needed to fix the repeated-recall
resource buildup in acdream. It does not attempt to reproduce Direct3D 9 or
retail's whole database cache. The modern renderer keeps shared atlas textures,
but per-object palette/texture composites must follow the same owner lifetime as
retail's `CSurface`/`ImgTex` pair.
## Retail oracle
Named Sept-2013 client symbols and pseudo-C:
- `CSurface::SetTextureAndPalette` `0x00535FB0`
- `CSurface::Destroy` `0x005361F0`
- `CSurface::UseTextureMap(ImgTex*, SurfaceHandlerEnum)` `0x00536350`
- `CSurface::InitEnd` `0x00536400`
- `CSurface::RestorePalShiftSurface` `0x00536530`
- `ImgTex::PurgeResource` `0x0053E550`
- `ImgTex::GetD3DTexture` `0x0053E5B0`
- `ImgTex::CreateD3DTexture` `0x0053EDB0`
- `DBCache::UseTime` `0x00414090`
- `DBCache::FlushFreeObjects` `0x004141E0`
- `GraphicsResource::PurgeOldResources` `0x00446C60`
- `SceneTool::PurgeOldGraphicsResources` `0x0043E5C0`
Modern reference seam:
- WorldBuilder's extracted `TextureAtlasManager` reference-counts a texture
layer and releases it when its last mesh user disappears.
- acdream's `ObjectMeshManager.UnloadObject` already disposes an empty atlas
and removes it from `_globalAtlases`; the missing lifetime is the separate
`TextureCache` used by live-object composites.
## Pseudocode
### A database surface owns its current image texture
```text
CSurface::UseTextureMap(newTexture):
if current base1map exists:
current base1map.Release()
current base1map = null
current base1map = newTexture
newTexture.AddRef()
```
### Replacing a palette composite releases the previous composite
```text
CSurface::SetTextureAndPalette(sourceTexture, palette):
combined = ImgTex::CreateCombinedTexture(sourceTexture, palette, clipmap)
if combined is null:
fail
if current base1map exists:
current base1map.Release()
current base1map = null
current base1map = combined
```
`RestorePalShiftSurface` follows the same order: release the old `base1map`,
create the replacement combined texture, then install it.
### Object destruction releases its material resources immediately
```text
CSurface::Destroy():
if base1map exists:
base1map.Release()
base1map = null
if base1pal exists:
Palette::releasePalette(base1pal)
base1pal = null
reset surface fields
```
This is the important portal/recall rule: a palette-shifted texture is not a
session-global retain-forever entry. Its owning surface contributes a reference,
and destroying/replacing that surface releases the reference.
### Shared image resources are separately purgeable
```text
ImgTex::GetD3DTexture():
if resource is lost:
RestoreResource()
TimeUsed = Timer.local_time
FrameUsed = render_device.frame_stamp
return D3D texture
SceneTool::PurgeOldGraphicsResources():
every 5 seconds:
if available video memory is low:
GraphicsResource::PurgeOldResources(unused-age threshold)
GraphicsResource::PurgeOldResources(age):
for each registered graphics resource:
if not lost
and not used this frame
and it is thrashable
and TimeUsed is older than age:
resource.PurgeResource()
mark resource lost
```
Shared DAT image residency and owner-scoped composites are therefore two
different lifetimes. The modern atlas is acdream's shared-image adaptation;
palette and original-texture overrides remain owner-scoped.
## acdream port contract
```text
ResolveTexture(entity, meshBatch):
if no original-texture override
and no applicable palette override:
use meshBatch's existing shared atlas handle and layer
if entity has a palette override
and the resolved RenderSurface is P8 or INDEX16:
get/create one owner-scoped palette composite
record (entity local id -> composite key)
use composite handle, layer 0
else if entity has an original-texture override:
get/create one owner-scoped override texture
record (entity local id -> override key)
use override handle, layer 0
else:
use shared atlas handle and layer
```
```text
ReleaseTextureOwner(entity local id):
for each composite key first acquired by that owner:
decrement the key's owner count
if count becomes zero:
mark the cache entry unowned and therefore evictable
EvictUnownedTexture(key):
remove key from every public lookup first
enqueue its handle/resource against the current GPU frame serial
after the retirement fence signals:
make bindless handle non-resident
delete GL texture or recycle the array layer
remove physical diagnostic metadata
```
The registry is idempotent per `(owner, key)`, so classifying the same animated
object every frame does not add references. Appearance replacement resolves and
acquires the complete desired set first, publishes that exact incarnation, then
releases resources no longer used by the old description. Logical entity
teardown withdraws publication and releases the same set exactly once.
Keeping a bounded unowned cache is the modern equivalent of retail's separately
purgeable `GraphicsResource`: it does not change the owner contract. Current
production budgets are 32 MiB / 256 unowned standalone textures and 64 MiB
unowned / 128 MiB physical composite arrays. Physical destruction/reuse waits
for the three-frame fence owner so the GPU cannot still be sampling a resource
whose logical owner has disappeared.
## Captured failure evidence
The connected C95B -> 3032 -> F682 -> 0904 -> C95B sequence retained normal
world ownership (`139` live network objects, `13` animated objects, `1,705`
particle emitters) but `TextureCache` held `1,945` GL textures:
- `527` legacy palette composites pre-warmed by `EntitySpawnAdapter` but never
consumed by the modern draw path;
- `366` base bindless textures duplicating textures already resident in the
WorldBuilder atlas;
- `587` bindless palette composites, including stale objects from earlier
destinations and duplicates for non-indexed surfaces.
The process reached about 2.7 GB private memory, about 891 MB dedicated GPU
memory, and 5-8 FPS. A managed CPU trace spent nearly the entire render thread
inside native rendering, while a GC snapshot reduced working set but did not
recover frame rate. That combination isolates retained GL texture residency,
not live-entity, particle, or managed-update counts, as the remaining
recall-triggered buildup.
## Final connected conformance evidence
The integrated build ran Caul → Sawato → Rynthid → Aerlinthe → Sawato →
Holtburg → Caul with 2530 second destination dwells and a 60-second final
reclamation dwell. Compared with the failing build on the same route:
| Metric | Failing build | Integrated build |
|---|---:|---:|
| Peak working set | 2,954.5 MiB | 1,493.4 MiB |
| Peak private bytes | 3,502.3 MiB | 1,969.5 MiB |
| Final Caul working set | 1,826.2 MiB | 1,030.6 MiB |
| Final Caul private bytes | 2,433.7 MiB | 1,638.2 MiB |
Final local GPU residency was 831.6 MiB and stable through the dwell. The
composite cache ended at 65,780,224 unowned bytes and 122,437,632 physical
bytes, below its 64 MiB policy modulo one entry's admission granularity and
below the strict 128 MiB physical ceiling. Emitter and binding indexes balanced
at 1,715 each. Caul held 125153 FPS (141.8 average) on the local display.
There was no application WER event or AMD display-driver reset. The old run's
32 FPS was imposed by the RDP monitor's 32 Hz refresh and is not a performance
comparison; its process-memory measurements remain valid.

View file

@ -50,6 +50,7 @@ internal sealed class ProjectileController
private readonly ShadowObjectRegistry _shadows; private readonly ShadowObjectRegistry _shadows;
private readonly Func<uint, Setup?> _setupResolver; private readonly Func<uint, Setup?> _setupResolver;
private readonly Action<WorldEntity> _publishRootPose; private readonly Action<WorldEntity> _publishRootPose;
private readonly List<LiveEntityRecord> _spatialProjectileSnapshot = new();
private double _lastFiniteGameTime; private double _lastFiniteGameTime;
internal ProjectileController( internal ProjectileController(
@ -64,6 +65,7 @@ internal sealed class ProjectileController
_shadows = physicsEngine.ShadowObjects; _shadows = physicsEngine.ShadowObjects;
_setupResolver = setupResolver ?? (_ => null); _setupResolver = setupResolver ?? (_ => null);
_publishRootPose = publishRootPose ?? (_ => { }); _publishRootPose = publishRootPose ?? (_ => { });
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
} }
internal Action<string>? DiagnosticSink { get; set; } internal Action<string>? DiagnosticSink { get; set; }
@ -483,9 +485,11 @@ internal sealed class ProjectileController
return false; return false;
} }
internal bool LeaveWorld(uint serverGuid) internal bool LeaveWorld(LiveEntityRecord record)
{ {
if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime) ArgumentNullException.ThrowIfNull(record);
if (record.ProjectileRuntime is not Runtime runtime
|| runtime.Generation != record.Generation
|| record.WorldEntity is not { } entity) || record.WorldEntity is not { } entity)
return false; return false;
SuspendOutsideWorld(runtime, entity.Id); SuspendOutsideWorld(runtime, entity.Id);
@ -505,10 +509,12 @@ internal sealed class ProjectileController
} }
_lastFiniteGameTime = currentTime; _lastFiniteGameTime = currentTime;
foreach (LiveEntityRecord record in _liveEntities.Records.ToArray()) _liveEntities.CopySpatialProjectileRecordsTo(_spatialProjectileSnapshot);
foreach (LiveEntityRecord record in _spatialProjectileSnapshot)
{ {
if (record.ProjectileRuntime is not Runtime runtime if (record.ProjectileRuntime is not Runtime runtime
|| runtime.Generation != record.Generation || runtime.Generation != record.Generation
|| !_liveEntities.IsCurrentSpatialProjectile(record, runtime)
|| record.WorldEntity is not { } entity) || record.WorldEntity is not { } entity)
continue; continue;
@ -668,6 +674,25 @@ internal sealed class ProjectileController
} }
} }
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (visible
|| record.ProjectileRuntime is not Runtime runtime
|| record.WorldEntity is not { } entity
|| !_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, record)
|| !ReferenceEquals(current.ProjectileRuntime, runtime))
{
return;
}
// Pending/cell-less records retain their logical projectile runtime,
// but retail exit_world removes them from active physics and collision
// immediately. They are absent from the frame workset after this edge,
// so suspension belongs on the authoritative residency transition.
SuspendOutsideWorld(runtime, entity.Id);
}
private static bool HasVisibleCell(LiveEntityRecord record) => private static bool HasVisibleCell(LiveEntityRecord record) =>
record.IsSpatiallyProjected record.IsSpatiallyProjected
&& record.IsSpatiallyVisible && record.IsSpatiallyVisible

View file

@ -43,6 +43,7 @@ internal sealed class RemotePhysicsUpdater
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine; private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine;
private readonly System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> _getSetupCylinder; private readonly System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> _getSetupCylinder;
private readonly System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> _applyServerControlledVelocityCycle; private readonly System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> _applyServerControlledVelocityCycle;
private readonly List<AcDream.App.World.LiveEntityRecord> _spatialRemoteSnapshot = new();
internal RemotePhysicsUpdater( internal RemotePhysicsUpdater(
AcDream.Core.Physics.PhysicsEngine physicsEngine, AcDream.Core.Physics.PhysicsEngine physicsEngine,
@ -72,17 +73,26 @@ internal sealed class RemotePhysicsUpdater
ArgumentNullException.ThrowIfNull(liveEntities); ArgumentNullException.ThrowIfNull(liveEntities);
ArgumentNullException.ThrowIfNull(publishRootPose); ArgumentNullException.ThrowIfNull(publishRootPose);
foreach (AcDream.App.World.LiveEntityRecord record in liveEntities.Records.ToArray()) liveEntities.CopySpatialRemoteMotionRecordsTo(_spatialRemoteSnapshot);
foreach (AcDream.App.World.LiveEntityRecord record in _spatialRemoteSnapshot)
{ {
if (record.ServerGuid == localPlayerServerGuid if (record.ServerGuid == localPlayerServerGuid
|| (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0 || (record.FinalPhysicsState & AcDream.Core.Physics.PhysicsStateFlags.Hidden) == 0
|| !liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid) || !liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)
|| record.RemoteMotionRuntime is not RemoteMotion remote || record.RemoteMotionRuntime is not RemoteMotion remote
|| !liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
|| record.WorldEntity is not { } entity) || record.WorldEntity is not { } entity)
continue; continue;
TickHidden(remote, entity, dt); TickHidden(remote, entity, dt);
publishRootPose(entity); // PositionManager callbacks can rebucket, delete, or replace this
// GUID. Publish only while the captured record/runtime pair still
// owns a loaded spatial projection.
if (liveEntities.IsCurrentSpatialRemoteMotion(record, remote)
&& ReferenceEquals(record.WorldEntity, entity))
{
publishRootPose(entity);
}
} }
} }

View file

@ -0,0 +1,121 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Least-recently-used set for resources which currently have no logical
/// owner but remain resident for inexpensive reuse. The byte budget is a
/// cache policy, not a lifetime fence: callers remove one key here and then
/// retire its physical GPU storage through <see cref="IGpuResourceRetirementQueue"/>.
/// Render-thread only.
/// </summary>
internal sealed class BoundedUnownedResourceCache<TKey> where TKey : notnull
{
private readonly long _budgetBytes;
private readonly int _maximumCount;
private readonly LinkedList<TKey> _lru = new();
private readonly Dictionary<TKey, Entry> _entries = new();
private long _residentBytes;
private readonly record struct Entry(long Bytes, LinkedListNode<TKey> Node);
public BoundedUnownedResourceCache(long budgetBytes, int maximumCount = int.MaxValue)
{
ArgumentOutOfRangeException.ThrowIfNegative(budgetBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1);
_budgetBytes = budgetBytes;
_maximumCount = maximumCount;
}
public int Count => _entries.Count;
public long ResidentBytes => _residentBytes;
public long BudgetBytes => _budgetBytes;
public int MaximumCount => _maximumCount;
public bool Contains(TKey key) => _entries.ContainsKey(key);
public void MarkUnowned(TKey key, long bytes)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes);
if (_entries.TryGetValue(key, out Entry existing))
{
if (existing.Bytes != bytes)
throw new InvalidOperationException(
$"Cached resource {key} changed size from {existing.Bytes} to {bytes} bytes.");
_lru.Remove(existing.Node);
_lru.AddLast(existing.Node);
return;
}
LinkedListNode<TKey> node = _lru.AddLast(key);
_entries.Add(key, new Entry(bytes, node));
_residentBytes = checked(_residentBytes + bytes);
}
public bool MarkOwned(TKey key)
{
if (!_entries.Remove(key, out Entry entry))
return false;
_lru.Remove(entry.Node);
_residentBytes -= entry.Bytes;
return true;
}
/// <summary>
/// Removes the oldest unowned key only while the cache is above budget.
/// Calling once per frame gives the GPU driver explicit destruction
/// backpressure instead of turning a portal transition into one large
/// resource-release burst.
/// </summary>
public bool TryTakeOldestOverBudget(out TKey key)
{
if ((_residentBytes <= _budgetBytes && _entries.Count <= _maximumCount)
|| _lru.First is null)
{
key = default!;
return false;
}
LinkedListNode<TKey> node = _lru.First;
key = node.Value;
Entry entry = _entries[key];
_lru.RemoveFirst();
_entries.Remove(key);
_residentBytes -= entry.Bytes;
return true;
}
public bool TryTakeOldest(out TKey key)
{
if (_lru.First is null)
{
key = default!;
return false;
}
LinkedListNode<TKey> node = _lru.First;
key = node.Value;
Entry entry = _entries[key];
_lru.RemoveFirst();
_entries.Remove(key);
_residentBytes -= entry.Bytes;
return true;
}
public bool TryTake(TKey key)
{
if (!_entries.Remove(key, out Entry entry))
return false;
_lru.Remove(entry.Node);
_residentBytes -= entry.Bytes;
return true;
}
public void Clear()
{
_lru.Clear();
_entries.Clear();
_residentBytes = 0;
}
}

View file

@ -22,12 +22,13 @@
// terrain OutsideView planes, then points each renderer's per-instance slot // terrain OutsideView planes, then points each renderer's per-instance slot
// buffer at the right slots. // buffer at the right slots.
// //
// Pure CPU byte-packing + a thin GL upload. NO GL types appear except in // Pure CPU byte-packing + a thin GL upload. The byte layout is asserted by
// UploadShared. The byte layout is asserted by ClipFrameLayoutTests so a silent // ClipFrameLayoutTests so a silent
// std430/std140 drift can't reach the GPU. // std430/std140 drift can't reach the GPU.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -78,16 +79,54 @@ public sealed class ClipFrame : IDisposable
// Packed std140 bytes for the terrain UBO (always TerrainUboBytes long). // Packed std140 bytes for the terrain UBO (always TerrainUboBytes long).
private readonly byte[] _terrainBytes = new byte[TerrainUboBytes]; private readonly byte[] _terrainBytes = new byte[TerrainUboBytes];
// ---- GL-side state (lazily created on first UploadShared) ---------------- // ---- GL-side state (lazily created on first upload) ----------------------
private uint _regionSsbo; private uint _regionSsbo;
private uint _terrainUbo; private uint _terrainUbo;
private bool _glInitialized; private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposing;
private bool _disposed; private bool _disposed;
// GL reference captured on the first UploadShared so Dispose can delete the two internal const int FrameSlotCount = GpuFrameFlightController.DefaultMaximumFramesInFlight;
// buffers. ClipFrame is long-lived in U.3 (GameWindow holds one via ??= NoClip()
// and reuses it every frame), so we DO own buffer teardown — see Dispose. private sealed class RegionBuffer
{
public uint Name;
public int CapacityBytes;
public ClipBufferCapacityPolicy CapacityPolicy;
}
private sealed class TerrainBufferArena
{
public uint Name;
public int CapacityBytes;
public ClipBufferCapacityPolicy CapacityPolicy;
}
// A full clip-region table is immutable once ClipFrameAssembler has packed
// the frame. Keep exactly one SSBO per GPU-fenced frame slot. Terrain clip
// state changes between outside-view slices, so each frame slot owns one
// UBO arena and each draw binds a distinct aligned range within that arena.
// This bounds native object retention at six buffers total instead of one
// region/terrain pair per outside-view slice.
private readonly ClipFrameResourceRing<RegionBuffer> _regionBuffers =
new(FrameSlotCount);
private readonly ClipFrameResourceRing<TerrainBufferArena> _terrainBuffers =
new(FrameSlotCount);
private readonly ClipFrameUploadState _uploadState = new();
private int _dynamicFrameSlot;
private bool _dynamicFrameStarted;
private int _terrainRecordStrideBytes;
private TerrainClipBufferBinding _terrainBinding;
internal int DynamicBufferSetCount =>
Math.Max(_regionBuffers.Count, _terrainBuffers.Count);
internal int RegionBufferCount => _regionBuffers.Count;
internal int TerrainBufferCount => _terrainBuffers.Count;
internal int TerrainRecordStrideBytes => _terrainRecordStrideBytes;
// GL reference captured on the first upload so Dispose can delete each
// frame-slot buffer. ClipFrame is long-lived and owns their teardown.
private GL? _gl; private GL? _gl;
private ClipFrame(byte[] regionBytes, int slotCount) private ClipFrame(byte[] regionBytes, int slotCount)
@ -119,9 +158,7 @@ public sealed class ClipFrame : IDisposable
/// (no-clip, count 0) and a terrain count of 0 — WITHOUT allocating a new /// (no-clip, count 0) and a terrain count of 0 — WITHOUT allocating a new
/// frame or new GL buffers. The single long-lived <c>_clipFrame</c> in /// frame or new GL buffers. The single long-lived <c>_clipFrame</c> in
/// GameWindow is reset + re-packed every frame by <see cref="ClipFrameAssembler"/>, /// GameWindow is reset + re-packed every frame by <see cref="ClipFrameAssembler"/>,
/// then re-uploaded via <see cref="UploadShared"/> (which reuses the same SSBO / /// then uploaded through one SSBO and one terrain arena per fenced frame slot.
/// UBO ids). This keeps the per-frame cost at one BufferData per buffer instead
/// of leaking a fresh pair of GL buffers each frame.
/// </summary> /// </summary>
public void Reset() public void Reset()
{ {
@ -139,16 +176,36 @@ public sealed class ClipFrame : IDisposable
} }
/// <summary>The shared mesh-clip SSBO id, or 0 before the first /// <summary>The shared mesh-clip SSBO id, or 0 before the first
/// <see cref="UploadShared"/>. Renderers may bind this directly if they don't /// <see cref="UploadRegions"/>. Renderers may bind this directly if they don't
/// receive it via a parameter; <see cref="UploadShared"/> already binds it to /// receive it via a parameter; <see cref="UploadRegions"/> already binds it to
/// <see cref="MeshClipSsboBinding"/>.</summary> /// <see cref="MeshClipSsboBinding"/>.</summary>
public uint RegionSsbo => _regionSsbo; public uint RegionSsbo => _regionSsbo;
/// <summary>The terrain-clip UBO id, or 0 before the first /// <summary>The terrain-clip UBO id, or 0 before the first
/// <see cref="UploadShared"/>. Handed to <see cref="TerrainModernRenderer"/> /// <see cref="UploadTerrainClip"/>. The buffer alone does not identify the
/// so it can re-bind binding=2 (UBO namespace) before its draw.</summary> /// active range; consumers should use <see cref="TerrainBinding"/>.</summary>
public uint TerrainUbo => _terrainUbo; public uint TerrainUbo => _terrainUbo;
/// <summary>The currently installed terrain-clip range. The buffer name is
/// stable for the current GPU-fenced frame slot; the offset advances once
/// for every outside-view draw.</summary>
public TerrainClipBufferBinding TerrainBinding => _terrainBinding;
/// <summary>
/// Selects one GPU-fenced frame slot. Its region SSBO and terrain arena are
/// safe to reuse because the frame-flight controller already waited for the
/// slot's preceding submission.
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= FrameSlotCount)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
_dynamicFrameSlot = frameSlot;
_dynamicFrameStarted = true;
_terrainBinding = default;
_uploadState.BeginFrame();
}
/// <summary> /// <summary>
/// Append one clip region (becomes the next slot index) from a /// Append one clip region (becomes the next slot index) from a
/// <see cref="ClipPlaneSet"/>. Only the convex-plane case is supported in /// <see cref="ClipPlaneSet"/>. Only the convex-plane case is supported in
@ -215,55 +272,265 @@ public sealed class ClipFrame : IDisposable
} }
/// <summary> /// <summary>
/// Upload the shared mesh-clip SSBO (binding=2) and the terrain-clip UBO /// Compatibility entry point for one-region/one-terrain callers. Complex
/// (binding=2, UBO namespace) and bind both to their binding points. Idempotent /// PView frames should reserve their complete terrain sequence, upload the
/// to call once per frame. Creates the GL buffers lazily on first call. /// regions once, then call <see cref="UploadTerrainClip"/> per slice.
/// </summary> /// </summary>
public unsafe void UploadShared(GL gl) public unsafe void UploadShared(GL gl)
{ {
ArgumentNullException.ThrowIfNull(gl); ReserveTerrainUploads(gl, 1);
ObjectDisposedException.ThrowIf(_disposed, this); UploadRegions(gl);
UploadTerrainClip(gl);
}
if (!_glInitialized) /// <summary>
/// Allocates the current frame slot's terrain arena before any draw uses it.
/// The arena has one alignment-safe range per subsequent
/// <see cref="UploadTerrainClip"/> call, so later slices never overwrite
/// uniform data referenced by earlier GPU commands.
/// </summary>
public void ReserveTerrainUploads(GL gl, int uploadCount)
{
ValidateGlContext(gl);
ArgumentOutOfRangeException.ThrowIfLessThan(uploadCount, 1);
_uploadState.ValidateTerrainReservation(uploadCount);
if (_terrainRecordStrideBytes == 0)
{ {
_gl = gl; // captured for Dispose (single context for the frame's lifetime) GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query (precondition)");
_regionSsbo = gl.GenBuffer(); gl.GetInteger(GetPName.UniformBufferOffsetAlignment, out int alignment);
_terrainUbo = gl.GenBuffer(); GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query");
_glInitialized = true; _terrainRecordStrideBytes = ClipFrameArenaLayout.RecordStride(alignment);
}
TerrainBufferArena arena = GetOrCreateTerrainArena(gl);
int requiredBytes = ClipFrameArenaLayout.RequiredBytes(
uploadCount,
_terrainRecordStrideBytes);
int targetCapacity = arena.CapacityPolicy.SelectCapacity(
arena.CapacityBytes,
requiredBytes);
if (targetCapacity != arena.CapacityBytes)
{
ClipBufferCapacityTransaction.Resize(
ref arena.CapacityBytes,
targetCapacity,
(previousBytes, newBytes) =>
TrackedGlResource.AllocateBufferStorage(
gl,
GLEnum.UniformBuffer,
arena.Name,
previousBytes,
newBytes,
GLEnum.DynamicDraw,
"ClipFrame terrain UBO arena resize"));
}
_terrainUbo = arena.Name;
_uploadState.CommitTerrainReservation(uploadCount);
}
/// <summary>Uploads and binds the immutable clip-region table once for the
/// assembled frame.</summary>
public unsafe void UploadRegions(GL gl)
{
ValidateGlContext(gl);
_uploadState.ValidateRegionsNotUploaded();
RegionBuffer region = GetOrCreateRegionBuffer(gl);
int regionByteCount = checked(_slotCount * CellClipStrideBytes);
int targetCapacity = region.CapacityPolicy.SelectCapacity(
region.CapacityBytes,
regionByteCount);
if (targetCapacity != region.CapacityBytes)
{
ClipBufferCapacityTransaction.Resize(
ref region.CapacityBytes,
targetCapacity,
(previousBytes, newBytes) =>
TrackedGlResource.AllocateBufferStorage(
gl,
GLEnum.ShaderStorageBuffer,
region.Name,
previousBytes,
newBytes,
GLEnum.DynamicDraw,
"ClipFrame region SSBO resize"));
} }
int regionByteCount = _slotCount * CellClipStrideBytes;
gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, _regionSsbo);
fixed (byte* p = _regionBytes) fixed (byte* p = _regionBytes)
{ {
gl.BufferData(BufferTargetARB.ShaderStorageBuffer, TrackedGlResource.UpdateBufferSubData(
(nuint)regionByteCount, p, BufferUsageARB.DynamicDraw); gl,
GLEnum.ShaderStorageBuffer,
region.Name,
0,
regionByteCount,
p,
"ClipFrame region SSBO update");
} }
gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, MeshClipSsboBinding, _regionSsbo); gl.BindBufferBase(
BufferTargetARB.ShaderStorageBuffer,
MeshClipSsboBinding,
region.Name);
GLHelpers.ThrowOnResourceError(gl, "ClipFrame region SSBO binding");
_regionSsbo = region.Name;
_uploadState.MarkRegionsUploaded();
}
/// <summary>Uploads the current terrain clip bytes into the next unique
/// aligned arena record and installs that range at UBO binding 2.</summary>
public unsafe TerrainClipBufferBinding UploadTerrainClip(GL gl)
{
ValidateGlContext(gl);
int recordIndex = _uploadState.NextTerrainRecord();
TerrainBufferArena arena = _terrainBuffers.GetRequired(_dynamicFrameSlot);
int offsetBytes = ClipFrameArenaLayout.RecordOffset(
recordIndex,
_terrainRecordStrideBytes);
gl.BindBuffer(BufferTargetARB.UniformBuffer, _terrainUbo);
fixed (byte* p = _terrainBytes) fixed (byte* p = _terrainBytes)
{ {
gl.BufferData(BufferTargetARB.UniformBuffer, TrackedGlResource.UpdateBufferSubData(
(nuint)TerrainUboBytes, p, BufferUsageARB.DynamicDraw); gl,
GLEnum.UniformBuffer,
arena.Name,
offsetBytes,
TerrainUboBytes,
p,
"ClipFrame terrain UBO range update");
} }
gl.BindBufferBase(BufferTargetARB.UniformBuffer, TerrainClipUboBinding, _terrainUbo);
_terrainBinding = new TerrainClipBufferBinding(
arena.Name,
offsetBytes,
TerrainUboBytes);
_terrainBinding.Bind(gl);
_terrainUbo = arena.Name;
return _terrainBinding;
}
public void BindTerrainClip(GL gl)
{
ValidateGlContext(gl);
if (!_terrainBinding.IsValid)
throw new InvalidOperationException("No terrain clip range has been uploaded for this frame.");
_terrainBinding.Bind(gl);
}
private RegionBuffer GetOrCreateRegionBuffer(GL gl)
{
if (_regionBuffers.TryGet(_dynamicFrameSlot, out RegionBuffer? existing))
return existing!;
uint name = TrackedGlResource.CreateBuffer(gl, "ClipFrame region SSBO creation");
var created = new RegionBuffer { Name = name };
try
{
_regionBuffers.Set(_dynamicFrameSlot, created);
return created;
}
catch
{
TrackedGlResource.DeleteBuffer(gl, name, 0, "ClipFrame region SSBO rollback");
throw;
}
}
private TerrainBufferArena GetOrCreateTerrainArena(GL gl)
{
if (_terrainBuffers.TryGet(_dynamicFrameSlot, out TerrainBufferArena? existing))
return existing!;
uint name = TrackedGlResource.CreateBuffer(gl, "ClipFrame terrain UBO creation");
var created = new TerrainBufferArena { Name = name };
try
{
_terrainBuffers.Set(_dynamicFrameSlot, created);
return created;
}
catch
{
TrackedGlResource.DeleteBuffer(gl, name, 0, "ClipFrame terrain UBO rollback");
throw;
}
}
private void ValidateGlContext(GL gl)
{
ArgumentNullException.ThrowIfNull(gl);
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before uploading clip data.");
if (_gl is null)
_gl = gl;
else if (!ReferenceEquals(_gl, gl))
throw new InvalidOperationException("ClipFrame cannot span OpenGL contexts.");
} }
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (_disposed || _disposing) return;
_disposed = true; _disposing = true;
// ClipFrame is long-lived in U.3 (GameWindow holds one via ??= NoClip() and try
// reuses it every frame), so we own the two GL buffers and delete them here.
// _glInitialized guards the case where UploadShared never ran (no buffers to
// delete, and _gl was never captured).
if (_glInitialized && _gl is not null)
{ {
_gl.DeleteBuffer(_regionSsbo); if (_disposeResources is null)
_gl.DeleteBuffer(_terrainUbo); {
var releases = new List<(string Name, Action Release)>();
if (_gl is not null)
{
int regionIndex = 0;
foreach (RegionBuffer set in _regionBuffers.Values)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
set.Name,
set.CapacityBytes,
"ClipFrame region SSBO disposal");
releases.Add(($"region-ssbo-{regionIndex++}", release.Run));
}
int terrainIndex = 0;
foreach (TerrainBufferArena arena in _terrainBuffers.Values)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
arena.Name,
arena.CapacityBytes,
"ClipFrame terrain UBO disposal");
releases.Add(($"terrain-ubo-{terrainIndex++}", release.Run));
}
}
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
{
throw attempt.ToException(
"One or more ClipFrame GPU buffers could not be released.");
}
_regionSsbo = 0; _regionSsbo = 0;
_terrainUbo = 0; _terrainUbo = 0;
_terrainBinding = default;
_dynamicFrameStarted = false;
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
{
throw attempt.ToException(
"ClipFrame GPU buffers released with exceptional committed outcomes.");
}
}
finally
{
_disposing = false;
} }
} }
@ -310,3 +577,230 @@ public sealed class ClipFrame : IDisposable
/// <summary>Test seam: the packed std140 terrain UBO bytes.</summary> /// <summary>Test seam: the packed std140 terrain UBO bytes.</summary>
internal ReadOnlySpan<byte> TerrainBytesForTest => _terrainBytes; internal ReadOnlySpan<byte> TerrainBytesForTest => _terrainBytes;
} }
/// <summary>A single std140 terrain-clip record within a frame-slot UBO arena.</summary>
public readonly record struct TerrainClipBufferBinding(
uint Buffer,
int OffsetBytes,
int SizeBytes)
{
public bool IsValid => Buffer != 0 && OffsetBytes >= 0 && SizeBytes > 0;
public void Bind(GL gl)
{
ArgumentNullException.ThrowIfNull(gl);
if (!IsValid)
throw new InvalidOperationException("Cannot bind an empty terrain clip range.");
gl.BindBufferRange(
BufferTargetARB.UniformBuffer,
ClipFrame.TerrainClipUboBinding,
Buffer,
(nint)OffsetBytes,
(nuint)SizeBytes);
GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain UBO range binding");
}
}
internal static class ClipFrameArenaLayout
{
public static int RecordStride(int uniformBufferOffsetAlignment)
{
ArgumentOutOfRangeException.ThrowIfLessThan(uniformBufferOffsetAlignment, 1);
long stride = ((long)ClipFrame.TerrainUboBytes + uniformBufferOffsetAlignment - 1L)
/ uniformBufferOffsetAlignment
* uniformBufferOffsetAlignment;
if (stride > int.MaxValue)
throw new OverflowException("Terrain clip UBO record stride exceeds Int32.MaxValue.");
return (int)stride;
}
public static int RecordOffset(int recordIndex, int recordStrideBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(recordIndex);
ArgumentOutOfRangeException.ThrowIfLessThan(recordStrideBytes, ClipFrame.TerrainUboBytes);
return checked(recordIndex * recordStrideBytes);
}
public static int RequiredBytes(int recordCount, int recordStrideBytes)
{
ArgumentOutOfRangeException.ThrowIfLessThan(recordCount, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(recordStrideBytes, ClipFrame.TerrainUboBytes);
return checked(recordCount * recordStrideBytes);
}
}
internal static class ClipBufferCapacityTransaction
{
/// <summary>Publishes capacity only after BufferData succeeds, and before
/// any later upload/bind operation can throw.</summary>
public static void Resize(
ref int publishedCapacityBytes,
int targetCapacityBytes,
Action<int, int> allocate)
{
ArgumentOutOfRangeException.ThrowIfNegative(publishedCapacityBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(targetCapacityBytes, 1);
ArgumentNullException.ThrowIfNull(allocate);
int previousCapacityBytes = publishedCapacityBytes;
allocate(previousCapacityBytes, targetCapacityBytes);
publishedCapacityBytes = targetCapacityBytes;
}
}
/// <summary>
/// Growth is immediate; shrinking requires repeated severe under-use. This
/// avoids reallocating during ordinary portal-count jitter while ensuring a
/// one-off pathological flood does not permanently pin its peak GPU storage.
/// </summary>
internal struct ClipBufferCapacityPolicy
{
internal const int ShrinkAfterUnderusedFrames = 3;
internal const int ShrinkUtilizationDivisor = 4;
private int _underusedFrames;
public int SelectCapacity(int currentBytes, int requiredBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(currentBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(requiredBytes, 1);
if (requiredBytes > currentBytes)
{
_underusedFrames = 0;
return DynamicBufferCapacity.Grow(currentBytes, requiredBytes);
}
if ((long)requiredBytes * ShrinkUtilizationDivisor <= currentBytes)
{
_underusedFrames++;
if (_underusedFrames >= ShrinkAfterUnderusedFrames)
{
_underusedFrames = 0;
return DynamicBufferCapacity.Grow(0, requiredBytes);
}
}
else
{
_underusedFrames = 0;
}
return currentBytes;
}
}
/// <summary>Fixed-cardinality resource ownership indexed by the same frame
/// slots protected by <see cref="GpuFrameFlightController"/>.</summary>
internal sealed class ClipFrameResourceRing<T>(int slotCount) where T : class
{
private readonly T?[] _slots = new T?[slotCount > 0
? slotCount
: throw new ArgumentOutOfRangeException(nameof(slotCount))];
public int Count { get; private set; }
public bool TryGet(int slot, out T? resource)
{
ValidateSlot(slot);
resource = _slots[slot];
return resource is not null;
}
public T GetRequired(int slot)
{
ValidateSlot(slot);
return _slots[slot]
?? throw new InvalidOperationException($"Frame slot {slot} has no resource.");
}
public void Set(int slot, T resource)
{
ValidateSlot(slot);
ArgumentNullException.ThrowIfNull(resource);
if (_slots[slot] is not null)
throw new InvalidOperationException($"Frame slot {slot} already owns a resource.");
_slots[slot] = resource;
Count++;
}
public IEnumerable<T> Values
{
get
{
for (int i = 0; i < _slots.Length; i++)
if (_slots[i] is T value)
yield return value;
}
}
private void ValidateSlot(int slot)
{
if ((uint)slot >= (uint)_slots.Length)
throw new ArgumentOutOfRangeException(nameof(slot));
}
}
/// <summary>Pure sequencing state for one ClipFrame render submission.</summary>
internal sealed class ClipFrameUploadState
{
private bool _frameStarted;
private bool _regionsUploaded;
private int _terrainReserved;
private int _terrainCursor;
public int TerrainReserved => _terrainReserved;
public int TerrainUploaded => _terrainCursor;
public bool RegionsUploaded => _regionsUploaded;
public void BeginFrame()
{
_frameStarted = true;
_regionsUploaded = false;
_terrainReserved = 0;
_terrainCursor = 0;
}
public void ValidateRegionsNotUploaded()
{
EnsureFrameStarted();
if (_regionsUploaded)
throw new InvalidOperationException("Clip regions may be uploaded only once per frame.");
}
public void MarkRegionsUploaded()
{
ValidateRegionsNotUploaded();
_regionsUploaded = true;
}
public void ValidateTerrainReservation(int uploadCount)
{
EnsureFrameStarted();
ArgumentOutOfRangeException.ThrowIfLessThan(uploadCount, 1);
if (_terrainReserved != 0)
throw new InvalidOperationException("Terrain uploads have already been reserved for this frame.");
}
public void CommitTerrainReservation(int uploadCount)
{
ValidateTerrainReservation(uploadCount);
_terrainReserved = uploadCount;
}
public int NextTerrainRecord()
{
EnsureFrameStarted();
if (_terrainReserved == 0)
throw new InvalidOperationException("ReserveTerrainUploads must run before terrain clip uploads.");
if (_terrainCursor >= _terrainReserved)
throw new InvalidOperationException("Terrain clip uploads exceeded the reserved arena record count.");
return _terrainCursor++;
}
private void EnsureFrameStarted()
{
if (!_frameStarted)
throw new InvalidOperationException("BeginFrame must be called before uploading clip data.");
}
}

View file

@ -45,47 +45,220 @@ public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[]
/// </summary> /// </summary>
public sealed class ClipFrameAssembly public sealed class ClipFrameAssembly
{ {
public required ClipFrame Frame { get; init; } public ClipFrame Frame { get; private set; } = null!;
/// <summary>First drawable slice slot per visible cell. Compatibility map /// <summary>First drawable slice slot per visible cell. Compatibility map
/// for renderer APIs that can accept only one slot at a time.</summary> /// for renderer APIs that can accept only one slot at a time.</summary>
public required Dictionary<uint, int> CellIdToSlot { get; init; } public Dictionary<uint, int> CellIdToSlot { get; } = new();
/// <summary>Slot-only cell slices, retained for older renderer APIs.</summary> /// <summary>Slot-only cell slices, retained for older renderer APIs.</summary>
public required Dictionary<uint, int[]> CellIdToViewSlots { get; init; } public Dictionary<uint, int[]> CellIdToViewSlots { get; } = new();
/// <summary>Full retail portal_view slices per visible cell.</summary> /// <summary>Full retail portal_view slices per visible cell.</summary>
public required Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; init; } public Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; } = new();
/// <summary>Full retail outside_view slices.</summary> /// <summary>Full retail outside_view slices.</summary>
public required ClipViewSlice[] OutsideViewSlices { get; init; } public ClipViewSlice[] OutsideViewSlices { get; private set; } = System.Array.Empty<ClipViewSlice>();
public required int OutdoorSlot { get; init; } public int OutdoorSlot { get; internal set; }
public required bool OutdoorVisible { get; init; } public bool OutdoorVisible { get; internal set; }
public required TerrainClipMode TerrainMode { get; init; } public TerrainClipMode TerrainMode { get; internal set; }
public required Vector4 TerrainScissorNdcAabb { get; init; } public Vector4 TerrainScissorNdcAabb { get; internal set; }
public required bool HasOutsideView { get; init; } public bool HasOutsideView { get; internal set; }
public required Vector4 OutsideViewNdcAabb { get; init; } public Vector4 OutsideViewNdcAabb { get; internal set; }
// Probe data. // Probe data.
public required int OutsidePlaneCount { get; init; } public int OutsidePlaneCount { get; internal set; }
public required Dictionary<uint, int> PerCellPlaneCounts { get; init; } public Dictionary<uint, int> PerCellPlaneCounts { get; } = new();
public required int ScissorFallbacks { get; init; } public int ScissorFallbacks { get; internal set; }
// The assembly is frame-scoped. An owner that passes it back to Assemble may reuse the
// dictionaries, per-cell arrays, and construction list after the prior frame is consumed.
// Exact-length pools keep the public array API honest: Length is always the live slice count.
private readonly Dictionary<int, Stack<ClipViewSlice[]>> _sliceArraysByLength = new();
private readonly Dictionary<int, Stack<int[]>> _slotArraysByLength = new();
internal List<ClipViewSlice> SliceScratch { get; } = new();
internal int SliceArrayAllocationCount { get; private set; }
internal int SlotArrayAllocationCount { get; private set; }
internal const int MaxRetainedSliceItems = 4096;
internal const int MaxRetainedSlotItems = 8192;
internal const int MaxRetainedArraysPerPool = 128;
internal int RetainedSliceItems { get; private set; }
internal int RetainedSlotItems { get; private set; }
internal int RetainedSliceArrays { get; private set; }
internal int RetainedSlotArrays { get; private set; }
internal void Reset(ClipFrame frame)
{
Frame = frame;
foreach (ClipViewSlice[] slices in CellIdToViewSlices.Values)
ReturnSlices(slices);
foreach (int[] slots in CellIdToViewSlots.Values)
ReturnSlots(slots);
if (OutsideViewSlices.Length != 0)
ReturnSlices(OutsideViewSlices);
CellIdToSlot.Clear();
CellIdToViewSlots.Clear();
CellIdToViewSlices.Clear();
PerCellPlaneCounts.Clear();
OutsideViewSlices = System.Array.Empty<ClipViewSlice>();
SliceScratch.Clear();
}
internal ClipViewSlice[] CopySlices(List<ClipViewSlice> source)
{
if (source.Count == 0)
return System.Array.Empty<ClipViewSlice>();
ClipViewSlice[] result = RentSlices(source.Count);
source.CopyTo(result, 0);
return result;
}
internal int[] CopySlots(ClipViewSlice[] slices)
{
if (slices.Length == 0)
return System.Array.Empty<int>();
int[] result = RentSlots(slices.Length);
for (int i = 0; i < slices.Length; i++)
result[i] = slices[i].Slot;
return result;
}
internal void SetOutsideViewSlices(ClipViewSlice[] slices) => OutsideViewSlices = slices;
private ClipViewSlice[] RentSlices(int length)
{
if (_sliceArraysByLength.TryGetValue(length, out Stack<ClipViewSlice[]>? pool)
&& pool.Count != 0)
{
ClipViewSlice[] result = pool.Pop();
RetainedSliceItems -= result.Length;
RetainedSliceArrays--;
if (pool.Count == 0)
_sliceArraysByLength.Remove(length);
return result;
}
SliceArrayAllocationCount++;
return new ClipViewSlice[length];
}
private int[] RentSlots(int length)
{
if (_slotArraysByLength.TryGetValue(length, out Stack<int[]>? pool)
&& pool.Count != 0)
{
int[] result = pool.Pop();
RetainedSlotItems -= result.Length;
RetainedSlotArrays--;
if (pool.Count == 0)
_slotArraysByLength.Remove(length);
return result;
}
SlotArrayAllocationCount++;
return new int[length];
}
private void ReturnSlices(ClipViewSlice[] array)
{
// ClipViewSlice contains a Vector4[] reference. Clear before either retaining or dropping
// the array so a bounded cache cannot accidentally extend plane-payload lifetimes.
System.Array.Clear(array);
if (array.Length > MaxRetainedSliceItems)
return;
while (RetainedSliceItems + array.Length > MaxRetainedSliceItems
|| RetainedSliceArrays >= MaxRetainedArraysPerPool)
{
if (!EvictOneSliceArray())
break;
}
if (!_sliceArraysByLength.TryGetValue(array.Length, out Stack<ClipViewSlice[]>? pool))
{
pool = new Stack<ClipViewSlice[]>();
_sliceArraysByLength.Add(array.Length, pool);
}
pool.Push(array);
RetainedSliceItems += array.Length;
RetainedSliceArrays++;
}
private void ReturnSlots(int[] array)
{
if (array.Length > MaxRetainedSlotItems)
return;
while (RetainedSlotItems + array.Length > MaxRetainedSlotItems
|| RetainedSlotArrays >= MaxRetainedArraysPerPool)
{
if (!EvictOneSlotArray())
break;
}
if (!_slotArraysByLength.TryGetValue(array.Length, out Stack<int[]>? pool))
{
pool = new Stack<int[]>();
_slotArraysByLength.Add(array.Length, pool);
}
pool.Push(array);
RetainedSlotItems += array.Length;
RetainedSlotArrays++;
}
private bool EvictOneSliceArray()
{
int selectedLength = -1;
foreach ((int length, Stack<ClipViewSlice[]> pool) in _sliceArraysByLength)
{
if (pool.Count != 0 && length > selectedLength)
selectedLength = length;
}
if (selectedLength < 0)
return false;
Stack<ClipViewSlice[]> selected = _sliceArraysByLength[selectedLength];
ClipViewSlice[] evicted = selected.Pop();
RetainedSliceItems -= evicted.Length;
RetainedSliceArrays--;
if (selected.Count == 0)
_sliceArraysByLength.Remove(selectedLength);
return true;
}
private bool EvictOneSlotArray()
{
int selectedLength = -1;
foreach ((int length, Stack<int[]> pool) in _slotArraysByLength)
{
if (pool.Count != 0 && length > selectedLength)
selectedLength = length;
}
if (selectedLength < 0)
return false;
Stack<int[]> selected = _slotArraysByLength[selectedLength];
int[] evicted = selected.Pop();
RetainedSlotItems -= evicted.Length;
RetainedSlotArrays--;
if (selected.Count == 0)
_slotArraysByLength.Remove(selectedLength);
return true;
}
} }
public static class ClipFrameAssembler public static class ClipFrameAssembler
{ {
public static ClipFrameAssembly Assemble(ClipFrame frame, PortalVisibilityFrame pvFrame) public static ClipFrameAssembly Assemble(
ClipFrame frame,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly? reuseAssembly = null)
{ {
System.ArgumentNullException.ThrowIfNull(frame); System.ArgumentNullException.ThrowIfNull(frame);
System.ArgumentNullException.ThrowIfNull(pvFrame); System.ArgumentNullException.ThrowIfNull(pvFrame);
frame.Reset(); frame.Reset();
ClipFrameAssembly assembly = reuseAssembly ?? new ClipFrameAssembly();
assembly.Reset(frame);
var cellIdToSlot = new Dictionary<uint, int>(); Dictionary<uint, int> cellIdToSlot = assembly.CellIdToSlot;
var cellIdToViewSlots = new Dictionary<uint, int[]>(); Dictionary<uint, int[]> cellIdToViewSlots = assembly.CellIdToViewSlots;
var cellIdToViewSlices = new Dictionary<uint, ClipViewSlice[]>(); Dictionary<uint, ClipViewSlice[]> cellIdToViewSlices = assembly.CellIdToViewSlices;
var perCellPlaneCounts = new Dictionary<uint, int>(); Dictionary<uint, int> perCellPlaneCounts = assembly.PerCellPlaneCounts;
int scissorFallbacks = 0; int scissorFallbacks = 0;
foreach (uint cellId in pvFrame.OrderedVisibleCells) foreach (uint cellId in pvFrame.OrderedVisibleCells)
@ -93,12 +266,13 @@ public static class ClipFrameAssembler
if (!pvFrame.CellViews.TryGetValue(cellId, out var view)) if (!pvFrame.CellViews.TryGetValue(cellId, out var view))
continue; continue;
var slices = new List<ClipViewSlice>(view.Polygons.Count); List<ClipViewSlice> slices = assembly.SliceScratch;
slices.Clear();
int maxPlaneCount = 0; int maxPlaneCount = 0;
foreach (var poly in view.Polygons) foreach (var poly in view.Polygons)
{ {
var cps = ClipPlaneSet.From(ViewOf(poly)); var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible) if (cps.IsNothingVisible)
continue; continue;
@ -106,7 +280,7 @@ public static class ClipFrameAssembler
Vector4[] planes; Vector4[] planes;
if (cps.Count > 0) if (cps.Count > 0)
{ {
planes = ToPlaneSpan(cps); planes = cps.PlaneArray;
slot = frame.AppendSlot(planes); slot = frame.AppendSlot(planes);
if (cps.Count > maxPlaneCount) if (cps.Count > maxPlaneCount)
maxPlaneCount = cps.Count; maxPlaneCount = cps.Count;
@ -124,20 +298,21 @@ public static class ClipFrameAssembler
if (slices.Count == 0) if (slices.Count == 0)
continue; continue;
var sliceArray = slices.ToArray(); ClipViewSlice[] sliceArray = assembly.CopySlices(slices);
cellIdToViewSlices[cellId] = sliceArray; cellIdToViewSlices[cellId] = sliceArray;
cellIdToViewSlots[cellId] = ToSlots(sliceArray); cellIdToViewSlots[cellId] = assembly.CopySlots(sliceArray);
cellIdToSlot[cellId] = sliceArray[0].Slot; cellIdToSlot[cellId] = sliceArray[0].Slot;
perCellPlaneCounts[cellId] = maxPlaneCount; perCellPlaneCounts[cellId] = maxPlaneCount;
} }
var outsideSlicesList = new List<ClipViewSlice>(pvFrame.OutsideView.Polygons.Count); List<ClipViewSlice> outsideSlicesList = assembly.SliceScratch;
outsideSlicesList.Clear();
int outsideMaxPlaneCount = 0; int outsideMaxPlaneCount = 0;
bool outsideHasScissorFallback = false; bool outsideHasScissorFallback = false;
foreach (var poly in pvFrame.OutsideView.Polygons) foreach (var poly in pvFrame.OutsideView.Polygons)
{ {
var cps = ClipPlaneSet.From(ViewOf(poly)); var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible) if (cps.IsNothingVisible)
continue; continue;
@ -145,7 +320,7 @@ public static class ClipFrameAssembler
Vector4[] planes; Vector4[] planes;
if (cps.Count > 0) if (cps.Count > 0)
{ {
planes = ToPlaneSpan(cps); planes = cps.PlaneArray;
slot = frame.AppendSlot(planes); slot = frame.AppendSlot(planes);
if (cps.Count > outsideMaxPlaneCount) if (cps.Count > outsideMaxPlaneCount)
outsideMaxPlaneCount = cps.Count; outsideMaxPlaneCount = cps.Count;
@ -161,7 +336,7 @@ public static class ClipFrameAssembler
outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes)); outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
} }
var outsideViewSlices = outsideSlicesList.ToArray(); ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList);
bool outdoorVisible = outsideViewSlices.Length > 0; bool outdoorVisible = outsideViewSlices.Length > 0;
int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0; int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0;
TerrainClipMode terrainMode = !outdoorVisible TerrainClipMode terrainMode = !outdoorVisible
@ -176,49 +351,19 @@ public static class ClipFrameAssembler
? outsideViewNdcAabb ? outsideViewNdcAabb
: Vector4.Zero; : Vector4.Zero;
return new ClipFrameAssembly assembly.SetOutsideViewSlices(outsideViewSlices);
{ assembly.OutdoorSlot = outdoorSlot;
Frame = frame, assembly.OutdoorVisible = outdoorVisible;
CellIdToSlot = cellIdToSlot, assembly.TerrainMode = terrainMode;
CellIdToViewSlots = cellIdToViewSlots, assembly.TerrainScissorNdcAabb = terrainScissor;
CellIdToViewSlices = cellIdToViewSlices, assembly.HasOutsideView = outdoorVisible;
OutsideViewSlices = outsideViewSlices, assembly.OutsideViewNdcAabb = outsideViewNdcAabb;
OutdoorSlot = outdoorSlot, assembly.OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0;
OutdoorVisible = outdoorVisible, assembly.ScissorFallbacks = scissorFallbacks;
TerrainMode = terrainMode, return assembly;
TerrainScissorNdcAabb = terrainScissor,
HasOutsideView = outdoorVisible,
OutsideViewNdcAabb = outsideViewNdcAabb,
OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0,
PerCellPlaneCounts = perCellPlaneCounts,
ScissorFallbacks = scissorFallbacks,
};
}
private static CellView ViewOf(ViewPolygon poly)
{
var view = new CellView();
view.Add(poly);
return view;
} }
private static Vector4 AabbOf(ViewPolygon poly) => private static Vector4 AabbOf(ViewPolygon poly) =>
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY); new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);
private static int[] ToSlots(ClipViewSlice[] slices)
{
var slots = new int[slices.Length];
for (int i = 0; i < slices.Length; i++)
slots[i] = slices[i].Slot;
return slots;
}
private static Vector4[] ToPlaneSpan(ClipPlaneSet set)
{
int n = set.Count;
var planes = new Vector4[n];
for (int i = 0; i < n; i++)
planes[i] = set.Planes[i];
return planes;
}
} }

View file

@ -38,6 +38,7 @@
// pass-all" slot 0 is constructed by the consumer directly, not via From().) // pass-all" slot 0 is constructed by the consumer directly, not via From().)
// When Count > 0, Planes carries the convex gate and the scissor fields are unused. // When Count > 0, Planes carries the convex gate and the scissor fields are unused.
using System; using System;
using System.Buffers;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
@ -84,6 +85,10 @@ public readonly struct ClipPlaneSet
/// A clip-space point <c>clip</c> is inside iff <c>Vector4.Dot(plane, clip) &gt;= 0</c> for every plane.</summary> /// A clip-space point <c>clip</c> is inside iff <c>Vector4.Dot(plane, clip) &gt;= 0</c> for every plane.</summary>
public IReadOnlyList<Vector4> Planes => _planes ?? (IReadOnlyList<Vector4>)Array.Empty<Vector4>(); public IReadOnlyList<Vector4> Planes => _planes ?? (IReadOnlyList<Vector4>)Array.Empty<Vector4>();
// The set is immutable after construction. ClipFrameAssembler transfers this exact array into
// its frame-scoped slice instead of cloning every plane payload a second time.
internal Vector4[] PlaneArray => _planes ?? Array.Empty<Vector4>();
/// <summary>True ⇒ the convex-plane budget was exceeded; gate on <see cref="ScissorNdcAabb"/> /// <summary>True ⇒ the convex-plane budget was exceeded; gate on <see cref="ScissorNdcAabb"/>
/// instead (draw the box). Always false when <see cref="Count"/> &gt; 0 or when the region is empty.</summary> /// instead (draw the box). Always false when <see cref="Count"/> &gt; 0 or when the region is empty.</summary>
public bool UseScissorFallback { get; } public bool UseScissorFallback { get; }
@ -119,34 +124,68 @@ public readonly struct ClipPlaneSet
if (region.Polygons.Count > 1) if (region.Polygons.Count > 1)
return Scissor(region.MinX, region.MinY, region.MaxX, region.MaxY); return Scissor(region.MinX, region.MinY, region.MaxX, region.MaxY);
// Exactly one polygon: normalize winding to CCW, merge collinear edges, then decide. return From(region.Polygons[0]);
Vector2[] verts = NormalizeAndMerge(region.Polygons[0].Vertices); }
// Fewer than 3 distinct edges survive ⇒ a sliver/line with no area. There is no /// <summary>
// meaningful AABB to over-include (a zero-area region), so treat it as nothing visible. /// Reduce one convex view polygon without wrapping it in a temporary <see cref="CellView"/>.
if (verts.Length < 3) /// The clip-frame assembler already iterates individual retail <c>view_poly</c> slices, so
/// constructing a CellView there needlessly ran the flood's canonical-key/dedup machinery for
/// every slice on every frame. This overload is behavior-identical to the one-polygon branch
/// of <see cref="From(CellView)"/> and avoids that unrelated allocation path.
/// </summary>
public static ClipPlaneSet From(in ViewPolygon polygon)
{
if (polygon.IsEmpty)
return Empty; return Empty;
// A single convex polygon with too many edges to fit the hardware budget ⇒ scissor // Exactly one polygon: normalize winding to CCW and merge collinear edges in reusable
// on ITS own AABB (still a superset of the polygon → over-include, safe). // scratch. The surviving vertices are consumed immediately to build the actual plane
if (verts.Length > MaxPlanes) // payload; there is no reason to allocate a second exact-sized polygon array per slice.
return Scissor(verts); Vector2[] input = polygon.Vertices;
Vector2[]? rented = null;
Span<Vector2> verts = input.Length <= 32
? stackalloc Vector2[input.Length]
: (rented = ArrayPool<Vector2>.Shared.Rent(input.Length)).AsSpan(0, input.Length);
// 3..8 edges: emit one inward half-space plane per edge (CCW formula). try
var planes = new Vector4[verts.Length];
for (int i = 0; i < verts.Length; i++)
{ {
Vector2 p = verts[i]; int count = NormalizeAndMerge(input, verts);
Vector2 q = verts[(i + 1) % verts.Length];
Vector2 dir = q - p; // Fewer than 3 distinct edges survive ⇒ a sliver/line with no area. There is no
// Inward normal for CCW winding: perp(dir) = (-dir.y, dir.x) points to the polygon's // meaningful AABB to over-include (a zero-area region), so treat it as nothing visible.
// interior (the "left" side of the directed edge p→q). if (count < 3)
Vector2 n = Vector2.Normalize(new Vector2(-dir.Y, dir.X)); return Empty;
// Plane: n·x + d >= 0 inside, with d = -(n·p). In clip space with NDC x = clip.x/clip.w:
// dist = n.x*clip.x + n.y*clip.y + 0*clip.z + (-(n·p))*clip.w (>= 0 ⇒ keep) ReadOnlySpan<Vector2> normalized = verts[..count];
planes[i] = new Vector4(n.X, n.Y, 0f, -Vector2.Dot(n, p));
// A single convex polygon with too many edges to fit the hardware budget ⇒ scissor
// on ITS own AABB (still a superset of the polygon → over-include, safe).
if (count > MaxPlanes)
return Scissor(normalized);
// 3..8 edges: emit one inward half-space plane per edge (CCW formula). This array is
// the retained GPU-routing payload and therefore the one necessary allocation.
var planes = new Vector4[count];
for (int i = 0; i < count; i++)
{
Vector2 p = normalized[i];
Vector2 q = normalized[(i + 1) % count];
Vector2 dir = q - p;
// Inward normal for CCW winding: perp(dir) = (-dir.y, dir.x) points to the polygon's
// interior (the "left" side of the directed edge p→q).
Vector2 n = Vector2.Normalize(new Vector2(-dir.Y, dir.X));
// Plane: n·x + d >= 0 inside, with d = -(n·p). In clip space with NDC x = clip.x/clip.w:
// dist = n.x*clip.x + n.y*clip.y + 0*clip.z + (-(n·p))*clip.w (>= 0 ⇒ keep)
planes[i] = new Vector4(n.X, n.Y, 0f, -Vector2.Dot(n, p));
}
return new ClipPlaneSet(planes, useScissorFallback: false, isNothingVisible: false, scissorNdcAabb: DegenerateAabb);
}
finally
{
if (rented is not null)
ArrayPool<Vector2>.Shared.Return(rented);
} }
return new ClipPlaneSet(planes, useScissorFallback: false, isNothingVisible: false, scissorNdcAabb: DegenerateAabb);
} }
private static ClipPlaneSet Scissor(float minX, float minY, float maxX, float maxY) => private static ClipPlaneSet Scissor(float minX, float minY, float maxX, float maxY) =>
@ -171,41 +210,41 @@ public readonly struct ClipPlaneSet
/// already EnsureCcw's its output, but From() is a public entry point that must be robust to /// already EnsureCcw's its output, but From() is a public entry point that must be robust to
/// either winding (e.g. a hand-built CellView), so we normalize here too. /// either winding (e.g. a hand-built CellView), so we normalize here too.
/// </summary> /// </summary>
private static Vector2[] NormalizeAndMerge(Vector2[] input) private static int NormalizeAndMerge(ReadOnlySpan<Vector2> input, Span<Vector2> points)
{ {
if (input is null || input.Length < 3) if (input.Length < 3)
return Array.Empty<Vector2>(); return 0;
// 1) Drop exact/near-duplicate consecutive points first so edge directions are well-defined. // 1) Drop exact/near-duplicate consecutive points first so edge directions are well-defined.
var pts = new List<Vector2>(input.Length); int count = 0;
foreach (var v in input) foreach (Vector2 vertex in input)
{ {
if (pts.Count == 0 || (v - pts[^1]).LengthSquared() > DegenerateEdgeLen * DegenerateEdgeLen) if (count == 0 || (vertex - points[count - 1]).LengthSquared() > DegenerateEdgeLen * DegenerateEdgeLen)
pts.Add(v); points[count++] = vertex;
} }
// Wrap-around duplicate (last == first). // Wrap-around duplicate (last == first).
if (pts.Count >= 2 && (pts[^1] - pts[0]).LengthSquared() <= DegenerateEdgeLen * DegenerateEdgeLen) if (count >= 2 && (points[count - 1] - points[0]).LengthSquared() <= DegenerateEdgeLen * DegenerateEdgeLen)
pts.RemoveAt(pts.Count - 1); count--;
if (pts.Count < 3) if (count < 3)
return Array.Empty<Vector2>(); return 0;
// 2) Force CCW winding (positive signed area). perp(dir)=(-y,x) is the inward normal only // 2) Force CCW winding (positive signed area). perp(dir)=(-y,x) is the inward normal only
// for CCW; if the caller handed us CW, reverse so the plane signs come out inside-positive. // for CCW; if the caller handed us CW, reverse so the plane signs come out inside-positive.
if (SignedArea2(pts) < 0f) if (SignedArea2(points[..count]) < 0f)
pts.Reverse(); points[..count].Reverse();
// 3) Merge collinear edges: drop vertex i when edge (i-1→i) and edge (i→i+1) point the same // 3) Merge collinear edges: drop vertex i when edge (i-1→i) and edge (i→i+1) point the same
// way (turn angle < ~0.5°). Iterate until stable — removing one vertex can expose a new // way (turn angle < ~0.5°). Iterate until stable — removing one vertex can expose a new
// collinear triple. |cross(a,b)| of unit dirs = |sin θ|; dot>0 rules out a 180° reversal. // collinear triple. |cross(a,b)| of unit dirs = |sin θ|; dot>0 rules out a 180° reversal.
bool changed = true; bool changed = true;
while (changed && pts.Count >= 3) while (changed && count >= 3)
{ {
changed = false; changed = false;
for (int i = 0; i < pts.Count; i++) for (int i = 0; i < count; i++)
{ {
Vector2 prev = pts[(i - 1 + pts.Count) % pts.Count]; Vector2 prev = points[(i - 1 + count) % count];
Vector2 cur = pts[i]; Vector2 cur = points[i];
Vector2 next = pts[(i + 1) % pts.Count]; Vector2 next = points[(i + 1) % count];
Vector2 d0 = cur - prev; Vector2 d0 = cur - prev;
Vector2 d1 = next - cur; Vector2 d1 = next - cur;
@ -213,7 +252,8 @@ public readonly struct ClipPlaneSet
float l1 = d1.Length(); float l1 = d1.Length();
if (l0 < DegenerateEdgeLen || l1 < DegenerateEdgeLen) if (l0 < DegenerateEdgeLen || l1 < DegenerateEdgeLen)
{ {
pts.RemoveAt(i); points[(i + 1)..count].CopyTo(points[i..]);
count--;
changed = true; changed = true;
break; break;
} }
@ -224,34 +264,35 @@ public readonly struct ClipPlaneSet
float dot = d0.X * d1.X + d0.Y * d1.Y; // cos θ float dot = d0.X * d1.X + d0.Y * d1.Y; // cos θ
if (dot > 0f && MathF.Abs(cross) < CollinearSinEps) if (dot > 0f && MathF.Abs(cross) < CollinearSinEps)
{ {
pts.RemoveAt(i); // cur lies on the straight line prev→next points[(i + 1)..count].CopyTo(points[i..]);
count--; // cur lies on the straight line prev→next
changed = true; changed = true;
break; break;
} }
} }
} }
if (pts.Count < 3) if (count < 3)
return Array.Empty<Vector2>(); return 0;
// Final degeneracy gate: a polygon with negligible area is a line/point even if it still // Final degeneracy gate: a polygon with negligible area is a line/point even if it still
// has >= 3 distinct vertices (e.g. an edge-on portal, or a near-collinear triple the 0.5° // has >= 3 distinct vertices (e.g. an edge-on portal, or a near-collinear triple the 0.5°
// merge didn't quite collapse). Emitting its planes would yield an empty half-space // merge didn't quite collapse). Emitting its planes would yield an empty half-space
// intersection that silently gates out everything; report it honestly as nothing-visible. // intersection that silently gates out everything; report it honestly as nothing-visible.
if (MathF.Abs(SignedArea2(pts)) * 0.5f < MinPolygonArea) if (MathF.Abs(SignedArea2(points[..count])) * 0.5f < MinPolygonArea)
return Array.Empty<Vector2>(); return 0;
return pts.ToArray(); return count;
} }
// Twice the signed area (the "shoelace" sum). > 0 ⇒ CCW, < 0 ⇒ CW. // Twice the signed area (the "shoelace" sum). > 0 ⇒ CCW, < 0 ⇒ CW.
private static float SignedArea2(List<Vector2> poly) private static float SignedArea2(ReadOnlySpan<Vector2> poly)
{ {
float a = 0f; float a = 0f;
for (int i = 0; i < poly.Count; i++) for (int i = 0; i < poly.Length; i++)
{ {
Vector2 p = poly[i]; Vector2 p = poly[i];
Vector2 q = poly[(i + 1) % poly.Count]; Vector2 q = poly[(i + 1) % poly.Length];
a += p.X * q.Y - q.X * p.Y; a += p.X * q.Y - q.X * p.Y;
} }
return a; return a;

View file

@ -0,0 +1,933 @@
using AcDream.Core.Textures;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Location of one decoded entity-material composite in a resident bindless
/// texture array. The modern mesh shader consumes this exact pair.
/// </summary>
public readonly record struct BindlessTextureLocation(ulong Handle, uint Layer);
internal enum CompositeTextureKind : byte
{
OriginalTextureOverride,
PaletteComposite,
}
/// <summary>
/// Structural palette identity. The precomputed hash accelerates dictionary
/// bucketing, but equality still compares every server-supplied range so a
/// hash collision can never share the wrong material pixels.
/// </summary>
internal readonly struct PaletteCompositeIdentity : IEquatable<PaletteCompositeIdentity>
{
private readonly IReadOnlyList<PaletteOverride.SubPaletteRange>? _ranges;
public PaletteCompositeIdentity(PaletteOverride palette, ulong hash)
{
ArgumentNullException.ThrowIfNull(palette);
BasePaletteId = palette.BasePaletteId;
Hash = hash;
_ranges = palette.SubPalettes;
}
public uint BasePaletteId { get; }
public ulong Hash { get; }
public int RangeCount => _ranges?.Count ?? 0;
public bool Equals(PaletteCompositeIdentity other)
{
if (Hash != other.Hash
|| BasePaletteId != other.BasePaletteId
|| RangeCount != other.RangeCount)
{
return false;
}
for (int i = 0; i < RangeCount; i++)
if (_ranges![i] != other._ranges![i])
return false;
return true;
}
public override bool Equals(object? obj) =>
obj is PaletteCompositeIdentity other && Equals(other);
public override int GetHashCode() => HashCode.Combine(BasePaletteId, Hash, RangeCount);
public static bool operator ==(PaletteCompositeIdentity left, PaletteCompositeIdentity right) =>
left.Equals(right);
public static bool operator !=(PaletteCompositeIdentity left, PaletteCompositeIdentity right) =>
!left.Equals(right);
}
internal readonly record struct CompositeTextureKey(
CompositeTextureKind Kind,
uint SurfaceId,
uint OrigTextureOverride,
PaletteCompositeIdentity Palette);
internal sealed class CompositeTextureArrayResource
{
public required uint Name { get; init; }
public required ulong Handle { get; init; }
public required int Width { get; init; }
public required int Height { get; init; }
public required int Capacity { get; init; }
public required long Bytes { get; init; }
}
internal interface ICompositeTextureArrayBackend
{
int MaximumArrayLayers { get; }
CompositeTextureArrayResource Create(int width, int height, int capacity);
void Upload(CompositeTextureArrayResource resource, int layer, byte[] rgba);
void MakeNonResident(CompositeTextureArrayResource resource);
void Delete(CompositeTextureArrayResource resource);
}
/// <summary>
/// Narrow GL backend for composite arrays. Unlike ManagedGLTextureArray it
/// deliberately has one mip level, no PBO, and one resident handle: those are
/// the semantics of the standalone composite textures this pool replaces.
/// </summary>
internal sealed unsafe class GlCompositeTextureArrayBackend : ICompositeTextureArrayBackend
{
private readonly GL _gl;
private readonly Wb.BindlessSupport _bindless;
public GlCompositeTextureArrayBackend(GL gl, Wb.BindlessSupport bindless)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
_gl.GetInteger(GetPName.MaxArrayTextureLayers, out int maximumLayers);
MaximumArrayLayers = Math.Max(1, maximumLayers);
}
public int MaximumArrayLayers { get; }
public CompositeTextureArrayResource Create(int width, int height, int capacity)
{
uint name = _gl.GenTexture();
if (name == 0)
throw new InvalidOperationException("OpenGL did not create a composite texture array.");
bool resident = false;
ulong handle = 0;
long bytes = 0;
bool tracked = false;
try
{
// Composite creation/upload runs in the render thread's pre-draw
// preparation phase. Normalize that phase to texture unit zero
// instead of synchronously reading driver binding state.
_gl.ActiveTexture(TextureUnit.Texture0);
Wb.RenderStateCache.CurrentAtlas = 0;
_gl.BindTexture(TextureTarget.Texture2DArray, name);
_gl.TexStorage3D(
TextureTarget.Texture2DArray,
levels: 1,
SizedInternalFormat.Rgba8,
checked((uint)width),
checked((uint)height),
checked((uint)capacity));
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureBaseLevel, 0);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMaxLevel, 0);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
handle = _bindless.GetResidentHandle(name);
resident = true;
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"creating composite texture array {width}x{height}x{capacity}");
bytes = checked((long)width * height * 4L * capacity);
Wb.GpuMemoryTracker.TrackResourceAllocation(Wb.GpuResourceType.Texture);
Wb.GpuMemoryTracker.TrackAllocation(bytes, Wb.GpuResourceType.Texture);
tracked = true;
return new CompositeTextureArrayResource
{
Name = name,
Handle = handle,
Width = width,
Height = height,
Capacity = capacity,
Bytes = bytes,
};
}
catch (Exception creationFailure)
{
List<Exception>? cleanupFailures = null;
void Attempt(Action cleanup)
{
try { cleanup(); }
catch (Exception ex) { (cleanupFailures ??= []).Add(ex); }
}
bool residencyReleased = !resident;
if (resident)
{
Attempt(() =>
{
_bindless.MakeNonResident(handle);
Wb.GLHelpers.ThrowOnResourceError(_gl, "rolling back composite texture residency");
residencyReleased = true;
});
}
if (residencyReleased)
{
Attempt(() =>
{
_gl.DeleteTexture(name);
Wb.GLHelpers.ThrowOnResourceError(_gl, "rolling back composite texture array");
if (tracked)
{
Wb.GpuMemoryTracker.TrackDeallocation(bytes, Wb.GpuResourceType.Texture);
Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture);
}
});
}
if (cleanupFailures is not null)
{
cleanupFailures.Insert(0, creationFailure);
throw new AggregateException(
"Composite texture-array construction and rollback both failed.",
cleanupFailures);
}
throw;
}
finally
{
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
_gl.ActiveTexture(TextureUnit.Texture0);
}
}
public void Upload(CompositeTextureArrayResource resource, int layer, byte[] rgba)
{
_gl.ActiveTexture(TextureUnit.Texture0);
Wb.RenderStateCache.CurrentAtlas = 0;
try
{
_gl.BindBuffer(BufferTargetARB.PixelUnpackBuffer, 0);
_gl.BindTexture(TextureTarget.Texture2DArray, resource.Name);
fixed (byte* pixels = rgba)
{
_gl.TexSubImage3D(
TextureTarget.Texture2DArray,
level: 0,
xoffset: 0,
yoffset: 0,
zoffset: layer,
checked((uint)resource.Width),
checked((uint)resource.Height),
depth: 1,
PixelFormat.Rgba,
PixelType.UnsignedByte,
pixels);
}
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"uploading composite texture layer {layer} ({resource.Width}x{resource.Height})");
}
finally
{
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
_gl.BindBuffer(BufferTargetARB.PixelUnpackBuffer, 0);
_gl.ActiveTexture(TextureUnit.Texture0);
}
}
public void MakeNonResident(CompositeTextureArrayResource resource)
{
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"releasing composite texture handle {resource.Handle} (precondition)");
_bindless.MakeNonResident(resource.Handle);
Wb.GLHelpers.ThrowOnResourceError(_gl, $"releasing composite texture handle {resource.Handle}");
}
public void Delete(CompositeTextureArrayResource resource)
{
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"deleting composite texture array {resource.Name} (precondition)");
_gl.DeleteTexture(resource.Name);
Wb.GLHelpers.ThrowOnResourceError(_gl, $"deleting composite texture array {resource.Name}");
Wb.GpuMemoryTracker.TrackDeallocation(resource.Bytes, Wb.GpuResourceType.Texture);
Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture);
}
}
/// <summary>
/// Pools per-entity material composites into dimension-compatible texture
/// arrays. Retail releases the owning CSurface reference immediately. This
/// modern adaptation preserves that logical boundary while retaining recent
/// same-pixel layers under a bounded LRU; layer reuse waits for a GPU fence.
/// </summary>
internal sealed class CompositeTextureArrayCache : IDisposable
{
internal const long DefaultUnownedBudgetBytes = 64L * 1024 * 1024;
internal const long DefaultPhysicalBudgetBytes = 128L * 1024 * 1024;
internal const long TargetArrayBytes = 4L * 1024 * 1024;
internal const int MaximumLayersPerArray = 64;
internal const int DefaultMaximumUploadsPerFrame = 16;
internal const long DefaultMaximumUploadBytesPerFrame = 8L * 1024 * 1024;
internal const int MaximumLogicalEvictionsPerFrame = 16;
internal const int MaximumAtlasCreationsPerFrame = 1;
private readonly ICompositeTextureArrayBackend _backend;
private readonly GpuRetirementLedger _retirementLedger;
private readonly OwnerScopedResourceRegistry<CompositeTextureKey> _owners = new();
private readonly BoundedUnownedResourceCache<CompositeTextureKey> _unowned;
private readonly long _physicalBudgetBytes;
private readonly int _maximumArrayLayers;
private readonly int _maximumUploadsPerFrame;
private readonly long _maximumUploadBytesPerFrame;
private readonly Dictionary<CompositeTextureKey, Entry> _entries = new();
private readonly Dictionary<(int Width, int Height), List<Atlas>> _atlasesBySize = new();
private readonly List<Atlas> _atlases = new();
private long _allocatedBytes;
private long _useSequence;
private int _frameUploadCount;
private long _frameUploadBytes;
private int _frameAtlasCreationCount;
private bool _uploadBudgetBlocked;
private int _pendingAtlasWidth;
private int _pendingAtlasHeight;
private long _pendingAtlasAllocationBytes;
private readonly List<CompositeTextureKey> _evictionScratch = new(MaximumLogicalEvictionsPerFrame);
private bool _disposeRequested;
private bool _disposed;
private sealed class Entry
{
public required Atlas Atlas { get; init; }
public required int Layer { get; init; }
public required long Bytes { get; init; }
}
private sealed class Atlas
{
public required CompositeTextureArrayResource Resource { get; init; }
public required Wb.TextureAtlasSlotAllocator Slots { get; init; }
public int EntryCount { get; set; }
public int PendingRetirements { get; set; }
public long LastUseSequence { get; set; }
public bool ReleaseRequested { get; set; }
public AtlasReleaseStage ReleaseStage { get; set; }
public int AvailableLayers => Slots.AvailableCount;
public bool IsGpuSafeEmpty => EntryCount == 0 && PendingRetirements == 0;
public bool IsReusable => !ReleaseRequested && ReleaseStage == AtlasReleaseStage.Resident;
public bool Deleted => ReleaseStage >= AtlasReleaseStage.Deleted;
}
private enum AtlasReleaseStage : byte
{
Resident,
NonResident,
Deleted,
Accounted,
}
public CompositeTextureArrayCache(
GL gl,
Wb.BindlessSupport bindless,
IGpuResourceRetirementQueue retirementQueue,
long unownedBudgetBytes = DefaultUnownedBudgetBytes,
long physicalBudgetBytes = DefaultPhysicalBudgetBytes,
int maximumUploadsPerFrame = DefaultMaximumUploadsPerFrame,
long maximumUploadBytesPerFrame = DefaultMaximumUploadBytesPerFrame)
: this(
new GlCompositeTextureArrayBackend(gl, bindless),
retirementQueue,
unownedBudgetBytes,
physicalBudgetBytes,
maximumUploadsPerFrame,
maximumUploadBytesPerFrame)
{
}
internal CompositeTextureArrayCache(
ICompositeTextureArrayBackend backend,
IGpuResourceRetirementQueue retirementQueue,
long unownedBudgetBytes = DefaultUnownedBudgetBytes,
long physicalBudgetBytes = DefaultPhysicalBudgetBytes,
int maximumUploadsPerFrame = DefaultMaximumUploadsPerFrame,
long maximumUploadBytesPerFrame = DefaultMaximumUploadBytesPerFrame)
{
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
ArgumentNullException.ThrowIfNull(retirementQueue);
_retirementLedger = new GpuRetirementLedger(retirementQueue);
ArgumentOutOfRangeException.ThrowIfNegative(physicalBudgetBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumUploadsPerFrame, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumUploadBytesPerFrame, 1);
_unowned = new BoundedUnownedResourceCache<CompositeTextureKey>(unownedBudgetBytes);
_physicalBudgetBytes = physicalBudgetBytes;
_maximumUploadsPerFrame = maximumUploadsPerFrame;
_maximumUploadBytesPerFrame = maximumUploadBytesPerFrame;
_maximumArrayLayers = Math.Max(
1,
Math.Min(backend.MaximumArrayLayers, MaximumLayersPerArray));
}
internal int ActiveResourceCount => _owners.ResourceCount;
internal int OwnerCount => _owners.OwnerCount;
internal int CachedEntryCount => _entries.Count;
internal int UnownedEntryCount => _unowned.Count;
internal long UnownedBytes => _unowned.ResidentBytes;
internal int AtlasCount => _atlases.Count;
internal long AllocatedBytes => _allocatedBytes;
internal int FrameUploadCount => _frameUploadCount;
internal long FrameUploadBytes => _frameUploadBytes;
internal bool CanStartUpload =>
!_uploadBudgetBlocked
&& _frameUploadCount < _maximumUploadsPerFrame
&& (_frameUploadCount == 0 || _frameUploadBytes < _maximumUploadBytesPerFrame);
internal bool CanUpload(long bytes)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes);
if (_frameUploadCount >= _maximumUploadsPerFrame)
return false;
// Always allow one item so a texture larger than the normal frame
// budget cannot permanently stall portal readiness. Every later item
// must fit completely inside the advertised byte budget.
return _frameUploadCount == 0
|| bytes <= _maximumUploadBytesPerFrame - _frameUploadBytes;
}
internal bool CanPrepareUpload(int width, int height)
{
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
long layerBytes = checked((long)width * height * 4L);
if (!CanUpload(layerBytes))
{
_uploadBudgetBlocked = true;
return false;
}
if (_atlasesBySize.TryGetValue((width, height), out List<Atlas>? compatible))
{
for (int i = 0; i < compatible.Count; i++)
if (compatible[i].IsReusable && compatible[i].AvailableLayers != 0)
return true;
}
int capacity = CalculateLayerCapacity(width, height, _maximumArrayLayers);
long requestedBytes = checked(layerBytes * capacity);
if (_frameAtlasCreationCount < MaximumAtlasCreationsPerFrame
&& CanAllocateAtlas(requestedBytes))
return true;
SetPendingAllocation(width, height, requestedBytes);
_uploadBudgetBlocked = true;
return false;
}
public void BeginFrame()
{
ThrowIfUnavailable();
_retirementLedger.RetryPendingPublications();
_frameUploadCount = 0;
_frameUploadBytes = 0;
_frameAtlasCreationCount = 0;
_uploadBudgetBlocked = false;
}
public bool TryAcquire(
uint ownerLocalId,
CompositeTextureKey key,
out BindlessTextureLocation location)
{
ThrowIfUnavailable();
if (!_entries.TryGetValue(key, out Entry? entry))
{
location = default;
return false;
}
_owners.Acquire(ownerLocalId, key);
_unowned.MarkOwned(key);
entry.Atlas.LastUseSequence = ++_useSequence;
location = new BindlessTextureLocation(
entry.Atlas.Resource.Handle,
checked((uint)entry.Layer));
return true;
}
public bool TryAddAndAcquire(
uint ownerLocalId,
CompositeTextureKey key,
DecodedTexture decoded,
out BindlessTextureLocation location)
{
ThrowIfUnavailable();
if (TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing))
{
location = existing;
return true;
}
ValidateDecodedTexture(decoded);
long bytes = checked((long)decoded.Width * decoded.Height * 4L);
if (!CanUpload(bytes))
{
// Dimensions are only known after DAT decode. Once one candidate
// does not fit, stop all later decodes this frame rather than
// repeatedly allocating RGBA buffers that cannot be uploaded.
_uploadBudgetBlocked = true;
location = default;
return false;
}
if (!TryFindOrCreateAtlas(decoded.Width, decoded.Height, out Atlas atlas))
{
// As with a byte-budget rejection, stop subsequent DAT decodes in
// this frame. Tick advances compatible reclamation before the next
// frame retries the same logical composite.
_uploadBudgetBlocked = true;
location = default;
return false;
}
int layer = atlas.Slots.Rent();
try
{
_backend.Upload(atlas.Resource, layer, decoded.Rgba8);
}
catch (Exception uploadFailure)
{
atlas.Slots.Return(layer);
if (atlas.IsGpuSafeEmpty)
{
try { DeleteAtlas(atlas); }
catch (Exception releaseFailure)
{
throw new AggregateException(
"Composite upload and empty-atlas rollback both failed.",
uploadFailure,
releaseFailure);
}
}
throw;
}
var entry = new Entry { Atlas = atlas, Layer = layer, Bytes = bytes };
_entries.Add(key, entry);
atlas.EntryCount++;
atlas.LastUseSequence = ++_useSequence;
_owners.Acquire(ownerLocalId, key);
_frameUploadCount++;
_frameUploadBytes = checked(_frameUploadBytes + bytes);
location = new BindlessTextureLocation(atlas.Resource.Handle, checked((uint)layer));
return true;
}
public void ReleaseOwner(uint ownerLocalId)
{
ThrowIfUnavailable();
IReadOnlyList<CompositeTextureKey> unowned = _owners.ReleaseOwner(ownerLocalId);
for (int i = 0; i < unowned.Count; i++)
{
CompositeTextureKey key = unowned[i];
if (_entries.TryGetValue(key, out Entry? entry))
_unowned.MarkUnowned(key, entry.Bytes);
}
}
/// <summary>
/// Logical layer eviction is a bounded CPU-only batch; its fenced callback
/// merely returns integer slots. At most one already-empty GL array becomes
/// non-resident and is deleted per frame, so a portal unload cannot become
/// one large driver destruction batch.
/// </summary>
public void Tick()
{
ThrowIfUnavailable();
_retirementLedger.RetryPendingPublications();
bool allocationPressure = HasPendingAllocationPressure();
bool physicalOverBudget = _allocatedBytes > _physicalBudgetBytes;
bool needsPhysicalRelief = allocationPressure || physicalOverBudget;
// Finish an already-started release before selecting another array.
// This keeps driver-visible destruction bounded to one array per tick
// even when a prior non-resident/delete stage had to be retried.
bool servicedPendingRelease = CompleteOnePendingAtlasRelease();
// A fence may have made an array safe since the prior frame. Free one
// first; this is the only driver-visible destruction operation here.
if (needsPhysicalRelief && !servicedPendingRelease)
DeleteOneGpuSafeEmptyAtlas(
_pendingAtlasAllocationBytes == 0 ? null : (_pendingAtlasWidth, _pendingAtlasHeight));
allocationPressure = HasPendingAllocationPressure();
physicalOverBudget = _allocatedBytes > _physicalBudgetBytes;
needsPhysicalRelief = allocationPressure || physicalOverBudget;
int evicted = 0;
if (needsPhysicalRelief && _pendingAtlasAllocationBytes != 0)
{
evicted += EvictCompatibleUnowned(
_pendingAtlasWidth,
_pendingAtlasHeight,
MaximumLogicalEvictionsPerFrame);
}
while (evicted < MaximumLogicalEvictionsPerFrame)
{
bool take = needsPhysicalRelief
? _unowned.TryTakeOldest(out CompositeTextureKey key)
: _unowned.TryTakeOldestOverBudget(out key);
if (!take)
break;
EvictEntry(key);
evicted++;
}
// Allocation pressure is a one-frame demand signal. The requesting
// entity will set it again later this frame if it is still relevant;
// stale portal destinations must not keep evicting unrelated storage.
_pendingAtlasWidth = 0;
_pendingAtlasHeight = 0;
_pendingAtlasAllocationBytes = 0;
}
internal void VisitEntries(Action<uint, int, int> visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
foreach ((CompositeTextureKey key, Entry entry) in _entries)
visitor(key.SurfaceId, entry.Atlas.Resource.Width, entry.Atlas.Resource.Height);
}
internal static int CalculateLayerCapacity(int width, int height, int driverMaximumLayers)
{
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(driverMaximumLayers, 1);
long layerBytes = checked((long)width * height * 4L);
long targetLayers = Math.Max(1L, TargetArrayBytes / layerBytes);
return checked((int)Math.Min(
targetLayers,
Math.Min(driverMaximumLayers, MaximumLayersPerArray)));
}
private bool TryFindOrCreateAtlas(int width, int height, out Atlas atlas)
{
var size = (width, height);
if (_atlasesBySize.TryGetValue(size, out List<Atlas>? compatible))
{
for (int i = 0; i < compatible.Count; i++)
{
Atlas candidate = compatible[i];
if (candidate.IsReusable && candidate.AvailableLayers != 0)
{
ClearPendingAllocation(width, height);
atlas = candidate;
return true;
}
}
}
int capacity = CalculateLayerCapacity(width, height, _maximumArrayLayers);
long requestedBytes = checked((long)width * height * 4L * capacity);
if (_frameAtlasCreationCount >= MaximumAtlasCreationsPerFrame
|| !CanAllocateAtlas(requestedBytes))
{
SetPendingAllocation(width, height, requestedBytes);
atlas = null!;
return false;
}
CompositeTextureArrayResource resource = _backend.Create(width, height, capacity);
atlas = new Atlas
{
Resource = resource,
Slots = new Wb.TextureAtlasSlotAllocator(capacity),
};
if (compatible is null)
{
compatible = new List<Atlas>();
_atlasesBySize.Add(size, compatible);
}
compatible.Add(atlas);
_atlases.Add(atlas);
_allocatedBytes = checked(_allocatedBytes + resource.Bytes);
_frameAtlasCreationCount++;
ClearPendingAllocation(width, height);
return true;
}
private bool CanAllocateAtlas(long requestedBytes)
{
if (FitsWithinPhysicalBudget(requestedBytes))
return true;
// The budget bounds reusable/cache storage, not required live scene
// content. Wait while stale or retiring storage can make room; if the
// entire resident set is live, permit one new atlas this frame so an
// unusually large destination cannot deadlock portal readiness.
return !HasReclaimableStorage();
}
private bool FitsWithinPhysicalBudget(long requestedBytes)
{
if (requestedBytes > _physicalBudgetBytes)
return _allocatedBytes == 0;
return _allocatedBytes <= _physicalBudgetBytes - requestedBytes;
}
private bool HasPendingAllocationPressure() =>
_pendingAtlasAllocationBytes != 0
&& !FitsWithinPhysicalBudget(_pendingAtlasAllocationBytes);
private bool HasReclaimableStorage()
{
if (_unowned.Count != 0)
return true;
for (int i = 0; i < _atlases.Count; i++)
{
Atlas candidate = _atlases[i];
if (!candidate.Deleted
&& (candidate.ReleaseRequested
|| candidate.PendingRetirements != 0
|| candidate.IsGpuSafeEmpty))
{
return true;
}
}
return false;
}
private void SetPendingAllocation(int width, int height, long bytes)
{
_pendingAtlasWidth = width;
_pendingAtlasHeight = height;
_pendingAtlasAllocationBytes = bytes;
}
private void ClearPendingAllocation(int width, int height)
{
if (_pendingAtlasWidth != width || _pendingAtlasHeight != height)
return;
_pendingAtlasWidth = 0;
_pendingAtlasHeight = 0;
_pendingAtlasAllocationBytes = 0;
}
private int EvictCompatibleUnowned(int width, int height, int maximum)
{
_evictionScratch.Clear();
foreach ((CompositeTextureKey key, Entry entry) in _entries)
{
if (_evictionScratch.Count == maximum)
break;
if (entry.Atlas.Resource.Width == width
&& entry.Atlas.Resource.Height == height
&& _unowned.Contains(key))
{
_evictionScratch.Add(key);
}
}
int evicted = 0;
for (int i = 0; i < _evictionScratch.Count; i++)
{
CompositeTextureKey key = _evictionScratch[i];
if (!_unowned.TryTake(key))
continue;
EvictEntry(key);
evicted++;
}
return evicted;
}
private void EvictEntry(CompositeTextureKey key)
{
if (!_entries.Remove(key, out Entry? entry))
return;
Atlas atlas = entry.Atlas;
atlas.EntryCount--;
atlas.PendingRetirements++;
int layer = entry.Layer;
_retirementLedger.Retire(new RetryableGpuResourceRelease(
() => atlas.Slots.Return(layer),
() => atlas.PendingRetirements--));
}
private bool CompleteOnePendingAtlasRelease()
{
for (int i = 0; i < _atlases.Count; i++)
{
Atlas atlas = _atlases[i];
if (!atlas.ReleaseRequested)
continue;
DeleteAtlas(atlas);
return true;
}
return false;
}
private void DeleteOneGpuSafeEmptyAtlas((int Width, int Height)? preserveSize = null)
{
Atlas? oldest = null;
for (int i = 0; i < _atlases.Count; i++)
{
Atlas candidate = _atlases[i];
if (preserveSize is { } preserve
&& candidate.Resource.Width == preserve.Width
&& candidate.Resource.Height == preserve.Height)
{
continue;
}
if (candidate.IsReusable
&& candidate.IsGpuSafeEmpty
&& (oldest is null || candidate.LastUseSequence < oldest.LastUseSequence))
{
oldest = candidate;
}
}
if (oldest is not null)
DeleteAtlas(oldest);
}
private void DeleteAtlas(Atlas atlas, bool requireGpuSafeEmpty = true)
{
if (atlas.ReleaseStage == AtlasReleaseStage.Accounted)
return;
if (requireGpuSafeEmpty && !atlas.IsGpuSafeEmpty)
throw new InvalidOperationException("Cannot delete a composite array while a layer is live or retiring.");
atlas.ReleaseRequested = true;
if (atlas.ReleaseStage == AtlasReleaseStage.Resident)
{
try
{
_backend.MakeNonResident(atlas.Resource);
atlas.ReleaseStage = AtlasReleaseStage.NonResident;
RemoveFromReusableAtlasIndex(atlas);
}
catch (GpuResourceMutationException error) when (error.MutationCommitted)
{
atlas.ReleaseStage = AtlasReleaseStage.NonResident;
RemoveFromReusableAtlasIndex(atlas);
throw;
}
}
if (atlas.ReleaseStage == AtlasReleaseStage.NonResident)
{
try
{
_backend.Delete(atlas.Resource);
atlas.ReleaseStage = AtlasReleaseStage.Deleted;
}
catch (GpuResourceMutationException error) when (error.MutationCommitted)
{
atlas.ReleaseStage = AtlasReleaseStage.Deleted;
throw;
}
}
if (atlas.ReleaseStage == AtlasReleaseStage.Deleted)
{
_allocatedBytes = checked(_allocatedBytes - atlas.Resource.Bytes);
_atlases.Remove(atlas);
atlas.ReleaseStage = AtlasReleaseStage.Accounted;
}
}
private void RevokeAtlasResidencyForDispose(Atlas atlas)
{
atlas.ReleaseRequested = true;
if (atlas.ReleaseStage != AtlasReleaseStage.Resident)
return;
try
{
_backend.MakeNonResident(atlas.Resource);
atlas.ReleaseStage = AtlasReleaseStage.NonResident;
RemoveFromReusableAtlasIndex(atlas);
}
catch (GpuResourceMutationException error) when (error.MutationCommitted)
{
atlas.ReleaseStage = AtlasReleaseStage.NonResident;
RemoveFromReusableAtlasIndex(atlas);
throw;
}
}
private void RemoveFromReusableAtlasIndex(Atlas atlas)
{
var size = (atlas.Resource.Width, atlas.Resource.Height);
if (!_atlasesBySize.TryGetValue(size, out List<Atlas>? compatible))
return;
compatible.Remove(atlas);
if (compatible.Count == 0)
_atlasesBySize.Remove(size);
}
private static void ValidateDecodedTexture(DecodedTexture decoded)
{
ArgumentOutOfRangeException.ThrowIfLessThan(decoded.Width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(decoded.Height, 1);
long expected = checked((long)decoded.Width * decoded.Height * 4L);
if (decoded.Rgba8.LongLength != expected)
throw new ArgumentException(
$"Decoded RGBA texture has {decoded.Rgba8.LongLength} bytes; expected {expected}.",
nameof(decoded));
}
public void Dispose()
{
if (_disposed)
return;
_disposeRequested = true;
_retirementLedger.RetryPendingPublications();
// GameWindow drains frame-flight fences before TextureCache teardown.
// Release every handle first, then delete any backing array. A failed
// stage leaves its exact atlas/stage reachable for a later Dispose.
List<Exception>? failures = null;
for (int i = 0; i < _atlases.Count; i++)
{
Atlas atlas = _atlases[i];
try { RevokeAtlasResidencyForDispose(atlas); }
catch (Exception ex) { (failures ??= []).Add(ex); }
}
if (failures is not null)
throw new AggregateException("One or more composite-array residency releases failed.", failures);
// DeleteAtlas removes completed entries, so walk a stable snapshot.
Atlas[] atlases = _atlases.ToArray();
for (int i = 0; i < atlases.Length; i++)
{
try { DeleteAtlas(atlases[i], requireGpuSafeEmpty: false); }
catch (Exception ex) { (failures ??= []).Add(ex); }
}
if (failures is not null)
throw new AggregateException("One or more composite-array deletions failed.", failures);
_entries.Clear();
_owners.Clear();
_unowned.Clear();
_atlasesBySize.Clear();
_atlases.Clear();
_allocatedBytes = 0;
_pendingAtlasAllocationBytes = 0;
_disposed = true;
}
private void ThrowIfUnavailable() =>
ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
}

View file

@ -0,0 +1,30 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Capacity policy for render-thread-owned streaming GPU buffers. Dynamic
/// buffers keep one backing allocation and update its active prefix; they do
/// not orphan a new driver allocation on every draw call.
/// </summary>
internal static class DynamicBufferCapacity
{
internal const int DefaultAlignment = 4096;
public static int Grow(int currentBytes, int requiredBytes, int alignment = DefaultAlignment)
{
if (currentBytes < 0)
throw new ArgumentOutOfRangeException(nameof(currentBytes));
if (requiredBytes < 0)
throw new ArgumentOutOfRangeException(nameof(requiredBytes));
if (alignment <= 0)
throw new ArgumentOutOfRangeException(nameof(alignment));
if (requiredBytes <= currentBytes)
return currentBytes;
long doubled = currentBytes == 0 ? alignment : (long)currentBytes * 2L;
long target = Math.Max(requiredBytes, doubled);
long aligned = ((target + alignment - 1L) / alignment) * alignment;
if (aligned > int.MaxValue)
throw new OverflowException("Dynamic GPU buffer capacity exceeds Int32.MaxValue.");
return (int)aligned;
}
}

View file

@ -8,6 +8,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
using DatReaderWriter.Types; using DatReaderWriter.Types;
@ -23,7 +24,7 @@ namespace AcDream.App.Rendering;
/// </summary> /// </summary>
public sealed class EquippedChildRenderController : IDisposable public sealed class EquippedChildRenderController : IDisposable
{ {
private readonly DatCollection _dats; private readonly IDatReaderWriter _dats;
private readonly object _datLock; private readonly object _datLock;
private readonly ClientObjectTable _objects; private readonly ClientObjectTable _objects;
private readonly LiveEntityRuntime _liveEntities; private readonly LiveEntityRuntime _liveEntities;
@ -53,7 +54,7 @@ public sealed class EquippedChildRenderController : IDisposable
} }
public EquippedChildRenderController( public EquippedChildRenderController(
DatCollection dats, IDatReaderWriter dats,
object datLock, object datLock,
ClientObjectTable objects, ClientObjectTable objects,
LiveEntityRuntime liveEntities, LiveEntityRuntime liveEntities,

View file

@ -0,0 +1,31 @@
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering;
/// <summary>
/// Balances one synthetic render owner's texture lifetime across replacement.
/// The next draw reacquires only the replacement entity's current materials.
/// </summary>
internal sealed class FixedEntityTextureOwnerLease : IDisposable
{
private readonly IEntityTextureLifetime _lifetime;
private readonly uint _ownerLocalId;
private bool _active;
public FixedEntityTextureOwnerLease(IEntityTextureLifetime lifetime, uint ownerLocalId)
{
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
if (ownerLocalId == 0)
throw new ArgumentOutOfRangeException(nameof(ownerLocalId));
_ownerLocalId = ownerLocalId;
}
public void Replace(bool hasReplacement)
{
if (_active)
_lifetime.ReleaseOwner(_ownerLocalId);
_active = hasReplacement;
}
public void Dispose() => Replace(hasReplacement: false);
}

View file

@ -0,0 +1,155 @@
using System.Diagnostics;
namespace AcDream.App.Rendering;
/// <summary>
/// Deadline-based software frame pacer used only when the user disables
/// VSync. VSync-on presentation waits in the driver's buffer swap instead.
/// </summary>
/// <remarks>
/// Silk.NET's <c>FramesPerSecond</c> gate still runs its outer loop as a busy
/// poll. This owner performs a real thread wait, bounding both render work and
/// CPU use. A missed deadline is rebased from the current time so one slow
/// frame cannot trigger a burst of catch-up frames.
/// </remarks>
internal sealed class FramePacingController
{
private readonly IFramePacingClock _clock;
private readonly IFramePacingWaiter _waiter;
private FramePacingPolicy _policy;
private long _periodTicks;
private long _nextDeadline;
private bool _hasDeadline;
public FramePacingController()
: this(StopwatchFramePacingClock.Instance, ThreadFramePacingWaiter.Instance)
{
}
internal FramePacingController(
IFramePacingClock clock,
IFramePacingWaiter waiter)
{
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_waiter = waiter ?? throw new ArgumentNullException(nameof(waiter));
if (_clock.Frequency <= 0)
throw new ArgumentOutOfRangeException(nameof(clock), "Clock frequency must be positive.");
}
internal FramePacingPolicy Policy => _policy;
public void Apply(FramePacingPolicy policy)
{
if (_policy == policy)
return;
_policy = policy;
if (policy.UseVSync
|| policy.SoftwareLimitHz is not { } limitHz
|| !double.IsFinite(limitHz)
|| limitHz <= 0d)
{
_periodTicks = 0;
_nextDeadline = 0;
_hasDeadline = false;
return;
}
_periodTicks = Math.Max(
1L,
checked((long)Math.Round(_clock.Frequency / limitHz)));
_nextDeadline = AddSaturating(_clock.GetTimestamp(), _periodTicks);
_hasDeadline = true;
}
/// <summary>
/// Wait until the current frame's presentation deadline. GameWindow wires
/// this after its render callback and before Silk.NET's automatic swap.
/// </summary>
public void CompleteFrame()
{
if (!_hasDeadline || _periodTicks <= 0)
return;
long deadline = _nextDeadline;
long now = _clock.GetTimestamp();
if (now < deadline)
{
do
{
long remaining = deadline - now;
_waiter.Wait(remaining, _clock.Frequency);
now = _clock.GetTimestamp();
}
while (now < deadline);
long followingDeadline = AddSaturating(deadline, _periodTicks);
_nextDeadline = followingDeadline > now
? followingDeadline
: AddSaturating(now, _periodTicks);
return;
}
// The frame reached or missed its deadline without waiting. Rebase
// from now instead of advancing through old deadlines: there is never
// an immediate catch-up frame after a stall or long portal load.
_nextDeadline = AddSaturating(now, _periodTicks);
}
private static long AddSaturating(long value, long increment)
=> value > long.MaxValue - increment
? long.MaxValue
: value + increment;
}
internal interface IFramePacingClock
{
long Frequency { get; }
long GetTimestamp();
}
internal interface IFramePacingWaiter
{
void Wait(long durationTicks, long clockFrequency);
}
internal sealed class StopwatchFramePacingClock : IFramePacingClock
{
public static StopwatchFramePacingClock Instance { get; } = new();
private StopwatchFramePacingClock()
{
}
public long Frequency => Stopwatch.Frequency;
public long GetTimestamp() => Stopwatch.GetTimestamp();
}
internal sealed class ThreadFramePacingWaiter : IFramePacingWaiter
{
public static ThreadFramePacingWaiter Instance { get; } = new();
private ThreadFramePacingWaiter()
{
}
public void Wait(long durationTicks, long clockFrequency)
{
if (durationTicks <= 0 || clockFrequency <= 0)
return;
double milliseconds = durationTicks * 1000d / clockFrequency;
// A kernel sleep is intentionally preferred over a final spin. It can
// overshoot a presentation deadline slightly, but it keeps normal CPU
// use low—the central reason this fallback exists.
int sleepMilliseconds = Math.Max(
1,
(int)Math.Ceiling(Math.Min(milliseconds, int.MaxValue)));
Thread.Sleep(sleepMilliseconds);
}
}

View file

@ -0,0 +1,31 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Resolves the user's presentation preference into a bounded render policy.
/// A normal client is never uncapped: disabling VSync selects a software
/// ceiling at the active monitor's refresh rate. Only the explicit startup
/// diagnostic may disable both mechanisms.
/// </summary>
internal readonly record struct FramePacingPolicy(
bool UseVSync,
double? SoftwareLimitHz)
{
internal const double FallbackRefreshHz = 60d;
public static FramePacingPolicy Resolve(
bool requestedVSync,
bool uncappedRendering,
int? monitorRefreshHz)
{
if (uncappedRendering)
return new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: null);
if (requestedVSync)
return new FramePacingPolicy(UseVSync: true, SoftwareLimitHz: null);
double refreshHz = monitorRefreshHz is > 0
? monitorRefreshHz.Value
: FallbackRefreshHz;
return new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: refreshHz);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,486 @@
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Bounds how far the CPU may submit OpenGL work ahead of the GPU.
/// Dynamic buffers are intentionally reused from frame to frame; without a
/// frames-in-flight bound, an uncapped render loop can make the driver retain
/// an unbounded chain of renamed backing stores while older draws are pending.
/// </summary>
internal interface IGpuResourceRetirementQueue
{
void Retire(Action release);
}
/// <summary>
/// A physical GPU-resource release split into independently committed stages.
/// A retirement callback may be retried after a driver or accounting failure;
/// stages which already returned successfully are never executed twice.
/// </summary>
internal sealed class RetryableGpuResourceRelease
{
private readonly Action[] _stages;
private int _nextStage;
private bool _running;
public RetryableGpuResourceRelease(params Action[] stages)
{
ArgumentNullException.ThrowIfNull(stages);
if (stages.Length == 0)
throw new ArgumentException("At least one release stage is required.", nameof(stages));
if (Array.Exists(stages, static stage => stage is null))
throw new ArgumentException("Release stages cannot contain null.", nameof(stages));
_stages = stages;
}
public int CompletedStageCount => _nextStage;
public bool IsComplete => _nextStage == _stages.Length;
public void Run()
{
// A release stage is allowed to call code which drains retirement
// work. Treat that nested drain as observing the active transaction,
// not as permission to execute the same physical mutation twice.
if (_running)
return;
_running = true;
try
{
while (_nextStage < _stages.Length)
{
_stages[_nextStage]();
_nextStage++;
}
}
finally
{
_running = false;
}
}
}
/// <summary>
/// Reports whether a throwable GPU operation changed physical ownership
/// before surfacing its error. Stateful resource owners use this for backends
/// or observers which can explicitly prove that ownership changed before an
/// exception. OpenGL error validation remains in the same retryable stage as
/// its command because a GL error means that command did not commit.
/// </summary>
internal sealed class GpuResourceMutationException : InvalidOperationException
{
public GpuResourceMutationException(
string message,
bool mutationCommitted,
Exception innerException)
: base(message, innerException) => MutationCommitted = mutationCommitted;
public bool MutationCommitted { get; }
}
/// <summary>
/// Retains release ownership until a callback has either run immediately or
/// has been accepted by the frame-flight queue. Queue insertion failure can
/// therefore be retried without losing the only references to old GL names.
/// </summary>
internal sealed class GpuRetirementLedger
{
private readonly IGpuResourceRetirementQueue _queue;
private readonly List<RetryableGpuResourceRelease> _awaitingPublication = [];
private readonly HashSet<RetryableGpuResourceRelease> _publishing =
new(ReferenceEqualityComparer.Instance);
public GpuRetirementLedger(IGpuResourceRetirementQueue queue) =>
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
public int AwaitingPublicationCount => _awaitingPublication.Count;
public void Retire(RetryableGpuResourceRelease release)
{
ArgumentNullException.ThrowIfNull(release);
_awaitingPublication.Add(release);
PublishAt(_awaitingPublication.Count - 1);
}
public void RetireMany(IEnumerable<RetryableGpuResourceRelease> releases)
{
ArgumentNullException.ThrowIfNull(releases);
RetryableGpuResourceRelease[] batch = releases.ToArray();
if (batch.Length == 0)
return;
if (Array.Exists(batch, static release => release is null))
throw new ArgumentException("Retirement batches cannot contain null.", nameof(releases));
// Establish ownership for the complete physical set before the first
// queue call. If publication N fails, later resources remain reachable
// and the next maintenance pass can publish every independent member.
_awaitingPublication.AddRange(batch);
List<Exception>? failures = null;
for (int i = 0; i < batch.Length; i++)
{
try { Publish(batch[i]); }
catch (Exception error) { (failures ??= []).Add(error); }
}
if (failures is not null)
throw new AggregateException(
"One or more GPU retirement callbacks could not be published.",
failures);
}
public void RetryPendingPublications()
{
RetryableGpuResourceRelease[] pending = _awaitingPublication.ToArray();
List<Exception>? failures = null;
for (int i = 0; i < pending.Length; i++)
{
RetryableGpuResourceRelease release = pending[i];
if (!_awaitingPublication.Contains(release, ReferenceEqualityComparer.Instance)
|| _publishing.Contains(release))
{
continue;
}
try
{
Publish(release);
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (failures is not null)
throw new AggregateException(
"One or more GPU retirement callbacks could not be published.",
failures);
}
public void RetryPendingPublication(RetryableGpuResourceRelease release)
{
ArgumentNullException.ThrowIfNull(release);
if (!_awaitingPublication.Contains(release, ReferenceEqualityComparer.Instance)
|| _publishing.Contains(release))
{
return;
}
Publish(release);
}
private void PublishAt(int index)
{
RetryableGpuResourceRelease release = _awaitingPublication[index];
Publish(release);
}
private void Publish(RetryableGpuResourceRelease release)
{
if (!_publishing.Add(release))
return;
try
{
_queue.Retire(release.Run);
_awaitingPublication.Remove(release);
}
catch
{
// An immediate queue can throw from the callback itself. If every
// stage committed before a later wrapper failed, no retry remains.
if (release.IsComplete)
_awaitingPublication.Remove(release);
throw;
}
finally
{
_publishing.Remove(release);
}
}
}
internal sealed class ImmediateGpuResourceRetirementQueue : IGpuResourceRetirementQueue
{
public static ImmediateGpuResourceRetirementQueue Instance { get; } = new();
private ImmediateGpuResourceRetirementQueue()
{
}
public void Retire(Action release)
{
ArgumentNullException.ThrowIfNull(release);
release();
}
}
internal sealed class GpuFrameFlightController : IGpuResourceRetirementQueue, IDisposable
{
internal const int DefaultMaximumFramesInFlight = 3;
private const ulong WaitSliceNanoseconds = 1_000_000;
private readonly IGpuFenceApi _fenceApi;
private readonly nint[] _fences;
private readonly long[] _fenceSerials;
private readonly SortedDictionary<long, List<Action>> _retirements = new();
private int _slot;
private long _lastSubmittedSerial;
private bool _frameOpen;
private bool _disposed;
public int CurrentSlot => _slot;
public int SlotCount => _fences.Length;
internal int PendingRetirementCount => _retirements.Sum(entry => entry.Value.Count);
public GpuFrameFlightController(GL gl, int maximumFramesInFlight = DefaultMaximumFramesInFlight)
: this(new SilkGpuFenceApi(gl), maximumFramesInFlight)
{
}
internal GpuFrameFlightController(
IGpuFenceApi fenceApi,
int maximumFramesInFlight = DefaultMaximumFramesInFlight)
{
ArgumentNullException.ThrowIfNull(fenceApi);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumFramesInFlight, 1);
_fenceApi = fenceApi;
_fences = new nint[maximumFramesInFlight];
_fenceSerials = new long[maximumFramesInFlight];
}
/// <summary>
/// Waits for the frame currently occupying the next ring slot. The first
/// wait flushes submitted commands so the fence can make progress; later
/// one-millisecond slices avoid an unbounded native blocking call.
/// </summary>
public void BeginFrame()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_frameOpen)
throw new InvalidOperationException("EndFrame must close the current frame before BeginFrame is called again.");
nint fence = _fences[_slot];
if (fence != 0)
RetireFence(_slot);
_frameOpen = true;
}
private void RetireFence(int slot)
{
nint fence = _fences[slot];
if (fence == 0)
return;
bool flushCommands = true;
while (true)
{
GpuFenceWaitResult result = _fenceApi.Wait(
fence,
flushCommands,
WaitSliceNanoseconds);
flushCommands = false;
if (result == GpuFenceWaitResult.Timeout)
continue;
if (result == GpuFenceWaitResult.Failed)
throw new InvalidOperationException("OpenGL failed while waiting for an in-flight frame fence.");
break;
}
_fenceApi.Delete(fence);
_fences[slot] = 0;
long completedSerial = _fenceSerials[slot];
_fenceSerials[slot] = 0;
RunRetirementsThrough(completedSerial);
}
/// <summary>Marks every GL command submitted by the current frame.</summary>
public void EndFrame()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_frameOpen)
throw new InvalidOperationException("BeginFrame must be called before EndFrame.");
if (_fences[_slot] != 0)
throw new InvalidOperationException("BeginFrame must retire the current frame slot before EndFrame.");
nint fence = _fenceApi.Insert();
if (fence == 0)
throw new InvalidOperationException("OpenGL did not create an in-flight frame fence.");
_fences[_slot] = fence;
_fenceSerials[_slot] = ++_lastSubmittedSerial;
_frameOpen = false;
_slot = (_slot + 1) % _fences.Length;
}
/// <summary>
/// Defers destruction until the fence covering every draw that could still
/// reference the resource has signaled. Calls made during a render frame
/// include that frame; update-thread calls cover the last submitted frame.
/// </summary>
public void Retire(Action release)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(release);
long targetSerial = _frameOpen
? _lastSubmittedSerial + 1
: _lastSubmittedSerial;
if (targetSerial == 0)
{
release();
return;
}
if (!_retirements.TryGetValue(targetSerial, out List<Action>? releases))
{
releases = [];
_retirements.Add(targetSerial, releases);
}
releases.Add(release);
}
/// <summary>
/// Waits for all submitted GL work and runs every resource retirement it
/// protects. Used before orderly renderer teardown while the context lives.
/// </summary>
public void WaitForSubmittedWork()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_frameOpen)
throw new InvalidOperationException("Cannot drain submitted work while a render frame is open.");
List<Exception>? failures = null;
for (int i = 0; i < _fences.Length; i++)
{
int slot = (_slot + i) % _fences.Length;
try
{
RetireFence(slot);
}
catch (AggregateException ex)
{
(failures ??= []).AddRange(ex.InnerExceptions);
}
}
try
{
RunRetirementsThrough(_lastSubmittedSerial);
}
catch (AggregateException ex)
{
(failures ??= []).AddRange(ex.InnerExceptions);
}
if (failures is not null)
throw new AggregateException("One or more GPU resource retirements failed.", failures);
}
private void RunRetirementsThrough(long completedSerial)
{
List<Exception>? failures = null;
List<(long Serial, Action Release)>? retry = null;
while (_retirements.Count != 0)
{
KeyValuePair<long, List<Action>> first;
using (IEnumerator<KeyValuePair<long, List<Action>>> enumerator = _retirements.GetEnumerator())
{
if (!enumerator.MoveNext() || enumerator.Current.Key > completedSerial)
break;
first = enumerator.Current;
}
if (!_retirements.Remove(first.Key))
break;
List<Action> releases = first.Value;
for (int i = 0; i < releases.Count; i++)
{
try
{
releases[i]();
}
catch (Exception ex)
{
(failures ??= []).Add(ex);
(retry ??= []).Add((first.Key, releases[i]));
}
}
}
if (retry is not null)
{
for (int i = 0; i < retry.Count; i++)
{
(long serial, Action release) = retry[i];
if (!_retirements.TryGetValue(serial, out List<Action>? releases))
{
releases = [];
_retirements.Add(serial, releases);
}
releases.Add(release);
}
}
if (failures is not null)
throw new AggregateException("One or more GPU resource retirements failed.", failures);
}
public void Dispose()
{
if (_disposed)
return;
if (_frameOpen)
EndFrame();
WaitForSubmittedWork();
Array.Clear(_fences);
Array.Clear(_fenceSerials);
_disposed = true;
}
}
internal enum GpuFenceWaitResult
{
Signaled,
Timeout,
Failed,
}
internal interface IGpuFenceApi
{
nint Insert();
GpuFenceWaitResult Wait(nint fence, bool flushCommands, ulong timeoutNanoseconds);
void Delete(nint fence);
}
internal sealed class SilkGpuFenceApi(GL gl) : IGpuFenceApi
{
private readonly GL _gl = gl ?? throw new ArgumentNullException(nameof(gl));
public nint Insert() =>
_gl.FenceSync(SyncCondition.SyncGpuCommandsComplete, SyncBehaviorFlags.None);
public GpuFenceWaitResult Wait(nint fence, bool flushCommands, ulong timeoutNanoseconds)
{
SyncObjectMask flags = flushCommands
? SyncObjectMask.Bit
: 0;
return _gl.ClientWaitSync(fence, flags, timeoutNanoseconds) switch
{
GLEnum.AlreadySignaled or GLEnum.ConditionSatisfied => GpuFenceWaitResult.Signaled,
GLEnum.TimeoutExpired => GpuFenceWaitResult.Timeout,
GLEnum.WaitFailed => GpuFenceWaitResult.Failed,
GLEnum value => throw new InvalidOperationException(
$"OpenGL returned unexpected fence wait status {value} (0x{(uint)value:X})."),
};
}
public void Delete(nint fence) => _gl.DeleteSync(fence);
}

View file

@ -0,0 +1,89 @@
using AcDream.Core.Terrain;
namespace AcDream.App.Rendering;
/// <summary>
/// Adds GPU-frame retirement to the terrain renderer's CPU slot allocator.
/// Removing a landblock stops future draws immediately, but its VBO/EBO slot
/// is not returned for overwrite until older submitted draws have completed.
/// </summary>
internal sealed class GpuRetiredTerrainSlotAllocator
{
private readonly TerrainSlotAllocator _allocator;
private readonly GpuRetirementLedger _retirementLedger;
private readonly Dictionary<int, RetryableGpuResourceRelease> _pendingReleases = [];
public GpuRetiredTerrainSlotAllocator(
int initialCapacity,
IGpuResourceRetirementQueue retirement)
{
_allocator = new TerrainSlotAllocator(initialCapacity);
_retirementLedger = new GpuRetirementLedger(
retirement ?? throw new ArgumentNullException(nameof(retirement)));
}
public int Capacity => _allocator.Capacity;
public int LoadedCount => _allocator.LoadedCount;
internal int PendingReleaseCount => _pendingReleases.Count;
public int Allocate(out bool needsGrow) => _allocator.Allocate(out needsGrow);
public void GrowTo(int newCapacity) => _allocator.GrowTo(newCapacity);
/// <summary>
/// Returns a slot whose contents were never published to a GPU draw. No
/// retirement fence is required because no submitted command can refer to
/// it.
/// </summary>
public void ReleaseUnsubmitted(int slot)
{
if (_pendingReleases.ContainsKey(slot))
{
throw new InvalidOperationException(
"A GPU-submitted terrain slot cannot be released as unsubmitted.");
}
_allocator.Free(slot);
}
public void FreeAfterGpuUse(int slot)
{
if (_pendingReleases.TryGetValue(slot, out RetryableGpuResourceRelease? pending))
{
try
{
_retirementLedger.RetryPendingPublication(pending);
}
catch when (pending.IsComplete)
{
// Completion is stronger than publication acknowledgement.
}
return;
}
var release = new RetryableGpuResourceRelease(
() => _allocator.Free(slot),
() =>
{
if (!_pendingReleases.Remove(slot))
{
throw new InvalidOperationException(
"Terrain-slot retirement lost its ownership record.");
}
});
_pendingReleases.Add(slot, release);
try
{
_retirementLedger.Retire(release);
}
catch when (release.IsComplete)
{
// An immediate retirement queue can complete before surfacing a
// wrapper failure. The slot is already safely reusable.
}
}
public void RetryPendingPublications() =>
_retirementLedger.RetryPendingPublications();
}

View file

@ -0,0 +1,36 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Runs dependency-ordered resource teardown one stage at a time. A failed
/// stage remains current, so a later <see cref="Advance"/> retries exactly
/// that owner before any dependent resource can be destroyed.
/// </summary>
internal sealed class OrderedResourceTeardown(params Action[] stages)
{
private readonly Action[] _stages = stages ?? throw new ArgumentNullException(nameof(stages));
private int _nextStage;
private bool _advancing;
public bool IsComplete => _nextStage == _stages.Length;
internal int NextStage => _nextStage;
public void Advance()
{
if (_advancing || IsComplete)
return;
_advancing = true;
try
{
while (_nextStage < _stages.Length)
{
_stages[_nextStage]();
_nextStage++;
}
}
finally
{
_advancing = false;
}
}
}

View file

@ -0,0 +1,61 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Tracks shared resources by logical owner. Repeated use of the same key by
/// one owner is idempotent; a key is returned for destruction only after its
/// final owner leaves. Render-thread only.
/// </summary>
internal sealed class OwnerScopedResourceRegistry<TKey> where TKey : notnull
{
private readonly Dictionary<uint, HashSet<TKey>> _keysByOwner = new();
private readonly Dictionary<TKey, int> _ownerCountByKey = new();
public int OwnerCount => _keysByOwner.Count;
public int ResourceCount => _ownerCountByKey.Count;
public bool Acquire(uint ownerId, TKey key)
{
if (ownerId == 0)
throw new ArgumentOutOfRangeException(nameof(ownerId));
if (!_keysByOwner.TryGetValue(ownerId, out HashSet<TKey>? keys))
{
keys = new HashSet<TKey>();
_keysByOwner.Add(ownerId, keys);
}
if (!keys.Add(key))
return false;
_ownerCountByKey[key] = _ownerCountByKey.GetValueOrDefault(key) + 1;
return true;
}
public IReadOnlyList<TKey> ReleaseOwner(uint ownerId)
{
if (!_keysByOwner.Remove(ownerId, out HashSet<TKey>? keys))
return Array.Empty<TKey>();
List<TKey>? unowned = null;
foreach (TKey key in keys)
{
int remaining = _ownerCountByKey[key] - 1;
if (remaining > 0)
{
_ownerCountByKey[key] = remaining;
continue;
}
_ownerCountByKey.Remove(key);
(unowned ??= new List<TKey>()).Add(key);
}
return unowned is null ? Array.Empty<TKey>() : unowned;
}
public void Clear()
{
_keysByOwner.Clear();
_ownerCountByKey.Clear();
}
}

View file

@ -32,6 +32,7 @@ public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDis
private readonly GL _gl; private readonly GL _gl;
private readonly WbDrawDispatcher _dispatcher; private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo; private readonly SceneLightingUboBinding _lightUbo;
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
private readonly DollCamera _camera = new(); private readonly DollCamera _camera = new();
// Off-screen target (lazily (re)created on size change). // Off-screen target (lazily (re)created on size change).
@ -55,15 +56,28 @@ public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDis
// AND what lets the idle animation (later slice, TickAnimations rebuilds MeshRefs) show. // AND what lets the idle animation (later slice, TickAnimations rebuilds MeshRefs) show.
private static readonly HashSet<uint> _dollAnimatedIds = new() { DollEntityBuilder.DollRenderId }; private static readonly HashSet<uint> _dollAnimatedIds = new() { DollEntityBuilder.DollRenderId };
public PaperdollViewportRenderer(GL gl, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo) public PaperdollViewportRenderer(
GL gl,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime)
{ {
_gl = gl ?? throw new ArgumentNullException(nameof(gl)); _gl = gl ?? throw new ArgumentNullException(nameof(gl));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo)); _lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
_textureOwnerLease = new FixedEntityTextureOwnerLease(
textureLifetime,
DollEntityBuilder.DollRenderId);
} }
/// <summary>Set (or clear) the doll entity. GameWindow builds/re-dresses it from the live player.</summary> /// <summary>Set (or clear) the doll entity. GameWindow builds/re-dresses it from the live player.</summary>
public void SetDoll(WorldEntity? doll) => _doll = doll; public void SetDoll(WorldEntity? doll)
{
if (ReferenceEquals(_doll, doll))
return;
_textureOwnerLease.Replace(doll is not null);
_doll = doll;
}
/// <inheritdoc/> /// <inheritdoc/>
public uint Render(int width, int height) public uint Render(int width, int height)
@ -189,5 +203,10 @@ public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDis
_fbW = _fbH = 0; _fbW = _fbH = 0;
} }
public void Dispose() => DeleteFramebuffer(); public void Dispose()
{
_doll = null;
_textureOwnerLease.Dispose();
DeleteFramebuffer();
}
} }

View file

@ -0,0 +1,139 @@
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering;
/// <summary>
/// Durable, per-emitter teardown ledger. Emitter death removes simulation
/// state first, so renderer-owned resources must retain their own retryable
/// obligations instead of relying on the death event being raised again.
/// </summary>
internal sealed class ParticleEmitterRetirementTracker
{
private sealed class RetirementState
{
public bool MeshReleased;
public bool GfxInfoRemoved;
public bool TextureOwnerReleased;
public bool FailureReported;
}
private readonly Action<int> _releaseMesh;
private readonly Action<int> _removeGfxInfo;
private readonly Action<int> _releaseTextureOwner;
private readonly Action<Exception>? _reportFailure;
private readonly Dictionary<int, RetirementState> _pending = [];
private readonly HashSet<int> _advancingHandles = [];
public ParticleEmitterRetirementTracker(
Action<int> releaseMesh,
Action<int> removeGfxInfo,
Action<int> releaseTextureOwner,
Action<Exception>? reportFailure = null)
{
_releaseMesh = releaseMesh ?? throw new ArgumentNullException(nameof(releaseMesh));
_removeGfxInfo = removeGfxInfo ?? throw new ArgumentNullException(nameof(removeGfxInfo));
_releaseTextureOwner = releaseTextureOwner ?? throw new ArgumentNullException(nameof(releaseTextureOwner));
_reportFailure = reportFailure;
}
internal int PendingCount => _pending.Count;
public void BeginRetirement(int emitterHandle)
{
if (_advancingHandles.Contains(emitterHandle))
return;
if (!_pending.TryGetValue(emitterHandle, out RetirementState? state))
{
state = new RetirementState();
_pending.Add(emitterHandle, state);
}
Advance(emitterHandle, state);
}
public void RetryPending()
{
if (_pending.Count == 0)
return;
int[] handles = [.. _pending.Keys];
for (int i = 0; i < handles.Length; i++)
{
if (_pending.TryGetValue(handles[i], out RetirementState? state))
Advance(handles[i], state);
}
}
public void CompleteOrThrow()
{
RetryPending();
if (_pending.Count != 0)
throw new InvalidOperationException(
$"{_pending.Count} particle-emitter teardown obligation(s) remain incomplete.");
}
private void Advance(int emitterHandle, RetirementState state)
{
if (!_advancingHandles.Add(emitterHandle))
return;
List<Exception>? failures = null;
try
{
if (!state.MeshReleased)
{
try
{
_releaseMesh(emitterHandle);
state.MeshReleased = true;
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
state.MeshReleased = true;
(failures ??= []).Add(error);
}
}
if (!state.GfxInfoRemoved)
{
try
{
_removeGfxInfo(emitterHandle);
state.GfxInfoRemoved = true;
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (!state.TextureOwnerReleased)
{
try
{
_releaseTextureOwner(emitterHandle);
state.TextureOwnerReleased = true;
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (state.MeshReleased && state.GfxInfoRemoved && state.TextureOwnerReleased)
_pending.Remove(emitterHandle);
if (failures is not null && !state.FailureReported)
{
state.FailureReported = true;
_reportFailure?.Invoke(new AggregateException(
$"Particle emitter {emitterHandle} teardown will be retried.",
failures));
}
}
finally
{
_advancingHandles.Remove(emitterHandle);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -42,9 +43,18 @@ internal static class ParticleSubmissionOrdering
/// </summary> /// </summary>
internal sealed class ParticleMeshReferenceTracker : IDisposable internal sealed class ParticleMeshReferenceTracker : IDisposable
{ {
private sealed class ReferenceState
{
public required uint GfxObjId { get; init; }
public bool Desired { get; set; }
public bool Held { get; set; }
public bool Reconciling { get; set; }
}
private readonly Action<uint> _increment; private readonly Action<uint> _increment;
private readonly Action<uint> _decrement; private readonly Action<uint> _decrement;
private readonly Dictionary<int, uint> _gfxByEmitter = new(); private readonly Dictionary<int, ReferenceState> _referencesByEmitter = new();
private bool _disposeRequested;
private bool _disposed; private bool _disposed;
public ParticleMeshReferenceTracker(Action<uint> increment, Action<uint> decrement) public ParticleMeshReferenceTracker(Action<uint> increment, Action<uint> decrement)
@ -55,28 +65,137 @@ internal sealed class ParticleMeshReferenceTracker : IDisposable
public void Register(int emitterHandle, uint gfxObjId) public void Register(int emitterHandle, uint gfxObjId)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposeRequested, this);
if (_gfxByEmitter.ContainsKey(emitterHandle)) if (!_referencesByEmitter.TryGetValue(emitterHandle, out ReferenceState? state))
return; {
state = new ReferenceState { GfxObjId = gfxObjId };
_referencesByEmitter.Add(emitterHandle, state);
}
else if (state.GfxObjId != gfxObjId)
{
throw new InvalidOperationException(
$"Particle emitter {emitterHandle} is already associated with " +
$"GfxObj 0x{state.GfxObjId:X8}, not 0x{gfxObjId:X8}.");
}
_gfxByEmitter.Add(emitterHandle, gfxObjId); state.Desired = true;
_increment(gfxObjId); Reconcile(emitterHandle, state);
} }
public void Release(int emitterHandle) public void Release(int emitterHandle)
{ {
if (_disposed || !_gfxByEmitter.Remove(emitterHandle, out uint gfxObjId)) if (_disposed || !_referencesByEmitter.TryGetValue(emitterHandle, out ReferenceState? state))
return; return;
_decrement(gfxObjId);
state.Desired = false;
Reconcile(emitterHandle, state);
} }
public void Dispose() public void Dispose()
{ {
if (_disposed) if (_disposed)
return; return;
_disposed = true;
foreach (uint gfxObjId in _gfxByEmitter.Values) _disposeRequested = true;
_decrement(gfxObjId); List<Exception>? failures = null;
_gfxByEmitter.Clear(); int[] emitterHandles = [.. _referencesByEmitter.Keys];
for (int i = 0; i < emitterHandles.Length; i++)
{
int emitterHandle = emitterHandles[i];
if (!_referencesByEmitter.TryGetValue(emitterHandle, out ReferenceState? state))
continue;
state.Desired = false;
try
{
Reconcile(emitterHandle, state);
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (_referencesByEmitter.Count == 0)
_disposed = true;
if (failures is not null)
throw new AggregateException(
"One or more particle mesh references failed to release.",
failures);
}
private void Reconcile(int emitterHandle, ReferenceState state)
{
if (state.Reconciling)
return;
state.Reconciling = true;
Exception? failure = null;
try
{
while (state.Desired != state.Held)
{
if (state.Desired)
{
try
{
_increment(state.GfxObjId);
state.Held = true;
}
catch (MeshReferenceMutationException error)
{
if (error.MutationCommitted)
{
state.Held = true;
failure ??= error;
continue;
}
failure = error;
break;
}
catch (Exception error)
{
failure = error;
break;
}
}
else
{
try
{
_decrement(state.GfxObjId);
state.Held = false;
}
catch (MeshReferenceMutationException error)
{
if (error.MutationCommitted)
{
state.Held = false;
failure ??= error;
continue;
}
failure = error;
break;
}
catch (Exception error)
{
failure = error;
break;
}
}
}
}
finally
{
state.Reconciling = false;
if (!state.Desired && !state.Held)
_referencesByEmitter.Remove(emitterHandle);
if (_disposeRequested && _referencesByEmitter.Count == 0)
_disposed = true;
}
if (failure is not null)
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw();
} }
} }

View file

@ -1,5 +1,7 @@
using System; using System;
using System.Linq;
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -80,8 +82,6 @@ void main() { } // depth-only: color writes are masked off by the caller state
private readonly GL _gl; private readonly GL _gl;
private readonly uint _program; private readonly uint _program;
private readonly uint _vao;
private readonly uint _vbo;
private readonly int _locViewProjection; private readonly int _locViewProjection;
private readonly int _locPlaneCount; private readonly int _locPlaneCount;
private readonly int _locPlanes; private readonly int _locPlanes;
@ -92,6 +92,20 @@ void main() { } // depth-only: color writes are masked off by the caller state
private const int MaxFanVerts = 32; private const int MaxFanVerts = 32;
private readonly float[] _scratch = new float[MaxFanVerts * 3]; private readonly float[] _scratch = new float[MaxFanVerts * 3];
private sealed class FrameBufferSet
{
public uint Vao;
public uint Vbo;
public int CapacityBytes;
public int UsedVertices;
}
private readonly FrameBufferSet[] _frameBuffers = new FrameBufferSet[3];
private FrameBufferSet? _activeFrameBuffer;
internal long DynamicBufferCapacityBytes =>
_frameBuffers.Sum(set => (long)set.CapacityBytes);
public PortalDepthMaskRenderer(GL gl) public PortalDepthMaskRenderer(GL gl)
{ {
_gl = gl ?? throw new ArgumentNullException(nameof(gl)); _gl = gl ?? throw new ArgumentNullException(nameof(gl));
@ -115,19 +129,57 @@ void main() { } // depth-only: color writes are masked off by the caller state
_locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias"); _locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
_locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN"); _locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN");
_vao = _gl.GenVertexArray(); for (int i = 0; i < _frameBuffers.Length; i++)
_vbo = _gl.GenBuffer(); _frameBuffers[i] = CreateFrameBufferSet();
_gl.BindVertexArray(_vao); }
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
unsafe /// <summary>
/// Selects the GPU-fenced frame slot and resets its append cursor. Portal
/// fans written during one frame occupy distinct ranges, so later portal
/// slices never overwrite vertices still referenced by earlier draws.
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_frameBuffers.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
_activeFrameBuffer = _frameBuffers[frameSlot];
_activeFrameBuffer.UsedVertices = 0;
}
private FrameBufferSet CreateFrameBufferSet()
{
uint vao = 0;
uint vbo = 0;
try
{ {
_gl.BufferData(BufferTargetARB.ArrayBuffer, vao = TrackedGlResource.CreateVertexArray(
(nuint)(MaxFanVerts * 3 * sizeof(float)), null, BufferUsageARB.DynamicDraw); _gl,
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), (void*)0); "PortalDepthMask frame VAO creation");
vbo = TrackedGlResource.CreateBuffer(
_gl,
"PortalDepthMask frame VBO creation");
var set = new FrameBufferSet { Vao = vao, Vbo = vbo };
_gl.BindVertexArray(set.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo);
unsafe
{
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), (void*)0);
}
_gl.EnableVertexAttribArray(0);
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
return set;
}
catch
{
if (vbo != 0)
TrackedGlResource.DeleteBuffer(
_gl, vbo, 0, "PortalDepthMask frame VBO rollback");
if (vao != 0)
TrackedGlResource.DeleteVertexArray(
_gl, vao, "PortalDepthMask frame VAO rollback");
throw;
} }
_gl.EnableVertexAttribArray(0);
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
} }
private uint Compile(ShaderType type, string src) private uint Compile(ShaderType type, string src)
@ -210,8 +262,12 @@ void main() { } // depth-only: color writes are masked off by the caller state
{ {
if (worldVerts.Length < 3) if (worldVerts.Length < 3)
return; return;
FrameBufferSet frameBuffer = _activeFrameBuffer
?? throw new InvalidOperationException("BeginFrame must be called before drawing portal depth masks.");
int n = Math.Min(worldVerts.Length, MaxFanVerts); int n = Math.Min(worldVerts.Length, MaxFanVerts);
int planeCount = Math.Min(planes.Length, 8); int planeCount = Math.Min(planes.Length, 8);
int firstVertex = frameBuffer.UsedVertices;
int requiredBytes = checked((firstVertex + n) * 3 * sizeof(float));
for (int i = 0; i < n; i++) for (int i = 0; i < n; i++)
{ {
@ -249,10 +305,30 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.Uniform4(_locPlanes, (uint)planeCount, pp); _gl.Uniform4(_locPlanes, (uint)planeCount, pp);
} }
_gl.BindVertexArray(_vao); _gl.BindVertexArray(frameBuffer.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, frameBuffer.Vbo);
if (frameBuffer.CapacityBytes < requiredBytes)
{
int newCapacity = DynamicBufferCapacity.Grow(
frameBuffer.CapacityBytes,
requiredBytes);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ArrayBuffer,
frameBuffer.Vbo,
frameBuffer.CapacityBytes,
newCapacity,
GLEnum.DynamicDraw,
"PortalDepthMask frame VBO growth");
frameBuffer.CapacityBytes = newCapacity;
}
fixed (float* v = _scratch) fixed (float* v = _scratch)
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(n * 3 * sizeof(float)), v); _gl.BufferSubData(
BufferTargetARB.ArrayBuffer,
(nint)(firstVertex * 3 * sizeof(float)),
(nuint)(n * 3 * sizeof(float)),
v);
frameBuffer.UsedVertices += n;
if (!forceFarZ) if (!forceFarZ)
{ {
@ -261,7 +337,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.DepthMask(true); _gl.DepthMask(true);
_gl.Uniform1(_locForceFarZ, 0); _gl.Uniform1(_locForceFarZ, 0);
_gl.Uniform1(_locDepthBias, 0f); _gl.Uniform1(_locDepthBias, 0f);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n); _gl.DrawArrays(PrimitiveType.TriangleFan, firstVertex, (uint)n);
} }
else else
{ {
@ -276,7 +352,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.Uniform1(_locDepthBias, PunchMarkDepthBias); _gl.Uniform1(_locDepthBias, PunchMarkDepthBias);
_gl.Uniform1(_locDepthBiasEyeCapN, _gl.Uniform1(_locDepthBiasEyeCapN,
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters); PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n); _gl.DrawArrays(PrimitiveType.TriangleFan, firstVertex, (uint)n);
// ── PUNCH pass B: far-Z write on marked pixels only; // ── PUNCH pass B: far-Z write on marked pixels only;
// zero the stencil as we go (self-cleaning) ── // zero the stencil as we go (self-cleaning) ──
@ -286,7 +362,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.DepthMask(true); _gl.DepthMask(true);
_gl.Uniform1(_locForceFarZ, 1); _gl.Uniform1(_locForceFarZ, 1);
_gl.Uniform1(_locDepthBias, 0f); _gl.Uniform1(_locDepthBias, 0f);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n); _gl.DrawArrays(PrimitiveType.TriangleFan, firstVertex, (uint)n);
_gl.Disable(EnableCap.StencilTest); _gl.Disable(EnableCap.StencilTest);
} }
@ -310,7 +386,17 @@ void main() { } // depth-only: color writes are masked off by the caller state
public void Dispose() public void Dispose()
{ {
_gl.DeleteProgram(_program); _gl.DeleteProgram(_program);
_gl.DeleteVertexArray(_vao); foreach (FrameBufferSet set in _frameBuffers)
_gl.DeleteBuffer(_vbo); {
TrackedGlResource.DeleteVertexArray(
_gl,
set.Vao,
"PortalDepthMask frame VAO disposal");
TrackedGlResource.DeleteBuffer(
_gl,
set.Vbo,
set.CapacityBytes,
"PortalDepthMask frame VBO disposal");
}
} }
} }

View file

@ -0,0 +1,154 @@
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering;
/// <summary>
/// Owns the synthetic portal creature's mesh references. Acquisition happens
/// only after the presentation has been published to its runtime owner, so a
/// partial backend failure can never strand an unreachable constructor-local
/// reference. Per-reference physical state makes both acquisition and teardown
/// resumable without replaying successful mutations.
/// </summary>
internal sealed class PortalMeshReferenceOwner : IDisposable
{
private sealed class ReferenceState(ulong gfxObjId)
{
public ulong GfxObjId { get; } = gfxObjId;
public bool Held { get; set; }
}
private readonly IWbMeshAdapter _meshAdapter;
private readonly ReferenceState[] _references;
private bool _desired;
private bool _disposeRequested;
private bool _disposed;
private bool _reconciling;
private bool _reconcileAgain;
public PortalMeshReferenceOwner(
IWbMeshAdapter meshAdapter,
IEnumerable<ulong> gfxObjIds)
{
_meshAdapter = meshAdapter ?? throw new ArgumentNullException(nameof(meshAdapter));
ArgumentNullException.ThrowIfNull(gfxObjIds);
_references = gfxObjIds
.Where(static id => id != 0u)
.Distinct()
.Select(static id => new ReferenceState(id))
.ToArray();
}
public bool IsDisposed => _disposed;
public void Acquire()
{
ObjectDisposedException.ThrowIf(_disposeRequested, this);
_desired = true;
try
{
Reconcile();
ObjectDisposedException.ThrowIf(_disposeRequested, this);
}
catch (Exception acquisitionFailure)
{
// Acquisition is transactional even though the owner is already
// reachable. Roll back every committed sibling; a failed rollback
// remains represented by Held=true so Dispose can resume it.
_desired = false;
try
{
Reconcile();
}
catch (Exception rollbackFailure)
{
throw new AggregateException(
"Portal mesh acquisition failed and its rollback did not fully converge.",
acquisitionFailure,
rollbackFailure);
}
System.Runtime.ExceptionServices.ExceptionDispatchInfo
.Capture(acquisitionFailure)
.Throw();
throw new InvalidOperationException("Unreachable exception dispatch path.");
}
}
public void Dispose()
{
if (_disposed)
return;
_disposeRequested = true;
_desired = false;
if (_reconciling)
{
_reconcileAgain = true;
return;
}
Reconcile();
}
private void Reconcile()
{
if (_reconciling)
{
_reconcileAgain = true;
return;
}
_reconciling = true;
List<Exception>? failures = null;
try
{
do
{
_reconcileAgain = false;
for (int i = 0; i < _references.Length; i++)
{
ReferenceState reference = _references[i];
bool targetHeld = _desired;
if (reference.Held == targetHeld)
continue;
try
{
if (targetHeld)
_meshAdapter.IncrementRefCount(reference.GfxObjId);
else
_meshAdapter.DecrementRefCount(reference.GfxObjId);
reference.Held = targetHeld;
}
catch (Exception error)
{
if (error is MeshReferenceMutationException
{
MutationCommitted: true,
})
{
reference.Held = targetHeld;
}
string operation = targetHeld ? "acquisition" : "release";
(failures ??= []).Add(new InvalidOperationException(
$"Portal mesh 0x{reference.GfxObjId:X10} reference {operation} failed.",
error));
}
}
}
while (_reconcileAgain);
}
finally
{
_reconciling = false;
if (_disposeRequested && _references.All(static reference => !reference.Held))
_disposed = true;
}
if (failures is not null)
throw new AggregateException(
"One or more portal mesh references failed to reconcile.",
failures);
}
}

View file

@ -17,6 +17,7 @@
// (w > MinW) keeps a portal you're standing in (it covers the screen) so the cell behind stays // (w > MinW) keeps a portal you're standing in (it covers the screen) so the cell behind stays
// visible. Retail PView::GetClip / ConstructView(CBldPortal) (decomp:432344 / 433832) near-clip the // visible. Retail PView::GetClip / ConstructView(CBldPortal) (decomp:432344 / 433832) near-clip the
// portal poly likewise before projecting. // portal poly likewise before projecting.
using System.Buffers;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
@ -24,21 +25,97 @@ namespace AcDream.App.Rendering;
public static class PortalProjection public static class PortalProjection
{ {
internal ref struct ClipPolygonLease
{
private readonly ArrayPool<Vector4>? _pool;
private Vector4[]? _first;
private Vector4[]? _second;
private Vector4[]? _result;
private readonly int _count;
private bool _disposed;
internal ClipPolygonLease(
ArrayPool<Vector4>? pool,
Vector4[]? first,
Vector4[]? second,
Vector4[]? result,
int count)
{
_pool = pool;
_first = first;
_second = second;
_result = result;
_count = count;
_disposed = false;
}
public int Count
{
get
{
ThrowIfDisposed();
return _count;
}
}
public ReadOnlySpan<Vector4> Span
{
get
{
ThrowIfDisposed();
return _result is null
? ReadOnlySpan<Vector4>.Empty
: _result.AsSpan(0, _count);
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
ArrayPool<Vector4>? pool = _pool;
if (_first is not null)
pool!.Return(_first);
if (_second is not null)
pool!.Return(_second);
_first = null;
_second = null;
_result = null;
}
private readonly void ThrowIfDisposed()
{
if (_disposed)
throw new ObjectDisposedException(nameof(ClipPolygonLease));
}
}
/// <summary>Project a cell-local polygon to NDC, preserving the projected winding of /// <summary>Project a cell-local polygon to NDC, preserving the projected winding of
/// the input (NOT normalized to CCW). The caller (PortalVisibilityBuilder) is responsible /// the input (NOT normalized to CCW). The caller (PortalVisibilityBuilder) is responsible
/// for feeding camera-facing portal polygons (via the portal-side test) so the result is /// for feeding camera-facing portal polygons (via the portal-side test) so the result is
/// CCW for the CCW-only <see cref="ScreenPolygonClip"/>. Returns fewer than 3 verts when /// CCW for the CCW-only <see cref="ScreenPolygonClip"/>. Returns fewer than 3 verts when
/// the polygon is entirely behind the camera / degenerate.</summary> /// the polygon is entirely behind the camera / degenerate.</summary>
public static Vector2[] ProjectToNdc(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj) public static Vector2[] ProjectToNdc(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
=> ProjectToNdc(localPoly, cellToWorld, viewProj, ArrayPool<Vector4>.Shared);
internal static Vector2[] ProjectToNdc(
IReadOnlyList<Vector3> localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj,
ArrayPool<Vector4> vectorPool)
{ {
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector2>(); if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector2>();
ArgumentNullException.ThrowIfNull(vectorPool);
Matrix4x4 m = cellToWorld * viewProj; Matrix4x4 m = cellToWorld * viewProj;
// To clip space (keep w). // A convex polygon can gain at most one vertex at each clipping plane. Keep the two
var clip = new List<Vector4>(localPoly.Count); // Sutherland-Hodgman work buffers in ArrayPool instead of allocating six Lists per portal.
foreach (var lp in localPoly) int capacity = checked(localPoly.Count + 5);
clip.Add(Vector4.Transform(new Vector4(lp, 1f), m)); Vector4[] first = vectorPool.Rent(capacity);
Vector4[]? second = null;
// Homogeneous frustum clip in CLIP SPACE, before the perspective divide. First the // Homogeneous frustum clip in CLIP SPACE, before the perspective divide. First the
// in-front-of-eye half-space (w > MinW) — near-INDEPENDENT, so a portal the camera is // in-front-of-eye half-space (w > MinW) — near-INDEPENDENT, so a portal the camera is
@ -52,22 +129,47 @@ public static class PortalProjection
// all survivors have w > 0, making the side-plane functionals (w ± x, w ± y) well defined. // all survivors have w > 0, making the side-plane functionals (w ± x, w ± y) well defined.
// Near/far are intentionally NOT clipped (near-independence). Retail PView::GetClip // Near/far are intentionally NOT clipped (near-independence). Retail PView::GetClip
// (decomp:0x005a4320) projects + frustum-clips the portal poly likewise (research doc A §3.5). // (decomp:0x005a4320) projects + frustum-clips the portal poly likewise (research doc A §3.5).
clip = ClipPlane(clip, v => v.W - MinW); // in front of eye (near-independent) try
if (clip.Count < 3) return System.Array.Empty<Vector2>();
clip = ClipPlane(clip, v => v.W + v.X); // left: x/w >= -1 <=> w + x >= 0
clip = ClipPlane(clip, v => v.W - v.X); // right: x/w <= 1 <=> w - x >= 0
clip = ClipPlane(clip, v => v.W + v.Y); // bottom: y/w >= -1 <=> w + y >= 0
clip = ClipPlane(clip, v => v.W - v.Y); // top: y/w <= 1 <=> w - y >= 0
if (clip.Count < 3) return System.Array.Empty<Vector2>();
// Perspective divide → NDC xy.
var ndc = new Vector2[clip.Count];
for (int i = 0; i < clip.Count; i++)
{ {
float w = clip[i].W; second = vectorPool.Rent(capacity);
ndc[i] = new Vector2(clip[i].X / w, clip[i].Y / w); int currentCount = localPoly.Count;
for (int i = 0; i < currentCount; i++)
first[i] = Vector4.Transform(new Vector4(localPoly[i], 1f), m);
Vector4[] current = first;
Vector4[] output = second;
ReadOnlySpan<HomogeneousPlane> planes =
[
HomogeneousPlane.EyeMinW,
HomogeneousPlane.Left,
HomogeneousPlane.Right,
HomogeneousPlane.Bottom,
HomogeneousPlane.Top,
];
foreach (HomogeneousPlane plane in planes)
{
currentCount = ClipHomogeneousPlane(
current.AsSpan(0, currentCount), output, plane);
if (currentCount < 3)
return System.Array.Empty<Vector2>();
(current, output) = (output, current);
}
// Perspective divide → NDC xy. This is the only result allocation.
var ndc = new Vector2[currentCount];
for (int i = 0; i < currentCount; i++)
{
float w = current[i].W;
ndc[i] = new Vector2(current[i].X / w, current[i].Y / w);
}
return ndc;
}
finally
{
vectorPool.Return(first);
if (second is not null)
vectorPool.Return(second);
} }
return ndc;
} }
/// <summary>Faithful homogeneous projection (retail PrimD3DRender::xformStart + the W=0 clip of /// <summary>Faithful homogeneous projection (retail PrimD3DRender::xformStart + the W=0 clip of
@ -89,24 +191,83 @@ public static class PortalProjection
/// when some vertex has w &lt; 0; &lt;3 survivors → reject (empty).</para></summary> /// when some vertex has w &lt; 0; &lt;3 survivors → reject (empty).</para></summary>
public static Vector4[] ProjectToClip(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj) public static Vector4[] ProjectToClip(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
{ {
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector4>(); using ClipPolygonLease lease = ProjectToClipLease(localPoly, cellToWorld, viewProj);
return lease.Count < 3 ? System.Array.Empty<Vector4>() : lease.Span.ToArray();
}
internal static ClipPolygonLease ProjectToClipLease(
IReadOnlyList<Vector3> localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj)
=> ProjectToClipLease(
localPoly,
cellToWorld,
viewProj,
ArrayPool<Vector4>.Shared);
internal static ClipPolygonLease ProjectToClipLease(
IReadOnlyList<Vector3> localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj,
ArrayPool<Vector4> vectorPool)
{
ArgumentNullException.ThrowIfNull(vectorPool);
if (localPoly == null || localPoly.Count < 3)
return new ClipPolygonLease(null, null, null, null, 0);
Matrix4x4 m = cellToWorld * viewProj; Matrix4x4 m = cellToWorld * viewProj;
var clip = new List<Vector4>(localPoly.Count); Vector4[] transformed = vectorPool.Rent(localPoly.Count);
bool anyBehind = false; Vector4[]? clipped = null;
foreach (var lp in localPoly) bool success = false;
try
{ {
var v = Vector4.Transform(new Vector4(lp, 1f), m); clipped = vectorPool.Rent(checked(localPoly.Count + 1));
if (v.W < 0f) anyBehind = true; bool anyBehind = false;
clip.Add(v); for (int i = 0; i < localPoly.Count; i++)
} {
Vector4 vertex = Vector4.Transform(new Vector4(localPoly[i], 1f), m);
if (vertex.W < 0f) anyBehind = true;
transformed[i] = vertex;
}
// polyClipFinish part 1 (0x006b6d5d): the W pass runs only when some vertex sits behind // polyClipFinish part 1 (0x006b6d5d): the W pass runs only when some vertex sits behind
// the eye plane (w < 0); an all-in-front polygon passes through untouched (and an // the eye plane (w < 0); an all-in-front polygon passes through untouched (and an
// all-behind one clips to empty inside the pass). // all-behind one clips to empty inside the pass).
if (anyBehind) ReadOnlySpan<Vector4> result = transformed.AsSpan(0, localPoly.Count);
clip = ClipPlane(clip, v => v.W); if (anyBehind)
return clip.Count >= 3 ? clip.ToArray() : System.Array.Empty<Vector4>(); {
int count = ClipHomogeneousPlane(result, clipped, HomogeneousPlane.EyeZero);
if (count < 3)
{
success = true;
return new ClipPolygonLease(
vectorPool,
transformed,
clipped,
null,
0);
}
result = clipped.AsSpan(0, count);
}
Vector4[] resultArray = anyBehind ? clipped : transformed;
success = true;
return new ClipPolygonLease(
vectorPool,
transformed,
clipped,
resultArray,
result.Length);
}
finally
{
if (!success)
{
vectorPool.Return(transformed);
if (clipped is not null)
vectorPool.Return(clipped);
}
}
} }
/// <summary>Clip a homogeneous (clip-space) portal polygon against an NDC view region /// <summary>Clip a homogeneous (clip-space) portal polygon against an NDC view region
@ -120,55 +281,128 @@ public static class PortalProjection
if (subjectClip == null || regionCcwNdc == null || subjectClip.Count < 3 || regionCcwNdc.Count < 3) if (subjectClip == null || regionCcwNdc == null || subjectClip.Count < 3 || regionCcwNdc.Count < 3)
return System.Array.Empty<Vector2>(); return System.Array.Empty<Vector2>();
if (subjectClip is Vector4[] array)
return ClipToRegion(array.AsSpan(), regionCcwNdc);
Vector4[] rented = ArrayPool<Vector4>.Shared.Rent(subjectClip.Count);
try
{
for (int i = 0; i < subjectClip.Count; i++)
rented[i] = subjectClip[i];
return ClipToRegion(rented.AsSpan(0, subjectClip.Count), regionCcwNdc);
}
finally
{
ArrayPool<Vector4>.Shared.Return(rented);
}
}
internal static Vector2[] ClipToRegion(
ReadOnlySpan<Vector4> subjectClip,
IReadOnlyList<Vector2> regionCcwNdc)
=> ClipToRegionCore(subjectClip, regionCcwNdc, vertexStore: null);
internal static Vector2[] ClipToRegion(
ReadOnlySpan<Vector4> subjectClip,
IReadOnlyList<Vector2> regionCcwNdc,
PortalPolygonVertexStore vertexStore)
=> ClipToRegionCore(
subjectClip,
regionCcwNdc,
vertexStore,
ArrayPool<Vector4>.Shared,
ArrayPool<Vector2>.Shared);
internal static Vector2[] ClipToRegion(
ReadOnlySpan<Vector4> subjectClip,
IReadOnlyList<Vector2> regionCcwNdc,
PortalPolygonVertexStore vertexStore,
ArrayPool<Vector4> vector4Pool)
=> ClipToRegionCore(
subjectClip,
regionCcwNdc,
vertexStore,
vector4Pool,
ArrayPool<Vector2>.Shared);
private static Vector2[] ClipToRegionCore(
ReadOnlySpan<Vector4> subjectClip,
IReadOnlyList<Vector2> regionCcwNdc,
PortalPolygonVertexStore? vertexStore,
ArrayPool<Vector4>? vector4Pool = null,
ArrayPool<Vector2>? vector2Pool = null)
{
if (subjectClip.Length < 3 || regionCcwNdc == null || regionCcwNdc.Count < 3)
return System.Array.Empty<Vector2>();
vector4Pool ??= ArrayPool<Vector4>.Shared;
vector2Pool ??= ArrayPool<Vector2>.Shared;
// Homogeneous Sutherland-Hodgman: clip the (w > 0) subject against each CCW edge of the NDC // Homogeneous Sutherland-Hodgman: clip the (w > 0) subject against each CCW edge of the NDC
// region. f(P) below is the NDC inside test cross(edge, P_ndc - a) multiplied through P.W, // region. f(P) below is the NDC inside test cross(edge, P_ndc - a) multiplied through P.W,
// which is > 0 after the eye-plane clip — so the sign is the NDC sign yet no near-eye vertex // which is > 0 after the eye-plane clip — so the sign is the NDC sign yet no near-eye vertex
// is ever divided (retail polyClipFinish, decomp 702749). // is ever divided (retail polyClipFinish, decomp 702749).
var poly = new List<Vector4>(subjectClip); int regionCount = regionCcwNdc.Count;
int n = regionCcwNdc.Count; int capacity = checked(subjectClip.Length + regionCount);
for (int e = 0; e < n; e++) Vector4[] first = vector4Pool.Rent(capacity);
{ Vector4[]? second = null;
if (poly.Count < 3) return System.Array.Empty<Vector2>(); Vector2[]? ndcScratch = null;
poly = ClipHomogeneousEdge(poly, regionCcwNdc[e], regionCcwNdc[(e + 1) % n]);
}
if (poly.Count < 3) return System.Array.Empty<Vector2>();
// Divide survivors → NDC. They are inside the region now, so |x| ≤ |w| and |y| ≤ |w|: the try
// divide is bounded by construction (this is why the homogeneous clip avoids the early-divide
// blow-up). Normalize to CCW so the result is a valid clip region for the next portal hop.
//
// W=0 port (2026-06-11): with ProjectToClip clipping at exactly w >= 0, a w == 0 vertex
// (a direction) cannot survive the bounded region clip above — a nonzero direction fails at
// least one edge's inside test of any bounded convex region — EXCEPT the measure-zero case
// of a direction lying exactly on a region corner with d == 0 on the adjoining edges. That
// case divides to ±Inf/NaN; treat it as the degenerate knife-edge sliver it is and return
// empty (retail's effective result for the same input: a <1 px degenerate region).
var ndc = new Vector2[poly.Count];
for (int i = 0; i < poly.Count; i++)
{ {
float w = poly[i].W; second = vector4Pool.Rent(capacity);
var v = new Vector2(poly[i].X / w, poly[i].Y / w); int currentCount = subjectClip.Length;
if (!float.IsFinite(v.X) || !float.IsFinite(v.Y)) subjectClip.CopyTo(first);
Vector4[] current = first;
Vector4[] output = second;
for (int edge = 0; edge < regionCount; edge++)
{
if (currentCount < 3)
return System.Array.Empty<Vector2>();
int outputCount = ClipHomogeneousEdge(
current.AsSpan(0, currentCount),
output,
regionCcwNdc[edge],
regionCcwNdc[(edge + 1) % regionCount]);
(current, output) = (output, current);
currentCount = outputCount;
}
if (currentCount < 3)
return System.Array.Empty<Vector2>(); return System.Array.Empty<Vector2>();
ndc[i] = v;
// Divide survivors → NDC. They are already inside the bounded region. A w=0
// measure-zero corner remains the same empty knife-edge result as the prior path.
ndcScratch = vector2Pool.Rent(currentCount);
Span<Vector2> ndc = ndcScratch.AsSpan(0, currentCount);
for (int i = 0; i < currentCount; i++)
{
float w = current[i].W;
var vertex = new Vector2(current[i].X / w, current[i].Y / w);
if (!float.IsFinite(vertex.X) || !float.IsFinite(vertex.Y))
return System.Array.Empty<Vector2>();
ndc[i] = vertex;
}
// T2 (BR-4): retail's post-divide ~1-pixel vertex merge. Compact in place; only a
// genuinely shortened result needs a second exactly-sized output array.
int mergedCount = MergeSubPixelVertices(ndc);
if (mergedCount < 3)
return System.Array.Empty<Vector2>();
Vector2[] merged = vertexStore?.Rent(mergedCount)
?? GC.AllocateUninitializedArray<Vector2>(mergedCount);
ndc[..mergedCount].CopyTo(merged);
EnsureCcw(merged);
return merged;
}
finally
{
vector4Pool.Return(first);
if (second is not null)
vector4Pool.Return(second);
if (ndcScratch is not null)
vector2Pool.Return(ndcScratch);
} }
// T2 (BR-4): retail's post-divide vertex merge — Render::copy_view
// (Ghidra 0x0054dfc0) collapses consecutive vertices closer than ~1
// PIXEL (|dx|<=1 && |dy|<=1 screen units) after the perspective divide.
// This is the flood's physical fixpoint floor: re-clipping a view can
// only insert sub-pixel sliver vertices, which this merge removes, so
// accumulated views converge instead of drifting (the drift is what
// forced the MaxReprocessPerCell=16 cap). Unit approximation: the
// builder has no viewport, so 1 px is expressed in NDC at a reference
// 1080p (2/1080 ≈ 0.00185); at higher resolutions the merge is merely
// slightly coarser than retail's, which only strengthens convergence.
var merged = MergeSubPixelVertices(ndc);
if (merged.Length < 3)
return System.Array.Empty<Vector2>();
EnsureCcw(merged);
return merged;
} }
// Retail copy_view's ~1-pixel vertex merge (see ClipToRegion). Collapses // Retail copy_view's ~1-pixel vertex merge (see ClipToRegion). Collapses
@ -178,47 +412,52 @@ public static class PortalProjection
// "<3 surviving verts → output count 0". // "<3 surviving verts → output count 0".
private const float VertexMergeEpsilonNdc = 2f / 1080f; private const float VertexMergeEpsilonNdc = 2f / 1080f;
private static Vector2[] MergeSubPixelVertices(Vector2[] poly) private static int MergeSubPixelVertices(Span<Vector2> poly)
{ {
if (poly.Length < 3) return poly; if (poly.Length < 3) return poly.Length;
var kept = new List<Vector2>(poly.Length); int kept = 0;
foreach (var v in poly) for (int i = 0; i < poly.Length; i++)
{ {
if (kept.Count > 0) Vector2 vertex = poly[i];
if (kept > 0)
{ {
var prev = kept[^1]; Vector2 previous = poly[kept - 1];
if (MathF.Abs(v.X - prev.X) <= VertexMergeEpsilonNdc if (MathF.Abs(vertex.X - previous.X) <= VertexMergeEpsilonNdc
&& MathF.Abs(v.Y - prev.Y) <= VertexMergeEpsilonNdc) && MathF.Abs(vertex.Y - previous.Y) <= VertexMergeEpsilonNdc)
continue; continue;
} }
kept.Add(v); poly[kept++] = vertex;
} }
// Wrap-around: last ≈ first. // Wrap-around: last ≈ first.
while (kept.Count >= 2) while (kept >= 2)
{ {
var first = kept[0]; Vector2 first = poly[0];
var last = kept[^1]; Vector2 last = poly[kept - 1];
if (MathF.Abs(first.X - last.X) <= VertexMergeEpsilonNdc if (MathF.Abs(first.X - last.X) <= VertexMergeEpsilonNdc
&& MathF.Abs(first.Y - last.Y) <= VertexMergeEpsilonNdc) && MathF.Abs(first.Y - last.Y) <= VertexMergeEpsilonNdc)
kept.RemoveAt(kept.Count - 1); kept--;
else else
break; break;
} }
return kept.Count == poly.Length ? poly : kept.ToArray(); return kept;
} }
// One Sutherland-Hodgman half-plane against the directed NDC edge a→b, keeping the CCW-inside // One Sutherland-Hodgman half-plane against the directed NDC edge a→b, keeping the CCW-inside
// (left) part of a HOMOGENEOUS polygon. Inside test for vertex P (clip space): the NDC cross // (left) part of a HOMOGENEOUS polygon. Inside test for vertex P (clip space): the NDC cross
// product cross(b-a, P/P.W - a) scaled by P.W (> 0): ex·(P.Y - P.W·a.Y) - ey·(P.X - P.W·a.X) ≥ 0. // product cross(b-a, P/P.W - a) scaled by P.W (> 0): ex·(P.Y - P.W·a.Y) - ey·(P.X - P.W·a.X) ≥ 0.
// Crossings interpolate in homogeneous coords (perspective-correct), via the shared Lerp. // Crossings interpolate in homogeneous coords (perspective-correct), via the shared Lerp.
private static List<Vector4> ClipHomogeneousEdge(List<Vector4> poly, Vector2 a, Vector2 b) private static int ClipHomogeneousEdge(
ReadOnlySpan<Vector4> polygon,
Span<Vector4> result,
Vector2 a,
Vector2 b)
{ {
var result = new List<Vector4>(poly.Count + 1); int outputCount = 0;
float ex = b.X - a.X, ey = b.Y - a.Y; float ex = b.X - a.X, ey = b.Y - a.Y;
for (int i = 0; i < poly.Count; i++) for (int i = 0; i < polygon.Length; i++)
{ {
Vector4 cur = poly[i]; Vector4 cur = polygon[i];
Vector4 prev = poly[(i + poly.Count - 1) % poly.Count]; Vector4 prev = polygon[(i + polygon.Length - 1) % polygon.Length];
float dCur = ex * (cur.Y - cur.W * a.Y) - ey * (cur.X - cur.W * a.X); float dCur = ex * (cur.Y - cur.W * a.Y) - ey * (cur.X - cur.W * a.X);
float dPrev = ex * (prev.Y - prev.W * a.Y) - ey * (prev.X - prev.W * a.X); float dPrev = ex * (prev.Y - prev.W * a.Y) - ey * (prev.X - prev.W * a.X);
bool curIn = dCur >= 0f; bool curIn = dCur >= 0f;
@ -226,15 +465,15 @@ public static class PortalProjection
if (curIn) if (curIn)
{ {
if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur)); if (!prevIn) result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
result.Add(cur); result[outputCount++] = cur;
} }
else if (prevIn) else if (prevIn)
{ {
result.Add(Lerp(prev, cur, dPrev, dCur)); result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
} }
} }
return result; return outputCount;
} }
// Reverse vertex order in place if wound clockwise (signed area < 0). Mirrors the builder's // Reverse vertex order in place if wound clockwise (signed area < 0). Mirrors the builder's
@ -257,33 +496,54 @@ public static class PortalProjection
private const float MinW = 0.05f; private const float MinW = 0.05f;
// Sutherland-Hodgman against one half-space of the homogeneous view frustum, in CLIP SPACE. // Sutherland-Hodgman against one half-space of the homogeneous view frustum, in CLIP SPACE.
// `dist` is the signed plane functional (>= 0 keeps the vertex); crossings are interpolated in // The enum avoids per-plane delegate/lambda traffic in this per-portal hot path.
// homogeneous coords (perspective-correct). Callers apply the eye plane first so every survivor private static int ClipHomogeneousPlane(
// has w > 0, making the side-plane functionals (w ± x, w ± y) well defined. ReadOnlySpan<Vector4> polygon,
private static List<Vector4> ClipPlane(List<Vector4> poly, System.Func<Vector4, float> dist) Span<Vector4> result,
HomogeneousPlane plane)
{ {
if (poly.Count == 0) return poly; int outputCount = 0;
var result = new List<Vector4>(poly.Count + 1); for (int i = 0; i < polygon.Length; i++)
for (int i = 0; i < poly.Count; i++)
{ {
Vector4 cur = poly[i]; Vector4 cur = polygon[i];
Vector4 prev = poly[(i + poly.Count - 1) % poly.Count]; Vector4 prev = polygon[(i + polygon.Length - 1) % polygon.Length];
float dCur = dist(cur); float dCur = PlaneDistance(cur, plane);
float dPrev = dist(prev); float dPrev = PlaneDistance(prev, plane);
bool curIn = dCur >= 0f; bool curIn = dCur >= 0f;
bool prevIn = dPrev >= 0f; bool prevIn = dPrev >= 0f;
if (curIn) if (curIn)
{ {
if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur)); if (!prevIn) result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
result.Add(cur); result[outputCount++] = cur;
} }
else if (prevIn) else if (prevIn)
{ {
result.Add(Lerp(prev, cur, dPrev, dCur)); result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
} }
} }
return result; return outputCount;
}
private static float PlaneDistance(in Vector4 vertex, HomogeneousPlane plane) => plane switch
{
HomogeneousPlane.EyeMinW => vertex.W - MinW,
HomogeneousPlane.EyeZero => vertex.W,
HomogeneousPlane.Left => vertex.W + vertex.X,
HomogeneousPlane.Right => vertex.W - vertex.X,
HomogeneousPlane.Bottom => vertex.W + vertex.Y,
HomogeneousPlane.Top => vertex.W - vertex.Y,
_ => throw new System.ArgumentOutOfRangeException(nameof(plane)),
};
private enum HomogeneousPlane : byte
{
EyeMinW,
EyeZero,
Left,
Right,
Bottom,
Top,
} }
private static Vector4 Lerp(Vector4 p, Vector4 q, float dp, float dq) private static Vector4 Lerp(Vector4 p, Vector4 q, float dp, float dq)

View file

@ -8,6 +8,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion; using AcDream.Core.Physics.Motion;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
@ -44,7 +45,6 @@ public sealed class PortalTunnelPresentation : IDisposable
private readonly GL _gl; private readonly GL _gl;
private readonly WbDrawDispatcher _dispatcher; private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo; private readonly SceneLightingUboBinding _lightUbo;
private readonly IWbMeshAdapter _meshAdapter;
private readonly Setup _setup; private readonly Setup _setup;
private readonly uint _setupDid; private readonly uint _setupDid;
private readonly uint _animationDid; private readonly uint _animationDid;
@ -54,7 +54,7 @@ public sealed class PortalTunnelPresentation : IDisposable
private readonly PortalTunnelCamera _camera = new(); private readonly PortalTunnelCamera _camera = new();
private readonly Random _random; private readonly Random _random;
private readonly Action<string>? _displayNotice; private readonly Action<string>? _displayNotice;
private readonly uint[] _registeredGfxObjects; private readonly PortalMeshReferenceOwner _meshReferences;
private bool _visible; private bool _visible;
private double _rotationElapsed; private double _rotationElapsed;
@ -62,6 +62,8 @@ public sealed class PortalTunnelPresentation : IDisposable
private float _rotationStartAngle; private float _rotationStartAngle;
private float _rotationEndAngle; private float _rotationEndAngle;
private float _rotationCurrentAngle; private float _rotationCurrentAngle;
private bool _disposeRequested;
private bool _disposing;
private bool _disposed; private bool _disposed;
private PortalTunnelPresentation( private PortalTunnelPresentation(
@ -80,7 +82,6 @@ public sealed class PortalTunnelPresentation : IDisposable
_gl = gl; _gl = gl;
_dispatcher = dispatcher; _dispatcher = dispatcher;
_lightUbo = lightUbo; _lightUbo = lightUbo;
_meshAdapter = meshAdapter;
_setup = setup; _setup = setup;
_setupDid = setupDid; _setupDid = setupDid;
_animationDid = animationDid; _animationDid = animationDid;
@ -99,13 +100,9 @@ public sealed class PortalTunnelPresentation : IDisposable
MeshRefs = SetupMesh.Flatten(setup), MeshRefs = SetupMesh.Flatten(setup),
}; };
_registeredGfxObjects = setup.Parts _meshReferences = new PortalMeshReferenceOwner(
.Select(part => (uint)part) meshAdapter,
.Where(id => id != 0u) setup.Parts.Select(part => (ulong)(uint)part));
.Distinct()
.ToArray();
foreach (uint gfxObjId in _registeredGfxObjects)
_meshAdapter.IncrementRefCount(gfxObjId);
} }
/// <summary> /// <summary>
@ -116,7 +113,7 @@ public sealed class PortalTunnelPresentation : IDisposable
/// </summary> /// </summary>
public static PortalTunnelPresentation CreateRequired( public static PortalTunnelPresentation CreateRequired(
GL gl, GL gl,
DatCollection dats, IDatReaderWriter dats,
IAnimationLoader animationLoader, IAnimationLoader animationLoader,
IAnimationHookSink hookSink, IAnimationHookSink hookSink,
WbDrawDispatcher dispatcher, WbDrawDispatcher dispatcher,
@ -180,6 +177,17 @@ public sealed class PortalTunnelPresentation : IDisposable
public uint SetupDid => _setupDid; public uint SetupDid => _setupDid;
public uint AnimationDid => _animationDid; public uint AnimationDid => _animationDid;
/// <summary>
/// Publishes the synthetic scene's mesh ownership after the presentation
/// itself has been stored by <c>GameWindow</c>. A partial acquisition stays
/// reachable and is resumed or released by the same lifetime owner.
/// </summary>
internal void PrepareResources()
{
ThrowIfDisposed();
_meshReferences.Acquire();
}
/// <summary> /// <summary>
/// Retail <c>set_sequence_animation(anim, clear=1, low=1, fps=40)</c> /// Retail <c>set_sequence_animation(anim, clear=1, low=1, fps=40)</c>
/// on the edge where portal space becomes visible. /// on the edge where portal space becomes visible.
@ -332,7 +340,8 @@ public sealed class PortalTunnelPresentation : IDisposable
}); });
} }
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); private void ThrowIfDisposed() =>
ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
/// <summary> /// <summary>
/// Retail stages animation hooks while advancing the sequence, then drains /// Retail stages animation hooks while advancing the sequence, then drains
@ -372,12 +381,23 @@ public sealed class PortalTunnelPresentation : IDisposable
public void Dispose() public void Dispose()
{ {
if (_disposed) if (_disposed || _disposing)
return; return;
Exit();
_animationHooks.Clear(); _disposeRequested = true;
foreach (uint gfxObjId in _registeredGfxObjects) _disposing = true;
_meshAdapter.DecrementRefCount(gfxObjId); try
_disposed = true; {
_visible = false;
_animationHooks.Clear();
_sequence.ClearAnimations();
_meshReferences.Dispose();
if (_meshReferences.IsDisposed)
_disposed = true;
}
finally
{
_disposing = false;
}
} }
} }

View file

@ -3,9 +3,9 @@
// Phase A8.F: GL-free 2D screen-space (NDC) clip-region data model. // Phase A8.F: GL-free 2D screen-space (NDC) clip-region data model.
// Mirrors retail view_poly (acclient.h:32465) and view_type (acclient.h:32338): // Mirrors retail view_poly (acclient.h:32465) and view_type (acclient.h:32338):
// a cell's clip region is a SET of convex polygons in normalized device coords. // a cell's clip region is a SET of convex polygons in normalized device coords.
using System.Buffers;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Text;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -37,14 +37,96 @@ public readonly struct ViewPolygon
public bool IsEmpty => Vertices is null || Vertices.Length < 3; public bool IsEmpty => Vertices is null || Vertices.Length < 3;
} }
/// <summary>
/// Frame-owned exact-length storage for projected portal polygons. A polygon's
/// vertex array remains immutable for the lifetime of its visibility frame, then
/// becomes reusable when that frame is reset. Exact lengths preserve the existing
/// <see cref="ViewPolygon.Vertices"/> contract and all clipping semantics.
/// </summary>
internal sealed class PortalPolygonVertexStore
{
private const int MaxRetainedArrays = 4_096;
private const int MaxRetainedVertices = 65_536;
private const int MaxRetainedPolygonVertices = 256;
private sealed class Bucket
{
public readonly List<Vector2[]> Buffers = new(4);
public int Used;
}
private readonly Dictionary<int, Bucket> _buckets = new();
private int _retainedArrays;
private int _retainedVertices;
internal int AllocationCount { get; private set; }
internal int RetainedArrayCount => _retainedArrays;
internal Vector2[] Rent(int vertexCount)
{
ArgumentOutOfRangeException.ThrowIfLessThan(vertexCount, 1);
if (_buckets.TryGetValue(vertexCount, out Bucket? bucket)
&& bucket.Used < bucket.Buffers.Count)
{
return bucket.Buffers[bucket.Used++];
}
Vector2[] result = GC.AllocateUninitializedArray<Vector2>(vertexCount);
AllocationCount++;
bool retain = vertexCount <= MaxRetainedPolygonVertices
&& _retainedArrays < MaxRetainedArrays
&& _retainedVertices + vertexCount <= MaxRetainedVertices;
if (!retain)
return result;
if (bucket is null)
{
bucket = new Bucket();
_buckets.Add(vertexCount, bucket);
}
bucket.Buffers.Add(result);
bucket.Used++;
_retainedArrays++;
_retainedVertices += vertexCount;
return result;
}
internal void ResetUsage()
{
foreach (Bucket bucket in _buckets.Values)
bucket.Used = 0;
}
}
/// <summary>A cell's accumulated clip region: a set of convex view polygons + the union bounding rect.</summary> /// <summary>A cell's accumulated clip region: a set of convex view polygons + the union bounding rect.</summary>
public sealed class CellView public sealed class CellView
{ {
// ViewPolygon exposes its vertex array for the renderer, so this seed must
// be owned by the CellView rather than shared globally. Pooling the
// CellView then reuses the four vertices without allowing one caller to
// corrupt every future full-screen seed.
private readonly ViewPolygon _fullScreenPolygon = new(new[]
{
new Vector2(-1f, -1f),
new Vector2(1f, -1f),
new Vector2(1f, 1f),
new Vector2(-1f, 1f),
});
public readonly List<ViewPolygon> Polygons = new(); public readonly List<ViewPolygon> Polygons = new();
// Canonical (snapped) keys of the polygons in <see cref="Polygons"/>, backing the drift-tolerant // Canonical (snapped) keys of the polygons in <see cref="Polygons"/>, backing the drift-tolerant
// dedup in <see cref="Add"/>. One entry per stored polygon; HashSet membership IS the dedup. // dedup in <see cref="Add"/>. Hash-bucket membership is the dedup; a stored key owns only its
private readonly HashSet<string> _polygonKeys = new(); // snapped integer coordinates while duplicate probes use stack or ArrayPool scratch.
// Hash -> head index in _polygonKeyStorage. Collision chains are integer
// links rather than one List allocation per accepted hash. Storage and
// coordinate arrays stay with this pooled CellView and are reused across
// frames; _activePolygonKeyCount marks the live prefix.
private readonly Dictionary<int, int> _polygonKeyHeads = new();
private readonly List<PolygonKey> _polygonKeyStorage = new();
private int _activePolygonKeyCount;
public float MinX { get; private set; } = float.MaxValue; public float MinX { get; private set; } = float.MaxValue;
public float MinY { get; private set; } = float.MaxValue; public float MinY { get; private set; } = float.MaxValue;
public float MaxX { get; private set; } = float.MinValue; public float MaxX { get; private set; } = float.MinValue;
@ -52,18 +134,57 @@ public sealed class CellView
public bool IsEmpty => Polygons.Count == 0; public bool IsEmpty => Polygons.Count == 0;
internal bool IsRetainable
{
get
{
if (Polygons.Capacity > 256
|| _polygonKeyHeads.EnsureCapacity(0) > 512
|| _polygonKeyStorage.Count > 512)
{
return false;
}
int retainedCoordinateInts = 0;
for (int i = 0; i < _polygonKeyStorage.Count; i++)
{
int length = _polygonKeyStorage[i].Coordinates.Length;
if (length > 256)
return false;
retainedCoordinateInts += length;
if (retainedCoordinateInts > 8192)
return false;
}
return true;
}
}
internal void Reset()
{
Polygons.Clear();
_polygonKeyHeads.Clear();
_activePolygonKeyCount = 0;
MinX = float.MaxValue;
MinY = float.MaxValue;
MaxX = float.MinValue;
MaxY = float.MinValue;
}
/// <summary>A region covering the entire NDC viewport — the camera cell's seed region /// <summary>A region covering the entire NDC viewport — the camera cell's seed region
/// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814).</summary> /// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814).</summary>
public static CellView FullScreen() public static CellView FullScreen()
{ {
var v = new CellView(); var v = new CellView();
v.Add(new ViewPolygon(new[] v.Add(v._fullScreenPolygon);
{
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
}));
return v; return v;
} }
internal void SetFullScreen()
{
Reset();
Add(_fullScreenPolygon);
}
public bool Add(ViewPolygon p) public bool Add(ViewPolygon p)
{ {
if (p.IsEmpty) return false; if (p.IsEmpty) return false;
@ -79,9 +200,9 @@ public sealed class CellView
// bounded and the flood is GUARANTEED to converge. The stored polygon keeps full precision (only // bounded and the flood is GUARANTEED to converge. The stored polygon keeps full precision (only
// the key is snapped), so downstream clip geometry is unchanged, and the grid (1e-3 NDC ~ sub- // the key is snapped), so downstream clip geometry is unchanged, and the grid (1e-3 NDC ~ sub-
// pixel) is far finer than the gap between genuinely distinct openings, so real regions never merge. // pixel) is far finer than the gap between genuinely distinct openings, so real regions never merge.
string? key = CanonicalKey(p.Vertices); CanonicalKeyResult keyResult = TryAddCanonicalKey(p.Vertices);
if (key is null) return false; // degenerate after snap (< 3 distinct vertices) if (keyResult == CanonicalKeyResult.Degenerate) return false;
if (!_polygonKeys.Add(key)) return false; // duplicate region (drift / rotation / count tolerant) if (keyResult == CanonicalKeyResult.Duplicate) return false;
// #120 convergence (2026-06-11): reject a polygon CONTAINED in one already // #120 convergence (2026-06-11): reject a polygon CONTAINED in one already
// stored. The reciprocal ping-pong (eye within PortalSideEpsilon of a // stored. The reciprocal ping-pong (eye within PortalSideEpsilon of a
@ -178,8 +299,8 @@ public sealed class CellView
// against the zero-area region). Rejecting these views dropped the whole chain behind an // against the zero-area region). Rejecting these views dropped the whole chain behind an
// exactly-in-plane portal for the frame — the parked-eye knife-edge band (tower deck, spiral // exactly-in-plane portal for the frame — the parked-eye knife-edge band (tower deck, spiral
// landings). The segment key space is finite like the area-key space, so dedup + the strict // landings). The segment key space is finite like the area-key space, so dedup + the strict
// growth convergence invariant are unchanged. Returns null only when fewer than 2 distinct // growth convergence invariant are unchanged. Degenerate is returned only when fewer than 2
// snapped points survive (a true sub-grid point — not a real region OR segment). // distinct snapped points survive (a true sub-grid point — not a real region OR segment).
// //
// §4 corner/doorway fix (2026-06-10) — the collinear pass: the homogeneous region clipper // §4 corner/doorway fix (2026-06-10) — the collinear pass: the homogeneous region clipper
// (PortalProjection.ClipToRegion, used by the forward AND — as of today — the reciprocal hop) // (PortalProjection.ClipToRegion, used by the forward AND — as of today — the reciprocal hop)
@ -190,89 +311,194 @@ public sealed class CellView
// exact reason the reciprocal clip was previously parked on the unstable divide-first path). // exact reason the reciprocal clip was previously parked on the unstable divide-first path).
// Dropping collinear snapped points makes the key purely a function of the region's CORNERS, so // Dropping collinear snapped points makes the key purely a function of the region's CORNERS, so
// any re-emission of the same shape — drifted, rotated, vertex-count-inflated — deduplicates. // any re-emission of the same shape — drifted, rotated, vertex-count-inflated — deduplicates.
private static string? CanonicalKey(Vector2[]? verts) private CanonicalKeyResult TryAddCanonicalKey(Vector2[]? verts)
{ {
if (verts is null || verts.Length < 3) return null; if (verts is null || verts.Length < 3)
return CanonicalKeyResult.Degenerate;
var pts = new List<(int X, int Y)>(verts.Length); SnappedPoint[]? rented = null;
foreach (var v in verts) Span<SnappedPoint> points = verts.Length <= 32
? stackalloc SnappedPoint[verts.Length]
: (rented = ArrayPool<SnappedPoint>.Shared.Rent(verts.Length)).AsSpan(0, verts.Length);
try
{ {
var q = ((int)System.MathF.Round(v.X / DedupGridNdc), (int)System.MathF.Round(v.Y / DedupGridNdc)); int count = 0;
if (pts.Count == 0 || pts[^1] != q) pts.Add(q); foreach (Vector2 vertex in verts)
}
if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1);
// Snapshot the distinct snapped points BEFORE collinear removal — the all-collinear
// fallback keys off the segment EXTREMES of the full point set (stable across
// re-emissions regardless of the removal loop's order).
List<(int X, int Y)>? preCollinear = pts.Count >= 2 ? new List<(int, int)>(pts) : null;
// Remove collinear points: for consecutive (prev, cur, next) around the cycle, drop cur when
// cross(cur-prev, next-cur) == 0 — exact in integer grid coordinates (deltas ≤ ~4000, products
// ≤ ~1.6e7, no overflow). Loop to a fixpoint: removing one point can make its neighbour
// collinear. All-collinear inputs reduce below 3 → the segment-key fallback below.
bool removed = true;
while (removed && pts.Count >= 3)
{
removed = false;
for (int i = 0; i < pts.Count && pts.Count >= 3; i++)
{ {
var prev = pts[(i + pts.Count - 1) % pts.Count]; var point = new SnappedPoint(
var cur = pts[i]; (int)MathF.Round(vertex.X / DedupGridNdc),
var next = pts[(i + 1) % pts.Count]; (int)MathF.Round(vertex.Y / DedupGridNdc));
long cross = (long)(cur.X - prev.X) * (next.Y - cur.Y) if (count == 0 || points[count - 1] != point)
- (long)(cur.Y - prev.Y) * (next.X - cur.X); points[count++] = point;
if (cross == 0) }
if (count >= 2 && points[count - 1] == points[0])
count--;
if (count < 2)
return CanonicalKeyResult.Degenerate;
SnappedPoint lo = points[0];
SnappedPoint hi = points[0];
for (int i = 1; i < count; i++)
{
SnappedPoint point = points[i];
if (point.X < lo.X || (point.X == lo.X && point.Y < lo.Y)) lo = point;
if (point.X > hi.X || (point.X == hi.X && point.Y > hi.Y)) hi = point;
}
bool removed = true;
while (removed && count >= 3)
{
removed = false;
for (int i = 0; i < count && count >= 3; i++)
{ {
pts.RemoveAt(i); SnappedPoint previous = points[(i + count - 1) % count];
SnappedPoint current = points[i];
SnappedPoint next = points[(i + 1) % count];
long cross = (long)(current.X - previous.X) * (next.Y - current.Y)
- (long)(current.Y - previous.Y) * (next.X - current.X);
if (cross != 0)
continue;
points.Slice(i + 1, count - i - 1).CopyTo(points.Slice(i));
count--;
removed = true; removed = true;
i--; i--;
} }
} }
}
if (pts.Count < 3) if (count < 3)
{
// Zero-area (all-collinear) view — key as its snapped segment so retail's
// degenerate-view propagation works (see method doc). Extremes are the
// lexicographic min/max of the full snapped point set.
if (preCollinear is null) return null;
var lo = preCollinear[0];
var hi = preCollinear[0];
foreach (var q in preCollinear)
{ {
if (q.X < lo.X || (q.X == lo.X && q.Y < lo.Y)) lo = q; if (lo == hi)
if (q.X > hi.X || (q.X == hi.X && q.Y > hi.Y)) hi = q; return CanonicalKeyResult.Degenerate;
Span<SnappedPoint> segment = stackalloc SnappedPoint[2] { lo, hi };
return AddCanonicalKey(PolygonKeyKind.Line, segment, start: 0);
} }
if (lo == hi) return null; // a sub-grid point — not a region or a segment
return $"L:{lo.X},{lo.Y};{hi.X},{hi.Y};"; int best = 0;
for (int start = 1; start < count; start++)
if (RotationLess(points, start, best, count)) best = start;
return AddCanonicalKey(PolygonKeyKind.Polygon, points[..count], best);
} }
finally
int n = pts.Count;
int best = 0;
for (int s = 1; s < n; s++)
if (RotationLess(pts, s, best, n)) best = s;
var sb = new StringBuilder(n * 10);
for (int i = 0; i < n; i++)
{ {
var q = pts[(best + i) % n]; if (rented is not null)
sb.Append(q.X).Append(',').Append(q.Y).Append(';'); ArrayPool<SnappedPoint>.Shared.Return(rented);
} }
return sb.ToString();
} }
// True when the rotation of `pts` starting at index a is lexicographically less than the rotation private CanonicalKeyResult AddCanonicalKey(
// starting at b (compare X then Y, vertex by vertex around the cycle). Gives a unique canonical PolygonKeyKind kind,
// start even when two vertices share the minimum snapped coordinate. ReadOnlySpan<SnappedPoint> points,
private static bool RotationLess(List<(int X, int Y)> pts, int a, int b, int n) int start)
{ {
for (int i = 0; i < n; i++) int hash = ComputeHash(kind, points, start);
if (_polygonKeyHeads.TryGetValue(hash, out int keyIndex))
{ {
var pa = pts[(a + i) % n]; while (keyIndex >= 0)
var pb = pts[(b + i) % n]; {
if (pa.X != pb.X) return pa.X < pb.X; PolygonKey existing = _polygonKeyStorage[keyIndex];
if (pa.Y != pb.Y) return pa.Y < pb.Y; if (existing.Equals(kind, points, start))
return CanonicalKeyResult.Duplicate;
keyIndex = existing.Next;
}
}
int coordinateCount = points.Length * 2;
int storageIndex = FindOrCreateCoordinateStorage(coordinateCount);
int[] coordinates = _polygonKeyStorage[storageIndex].Coordinates;
for (int i = 0; i < points.Length; i++)
{
SnappedPoint point = points[(start + i) % points.Length];
coordinates[i * 2] = point.X;
coordinates[i * 2 + 1] = point.Y;
}
int next = _polygonKeyHeads.GetValueOrDefault(hash, -1);
_polygonKeyStorage[storageIndex] = new PolygonKey(kind, coordinates, next);
_polygonKeyHeads[hash] = storageIndex;
_activePolygonKeyCount++;
return CanonicalKeyResult.Added;
}
private int FindOrCreateCoordinateStorage(int coordinateCount)
{
int storageIndex = _activePolygonKeyCount;
for (int i = storageIndex; i < _polygonKeyStorage.Count; i++)
{
if (_polygonKeyStorage[i].Coordinates.Length != coordinateCount)
continue;
if (i != storageIndex)
(_polygonKeyStorage[storageIndex], _polygonKeyStorage[i]) =
(_polygonKeyStorage[i], _polygonKeyStorage[storageIndex]);
return storageIndex;
}
_polygonKeyStorage.Add(new PolygonKey(
PolygonKeyKind.Polygon,
new int[coordinateCount],
-1));
int addedIndex = _polygonKeyStorage.Count - 1;
if (addedIndex != storageIndex)
(_polygonKeyStorage[storageIndex], _polygonKeyStorage[addedIndex]) =
(_polygonKeyStorage[addedIndex], _polygonKeyStorage[storageIndex]);
return storageIndex;
}
private static int ComputeHash(
PolygonKeyKind kind,
ReadOnlySpan<SnappedPoint> points,
int start)
{
unchecked
{
uint hash = 2166136261u;
hash = (hash ^ (byte)kind) * 16777619u;
hash = (hash ^ (uint)points.Length) * 16777619u;
for (int i = 0; i < points.Length; i++)
{
SnappedPoint point = points[(start + i) % points.Length];
hash = (hash ^ (uint)point.X) * 16777619u;
hash = (hash ^ (uint)point.Y) * 16777619u;
}
return (int)hash;
}
}
private static bool RotationLess(
ReadOnlySpan<SnappedPoint> points,
int a,
int b,
int count)
{
for (int i = 0; i < count; i++)
{
SnappedPoint left = points[(a + i) % count];
SnappedPoint right = points[(b + i) % count];
if (left.X != right.X) return left.X < right.X;
if (left.Y != right.Y) return left.Y < right.Y;
} }
return false; return false;
} }
private readonly record struct SnappedPoint(int X, int Y);
private readonly record struct PolygonKey(PolygonKeyKind Kind, int[] Coordinates, int Next)
{
public bool Equals(PolygonKeyKind kind, ReadOnlySpan<SnappedPoint> points, int start)
{
if (Kind != kind || Coordinates.Length != points.Length * 2)
return false;
for (int i = 0; i < points.Length; i++)
{
SnappedPoint point = points[(start + i) % points.Length];
if (Coordinates[i * 2] != point.X || Coordinates[i * 2 + 1] != point.Y)
return false;
}
return true;
}
}
private enum PolygonKeyKind : byte { Polygon, Line }
private enum CanonicalKeyResult : byte { Degenerate, Duplicate, Added }
} }

View file

@ -14,9 +14,26 @@ namespace AcDream.App.Rendering;
/// <summary>Per-frame output of the portal-frame BFS.</summary> /// <summary>Per-frame output of the portal-frame BFS.</summary>
public sealed class PortalVisibilityFrame public sealed class PortalVisibilityFrame
{ {
private const int MaxRetainedCellViews = 512;
internal const int MaxRetainedBuildCollectionCapacity = 512;
internal const int CapacityTrimIdleFrames = 120;
private readonly Stack<CellView> _cellViewPool = new();
private readonly PortalPolygonVertexStore _polygonVertices = new();
private int _cellViewsUnderusedFrames;
private int _crossBuildingViewsUnderusedFrames;
private int _queuedUnderusedFrames;
private int _drawListedUnderusedFrames;
private int _processedViewCountsUnderusedFrames;
private int _orderedVisibleCellsUnderusedFrames;
private int _todoUnderusedFrames;
internal PortalPolygonVertexStore PolygonVertices => _polygonVertices;
internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount;
internal int RetainedPolygonVertexArrayCount => _polygonVertices.RetainedArrayCount;
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals /// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
/// recursively clipped to their portal chain. The cellar-flap fix.</summary> /// recursively clipped to their portal chain. The cellar-flap fix.</summary>
public CellView OutsideView { get; } = new(); public CellView OutsideView { get; private set; } = new();
/// <summary>Per-cell accumulated clip region, keyed by full cell id (wire-in #2).</summary> /// <summary>Per-cell accumulated clip region, keyed by full cell id (wire-in #2).</summary>
public Dictionary<uint, CellView> CellViews { get; } = new(); public Dictionary<uint, CellView> CellViews { get; } = new();
@ -31,6 +48,199 @@ public sealed class PortalVisibilityFrame
/// <summary>Entry clip regions for other buildings reached through our portals, keyed by the /// <summary>Entry clip regions for other buildings reached through our portals, keyed by the
/// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5).</summary> /// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5).</summary>
public Dictionary<uint, CellView> CrossBuildingViews { get; } = new(); public Dictionary<uint, CellView> CrossBuildingViews { get; } = new();
// Build scratch belongs to the frame so a caller that reuses a frame also reuses the large
// hash tables and the 128-entry convergence trace. This is especially important outdoors,
// where retail runs one small ConstructView flood per nearby building every frame.
internal HashSet<uint> QueuedScratch { get; } = new();
internal HashSet<uint> DrawListedScratch { get; } = new();
internal Dictionary<uint, int> ProcessedViewCountsScratch { get; } = new();
internal uint[] PropagationChainScratch { get; } = new uint[128];
internal List<(LoadedCell Cell, float Distance)> TodoScratch { get; } = new();
// A build can recurse while an outer portal's clipped region is still live, so one mutable
// singleton is unsafe. Leases reuse Lists by recursion lifetime rather than by total portal-call
// count: ordinary sequential portals need one List, while nested AdjustCellView propagation gets
// one per active depth. Dispose returns scratch on every continue/exception path.
private readonly Stack<List<ViewPolygon>> _clipRegionPool = new();
private const int MaxRetainedClipRegionLists = 132; // recursion tripwire (128) + nested side work
private const int MaxRetainedClipRegionCapacity = 256;
internal int ClipRegionScratchCount => _clipRegionPool.Count;
internal int RetainedCellViewCount => _cellViewPool.Count;
internal int CellViewMapCapacity => CellViews.EnsureCapacity(0);
internal int CrossBuildingViewMapCapacity => CrossBuildingViews.EnsureCapacity(0);
internal int QueuedCapacity => QueuedScratch.EnsureCapacity(0);
internal int DrawListedCapacity => DrawListedScratch.EnsureCapacity(0);
internal int ProcessedViewCountCapacity => ProcessedViewCountsScratch.EnsureCapacity(0);
internal ClipRegionScratchLease RentClipRegionScratch()
{
List<ViewPolygon> region = _clipRegionPool.Count != 0
? _clipRegionPool.Pop()
: new List<ViewPolygon>(2);
region.Clear();
return new ClipRegionScratchLease(this, region);
}
private void ReturnClipRegionScratch(List<ViewPolygon> region)
{
region.Clear();
if (region.Capacity <= MaxRetainedClipRegionCapacity
&& _clipRegionPool.Count < MaxRetainedClipRegionLists)
_clipRegionPool.Push(region);
}
internal readonly ref struct ClipRegionScratchLease
{
private readonly PortalVisibilityFrame _owner;
public List<ViewPolygon> Region { get; }
internal ClipRegionScratchLease(PortalVisibilityFrame owner, List<ViewPolygon> region)
{
_owner = owner;
Region = region;
}
public void Dispose() => _owner.ReturnClipRegionScratch(Region);
}
internal void ResetForBuild()
{
int cellViewCount = CellViews.Count;
int crossBuildingViewCount = CrossBuildingViews.Count;
int queuedCount = QueuedScratch.Count;
int drawListedCount = DrawListedScratch.Count;
int processedViewCount = ProcessedViewCountsScratch.Count;
int orderedVisibleCellCount = OrderedVisibleCells.Count;
int todoCount = TodoScratch.Count;
if (OutsideView.IsRetainable)
OutsideView.Reset();
else
OutsideView = new CellView();
ReturnCellViews(CellViews);
ReturnCellViews(CrossBuildingViews);
CellViews.Clear();
OrderedVisibleCells.Clear();
CrossBuildingViews.Clear();
QueuedScratch.Clear();
DrawListedScratch.Clear();
ProcessedViewCountsScratch.Clear();
TodoScratch.Clear();
_polygonVertices.ResetUsage();
// These collections live as long as RetailPViewRenderer. A fixed
// capacity cutoff is unsafe here: a legitimate large portal flood can
// exceed it every frame, causing us to discard and rebuild the same
// warmed tables forever. Retire high-water storage only after it has
// remained at least 50% underused for a sustained idle window. Once a
// collection is right-sized for its recurring workload it stays warm.
TrimIfCold(CellViews, cellViewCount, ref _cellViewsUnderusedFrames);
TrimIfCold(
CrossBuildingViews,
crossBuildingViewCount,
ref _crossBuildingViewsUnderusedFrames);
TrimIfCold(QueuedScratch, queuedCount, ref _queuedUnderusedFrames);
TrimIfCold(DrawListedScratch, drawListedCount, ref _drawListedUnderusedFrames);
TrimIfCold(
ProcessedViewCountsScratch,
processedViewCount,
ref _processedViewCountsUnderusedFrames);
TrimIfCold(
OrderedVisibleCells,
orderedVisibleCellCount,
ref _orderedVisibleCellsUnderusedFrames);
TrimIfCold(TodoScratch, todoCount, ref _todoUnderusedFrames);
}
internal ViewPolygon CopyPolygon(ReadOnlySpan<Vector2> vertices)
{
if (vertices.Length < 3)
return default;
Vector2[] owned = _polygonVertices.Rent(vertices.Length);
vertices.CopyTo(owned);
return new ViewPolygon(owned);
}
internal CellView RentCellView(bool fullScreen = false)
{
CellView result = _cellViewPool.Count != 0
? _cellViewPool.Pop()
: new CellView();
if (fullScreen)
result.SetFullScreen();
else
result.Reset();
return result;
}
private void ReturnCellViews(Dictionary<uint, CellView> views)
{
foreach (CellView view in views.Values)
{
view.Reset();
if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews)
_cellViewPool.Push(view);
}
}
private static void TrimIfCold<TKey, TValue>(
Dictionary<TKey, TValue> values,
int usedCount,
ref int underusedFrames)
where TKey : notnull
{
int capacity = values.EnsureCapacity(0);
if (!ShouldTrim(capacity, usedCount, ref underusedFrames))
return;
if (capacity > MaxRetainedBuildCollectionCapacity)
values.TrimExcess();
}
private static void TrimIfCold<T>(
HashSet<T> values,
int usedCount,
ref int underusedFrames)
{
int capacity = values.EnsureCapacity(0);
if (!ShouldTrim(capacity, usedCount, ref underusedFrames))
return;
if (capacity > MaxRetainedBuildCollectionCapacity)
values.TrimExcess();
}
private static void TrimIfCold<T>(
List<T> values,
int usedCount,
ref int underusedFrames)
{
int capacity = values.Capacity;
if (!ShouldTrim(capacity, usedCount, ref underusedFrames))
return;
if (capacity > MaxRetainedBuildCollectionCapacity)
values.Capacity = 0;
}
private static bool ShouldTrim(int capacity, int usedCount, ref int underusedFrames)
{
if (capacity <= MaxRetainedBuildCollectionCapacity
|| (long)usedCount * 2L > capacity)
{
underusedFrames = 0;
return false;
}
underusedFrames++;
if (underusedFrames < CapacityTrimIdleFrames)
return false;
underusedFrames = 0;
return true;
}
} }
public static class PortalVisibilityBuilder public static class PortalVisibilityBuilder
@ -121,16 +331,18 @@ public static class PortalVisibilityBuilder
Func<uint, LoadedCell?> lookup, Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj, Matrix4x4 viewProj,
Func<uint, bool>? buildingMembership = null, Func<uint, bool>? buildingMembership = null,
float drawLiftZ = 0f) float drawLiftZ = 0f,
PortalVisibilityFrame? reuseFrame = null)
{ {
var frame = new PortalVisibilityFrame(); var frame = reuseFrame ?? new PortalVisibilityFrame();
frame.ResetForBuild();
if (cameraCell == null) return frame; if (cameraCell == null) return frame;
// Interior portals never cross landblocks (same invariant as CellVisibility.GetVisibleCells); // Interior portals never cross landblocks (same invariant as CellVisibility.GetVisibleCells);
// building-boundary crossings are handled separately via the buildingMembership escape hatch. // building-boundary crossings are handled separately via the buildingMembership escape hatch.
uint lbMask = cameraCell.CellId & 0xFFFF0000u; uint lbMask = cameraCell.CellId & 0xFFFF0000u;
frame.CellViews[cameraCell.CellId] = CellView.FullScreen(); frame.CellViews[cameraCell.CellId] = frame.RentCellView(fullScreen: true);
// Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor // Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor
// node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC // node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC
@ -142,15 +354,15 @@ public static class PortalVisibilityBuilder
// ids, so an id test would misfire. An interior root never sets this flag, so the indoor // ids, so an id test would misfire. An interior root never sets this flag, so the indoor
// exit-portal path (OtherCellId==0xFFFF below) still owns the doorway OutsideView region. // exit-portal path (OtherCellId==0xFFFF below) still owns the doorway OutsideView region.
if (cameraCell.IsOutdoorNode) if (cameraCell.IsOutdoorNode)
frame.OutsideView.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone())); frame.OutsideView.Add(frame.CopyPolygon(FullScreenQuad));
// Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first; // Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first;
// each cell carries the camera→nearest-portal-vertex distance that put it on the list // each cell carries the camera→nearest-portal-vertex distance that put it on the list
// (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The // (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The
// camera cell seeds at distance 0 (retail InsCellTodoList(this, arg2, 0f) at 433758) so it // camera cell seeds at distance 0 (retail InsCellTodoList(this, arg2, 0f) at 433758) so it
// always pops first. // always pops first.
var todo = new CellTodoList(); List<(LoadedCell Cell, float Distance)> todo = frame.TodoScratch;
todo.Insert(cameraCell, 0f); InsertTodo(todo, cameraCell, 0f);
// Fixpoint termination replacing the old MaxReprocessPerCell hard cap. This mirrors the // Fixpoint termination replacing the old MaxReprocessPerCell hard cap. This mirrors the
// retail portal_view slice offset 0x44 (last-incorporated view-poly watermark) vs 0x38 // retail portal_view slice offset 0x44 (last-incorporated view-poly watermark) vs 0x38
@ -162,9 +374,10 @@ public static class PortalVisibilityBuilder
// the instant a cell is popped). Enqueue-once across the cell set is the hard termination // the instant a cell is popped). Enqueue-once across the cell set is the hard termination
// guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The // guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The
// camera cell is pre-marked so a portal looping back to it can never re-enqueue it. // camera cell is pre-marked so a portal looping back to it can never re-enqueue it.
var queued = new HashSet<uint> { cameraCell.CellId }; HashSet<uint> queued = frame.QueuedScratch;
var drawListed = new HashSet<uint>(); queued.Add(cameraCell.CellId);
var processedViewCounts = new Dictionary<uint, int>(); HashSet<uint> drawListed = frame.DrawListedScratch;
Dictionary<uint, int> processedViewCounts = frame.ProcessedViewCountsScratch;
var trace = PortalBuildTrace.Start(cameraCell, cameraPos); var trace = PortalBuildTrace.Start(cameraCell, cameraPos);
// [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn // [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn
@ -224,7 +437,7 @@ public static class PortalVisibilityBuilder
// reproduce the T5 firing — production-only ingredients (full lookup // reproduce the T5 firing — production-only ingredients (full lookup
// graph / real camera path) are suspected; this dump pins them on the // graph / real camera path) are suspected; this dump pins them on the
// next natural occurrence. // next natural occurrence.
var propagationChain = new uint[RecursionTripwire]; uint[] propagationChain = frame.PropagationChainScratch;
void ProcessCellPortals(LoadedCell cell, int depth) void ProcessCellPortals(LoadedCell cell, int depth)
{ {
@ -251,7 +464,6 @@ public static class PortalVisibilityBuilder
} }
trace?.Add($"proc cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} depth={depth}"); trace?.Add($"proc cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} depth={depth}");
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
processedViewCounts[cell.CellId] = endCount; processedViewCounts[cell.CellId] = endCount;
for (int i = 0; i < cell.Portals.Count; i++) for (int i = 0; i < cell.Portals.Count; i++)
@ -301,11 +513,18 @@ public static class PortalVisibilityBuilder
// homogeneous clip space, clip at the eye, then clip against the current // homogeneous clip space, clip at the eye, then clip against the current
// portal_view region before the divide. Do the same here; the old early // portal_view region before the divide. Do the same here; the old early
// ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways. // ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways.
var clippedRegion = ClipPortalAgainstView( using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease =
frame.RentClipRegionScratch();
List<ViewPolygon> clippedRegion = clippedRegionLease.Region;
ClipPortalAgainstView(
frame,
poly, poly,
cell.WorldTransform, cell.WorldTransform,
viewProj, viewProj,
activeViewPolygons, currentView.Polygons,
processedCount,
endCount - processedCount,
clippedRegion,
out int clipVerts); out int clipVerts);
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})"); if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}"); if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
@ -339,16 +558,31 @@ public static class PortalVisibilityBuilder
// lifted space or terrain stops a lift-height short of the // lifted space or terrain stops a lift-height short of the
// drawn lintel (#130 strip). Flood semantics keep the // drawn lintel (#130 strip). Flood semantics keep the
// unlifted clippedRegion path above. // unlifted clippedRegion path above.
var outsideRegion = drawLiftZ == 0f int outsideCount;
? clippedRegion if (drawLiftZ == 0f)
: ClipPortalAgainstView( {
AddRegion(frame.OutsideView, clippedRegion);
outsideCount = clippedRegion.Count;
}
else
{
using PortalVisibilityFrame.ClipRegionScratchLease outsideRegionLease =
frame.RentClipRegionScratch();
List<ViewPolygon> outsideRegion = outsideRegionLease.Region;
ClipPortalAgainstView(
frame,
poly, poly,
cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ), cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ),
viewProj, viewProj,
activeViewPolygons, currentView.Polygons,
processedCount,
endCount - processedCount,
outsideRegion,
out _); out _);
AddRegion(frame.OutsideView, outsideRegion); AddRegion(frame.OutsideView, outsideRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideRegion.Count} clipVerts={clipVerts}"); outsideCount = outsideRegion.Count;
}
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideCount} clipVerts={clipVerts}");
continue; continue;
} }
@ -360,7 +594,7 @@ public static class PortalVisibilityBuilder
// call inside ConstructView at decomp:433692.) // call inside ConstructView at decomp:433692.)
if (buildingMembership != null && !buildingMembership(neighbourId)) if (buildingMembership != null && !buildingMembership(neighbourId))
{ {
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId); var xview = GetOrCreate(frame, frame.CrossBuildingViews, neighbourId);
bool grewCross = AddRegion(xview, clippedRegion); bool grewCross = AddRegion(xview, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}"); trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}");
continue; continue;
@ -391,7 +625,8 @@ public static class PortalVisibilityBuilder
// neighbour's side; the old eye-in-opening restore was part of // neighbour's side; the old eye-in-opening restore was part of
// the deleted rescue. // the deleted rescue.
int preReciprocalCount = clippedRegion.Count; int preReciprocalCount = clippedRegion.Count;
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj); ApplyReciprocalClip(
frame, clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
if (churnProbe) if (churnProbe)
churnReciprocal!.Append(System.FormattableString.Invariant( churnReciprocal!.Append(System.FormattableString.Invariant(
$" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]")); $" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]"));
@ -402,7 +637,7 @@ public static class PortalVisibilityBuilder
} }
// Union the clipped region into the neighbour's accumulated view. // Union the clipped region into the neighbour's accumulated view.
var nview = GetOrCreate(frame.CellViews, neighbourId); var nview = GetOrCreate(frame, frame.CellViews, neighbourId);
bool grew = AddRegion(nview, clippedRegion); bool grew = AddRegion(nview, clippedRegion);
bool inserted = false; bool inserted = false;
bool inPlace = false; bool inPlace = false;
@ -417,7 +652,7 @@ public static class PortalVisibilityBuilder
if (queued.Add(neighbourId)) if (queued.Add(neighbourId))
{ {
dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist); InsertTodo(todo, neighbour, dist);
inserted = true; inserted = true;
} }
// Growth into an already-POPPED cell → retail AdjustCellView: // Growth into an already-POPPED cell → retail AdjustCellView:
@ -437,7 +672,7 @@ public static class PortalVisibilityBuilder
while (todo.Count > 0) while (todo.Count > 0)
{ {
var cell = todo.PopNearest(); LoadedCell cell = PopNearest(todo);
// Single pop per cell (enqueue-once) IS the cell's closest-first // Single pop per cell (enqueue-once) IS the cell's closest-first
// draw position (retail appends to cell_draw_list once per pop, // draw position (retail appends to cell_draw_list once per pop,
@ -491,13 +726,15 @@ public static class PortalVisibilityBuilder
Func<uint, LoadedCell?> lookup, Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj, Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity, float maxSeedDistance = float.PositiveInfinity,
IReadOnlyList<ViewPolygon>? seedRegion = null) IReadOnlyList<ViewPolygon>? seedRegion = null,
PortalVisibilityFrame? reuseFrame = null)
{ {
var frame = new PortalVisibilityFrame(); var frame = reuseFrame ?? new PortalVisibilityFrame();
var todo = new CellTodoList(); frame.ResetForBuild();
var queued = new HashSet<uint>(); List<(LoadedCell Cell, float Distance)> todo = frame.TodoScratch;
var drawListed = new HashSet<uint>(); HashSet<uint> queued = frame.QueuedScratch;
var processedViewCounts = new Dictionary<uint, int>(); HashSet<uint> drawListed = frame.DrawListedScratch;
Dictionary<uint, int> processedViewCounts = frame.ProcessedViewCountsScratch;
foreach (var cell in candidateCells) foreach (var cell in candidateCells)
{ {
@ -534,11 +771,19 @@ public static class PortalVisibilityBuilder
if (seedDistance > maxSeedDistance) if (seedDistance > maxSeedDistance)
continue; continue;
var clippedRegion = ClipPortalAgainstView( using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease =
frame.RentClipRegionScratch();
List<ViewPolygon> clippedRegion = clippedRegionLease.Region;
IReadOnlyList<ViewPolygon> activeSeedRegion = seedRegion ?? FullScreenRegion;
ClipPortalAgainstView(
frame,
poly, poly,
cell.WorldTransform, cell.WorldTransform,
viewProj, viewProj,
seedRegion ?? FullScreenRegion, activeSeedRegion,
0,
activeSeedRegion.Count,
clippedRegion,
out _); out _);
// T2 (BR-4): empty clip = no seed, no exceptions (retail's // T2 (BR-4): empty clip = no seed, no exceptions (retail's
@ -547,11 +792,11 @@ public static class PortalVisibilityBuilder
if (clippedRegion.Count == 0) if (clippedRegion.Count == 0)
continue; continue;
var seedView = GetOrCreate(frame.CellViews, cell.CellId); var seedView = GetOrCreate(frame, frame.CellViews, cell.CellId);
bool grew = AddRegion(seedView, clippedRegion); bool grew = AddRegion(seedView, clippedRegion);
if (grew && queued.Add(cell.CellId)) if (grew && queued.Add(cell.CellId))
todo.Insert(cell, seedDistance); InsertTodo(todo, cell, seedDistance);
} }
} }
@ -560,7 +805,7 @@ public static class PortalVisibilityBuilder
// re-enqueue + MaxReprocessPerCell cap and the eye-in-opening rescues // re-enqueue + MaxReprocessPerCell cap and the eye-in-opening rescues
// are deleted (empty clip culls, period). // are deleted (empty clip culls, period).
const int RecursionTripwire = 128; const int RecursionTripwire = 128;
var propagationChain = new uint[RecursionTripwire]; // #120 self-attribution — see Build() uint[] propagationChain = frame.PropagationChainScratch; // #120 self-attribution — see Build()
void ProcessCellPortals(LoadedCell cell, int depth) void ProcessCellPortals(LoadedCell cell, int depth)
{ {
@ -580,7 +825,6 @@ public static class PortalVisibilityBuilder
if (processedCount >= endCount) if (processedCount >= endCount)
return; return;
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
processedViewCounts[cell.CellId] = endCount; processedViewCounts[cell.CellId] = endCount;
uint lbMask = cell.CellId & 0xFFFF0000u; uint lbMask = cell.CellId & 0xFFFF0000u;
@ -602,11 +846,18 @@ public static class PortalVisibilityBuilder
&& !CameraOnInteriorSide(cell, i, cameraPos)) && !CameraOnInteriorSide(cell, i, cameraPos))
continue; continue;
var clippedRegion = ClipPortalAgainstView( using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease =
frame.RentClipRegionScratch();
List<ViewPolygon> clippedRegion = clippedRegionLease.Region;
ClipPortalAgainstView(
frame,
poly, poly,
cell.WorldTransform, cell.WorldTransform,
viewProj, viewProj,
activeViewPolygons, currentView.Polygons,
processedCount,
endCount - processedCount,
clippedRegion,
out _); out _);
if (clippedRegion.Count == 0) if (clippedRegion.Count == 0)
@ -617,11 +868,12 @@ public static class PortalVisibilityBuilder
if (neighbour == null) if (neighbour == null)
continue; continue;
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj); ApplyReciprocalClip(
frame, clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
if (clippedRegion.Count == 0) if (clippedRegion.Count == 0)
continue; continue;
var nview = GetOrCreate(frame.CellViews, neighbourId); var nview = GetOrCreate(frame, frame.CellViews, neighbourId);
bool grew = AddRegion(nview, clippedRegion); bool grew = AddRegion(nview, clippedRegion);
if (grew) if (grew)
@ -629,7 +881,7 @@ public static class PortalVisibilityBuilder
if (queued.Add(neighbourId)) if (queued.Add(neighbourId))
{ {
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist); InsertTodo(todo, neighbour, dist);
} }
else if (drawListed.Contains(neighbourId)) else if (drawListed.Contains(neighbourId))
{ {
@ -641,7 +893,7 @@ public static class PortalVisibilityBuilder
while (todo.Count > 0) while (todo.Count > 0)
{ {
var cell = todo.PopNearest(); LoadedCell cell = PopNearest(todo);
if (drawListed.Add(cell.CellId)) if (drawListed.Add(cell.CellId))
frame.OrderedVisibleCells.Add(cell.CellId); frame.OrderedVisibleCells.Add(cell.CellId);
@ -669,8 +921,16 @@ public static class PortalVisibilityBuilder
Func<uint, LoadedCell?> lookup, Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj, Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity, float maxSeedDistance = float.PositiveInfinity,
IReadOnlyList<ViewPolygon>? seedRegion = null) IReadOnlyList<ViewPolygon>? seedRegion = null,
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance, seedRegion); PortalVisibilityFrame? reuseFrame = null)
=> BuildFromExterior(
buildingCells,
cameraPos,
lookup,
viewProj,
maxSeedDistance,
seedRegion,
reuseFrame);
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute. // The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
private static readonly Vector2[] FullScreenQuad = private static readonly Vector2[] FullScreenQuad =
@ -679,30 +939,35 @@ public static class PortalVisibilityBuilder
private static readonly ViewPolygon[] FullScreenRegion = private static readonly ViewPolygon[] FullScreenRegion =
{ new ViewPolygon(FullScreenQuad) }; { new ViewPolygon(FullScreenQuad) };
private static List<ViewPolygon> ClipPortalAgainstView( private static void ClipPortalAgainstView(
PortalVisibilityFrame frame,
Vector3[] localPoly, Vector3[] localPoly,
Matrix4x4 cellToWorld, Matrix4x4 cellToWorld,
Matrix4x4 viewProj, Matrix4x4 viewProj,
IReadOnlyList<ViewPolygon> viewPolygons, IReadOnlyList<ViewPolygon> viewPolygons,
int viewStart,
int viewCount,
List<ViewPolygon> clippedRegion,
out int clipVertexCount) out int clipVertexCount)
{ {
var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj); using PortalProjection.ClipPolygonLease portalClip =
clipVertexCount = portalClip.Length; PortalProjection.ProjectToClipLease(localPoly, cellToWorld, viewProj);
var clippedRegion = new List<ViewPolygon>(); clipVertexCount = portalClip.Count;
if (portalClip.Length < 3) if (portalClip.Count < 3)
return clippedRegion; return;
foreach (var vp in viewPolygons) int viewEnd = viewStart + viewCount;
for (int i = viewStart; i < viewEnd; i++)
{ {
ViewPolygon vp = viewPolygons[i];
if (vp.IsEmpty) if (vp.IsEmpty)
continue; continue;
var clipped = PortalProjection.ClipToRegion(portalClip, vp.Vertices); var clipped = PortalProjection.ClipToRegion(
portalClip.Span, vp.Vertices, frame.PolygonVertices);
if (clipped.Length >= 3) if (clipped.Length >= 3)
clippedRegion.Add(new ViewPolygon(clipped)); clippedRegion.Add(new ViewPolygon(clipped));
} }
return clippedRegion;
} }
private const int PortalTraceEmitLimit = 160; private const int PortalTraceEmitLimit = 160;
@ -913,6 +1178,7 @@ public static class PortalVisibilityBuilder
private const ushort PortalFlagExactMatch = 0x0001; private const ushort PortalFlagExactMatch = 0x0001;
private static void ApplyReciprocalClip( private static void ApplyReciprocalClip(
PortalVisibilityFrame frame,
List<ViewPolygon> clippedRegion, ushort otherPortalId, ushort portalFlags, List<ViewPolygon> clippedRegion, ushort otherPortalId, ushort portalFlags,
LoadedCell neighbour, Matrix4x4 viewProj) LoadedCell neighbour, Matrix4x4 viewProj)
{ {
@ -944,23 +1210,37 @@ public static class PortalVisibilityBuilder
// pins this deterministically; the glitch steps die with this change). The old path's other // pins this deterministically; the glitch steps die with this change). The old path's other
// rationale — per-round float drift defeating the exact-match CellView dedup — is obsolete: // rationale — per-round float drift defeating the exact-match CellView dedup — is obsolete:
// CanonicalKey's 1e-3-grid snap dedup (2026-06-06) absorbs re-clip drift by construction. // CanonicalKey's 1e-3-grid snap dedup (2026-06-06) absorbs re-clip drift by construction.
var reciprocalClip = PortalProjection.ProjectToClip(reciprocalPoly, neighbour.WorldTransform, viewProj); using PortalProjection.ClipPolygonLease reciprocalClip =
if (reciprocalClip.Length < 3) return; // reciprocal entirely behind the eye → no constraint (over-include) PortalProjection.ProjectToClipLease(
reciprocalPoly,
neighbour.WorldTransform,
viewProj);
if (reciprocalClip.Count < 3) return; // reciprocal entirely behind the eye → no constraint (over-include)
// Intersect the reciprocal opening into each near-side polygon; drop any that fall away. // Intersect the reciprocal opening into each near-side polygon; drop any that fall away.
// ClipToRegion(subject=homogeneous reciprocal, region=near-side NDC polygon) = the same // ClipToRegion(subject=homogeneous reciprocal, region=near-side NDC polygon) = the same
// region-edge homogeneous Sutherland-Hodgman the forward hop uses (polyClipFinish port). // region-edge homogeneous Sutherland-Hodgman the forward hop uses (polyClipFinish port).
for (int k = clippedRegion.Count - 1; k >= 0; k--) for (int k = clippedRegion.Count - 1; k >= 0; k--)
{ {
var tightened = PortalProjection.ClipToRegion(reciprocalClip, clippedRegion[k].Vertices); var tightened = PortalProjection.ClipToRegion(
reciprocalClip.Span,
clippedRegion[k].Vertices,
frame.PolygonVertices);
if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened); if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened);
else clippedRegion.RemoveAt(k); else clippedRegion.RemoveAt(k);
} }
} }
private static CellView GetOrCreate(Dictionary<uint, CellView> map, uint key) private static CellView GetOrCreate(
PortalVisibilityFrame frame,
Dictionary<uint, CellView> map,
uint key)
{ {
if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; } if (!map.TryGetValue(key, out var v))
{
v = frame.RentCellView();
map[key] = v;
}
return v; return v;
} }
@ -998,30 +1278,22 @@ public static class PortalVisibilityBuilder
/// not-greater entry), so an equal-distance newcomer lands at the tail and pops FIRST — /// not-greater entry), so an equal-distance newcomer lands at the tail and pops FIRST —
/// LIFO on ties, matching retail's break-on-first-not-greater + pop-from-tail. /// LIFO on ties, matching retail's break-on-first-not-greater + pop-from-tail.
/// </summary> /// </summary>
private sealed class CellTodoList private static void InsertTodo(
List<(LoadedCell Cell, float Distance)> items,
LoadedCell cell,
float distance)
{ {
private readonly List<(LoadedCell Cell, float Distance)> _items = new(); int index = items.Count;
while (index > 0 && items[index - 1].Distance < distance)
index--;
items.Insert(index, (cell, distance));
}
public int Count => _items.Count; private static LoadedCell PopNearest(List<(LoadedCell Cell, float Distance)> items)
{
public void Insert(LoadedCell cell, float distance) int last = items.Count - 1;
{ LoadedCell cell = items[last].Cell;
// Find the slot: scan from the tail (nearest) toward the head while existing entries are items.RemoveAt(last);
// strictly nearer than `distance`, so the newcomer lands just ABOVE every entry that is return cell;
// farther-or-equal — i.e. nearest-at-tail order, LIFO on ties (an equal-distance
// newcomer inserts at the tail and pops first).
int idx = _items.Count;
while (idx > 0 && _items[idx - 1].Distance < distance)
idx--;
_items.Insert(idx, (cell, distance));
}
public LoadedCell PopNearest()
{
int last = _items.Count - 1;
var cell = _items[last].Cell;
_items.RemoveAt(last);
return cell;
}
} }
} }

View file

@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using AcDream.Content;
using DatReaderWriter; using DatReaderWriter;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
@ -13,7 +14,7 @@ namespace AcDream.App.Rendering;
/// </summary> /// </summary>
public sealed record RenderStack( public sealed record RenderStack(
GL Gl, GL Gl,
DatReaderWriter.DatCollection Dats, IDatReaderWriter Dats,
string ShaderDir, string ShaderDir,
Wb.BindlessSupport Bindless, Wb.BindlessSupport Bindless,
TextureCache TextureCache, TextureCache TextureCache,
@ -26,17 +27,47 @@ public sealed record RenderStack(
AcDream.App.UI.UiDatFont? VitalsDatFont, AcDream.App.UI.UiDatFont? VitalsDatFont,
AcDream.App.UI.UiDatFont? LargeDatFont) : System.IDisposable AcDream.App.UI.UiDatFont? LargeDatFont) : System.IDisposable
{ {
internal GpuFrameFlightController FrameFlights { get; init; } = null!;
private ResourceShutdownTransaction? _shutdown;
internal void BeginFrame()
{
FrameFlights.BeginFrame();
UiHost.TextRenderer.BeginFrame(FrameFlights.CurrentSlot);
}
internal void EndFrame() => FrameFlights.EndFrame();
/// <summary>Dispose the GL pieces this stack OWNS (everything created in /// <summary>Dispose the GL pieces this stack OWNS (everything created in
/// <see cref="RenderBootstrap.Create"/>). <see cref="Dats"/> + <see cref="Gl"/> are caller-owned /// <see cref="RenderBootstrap.Create"/>). <see cref="Dats"/> + <see cref="Gl"/> are caller-owned
/// and NOT disposed here. Called once at studio teardown.</summary> /// and NOT disposed here. Called once at studio teardown.</summary>
public void Dispose() public void Dispose()
{ {
DrawDispatcher.Dispose(); _shutdown ??= new ResourceShutdownTransaction(
MeshAdapter.Dispose(); new ResourceShutdownStage("submitted GPU work",
TextureCache.Dispose(); [
MeshShader.Dispose(); new("frame flight drain", FrameFlights.WaitForSubmittedWork),
LightingUbo.Dispose(); ]),
UiHost.Dispose(); new ResourceShutdownStage("draw frontend",
[
new("draw dispatcher", DrawDispatcher.Dispose),
]),
new ResourceShutdownStage("mesh adapter",
[
new("mesh adapter", MeshAdapter.Dispose),
]),
new ResourceShutdownStage("remaining render stack",
[
new("texture cache", TextureCache.Dispose),
new("mesh shader", MeshShader.Dispose),
new("lighting UBO", LightingUbo.Dispose),
new("UI host", UiHost.Dispose),
]),
new ResourceShutdownStage("frame flight owner",
[
new("frame flights", FrameFlights.Dispose),
]));
_shutdown.CompleteOrThrow();
} }
/// <summary> /// <summary>
@ -108,7 +139,7 @@ public static class RenderBootstrap
/// </summary> /// </summary>
public static RenderStack Create( public static RenderStack Create(
GL gl, GL gl,
DatReaderWriter.DatCollection dats, IDatReaderWriter dats,
RenderBootstrapOptions opts) RenderBootstrapOptions opts)
{ {
// --- Bindless detection (GameWindow ~1701-1723) --- // --- Bindless detection (GameWindow ~1701-1723) ---
@ -132,14 +163,15 @@ public static class RenderBootstrap
Path.Combine(shaderDir, "mesh_modern.frag")); Path.Combine(shaderDir, "mesh_modern.frag"));
// --- TextureCache (GameWindow ~1774) --- // --- TextureCache (GameWindow ~1774) ---
var textureCache = new TextureCache(gl, dats, bindless); var frameFlights = new GpuFrameFlightController(gl);
var textureCache = new TextureCache(gl, dats, bindless, frameFlights);
// --- AnimLoader (GameWindow ~1240) --- // --- AnimLoader (GameWindow ~1240) ---
var animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(dats); var animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(dats);
// --- WbMeshAdapter (GameWindow ~2286-2287) --- // --- WbMeshAdapter (GameWindow ~2286-2287) ---
var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance; var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance;
var meshAdapter = new Wb.WbMeshAdapter(gl, dats, wbLogger); var meshAdapter = new Wb.WbMeshAdapter(gl, dats, wbLogger, frameFlights);
// --- SequencerFactory (GameWindow ~2306-2334) --- // --- SequencerFactory (GameWindow ~2306-2334) ---
var capturedDats = dats; var capturedDats = dats;
@ -216,7 +248,10 @@ public static class RenderBootstrap
LightingUbo: lightingUbo, LightingUbo: lightingUbo,
UiHost: uiHost, UiHost: uiHost,
VitalsDatFont: vitalsDatFont, VitalsDatFont: vitalsDatFont,
LargeDatFont: largeDatFont); LargeDatFont: largeDatFont)
{
FrameFlights = frameFlights,
};
// Pre-seed the font cache with the two already-uploaded atlas instances // Pre-seed the font cache with the two already-uploaded atlas instances
// so ResolveDatFont(0x40000000) and ResolveDatFont(0x40000001) hit the cache // so ResolveDatFont(0x40000000) and ResolveDatFont(0x40000001) hit the cache

View file

@ -0,0 +1,104 @@
namespace AcDream.App.Rendering;
internal readonly record struct ResourceShutdownOperation(string Name, Action Execute);
internal sealed record ResourceShutdownStage(
string Name,
ResourceShutdownOperation[] Operations);
/// <summary>
/// Owns application-level shutdown ordering. Operations within one stage are
/// independent and are all attempted; later stages remain protected until the
/// current stage has converged. Successful operations are never replayed.
/// </summary>
internal sealed class ResourceShutdownTransaction(params ResourceShutdownStage[] stages)
{
private sealed class StageState(int operationCount)
{
public bool[] Complete { get; } = new bool[operationCount];
}
private const int MaximumConsecutiveStalledPasses = 2;
private readonly ResourceShutdownStage[] _stages =
stages ?? throw new ArgumentNullException(nameof(stages));
private readonly StageState[] _states =
stages.Select(stage => new StageState(stage.Operations.Length)).ToArray();
private int _currentStage;
private bool _completing;
public bool IsComplete => _currentStage == _stages.Length;
internal int CurrentStage => _currentStage;
public void CompleteOrThrow()
{
if (_completing || IsComplete)
return;
_completing = true;
try
{
int stalledPasses = 0;
List<Exception>? latestFailures = null;
while (!IsComplete)
{
bool progressed = AdvanceCurrentStage(out List<Exception>? failures);
latestFailures = failures;
if (progressed)
{
stalledPasses = 0;
continue;
}
stalledPasses++;
if (stalledPasses < MaximumConsecutiveStalledPasses)
continue;
ResourceShutdownStage stage = _stages[_currentStage];
throw new AggregateException(
$"Shutdown stage '{stage.Name}' did not converge after retrying its pending operations.",
latestFailures ??
[new InvalidOperationException("The shutdown stage made no progress and reported no failure.")]);
}
}
finally
{
_completing = false;
}
}
private bool AdvanceCurrentStage(out List<Exception>? failures)
{
ResourceShutdownStage stage = _stages[_currentStage];
StageState state = _states[_currentStage];
bool progressed = false;
failures = null;
for (int i = 0; i < stage.Operations.Length; i++)
{
if (state.Complete[i])
continue;
ResourceShutdownOperation operation = stage.Operations[i];
try
{
operation.Execute();
state.Complete[i] = true;
progressed = true;
}
catch (Exception error)
{
(failures ??= []).Add(new InvalidOperationException(
$"Shutdown operation '{operation.Name}' failed in stage '{stage.Name}'.",
error));
}
}
if (state.Complete.All(static complete => complete))
{
_currentStage++;
progressed = true;
}
return progressed;
}
}

View file

@ -25,7 +25,13 @@ internal static class RetailAlphaOrdering
/// </summary> /// </summary>
internal interface IRetailAlphaDrawSource internal interface IRetailAlphaDrawSource
{ {
void DrawAlphaBatch(ReadOnlySpan<int> tokens); /// <summary>Uploads this source's complete payload for the current sorted
/// alpha scope exactly once. Tokens arrive in final far-to-near order.</summary>
void PrepareAlphaDraws(ReadOnlySpan<int> tokens);
/// <summary>Draws a contiguous range from the payload prepared above.</summary>
void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount);
void ResetAlphaSubmissions(); void ResetAlphaSubmissions();
} }
@ -55,6 +61,7 @@ internal sealed class RetailAlphaQueue
private readonly List<RetailAlphaSubmission> _submissions = new(256); private readonly List<RetailAlphaSubmission> _submissions = new(256);
private readonly List<IRetailAlphaDrawSource> _sources = new(4); private readonly List<IRetailAlphaDrawSource> _sources = new(4);
private int[] _tokenScratch = new int[256]; private int[] _tokenScratch = new int[256];
private int[] _sourceDrawOffsets = new int[4];
private long _nextSequence; private long _nextSequence;
public bool IsCollecting { get; private set; } public bool IsCollecting { get; private set; }
@ -109,6 +116,26 @@ internal sealed class RetailAlphaQueue
_submissions.Sort(CompareRetailOrder); _submissions.Sort(CompareRetailOrder);
EnsureTokenCapacity(_submissions.Count); EnsureTokenCapacity(_submissions.Count);
EnsureSourceCapacity(_sources.Count);
Array.Clear(_sourceDrawOffsets, 0, _sources.Count);
// Prepare each renderer once for this alpha scope. The filtered
// token sequence preserves final retail order for that source, so
// every later adjacent source-run maps to one contiguous prepared
// range without another GPU upload.
for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++)
{
IRetailAlphaDrawSource source = _sources[sourceIndex];
int sourceCount = 0;
for (int i = 0; i < _submissions.Count; i++)
{
RetailAlphaSubmission submission = _submissions[i];
if (ReferenceEquals(submission.Source, source))
_tokenScratch[sourceCount++] = submission.Token;
}
source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount));
}
int start = 0; int start = 0;
while (start < _submissions.Count) while (start < _submissions.Count)
@ -120,9 +147,10 @@ internal sealed class RetailAlphaQueue
end++; end++;
int count = end - start; int count = end - start;
for (int i = 0; i < count; i++) int sourceIndex = FindSourceIndex(source);
_tokenScratch[i] = _submissions[start + i].Token; int firstPreparedDraw = _sourceDrawOffsets[sourceIndex];
source.DrawAlphaBatch(_tokenScratch.AsSpan(0, count)); source.DrawPreparedAlphaBatch(firstPreparedDraw, count);
_sourceDrawOffsets[sourceIndex] += count;
start = end; start = end;
} }
} }
@ -168,4 +196,19 @@ internal sealed class RetailAlphaQueue
return; return;
Array.Resize(ref _tokenScratch, count + 256); Array.Resize(ref _tokenScratch, count + 256);
} }
private void EnsureSourceCapacity(int count)
{
if (_sourceDrawOffsets.Length >= count)
return;
Array.Resize(ref _sourceDrawOffsets, count + 4);
}
private int FindSourceIndex(IRetailAlphaDrawSource source)
{
for (int i = 0; i < _sources.Count; i++)
if (ReferenceEquals(_sources[i], source))
return i;
throw new InvalidOperationException("Retail alpha source was not registered for this frame.");
}
} }

View file

@ -1,6 +1,7 @@
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Textures; using AcDream.Core.Textures;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using Silk.NET.Core; using Silk.NET.Core;
using Silk.NET.Input; using Silk.NET.Input;
@ -10,7 +11,7 @@ namespace AcDream.App.Rendering;
/// <summary>Applies retail cursor feedback to Silk using dat MediaDescCursor art when available.</summary> /// <summary>Applies retail cursor feedback to Silk using dat MediaDescCursor art when available.</summary>
public sealed class RetailCursorManager public sealed class RetailCursorManager
{ {
private readonly DatCollection _dats; private readonly IDatReaderWriter _dats;
private readonly object _datLock; private readonly object _datLock;
private readonly RetailCursorResolver _globalCursors; private readonly RetailCursorResolver _globalCursors;
private readonly Dictionary<uint, RawImage> _imagesBySurface = new(); private readonly Dictionary<uint, RawImage> _imagesBySurface = new();
@ -22,7 +23,7 @@ public sealed class RetailCursorManager
private UiCursorMedia _lastAppliedCursor; private UiCursorMedia _lastAppliedCursor;
private StandardCursor? _lastStandardCursor; private StandardCursor? _lastStandardCursor;
public RetailCursorManager(DatCollection dats, object datLock) public RetailCursorManager(IDatReaderWriter dats, object datLock)
{ {
_dats = dats; _dats = dats;
_datLock = datLock; _datLock = datLock;

View file

@ -1,5 +1,6 @@
using AcDream.App.UI; using AcDream.App.UI;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -7,12 +8,12 @@ namespace AcDream.App.Rendering;
/// <summary>Resolves retail global cursor enum IDs through the portal EnumIDMap chain.</summary> /// <summary>Resolves retail global cursor enum IDs through the portal EnumIDMap chain.</summary>
internal sealed class RetailCursorResolver internal sealed class RetailCursorResolver
{ {
private readonly DatCollection _dats; private readonly IDatReaderWriter _dats;
private readonly object _datLock; private readonly object _datLock;
private readonly Dictionary<uint, uint> _didByEnum = new(); private readonly Dictionary<uint, uint> _didByEnum = new();
private readonly HashSet<uint> _missingEnums = new(); private readonly HashSet<uint> _missingEnums = new();
public RetailCursorResolver(DatCollection dats, object datLock) public RetailCursorResolver(IDatReaderWriter dats, object datLock)
{ {
_dats = dats; _dats = dats;
_datLock = datLock; _datLock = datLock;
@ -57,7 +58,7 @@ internal sealed class RetailCursorResolver
{ {
lock (_datLock) lock (_datLock)
{ {
uint masterDid = (uint)_dats.Portal.Header.MasterMapId; uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId;
if (masterDid == 0) if (masterDid == 0)
return 0; return 0;

View file

@ -18,9 +18,14 @@ public sealed class RetailPViewRenderer
private readonly ClipFrame _clipFrame; private readonly ClipFrame _clipFrame;
private readonly EnvCellRenderer _envCells; private readonly EnvCellRenderer _envCells;
private readonly WbDrawDispatcher _entities; private readonly WbDrawDispatcher _entities;
private readonly PortalVisibilityFrame _mainPortalFrameScratch = new();
private readonly ClipFrameAssembly _clipAssemblyScratch = new();
private readonly ViewconeCuller _viewconeScratch = new();
private readonly RetailPViewFrameResult _frameResultScratch = new();
private static readonly ClipViewSlice NoClipSlice = private static readonly ClipViewSlice NoClipSlice =
new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>()); new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>());
private static readonly ClipViewSlice[] NoClipSlices = { NoClipSlice };
private readonly HashSet<uint> _oneCell = new(1); private readonly HashSet<uint> _oneCell = new(1);
// Shell-batch scratch: all of a pass's cells collected for ONE batched // Shell-batch scratch: all of a pass's cells collected for ONE batched
@ -28,14 +33,20 @@ public sealed class RetailPViewRenderer
// frames + across look-in buildings. Spec: // frames + across look-in buildings. Spec:
// docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md // docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md
private readonly HashSet<uint> _shellBatch = new(); private readonly HashSet<uint> _shellBatch = new();
// Transparent shell cells retain IndoorDrawPlan's far-to-near order. The
// EnvCell renderer uploads shared instance/command data once, then issues
// range-addressed MDI calls in this exact order.
private readonly List<uint> _orderedTransparentShellCells = new();
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame). // R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
private readonly Dictionary<uint, List<LoadedCell>> _buildingGroups = new(); private readonly BuildingGroupScratch _buildingGroups = new();
private readonly PortalVisibilityFrame _outdoorBuildingFrameScratch = new();
// #124: per-building look-in frames under an INTERIOR root, drawn as a // #124: per-building look-in frames under an INTERIOR root, drawn as a
// landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the // landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the
// main frame (see DrawInside). Rebuilt each interior-root frame. // main frame (see DrawInside). Rebuilt each interior-root frame.
private readonly List<PortalVisibilityFrame> _lookInFrames = new(); private readonly List<PortalVisibilityFrame> _lookInFrames = new();
private readonly Stack<PortalVisibilityFrame> _lookInFramePool = new();
private readonly HashSet<uint> _lookInPrepareScratch = new(); private readonly HashSet<uint> _lookInPrepareScratch = new();
// #131/#132: the late landscape phase's scene-particle owner survivors // #131/#132: the late landscape phase's scene-particle owner survivors
@ -56,6 +67,7 @@ public sealed class RetailPViewRenderer
// DrawCellObjectLists, RetailPViewFrameResult.DrawableCells) reads it // DrawCellObjectLists, RetailPViewFrameResult.DrawableCells) reads it
// synchronously within the same frame it was built. // synchronously within the same frame it was built.
private readonly HashSet<uint> _drawableCellsScratch = new(); private readonly HashSet<uint> _drawableCellsScratch = new();
private readonly RetailPViewScratchRetention _scratchRetention = new();
// T2 (BR-4): retail has NO distance constant on the flood-admission chain // T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test + // (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
@ -79,6 +91,8 @@ public sealed class RetailPViewRenderer
public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx) public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx)
{ {
ArgumentNullException.ThrowIfNull(ctx); ArgumentNullException.ThrowIfNull(ctx);
RecycleLookInFrames();
ResetBuildingGroups();
var pvFrame = PortalVisibilityBuilder.Build( var pvFrame = PortalVisibilityBuilder.Build(
ctx.RootCell, ctx.RootCell,
@ -86,7 +100,8 @@ public sealed class RetailPViewRenderer
ctx.CellLookup, ctx.CellLookup,
ctx.ViewProjection, ctx.ViewProjection,
buildingMembership: null, buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ,
reuseFrame: _mainPortalFrameScratch);
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge // R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
// the small (~2-cell) per-building views into the frame. Retail reaches building interiors via // the small (~2-cell) per-building views into the frame. Retail reaches building interiors via
@ -111,14 +126,17 @@ public sealed class RetailPViewRenderer
// side). Outdoor roots keep the MergeNearbyBuildingFloods path above // side). Outdoor roots keep the MergeNearbyBuildingFloods path above
// (no depth clear under outdoor roots — the merged form is equivalent // (no depth clear under outdoor roots — the merged form is equivalent
// there). // there).
_lookInFrames.Clear();
if (!ctx.RootCell.IsOutdoorNode if (!ctx.RootCell.IsOutdoorNode
&& ctx.NearbyBuildingCells is not null && ctx.NearbyBuildingCells is not null
&& pvFrame.OutsideView.Polygons.Count > 0) && pvFrame.OutsideView.Polygons.Count > 0)
BuildInteriorRootLookIns(ctx, pvFrame); BuildInteriorRootLookIns(ctx, pvFrame);
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame); var clipAssembly = ClipFrameAssembler.Assemble(
UploadClipFrame(ctx.SetTerrainClipUbo); _clipFrame,
pvFrame,
_clipAssemblyScratch);
int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2);
PrepareClipFrame(ctx.SetTerrainClipUbo, terrainUploadCount);
// R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the // R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the
// assembler handed a clip-slot. This feeds the Prepare filter + entity partition, // assembler handed a clip-slot. This feeds the Prepare filter + entity partition,
@ -161,13 +179,11 @@ public sealed class RetailPViewRenderer
InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries); InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries);
var partition = _partitionResult; var partition = _partitionResult;
var result = new RetailPViewFrameResult RetailPViewFrameResult result = _frameResultScratch.Reset(
{ pvFrame,
PortalFrame = pvFrame, clipAssembly,
ClipAssembly = clipAssembly, drawableCells,
DrawableCells = drawableCells, partition);
Partition = partition,
};
ctx.EmitDiagnostics?.Invoke(result); ctx.EmitDiagnostics?.Invoke(result);
@ -183,7 +199,10 @@ public sealed class RetailPViewRenderer
// T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view, // T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view,
// never clipped (Ghidra 0x0054c250). Built once per frame from the // never clipped (Ghidra 0x0054c250). Built once per frame from the
// assembled slices + this frame's view-projection. // assembled slices + this frame's view-projection.
var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection); var viewcone = ViewconeCuller.Build(
clipAssembly,
ctx.ViewProjection,
_viewconeScratch);
// #118: stage assignment for dynamics under an INTERIOR root. Retail // #118: stage assignment for dynamics under an INTERIOR root. Retail
// draws the OUTSIDE world's objects inside the landscape stage — // draws the OUTSIDE world's objects inside the landscape stage —
@ -224,36 +243,22 @@ public sealed class RetailPViewRenderer
// R-A2: group the nearby building cells by BuildingId and run one per-building flood per group // R-A2: group the nearby building cells by BuildingId and run one per-building flood per group
// (retail's per-building ConstructView(CBldPortal)), merging each small view into the frame. The // (retail's per-building ConstructView(CBldPortal)), merging each small view into the frame. The
// grouping dict is reused across frames; inner lists are cleared each frame so a building that left // grouping dict contains only this frame's keys; lists are pooled across frames.
// the near set simply contributes an empty (skipped) group.
private void MergeNearbyBuildingFloods(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame) private void MergeNearbyBuildingFloods(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
{ {
foreach (var group in _buildingGroups.Values) RebuildBuildingGroups(ctx.NearbyBuildingCells!);
group.Clear();
foreach (var cell in ctx.NearbyBuildingCells!)
{
// R-A2 seam fix: a cell without a BuildingId (unstamped, or outdoor-adjacent with an exit
// portal) must STILL flood — the pre-R-A2 node flood reached it via a reverse portal, so
// dropping it (the original `continue`) left holes at building/terrain seams. Key it by its
// own CellId → a singleton per-entrance flood: a cell with an exit portal seeds from it, a
// cell with none contributes nothing (same as before). BuildingId/CellId key collisions are
// harmless — BuildFromExterior seeds each exit-portal cell in a group independently.
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_buildingGroups.TryGetValue(groupKey, out var group))
{
group = new List<LoadedCell>();
_buildingGroups[groupKey] = group;
}
group.Add(cell);
}
foreach (var group in _buildingGroups.Values) foreach (var group in _buildingGroups.Values)
{ {
if (group.Count == 0) if (group.Count == 0)
continue; continue;
var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding( var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, OutdoorBuildingSeedDistance); group,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.ViewProjection,
OutdoorBuildingSeedDistance,
reuseFrame: _outdoorBuildingFrameScratch);
MergeBuildingFrame(pvFrame, buildingFrame); MergeBuildingFrame(pvFrame, buildingFrame);
} }
} }
@ -277,15 +282,19 @@ public sealed class RetailPViewRenderer
if (!src.CellViews.TryGetValue(cellId, out var srcView)) if (!src.CellViews.TryGetValue(cellId, out var srcView))
continue; continue;
if (target.CellViews.TryGetValue(cellId, out var existing)) if (!target.CellViews.TryGetValue(cellId, out var existing))
{ {
foreach (var p in srcView.Polygons) existing = target.RentCellView();
existing.Add(p); target.CellViews[cellId] = existing;
continue; target.OrderedVisibleCells.Add(cellId);
} }
target.CellViews[cellId] = srcView; // Copy the view polygons into storage owned by the target frame.
target.OrderedVisibleCells.Add(cellId); // Source building frames are reused immediately for the next
// building flood, so retaining their CellView reference would
// alias pooled scratch and mutate the merged main view.
foreach (var p in srcView.Polygons)
existing.Add(target.CopyPolygon(p.Vertices));
} }
} }
@ -297,32 +306,51 @@ public sealed class RetailPViewRenderer
// building self-excludes via the seed eye-side test. // building self-excludes via the seed eye-side test.
private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame) private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
{ {
foreach (var group in _buildingGroups.Values) RebuildBuildingGroups(ctx.NearbyBuildingCells!);
group.Clear();
foreach (var cell in ctx.NearbyBuildingCells!)
{
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_buildingGroups.TryGetValue(groupKey, out var group))
{
group = new List<LoadedCell>();
_buildingGroups[groupKey] = group;
}
group.Add(cell);
}
foreach (var group in _buildingGroups.Values) foreach (var group in _buildingGroups.Values)
{ {
if (group.Count == 0) if (group.Count == 0)
continue; continue;
PortalVisibilityFrame frameScratch = _lookInFramePool.Count != 0
? _lookInFramePool.Pop()
: new PortalVisibilityFrame();
var frame = PortalVisibilityBuilder.ConstructViewBuilding( var frame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection,
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons); OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons,
reuseFrame: frameScratch);
if (frame.OrderedVisibleCells.Count > 0) if (frame.OrderedVisibleCells.Count > 0)
_lookInFrames.Add(frame); _lookInFrames.Add(frame);
else
ReturnLookInFrame(frame);
} }
} }
private void RecycleLookInFrames()
{
for (int i = 0; i < _lookInFrames.Count; i++)
ReturnLookInFrame(_lookInFrames[i]);
_scratchRetention.ClearFrameBuffers(
_lookInFrames,
_lookInPrepareScratch,
_drawableCellsScratch,
_shellBatch,
_orderedTransparentShellCells);
}
private void ReturnLookInFrame(PortalVisibilityFrame frame)
{
frame.ResetForBuild();
if (_lookInFramePool.Count < RetailPViewScratchRetention.MaxRetainedLookInFrames)
_lookInFramePool.Push(frame);
}
private void RebuildBuildingGroups(IReadOnlyList<LoadedCell> nearbyCells)
=> _buildingGroups.Rebuild(nearbyCells);
private void ResetBuildingGroups()
=> _buildingGroups.Reset();
// #124: draw the interior-root look-ins INSIDE the landscape stage — // #124: draw the interior-root look-ins INSIDE the landscape stage —
// retail's placement (LScape::draw → DrawBlock → DrawSortCell → // retail's placement (LScape::draw → DrawBlock → DrawSortCell →
// DrawBuilding runs as the FIRST call of DrawCells' outside-view branch, // DrawBuilding runs as the FIRST call of DrawCells' outside-view branch,
@ -355,17 +383,15 @@ public sealed class RetailPViewRenderer
continue; continue;
foreach (var poly in view.Polygons) foreach (var poly in view.Polygons)
{ {
var single = new CellView(); var cps = ClipPlaneSet.From(poly);
single.Add(poly);
var cps = ClipPlaneSet.From(single);
if (cps.IsNothingVisible) if (cps.IsNothingVisible)
continue; continue;
var planes = new Vector4[cps.Count];
for (int p = 0; p < cps.Count; p++)
planes[p] = cps.Planes[p];
ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext( ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext(
cellId, cellId,
new ClipViewSlice(0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), planes), new ClipViewSlice(
0,
new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY),
cps.PlaneArray),
Array.Empty<WorldEntity>())); Array.Empty<WorldEntity>()));
} }
} }
@ -454,7 +480,7 @@ public sealed class RetailPViewRenderer
foreach (var slice in clipAssembly.OutsideViewSlices) foreach (var slice in clipAssembly.OutsideViewSlices)
{ {
_clipFrame.SetTerrainClip(slice.Planes); _clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo); UploadTerrainClip(ctx.SetTerrainClipUbo);
// T3 (BR-5): entities are never hard-clipped — retail viewcone- // T3 (BR-5): entities are never hard-clipped — retail viewcone-
// CHECKS each mesh's sphere against the view (Ghidra 0x0054c250) // CHECKS each mesh's sphere against the view (Ghidra 0x0054c250)
// and draws it whole. The old per-slice entity clip routing // and draws it whole. The old per-slice entity clip routing
@ -490,7 +516,7 @@ public sealed class RetailPViewRenderer
foreach (var slice in clipAssembly.OutsideViewSlices) foreach (var slice in clipAssembly.OutsideViewSlices)
{ {
_clipFrame.SetTerrainClip(slice.Planes); _clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo); UploadTerrainClip(ctx.SetTerrainClipUbo);
_entities.ClearClipRouting(); _entities.ClearClipRouting();
_outdoorStaticScratch.Clear(); // late: dynamics survivors _outdoorStaticScratch.Clear(); // late: dynamics survivors
@ -595,7 +621,7 @@ public sealed class RetailPViewRenderer
// the outside slice's slot + NDC AABB + planes (CPU side), the region-SSBO bytes decoded // the outside slice's slot + NDC AABB + planes (CPU side), the region-SSBO bytes decoded
// at that slot (what mesh_modern.vert reads for routed instances), the terrain-UBO head // at that slot (what mesh_modern.vert reads for routed instances), the terrain-UBO head
// (what terrain/sky gate against), and the CellIdToSlot routing table. Fires AFTER // (what terrain/sky gate against), and the CellIdToSlot routing table. Fires AFTER
// SetTerrainClip + UploadClipFrame + SetClipRouting, BEFORE DrawLandscapeSlice — so the // SetTerrainClip + UploadTerrainClip + SetClipRouting, BEFORE DrawLandscapeSlice — so the
// printed bytes are exactly what this slice's draws consume. // printed bytes are exactly what this slice's draws consume.
private void EmitClipRouteProbe(ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex) private void EmitClipRouteProbe(ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex)
{ {
@ -626,7 +652,7 @@ public sealed class RetailPViewRenderer
} }
sb.Append('}'); sb.Append('}');
// Region-SSBO content decoded at the routed slot, from the packed bytes UploadClipFrame // Region-SSBO content decoded at the routed slot, from the packed bytes UploadRegions
// just uploaded — slot stride 144: count uint at +0, planes[8] at +16. // just uploaded — slot stride 144: count uint at +0, planes[8] at +16.
var rb = _clipFrame.RegionBytesForTest; var rb = _clipFrame.RegionBytesForTest;
int off = slice.Slot * ClipFrame.CellClipStrideBytes; int off = slice.Slot * ClipFrame.CellClipStrideBytes;
@ -715,17 +741,17 @@ public sealed class RetailPViewRenderer
if (_shellBatch.Count > 0) if (_shellBatch.Count > 0)
_envCells.Render(WbRenderPass.Opaque, _shellBatch); _envCells.Render(WbRenderPass.Opaque, _shellBatch);
// Transparent: far→near order matters for compositing, so keep these // Transparent: far-to-near order matters for compositing. The ordered
// per-cell in ShellPass order — but skip cells with no transparent // list retains ShellPass cell boundaries while EnvCellRenderer shares
// geometry (most are opaque-only walls/floors), removing the bulk of the // one instance/command/light upload across the complete pass.
// per-cell transparent Render calls. _orderedTransparentShellCells.Clear();
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{ {
if (!_envCells.CellHasTransparent(entry.CellId)) continue; if (_envCells.CellHasTransparent(entry.CellId))
_oneCell.Clear(); _orderedTransparentShellCells.Add(entry.CellId);
_oneCell.Add(entry.CellId);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
} }
if (_orderedTransparentShellCells.Count > 0)
_envCells.RenderTransparentOrdered(_orderedTransparentShellCells);
} }
// T1: the frame's single LAST entity pass — ALL server-spawned dynamics // T1: the frame's single LAST entity pass — ALL server-spawned dynamics
@ -994,7 +1020,7 @@ public sealed class RetailPViewRenderer
&& slices.Length > 0) && slices.Length > 0)
return slices; return slices;
return new[] { NoClipSlice }; return NoClipSlices;
} }
private void UseIndoorMembershipOnlyRouting() private void UseIndoorMembershipOnlyRouting()
@ -1026,19 +1052,25 @@ public sealed class RetailPViewRenderer
animatedEntityIds: ctx.AnimatedEntityIds); animatedEntityIds: ctx.AnimatedEntityIds);
} }
private void RestoreNoClip(Action<uint> setTerrainClipUbo) private void PrepareClipFrame(
Action<TerrainClipBufferBinding> setTerrainClipUbo,
int terrainUploadCount)
{ {
_clipFrame.Reset(); // Allocate every terrain record before issuing the first draw. BufferData
UploadClipFrame(setTerrainClipUbo); // therefore never replaces the arena's backing store while an earlier
UseIndoorMembershipOnlyRouting(); // slice can still reference it. The large region table is immutable for
} // this assembled PView frame and is uploaded exactly once.
_clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount);
private void UploadClipFrame(Action<uint> setTerrainClipUbo) _clipFrame.UploadRegions(_gl);
{
_clipFrame.UploadShared(_gl);
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo); _entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo); _envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
setTerrainClipUbo(_clipFrame.TerrainUbo); UploadTerrainClip(setTerrainClipUbo);
}
private void UploadTerrainClip(Action<TerrainClipBufferBinding> setTerrainClipUbo)
{
TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl);
setTerrainClipUbo(binding);
} }
} }
@ -1067,6 +1099,168 @@ public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; } public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; }
} }
/// <summary>
/// Capacity policy for renderer-owned, one-frame scratch. Normal warmed frames
/// retain their storage; a pathological visibility spike is released at the
/// next frame boundary instead of becoming permanent process memory.
/// </summary>
internal sealed class RetailPViewScratchRetention
{
internal const int MaxRetainedLookInFrames = 32;
internal const int MaxRetainedCellItems = 512;
internal const int CapacityTrimIdleFrames = 120;
private int _lookInFramesUnderusedFrames;
private int _lookInPrepareUnderusedFrames;
private int _drawableCellsUnderusedFrames;
private int _shellBatchUnderusedFrames;
private int _orderedTransparentUnderusedFrames;
internal void ClearFrameBuffers(
List<PortalVisibilityFrame> lookInFrames,
HashSet<uint> lookInPrepare,
HashSet<uint> drawableCells,
HashSet<uint> shellBatch,
List<uint> orderedTransparentShellCells)
{
ClearCold(
lookInFrames,
MaxRetainedLookInFrames,
ref _lookInFramesUnderusedFrames);
ClearCold(
lookInPrepare,
MaxRetainedCellItems,
ref _lookInPrepareUnderusedFrames);
ClearCold(
drawableCells,
MaxRetainedCellItems,
ref _drawableCellsUnderusedFrames);
ClearCold(shellBatch, MaxRetainedCellItems, ref _shellBatchUnderusedFrames);
ClearCold(
orderedTransparentShellCells,
MaxRetainedCellItems,
ref _orderedTransparentUnderusedFrames);
}
private static void ClearCold<T>(
List<T> values,
int maximumRetainedCapacity,
ref int underusedFrames)
{
int usedCount = values.Count;
int capacity = values.Capacity;
values.Clear();
if (!ShouldTrim(
capacity,
usedCount,
maximumRetainedCapacity,
ref underusedFrames))
return;
if (capacity > maximumRetainedCapacity)
values.Capacity = 0;
}
private static void ClearCold<T>(
HashSet<T> values,
int maximumRetainedCapacity,
ref int underusedFrames)
{
int usedCount = values.Count;
int capacity = values.EnsureCapacity(0);
values.Clear();
if (!ShouldTrim(
capacity,
usedCount,
maximumRetainedCapacity,
ref underusedFrames))
return;
if (capacity > maximumRetainedCapacity)
values.TrimExcess();
}
private static bool ShouldTrim(
int capacity,
int usedCount,
int maximumRetainedCapacity,
ref int underusedFrames)
{
if (capacity <= maximumRetainedCapacity || (long)usedCount * 2L > capacity)
{
underusedFrames = 0;
return false;
}
underusedFrames++;
if (underusedFrames < CapacityTrimIdleFrames)
return false;
underusedFrames = 0;
return true;
}
}
/// <summary>
/// Frame-scoped grouping for retail's per-building portal floods. Active keys
/// are rebuilt in nearby-cell encounter order every frame; the value lists are
/// retained through a bounded pool so travelling through the world cannot turn
/// every historical building id into permanent memory or per-frame scan work.
/// </summary>
internal sealed class BuildingGroupScratch
{
internal const int MaxRetainedGroups = 256;
internal const int MaxRetainedCellsPerGroup = 256;
private readonly Dictionary<uint, List<LoadedCell>> _active = new();
private readonly Stack<List<LoadedCell>> _listPool = new();
internal Dictionary<uint, List<LoadedCell>>.ValueCollection Values => _active.Values;
internal IReadOnlyDictionary<uint, List<LoadedCell>> Groups => _active;
internal int ActiveGroupCount => _active.Count;
internal int RetainedListCount => _listPool.Count;
internal int MapCapacity => _active.EnsureCapacity(0);
internal void Rebuild(IReadOnlyList<LoadedCell> nearbyCells)
{
ArgumentNullException.ThrowIfNull(nearbyCells);
Reset();
for (int i = 0; i < nearbyCells.Count; i++)
{
LoadedCell cell = nearbyCells[i];
// R-A2 seam behavior: an unstamped cell still gets a singleton
// entrance flood keyed by CellId.
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_active.TryGetValue(groupKey, out List<LoadedCell>? group))
{
group = _listPool.Count != 0
? _listPool.Pop()
: new List<LoadedCell>();
_active.Add(groupKey, group);
}
group.Add(cell);
}
}
internal void Reset()
{
foreach (List<LoadedCell> group in _active.Values)
{
group.Clear();
if (group.Capacity <= MaxRetainedCellsPerGroup
&& _listPool.Count < MaxRetainedGroups)
{
_listPool.Push(group);
}
}
_active.Clear();
if (_active.EnsureCapacity(0) > MaxRetainedGroups)
_active.TrimExcess();
}
}
public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
{ {
public required LoadedCell RootCell { get; init; } public required LoadedCell RootCell { get; init; }
@ -1089,7 +1283,7 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities, IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; } IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<uint> SetTerrainClipUbo { get; init; } public required Action<TerrainClipBufferBinding> SetTerrainClipUbo { get; init; }
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; } public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
/// <summary>#131/#132: the LATE landscape phase, per slice, after the #124 /// <summary>#131/#132: the LATE landscape phase, per slice, after the #124
@ -1115,15 +1309,38 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
/// remains open for the final world alpha scope.</summary> /// remains open for the final world alpha scope.</summary>
public Action? FlushLandscapeAlpha { get; init; } public Action? FlushLandscapeAlpha { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; } public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
/// <summary>
/// Synchronous observation of the borrowed current-frame result. The
/// callback must copy anything it needs to retain beyond this invocation.
/// </summary>
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; } public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
} }
/// <summary>
/// Borrowed renderer scratch valid only until the next
/// <see cref="RetailPViewRenderer.DrawInside"/> call. Collections and nested
/// frame objects are deliberately reused to keep the render loop allocation
/// free; consumers must copy any state they need to retain asynchronously.
/// </summary>
public sealed class RetailPViewFrameResult public sealed class RetailPViewFrameResult
{ {
public required PortalVisibilityFrame PortalFrame { get; init; } public PortalVisibilityFrame PortalFrame { get; private set; } = null!;
public required ClipFrameAssembly ClipAssembly { get; init; } public ClipFrameAssembly ClipAssembly { get; private set; } = null!;
public required HashSet<uint> DrawableCells { get; init; } public HashSet<uint> DrawableCells { get; private set; } = null!;
public required InteriorEntityPartition.Result Partition { get; init; } public InteriorEntityPartition.Result Partition { get; private set; } = null!;
internal RetailPViewFrameResult Reset(
PortalVisibilityFrame portalFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells,
InteriorEntityPartition.Result partition)
{
PortalFrame = portalFrame;
ClipAssembly = clipAssembly;
DrawableCells = drawableCells;
Partition = partition;
return this;
}
} }
public readonly record struct RetailPViewLandscapeSliceContext( public readonly record struct RetailPViewLandscapeSliceContext(

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Lighting; using AcDream.Core.Lighting;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
@ -23,27 +24,70 @@ namespace AcDream.App.Rendering;
public sealed unsafe class SceneLightingUboBinding : IDisposable public sealed unsafe class SceneLightingUboBinding : IDisposable
{ {
private readonly GL _gl; private readonly GL _gl;
private readonly uint _ubo; private uint _ubo;
private readonly List<uint>[] _buffersByFrame = [[], [], []];
private int _frameSlot;
private int _bufferCursor;
private bool _frameStarted;
private bool _disposed; private bool _disposed;
internal int DynamicBufferCount => _buffersByFrame.Sum(buffers => buffers.Count);
public SceneLightingUboBinding(GL gl) public SceneLightingUboBinding(GL gl)
{ {
_gl = gl ?? throw new ArgumentNullException(nameof(gl)); _gl = gl ?? throw new ArgumentNullException(nameof(gl));
_ubo = _gl.GenBuffer(); }
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo);
// Pre-allocate with the final size; BufferSubData each frame.
_gl.BufferData(
BufferTargetARB.UniformBuffer,
(nuint)SceneLightingUbo.SizeInBytes,
(void*)0,
BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.UniformBuffer, 0);
// Bind the buffer to the chosen binding point exactly once — shaders /// <summary>
// that declare this binding in their layout block will read from it /// Resets the lighting-submission cursor for a GPU-fenced frame slot.
// on every draw without further intervention. /// World, portal-space, and paperdoll draws can each upload different
_gl.BindBufferBase(BufferTargetARB.UniformBuffer, /// lighting in one frame, so every upload receives distinct storage.
SceneLightingUbo.BindingPoint, _ubo); /// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_buffersByFrame.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
_frameSlot = frameSlot;
_bufferCursor = 0;
_frameStarted = true;
}
private void ActivateNextBuffer()
{
if (!_frameStarted)
throw new InvalidOperationException("BeginFrame must be called before uploading scene lighting.");
List<uint> buffers = _buffersByFrame[_frameSlot];
if (_bufferCursor == buffers.Count)
{
uint buffer = TrackedGlResource.CreateBuffer(
_gl,
"SceneLighting frame UBO creation");
try
{
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.UniformBuffer,
buffer,
0,
SceneLightingUbo.SizeInBytes,
GLEnum.DynamicDraw,
"SceneLighting frame UBO allocation");
buffers.Add(buffer);
}
catch
{
TrackedGlResource.DeleteBuffer(
_gl,
buffer,
0,
"SceneLighting frame UBO rollback");
throw;
}
}
_ubo = buffers[_bufferCursor++];
} }
/// <summary> /// <summary>
@ -52,16 +96,30 @@ public sealed unsafe class SceneLightingUboBinding : IDisposable
/// </summary> /// </summary>
public void Upload(SceneLightingUbo data) public void Upload(SceneLightingUbo data)
{ {
ActivateNextBuffer();
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo); _gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo);
_gl.BufferSubData(BufferTargetARB.UniformBuffer, _gl.BufferSubData(BufferTargetARB.UniformBuffer,
(nint)0, (nuint)SceneLightingUbo.SizeInBytes, &data); (nint)0, (nuint)SceneLightingUbo.SizeInBytes, &data);
_gl.BindBufferBase(BufferTargetARB.UniformBuffer,
SceneLightingUbo.BindingPoint, _ubo);
_gl.BindBuffer(BufferTargetARB.UniformBuffer, 0); _gl.BindBuffer(BufferTargetARB.UniformBuffer, 0);
} }
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (_disposed) return;
_gl.DeleteBuffer(_ubo); foreach (List<uint> buffers in _buffersByFrame)
{
foreach (uint buffer in buffers)
{
TrackedGlResource.DeleteBuffer(
_gl,
buffer,
SceneLightingUbo.SizeInBytes,
"SceneLighting frame UBO disposal");
}
buffers.Clear();
}
_disposed = true; _disposed = true;
} }
} }

View file

@ -1,4 +1,5 @@
using System.Numerics; using System.Numerics;
using AcDream.Content;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
@ -11,13 +12,13 @@ namespace AcDream.App.Rendering.Selection;
/// </summary> /// </summary>
internal sealed class RetailSelectionGeometryCache internal sealed class RetailSelectionGeometryCache
{ {
private readonly DatCollection _dats; private readonly IDatReaderWriter _dats;
private readonly object _datLock; private readonly object _datLock;
// Render-thread owned. Dictionary permits a cached null for a GfxObj which // Render-thread owned. Dictionary permits a cached null for a GfxObj which
// legitimately has no drawing BSP; ConcurrentDictionary does not. // legitimately has no drawing BSP; ConcurrentDictionary does not.
private readonly Dictionary<uint, RetailSelectionMesh?> _cache = new(); private readonly Dictionary<uint, RetailSelectionMesh?> _cache = new();
public RetailSelectionGeometryCache(DatCollection dats, object datLock) public RetailSelectionGeometryCache(IDatReaderWriter dats, object datLock)
{ {
_dats = dats ?? throw new ArgumentNullException(nameof(dats)); _dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));

View file

@ -6,6 +6,7 @@ namespace AcDream.App.Rendering;
public sealed class Shader : IDisposable public sealed class Shader : IDisposable
{ {
private readonly GL _gl; private readonly GL _gl;
private readonly Dictionary<string, int> _uniformLocations = new(StringComparer.Ordinal);
public uint Program { get; } public uint Program { get; }
public Shader(GL gl, string vertexPath, string fragmentPath) public Shader(GL gl, string vertexPath, string fragmentPath)
@ -42,39 +43,54 @@ public sealed class Shader : IDisposable
public unsafe void SetMatrix4(string name, Matrix4x4 m) public unsafe void SetMatrix4(string name, Matrix4x4 m)
{ {
int loc = _gl.GetUniformLocation(Program, name); int loc = GetUniformLocation(name);
_gl.UniformMatrix4(loc, 1, false, (float*)&m); _gl.UniformMatrix4(loc, 1, false, (float*)&m);
} }
public void SetInt(string name, int value) public void SetInt(string name, int value)
{ {
int loc = _gl.GetUniformLocation(Program, name); int loc = GetUniformLocation(name);
_gl.Uniform1(loc, value); _gl.Uniform1(loc, value);
} }
public void SetFloat(string name, float value) public void SetFloat(string name, float value)
{ {
int loc = _gl.GetUniformLocation(Program, name); int loc = GetUniformLocation(name);
_gl.Uniform1(loc, value); _gl.Uniform1(loc, value);
} }
public void SetVec3(string name, Vector3 v) public void SetVec3(string name, Vector3 v)
{ {
int loc = _gl.GetUniformLocation(Program, name); int loc = GetUniformLocation(name);
_gl.Uniform3(loc, v.X, v.Y, v.Z); _gl.Uniform3(loc, v.X, v.Y, v.Z);
} }
public void SetVec2(string name, Vector2 v) public void SetVec2(string name, Vector2 v)
{ {
int loc = _gl.GetUniformLocation(Program, name); int loc = GetUniformLocation(name);
_gl.Uniform2(loc, v.X, v.Y); _gl.Uniform2(loc, v.X, v.Y);
} }
public void SetVec4(string name, Vector4 v) public void SetVec4(string name, Vector4 v)
{ {
int loc = _gl.GetUniformLocation(Program, name); int loc = GetUniformLocation(name);
_gl.Uniform4(loc, v.X, v.Y, v.Z, v.W); _gl.Uniform4(loc, v.X, v.Y, v.Z, v.W);
} }
public void Dispose() => _gl.DeleteProgram(Program); private int GetUniformLocation(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
if (_uniformLocations.TryGetValue(name, out int location))
return location;
location = _gl.GetUniformLocation(Program, name);
_uniformLocations.Add(name, location);
return location;
}
public void Dispose()
{
_uniformLocations.Clear();
_gl.DeleteProgram(Program);
}
} }

View file

@ -11,7 +11,7 @@ struct InstanceData {
struct BatchData { struct BatchData {
uvec2 textureHandle; // bindless handle for sampler2DArray uvec2 textureHandle; // bindless handle for sampler2DArray
uint textureLayer; // layer index (always 0 for per-instance composites) uint textureLayer; // layer in the shared WB or pooled composite array
uint flags; // reserved — N.5 dispatcher owns all blend state uint flags; // reserved — N.5 dispatcher owns all blend state
// (glBlendFunc per pass). If a future phase wants // (glBlendFunc per pass). If a future phase wants
// shader-side per-batch additive flag (Decision 2 // shader-side per-batch additive flag (Decision 2

View file

@ -6,6 +6,7 @@ using AcDream.Core.Meshing;
using AcDream.Core.Terrain; using AcDream.Core.Terrain;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
@ -45,7 +46,7 @@ namespace AcDream.App.Rendering.Sky;
public sealed unsafe class SkyRenderer : IDisposable public sealed unsafe class SkyRenderer : IDisposable
{ {
private readonly GL _gl; private readonly GL _gl;
private readonly DatCollection _dats; private readonly IDatReaderWriter _dats;
private readonly Shader _shader; private readonly Shader _shader;
private readonly TextureCache _textures; private readonly TextureCache _textures;
private readonly SamplerCache _samplers; private readonly SamplerCache _samplers;
@ -62,7 +63,7 @@ public sealed unsafe class SkyRenderer : IDisposable
public float Near { get; set; } = 0.1f; public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 1_000_000f; public float Far { get; set; } = 1_000_000f;
public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures, SamplerCache samplers) public SkyRenderer(GL gl, IDatReaderWriter dats, Shader shader, TextureCache textures, SamplerCache samplers)
{ {
_gl = gl ?? throw new ArgumentNullException(nameof(gl)); _gl = gl ?? throw new ArgumentNullException(nameof(gl));
_dats = dats ?? throw new ArgumentNullException(nameof(dats)); _dats = dats ?? throw new ArgumentNullException(nameof(dats));

View file

@ -0,0 +1,242 @@
namespace AcDream.App.Rendering;
/// <summary>
/// One standalone, resident bindless texture used by particle billboards.
/// Unlike entity composites, the shader always samples layer zero.
/// </summary>
internal sealed class StandaloneBindlessTextureResource
{
public required uint SurfaceId { get; init; }
public required uint Name { get; init; }
public required ulong Handle { get; init; }
public required long Bytes { get; init; }
}
internal interface IStandaloneBindlessTextureBackend
{
void MakeNonResident(StandaloneBindlessTextureResource resource);
void Delete(StandaloneBindlessTextureResource resource);
}
/// <summary>
/// Shares exact DAT-decoded particle textures between live emitter owners.
/// The final owner moves a resource into a small LRU reuse pool; exceeding
/// either cache bound logically evicts the oldest entry immediately and
/// retires its resident handle/backing texture behind the frame-flight fence.
/// Live resources are never evicted, so this policy cannot change visuals.
/// Render-thread only.
/// </summary>
internal sealed class StandaloneBindlessTextureCache : IDisposable
{
internal const long DefaultUnownedBudgetBytes = 32L * 1024 * 1024;
internal const int DefaultMaximumUnownedCount = 256;
internal const int DefaultMaximumEvictionsPerFrame = 1;
private readonly IStandaloneBindlessTextureBackend _backend;
private readonly GpuRetirementLedger _retirementLedger;
private readonly OwnerScopedResourceRegistry<uint> _owners = new();
private readonly BoundedUnownedResourceCache<uint> _unowned;
private readonly Dictionary<uint, StandaloneBindlessTextureResource> _entries = new();
private readonly HashSet<uint> _disposeResidencyReleased = [];
private readonly HashSet<uint> _disposeDeleted = [];
private bool _disposeRequested;
private bool _disposing;
private bool _disposed;
public StandaloneBindlessTextureCache(
IStandaloneBindlessTextureBackend backend,
IGpuResourceRetirementQueue retirementQueue,
long unownedBudgetBytes = DefaultUnownedBudgetBytes,
int maximumUnownedCount = DefaultMaximumUnownedCount)
{
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
ArgumentNullException.ThrowIfNull(retirementQueue);
_retirementLedger = new GpuRetirementLedger(retirementQueue);
_unowned = new BoundedUnownedResourceCache<uint>(
unownedBudgetBytes,
maximumUnownedCount);
}
internal int EntryCount => _entries.Count;
internal int ActiveResourceCount => _owners.ResourceCount;
internal int OwnerCount => _owners.OwnerCount;
internal int UnownedEntryCount => _unowned.Count;
internal long UnownedBytes => _unowned.ResidentBytes;
internal int AwaitingRetirementPublicationCount =>
_retirementLedger.AwaitingPublicationCount;
public bool TryAcquire(
uint ownerId,
uint surfaceId,
out StandaloneBindlessTextureResource resource)
{
ObjectDisposedException.ThrowIf(_disposeRequested, this);
ValidateOwnerAndSurface(ownerId, surfaceId);
if (!_entries.TryGetValue(surfaceId, out resource!))
return false;
_owners.Acquire(ownerId, surfaceId);
_unowned.MarkOwned(surfaceId);
return true;
}
public void AddAndAcquire(
uint ownerId,
StandaloneBindlessTextureResource resource)
{
ObjectDisposedException.ThrowIf(_disposeRequested, this);
ArgumentNullException.ThrowIfNull(resource);
ValidateOwnerAndSurface(ownerId, resource.SurfaceId);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Handle);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Bytes);
if (!_entries.TryAdd(resource.SurfaceId, resource))
throw new InvalidOperationException(
$"Standalone particle surface 0x{resource.SurfaceId:X8} is already cached.");
_owners.Acquire(ownerId, resource.SurfaceId);
}
public void ReleaseOwner(uint ownerId)
{
ObjectDisposedException.ThrowIf(_disposeRequested, this);
if (ownerId == 0)
return;
IReadOnlyList<uint> newlyUnowned = _owners.ReleaseOwner(ownerId);
for (int i = 0; i < newlyUnowned.Count; i++)
{
uint surfaceId = newlyUnowned[i];
if (_entries.TryGetValue(surfaceId, out StandaloneBindlessTextureResource? resource))
_unowned.MarkUnowned(surfaceId, resource.Bytes);
}
}
internal void VisitEntries(Action<StandaloneBindlessTextureResource> visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
foreach (StandaloneBindlessTextureResource resource in _entries.Values)
visitor(resource);
}
/// <summary>
/// Advances logical eviction at a bounded rate. Owner release only marks
/// resources reusable; this render-frame maintenance edge prevents a
/// portal unload from submitting hundreds of bindless destruction calls
/// to the same GPU fence serial.
/// </summary>
public void Tick(int maximumEvictions = DefaultMaximumEvictionsPerFrame)
{
ObjectDisposedException.ThrowIf(_disposeRequested, this);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumEvictions);
_retirementLedger.RetryPendingPublications();
for (int i = 0; i < maximumEvictions; i++)
{
if (!_unowned.TryTakeOldestOverBudget(out uint surfaceId))
break;
if (!_entries.Remove(surfaceId, out StandaloneBindlessTextureResource? resource))
continue;
// The publication ledger owns this release from this point even
// when queue admission or an immediate callback throws. Never
// republish a texture after residency release has started.
_retirementLedger.Retire(new RetryableGpuResourceRelease(
() => _backend.MakeNonResident(resource),
() => _backend.Delete(resource)));
}
}
private static void ValidateOwnerAndSurface(uint ownerId, uint surfaceId)
{
ArgumentOutOfRangeException.ThrowIfZero(ownerId);
ArgumentOutOfRangeException.ThrowIfZero(surfaceId);
}
public void Dispose()
{
if (_disposed || _disposing)
return;
_disposeRequested = true;
_disposing = true;
try
{
// GameWindow drains the frame-flight queue before TextureCache
// teardown. Release every handle before deleting any texture so the
// ARB_bindless_texture lifetime ordering remains explicit.
List<Exception>? failures = null;
try
{
_retirementLedger.RetryPendingPublications();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
foreach (StandaloneBindlessTextureResource resource in _entries.Values)
{
if (_disposeResidencyReleased.Contains(resource.SurfaceId))
continue;
try
{
_backend.MakeNonResident(resource);
_disposeResidencyReleased.Add(resource.SurfaceId);
}
catch (Exception ex)
{
(failures ??= []).Add(ex);
}
}
foreach (StandaloneBindlessTextureResource resource in _entries.Values)
{
if (!_disposeResidencyReleased.Contains(resource.SurfaceId)
|| _disposeDeleted.Contains(resource.SurfaceId))
{
continue;
}
try
{
_backend.Delete(resource);
_disposeDeleted.Add(resource.SurfaceId);
}
catch (Exception ex)
{
(failures ??= []).Add(ex);
}
}
if (_disposeDeleted.Count != 0)
{
foreach (uint surfaceId in _disposeDeleted)
_entries.Remove(surfaceId);
}
if (_entries.Count == 0
&& _retirementLedger.AwaitingPublicationCount == 0)
{
_owners.Clear();
_unowned.Clear();
_disposeResidencyReleased.Clear();
_disposeDeleted.Clear();
_disposed = true;
}
if (failures is not null)
{
throw new AggregateException(
"One or more standalone particle textures failed to retire.",
failures);
}
}
finally
{
_disposing = false;
}
}
}

View file

@ -1,5 +1,6 @@
using AcDream.Core.Textures; using AcDream.Core.Textures;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
@ -118,7 +119,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// for the mapping from TerrainTextureType to SurfaceTexture id, decoding each /// for the mapping from TerrainTextureType to SurfaceTexture id, decoding each
/// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY. /// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY.
/// </summary> /// </summary>
public static TerrainAtlas Build(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null) public static TerrainAtlas Build(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
{ {
var region = dats.Get<Region>(0x13000000u) var region = dats.Get<Region>(0x13000000u)
?? throw new InvalidOperationException("Region dat id 0x13000000 missing"); ?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
@ -251,7 +252,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes); IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes);
private static AlphaAtlasBuildResult BuildAlphaAtlas( private static AlphaAtlasBuildResult BuildAlphaAtlas(
GL gl, DatCollection dats, DatReaderWriter.Types.TexMerge texMerge) GL gl, IDatReaderWriter dats, DatReaderWriter.Types.TexMerge texMerge)
{ {
var decoded = new List<DecodedTexture>(); var decoded = new List<DecodedTexture>();
var cornerLayers = new List<byte>(); var cornerLayers = new List<byte>();
@ -364,7 +365,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
cornerTCodes, sideTCodes, roadRCodes); cornerTCodes, sideTCodes, roadRCodes);
} }
private static bool TryDecodeAlphaMap(DatCollection dats, uint surfaceTextureId, out DecodedTexture decoded) private static bool TryDecodeAlphaMap(IDatReaderWriter dats, uint surfaceTextureId, out DecodedTexture decoded)
{ {
decoded = DecodedTexture.Magenta; decoded = DecodedTexture.Magenta;

View file

@ -39,7 +39,10 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
/// anisotropic level mid-session via <see cref="TerrainAtlas.SetAnisotropic"/>.</summary> /// anisotropic level mid-session via <see cref="TerrainAtlas.SetAnisotropic"/>.</summary>
public TerrainAtlas Atlas => _atlas; public TerrainAtlas Atlas => _atlas;
private readonly TerrainSlotAllocator _alloc; private readonly GpuRetiredTerrainSlotAllocator _alloc;
private readonly GpuRetirementLedger _retirementLedger;
private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposed;
// Per-slot live data (index by slot integer; null entries are unused slots). // Per-slot live data (index by slot integer; null entries are unused slots).
private SlotData?[] _slots; private SlotData?[] _slots;
@ -51,14 +54,31 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private uint _globalVao; private uint _globalVao;
private uint _globalVbo; private uint _globalVbo;
private uint _globalEbo; private uint _globalEbo;
private long _globalVboCapacityBytes;
private long _globalEboCapacityBytes;
private uint _indirectBuffer; private uint _indirectBuffer;
private int _indirectCapacity; private int _indirectCapacity;
private sealed class DynamicIndirectBuffer
{
public uint Buffer;
public int Capacity;
}
private readonly List<DynamicIndirectBuffer>[] _indirectBuffersByFrame =
[[], [], []];
private int _dynamicFrameSlot;
private int _dynamicBufferCursor;
private bool _dynamicFrameStarted;
internal int DynamicIndirectBufferCount =>
_indirectBuffersByFrame.Sum(frameBuffers => frameBuffers.Count);
// Phase U.3: terrain clip UBO (binding=2, terrain_modern.vert TerrainClip). // Phase U.3: terrain clip UBO (binding=2, terrain_modern.vert TerrainClip).
// The shared one is created + uploaded by the GameWindow-level ClipFrame and // The shared one is created + uploaded by the GameWindow-level ClipFrame and
// handed in via SetClipUbo. When 0, we bind a lazily-created no-clip fallback // handed in via SetClipUbo. When 0, we bind a lazily-created no-clip fallback
// (count 0 = ungated) so the shader never reads an unbound UBO at binding=2. // (count 0 = ungated) so the shader never reads an unbound UBO at binding=2.
private uint _sharedClipUbo; private TerrainClipBufferBinding _sharedClipBinding;
private uint _fallbackClipUbo; private uint _fallbackClipUbo;
// Cached uvec2-handle uniform locations (matrix uniforms are set by name via Shader.SetMatrix4). // Cached uvec2-handle uniform locations (matrix uniforms are set by name via Shader.SetMatrix4).
@ -91,12 +111,31 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
Shader shader, Shader shader,
TerrainAtlas atlas, TerrainAtlas atlas,
int initialSlotCapacity = 64) int initialSlotCapacity = 64)
: this(
gl,
bindless,
shader,
atlas,
ImmediateGpuResourceRetirementQueue.Instance,
initialSlotCapacity)
{
}
internal TerrainModernRenderer(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
IGpuResourceRetirementQueue resourceRetirement,
int initialSlotCapacity = 64)
{ {
_gl = gl; _gl = gl;
_bindless = bindless; _bindless = bindless;
_shader = shader; _shader = shader;
_atlas = atlas; _atlas = atlas;
_alloc = new TerrainSlotAllocator(initialSlotCapacity); ArgumentNullException.ThrowIfNull(resourceRetirement);
_retirementLedger = new GpuRetirementLedger(resourceRetirement);
_alloc = new GpuRetiredTerrainSlotAllocator(initialSlotCapacity, resourceRetirement);
_slots = new SlotData?[initialSlotCapacity]; _slots = new SlotData?[initialSlotCapacity];
_uTerrainHandleLoc = _gl.GetUniformLocation(_shader.Program, "uTerrainHandle"); _uTerrainHandleLoc = _gl.GetUniformLocation(_shader.Program, "uTerrainHandle");
@ -105,22 +144,105 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
if (_uTexTilingLoc < 0) if (_uTexTilingLoc < 0)
throw new InvalidOperationException("terrain_modern.frag is missing the required uTexTiling uniform."); throw new InvalidOperationException("terrain_modern.frag is missing the required uTexTiling uniform.");
_globalVao = _gl.GenVertexArray(); try
_globalVbo = _gl.GenBuffer(); {
_globalEbo = _gl.GenBuffer(); _globalVao = TrackedGlResource.CreateVertexArray(
AllocateGpuBuffers(initialSlotCapacity); _gl,
ConfigureVao(); "creating terrain global VAO");
_globalVbo = TrackedGlResource.CreateBuffer(
_gl,
"creating terrain global vertex buffer");
_globalEbo = TrackedGlResource.CreateBuffer(
_gl,
"creating terrain global index buffer");
AllocateGpuBuffers(initialSlotCapacity);
ConfigureVao(_globalVao, _globalVbo, _globalEbo);
}
catch
{
TrackedGlResource.DeleteVertexArray(
_gl,
_globalVao,
"rolling back terrain global VAO");
TrackedGlResource.DeleteBuffer(
_gl,
_globalVbo,
_globalVboCapacityBytes,
"rolling back terrain global vertex buffer");
TrackedGlResource.DeleteBuffer(
_gl,
_globalEbo,
_globalEboCapacityBytes,
"rolling back terrain global index buffer");
_globalVao = 0;
_globalVbo = 0;
_globalEbo = 0;
_globalVboCapacityBytes = 0;
_globalEboCapacityBytes = 0;
throw;
}
_indirectBuffer = _gl.GenBuffer();
} }
/// <summary> /// <summary>
/// Phase U.3: hand the renderer the SHARED terrain-clip UBO (binding=2) /// Resets the indirect-command submission cursor for a GPU-fenced frame
/// created by <see cref="ClipFrame.UploadShared"/>. The renderer binds it to /// slot. A retail outside view may draw terrain more than once in a frame;
/// binding=2 before its draw. Pass 0 to fall back to the internal no-clip UBO /// each draw receives storage that cannot overwrite an earlier command.
/// (count 0 = ungated terrain).
/// </summary> /// </summary>
public void SetClipUbo(uint sharedClipUbo) => _sharedClipUbo = sharedClipUbo; public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_indirectBuffersByFrame.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
_retirementLedger.RetryPendingPublications();
_dynamicFrameSlot = frameSlot;
_dynamicBufferCursor = 0;
_dynamicFrameStarted = true;
}
private void ActivateNextIndirectBuffer()
{
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
List<DynamicIndirectBuffer> frameBuffers = _indirectBuffersByFrame[_dynamicFrameSlot];
if (_dynamicBufferCursor == frameBuffers.Count)
{
uint buffer = TrackedGlResource.CreateBuffer(
_gl,
$"creating terrain indirect buffer for frame slot {_dynamicFrameSlot}");
try
{
frameBuffers.Add(new DynamicIndirectBuffer { Buffer = buffer });
}
catch
{
TrackedGlResource.DeleteBuffer(
_gl,
buffer,
0,
"rolling back terrain indirect buffer");
throw;
}
}
DynamicIndirectBuffer active = frameBuffers[_dynamicBufferCursor++];
_indirectBuffer = active.Buffer;
_indirectCapacity = active.Capacity;
}
private void PersistIndirectCapacity()
{
_indirectBuffersByFrame[_dynamicFrameSlot][_dynamicBufferCursor - 1].Capacity =
_indirectCapacity;
}
/// <summary>
/// Hand the renderer the current aligned terrain-clip range (binding=2).
/// Each outside-view slice occupies a distinct range in the current
/// GPU-fenced frame's UBO arena.
/// </summary>
public void SetClipUbo(TerrainClipBufferBinding sharedClipBinding) =>
_sharedClipBinding = sharedClipBinding;
/// <summary> /// <summary>
/// Two-tier streaming entry point. Accepts a prebuilt mesh from /// Two-tier streaming entry point. Accepts a prebuilt mesh from
@ -146,15 +268,21 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
$"Expected {IndicesPerLandblock} indices, got {meshData.Indices.Length}", $"Expected {IndicesPerLandblock} indices, got {meshData.Indices.Length}",
nameof(meshData)); nameof(meshData));
if (_idToSlot.ContainsKey(landblockId)) // A prior replacement may have committed the logical slot switch
RemoveLandblock(landblockId); // before queue publication failed. Retry those retained physical-slot
// transactions before allocating more terrain storage.
_alloc.RetryPendingPublications();
bool replacing = _idToSlot.TryGetValue(landblockId, out int replacedSlot);
int slot = _alloc.Allocate(out var needsGrow); int slot = _alloc.Allocate(out var needsGrow);
if (needsGrow) bool published = false;
try
{ {
int newCap = Math.Max(_alloc.Capacity * 2, slot + 1); if (needsGrow)
EnsureCapacity(newCap); {
} int newCap = Math.Max(_alloc.Capacity * 2, slot + 1);
EnsureCapacity(newCap);
}
// Bake worldOrigin into vertex positions; capture min/max Z for AABB. // Bake worldOrigin into vertex positions; capture min/max Z for AABB.
var bakedVerts = new TerrainVertex[VertsPerLandblock]; var bakedVerts = new TerrainVertex[VertsPerLandblock];
@ -179,41 +307,66 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
nint vboByteOffset = (nint)(slot * VertsPerLandblock * VertexSize); nint vboByteOffset = (nint)(slot * VertsPerLandblock * VertexSize);
nint eboByteOffset = (nint)(slot * IndicesPerLandblock * IndexSize); nint eboByteOffset = (nint)(slot * IndicesPerLandblock * IndexSize);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo); fixed (TerrainVertex* p = bakedVerts)
fixed (TerrainVertex* p = bakedVerts) {
{ TrackedGlResource.UpdateBufferSubData(
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboByteOffset, _gl,
(nuint)(VertsPerLandblock * VertexSize), p); BufferTargetARB.ArrayBuffer,
} _globalVbo,
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); vboByteOffset,
VertsPerLandblock * VertexSize,
p,
$"uploading terrain vertices for 0x{landblockId:X8}");
}
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo); fixed (uint* p = bakedIndices)
fixed (uint* p = bakedIndices) {
{ TrackedGlResource.UpdateBufferSubData(
_gl.BufferSubData(BufferTargetARB.ElementArrayBuffer, eboByteOffset, _gl,
(nuint)(IndicesPerLandblock * IndexSize), p); BufferTargetARB.ElementArrayBuffer,
} _globalEbo,
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0); eboByteOffset,
IndicesPerLandblock * IndexSize,
p,
$"uploading terrain indices for 0x{landblockId:X8}");
}
_slots[slot] = new SlotData _slots[slot] = new SlotData
{
LandblockId = landblockId,
WorldOrigin = worldOrigin,
FirstIndex = (uint)(slot * IndicesPerLandblock),
IndexCount = IndicesPerLandblock,
AabbMin = new Vector3(worldOrigin.X, worldOrigin.Y, zMin),
AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax),
};
_idToSlot[landblockId] = slot;
published = true;
if (replacing)
{
_slots[replacedSlot] = null;
_alloc.FreeAfterGpuUse(replacedSlot);
}
}
finally
{ {
LandblockId = landblockId, if (!published)
WorldOrigin = worldOrigin, _alloc.ReleaseUnsubmitted(slot);
FirstIndex = (uint)(slot * IndicesPerLandblock), }
IndexCount = IndicesPerLandblock,
AabbMin = new Vector3(worldOrigin.X, worldOrigin.Y, zMin),
AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax),
};
_idToSlot[landblockId] = slot;
} }
public void RemoveLandblock(uint landblockId) public void RemoveLandblock(uint landblockId)
{ {
// Removal clears the logical lookup before retirement publication. A
// retry therefore has to advance retained publications even when the
// landblock is no longer present in the map.
_alloc.RetryPendingPublications();
if (!_idToSlot.TryGetValue(landblockId, out var slot)) if (!_idToSlot.TryGetValue(landblockId, out var slot))
return; return;
_idToSlot.Remove(landblockId); _idToSlot.Remove(landblockId);
_slots[slot] = null; _slots[slot] = null;
_alloc.Free(slot); _alloc.FreeAfterGpuUse(slot);
// No GPU clear: the per-frame DEIC array won't reference this slot. // No GPU clear: the per-frame DEIC array won't reference this slot.
} }
@ -252,6 +405,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
ndcClipAabb); ndcClipAabb);
} }
if (_visibleSlots.Count == 0) return; if (_visibleSlots.Count == 0) return;
ActivateNextIndirectBuffer();
// Build DEIC array. // Build DEIC array.
if (_deicScratch.Length < _visibleSlots.Count) if (_deicScratch.Length < _visibleSlots.Count)
@ -272,23 +426,31 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// Grow indirect buffer if needed. // Grow indirect buffer if needed.
if (_visibleSlots.Count > _indirectCapacity) if (_visibleSlots.Count > _indirectCapacity)
{ {
_indirectCapacity = Math.Max(64, _visibleSlots.Count * 2); int grownCapacity = Math.Max(64, _visibleSlots.Count * 2);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer); TrackedGlResource.AllocateBufferStorage(
_gl.BufferData(GLEnum.DrawIndirectBuffer, _gl,
(nuint)(_indirectCapacity * sizeof(DrawElementsIndirectCommand)), GLEnum.DrawIndirectBuffer,
null, GLEnum.DynamicDraw); _indirectBuffer,
} checked((long)_indirectCapacity * sizeof(DrawElementsIndirectCommand)),
else checked((long)grownCapacity * sizeof(DrawElementsIndirectCommand)),
{ GLEnum.DynamicDraw,
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer); "growing terrain indirect command buffer");
_indirectCapacity = grownCapacity;
} }
// Upload DEIC array. // Upload DEIC array.
fixed (DrawElementsIndirectCommand* p = _deicScratch) fixed (DrawElementsIndirectCommand* p = _deicScratch)
{ {
_gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0, TrackedGlResource.UpdateBufferSubData(
(nuint)(_visibleSlots.Count * sizeof(DrawElementsIndirectCommand)), p); _gl,
GLEnum.DrawIndirectBuffer,
_indirectBuffer,
0,
checked((long)_visibleSlots.Count * sizeof(DrawElementsIndirectCommand)),
p,
"uploading terrain indirect commands");
} }
PersistIndirectCapacity();
// Bind shader + uniforms + atlas handles. // Bind shader + uniforms + atlas handles.
// Verified Phase W Stage 4 (T4.2): terrain projects from the camera view-proj; // Verified Phase W Stage 4 (T4.2): terrain projects from the camera view-proj;
@ -352,11 +514,96 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
public void Dispose() public void Dispose()
{ {
_gl.DeleteVertexArray(_globalVao); if (_disposed)
_gl.DeleteBuffer(_globalVbo); return;
_gl.DeleteBuffer(_globalEbo); _retirementLedger.RetryPendingPublications();
_gl.DeleteBuffer(_indirectBuffer);
if (_fallbackClipUbo != 0) { _gl.DeleteBuffer(_fallbackClipUbo); _fallbackClipUbo = 0; } // Phase U.3 if (_disposeResources is null)
{
var releases = new List<(string Name, Action Release)>();
if (_globalVao != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
_globalVao,
"deleting terrain global VAO");
releases.Add(("global-vao", release.Run));
}
if (_globalVbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_globalVbo,
_globalVboCapacityBytes,
"deleting terrain global vertex buffer");
releases.Add(("global-vbo", release.Run));
}
if (_globalEbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_globalEbo,
_globalEboCapacityBytes,
"deleting terrain global index buffer");
releases.Add(("global-ebo", release.Run));
}
for (int frame = 0; frame < _indirectBuffersByFrame.Length; frame++)
{
List<DynamicIndirectBuffer> frameBuffers = _indirectBuffersByFrame[frame];
for (int index = 0; index < frameBuffers.Count; index++)
{
DynamicIndirectBuffer buffer = frameBuffers[index];
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
buffer.Buffer,
checked((long)buffer.Capacity * sizeof(DrawElementsIndirectCommand)),
"deleting terrain indirect command buffer");
releases.Add(($"indirect-{frame}-{index}", release.Run));
}
}
if (_fallbackClipUbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_fallbackClipUbo,
ClipFrame.TerrainUboBytes,
"deleting terrain fallback clip UBO");
releases.Add(("fallback-clip-ubo", release.Run));
}
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
{
throw attempt.ToException(
"One or more terrain GPU resources could not be released.");
}
_globalVao = 0;
_globalVbo = 0;
_globalEbo = 0;
_globalVboCapacityBytes = 0;
_globalEboCapacityBytes = 0;
foreach (List<DynamicIndirectBuffer> frameBuffers in _indirectBuffersByFrame)
frameBuffers.Clear();
_indirectBuffer = 0;
_indirectCapacity = 0;
_dynamicFrameStarted = false;
_fallbackClipUbo = 0;
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
{
throw attempt.ToException(
"Terrain GPU resources released with exceptional committed outcomes.");
}
} }
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@ -393,28 +640,48 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
/// <summary> /// <summary>
/// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared /// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared
/// <see cref="ClipFrame"/> UBO (<see cref="SetClipUbo"/>); otherwise lazily /// <see cref="ClipFrame"/> UBO range (<see cref="SetClipUbo"/>); otherwise lazily
/// creates + binds a no-clip fallback (count 0 = ungated) so the shader never /// creates + binds a no-clip fallback (count 0 = ungated) so the shader never
/// reads an unbound UBO. The fallback is std140-sized to /// reads an unbound UBO. The fallback is std140-sized to
/// <see cref="ClipFrame.TerrainUboBytes"/> and zero-filled (count 0). /// <see cref="ClipFrame.TerrainUboBytes"/> and zero-filled (count 0).
/// </summary> /// </summary>
private void BindClipUboBinding2() private void BindClipUboBinding2()
{ {
if (_sharedClipUbo != 0) if (_sharedClipBinding.IsValid)
{ {
_gl.BindBufferBase(BufferTargetARB.UniformBuffer, _sharedClipBinding.Bind(_gl);
ClipFrame.TerrainClipUboBinding, _sharedClipUbo);
return; return;
} }
if (_fallbackClipUbo == 0) if (_fallbackClipUbo == 0)
{ {
_fallbackClipUbo = _gl.GenBuffer();
var zero = stackalloc byte[ClipFrame.TerrainUboBytes]; var zero = stackalloc byte[ClipFrame.TerrainUboBytes];
for (int i = 0; i < ClipFrame.TerrainUboBytes; i++) zero[i] = 0; for (int i = 0; i < ClipFrame.TerrainUboBytes; i++) zero[i] = 0;
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _fallbackClipUbo); uint fallback = TrackedGlResource.CreateBuffer(
_gl.BufferData(BufferTargetARB.UniformBuffer, _gl,
(nuint)ClipFrame.TerrainUboBytes, zero, BufferUsageARB.DynamicDraw); "creating terrain fallback clip UBO");
try
{
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.UniformBuffer,
fallback,
0,
ClipFrame.TerrainUboBytes,
BufferUsageARB.DynamicDraw,
zero,
"allocating terrain fallback clip UBO");
_fallbackClipUbo = fallback;
}
catch
{
TrackedGlResource.DeleteBuffer(
_gl,
fallback,
0,
"rolling back terrain fallback clip UBO");
throw;
}
} }
_gl.BindBufferBase(BufferTargetARB.UniformBuffer, _gl.BindBufferBase(BufferTargetARB.UniformBuffer,
ClipFrame.TerrainClipUboBinding, _fallbackClipUbo); ClipFrame.TerrainClipUboBinding, _fallbackClipUbo);
@ -422,23 +689,35 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private void AllocateGpuBuffers(int capacitySlots) private void AllocateGpuBuffers(int capacitySlots)
{ {
nuint vboBytes = (nuint)(capacitySlots * VertsPerLandblock * VertexSize); long vboBytes = checked((long)capacitySlots * VertsPerLandblock * VertexSize);
nuint eboBytes = (nuint)(capacitySlots * IndicesPerLandblock * IndexSize); long eboBytes = checked((long)capacitySlots * IndicesPerLandblock * IndexSize);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo); TrackedGlResource.AllocateBufferStorage(
_gl.BufferData(BufferTargetARB.ArrayBuffer, vboBytes, null, BufferUsageARB.DynamicDraw); _gl,
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); BufferTargetARB.ArrayBuffer,
_globalVbo,
_globalVboCapacityBytes,
vboBytes,
BufferUsageARB.DynamicDraw,
"allocating terrain global vertex storage");
_globalVboCapacityBytes = vboBytes;
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo); TrackedGlResource.AllocateBufferStorage(
_gl.BufferData(BufferTargetARB.ElementArrayBuffer, eboBytes, null, BufferUsageARB.DynamicDraw); _gl,
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0); BufferTargetARB.ElementArrayBuffer,
_globalEbo,
_globalEboCapacityBytes,
eboBytes,
BufferUsageARB.DynamicDraw,
"allocating terrain global index storage");
_globalEboCapacityBytes = eboBytes;
} }
private void ConfigureVao() private void ConfigureVao(uint vao, uint vbo, uint ebo)
{ {
_gl.BindVertexArray(_globalVao); _gl.BindVertexArray(vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
uint stride = (uint)VertexSize; uint stride = (uint)VertexSize;
@ -460,6 +739,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
_gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12)); _gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
_gl.BindVertexArray(0); _gl.BindVertexArray(0);
GLHelpers.ThrowOnResourceError(_gl, "configuring terrain VAO");
} }
internal static void CollectVisibleCells( internal static void CollectVisibleCells(
@ -588,43 +868,129 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private void EnsureCapacity(int newCapacity) private void EnsureCapacity(int newCapacity)
{ {
if (newCapacity <= _alloc.Capacity) return; if (newCapacity <= _alloc.Capacity)
return;
// Allocate new VBO + EBO at new size; copy old contents; swap; recreate VAO. var grownSlots = new SlotData?[newCapacity];
uint newVbo = _gl.GenBuffer(); Array.Copy(_slots, grownSlots, _slots.Length);
uint newEbo = _gl.GenBuffer();
nuint newVboBytes = (nuint)(newCapacity * VertsPerLandblock * VertexSize); long newVboBytes = checked((long)newCapacity * VertsPerLandblock * VertexSize);
nuint newEboBytes = (nuint)(newCapacity * IndicesPerLandblock * IndexSize); long newEboBytes = checked((long)newCapacity * IndicesPerLandblock * IndexSize);
nuint oldVboBytes = (nuint)(_alloc.Capacity * VertsPerLandblock * VertexSize); uint newVbo = 0;
nuint oldEboBytes = (nuint)(_alloc.Capacity * IndicesPerLandblock * IndexSize); uint newEbo = 0;
uint newVao = 0;
long allocatedNewVboBytes = 0;
long allocatedNewEboBytes = 0;
bool published = false;
try
{
newVbo = TrackedGlResource.CreateBuffer(
_gl,
"creating grown terrain vertex buffer");
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.ArrayBuffer,
newVbo,
0,
newVboBytes,
BufferUsageARB.DynamicDraw,
"allocating grown terrain vertex buffer");
allocatedNewVboBytes = newVboBytes;
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, newVbo); newEbo = TrackedGlResource.CreateBuffer(
_gl.BufferData(BufferTargetARB.ArrayBuffer, newVboBytes, null, BufferUsageARB.DynamicDraw); _gl,
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo); "creating grown terrain index buffer");
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo); TrackedGlResource.AllocateBufferStorage(
_gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer, _gl,
0, 0, oldVboBytes); BufferTargetARB.ElementArrayBuffer,
_gl.DeleteBuffer(_globalVbo); newEbo,
_globalVbo = newVbo; 0,
newEboBytes,
BufferUsageARB.DynamicDraw,
"allocating grown terrain index buffer");
allocatedNewEboBytes = newEboBytes;
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, newEbo); GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers (precondition)");
_gl.BufferData(BufferTargetARB.ElementArrayBuffer, newEboBytes, null, BufferUsageARB.DynamicDraw); _gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo);
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo); _gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo); _gl.CopyBufferSubData(
_gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer, CopyBufferSubDataTarget.CopyReadBuffer,
0, 0, oldEboBytes); CopyBufferSubDataTarget.CopyWriteBuffer,
_gl.DeleteBuffer(_globalEbo); 0,
_globalEbo = newEbo; 0,
checked((nuint)_globalVboCapacityBytes));
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo);
_gl.CopyBufferSubData(
CopyBufferSubDataTarget.CopyReadBuffer,
CopyBufferSubDataTarget.CopyWriteBuffer,
0,
0,
checked((nuint)_globalEboCapacityBytes));
GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers");
// Recreate VAO with new buffer bindings. newVao = TrackedGlResource.CreateVertexArray(
_gl.DeleteVertexArray(_globalVao); _gl,
_globalVao = _gl.GenVertexArray(); "creating grown terrain VAO");
ConfigureVao(); ConfigureVao(newVao, newVbo, newEbo);
// Grow slot tracking array. uint oldVao = _globalVao;
Array.Resize(ref _slots, newCapacity); uint oldVbo = _globalVbo;
_alloc.GrowTo(newCapacity); uint oldEbo = _globalEbo;
long oldVboBytes = _globalVboCapacityBytes;
long oldEboBytes = _globalEboCapacityBytes;
_globalVao = newVao;
_globalVbo = newVbo;
_globalEbo = newEbo;
_globalVboCapacityBytes = newVboBytes;
_globalEboCapacityBytes = newEboBytes;
_slots = grownSlots;
_alloc.GrowTo(newCapacity);
published = true;
// Older submitted draws captured the former VAO/buffer bindings.
// Retire the complete old set only after the replacement is valid.
RetryableGpuResourceRelease oldVaoRelease =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
oldVao,
"retiring terrain VAO after growth");
RetryableGpuResourceRelease oldVboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
oldVbo,
oldVboBytes,
"retiring terrain vertex buffer after growth");
RetryableGpuResourceRelease oldEboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
oldEbo,
oldEboBytes,
"retiring terrain index buffer after growth");
_retirementLedger.RetireMany(
[oldVaoRelease, oldVboRelease, oldEboRelease]);
}
finally
{
if (!published)
{
TrackedGlResource.DeleteVertexArray(
_gl,
newVao,
"rolling back grown terrain VAO");
TrackedGlResource.DeleteBuffer(
_gl,
newVbo,
allocatedNewVboBytes,
"rolling back grown terrain vertex buffer");
TrackedGlResource.DeleteBuffer(
_gl,
newEbo,
allocatedNewEboBytes,
"rolling back grown terrain index buffer");
}
}
} }
private sealed class SlotData private sealed class SlotData

View file

@ -1,8 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -23,11 +25,25 @@ public sealed unsafe class TextRenderer : IDisposable
private readonly GL _gl; private readonly GL _gl;
private readonly Shader _shader; private readonly Shader _shader;
private readonly uint _vao; private uint _vao;
private readonly uint _vbo; private uint _vbo;
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
private int _vboCapacityBytes; private int _vboCapacityBytes;
private sealed class FrameBufferSet
{
public uint Vao;
public uint Vbo;
public int CapacityBytes;
public int UsedBytes;
}
private readonly FrameBufferSet[] _frameBuffers = new FrameBufferSet[3];
private FrameBufferSet? _activeFrameBuffer;
internal long DynamicBufferCapacityBytes =>
_frameBuffers.Sum(set => (long)set.CapacityBytes);
private readonly List<float> _textBuf = new(8192); private readonly List<float> _textBuf = new(8192);
private readonly List<float> _rectBuf = new(1024); private readonly List<float> _rectBuf = new(1024);
// Submission-ordered sprite segments: consecutive DrawSprite calls with the // Submission-ordered sprite segments: consecutive DrawSprite calls with the
@ -65,22 +81,8 @@ public sealed unsafe class TextRenderer : IDisposable
Path.Combine(shaderDir, "ui_text.vert"), Path.Combine(shaderDir, "ui_text.vert"),
Path.Combine(shaderDir, "ui_text.frag")); Path.Combine(shaderDir, "ui_text.frag"));
_vao = _gl.GenVertexArray(); for (int i = 0; i < _frameBuffers.Length; i++)
_vbo = _gl.GenBuffer(); _frameBuffers[i] = CreateFrameBufferSet();
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
uint stride = FloatsPerVertex * sizeof(float);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float)));
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindVertexArray(0);
// 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE // 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE
// bucket (the shader multiplies texel×color → white×color = color). Lets a panel // bucket (the shader multiplies texel×color → white×color = color). Lets a panel
@ -97,6 +99,63 @@ public sealed unsafe class TextRenderer : IDisposable
_gl.BindTexture(TextureTarget.Texture2D, 0); _gl.BindTexture(TextureTarget.Texture2D, 0);
} }
/// <summary>
/// Selects the GPU-fenced frame slot and resets its append cursor. Every
/// UI segment rendered during the frame receives a distinct byte range;
/// later text or sprite batches cannot overwrite an earlier in-flight draw.
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_frameBuffers.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
FrameBufferSet set = _frameBuffers[frameSlot];
set.UsedBytes = 0;
_activeFrameBuffer = set;
_vao = set.Vao;
_vbo = set.Vbo;
_vboCapacityBytes = set.CapacityBytes;
}
private FrameBufferSet CreateFrameBufferSet()
{
uint vao = 0;
uint vbo = 0;
try
{
vao = TrackedGlResource.CreateVertexArray(
_gl,
"TextRenderer frame VAO creation");
vbo = TrackedGlResource.CreateBuffer(
_gl,
"TextRenderer frame VBO creation");
var set = new FrameBufferSet { Vao = vao, Vbo = vbo };
_gl.BindVertexArray(set.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo);
uint stride = FloatsPerVertex * sizeof(float);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float)));
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindVertexArray(0);
return set;
}
catch
{
if (vbo != 0)
TrackedGlResource.DeleteBuffer(
_gl, vbo, 0, "TextRenderer frame VBO rollback");
if (vao != 0)
TrackedGlResource.DeleteVertexArray(
_gl, vao, "TextRenderer frame VAO rollback");
throw;
}
}
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary> /// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
public void Begin(Vector2 screenSize) public void Begin(Vector2 screenSize)
{ {
@ -357,8 +416,8 @@ public sealed unsafe class TextRenderer : IDisposable
var seg = spriteSegs[i]; var seg = spriteSegs[i];
if (seg.Verts.Count == 0) continue; if (seg.Verts.Count == 0) continue;
_gl.BindTexture(TextureTarget.Texture2D, seg.Texture); _gl.BindTexture(TextureTarget.Texture2D, seg.Texture);
UploadBuffer(seg.Verts); int firstVertex = UploadBuffer(seg.Verts);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(seg.Verts.Count / FloatsPerVertex)); _gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)(seg.Verts.Count / FloatsPerVertex));
} }
} }
@ -366,8 +425,8 @@ public sealed unsafe class TextRenderer : IDisposable
if (rectVerts > 0) if (rectVerts > 0)
{ {
_shader.SetInt("uUseTexture", 0); _shader.SetInt("uUseTexture", 0);
UploadBuffer(rectBuf); int firstVertex = UploadBuffer(rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts); _gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)rectVerts);
} }
// 3. Textured debug-font text glyphs on top. // 3. Textured debug-font text glyphs on top.
@ -377,34 +436,59 @@ public sealed unsafe class TextRenderer : IDisposable
_gl.ActiveTexture(TextureUnit.Texture0); _gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId); _gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
_shader.SetInt("uTex", 0); _shader.SetInt("uTex", 0);
UploadBuffer(textBuf); int firstVertex = UploadBuffer(textBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)textVerts); _gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)textVerts);
} }
} }
private void UploadBuffer(List<float> buf) private int UploadBuffer(List<float> buf)
{ {
int bytes = buf.Count * sizeof(float); int bytes = buf.Count * sizeof(float);
if (bytes == 0) return; if (bytes == 0) return 0;
FrameBufferSet set = _activeFrameBuffer
?? throw new InvalidOperationException("BeginFrame must be called before rendering text.");
int byteOffset = set.UsedBytes;
int requiredBytes = checked(byteOffset + bytes);
if (bytes > _vboCapacityBytes) if (requiredBytes > _vboCapacityBytes)
{ {
fixed (float* p = CollectionsMarshal.AsSpan(buf)) int newCapacity = DynamicBufferCapacity.Grow(
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)bytes, p, BufferUsageARB.DynamicDraw); _vboCapacityBytes,
_vboCapacityBytes = bytes; requiredBytes);
} TrackedGlResource.AllocateBufferStorage(
else _gl,
{ GLEnum.ArrayBuffer,
fixed (float* p = CollectionsMarshal.AsSpan(buf)) _vbo,
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)bytes, p); _vboCapacityBytes,
newCapacity,
GLEnum.DynamicDraw,
"TextRenderer frame VBO growth");
_vboCapacityBytes = newCapacity;
} }
fixed (float* p = CollectionsMarshal.AsSpan(buf))
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, (nint)byteOffset, (nuint)bytes, p);
set.UsedBytes = requiredBytes;
set.CapacityBytes = _vboCapacityBytes;
return byteOffset / (FloatsPerVertex * sizeof(float));
} }
public void Dispose() public void Dispose()
{ {
_gl.DeleteTexture(_whiteTex); _gl.DeleteTexture(_whiteTex);
_gl.DeleteBuffer(_vbo); foreach (FrameBufferSet set in _frameBuffers)
_gl.DeleteVertexArray(_vao); {
TrackedGlResource.DeleteBuffer(
_gl,
set.Vbo,
set.CapacityBytes,
"TextRenderer frame VBO disposal");
TrackedGlResource.DeleteVertexArray(
_gl,
set.Vao,
"TextRenderer frame VAO disposal");
}
_shader.Dispose(); _shader.Dispose();
} }
} }

View file

@ -1,34 +1,27 @@
// src/AcDream.App/Rendering/TextureCache.cs // src/AcDream.App/Rendering/TextureCache.cs
using AcDream.Core.Textures; using AcDream.Core.Textures;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Content;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
using System.Linq; using System.Linq;
using PixelFormatId = DatReaderWriter.Enums.PixelFormat;
using SurfaceType = DatReaderWriter.Enums.SurfaceType; using SurfaceType = DatReaderWriter.Enums.SurfaceType;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposable public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable
{ {
private readonly GL _gl; private readonly GL _gl;
private readonly DatCollection _dats; private readonly IDatReaderWriter _dats;
private readonly Dictionary<uint, uint> _handlesBySurfaceId = new(); // Handle and decoded dimensions are one atomic cache entry. Keeping them
private readonly Dictionary<uint, (int w, int h)> _sizeBySurfaceId = new(); // in separate dictionaries allowed GetOrUpload(surfaceId) followed by the
/// <summary> // sized overload to upload a second GL texture and orphan the first.
/// Composite cache for surface-with-override-origtex entries (Phase 5 private readonly Dictionary<uint, (uint Handle, int Width, int Height)>
/// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId), _surfacesById = new();
/// value = GL texture handle. private readonly Dictionary<(uint SurfaceId, uint OrigTextureId), (int Width, int Height)>
/// </summary> _decodedDimensionsByTexture = new();
private readonly Dictionary<(uint surfaceId, uint origTexOverride), uint> _handlesByOverridden = new();
/// <summary>
/// Composite cache for palette-overridden entries (Phase 5 SubPalettes).
/// Key = (baseSurfaceId, origTexOverride, paletteHash), value = handle.
/// paletteHash is a cheap combined hash of the PaletteOverride's ids +
/// offsets + lengths so two entities with equivalent palette setups
/// share the same decoded texture.
/// </summary>
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new();
private uint _magentaHandle; private uint _magentaHandle;
// Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids // Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids
@ -44,15 +37,32 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private readonly List<uint> _adhocHandles = new(); private readonly List<uint> _adhocHandles = new();
private readonly Wb.BindlessSupport? _bindless; private readonly Wb.BindlessSupport? _bindless;
private readonly CompositeTextureArrayCache? _compositeTextures;
// Bindless / Texture2DArray parallel caches. Keys mirror the legacy three // Standalone Texture2DArray caches. Shared world surfaces use WB's atlas;
// caches so a surface used by both the legacy (Texture2D, sampler2D) and // this base cache remains for consumers such as particle rendering.
// modern (Texture2DArray, sampler2DArray) paths is uploaded twice — once // Per-entity override composites are owner-scoped but share pooled array
// per target. Each entry stores both the GL texture name (for Dispose // storage. Retail CSurface ownership releases immediately while ImgTex
// cleanup) and the resident bindless handle (returned to callers). // residency remains separately purgeable; CompositeTextureArrayCache
private readonly Dictionary<uint, (uint Name, ulong Handle)> _bindlessBySurfaceId = new(); // mirrors that split without one GL object per material composite.
private readonly Dictionary<(uint surfaceId, uint origTexOverride), (uint Name, ulong Handle)> _bindlessByOverridden = new(); private readonly StandaloneBindlessTextureCache? _particleTextures;
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), (uint Name, ulong Handle)> _bindlessByPalette = new(); private readonly Dictionary<(uint surfaceId, uint origTexOverride), bool> _paletteIndexedByTexture = new();
internal int OwnedBindlessTextureCount => _compositeTextures?.ActiveResourceCount ?? 0;
internal int TextureOwnerCount => _compositeTextures?.OwnerCount ?? 0;
internal int CachedCompositeTextureCount => _compositeTextures?.CachedEntryCount ?? 0;
internal int CachedUnownedCompositeCount => _compositeTextures?.UnownedEntryCount ?? 0;
internal long CachedUnownedCompositeBytes => _compositeTextures?.UnownedBytes ?? 0;
internal int CompositeAtlasCount => _compositeTextures?.AtlasCount ?? 0;
internal long CompositeAtlasBytes => _compositeTextures?.AllocatedBytes ?? 0;
internal int CompositeFrameUploadCount => _compositeTextures?.FrameUploadCount ?? 0;
internal long CompositeFrameUploadBytes => _compositeTextures?.FrameUploadBytes ?? 0;
internal bool CanStartCompositeUpload => _compositeTextures?.CanStartUpload == true;
internal int CachedParticleTextureCount => _particleTextures?.EntryCount ?? 0;
internal int ActiveParticleTextureCount => _particleTextures?.ActiveResourceCount ?? 0;
internal int ParticleTextureOwnerCount => _particleTextures?.OwnerCount ?? 0;
internal int CachedUnownedParticleTextureCount => _particleTextures?.UnownedEntryCount ?? 0;
internal long CachedUnownedParticleTextureBytes => _particleTextures?.UnownedBytes ?? 0;
// Phase N.6 slice 1 (2026-05-11): per-upload metadata for the // Phase N.6 slice 1 (2026-05-11): per-upload metadata for the
// ACDREAM_DUMP_SURFACES=1 histogram dump path. Populated at upload // ACDREAM_DUMP_SURFACES=1 histogram dump path. Populated at upload
@ -68,11 +78,28 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private int _dumpFrameCounter; private int _dumpFrameCounter;
private bool _surfaceHistogramAlreadyDumped; private bool _surfaceHistogramAlreadyDumped;
public TextureCache(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null) public TextureCache(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
: this(gl, dats, bindless, ImmediateGpuResourceRetirementQueue.Instance)
{
}
internal TextureCache(
GL gl,
IDatReaderWriter dats,
Wb.BindlessSupport? bindless,
IGpuResourceRetirementQueue retirementQueue)
{ {
_gl = gl; _gl = gl;
_dats = dats; _dats = dats;
_bindless = bindless; _bindless = bindless;
ArgumentNullException.ThrowIfNull(retirementQueue);
if (bindless is not null)
{
_compositeTextures = new CompositeTextureArrayCache(gl, bindless, retirementQueue);
_particleTextures = new StandaloneBindlessTextureCache(
new ParticleTextureBackend(this),
retirementQueue);
}
} }
/// <summary> /// <summary>
@ -81,17 +108,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
/// missing or uses an unsupported format. /// missing or uses an unsupported format.
/// </summary> /// </summary>
public uint GetOrUpload(uint surfaceId) public uint GetOrUpload(uint surfaceId)
{ => GetOrUploadSurfaceCore(surfaceId, out _, out _);
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var h))
return h;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
DumpAlphaHistogram(surfaceId, decoded);
h = UploadRgba8(decoded);
_handlesBySurfaceId[surfaceId] = h;
return h;
}
/// <summary> /// <summary>
/// Like <see cref="GetOrUpload(uint)"/> but also returns the decoded /// Like <see cref="GetOrUpload(uint)"/> but also returns the decoded
@ -99,19 +116,27 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
/// compute slice UVs. Cached alongside the handle. /// compute slice UVs. Cached alongside the handle.
/// </summary> /// </summary>
public uint GetOrUpload(uint surfaceId, out int width, out int height) public uint GetOrUpload(uint surfaceId, out int width, out int height)
=> GetOrUploadSurfaceCore(surfaceId, out width, out height);
private uint GetOrUploadSurfaceCore(uint surfaceId, out int width, out int height)
{ {
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing) if (_surfacesById.TryGetValue(surfaceId, out var existing))
&& _sizeBySurfaceId.TryGetValue(surfaceId, out var sz))
{ {
width = sz.w; height = sz.h; width = existing.Width;
return existing; height = existing.Height;
return existing.Handle;
} }
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null); DecodedTexture decoded = DecodeFromDats(
surfaceId,
origTextureOverride: null,
paletteOverride: null);
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
DumpAlphaHistogram(surfaceId, decoded);
uint h = UploadRgba8(decoded); uint h = UploadRgba8(decoded);
_handlesBySurfaceId[surfaceId] = h; _surfacesById.Add(surfaceId, (h, decoded.Width, decoded.Height));
_sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height); width = decoded.Width;
width = decoded.Width; height = decoded.Height; height = decoded.Height;
return h; return h;
} }
@ -200,129 +225,228 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
} }
/// <summary> /// <summary>
/// Get or upload a texture for a Surface id but with its /// Acquires the exact DAT-decoded one-layer texture array for a live
/// <c>OrigTextureId</c> replaced by <paramref name="overrideOrigTextureId"/>. /// particle emitter. Equivalent surfaces are shared; the cache ownership
/// The Surface's other properties (type flags, color, translucency, /// ends with <see cref="ReleaseParticleTextureOwner"/>.
/// clipmap handling, default palette) are preserved — only the
/// SurfaceTexture lookup is swapped. This is how the server's
/// CreateObject.TextureChanges are applied at render time.
/// Caches under a composite key so multiple entities can share.
/// </summary> /// </summary>
public uint GetOrUploadWithOrigTextureOverride(uint surfaceId, uint overrideOrigTextureId) internal ulong AcquireParticleTexture(int emitterHandle, uint surfaceId)
{ {
var key = (surfaceId, overrideOrigTextureId); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(emitterHandle);
if (_handlesByOverridden.TryGetValue(key, out var h)) ArgumentOutOfRangeException.ThrowIfZero(surfaceId);
return h; StandaloneBindlessTextureCache textures = EnsureParticleTexturesAvailable();
uint ownerId = checked((uint)emitterHandle);
if (textures.TryAcquire(
ownerId,
surfaceId,
out StandaloneBindlessTextureResource? existing))
{
return existing.Handle;
}
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: null); DecodedTexture decoded = DecodeFromDats(
h = UploadRgba8(decoded); surfaceId,
_handlesByOverridden[key] = h; origTextureOverride: null,
return h; paletteOverride: null);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = 0;
try
{
handle = _bindless!.GetResidentHandle(name);
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"making particle surface 0x{surfaceId:X8} resident");
var resource = new StandaloneBindlessTextureResource
{
SurfaceId = surfaceId,
Name = name,
Handle = handle,
Bytes = checked((long)decoded.Width * decoded.Height * 4L),
};
textures.AddAndAcquire(ownerId, resource);
return handle;
}
catch (Exception residencyFailure)
{
List<Exception>? cleanupFailures = null;
void Attempt(Action cleanup)
{
try { cleanup(); }
catch (Exception ex) { (cleanupFailures ??= []).Add(ex); }
}
bool residencyReleased = handle == 0;
if (handle != 0)
{
Attempt(() =>
{
_bindless!.MakeNonResident(handle);
Wb.GLHelpers.ThrowOnResourceError(
_gl,
"rolling back particle texture residency");
residencyReleased = true;
});
}
if (residencyReleased)
Attempt(() => DeleteUploadedTexture(name));
if (cleanupFailures is not null)
{
cleanupFailures.Insert(0, residencyFailure);
throw new AggregateException(
"Particle texture residency and rollback both failed.",
cleanupFailures);
}
throw;
}
}
internal void ReleaseParticleTextureOwner(int emitterHandle)
{
if (emitterHandle <= 0 || _particleTextures is null)
return;
_particleTextures.ReleaseOwner(checked((uint)emitterHandle));
} }
/// <summary> /// <summary>
/// Full Phase 5 override: for palette-indexed textures (PFID_P8 / /// Owner-scoped bindless variant for a server-supplied original-texture
/// PFID_INDEX16), applies <paramref name="paletteOverride"/>'s /// replacement. Stores compatible composites in a pooled Texture2DArray
/// subpalette overlays on top of the texture's default palette /// and returns its resident handle plus the assigned layer. Equivalent
/// before decoding. Non-palette formats ignore the palette override. /// composites are shared until their final live owner leaves. Throws if
/// Also honors <paramref name="overrideOrigTextureId"/> if non-null. /// BindlessSupport wasn't provided.
/// </summary> /// </summary>
public uint GetOrUploadWithPaletteOverride( internal BindlessTextureLocation GetOrUploadWithOrigTextureOverrideBindless(
uint ownerLocalId,
uint surfaceId, uint surfaceId,
uint? overrideOrigTextureId, uint overrideOrigTextureId)
PaletteOverride paletteOverride)
=> GetOrUploadWithPaletteOverride(surfaceId, overrideOrigTextureId, paletteOverride,
HashPaletteOverride(paletteOverride));
/// <summary>
/// Overload that accepts a precomputed palette hash. Lets callers (e.g.
/// the WB draw dispatcher) compute the hash ONCE per entity and reuse
/// it across every (part, batch) lookup, avoiding the per-batch
/// FNV-1a fold over <see cref="PaletteOverride.SubPalettes"/>.
/// </summary>
public uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride,
ulong precomputedPaletteHash)
{ {
uint origTexKey = overrideOrigTextureId ?? 0; CompositeTextureArrayCache composites = EnsureCompositeTexturesAvailable();
var key = (surfaceId, origTexKey, precomputedPaletteHash); var key = new CompositeTextureKey(
if (_handlesByPalette.TryGetValue(key, out var h)) CompositeTextureKind.OriginalTextureOverride,
return h; surfaceId,
overrideOrigTextureId,
Palette: default);
if (composites.TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing))
return existing;
if (!composites.CanStartUpload)
return default;
(int width, int height) = ResolveDecodedDimensions(surfaceId, overrideOrigTextureId);
if (!composites.CanPrepareUpload(width, height))
return default;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: paletteOverride); DecodedTexture decoded = DecodeFromDats(
h = UploadRgba8(decoded); surfaceId,
_handlesByPalette[key] = h; origTextureOverride: overrideOrigTextureId,
return h; paletteOverride: null);
return composites.TryAddAndAcquire(ownerLocalId, key, decoded, out BindlessTextureLocation added)
? added
: default;
} }
/// <summary> /// <summary>
/// 64-bit bindless handle variant of <see cref="GetOrUpload"/> for the WB /// Owner-scoped bindless palette composite. Applies the palette override on
/// modern rendering path. Uploads the texture as a 1-layer Texture2DArray /// top of the texture's default palette before decoding, stores compatible
/// (so the shader's <c>sampler2DArray</c> can sample at layer 0) and returns /// composites in a pooled Texture2DArray, and returns its resident handle
/// a resident bindless handle. Caches by surfaceId in a separate dictionary /// plus the assigned layer. Structural identity is computed once per entity.
/// from the legacy Texture2D path; the same surface may be uploaded twice
/// if used by both paths (acceptable transition cost — N.6 deletes the legacy
/// path).
/// Throws if BindlessSupport wasn't provided to the constructor. /// Throws if BindlessSupport wasn't provided to the constructor.
/// </summary> /// </summary>
public ulong GetOrUploadBindless(uint surfaceId) internal BindlessTextureLocation GetOrUploadWithPaletteOverrideBindless(
{ uint ownerLocalId,
EnsureBindlessAvailable();
if (_bindlessBySurfaceId.TryGetValue(surfaceId, out var entry))
return entry.Handle;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = _bindless!.GetResidentHandle(name);
_bindlessBySurfaceId[surfaceId] = (name, handle);
return handle;
}
/// <summary>
/// 64-bit bindless handle variant of <see cref="GetOrUploadWithOrigTextureOverride"/>
/// for the WB modern rendering path. Uploads the texture as a 1-layer
/// Texture2DArray with the override SurfaceTexture id and returns a resident
/// bindless handle. Caches under a separate composite key from the legacy
/// path. Throws if BindlessSupport wasn't provided to the constructor.
/// </summary>
public ulong GetOrUploadWithOrigTextureOverrideBindless(uint surfaceId, uint overrideOrigTextureId)
{
EnsureBindlessAvailable();
var key = (surfaceId, overrideOrigTextureId);
if (_bindlessByOverridden.TryGetValue(key, out var entry))
return entry.Handle;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: null);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = _bindless!.GetResidentHandle(name);
_bindlessByOverridden[key] = (name, handle);
return handle;
}
/// <summary>
/// 64-bit bindless handle variant of <see cref="GetOrUploadWithPaletteOverride"/>
/// for the WB modern rendering path. Applies the palette override on top of
/// the texture's default palette before decoding, uploads as a 1-layer
/// Texture2DArray, and returns a resident bindless handle. Takes a
/// precomputed palette hash so the WB dispatcher can compute it once per
/// entity. Throws if BindlessSupport wasn't provided to the constructor.
/// </summary>
public ulong GetOrUploadWithPaletteOverrideBindless(
uint surfaceId, uint surfaceId,
uint? overrideOrigTextureId, uint? overrideOrigTextureId,
PaletteOverride paletteOverride, PaletteOverride paletteOverride,
ulong precomputedPaletteHash) PaletteCompositeIdentity paletteIdentity)
{ {
EnsureBindlessAvailable(); CompositeTextureArrayCache composites = EnsureCompositeTexturesAvailable();
uint origTexKey = overrideOrigTextureId ?? 0; uint origTexKey = overrideOrigTextureId ?? 0;
var key = (surfaceId, origTexKey, precomputedPaletteHash); var key = new CompositeTextureKey(
if (_bindlessByPalette.TryGetValue(key, out var entry)) CompositeTextureKind.PaletteComposite,
return entry.Handle; surfaceId,
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: paletteOverride); origTexKey,
uint name = UploadRgba8AsLayer1Array(decoded); paletteIdentity);
ulong handle = _bindless!.GetResidentHandle(name); if (composites.TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing))
_bindlessByPalette[key] = (name, handle); return existing;
return handle; if (!composites.CanStartUpload)
return default;
(int width, int height) = ResolveDecodedDimensions(surfaceId, overrideOrigTextureId);
if (!composites.CanPrepareUpload(width, height))
return default;
DecodedTexture decoded = DecodeFromDats(
surfaceId,
origTextureOverride: overrideOrigTextureId,
paletteOverride: paletteOverride);
return composites.TryAddAndAcquire(ownerLocalId, key, decoded, out BindlessTextureLocation added)
? added
: default;
}
/// <summary>
/// Retail applies a palette composite only to P8/INDEX16 image data.
/// Cache the resolved source format so animated entities do not reopen the
/// DAT chain every frame.
/// </summary>
internal bool IsPaletteIndexed(uint surfaceId, uint? overrideOrigTextureId)
{
uint origTexKey = overrideOrigTextureId ?? 0;
var key = (surfaceId, origTexKey);
if (_paletteIndexedByTexture.TryGetValue(key, out bool indexed))
return indexed;
Surface? surface = _dats.Get<Surface>(surfaceId);
if (surface is null || surface.Type.HasFlag(SurfaceType.Base1Solid))
return _paletteIndexedByTexture[key] = false;
uint surfaceTextureId = overrideOrigTextureId ?? (uint)surface.OrigTextureId;
SurfaceTexture? texture = _dats.Get<SurfaceTexture>(surfaceTextureId);
if (texture is null || texture.Textures.Count == 0)
return _paletteIndexedByTexture[key] = false;
uint renderSurfaceId = (uint)texture.Textures[0];
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out RenderSurface? renderSurface)
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out renderSurface))
return _paletteIndexedByTexture[key] = false;
indexed = renderSurface.Format is PixelFormatId.PFID_P8 or PixelFormatId.PFID_INDEX16;
_paletteIndexedByTexture[key] = indexed;
return indexed;
}
private (int Width, int Height) ResolveDecodedDimensions(
uint surfaceId,
uint? overrideOrigTextureId)
{
var key = (surfaceId, overrideOrigTextureId ?? 0);
if (_decodedDimensionsByTexture.TryGetValue(key, out var cached))
return cached;
Surface? surface = _dats.Get<Surface>(surfaceId);
if (surface is null
|| surface.Type.HasFlag(SurfaceType.Base1Solid)
|| (uint)surface.OrigTextureId == 0)
return _decodedDimensionsByTexture[key] = (1, 1);
uint surfaceTextureId = overrideOrigTextureId ?? (uint)surface.OrigTextureId;
SurfaceTexture? texture = _dats.Get<SurfaceTexture>(surfaceTextureId);
if (texture is null || texture.Textures.Count == 0)
return _decodedDimensionsByTexture[key] = (1, 1);
uint renderSurfaceId = (uint)texture.Textures[0];
if ((!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out RenderSurface? renderSurface)
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out renderSurface))
|| renderSurface.Width <= 0
|| renderSurface.Height <= 0
|| renderSurface.SourceData is null)
return _decodedDimensionsByTexture[key] = (1, 1);
return _decodedDimensionsByTexture[key] = (renderSurface.Width, renderSurface.Height);
}
/// <summary>
/// Retail <c>CSurface::Destroy</c> (0x005361F0) releases its current
/// <c>ImgTex</c>. Mirror that ownership boundary for per-entity composites.
/// </summary>
public void ReleaseOwner(uint localEntityId)
{
EnsureCompositeTexturesAvailable().ReleaseOwner(localEntityId);
} }
private void EnsureBindlessAvailable() private void EnsureBindlessAvailable()
@ -333,6 +457,49 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
"WbDrawDispatcher requires the bindless-aware ctor overload (pass non-null BindlessSupport)."); "WbDrawDispatcher requires the bindless-aware ctor overload (pass non-null BindlessSupport).");
} }
private CompositeTextureArrayCache EnsureCompositeTexturesAvailable()
{
EnsureBindlessAvailable();
return _compositeTextures!;
}
private StandaloneBindlessTextureCache EnsureParticleTexturesAvailable()
{
EnsureBindlessAvailable();
return _particleTextures!;
}
private sealed class ParticleTextureBackend(TextureCache owner)
: IStandaloneBindlessTextureBackend
{
public void MakeNonResident(StandaloneBindlessTextureResource resource)
{
owner._bindless!.MakeNonResident(resource.Handle);
Wb.GLHelpers.ThrowOnResourceError(
owner._gl,
$"releasing particle texture handle {resource.Handle}");
}
public void Delete(StandaloneBindlessTextureResource resource)
=> owner.DeleteUploadedTexture(resource.Name);
}
/// <summary>
/// Advances bounded composite-cache maintenance once per render frame.
/// Logical owner release is immediate; at most one over-budget layer and
/// one empty backing array are physically retired in this call.
/// </summary>
public void TickCompositeTextureCache() => _compositeTextures?.Tick();
/// <summary>
/// Retires at most one over-budget standalone particle texture per render
/// frame. Keeping this separate from owner release avoids portal-time GPU
/// destruction bursts without changing live particle range or quality.
/// </summary>
public void TickParticleTextureCache() => _particleTextures?.Tick();
public void BeginCompositeTextureFrame() => _compositeTextures?.BeginFrame();
/// <summary> /// <summary>
/// Cheap 64-bit hash over a palette override's identity so two /// Cheap 64-bit hash over a palette override's identity so two
/// entities with the same palette setup share a decode. Internal so /// entities with the same palette setup share a decode. Internal so
@ -354,6 +521,9 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
return h; return h;
} }
internal static PaletteCompositeIdentity GetPaletteIdentity(PaletteOverride palette) =>
new(palette, HashPaletteOverride(palette));
/// <summary> /// <summary>
/// Phase N.6 slice 1: one-shot surface-format histogram dump for the /// Phase N.6 slice 1: one-shot surface-format histogram dump for the
/// atlas-opportunity audit. Activated by ACDREAM_DUMP_SURFACES=1; fires /// atlas-opportunity audit. Activated by ACDREAM_DUMP_SURFACES=1; fires
@ -434,12 +604,19 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
bucketsByTriple[tripleKey] = bucketsByTriple.GetValueOrDefault(tripleKey) + 1; bucketsByTriple[tripleKey] = bucketsByTriple.GetValueOrDefault(tripleKey) + 1;
} }
foreach (var kv in _handlesBySurfaceId) Emit(kv.Key, kv.Value); foreach (var kv in _surfacesById) Emit(kv.Key, kv.Value.Handle);
foreach (var kv in _handlesByOverridden) Emit(kv.Key.surfaceId, kv.Value); _particleTextures?.VisitEntries(resource => Emit(resource.SurfaceId, resource.Name));
foreach (var kv in _handlesByPalette) Emit(kv.Key.surfaceId, kv.Value); _compositeTextures?.VisitEntries((surfaceId, width, height) =>
foreach (var kv in _bindlessBySurfaceId) Emit(kv.Key, kv.Value.Name); {
foreach (var kv in _bindlessByOverridden) Emit(kv.Key.surfaceId, kv.Value.Name); int bytes = checked(width * height * 4);
foreach (var kv in _bindlessByPalette) Emit(kv.Key.surfaceId, kv.Value.Name); totalBytes += bytes;
sb.AppendLine($"0x{surfaceId:X8}, {width}, {height}, RGBA8_COMPOSITE_LAYER, {bytes}");
bucketsByDim[(width, height)] = bucketsByDim.GetValueOrDefault((width, height)) + 1;
bucketsByFormat["RGBA8_COMPOSITE_LAYER"] =
bucketsByFormat.GetValueOrDefault("RGBA8_COMPOSITE_LAYER") + 1;
bucketsByTriple[(width, height, "RGBA8_COMPOSITE_LAYER")] =
bucketsByTriple.GetValueOrDefault((width, height, "RGBA8_COMPOSITE_LAYER")) + 1;
});
sb.AppendLine(); sb.AppendLine();
sb.AppendLine("# Rollups"); sb.AppendLine("# Rollups");
@ -572,31 +749,47 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private uint UploadRgba8(DecodedTexture decoded, bool nearest = false) private uint UploadRgba8(DecodedTexture decoded, bool nearest = false)
{ {
uint tex = _gl.GenTexture(); uint tex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, tex); if (tex == 0)
throw new InvalidOperationException("OpenGL did not create a 2D texture.");
try
{
_gl.BindTexture(TextureTarget.Texture2D, tex);
fixed (byte* p = decoded.Rgba8) fixed (byte* p = decoded.Rgba8)
_gl.TexImage2D( _gl.TexImage2D(
TextureTarget.Texture2D, TextureTarget.Texture2D,
0, 0,
InternalFormat.Rgba8, InternalFormat.Rgba8,
(uint)decoded.Width, (uint)decoded.Width,
(uint)decoded.Height, (uint)decoded.Height,
0, 0,
PixelFormat.Rgba, PixelFormat.Rgba,
PixelType.UnsignedByte, PixelType.UnsignedByte,
p); p);
// Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat // Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat
// font's small glyphs. Other surfaces use bilinear. // font's small glyphs. Other surfaces use bilinear.
int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear; int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear;
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"uploading 2D RGBA8 texture {decoded.Width}x{decoded.Height}");
_gl.BindTexture(TextureTarget.Texture2D, 0); TrackUploadedTexture(tex, decoded.Width, decoded.Height);
_uploadMetadata[tex] = (decoded.Width, decoded.Height, "RGBA8_DECODED"); return tex;
return tex; }
catch
{
_gl.DeleteTexture(tex);
throw;
}
finally
{
_gl.BindTexture(TextureTarget.Texture2D, 0);
}
} }
/// <summary> /// <summary>
@ -607,88 +800,99 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private uint UploadRgba8AsLayer1Array(DecodedTexture decoded) private uint UploadRgba8AsLayer1Array(DecodedTexture decoded)
{ {
uint tex = _gl.GenTexture(); uint tex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2DArray, tex); if (tex == 0)
throw new InvalidOperationException("OpenGL did not create a one-layer texture array.");
try
{
_gl.BindTexture(TextureTarget.Texture2DArray, tex);
fixed (byte* p = decoded.Rgba8) fixed (byte* p = decoded.Rgba8)
_gl.TexImage3D( _gl.TexImage3D(
TextureTarget.Texture2DArray, TextureTarget.Texture2DArray,
0, 0,
InternalFormat.Rgba8, InternalFormat.Rgba8,
(uint)decoded.Width, (uint)decoded.Width,
(uint)decoded.Height, (uint)decoded.Height,
depth: 1, depth: 1,
border: 0, border: 0,
PixelFormat.Rgba, PixelFormat.Rgba,
PixelType.UnsignedByte, PixelType.UnsignedByte,
p); p);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"uploading one-layer RGBA8 array {decoded.Width}x{decoded.Height}");
_gl.BindTexture(TextureTarget.Texture2DArray, 0); TrackUploadedTexture(tex, decoded.Width, decoded.Height);
_uploadMetadata[tex] = (decoded.Width, decoded.Height, "RGBA8_DECODED"); return tex;
return tex; }
catch
{
_gl.DeleteTexture(tex);
throw;
}
finally
{
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
}
}
private void TrackUploadedTexture(uint name, int width, int height)
{
_uploadMetadata[name] = (width, height, "RGBA8_DECODED");
long bytes = checked((long)width * height * 4L);
Wb.GpuMemoryTracker.TrackResourceAllocation(Wb.GpuResourceType.Texture);
Wb.GpuMemoryTracker.TrackAllocation(bytes, Wb.GpuResourceType.Texture);
}
private void DeleteUploadedTexture(uint name)
{
_gl.DeleteTexture(name);
Wb.GLHelpers.ThrowOnResourceError(_gl, $"deleting uploaded texture {name}");
if (_uploadMetadata.Remove(name, out var metadata))
{
long bytes = checked((long)metadata.Width * metadata.Height * 4L);
Wb.GpuMemoryTracker.TrackDeallocation(bytes, Wb.GpuResourceType.Texture);
Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture);
}
} }
public void Dispose() public void Dispose()
{ {
// Phase 1: make all bindless handles non-resident BEFORE any // GameWindow drains frame-flight fences before this teardown. The
// DeleteTexture call. ARB_bindless_texture requires that resident // bindless caches make every handle non-resident before deleting
// handles be released before their backing texture is deleted — // their backing storage.
// interleaving per-entry is UB. Single null-guard around the whole _particleTextures?.Dispose();
// block (cleaner than per-call null-conditionals). _compositeTextures?.Dispose();
if (_bindless is not null)
{
foreach (var (_, handle) in _bindlessBySurfaceId.Values)
_bindless.MakeNonResident(handle);
foreach (var (_, handle) in _bindlessByOverridden.Values)
_bindless.MakeNonResident(handle);
foreach (var (_, handle) in _bindlessByPalette.Values)
_bindless.MakeNonResident(handle);
}
// Phase 2: delete the Texture2DArray textures backing those handles. _paletteIndexedByTexture.Clear();
foreach (var (name, _) in _bindlessBySurfaceId.Values)
_gl.DeleteTexture(name);
_bindlessBySurfaceId.Clear();
foreach (var (name, _) in _bindlessByOverridden.Values)
_gl.DeleteTexture(name);
_bindlessByOverridden.Clear();
foreach (var (name, _) in _bindlessByPalette.Values)
_gl.DeleteTexture(name);
_bindlessByPalette.Clear();
// Phase 3: legacy Texture2D textures. // Legacy Texture2D textures.
foreach (var h in _handlesBySurfaceId.Values) foreach (var entry in _surfacesById.Values)
_gl.DeleteTexture(h); DeleteUploadedTexture(entry.Handle);
_handlesBySurfaceId.Clear(); _surfacesById.Clear();
foreach (var h in _handlesByOverridden.Values)
_gl.DeleteTexture(h);
_handlesByOverridden.Clear();
foreach (var h in _handlesByPalette.Values)
_gl.DeleteTexture(h);
_handlesByPalette.Clear();
if (_magentaHandle != 0) if (_magentaHandle != 0)
{ {
_gl.DeleteTexture(_magentaHandle); DeleteUploadedTexture(_magentaHandle);
_magentaHandle = 0; _magentaHandle = 0;
} }
// RenderSurface (UI sprite) handles — pre-existing gap: this dict was populated // RenderSurface (UI sprite) handles — pre-existing gap: this dict was populated
// by GetOrUploadRenderSurface but was not swept here before this fix. // by GetOrUploadRenderSurface but was not swept here before this fix.
foreach (var h in _handlesByRenderSurfaceId.Values) foreach (var h in _handlesByRenderSurfaceId.Values)
_gl.DeleteTexture(h); DeleteUploadedTexture(h);
_handlesByRenderSurfaceId.Clear(); _handlesByRenderSurfaceId.Clear();
// Ad-hoc handles from the public UploadRgba8(byte[],int,int,bool) wrapper // Ad-hoc handles from the public UploadRgba8(byte[],int,int,bool) wrapper
// (IconComposer composited icons). Not stored in any keyed cache. // (IconComposer composited icons). Not stored in any keyed cache.
foreach (var h in _adhocHandles) foreach (var h in _adhocHandles)
_gl.DeleteTexture(h); DeleteUploadedTexture(h);
_adhocHandles.Clear(); _adhocHandles.Clear();
} }
} }

View file

@ -37,6 +37,12 @@ public sealed class EntityEffectController : IAnimationHookSink
private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new(); private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new();
private readonly Dictionary<uint, WorldEntity> _staticOwners = new(); private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
private readonly HashSet<uint> _syntheticOwners = new(); private readonly HashSet<uint> _syntheticOwners = new();
private readonly HashSet<LiveEntityRecord> _dirtyLiveOwners =
new(ReferenceEqualityComparer.Instance);
private readonly List<LiveEntityRecord> _dirtyLiveOwnerOrder = [];
private readonly List<LiveEntityRecord> _dirtyLiveOwnerSnapshot = [];
private readonly List<uint> _readyGuidSnapshot = [];
private uint _posePublishLocalId;
private Action<string>? _diagnosticSink = Console.WriteLine; private Action<string>? _diagnosticSink = Console.WriteLine;
public EntityEffectController( public EntityEffectController(
@ -58,6 +64,8 @@ public sealed class EntityEffectController : IAnimationHookSink
_ownerUnregistered = ownerUnregistered ?? (_ => { }); _ownerUnregistered = ownerUnregistered ?? (_ => { });
_ownerSoundTableChanged = ownerSoundTableChanged ?? ((_, _) => { }); _ownerSoundTableChanged = ownerSoundTableChanged ?? ((_, _) => { });
_runner.DiagnosticSink = message => _diagnosticSink?.Invoke(message); _runner.DiagnosticSink = message => _diagnosticSink?.Invoke(message);
_poses.EffectPoseChanged += OnEffectPoseChanged;
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
} }
public Action<string>? DiagnosticSink public Action<string>? DiagnosticSink
@ -68,6 +76,7 @@ public sealed class EntityEffectController : IAnimationHookSink
public int PendingPacketCount => _pendingByServerGuid.Values.Sum(queue => queue.Count); public int PendingPacketCount => _pendingByServerGuid.Values.Sum(queue => queue.Count);
public int ReadyOwnerCount => _profilesByLocalId.Count; public int ReadyOwnerCount => _profilesByLocalId.Count;
internal int LastPoseRefreshOwnerVisitCount { get; private set; }
public void HandleDirect(PlayPhysicsScript message) public void HandleDirect(PlayPhysicsScript message)
{ {
@ -222,7 +231,9 @@ public sealed class EntityEffectController : IAnimationHookSink
public void ClearNetworkState() public void ClearNetworkState()
{ {
foreach (uint serverGuid in _readyGenerationByServerGuid.Keys.ToArray()) _readyGuidSnapshot.Clear();
_readyGuidSnapshot.AddRange(_readyGenerationByServerGuid.Keys);
foreach (uint serverGuid in _readyGuidSnapshot)
{ {
if (_liveEntities.TryGetLocalEntityId(serverGuid, out uint localId)) if (_liveEntities.TryGetLocalEntityId(serverGuid, out uint localId))
{ {
@ -233,20 +244,54 @@ public sealed class EntityEffectController : IAnimationHookSink
} }
_readyGenerationByServerGuid.Clear(); _readyGenerationByServerGuid.Clear();
_pendingByServerGuid.Clear(); _pendingByServerGuid.Clear();
_dirtyLiveOwners.Clear();
_dirtyLiveOwnerOrder.Clear();
_dirtyLiveOwnerSnapshot.Clear();
} }
/// <summary>Refreshes every live root after animation/movement.</summary> /// <summary>
/// Publishes only roots dirtied by movement, animation, cell projection,
/// or an authoritative position update. The queue is detached before
/// callbacks run, so a callback-produced mutation is retained for the
/// following update rather than invalidating this traversal.
/// </summary>
public void RefreshLiveOwnerPoses() public void RefreshLiveOwnerPoses()
{ {
foreach (uint serverGuid in _readyGenerationByServerGuid.Keys.ToArray()) LastPoseRefreshOwnerVisitCount = 0;
if (_dirtyLiveOwnerOrder.Count == 0)
return;
_dirtyLiveOwnerSnapshot.Clear();
_dirtyLiveOwnerSnapshot.AddRange(_dirtyLiveOwnerOrder);
_dirtyLiveOwnerOrder.Clear();
_dirtyLiveOwners.Clear();
foreach (LiveEntityRecord record in _dirtyLiveOwnerSnapshot)
{ {
if (!TryGetReadyLocalId(serverGuid, out uint localId)) if (!_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, record)
|| !TryGetReadyLocalId(record.ServerGuid, out uint localId)
|| record.WorldEntity?.Id != localId)
{
continue; continue;
RefreshLiveAnchor(serverGuid, localId); }
TryReplayPending(serverGuid, localId);
LastPoseRefreshOwnerVisitCount++;
RefreshLiveAnchor(record.ServerGuid, localId);
TryReplayPending(record.ServerGuid, localId);
} }
} }
/// <summary>
/// Marks an accepted authoritative root mutation. The record reference,
/// rather than only its server GUID, isolates a queued update from later
/// delete/recreate reuse of that GUID.
/// </summary>
public void MarkLiveOwnerPoseDirty(uint serverGuid)
{
if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record))
MarkLiveOwnerPoseDirty(record);
}
public bool PlayDirect(uint ownerLocalId, uint scriptDid) public bool PlayDirect(uint ownerLocalId, uint scriptDid)
{ {
if (!CanStartOwner(ownerLocalId)) if (!CanStartOwner(ownerLocalId))
@ -411,6 +456,37 @@ public sealed class EntityEffectController : IAnimationHookSink
return false; return false;
} }
private void OnEffectPoseChanged(uint localId)
{
if (_posePublishLocalId == localId)
return;
if (_liveEntities.TryGetServerGuid(localId, out uint serverGuid)
&& _liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record))
{
MarkLiveOwnerPoseDirty(record);
}
}
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (visible)
MarkLiveOwnerPoseDirty(record);
}
private void MarkLiveOwnerPoseDirty(LiveEntityRecord record)
{
if (!_readyGenerationByServerGuid.TryGetValue(
record.ServerGuid,
out ushort readyGeneration)
|| readyGeneration != record.Generation
|| !_dirtyLiveOwners.Add(record))
{
return;
}
_dirtyLiveOwnerOrder.Add(record);
}
private void RefreshLiveAnchor(uint serverGuid, uint localId) private void RefreshLiveAnchor(uint serverGuid, uint localId)
{ {
if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
@ -423,7 +499,18 @@ public sealed class EntityEffectController : IAnimationHookSink
// bookkeeping Position would collapse a weapon effect to the // bookkeeping Position would collapse a weapon effect to the
// parent's origin. // parent's origin.
if (record.ProjectionKind is LiveEntityProjectionKind.World) if (record.ProjectionKind is LiveEntityProjectionKind.World)
_poses.UpdateRoot(entity); {
uint previousPublish = _posePublishLocalId;
_posePublishLocalId = localId;
try
{
_poses.UpdateRoot(entity);
}
finally
{
_posePublishLocalId = previousPublish;
}
}
Vector3 anchor = _poses.TryGetRootPose(localId, out Matrix4x4 rootWorld) Vector3 anchor = _poses.TryGetRootPose(localId, out Matrix4x4 rootWorld)
? rootWorld.Translation ? rootWorld.Translation

View file

@ -15,7 +15,10 @@ namespace AcDream.App.Rendering.Vfx;
/// (<c>0x0051D180</c>). This registry is the modern, read-only seam exposing /// (<c>0x0051D180</c>). This registry is the modern, read-only seam exposing
/// those same final frames without coupling Core effects to the renderer. /// those same final frames without coupling Core effects to the renderer.
/// </remarks> /// </remarks>
public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityEffectCellSource public sealed class EntityEffectPoseRegistry :
IEntityEffectPoseSource,
IEntityEffectCellSource,
IEntityEffectPoseChangeSource
{ {
private sealed class PoseRecord private sealed class PoseRecord
{ {
@ -27,6 +30,8 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
private readonly Dictionary<uint, PoseRecord> _poses = new(); private readonly Dictionary<uint, PoseRecord> _poses = new();
public event Action<uint>? EffectPoseChanged;
public int Count => _poses.Count; public int Count => _poses.Count;
public void Publish(WorldEntity entity, IReadOnlyList<Matrix4x4> partLocal) public void Publish(WorldEntity entity, IReadOnlyList<Matrix4x4> partLocal)
@ -60,17 +65,24 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation) Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation)
* Matrix4x4.CreateTranslation(entity.Position); * Matrix4x4.CreateTranslation(entity.Position);
bool changed;
if (!_poses.TryGetValue(entity.Id, out PoseRecord? record)) if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
{ {
record = new PoseRecord(); record = new PoseRecord();
_poses.Add(entity.Id, record); _poses.Add(entity.Id, record);
changed = true;
}
else
{
changed = record.RootWorld != rootWorld
|| record.CellId != (entity.EffectCellId ?? entity.ParentCellId ?? 0u);
} }
record.RootWorld = rootWorld; record.RootWorld = rootWorld;
record.CellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u; record.CellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u;
if (entity.IndexedPartTransforms.Count > 0) if (entity.IndexedPartTransforms.Count > 0)
{ {
CopyParts( changed |= CopyParts(
record, record,
entity.IndexedPartTransforms, entity.IndexedPartTransforms,
entity.IndexedPartAvailable); entity.IndexedPartAvailable);
@ -78,15 +90,26 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
else else
{ {
if (record.PartLocal.Length != entity.MeshRefs.Count) if (record.PartLocal.Length != entity.MeshRefs.Count)
{
record.PartLocal = new Matrix4x4[entity.MeshRefs.Count]; record.PartLocal = new Matrix4x4[entity.MeshRefs.Count];
changed = true;
}
if (record.PartAvailable.Length != entity.MeshRefs.Count) if (record.PartAvailable.Length != entity.MeshRefs.Count)
{
record.PartAvailable = new bool[entity.MeshRefs.Count]; record.PartAvailable = new bool[entity.MeshRefs.Count];
changed = true;
}
for (int i = 0; i < entity.MeshRefs.Count; i++) for (int i = 0; i < entity.MeshRefs.Count; i++)
{ {
changed |= record.PartLocal[i] != entity.MeshRefs[i].PartTransform
|| !record.PartAvailable[i];
record.PartLocal[i] = entity.MeshRefs[i].PartTransform; record.PartLocal[i] = entity.MeshRefs[i].PartTransform;
record.PartAvailable[i] = true; record.PartAvailable[i] = true;
} }
} }
if (changed)
EffectPoseChanged?.Invoke(entity.Id);
} }
public void Publish( public void Publish(
@ -100,15 +123,23 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
return; return;
ArgumentNullException.ThrowIfNull(partLocal); ArgumentNullException.ThrowIfNull(partLocal);
bool changed;
if (!_poses.TryGetValue(localEntityId, out PoseRecord? record)) if (!_poses.TryGetValue(localEntityId, out PoseRecord? record))
{ {
record = new PoseRecord(); record = new PoseRecord();
_poses.Add(localEntityId, record); _poses.Add(localEntityId, record);
changed = true;
}
else
{
changed = record.RootWorld != rootWorld || record.CellId != cellId;
} }
record.RootWorld = rootWorld; record.RootWorld = rootWorld;
record.CellId = cellId; record.CellId = cellId;
CopyParts(record, partLocal, availability); changed |= CopyParts(record, partLocal, availability);
if (changed)
EffectPoseChanged?.Invoke(localEntityId);
} }
/// <summary>Refresh only the moving root while retaining current part poses.</summary> /// <summary>Refresh only the moving root while retaining current part poses.</summary>
@ -117,15 +148,36 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(entity);
if (!_poses.TryGetValue(entity.Id, out PoseRecord? record)) if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
return false; return false;
record.RootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation) Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation)
* Matrix4x4.CreateTranslation(entity.Position); * Matrix4x4.CreateTranslation(entity.Position);
record.CellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u; uint cellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u;
if (record.RootWorld == rootWorld && record.CellId == cellId)
return true;
record.RootWorld = rootWorld;
record.CellId = cellId;
EffectPoseChanged?.Invoke(entity.Id);
return true; return true;
} }
public bool Remove(uint localEntityId) => _poses.Remove(localEntityId); public bool Remove(uint localEntityId)
{
if (!_poses.Remove(localEntityId))
return false;
EffectPoseChanged?.Invoke(localEntityId);
return true;
}
public void Clear() => _poses.Clear(); public void Clear()
{
if (_poses.Count == 0)
return;
uint[] removedOwners = _poses.Keys.ToArray();
_poses.Clear();
foreach (uint owner in removedOwners)
EffectPoseChanged?.Invoke(owner);
}
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld) public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
{ {
@ -196,21 +248,32 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
return false; return false;
} }
private static void CopyParts( private static bool CopyParts(
PoseRecord record, PoseRecord record,
IReadOnlyList<Matrix4x4> partLocal, IReadOnlyList<Matrix4x4> partLocal,
IReadOnlyList<bool>? availability) IReadOnlyList<bool>? availability)
{ {
if (availability is not null && availability.Count != partLocal.Count) if (availability is not null && availability.Count != partLocal.Count)
throw new ArgumentException("Part pose and availability counts must match."); throw new ArgumentException("Part pose and availability counts must match.");
bool changed = false;
if (record.PartLocal.Length != partLocal.Count) if (record.PartLocal.Length != partLocal.Count)
{
record.PartLocal = new Matrix4x4[partLocal.Count]; record.PartLocal = new Matrix4x4[partLocal.Count];
changed = true;
}
if (record.PartAvailable.Length != partLocal.Count) if (record.PartAvailable.Length != partLocal.Count)
{
record.PartAvailable = new bool[partLocal.Count]; record.PartAvailable = new bool[partLocal.Count];
changed = true;
}
for (int i = 0; i < partLocal.Count; i++) for (int i = 0; i < partLocal.Count; i++)
{ {
bool partAvailable = availability is null || availability[i];
changed |= record.PartLocal[i] != partLocal[i]
|| record.PartAvailable[i] != partAvailable;
record.PartLocal[i] = partLocal[i]; record.PartLocal[i] = partLocal[i];
record.PartAvailable[i] = availability is null || availability[i]; record.PartAvailable[i] = partAvailable;
} }
return changed;
} }
} }

View file

@ -32,63 +32,131 @@ namespace AcDream.App.Rendering;
/// </summary> /// </summary>
public sealed class ViewconeCuller public sealed class ViewconeCuller
{ {
private readonly Dictionary<uint, Vector4[][]> _cellPlanes = new(); private const int MaxRetainedCellPlaneSets = 512;
private Vector4[][] _outsidePlanes = Array.Empty<Vector4[]>(); private const int MaxRetainedPlanesPerCell = 256;
private const int MaxRetainedSlicesPerCell = 64;
private readonly Dictionary<uint, PlaneSet> _cellPlanes = new();
private readonly Stack<PlaneSet> _planeSetPool = new();
private PlaneSet _outsidePlanes = new();
private readonly record struct SliceRange(int Start, int Count);
private readonly record struct LiftedPlane(Vector4 Equation, float NormalLength);
/// <summary>
/// Contiguous per-cell plane storage. Reusing two Lists per visible cell
/// avoids rebuilding a jagged array graph on every render frame while
/// retaining the exact slice boundaries used by retail's any-view test.
/// </summary>
private sealed class PlaneSet
{
public List<LiftedPlane> Planes { get; } = new();
public List<SliceRange> Slices { get; } = new();
public bool IsRetainable =>
Planes.Capacity <= MaxRetainedPlanesPerCell
&& Slices.Capacity <= MaxRetainedSlicesPerCell;
public void Reset()
{
Planes.Clear();
Slices.Clear();
}
}
/// <summary>True when the outside view is a full-screen pass-all (the /// <summary>True when the outside view is a full-screen pass-all (the
/// synthetic outdoor root) — every outside-test passes.</summary> /// synthetic outdoor root) — every outside-test passes.</summary>
public bool OutsideIsFullScreen { get; private set; } public bool OutsideIsFullScreen { get; private set; }
public static ViewconeCuller Build(ClipFrameAssembly assembly, in Matrix4x4 viewProjection) public static ViewconeCuller Build(
ClipFrameAssembly assembly,
in Matrix4x4 viewProjection,
ViewconeCuller? reuse = null)
{ {
ArgumentNullException.ThrowIfNull(assembly); ArgumentNullException.ThrowIfNull(assembly);
var culler = new ViewconeCuller(); var culler = reuse ?? new ViewconeCuller();
culler.Reset();
foreach (var (cellId, slices) in assembly.CellIdToViewSlices) foreach (var (cellId, slices) in assembly.CellIdToViewSlices)
{ {
var lifted = new Vector4[slices.Length][]; PlaneSet lifted = culler.RentPlaneSet();
for (int s = 0; s < slices.Length; s++) for (int s = 0; s < slices.Length; s++)
lifted[s] = LiftPlanes(slices[s].Planes, viewProjection); AppendLiftedSlice(lifted, slices[s].Planes, viewProjection);
culler._cellPlanes[cellId] = lifted; culler._cellPlanes[cellId] = lifted;
} }
var outside = assembly.OutsideViewSlices; var outside = assembly.OutsideViewSlices;
var outsideLifted = new Vector4[outside.Length][];
bool fullScreen = false; bool fullScreen = false;
for (int s = 0; s < outside.Length; s++) for (int s = 0; s < outside.Length; s++)
{ {
outsideLifted[s] = LiftPlanes(outside[s].Planes, viewProjection); AppendLiftedSlice(culler._outsidePlanes, outside[s].Planes, viewProjection);
if (outside[s].Planes.Length == 0) if (outside[s].Planes.Length == 0)
fullScreen = true; fullScreen = true;
} }
culler._outsidePlanes = outsideLifted;
culler.OutsideIsFullScreen = fullScreen; culler.OutsideIsFullScreen = fullScreen;
return culler; return culler;
} }
private static Vector4[] LiftPlanes(Vector4[] clipPlanes, in Matrix4x4 m) private void Reset()
{ {
if (clipPlanes.Length == 0) foreach (PlaneSet set in _cellPlanes.Values)
return Array.Empty<Vector4>(); {
var world = new Vector4[clipPlanes.Length]; set.Reset();
if (set.IsRetainable && _planeSetPool.Count < MaxRetainedCellPlaneSets)
_planeSetPool.Push(set);
}
_cellPlanes.Clear();
if (_outsidePlanes.IsRetainable)
_outsidePlanes.Reset();
else
_outsidePlanes = new PlaneSet();
OutsideIsFullScreen = false;
}
private PlaneSet RentPlaneSet()
{
PlaneSet result = _planeSetPool.Count != 0
? _planeSetPool.Pop()
: new PlaneSet();
result.Reset();
return result;
}
private static void AppendLiftedSlice(
PlaneSet destination,
Vector4[] clipPlanes,
in Matrix4x4 m)
{
int start = destination.Planes.Count;
for (int i = 0; i < clipPlanes.Length; i++) for (int i = 0; i < clipPlanes.Length; i++)
{ {
var p = clipPlanes[i]; var p = clipPlanes[i];
world[i] = new Vector4( var equation = new Vector4(
m.M11 * p.X + m.M12 * p.Y + m.M13 * p.Z + m.M14 * p.W, m.M11 * p.X + m.M12 * p.Y + m.M13 * p.Z + m.M14 * p.W,
m.M21 * p.X + m.M22 * p.Y + m.M23 * p.Z + m.M24 * p.W, m.M21 * p.X + m.M22 * p.Y + m.M23 * p.Z + m.M24 * p.W,
m.M31 * p.X + m.M32 * p.Y + m.M33 * p.Z + m.M34 * p.W, m.M31 * p.X + m.M32 * p.Y + m.M33 * p.Z + m.M34 * p.W,
m.M41 * p.X + m.M42 * p.Y + m.M43 * p.Z + m.M44 * p.W); m.M41 * p.X + m.M42 * p.Y + m.M43 * p.Z + m.M44 * p.W);
float normalLength = MathF.Sqrt(
equation.X * equation.X
+ equation.Y * equation.Y
+ equation.Z * equation.Z);
destination.Planes.Add(new LiftedPlane(equation, normalLength));
} }
return world; destination.Slices.Add(new SliceRange(start, clipPlanes.Length));
} }
private static bool SphereInsidePlanes(Vector4[] planes, in Vector3 center, float radius) private static bool SphereInsidePlanes(
PlaneSet set,
SliceRange slice,
in Vector3 center,
float radius)
{ {
for (int i = 0; i < planes.Length; i++) int end = slice.Start + slice.Count;
for (int i = slice.Start; i < end; i++)
{ {
var l = planes[i]; LiftedPlane plane = set.Planes[i];
float nLen = MathF.Sqrt(l.X * l.X + l.Y * l.Y + l.Z * l.Z); Vector4 l = plane.Equation;
float nLen = plane.NormalLength;
if (nLen < 1e-12f) if (nLen < 1e-12f)
continue; // degenerate plane — no constraint continue; // degenerate plane — no constraint
float dist = l.X * center.X + l.Y * center.Y + l.Z * center.Z + l.W; float dist = l.X * center.X + l.Y * center.Y + l.Z * center.Z + l.W;
@ -103,10 +171,10 @@ public sealed class ViewconeCuller
/// retail). A zero-plane slice is pass-all.</summary> /// retail). A zero-plane slice is pass-all.</summary>
public bool SphereVisibleInCell(uint cellId, in Vector3 center, float radius) public bool SphereVisibleInCell(uint cellId, in Vector3 center, float radius)
{ {
if (!_cellPlanes.TryGetValue(cellId, out var slices)) if (!_cellPlanes.TryGetValue(cellId, out PlaneSet? set))
return false; return false;
for (int s = 0; s < slices.Length; s++) for (int s = 0; s < set.Slices.Count; s++)
if (SphereInsidePlanes(slices[s], center, radius)) if (SphereInsidePlanes(set, set.Slices[s], center, radius))
return true; return true;
return false; return false;
} }
@ -118,8 +186,8 @@ public sealed class ViewconeCuller
{ {
if (OutsideIsFullScreen) if (OutsideIsFullScreen)
return true; return true;
for (int s = 0; s < _outsidePlanes.Length; s++) for (int s = 0; s < _outsidePlanes.Slices.Count; s++)
if (SphereInsidePlanes(_outsidePlanes[s], center, radius)) if (SphereInsidePlanes(_outsidePlanes, _outsidePlanes.Slices[s], center, radius))
return true; return true;
return false; return false;
} }
@ -128,8 +196,12 @@ public sealed class ViewconeCuller
/// slice; its statics pre-filter tests against exactly that slice).</summary> /// slice; its statics pre-filter tests against exactly that slice).</summary>
public bool SphereVisibleInOutsideSlice(int sliceIndex, in Vector3 center, float radius) public bool SphereVisibleInOutsideSlice(int sliceIndex, in Vector3 center, float radius)
{ {
if ((uint)sliceIndex >= (uint)_outsidePlanes.Length) if ((uint)sliceIndex >= (uint)_outsidePlanes.Slices.Count)
return false; return false;
return SphereInsidePlanes(_outsidePlanes[sliceIndex], center, radius); return SphereInsidePlanes(
_outsidePlanes,
_outsidePlanes.Slices[sliceIndex],
center,
radius);
} }
} }

View file

@ -23,5 +23,14 @@ public sealed class AcSurfaceMetadataTable
public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta) public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta)
=> _table.TryGetValue((gfxObjId, surfaceIdx), out meta!); => _table.TryGetValue((gfxObjId, surfaceIdx), out meta!);
public void Remove(ulong gfxObjId)
{
foreach ((ulong Id, int SurfaceIndex) key in _table.Keys)
{
if (key.Id == gfxObjId)
_table.TryRemove(key, out _);
}
}
public void Clear() => _table.Clear(); public void Clear() => _table.Clear();
} }

View file

@ -26,6 +26,10 @@ internal sealed class ContiguousRangeAllocator
public int HighWaterMark { get; private set; } public int HighWaterMark { get; private set; }
public int Free => Capacity - Used; public int Free => Capacity - Used;
public int LargestFreeRange => _free.Count == 0 ? 0 : _free.Max(range => range.Length); public int LargestFreeRange => _free.Count == 0 ? 0 : _free.Max(range => range.Length);
public int TrailingFreeLength =>
_free.Count != 0 && _free[^1].End == Capacity
? _free[^1].Length
: 0;
public bool TryAllocate(int length, out MeshBufferRange allocation) public bool TryAllocate(int length, out MeshBufferRange allocation)
{ {
@ -73,6 +77,30 @@ internal sealed class ContiguousRangeAllocator
InsertAndCoalesce(new MeshBufferRange(oldCapacity, newCapacity - oldCapacity)); InsertAndCoalesce(new MeshBufferRange(oldCapacity, newCapacity - oldCapacity));
} }
/// <summary>
/// Removes an unused tail after the matching physical GPU buffer has been
/// replaced. Live offsets are unchanged, so no render-data rewrite is
/// required.
/// </summary>
public void Shrink(int newCapacity)
{
if (newCapacity <= 0 || newCapacity >= Capacity)
throw new ArgumentOutOfRangeException(nameof(newCapacity));
if (newCapacity < HighWaterMark)
throw new InvalidOperationException("Cannot trim a GPU arena through a live allocation.");
MeshBufferRange tail = _free.Count == 0 ? default : _free[^1];
if (tail.End != Capacity || tail.Offset > newCapacity)
throw new InvalidOperationException("The requested GPU arena tail is not wholly free.");
if (tail.Offset == newCapacity)
_free.RemoveAt(_free.Count - 1);
else
_free[^1] = new MeshBufferRange(tail.Offset, newCapacity - tail.Offset);
Capacity = newCapacity;
RecalculateHighWaterMark();
}
public void Release(MeshBufferRange allocation) public void Release(MeshBufferRange allocation)
{ {
if (allocation.Length <= 0 if (allocation.Length <= 0
@ -84,6 +112,7 @@ internal sealed class ContiguousRangeAllocator
InsertAndCoalesce(allocation); InsertAndCoalesce(allocation);
Used = checked(Used - allocation.Length); Used = checked(Used - allocation.Length);
RecalculateHighWaterMark();
} }
private void InsertAndCoalesce(MeshBufferRange released) private void InsertAndCoalesce(MeshBufferRange released)
@ -114,4 +143,11 @@ internal sealed class ContiguousRangeAllocator
_free.Insert(index, new MeshBufferRange(start, end - start)); _free.Insert(index, new MeshBufferRange(start, end - start));
} }
private void RecalculateHighWaterMark()
{
HighWaterMark = _free.Count != 0 && _free[^1].End == Capacity
? _free[^1].Offset
: Capacity;
}
} }

View file

@ -5,13 +5,21 @@ using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb; namespace AcDream.App.Rendering.Wb;
/// <summary>
/// The exact owner is still inside a reference transition, so its requested
/// logical removal has been queued and must be retried by the live-entity
/// teardown owner after the transition unwinds.
/// </summary>
public sealed class EntityPresentationRemovalDeferredException(uint serverGuid)
: InvalidOperationException(
$"Live entity 0x{serverGuid:X8} presentation removal is deferred until its active reference transition completes.");
/// <summary> /// <summary>
/// Routes server-spawned (<c>CreateObject</c>) entities through the /// Routes server-spawned (<c>CreateObject</c>) entities through the
/// per-instance rendering path. Server entities always carry per-instance /// per-instance rendering path. Server entities always carry per-instance
/// customizations (palette overrides, texture changes, part swaps) that /// customizations (palette overrides, texture changes, part swaps). Shared
/// don't fit WB's atlas key, so they bypass the atlas and use the existing /// surfaces use the WB atlas while per-instance texture composites are owned
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/> /// by the live entity and released with it.
/// path which already hash-keys overrides for caching.
/// ///
/// <para> /// <para>
/// Companion to <see cref="LandblockSpawnAdapter"/>: that adapter handles /// Companion to <see cref="LandblockSpawnAdapter"/>: that adapter handles
@ -21,17 +29,6 @@ namespace AcDream.App.Rendering.Wb;
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <b>Per-entity texture decode</b>: when <c>entity.PaletteOverride</c> is
/// non-null, the adapter calls
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// once per surface id that is known at spawn time (those on
/// <see cref="MeshRef.SurfaceOverrides"/>). Surfaces whose ids are only
/// discoverable by opening the GfxObj dat are decoded lazily by the draw
/// dispatcher (Task 22) on first use — that matches the existing
/// <c>StaticMeshRenderer</c> behavior.
/// </para>
///
/// <para>
/// <b>Sequencer factory</b>: the adapter is constructed with a /// <b>Sequencer factory</b>: the adapter is constructed with a
/// <c>Func&lt;WorldEntity, AnimationSequencer&gt;</c> factory so tests can /// <c>Func&lt;WorldEntity, AnimationSequencer&gt;</c> factory so tests can
/// inject a stub without needing a live DatCollection or MotionTable. /// inject a stub without needing a live DatCollection or MotionTable.
@ -47,24 +44,67 @@ namespace AcDream.App.Rendering.Wb;
/// </summary> /// </summary>
public sealed class EntitySpawnAdapter public sealed class EntitySpawnAdapter
{ {
private readonly ITextureCachePerInstance _textureCache; private readonly IEntityTextureLifetime _textureLifetime;
private readonly Func<WorldEntity, AnimationSequencer> _sequencerFactory; private readonly Func<WorldEntity, AnimationSequencer> _sequencerFactory;
private readonly IWbMeshAdapter? _meshAdapter; private readonly IWbMeshAdapter? _meshAdapter;
// Per-server-guid state. Written on OnCreate, released on OnRemove. // One logical owner per server GUID. Animated state survives projection
// suspension, while the resident bit controls the shorter GPU-presentation
// lifetime. The exact WorldEntity reference makes delayed visibility edges
// from a displaced GUID generation harmless.
// Single-threaded: called only from the render thread (same as GpuWorldState). // Single-threaded: called only from the render thread (same as GpuWorldState).
private readonly Dictionary<uint, AnimatedEntityState> _stateByGuid = new(); private readonly Dictionary<uint, Owner> _ownersByGuid = new();
// Per-server-guid set of GfxObj ids registered with the mesh adapter, private sealed class Owner(
// so OnRemove can decrement each. Per-instance entities don't go through WorldEntity entity,
// LandblockSpawnAdapter, so without this their meshes would never load AnimatedEntityState state,
// (WB doesn't know they exist). HashSet<ulong> meshIds)
private readonly Dictionary<uint, HashSet<ulong>> _meshIdsByGuid = new(); {
public WorldEntity Entity { get; } = entity;
public AnimatedEntityState State { get; } = state;
public HashSet<ulong> MeshIds { get; set; } = meshIds;
public HashSet<ulong> MeshReferencesHeld { get; } = new();
public bool IsPresentationResident { get; set; }
public bool TextureReleaseRequired { get; set; }
public PresentationTransition Transition { get; set; }
public bool RemovalPending { get; set; }
/// <param name="textureCache"> public bool HasPresentationResources
/// Per-instance texture decode path. In production this is the {
/// <see cref="TextureCache"/> instance (which implements get
/// <see cref="ITextureCachePerInstance"/>); in tests it is a capturing mock. {
if (TextureReleaseRequired)
return true;
return MeshReferencesHeld.Count != 0;
}
}
public bool IsFullyResident
{
get
{
if (!IsPresentationResident || !TextureReleaseRequired)
return false;
return MeshReferencesHeld.SetEquals(MeshIds);
}
}
public bool IsFullySuspended =>
!IsPresentationResident && !HasPresentationResources;
}
private enum PresentationTransition
{
None,
Resuming,
Suspending,
ChangingAppearance,
}
/// <param name="textureLifetime">
/// Per-entity texture lifetime owner. Production uses
/// <see cref="TextureCache"/>; tests use a recording implementation.
/// </param> /// </param>
/// <param name="sequencerFactory"> /// <param name="sequencerFactory">
/// Factory that builds an <see cref="AnimationSequencer"/> for a given /// Factory that builds an <see cref="AnimationSequencer"/> for a given
@ -74,20 +114,19 @@ public sealed class EntitySpawnAdapter
/// returns a stub sequencer. /// returns a stub sequencer.
/// </param> /// </param>
/// <param name="meshAdapter"> /// <param name="meshAdapter">
/// Optional WB mesh adapter. When non-null, <see cref="OnCreate"/> /// Optional WB mesh adapter. When non-null, presentation residency
/// registers each unique <c>MeshRef.GfxObjId</c> with the adapter so WB /// registers each unique <c>MeshRef.GfxObjId</c> so WB background-loads
/// background-loads the mesh data; <see cref="OnRemove"/> decrements the /// the mesh data. Projection suspension or logical removal balances those
/// matching ref counts. When null, the adapter only tracks per-instance /// references. When null, the adapter only tracks per-instance state.
/// state without driving WB lifecycle (test mode + flag-off mode).
/// </param> /// </param>
public EntitySpawnAdapter( public EntitySpawnAdapter(
ITextureCachePerInstance textureCache, IEntityTextureLifetime textureLifetime,
Func<WorldEntity, AnimationSequencer> sequencerFactory, Func<WorldEntity, AnimationSequencer> sequencerFactory,
IWbMeshAdapter? meshAdapter = null) IWbMeshAdapter? meshAdapter = null)
{ {
ArgumentNullException.ThrowIfNull(textureCache); ArgumentNullException.ThrowIfNull(textureLifetime);
ArgumentNullException.ThrowIfNull(sequencerFactory); ArgumentNullException.ThrowIfNull(sequencerFactory);
_textureCache = textureCache; _textureLifetime = textureLifetime;
_sequencerFactory = sequencerFactory; _sequencerFactory = sequencerFactory;
_meshAdapter = meshAdapter; _meshAdapter = meshAdapter;
} }
@ -105,29 +144,6 @@ public sealed class EntitySpawnAdapter
// are handled by LandblockSpawnAdapter, not here. // are handled by LandblockSpawnAdapter, not here.
if (entity.ServerGuid == 0) return null; if (entity.ServerGuid == 0) return null;
// Pre-warm the per-instance texture cache for surfaces whose ids are
// already known at spawn time (those appearing as keys in
// MeshRef.SurfaceOverrides). GfxObj sub-mesh surface ids that aren't
// covered by SurfaceOverrides are decoded lazily by the draw
// dispatcher on first use — consistent with StaticMeshRenderer.
if (entity.PaletteOverride is { } paletteOverride)
{
foreach (var meshRef in entity.MeshRefs)
{
if (meshRef.SurfaceOverrides is null) continue;
// SurfaceOverrides maps surfaceId → origTextureOverride (may be 0
// meaning "no texture swap, just the palette override applies").
foreach (var (surfaceId, origTexOverride) in meshRef.SurfaceOverrides)
{
_textureCache.GetOrUploadWithPaletteOverride(
surfaceId,
origTexOverride == 0 ? null : origTexOverride,
paletteOverride);
}
}
}
// A.5 T18: populate cached AABB so WalkEntities reads from the cache // A.5 T18: populate cached AABB so WalkEntities reads from the cache
// rather than recomputing Position±5 per frame. Called here because // rather than recomputing Position±5 per frame. Called here because
// all entity-state initialization (position, rotation) is complete // all entity-state initialization (position, rotation) is complete
@ -147,41 +163,228 @@ public sealed class EntitySpawnAdapter
foreach (var po in entity.PartOverrides) foreach (var po in entity.PartOverrides)
state.SetPartOverride(po.PartIndex, po.GfxObjId); state.SetPartOverride(po.PartIndex, po.GfxObjId);
_stateByGuid[entity.ServerGuid] = state; HashSet<ulong> meshIds = _meshAdapter is null
? []
: CollectMeshIds(entity.MeshRefs, entity.PartOverrides);
// Register each unique GfxObj id with WB so the meshes background-load. // Snapshot each unique GfxObj id for the shorter presentation lifetime.
// Includes both the entity's natural MeshRefs AND any server-sent // Includes both the entity's natural MeshRefs AND any server-sent
// PartOverride GfxObjs (weapons, clothing, helmets) — those replace the // PartOverride GfxObjs (weapons, clothing, helmets) — those replace the
// Setup default and need their own mesh data uploaded. // Setup default and need their own mesh data uploaded.
if (_meshAdapter is not null) // Construct the replacement completely before displacing a live owner.
// Sequencer/appearance construction is allowed to fail; in that case
// the prior GUID incarnation and its presentation references remain
// valid. Retirement is also completed before replacement publication:
// a failed texture or mesh release therefore leaves the prior owner in
// the dictionary, with its per-resource progress available to retry.
var replacementOwner = new Owner(entity, state, meshIds);
if (_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? displacedOwner))
{ {
var unique = new HashSet<ulong>(); if (displacedOwner.RemovalPending)
foreach (var meshRef in entity.MeshRefs) {
unique.Add((ulong)meshRef.GfxObjId); throw new EntityPresentationRemovalDeferredException(entity.ServerGuid);
foreach (var po in entity.PartOverrides) }
unique.Add((ulong)po.GfxObjId);
_meshIdsByGuid[entity.ServerGuid] = unique; if (displacedOwner.Transition != PresentationTransition.None)
foreach (var id in unique) _meshAdapter.IncrementRefCount(id); {
throw new InvalidOperationException(
$"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation transition was already in progress.");
}
if (displacedOwner.HasPresentationResources)
{
if (!SuspendPresentation(displacedOwner))
{
throw new InvalidOperationException(
$"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation teardown was already in progress.");
}
}
_ownersByGuid[entity.ServerGuid] = replacementOwner;
}
else
{
_ownersByGuid.Add(entity.ServerGuid, replacementOwner);
} }
return state; return state;
} }
/// <summary>
/// Changes only the shorter presentation/GPU lifetime of an already-created
/// live entity. A visible projection acquires WB mesh references; suspension
/// releases those references and every owner-scoped composite texture. The
/// animated state remains registered and no create-time scripts are replayed.
/// Duplicate edges and edges for an older incarnation of a reused server GUID
/// are ignored. A failed release keeps the published resident state and the
/// exact unfinished resources so the same edge can be retried safely.
/// </summary>
/// <returns><c>true</c> when this call applied a residency edge.</returns>
public bool SetPresentationResident(WorldEntity entity, bool resident)
{
ArgumentNullException.ThrowIfNull(entity);
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|| !ReferenceEquals(owner.Entity, entity)
|| owner.RemovalPending
|| (resident ? owner.IsFullyResident : owner.IsFullySuspended))
{
return false;
}
return resident
? ResumePresentation(owner)
: SuspendPresentation(owner);
}
/// <summary>
/// Reconciles the mesh-reference set for an in-place retail
/// <c>SmartBox::UpdateVisualDesc</c> mutation. Added meshes are acquired
/// before <paramref name="publishAppearance"/> makes the new appearance
/// visible. Only then is the exact new mesh set published and superseded
/// references released. A failed release remains represented by
/// <see cref="Owner.MeshReferencesHeld"/> and is retried by the next
/// residency edge or logical teardown.
/// <paramref name="afterPublication"/> runs after that commit point but
/// before retirement, so dependent pose/cache publication cannot leave the
/// new entity appearance paired with rolled-back mesh ownership.
/// </summary>
/// <remarks>
/// The exact <see cref="WorldEntity"/> reference is the incarnation token.
/// A delayed appearance callback from a GUID-reused owner is ignored. The
/// method is render-thread only; a re-entrant request is rejected before
/// its publication callback can mutate the active owner.
/// </remarks>
/// <returns>
/// <c>true</c> when the matching owner's appearance was published;
/// otherwise <c>false</c> for a stale, removing, or re-entrant owner.
/// </returns>
public bool OnAppearanceChanged(
WorldEntity entity,
IReadOnlyList<MeshRef> meshRefs,
IReadOnlyList<PartOverride> partOverrides,
Action publishAppearance,
Action? afterPublication = null)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(meshRefs);
ArgumentNullException.ThrowIfNull(partOverrides);
ArgumentNullException.ThrowIfNull(publishAppearance);
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|| !ReferenceEquals(owner.Entity, entity)
|| owner.RemovalPending
|| owner.Transition != PresentationTransition.None)
{
return false;
}
HashSet<ulong> nextMeshIds = _meshAdapter is null
? []
: CollectMeshIds(meshRefs, partOverrides);
owner.Transition = PresentationTransition.ChangingAppearance;
var acquiredThisTransition = new List<ulong>();
bool meshSetPublished = false;
try
{
if (owner.IsPresentationResident && _meshAdapter is not null)
AcquireMissingMeshReferences(owner, nextMeshIds, acquiredThisTransition);
publishAppearance();
// Publication is the commit point: every desired resident mesh is
// owned before this assignment. Superseded references may remain
// temporarily held if their release reports a retryable failure,
// but they are no longer part of the published appearance set.
owner.MeshIds = nextMeshIds;
meshSetPublished = true;
afterPublication?.Invoke();
List<Exception>? releaseFailures = owner.IsPresentationResident
? ReleaseMeshReferencesOutside(owner, nextMeshIds)
: ReleaseAllMeshReferences(owner);
if (releaseFailures is not null)
{
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} appearance mesh retirement failed.",
releaseFailures);
}
return true;
}
catch (Exception acquireOrPublicationFailure) when (!meshSetPublished)
{
// Acquisition failed before publication. Preserve the prior exact
// mesh set and undo only references acquired by this transition.
List<Exception>? rollbackFailures = RollBackAcquiredMeshReferences(
owner,
acquiredThisTransition);
if (rollbackFailures is null)
throw;
rollbackFailures.Insert(0, acquireOrPublicationFailure);
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} appearance publication and mesh rollback failed.",
rollbackFailures);
}
finally
{
owner.Transition = PresentationTransition.None;
}
}
/// <summary> /// <summary>
/// Release the per-entity state for <paramref name="serverGuid"/>. Called /// Release the per-entity state for <paramref name="serverGuid"/>. Called
/// on <c>RemoveObject</c>. Unknown guids (never spawned, or already /// on <c>RemoveObject</c>. Unknown guids (never spawned, or already
/// removed) are silently ignored. /// removed) are silently ignored. Cleanup failure leaves the owner registered
/// and propagates the exception; a later call resumes its unfinished releases.
/// </summary> /// </summary>
public void OnRemove(uint serverGuid) public void OnRemove(uint serverGuid)
{ => _ = TryRemove(serverGuid, expectedEntity: null);
_stateByGuid.Remove(serverGuid);
if (_meshAdapter is not null && _meshIdsByGuid.TryGetValue(serverGuid, out var ids)) private bool TryRemove(uint serverGuid, WorldEntity? expectedEntity)
{
if (!_ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
|| (expectedEntity is not null && !ReferenceEquals(owner.Entity, expectedEntity)))
{ {
foreach (var id in ids) _meshAdapter.DecrementRefCount(id); return false;
_meshIdsByGuid.Remove(serverGuid);
} }
owner.RemovalPending = true;
if (owner.Transition != PresentationTransition.None)
throw new EntityPresentationRemovalDeferredException(serverGuid);
// A resident owner still holds both mesh and potentially-lazy texture
// resources. A suspended owner released them at the visibility edge,
// so logical removal must not decrement or release a second time. Do
// not remove the dictionary entry until cleanup succeeds: otherwise a
// release exception would orphan its remaining references and make a
// later OnRemove retry impossible.
if (owner.HasPresentationResources && !SuspendPresentation(owner))
return false;
if (_ownersByGuid.TryGetValue(serverGuid, out Owner? current)
&& ReferenceEquals(current, owner))
{
_ownersByGuid.Remove(serverGuid);
return true;
}
return false;
}
/// <summary>
/// Releases a logical owner only when <paramref name="entity"/> is the exact
/// incarnation currently registered for its server GUID. This is the live
/// runtime teardown entry point: a delayed callback from an older generation
/// cannot remove a replacement that reused the same GUID.
/// </summary>
/// <returns><c>true</c> when the matching owner was removed.</returns>
public bool OnRemove(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
return TryRemove(entity.ServerGuid, entity);
} }
/// <summary> /// <summary>
@ -190,5 +393,209 @@ public sealed class EntitySpawnAdapter
/// been removed. /// been removed.
/// </summary> /// </summary>
public AnimatedEntityState? GetState(uint serverGuid) public AnimatedEntityState? GetState(uint serverGuid)
=> _stateByGuid.TryGetValue(serverGuid, out var s) ? s : null; => _ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
? owner.State
: null;
private bool ResumePresentation(Owner owner)
{
if (owner.Transition != PresentationTransition.None)
return false;
owner.Transition = PresentationTransition.Resuming;
var acquiredThisTransition = new List<ulong>();
bool desiredMeshSetAcquired = false;
try
{
if (_meshAdapter is not null)
AcquireMissingMeshReferences(owner, owner.MeshIds, acquiredThisTransition);
desiredMeshSetAcquired = true;
owner.TextureReleaseRequired = true;
owner.IsPresentationResident = true;
List<Exception>? releaseFailures = ReleaseMeshReferencesOutside(
owner,
owner.MeshIds);
if (releaseFailures is not null)
{
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation mesh reconciliation failed.",
releaseFailures);
}
return true;
}
catch (Exception acquireFailure) when (!desiredMeshSetAcquired)
{
List<Exception>? rollbackFailures = RollBackAcquiredMeshReferences(
owner,
acquiredThisTransition);
if (rollbackFailures is null)
throw;
rollbackFailures.Insert(0, acquireFailure);
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation resume and rollback failed.",
rollbackFailures);
}
finally
{
owner.Transition = PresentationTransition.None;
}
}
private bool SuspendPresentation(Owner owner)
{
if (owner.Transition != PresentationTransition.None)
return false;
// IsPresentationResident deliberately remains true until every release
// succeeds. Transition prevents a re-entrant duplicate edge from
// releasing the same resource twice, while the completion markers keep
// partial progress retryable after an exception.
owner.Transition = PresentationTransition.Suspending;
List<Exception>? failures = null;
if (owner.TextureReleaseRequired)
{
try
{
_textureLifetime.ReleaseOwner(owner.Entity.Id);
owner.TextureReleaseRequired = false;
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
}
List<Exception>? meshFailures = ReleaseAllMeshReferences(owner);
if (meshFailures is not null)
(failures ??= new List<Exception>()).AddRange(meshFailures);
if (failures is not null)
{
owner.Transition = PresentationTransition.None;
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation suspension failed.",
failures);
}
owner.IsPresentationResident = false;
owner.Transition = PresentationTransition.None;
return true;
}
private static HashSet<ulong> CollectMeshIds(
IReadOnlyList<MeshRef> meshRefs,
IReadOnlyList<PartOverride> partOverrides)
{
var unique = new HashSet<ulong>();
for (int i = 0; i < meshRefs.Count; i++)
unique.Add(meshRefs[i].GfxObjId);
for (int i = 0; i < partOverrides.Count; i++)
unique.Add(partOverrides[i].GfxObjId);
return unique;
}
private void AcquireMissingMeshReferences(
Owner owner,
HashSet<ulong> desiredMeshIds,
List<ulong> acquiredThisTransition)
{
if (_meshAdapter is null)
return;
foreach (ulong meshId in desiredMeshIds)
{
if (owner.MeshReferencesHeld.Contains(meshId))
continue;
try
{
_meshAdapter.IncrementRefCount(meshId);
owner.MeshReferencesHeld.Add(meshId);
acquiredThisTransition.Add(meshId);
}
catch (MeshReferenceMutationException error)
{
if (error.MutationCommitted)
{
owner.MeshReferencesHeld.Add(meshId);
acquiredThisTransition.Add(meshId);
}
throw;
}
}
}
private List<Exception>? RollBackAcquiredMeshReferences(
Owner owner,
List<ulong> acquiredThisTransition)
{
if (_meshAdapter is null)
return null;
List<Exception>? failures = null;
for (int i = acquiredThisTransition.Count - 1; i >= 0; i--)
{
ulong meshId = acquiredThisTransition[i];
if (!owner.MeshReferencesHeld.Contains(meshId))
continue;
try
{
_meshAdapter.DecrementRefCount(meshId);
owner.MeshReferencesHeld.Remove(meshId);
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
owner.MeshReferencesHeld.Remove(meshId);
(failures ??= new List<Exception>()).Add(error);
}
}
return failures;
}
private List<Exception>? ReleaseMeshReferencesOutside(
Owner owner,
HashSet<ulong> desiredMeshIds)
{
if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0)
return null;
List<Exception>? failures = null;
ulong[] heldSnapshot = [.. owner.MeshReferencesHeld];
foreach (ulong meshId in heldSnapshot)
{
if (desiredMeshIds.Contains(meshId))
continue;
try
{
_meshAdapter.DecrementRefCount(meshId);
owner.MeshReferencesHeld.Remove(meshId);
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
owner.MeshReferencesHeld.Remove(meshId);
(failures ??= new List<Exception>()).Add(error);
}
}
return failures;
}
private List<Exception>? ReleaseAllMeshReferences(Owner owner)
{
if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0)
return null;
return ReleaseMeshReferencesOutside(owner, []);
}
} }

View file

@ -1,9 +1,11 @@
namespace AcDream.App.Rendering.Wb; namespace AcDream.App.Rendering.Wb;
/// <summary> /// <summary>
/// Starts CPU mesh extraction for a completed EnvCell build without publishing /// Starts CPU mesh extraction after the completed EnvCell build has been
/// that build to the live renderer. ObjectMeshManager owns its thread-safe work /// published and its geometry ids have acquired render ownership. This order
/// queue; the render-thread commit remains a small atomic state replacement. /// lets ObjectMeshManager cancel queued work as soon as a landblock leaves the
/// current streaming generation; stale portal destinations never keep decoder
/// jobs or surface lists alive.
/// </summary> /// </summary>
public static class EnvCellMeshPreparationScheduler public static class EnvCellMeshPreparationScheduler
{ {
@ -11,8 +13,11 @@ public static class EnvCellMeshPreparationScheduler
EnvCellLandblockBuild build, EnvCellLandblockBuild build,
ObjectMeshManager meshManager) ObjectMeshManager meshManager)
{ {
var scheduled = new HashSet<ulong>();
foreach (var shell in build.Shells) foreach (var shell in build.Shells)
{ {
if (!scheduled.Add(shell.GeometryId))
continue;
_ = meshManager.PrepareEnvCellGeomMeshDataAsync( _ = meshManager.PrepareEnvCellGeomMeshDataAsync(
shell.GeometryId, shell.GeometryId,
shell.EnvironmentId, shell.EnvironmentId,

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,32 @@ namespace AcDream.App.Rendering.Wb {
Device = device; Device = device;
} }
/// <summary>
/// Always-on error boundary for resource transactions. Most render-path
/// checks remain Debug-only because <c>glGetError</c> is a synchronous
/// driver call; allocation/upload code must not publish CPU state after
/// OpenGL reported OOM, context loss, or a rejected transfer in Release.
/// Call exactly once before committing each transaction.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void ThrowOnResourceError(GL gl, string context) {
GLEnum error = gl.GetError();
if (error == GLEnum.NoError)
return;
var errors = new System.Text.StringBuilder();
do {
if (errors.Length != 0)
errors.Append(", ");
errors.Append(error).Append(" (").Append(GetErrorDetails(error)).Append(')');
error = gl.GetError();
} while (error != GLEnum.NoError);
string message = $"OpenGL resource transaction failed: {errors}. Context: {context}";
Logger?.LogError(message);
throw new InvalidOperationException(message);
}
#if DEBUG #if DEBUG
private static bool _loggedVersion = false; private static bool _loggedVersion = false;
@ -140,46 +166,6 @@ namespace AcDream.App.Rendering.Wb {
return info.ToString(); return info.ToString();
} }
/// <summary>
/// Validates texture completeness for mipmapping
/// </summary>
public static bool ValidateTextureMipmapStatus(GL gl, GLEnum target, out string errorMessage) {
try {
gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureWidth, out int width);
gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureHeight, out int height);
gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureInternalFormat, out int format);
if (width == 0 || height == 0) {
errorMessage = "Texture has zero dimensions";
return false;
}
// Check if format is valid for mipmap generation
var internalFormat = (InternalFormat)format;
if (IsCompressedFormat(internalFormat)) {
errorMessage = $"Compressed format {internalFormat} does not support automatic mipmap generation";
return false;
}
errorMessage = String.Empty;
return true;
}
catch (Exception ex) {
errorMessage = $"Exception during validation: {ex.Message}";
return false;
}
}
private static bool IsCompressedFormat(InternalFormat format) {
return format == InternalFormat.CompressedRgbaS3TCDxt1Ext ||
format == InternalFormat.CompressedRgbaS3TCDxt3Ext ||
format == InternalFormat.CompressedRgbaS3TCDxt5Ext ||
format == InternalFormat.CompressedRgbS3TCDxt1Ext ||
format == InternalFormat.CompressedSrgbAlphaS3TCDxt1Ext ||
format == InternalFormat.CompressedSrgbAlphaS3TCDxt3Ext ||
format == InternalFormat.CompressedSrgbAlphaS3TCDxt5Ext;
}
/// <summary> /// <summary>
/// Logs current OpenGL state for debugging /// Logs current OpenGL state for debugging
/// </summary> /// </summary>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,103 @@
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// A contiguous GPU-buffer suballocator whose released ranges become
/// reusable only after the frame-retirement queue says that older draws have
/// finished. This prevents <c>BufferSubData</c> from overwriting a range that
/// an in-flight draw still reads.
/// </summary>
internal sealed class GpuRetiredRangeAllocator
{
private readonly ContiguousRangeAllocator _allocator;
private readonly GpuRetirementLedger _retirementLedger;
private readonly Dictionary<MeshBufferRange, RetryableGpuResourceRelease> _pendingReleases = [];
private int _pendingReleaseCount;
private int _pendingReleaseLength;
public GpuRetiredRangeAllocator(int capacity, IGpuResourceRetirementQueue retirement)
{
_allocator = new ContiguousRangeAllocator(capacity);
_retirementLedger = new GpuRetirementLedger(
retirement ?? throw new ArgumentNullException(nameof(retirement)));
}
public int Capacity => _allocator.Capacity;
public int Used => _allocator.Used;
public int HighWaterMark => _allocator.HighWaterMark;
public int LargestFreeRange => _allocator.LargestFreeRange;
public int TrailingFreeLength => _allocator.TrailingFreeLength;
public int PendingReleaseCount => _pendingReleaseCount;
public int PendingReleaseLength => _pendingReleaseLength;
public bool TryAllocate(int length, out MeshBufferRange allocation) =>
_allocator.TryAllocate(length, out allocation);
public void Grow(int newCapacity) => _allocator.Grow(newCapacity);
public void Shrink(int newCapacity) => _allocator.Shrink(newCapacity);
public void ReleaseAfterGpuUse(MeshBufferRange allocation)
{
if (_pendingReleases.TryGetValue(allocation, out RetryableGpuResourceRelease? pending))
{
// The caller retries this method when queue publication failed.
// Re-publish the retained transaction instead of accounting for a
// second logical release of the same physical range.
try
{
_retirementLedger.RetryPendingPublication(pending);
}
catch when (pending.IsComplete)
{
// An immediate retirement queue may surface a wrapper failure
// after the retained transaction itself fully committed.
}
return;
}
int nextPendingCount = checked(_pendingReleaseCount + 1);
int nextPendingLength = checked(_pendingReleaseLength + allocation.Length);
var release = new RetryableGpuResourceRelease(
() => _allocator.Release(allocation),
() => _pendingReleaseCount = checked(_pendingReleaseCount - 1),
() => _pendingReleaseLength = checked(
_pendingReleaseLength - allocation.Length),
() =>
{
if (!_pendingReleases.Remove(allocation))
{
throw new InvalidOperationException(
"GPU buffer range retirement lost its ownership record.");
}
});
_pendingReleases.Add(allocation, release);
_pendingReleaseCount = nextPendingCount;
_pendingReleaseLength = nextPendingLength;
try
{
_retirementLedger.Retire(release);
}
catch when (release.IsComplete)
{
// Completion is stronger than publication acknowledgement. The
// physical range and its accounting already converged.
}
}
/// <summary>
/// Releases a range that failed before it could be submitted in a draw.
/// </summary>
public void ReleaseUnsubmitted(MeshBufferRange allocation)
{
if (_pendingReleases.ContainsKey(allocation))
{
throw new InvalidOperationException(
"A GPU-submitted range cannot be released as unsubmitted.");
}
_allocator.Release(allocation);
}
}

View file

@ -12,7 +12,6 @@ namespace AcDream.App.Rendering.Wb;
/// without depending on dispatcher internals. /// without depending on dispatcher internals.
/// </summary> /// </summary>
internal readonly record struct GroupKey( internal readonly record struct GroupKey(
uint Ibo,
uint FirstIndex, uint FirstIndex,
int BaseVertex, int BaseVertex,
int IndexCount, int IndexCount,

View file

@ -0,0 +1,12 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Logical-owner lifetime seam for per-entity texture composites. Retail
/// <c>CSurface::Destroy</c> (0x005361F0) releases its current <c>ImgTex</c>;
/// live-object teardown must do the same for modern bindless composites.
/// </summary>
public interface IEntityTextureLifetime
{
/// <summary>Release every composite acquired by one local entity id.</summary>
void ReleaseOwner(uint localEntityId);
}

View file

@ -1,22 +0,0 @@
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Seam interface over the per-instance palette-override decode path in
/// <see cref="TextureCache"/>. Extracted so <see cref="EntitySpawnAdapter"/>
/// can be tested without a live GL context.
/// </summary>
public interface ITextureCachePerInstance
{
/// <summary>
/// Decode (or return cached) the palette-overridden texture for
/// <paramref name="surfaceId"/>. Delegates to
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> in
/// production.
/// </summary>
uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride);
}

View file

@ -1,5 +1,25 @@
namespace AcDream.App.Rendering.Wb; namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Reports the physical outcome when a mesh-reference callback cannot provide
/// the normal strong exception guarantee. <see cref="MutationCommitted"/> lets
/// a transactional owner reconcile its marker without guessing whether a
/// throwing backend already changed the reference count.
/// </summary>
public sealed class MeshReferenceMutationException : Exception
{
public MeshReferenceMutationException(
string message,
bool mutationCommitted,
Exception innerException)
: base(message, innerException)
{
MutationCommitted = mutationCommitted;
}
public bool MutationCommitted { get; }
}
/// <summary> /// <summary>
/// Mockable interface over <see cref="WbMeshAdapter"/> so adapters that /// Mockable interface over <see cref="WbMeshAdapter"/> so adapters that
/// drive ref-count lifecycle (e.g. LandblockSpawnAdapter, EntitySpawnAdapter) /// drive ref-count lifecycle (e.g. LandblockSpawnAdapter, EntitySpawnAdapter)
@ -7,7 +27,17 @@ namespace AcDream.App.Rendering.Wb;
/// </summary> /// </summary>
public interface IWbMeshAdapter public interface IWbMeshAdapter
{ {
/// <summary>
/// Acquires one logical reference. A normal exception guarantees that no
/// reference was acquired; <see cref="MeshReferenceMutationException"/>
/// explicitly reports the exceptional backend case where it was committed.
/// </summary>
void IncrementRefCount(ulong id); void IncrementRefCount(ulong id);
/// <summary>
/// Releases one logical reference under the same committed-outcome contract
/// as <see cref="IncrementRefCount"/>.
/// </summary>
void DecrementRefCount(ulong id); void DecrementRefCount(ulong id);
/// <summary> /// <summary>

View file

@ -9,20 +9,24 @@ namespace AcDream.App.Rendering.Wb;
/// entities (procedural / dat-hydrated, identified by /// entities (procedural / dat-hydrated, identified by
/// <c>ServerGuid == 0</c>) drive ref counts. Server-spawned entities /// <c>ServerGuid == 0</c>) drive ref counts. Server-spawned entities
/// (per-instance tier) are skipped — those go through /// (per-instance tier) are skipped — those go through
/// <c>EntitySpawnAdapter</c> + <c>TextureCache.GetOrUploadWithPaletteOverride</c> /// <c>EntitySpawnAdapter</c> and the owner-scoped texture path
/// (see Phase N.4 spec, Architecture → Two-tier rendering split). /// (see Phase N.4 spec, Architecture → Two-tier rendering split).
/// ///
/// <para> /// <para>
/// On load: walks the landblock's atlas-tier entities, collects unique /// On load: walks the landblock's atlas-tier entities, collects unique
/// GfxObj ids from their <c>MeshRefs</c>, calls /// GfxObj ids from their <c>MeshRefs</c>, calls
/// <c>IncrementRefCount</c> per id, and pins each specialized EnvCell geometry /// <c>IncrementRefCount</c> per id, and pins each specialized EnvCell geometry
/// id without starting generic GfxObj decode. Snapshots both id-sets per /// id without starting generic GfxObj decode. Each reference has an explicit
/// landblock so unload can match the load 1:1. /// desired/held marker so a throwing backend cannot make the logical snapshot
/// disagree with the physical reference count.
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// On unload: looks up both snapshots, calls <c>DecrementRefCount</c> per id, /// On unload: releases only references whose held marker is still set. A
/// drops the snapshots. Unknown / never-loaded landblocks no-op. /// before-commit failure remains retryable; an after-commit
/// <see cref="MeshReferenceMutationException"/> advances the marker before the
/// exception is propagated. The registration is dropped only after every held
/// reference has been released. Unknown / never-loaded landblocks no-op.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -42,15 +46,21 @@ namespace AcDream.App.Rendering.Wb;
/// </summary> /// </summary>
public sealed class LandblockSpawnAdapter public sealed class LandblockSpawnAdapter
{ {
private readonly IWbMeshAdapter _adapter; private sealed class ReferenceRegistration
{
public bool Desired;
public bool Held;
}
// Maps landblock id → unique GfxObj ids registered for that landblock. private sealed class LandblockRegistration
// Written on load, read+cleared on unload. Single-threaded (streaming worker). {
private readonly Dictionary<uint, HashSet<ulong>> _idsByLandblock = new(); public bool WantsLoaded;
// EnvCell shells are prepared through PrepareEnvCellGeomMeshDataAsync rather public Dictionary<ulong, ReferenceRegistration> Ordinary { get; } = new();
// than generic GfxObj loading, but still require explicit lifetime pins. public Dictionary<ulong, ReferenceRegistration> Prepared { get; } = new();
// Keep their synthetic ids separate so registration uses the no-decode pin. }
private readonly Dictionary<uint, HashSet<ulong>> _additionalReadinessIdsByLandblock = new();
private readonly IWbMeshAdapter _adapter;
private readonly Dictionary<uint, LandblockRegistration> _registrations = new();
public LandblockSpawnAdapter(IWbMeshAdapter adapter) public LandblockSpawnAdapter(IWbMeshAdapter adapter)
{ {
@ -81,33 +91,40 @@ public sealed class LandblockSpawnAdapter
unique.Add((ulong)meshRef.GfxObjId); unique.Add((ulong)meshRef.GfxObjId);
} }
if (!_idsByLandblock.TryGetValue(landblock.LandblockId, out var registered)) HashSet<ulong>? preparedIds = additionalReadinessIds is null
? null
: new HashSet<ulong>(additionalReadinessIds);
if (!_registrations.TryGetValue(landblock.LandblockId, out var registration))
{ {
_idsByLandblock[landblock.LandblockId] = unique; registration = new LandblockRegistration { WantsLoaded = true };
foreach (var id in unique) _adapter.IncrementRefCount(id); _registrations.Add(landblock.LandblockId, registration);
} }
else else if (!registration.WantsLoaded)
{ {
foreach (var id in unique) // This is a new load edge that arrived while a preceding unload
{ // still had unfinished releases. The new snapshot replaces the old
if (registered.Add(id)) // desired set. Any still-held overlap remains acquired; obsolete
_adapter.IncrementRefCount(id); // residual references are released by Reconcile below.
} MarkAllUndesired(registration.Ordinary);
MarkAllUndesired(registration.Prepared);
registration.WantsLoaded = true;
} }
if (!_additionalReadinessIdsByLandblock.TryGetValue( MarkDesired(registration.Ordinary, unique);
landblock.LandblockId, if (preparedIds is not null)
out var additional)) MarkDesired(registration.Prepared, preparedIds);
{
additional = new HashSet<ulong>(); List<Exception>? failures = null;
_additionalReadinessIdsByLandblock[landblock.LandblockId] = additional; ReleaseUndesired(registration.Ordinary, ref failures);
} ReleaseUndesired(registration.Prepared, ref failures);
if (additionalReadinessIds is not null) PruneReleasedUndesired(registration.Ordinary);
{ PruneReleasedUndesired(registration.Prepared);
foreach (var id in additionalReadinessIds) AcquireDesired(registration.Ordinary, prepared: false, ref failures);
if (additional.Add(id)) AcquireDesired(registration.Prepared, prepared: true, ref failures);
_adapter.PinPreparedRenderData(id); ThrowFailures(
} failures,
$"Landblock 0x{landblock.LandblockId:X8} mesh-reference acquisition did not fully converge.");
} }
/// <summary> /// <summary>
@ -117,17 +134,19 @@ public sealed class LandblockSpawnAdapter
/// </summary> /// </summary>
public bool IsLandblockRenderReady(uint landblockId) public bool IsLandblockRenderReady(uint landblockId)
{ {
if (!_idsByLandblock.TryGetValue(landblockId, out var registered) if (!_registrations.TryGetValue(landblockId, out var registration)
|| !_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional)) || !registration.WantsLoaded)
{
return false; return false;
}
foreach (var id in registered) foreach (var pair in registration.Ordinary)
if (!_adapter.IsRenderDataReady(id)) if (!pair.Value.Desired
|| !pair.Value.Held
|| !_adapter.IsRenderDataReady(pair.Key))
return false; return false;
foreach (var id in additional) foreach (var pair in registration.Prepared)
if (!_adapter.IsRenderDataReady(id)) if (!pair.Value.Desired
|| !pair.Value.Held
|| !_adapter.IsRenderDataReady(pair.Key))
return false; return false;
return true; return true;
} }
@ -139,11 +158,127 @@ public sealed class LandblockSpawnAdapter
/// </summary> /// </summary>
public void OnLandblockUnloaded(uint landblockId) public void OnLandblockUnloaded(uint landblockId)
{ {
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return; if (!_registrations.TryGetValue(landblockId, out var registration))
foreach (var id in unique) _adapter.DecrementRefCount(id); return;
if (_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
foreach (var id in additional) _adapter.DecrementRefCount(id); registration.WantsLoaded = false;
_idsByLandblock.Remove(landblockId); MarkAllUndesired(registration.Ordinary);
_additionalReadinessIdsByLandblock.Remove(landblockId); MarkAllUndesired(registration.Prepared);
List<Exception>? failures = null;
ReleaseUndesired(registration.Ordinary, ref failures);
ReleaseUndesired(registration.Prepared, ref failures);
PruneReleasedUndesired(registration.Ordinary);
PruneReleasedUndesired(registration.Prepared);
// Even an after-commit backend exception may report failure after the
// final physical release. Drop the registration before propagating in
// that case so a caller retry cannot decrement the same reference.
if (registration.Ordinary.Count == 0 && registration.Prepared.Count == 0)
_registrations.Remove(landblockId);
ThrowFailures(
failures,
$"Landblock 0x{landblockId:X8} mesh-reference release did not fully converge.");
}
private static void MarkDesired(
Dictionary<ulong, ReferenceRegistration> registrations,
IEnumerable<ulong> ids)
{
foreach (ulong id in ids)
{
if (!registrations.TryGetValue(id, out var reference))
{
reference = new ReferenceRegistration();
registrations.Add(id, reference);
}
reference.Desired = true;
}
}
private static void MarkAllUndesired(
Dictionary<ulong, ReferenceRegistration> registrations)
{
foreach (var reference in registrations.Values)
reference.Desired = false;
}
private void AcquireDesired(
Dictionary<ulong, ReferenceRegistration> registrations,
bool prepared,
ref List<Exception>? failures)
{
foreach (var pair in registrations)
{
ReferenceRegistration reference = pair.Value;
if (!reference.Desired || reference.Held)
continue;
try
{
if (prepared)
_adapter.PinPreparedRenderData(pair.Key);
else
_adapter.IncrementRefCount(pair.Key);
reference.Held = true;
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
reference.Held = true;
(failures ??= new List<Exception>()).Add(error);
}
}
}
private void ReleaseUndesired(
Dictionary<ulong, ReferenceRegistration> registrations,
ref List<Exception>? failures)
{
foreach (var pair in registrations)
{
ReferenceRegistration reference = pair.Value;
if (reference.Desired || !reference.Held)
continue;
try
{
_adapter.DecrementRefCount(pair.Key);
reference.Held = false;
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
reference.Held = false;
(failures ??= new List<Exception>()).Add(error);
}
}
}
private static void PruneReleasedUndesired(
Dictionary<ulong, ReferenceRegistration> registrations)
{
List<ulong>? released = null;
foreach (var pair in registrations)
{
if (!pair.Value.Desired && !pair.Value.Held)
(released ??= new List<ulong>()).Add(pair.Key);
}
if (released is null)
return;
foreach (ulong id in released)
registrations.Remove(id);
}
private static void ThrowFailures(List<Exception>? failures, string message)
{
if (failures is null)
return;
if (failures.Count == 1)
throw failures[0];
throw new AggregateException(message, failures);
} }
} }

View file

@ -19,9 +19,6 @@ namespace AcDream.App.Rendering.Wb {
public int Height { get; private set; } public int Height { get; private set; }
public TextureFormat Format => TextureFormat.RGBA8; public TextureFormat Format => TextureFormat.RGBA8;
public ulong BindlessHandle { get; private set; }
public ulong BindlessWrapHandle { get; private set; }
public ulong BindlessClampHandle { get; private set; }
/// <inheritdoc/> /// <inheritdoc/>
public ManagedGLTexture(OpenGLGraphicsDevice device, byte[]? source, int width, int height, TextureParameters? texParams = null) { public ManagedGLTexture(OpenGLGraphicsDevice device, byte[]? source, int width, int height, TextureParameters? texParams = null) {
@ -54,12 +51,10 @@ namespace AcDream.App.Rendering.Wb {
if (p.EnableAnisotropicFiltering && _device.RenderSettings.EnableAnisotropicFiltering) if (p.EnableAnisotropicFiltering && _device.RenderSettings.EnableAnisotropicFiltering)
{ {
float maxAnisotropy = 0f; if (_device.MaxSupportedAnisotropy > 0)
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out maxAnisotropy);
if (maxAnisotropy > 0)
{ {
GL.TexParameter(GLEnum.Texture2D, GLEnum.TextureMaxAnisotropy, maxAnisotropy); GL.TexParameter(GLEnum.Texture2D, GLEnum.TextureMaxAnisotropy,
_device.MaxSupportedAnisotropy);
} }
} }
@ -72,15 +67,6 @@ namespace AcDream.App.Rendering.Wb {
GpuMemoryTracker.TrackAllocation(CalculateSize(), GpuResourceType.Texture); GpuMemoryTracker.TrackAllocation(CalculateSize(), GpuResourceType.Texture);
if (_device.HasBindless && _device.BindlessExtension != null) {
BindlessHandle = _device.BindlessExtension.GetTextureHandle(_texture);
BindlessWrapHandle = _device.BindlessExtension.GetTextureSamplerHandle(_texture, _device.WrapSampler);
BindlessClampHandle = _device.BindlessExtension.GetTextureSamplerHandle(_texture, _device.ClampSampler);
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessWrapHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessClampHandle);
}
} }
private long CalculateSize() { private long CalculateSize() {
@ -112,12 +98,6 @@ namespace AcDream.App.Rendering.Wb {
GL.GetInteger(GLEnum.TextureBinding2D, out int oldBinding); GL.GetInteger(GLEnum.TextureBinding2D, out int oldBinding);
GL.BindTexture(GLEnum.Texture2D, _texture); GL.BindTexture(GLEnum.Texture2D, _texture);
bool wasResident = false;
if (BindlessHandle != 0 && _device.BindlessExtension != null && _device.BindlessExtension.IsTextureHandleResident(BindlessHandle)) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
wasResident = true;
}
fixed (byte* ptr = data) { fixed (byte* ptr = data) {
GL.TexSubImage2D( GL.TexSubImage2D(
GLEnum.Texture2D, GLEnum.Texture2D,
@ -135,10 +115,6 @@ namespace AcDream.App.Rendering.Wb {
// Generate mipmaps if needed // Generate mipmaps if needed
GL.GenerateMipmap(GLEnum.Texture2D); GL.GenerateMipmap(GLEnum.Texture2D);
if (wasResident && BindlessHandle != 0 && _device.BindlessExtension != null) {
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
}
GL.BindTexture(GLEnum.Texture2D, (uint)oldBinding); GL.BindTexture(GLEnum.Texture2D, (uint)oldBinding);
GL.ActiveTexture((GLEnum)oldActiveTexture); GL.ActiveTexture((GLEnum)oldActiveTexture);
GLHelpers.CheckErrors(GL); GLHelpers.CheckErrors(GL);
@ -172,20 +148,6 @@ namespace AcDream.App.Rendering.Wb {
protected void ReleaseTexture() { protected void ReleaseTexture() {
_device.QueueGLAction(GL => { _device.QueueGLAction(GL => {
if (_device.BindlessExtension != null) {
if (BindlessHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
BindlessHandle = 0;
}
if (BindlessWrapHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessWrapHandle);
BindlessWrapHandle = 0;
}
if (BindlessClampHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessClampHandle);
BindlessClampHandle = 0;
}
}
if (_texture != 0) { if (_texture != 0) {
GL.DeleteTexture(_texture); GL.DeleteTexture(_texture);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture); GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);

View file

@ -6,6 +6,7 @@ using TextureHelpers = AcDream.Core.Rendering.Wb.TextureHelpers;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb { namespace AcDream.App.Rendering.Wb {
public class ManagedGLTextureArray : ITextureArray { public class ManagedGLTextureArray : ITextureArray {
@ -18,14 +19,15 @@ namespace AcDream.App.Rendering.Wb {
private readonly bool _isCompressed; private readonly bool _isCompressed;
private int _mipmapDirtyCount = 0; private int _mipmapDirtyCount = 0;
private readonly object _mipmapLock = new object(); private readonly object _mipmapLock = new object();
private uint _pboId;
private int _pboSize;
private readonly List<TextureLayerUpdate> _pendingUpdates = new(); private readonly List<TextureLayerUpdate> _pendingUpdates = new();
private int _disposeQueued;
private int _disposePublicationQueued;
private int _disposeRetirementAccepted;
private RetryableGpuResourceRelease? _disposeRelease;
private struct TextureLayerUpdate { private struct TextureLayerUpdate {
public int Layer; public int Layer;
public int Offset; public required byte[] Data;
public int Size;
public PixelFormat? UploadPixelFormat; public PixelFormat? UploadPixelFormat;
public PixelType? UploadPixelType; public PixelType? UploadPixelType;
} }
@ -36,13 +38,12 @@ namespace AcDream.App.Rendering.Wb {
public int Size { get; private set; } public int Size { get; private set; }
public TextureFormat Format { get; private set; } public TextureFormat Format { get; private set; }
public nint NativePtr { get; private set; } public nint NativePtr { get; private set; }
public ulong BindlessHandle { get; private set; }
public ulong BindlessWrapHandle { get; private set; } public ulong BindlessWrapHandle { get; private set; }
public ulong BindlessClampHandle { get; private set; } public ulong BindlessClampHandle { get; private set; }
public long TotalSizeInBytes => CalculateTotalSize(); public long TotalSizeInBytes => CalculateTotalSize();
/// <summary> /// <summary>
/// #105 diagnostic: staged layer updates (PBO writes + pending list) not yet /// #105 diagnostic: staged layer updates (retained decoded payloads) not yet
/// applied to the GL texture by <see cref="ProcessDirtyUpdates"/>. Layers with /// applied to the GL texture by <see cref="ProcessDirtyUpdates"/>. Layers with
/// a pending update sample UNDEFINED content (TexStorage3D contents) until the /// a pending update sample UNDEFINED content (TexStorage3D contents) until the
/// flush runs — a stuck non-zero count at standstill is the white-walls mechanism. /// flush runs — a stuck non-zero count at standstill is the white-walls mechanism.
@ -69,67 +70,121 @@ namespace AcDream.App.Rendering.Wb {
_isCompressed = IsCompressedFormat(format); _isCompressed = IsCompressedFormat(format);
GLHelpers.CheckErrors(GL); GLHelpers.CheckErrors(GL);
NativePtr = (nint)GL.GenTexture(); uint textureName = 0;
if (NativePtr == 0) { ulong wrapHandle = 0;
throw new InvalidOperationException("Failed to generate texture array."); ulong clampHandle = 0;
} bool textureTracked = false;
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture); bool textureBytesTracked = false;
bool wrapResident = false;
bool clampResident = false;
long textureBytes = CalculateTotalSize();
GLHelpers.CheckErrors(GL); try {
textureName = GL.GenTexture();
if (textureName == 0)
throw new InvalidOperationException("Failed to generate texture array.");
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture);
textureTracked = true;
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr); GL.BindTexture(GLEnum.Texture2DArray, textureName);
GLHelpers.CheckErrors(GL);
int maxDimension = Math.Max(width, height); int maxDimension = Math.Max(width, height);
int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1; int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1;
GL.TexStorage3D(GLEnum.Texture2DArray, (uint)mipLevels, format.ToGL(), (uint)width, (uint)height, GL.TexStorage3D(GLEnum.Texture2DArray, (uint)mipLevels, format.ToGL(), (uint)width, (uint)height,
(uint)size); (uint)size);
GLHelpers.CheckErrorsWithContext(GL, GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter,
$"Creating texture array storage (Format={format}, Size={width}x{height}x{size}, MipLevels={mipLevels})"); (int)p.MinFilter);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, mipLevels - 1);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter, if (p.EnableAnisotropicFiltering
(int)p.MinFilter); && graphicsDevice.RenderSettings.EnableAnisotropicFiltering
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, (int)mipLevels - 1); && graphicsDevice.MaxSupportedAnisotropy > 0) {
GL.TexParameter(
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter); GLEnum.Texture2DArray,
GLEnum.TextureMaxAnisotropy,
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS); graphicsDevice.MaxSupportedAnisotropy);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT);
if (p.EnableAnisotropicFiltering && graphicsDevice.RenderSettings.EnableAnisotropicFiltering) {
float maxAnisotropy = 0f;
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out maxAnisotropy);
if (maxAnisotropy > 0) {
GL.TexParameter(GLEnum.Texture2DArray, GLEnum.TextureMaxAnisotropy, maxAnisotropy);
} }
if (format == TextureFormat.A8) {
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleB, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red);
}
GLHelpers.ThrowOnResourceError(
GL,
$"creating texture array {format} {width}x{height}x{size} ({mipLevels} mip levels)");
GpuMemoryTracker.TrackAllocation(textureBytes, GpuResourceType.Texture);
textureBytesTracked = true;
if (_device.HasBindless && _device.BindlessExtension != null) {
wrapHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.WrapSampler);
clampHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.ClampSampler);
_device.BindlessExtension.MakeTextureHandleResident(wrapHandle);
wrapResident = true;
_device.BindlessExtension.MakeTextureHandleResident(clampHandle);
clampResident = true;
GLHelpers.ThrowOnResourceError(GL, "making texture-array sampler handles resident");
}
NativePtr = (nint)textureName;
BindlessWrapHandle = wrapHandle;
BindlessClampHandle = clampHandle;
} }
catch (Exception constructionFailure) {
// Set texture swizzle for single-channel formats // Constructor failure cannot use Dispose: the object was never
if (format == TextureFormat.A8) { // published and queued teardown would make retries accumulate
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One); // invalid resident handles. Attempt every independent cleanup.
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One); List<Exception>? cleanupFailures = null;
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleB, (int)GLEnum.One); void Attempt(Action cleanup) {
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red); try { cleanup(); }
catch (Exception ex) { (cleanupFailures ??= []).Add(ex); }
}
if (_device.BindlessExtension != null) {
if (clampResident)
Attempt(() => {
_device.BindlessExtension.MakeTextureHandleNonResident(clampHandle);
GLHelpers.ThrowOnResourceError(GL, "rolling back clamp texture-array handle");
clampResident = false;
});
if (wrapResident)
Attempt(() => {
_device.BindlessExtension.MakeTextureHandleNonResident(wrapHandle);
GLHelpers.ThrowOnResourceError(GL, "rolling back wrap texture-array handle");
wrapResident = false;
});
}
// Deleting a texture while either bindless sampler handle is
// still resident is undefined. A pre-commit residency failure
// therefore retains the texture instead of risking a driver
// reset during constructor rollback.
if (textureName != 0 && !clampResident && !wrapResident)
Attempt(() => {
GL.DeleteTexture(textureName);
GLHelpers.ThrowOnResourceError(GL, "rolling back texture array");
if (textureBytesTracked)
GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture);
if (textureTracked)
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
});
if (cleanupFailures is not null) {
cleanupFailures.Insert(0, constructionFailure);
throw new AggregateException(
"Texture-array construction and rollback both failed.",
cleanupFailures);
}
throw;
} }
finally {
GLHelpers.CheckErrors(GL); GL.ActiveTexture(TextureUnit.Texture0);
GL.BindTexture(GLEnum.Texture2DArray, 0);
GpuMemoryTracker.TrackAllocation(CalculateTotalSize(), GpuResourceType.Texture); RenderStateCache.CurrentAtlas = 0;
if (_device.HasBindless && _device.BindlessExtension != null) {
BindlessHandle = _device.BindlessExtension.GetTextureHandle((uint)NativePtr);
BindlessWrapHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.WrapSampler);
BindlessClampHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.ClampSampler);
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessWrapHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessClampHandle);
} }
_pboId = GL.GenBuffer();
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
} }
public long CalculateTotalSize() { public long CalculateTotalSize() {
@ -151,7 +206,7 @@ namespace AcDream.App.Rendering.Wb {
return totalSize; return totalSize;
} }
private bool IsCompressedFormat(TextureFormat format) { private static bool IsCompressedFormat(TextureFormat format) {
return format == TextureFormat.DXT1 || return format == TextureFormat.DXT1 ||
format == TextureFormat.DXT3 || format == TextureFormat.DXT3 ||
format == TextureFormat.DXT5; format == TextureFormat.DXT5;
@ -220,127 +275,156 @@ namespace AcDream.App.Rendering.Wb {
$"Layer index {layer} is out of range [0, {Size - 1}] (Slot={Slot})."); $"Layer index {layer} is out of range [0, {Size - 1}] (Slot={Slot}).");
} }
int currentPboOffset = 0; ValidateUploadPayload(
Format,
Width,
Height,
data.Length,
uploadPixelFormat,
uploadPixelType);
lock (_mipmapLock) { lock (_mipmapLock) {
if (_pendingUpdates.Count > 0) { // Retain the immutable decoded payload until the once-per-frame
var lastUpdate = _pendingUpdates[^1]; // atlas flush. The former per-atlas PBO permanently reserved
currentPboOffset = lastUpdate.Offset + lastUpdate.Size; // several MiB for every array and duplicated each upload
} // through BufferSubData before TexSubImage3D.
var update = new TextureLayerUpdate {
// Align to 4 bytes for safety
currentPboOffset = (currentPboOffset + 3) & ~3;
if (currentPboOffset + data.Length > _pboSize) {
// Flush existing updates first because BufferData will orphan/clear the PBO
if (_pendingUpdates.Count > 0) {
ProcessDirtyUpdatesInternal();
}
currentPboOffset = 0;
int newSize = Math.Max(_pboSize * 2, data.Length);
newSize = Math.Max(newSize, GetExpectedDataSize() * 4); // Initial size 4 layers
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
GL.BufferData(GLEnum.PixelUnpackBuffer, (nuint)newSize, (void*)0, GLEnum.StreamDraw);
if (_pboSize > 0) {
GpuMemoryTracker.TrackDeallocation(_pboSize, GpuResourceType.Buffer);
}
_pboSize = newSize;
GpuMemoryTracker.TrackAllocation(_pboSize, GpuResourceType.Buffer);
}
else {
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
}
fixed (byte* ptr = data) {
GL.BufferSubData(GLEnum.PixelUnpackBuffer, (nint)currentPboOffset, (nuint)data.Length, ptr);
}
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
_pendingUpdates.Add(new TextureLayerUpdate {
Layer = layer, Layer = layer,
Offset = currentPboOffset, Data = data,
Size = data.Length,
UploadPixelFormat = uploadPixelFormat, UploadPixelFormat = uploadPixelFormat,
UploadPixelType = uploadPixelType UploadPixelType = uploadPixelType
}); };
int existingIndex = _pendingUpdates.FindLastIndex(pending => pending.Layer == layer);
if (existingIndex >= 0)
_pendingUpdates[existingIndex] = update;
else
_pendingUpdates.Add(update);
_needsMipmapRegeneration = true; _needsMipmapRegeneration = true;
_mipmapDirtyCount++; if (existingIndex < 0)
_mipmapDirtyCount++;
} }
} }
public void ProcessDirtyUpdates() { public long ProcessDirtyUpdates() {
lock (_mipmapLock) { lock (_mipmapLock) {
ProcessDirtyUpdatesInternal(); return ProcessDirtyUpdatesInternal(generateMipmaps: true);
} }
} }
private unsafe void ProcessDirtyUpdatesInternal() { private unsafe long ProcessDirtyUpdatesInternal(bool generateMipmaps) {
if (_pendingUpdates.Count == 0 && !_needsMipmapRegeneration) return; if (_pendingUpdates.Count == 0
&& (!generateMipmaps || !_needsMipmapRegeneration)) return 0;
long generatedBytes = 0;
GLHelpers.CheckErrors(GL); GLHelpers.CheckErrors(GL);
GL.GetInteger(GLEnum.ActiveTexture, out int oldActiveTexture); // This runs in WbMeshAdapter.Tick before any draw pass. Establish
// the upload phase's canonical texture state directly instead of
// synchronously querying driver state for every dirty array.
GL.ActiveTexture(TextureUnit.Texture0);
RenderStateCache.CurrentAtlas = 0; RenderStateCache.CurrentAtlas = 0;
GL.GetInteger(GLEnum.TextureBinding2DArray, out int oldBinding); bool mipmapWorkCompleted = false;
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr); try {
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
bool wasResident = false; if (_pendingUpdates.Count > 0) {
if (BindlessHandle != 0 && _device.BindlessExtension != null && _device.BindlessExtension.IsTextureHandleResident(BindlessHandle)) { // A non-zero pixel-unpack binding changes pointer arguments
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle); // into byte offsets. Direct client-memory uploads therefore
wasResident = true; // establish the canonical zero binding once for the batch.
} GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
GL.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
GL.PixelStore(PixelStoreParameter.UnpackRowLength, 0);
GL.PixelStore(PixelStoreParameter.UnpackSkipRows, 0);
GL.PixelStore(PixelStoreParameter.UnpackSkipPixels, 0);
if (_pendingUpdates.Count > 0) { foreach (var update in _pendingUpdates) {
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId); fixed (byte* data = update.Data) {
if (_isCompressed) {
var internalFormat = Format.ToCompressedGL();
GL.CompressedTexSubImage3D(
GLEnum.Texture2DArray,
0,
0,
0,
update.Layer,
(uint)Width,
(uint)Height,
1,
internalFormat,
(uint)update.Data.Length,
data);
}
else {
var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat();
var pixelType = update.UploadPixelType ?? Format.ToPixelType();
GL.TexSubImage3D(
GLEnum.Texture2DArray,
0,
0,
0,
update.Layer,
(uint)Width,
(uint)Height,
1,
pixelFormat,
pixelType,
data);
}
}
}
}
foreach (var update in _pendingUpdates) { if (generateMipmaps && _needsMipmapRegeneration && _mipmapDirtyCount > 0) {
if (_isCompressed) { if (_isCompressed) {
var internalFormat = Format.ToCompressedGL(); _logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot);
GL.CompressedTexSubImage3D(GLEnum.Texture2DArray, 0, 0, 0, update.Layer,
(uint)Width, (uint)Height, 1, internalFormat, (uint)update.Size, (void*)update.Offset);
} }
else { else {
var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat(); try {
var pixelType = update.UploadPixelType ?? Format.ToPixelType(); // Width, height and format were validated when the
GL.TexSubImage3D(GLEnum.Texture2DArray, 0, 0, 0, update.Layer, (uint)Width, (uint)Height, 1, // immutable storage was allocated. Re-reading them
pixelFormat, pixelType, (void*)update.Offset); // here forced three CPU/GPU synchronization points
// for every dirty atlas without adding safety.
GL.GenerateMipmap(GLEnum.Texture2DArray);
generatedBytes = TotalSizeInBytes;
}
catch (Exception ex) {
_logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}); retaining upload state for retry.", Slot);
throw;
}
} }
} }
// Release builds must observe transfer/OOM/context errors
// before the pending offsets and dirty mip state are cleared.
// One check covers every layer in this array plus its single
// mip generation, keeping the synchronization cost bounded by
// dirty arrays rather than uploaded textures.
GLHelpers.ThrowOnResourceError(
GL,
$"committing texture-array updates (Slot={Slot}, Layers={_pendingUpdates.Count})");
mipmapWorkCompleted = generateMipmaps
&& _needsMipmapRegeneration
&& _mipmapDirtyCount > 0;
}
finally {
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0); GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
_pendingUpdates.Clear(); GL.BindTexture(GLEnum.Texture2DArray, 0);
GL.ActiveTexture(TextureUnit.Texture0);
} }
if (_needsMipmapRegeneration && _mipmapDirtyCount > 0) { // Commit CPU-side completion only after glGetError confirms the
if (_isCompressed) { // uploads/mipmap work succeeded. If the driver rejects an
_logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot); // operation, the retained payloads and dirty flags remain intact and the
} // atlas stays in ObjectMeshManager's dirty set for a later retry.
else if (!GLHelpers.ValidateTextureMipmapStatus(GL, GLEnum.Texture2DArray, out var errorMessage)) { _pendingUpdates.Clear();
_logger.LogWarning("Mipmap validation failed for texture array (Slot={Slot}): {Error}", Slot, errorMessage); if (mipmapWorkCompleted) {
}
else {
try {
GL.GenerateMipmap(GLEnum.Texture2DArray);
}
catch (Exception ex) {
_logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}).", Slot);
}
}
_mipmapDirtyCount = 0; _mipmapDirtyCount = 0;
_needsMipmapRegeneration = false; _needsMipmapRegeneration = false;
} }
return generatedBytes;
if (wasResident && BindlessHandle != 0 && _device.BindlessExtension != null) {
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
}
GL.BindTexture(GLEnum.Texture2DArray, (uint)oldBinding);
GL.ActiveTexture((GLEnum)oldActiveTexture);
GLHelpers.CheckErrors(GL);
} }
private void ClearLayerForMipmap(int layer) { private void ClearLayerForMipmap(int layer) {
@ -351,17 +435,51 @@ namespace AcDream.App.Rendering.Wb {
} }
private int GetExpectedDataSize() { private int GetExpectedDataSize() {
if (_isCompressed) { return CalculateExpectedDataSize(Format, Width, Height);
return TextureHelpers.GetCompressedLayerSize(Width, Height, Format); }
internal static int CalculateExpectedDataSize(TextureFormat format, int width, int height) {
if (IsCompressedFormat(format))
return TextureHelpers.GetCompressedLayerSize(width, height, format);
return format switch {
TextureFormat.RGBA8 => checked(width * height * 4),
TextureFormat.RGB8 => checked(width * height * 3),
TextureFormat.A8 => checked(width * height),
TextureFormat.Rgba32f => checked(width * height * 16),
_ => throw new NotSupportedException($"Unsupported format {format}")
};
}
internal static void ValidateUploadPayload(
TextureFormat format,
int width,
int height,
int dataLength,
PixelFormat? uploadPixelFormat,
PixelType? uploadPixelType) {
int expectedBytes = CalculateExpectedDataSize(format, width, height);
if (dataLength != expectedBytes) {
throw new ArgumentException(
$"Texture-array layer payload has {dataLength} bytes; expected exactly {expectedBytes} "
+ $"for {format} {width}x{height}.",
nameof(dataLength));
} }
return Format switch { if (IsCompressedFormat(format)) {
TextureFormat.RGBA8 => Width * Height * 4, if (uploadPixelFormat.HasValue || uploadPixelType.HasValue)
TextureFormat.RGB8 => Width * Height * 3, throw new ArgumentException("Compressed texture uploads cannot specify pixel format/type overrides.");
TextureFormat.A8 => Width * Height * 1, return;
TextureFormat.Rgba32f => Width * Height * 16, }
_ => throw new NotSupportedException($"Unsupported format {Format}")
}; PixelFormat expectedFormat = format.ToPixelFormat();
PixelType expectedType = format.ToPixelType();
if ((uploadPixelFormat ?? expectedFormat) != expectedFormat
|| (uploadPixelType ?? expectedType) != expectedType) {
throw new ArgumentException(
$"Upload descriptor {uploadPixelFormat}/{uploadPixelType} does not match "
+ $"the {expectedFormat}/{expectedType} transfer required by {format}.");
}
} }
public void RemoveLayer(int layer) { public void RemoveLayer(int layer) {
@ -376,15 +494,8 @@ namespace AcDream.App.Rendering.Wb {
_usedLayers[layer] = false; _usedLayers[layer] = false;
// Make layer defined for mipmap completeness (uncompressed only) // An unreferenced layer needs no clear or whole-array mip
if (!_isCompressed) { // regeneration before AddTexture overwrites it on reuse.
ClearLayerForMipmap(layer);
}
lock (_mipmapLock) {
_mipmapDirtyCount++; // Mark dirty to regen
_needsMipmapRegeneration = true;
}
} }
public bool IsLayerUsed(int layer) { public bool IsLayerUsed(int layer) {
@ -396,6 +507,22 @@ namespace AcDream.App.Rendering.Wb {
return _usedLayers.Count(x => x); return _usedLayers.Count(x => x);
} }
/// <summary>
/// True once disposal is durably owned by a queued GL publication,
/// the frame-retirement queue, or a completed retained release. A
/// caller may only commit its own logical disposal after this becomes
/// true; otherwise a synchronous enqueue failure still needs retry.
/// </summary>
internal bool HasDurableDisposeOwnership {
get {
if (Volatile.Read(ref _disposeQueued) == 0)
return false;
return Volatile.Read(ref _disposePublicationQueued) != 0
|| Volatile.Read(ref _disposeRetirementAccepted) != 0
|| Volatile.Read(ref _disposeRelease) is null;
}
}
public void Unbind() { public void Unbind() {
GL.BindTexture(GLEnum.Texture2DArray, 0); GL.BindTexture(GLEnum.Texture2DArray, 0);
GLHelpers.CheckErrors(GL); GLHelpers.CheckErrors(GL);
@ -409,37 +536,95 @@ namespace AcDream.App.Rendering.Wb {
} }
public void Dispose() { public void Dispose() {
_device.QueueGLAction(GL => { if (Interlocked.CompareExchange(ref _disposeQueued, 1, 0) != 0) {
if (_device.BindlessExtension != null) { ScheduleDisposeRelease();
if (BindlessHandle != 0) { return;
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle); }
BindlessHandle = 0;
uint textureName = (uint)NativePtr;
ulong bindlessWrapHandle = BindlessWrapHandle;
ulong bindlessClampHandle = BindlessClampHandle;
long textureBytes = CalculateTotalSize();
NativePtr = 0;
BindlessWrapHandle = 0;
BindlessClampHandle = 0;
_disposeRelease = new RetryableGpuResourceRelease(
() => {
if (_device.BindlessExtension != null && bindlessWrapHandle != 0)
GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle (precondition)");
},
() => {
if (_device.BindlessExtension != null && bindlessWrapHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(bindlessWrapHandle);
GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle");
} }
if (BindlessWrapHandle != 0) { },
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessWrapHandle); () => {
BindlessWrapHandle = 0; if (_device.BindlessExtension != null && bindlessClampHandle != 0)
GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle (precondition)");
},
() => {
if (_device.BindlessExtension != null && bindlessClampHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(bindlessClampHandle);
GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle");
} }
if (BindlessClampHandle != 0) { },
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessClampHandle); () => {
BindlessClampHandle = 0; if (textureName != 0)
GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName} (precondition)");
},
() => {
if (textureName != 0) {
GL.DeleteTexture(textureName);
GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName}");
} }
} },
if (NativePtr != 0) { () => {
GL.DeleteTexture((uint)NativePtr); if (textureName != 0)
GLHelpers.CheckErrors(GL); GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture);
GpuMemoryTracker.TrackDeallocation(CalculateTotalSize(), GpuResourceType.Texture); },
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture); () => {
NativePtr = 0; if (textureName != 0)
} GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
if (_pboId != 0) { },
GL.DeleteBuffer(_pboId); () => _disposeRelease = null);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
if (_pboSize > 0) { ScheduleDisposeRelease();
GpuMemoryTracker.TrackDeallocation(_pboSize, GpuResourceType.Buffer); }
private void ScheduleDisposeRelease(bool forNextPass = false) {
RetryableGpuResourceRelease? release = _disposeRelease;
if (release is null || release.IsComplete || Volatile.Read(ref _disposeRetirementAccepted) != 0)
return;
if (Interlocked.CompareExchange(ref _disposePublicationQueued, 1, 0) != 0)
return;
try {
Action<GL> publish = GL => {
Volatile.Write(ref _disposePublicationQueued, 0);
try {
_device.RetireGpuResource(release.Run);
Volatile.Write(ref _disposeRetirementAccepted, 1);
} }
_pboId = 0; catch {
} // Retire may fail before accepting the callback, or an
}); // immediate queue may surface a partial release. The
// release cursor makes this next-pass retry exact.
ScheduleDisposeRelease(forNextPass: true);
throw;
}
};
if (forNextPass)
_device.QueueGLActionForNextPass(publish);
else
_device.QueueGLAction(publish);
}
catch {
Volatile.Write(ref _disposePublicationQueued, 0);
throw;
}
} }
} }
} }

View file

@ -3,6 +3,23 @@ using AcDream.Content;
namespace AcDream.App.Rendering.Wb; namespace AcDream.App.Rendering.Wb;
internal enum MeshStageResult
{
Staged,
AlreadyStaged,
HighWater,
}
/// <summary>
/// Immutable identity for one staging claim. Object ids can be released and
/// reacquired while a render-thread upload is in flight; the generation keeps
/// a stale completion or retry from consuming the replacement claim.
/// </summary>
internal readonly record struct MeshUploadQueueItem(
ObjectMeshData Data,
long SourceBytes,
ulong Generation);
/// <summary> /// <summary>
/// Deduplicated CPU-to-GPU staging queue. One object id may be queued or /// Deduplicated CPU-to-GPU staging queue. One object id may be queued or
/// in-flight at a time; a retry retains ownership until upload succeeds or /// in-flight at a time; a retry retains ownership until upload succeeds or
@ -10,24 +27,209 @@ namespace AcDream.App.Rendering.Wb;
/// </summary> /// </summary>
internal sealed class MeshUploadStagingQueue internal sealed class MeshUploadStagingQueue
{ {
private readonly ConcurrentQueue<ObjectMeshData> _queue = new(); internal const int DefaultMaximumCount = 256;
private readonly ConcurrentDictionary<ulong, byte> _ownedIds = new(); internal const long DefaultMaximumBytes = 128L * 1024 * 1024;
internal const long DefaultMaximumSingleEntryBytes = 128L * 1024 * 1024;
public bool Stage(ObjectMeshData data) private readonly object _gate = new();
private readonly Queue<Entry> _queue = new();
private readonly Dictionary<ulong, ulong> _generationById = new();
private readonly int _maximumCount;
private readonly long _maximumBytes;
private readonly long _maximumSingleEntryBytes;
private long _queuedBytes;
private long _claimedBytes;
private ulong _nextGeneration;
private readonly record struct Entry(ObjectMeshData Data, long Bytes, ulong Generation)
{ {
if (!_ownedIds.TryAdd(data.ObjectId, 0)) public MeshUploadQueueItem Item => new(Data, Bytes, Generation);
return false;
data.UploadAttempts = 0;
_queue.Enqueue(data);
return true;
} }
public bool TryDequeue(out ObjectMeshData? data) => _queue.TryDequeue(out data); public MeshUploadStagingQueue(
int maximumCount = DefaultMaximumCount,
long maximumBytes = DefaultMaximumBytes,
long maximumSingleEntryBytes = 0)
{
ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1);
ArgumentOutOfRangeException.ThrowIfNegative(maximumSingleEntryBytes);
if (maximumSingleEntryBytes == 0)
maximumSingleEntryBytes = Math.Max(maximumBytes, DefaultMaximumSingleEntryBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumSingleEntryBytes, maximumBytes);
_maximumCount = maximumCount;
_maximumBytes = maximumBytes;
_maximumSingleEntryBytes = maximumSingleEntryBytes;
}
public void Requeue(ObjectMeshData data) => _queue.Enqueue(data); public bool Stage(ObjectMeshData data)
=> TryStage(data) == MeshStageResult.Staged;
public void Complete(ulong objectId) => _ownedIds.TryRemove(objectId, out _); public MeshStageResult TryStage(ObjectMeshData data)
{
ArgumentNullException.ThrowIfNull(data);
long bytes = ObjectMeshManager.EstimateUploadBytes(data);
lock (_gate)
{
if (_generationById.ContainsKey(data.ObjectId))
return MeshStageResult.AlreadyStaged;
if (bytes > _maximumSingleEntryBytes)
throw new NotSupportedException(
$"Mesh 0x{data.ObjectId:X10} requires {bytes:N0} staged bytes; "
+ $"the supported per-object maximum is {_maximumSingleEntryBytes:N0} bytes.");
// Permit one explicitly bounded oversized head item so unusual
// content cannot starve. Every other producer observes the strict
// count/byte watermark; active decoders retain their result in the
// bounded CPU cache and retry staging after the consumer drains.
if (_generationById.Count != 0
&& (_generationById.Count >= _maximumCount
|| bytes > _maximumBytes - Math.Min(_claimedBytes, _maximumBytes)))
{
return MeshStageResult.HighWater;
}
ulong generation = checked(++_nextGeneration);
_generationById.Add(data.ObjectId, generation);
data.UploadAttempts = 0;
_queue.Enqueue(new Entry(data, bytes, generation));
_queuedBytes = checked(_queuedBytes + bytes);
_claimedBytes = checked(_claimedBytes + bytes);
return MeshStageResult.Staged;
}
}
public bool TryDequeue(out MeshUploadQueueItem item)
{
lock (_gate)
{
if (!_queue.TryDequeue(out Entry entry))
{
item = default;
return false;
}
_queuedBytes -= entry.Bytes;
item = entry.Item;
return true;
}
}
public bool TryPeek(out MeshUploadQueueItem item)
{
lock (_gate)
{
if (!_queue.TryPeek(out Entry entry))
{
item = default;
return false;
}
item = entry.Item;
return true;
}
}
public int Count { get { lock (_gate) return _queue.Count; } }
public int ClaimCount { get { lock (_gate) return _generationById.Count; } }
public long QueuedBytes { get { lock (_gate) return _queuedBytes; } }
public long ClaimedBytes { get { lock (_gate) return _claimedBytes; } }
public bool IsAtHighWater
{
get
{
lock (_gate)
return _generationById.Count >= _maximumCount || _claimedBytes >= _maximumBytes;
}
}
public void Requeue(MeshUploadQueueItem item)
{
ArgumentNullException.ThrowIfNull(item.Data);
lock (_gate)
{
ValidateCurrentGeneration(item);
if (item.SourceBytes > _maximumSingleEntryBytes)
throw new InvalidOperationException("Cannot retry mesh data after staging ownership completed.");
_queue.Enqueue(new Entry(item.Data, item.SourceBytes, item.Generation));
_queuedBytes = checked(_queuedBytes + item.SourceBytes);
}
}
public void Complete(MeshUploadQueueItem item)
{
lock (_gate)
{
ValidateCurrentGeneration(item);
_generationById.Remove(item.Data.ObjectId);
_claimedBytes = checked(_claimedBytes - item.SourceBytes);
}
}
/// <summary>
/// Completes a dequeued, now-unowned generation and atomically hands the
/// same CPU payload to an owner that raced in while it was dequeued. A
/// concurrent cache hit either sees the old staging claim and this method
/// requeues, or runs after the claim is removed and stages for itself; the
/// mesh cannot fall through the gap between those operations.
/// </summary>
public bool CompleteOrRestageIfOwned(
MeshUploadQueueItem item,
MeshOwnershipCounter ownership)
{
ArgumentNullException.ThrowIfNull(item.Data);
ArgumentNullException.ThrowIfNull(ownership);
lock (_gate)
{
ValidateCurrentGeneration(item);
_generationById.Remove(item.Data.ObjectId);
_claimedBytes = checked(_claimedBytes - item.SourceBytes);
if (!ownership.IsOwned(item.Data.ObjectId))
return false;
ulong generation = checked(++_nextGeneration);
_generationById.Add(item.Data.ObjectId, generation);
item.Data.UploadAttempts = 0;
_queue.Enqueue(new Entry(item.Data, item.SourceBytes, generation));
_queuedBytes = checked(_queuedBytes + item.SourceBytes);
_claimedBytes = checked(_claimedBytes + item.SourceBytes);
return true;
}
}
public int DiscardUnownedPrefix(MeshOwnershipCounter ownership, int maximum)
{
ArgumentNullException.ThrowIfNull(ownership);
ArgumentOutOfRangeException.ThrowIfNegative(maximum);
int discarded = 0;
lock (_gate)
{
while (discarded < maximum
&& _queue.TryPeek(out Entry entry)
&& !ownership.IsOwned(entry.Data.ObjectId))
{
_queue.Dequeue();
_queuedBytes -= entry.Bytes;
if (_generationById.TryGetValue(entry.Data.ObjectId, out ulong generation)
&& generation == entry.Generation)
{
_generationById.Remove(entry.Data.ObjectId);
_claimedBytes = checked(_claimedBytes - entry.Bytes);
}
discarded++;
}
}
return discarded;
}
private void ValidateCurrentGeneration(MeshUploadQueueItem item)
{
if (!_generationById.TryGetValue(item.Data.ObjectId, out ulong current)
|| current != item.Generation)
{
throw new InvalidOperationException(
$"Stale mesh-upload generation {item.Generation} for 0x{item.Data.ObjectId:X10}; current={current}.");
}
}
} }
/// <summary> /// <summary>
@ -63,46 +265,74 @@ internal sealed class MeshOwnershipCounter
internal sealed class CpuMeshUploadCache internal sealed class CpuMeshUploadCache
{ {
private readonly Dictionary<ulong, ObjectMeshData> _data = new(); private readonly Dictionary<ulong, ObjectMeshData> _data = new();
private readonly Dictionary<ulong, long> _bytesById = new();
private readonly LinkedList<ulong> _lru = new(); private readonly LinkedList<ulong> _lru = new();
private readonly int _capacity; private readonly int _capacity;
private readonly long _byteCapacity;
private long _residentBytes;
public CpuMeshUploadCache(int capacity) public CpuMeshUploadCache(int capacity, long byteCapacity = 128L * 1024 * 1024)
{ {
if (capacity <= 0) if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity)); throw new ArgumentOutOfRangeException(nameof(capacity));
ArgumentOutOfRangeException.ThrowIfLessThan(byteCapacity, 1);
_capacity = capacity; _capacity = capacity;
_byteCapacity = byteCapacity;
} }
internal int Count { get { lock (_data) return _data.Count; } }
internal long ResidentBytes { get { lock (_data) return _residentBytes; } }
public bool TryGetAndStage( public bool TryGetAndStage(
ulong id, ulong id,
MeshUploadStagingQueue staging, MeshUploadStagingQueue staging,
out ObjectMeshData? data) out ObjectMeshData? data,
out MeshStageResult stageResult)
{ {
lock (_data) lock (_data)
{ {
if (!_data.TryGetValue(id, out data)) if (!_data.TryGetValue(id, out data)) {
stageResult = default;
return false; return false;
}
_lru.Remove(id); _lru.Remove(id);
_lru.AddLast(id); _lru.AddLast(id);
staging.Stage(data); stageResult = staging.TryStage(data);
return true; return true;
} }
} }
public void Store(ObjectMeshData data) public bool Store(ObjectMeshData data)
{ {
ArgumentNullException.ThrowIfNull(data);
long bytes = ObjectMeshManager.EstimateUploadBytes(data);
// Reject before touching the replacement/LRU state. The prior loop
// evicted everything and then published one arbitrarily large entry,
// allowing the cache-hit path to replay an unbounded payload forever.
if (bytes > _byteCapacity)
return false;
lock (_data) lock (_data)
{ {
if (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity) if (_bytesById.Remove(data.ObjectId, out long replacedBytes))
_residentBytes -= replacedBytes;
while (_lru.Count != 0
&& (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity
|| (_data.Count != 0 && bytes > _byteCapacity - _residentBytes)))
{ {
ulong oldest = _lru.First!.Value; ulong oldest = _lru.First!.Value;
_lru.RemoveFirst(); _lru.RemoveFirst();
_data.Remove(oldest); _data.Remove(oldest);
if (_bytesById.Remove(oldest, out long oldestBytes))
_residentBytes -= oldestBytes;
} }
_data[data.ObjectId] = data; _data[data.ObjectId] = data;
_bytesById[data.ObjectId] = bytes;
_residentBytes = checked(_residentBytes + bytes);
_lru.Remove(data.ObjectId); _lru.Remove(data.ObjectId);
_lru.AddLast(data.ObjectId); _lru.AddLast(data.ObjectId);
return true;
} }
} }
@ -111,7 +341,9 @@ internal sealed class CpuMeshUploadCache
lock (_data) lock (_data)
{ {
_data.Clear(); _data.Clear();
_bytesById.Clear();
_lru.Clear(); _lru.Clear();
_residentBytes = 0;
} }
} }
} }

View file

@ -0,0 +1,186 @@
namespace AcDream.App.Rendering.Wb;
internal readonly record struct MeshUploadCost(
long SourceBytes,
long ArrayAllocationBytes,
long MipmapBytes,
int NewArrayCount,
long BufferUploadBytes = 0,
long BufferAllocationBytes = 0,
long BufferCopyBytes = 0,
int NewBufferCount = 0,
ulong AdmissionKey = 0);
internal readonly record struct MeshUploadBudgetLimits(
int MaximumObjects,
long MaximumSourceBytes,
long MaximumArrayAllocationBytes,
long MaximumMipmapBytes,
int MaximumNewArrays,
long MaximumBufferUploadBytes = long.MaxValue,
long MaximumBufferAllocationBytes = long.MaxValue,
long MaximumBufferCopyBytes = long.MaxValue,
int MaximumNewBuffers = int.MaxValue,
long MaximumSingleSourceBytes = long.MaxValue,
long MaximumSingleArrayAllocationBytes = long.MaxValue,
long MaximumSingleMipmapBytes = long.MaxValue,
int MaximumSingleNewArrays = int.MaxValue,
long MaximumSingleBufferUploadBytes = long.MaxValue);
/// <summary>
/// Pure prefix-admission policy for render-thread mesh uploads. Ordinary
/// uploads fit one frame in every independent CPU/GPU dimension. A physically
/// indivisible FIFO head may exceed a soft frame budget only when it is below
/// the explicit single-operation ceiling; it runs immediately rather than
/// accumulating fictional credits across idle frames. Global-buffer growth
/// and prefix copies are never admitted here: they are real, chunked
/// maintenance operations driven by <see cref="GlobalMeshBuffer"/>.
/// </summary>
internal sealed class MeshUploadFrameBudget
{
private readonly MeshUploadBudgetLimits _limits;
public MeshUploadFrameBudget(MeshUploadBudgetLimits limits)
{
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumObjects, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSourceBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumArrayAllocationBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumMipmapBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumNewArrays, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferUploadBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferAllocationBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferCopyBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumNewBuffers, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleSourceBytes, limits.MaximumSourceBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleArrayAllocationBytes, limits.MaximumArrayAllocationBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleMipmapBytes, limits.MaximumMipmapBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleNewArrays, limits.MaximumNewArrays);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleBufferUploadBytes, limits.MaximumBufferUploadBytes);
_limits = limits;
}
public int ObjectCount { get; private set; }
public long SourceBytes { get; private set; }
public long ArrayAllocationBytes { get; private set; }
public long MipmapBytes { get; private set; }
public int NewArrayCount { get; private set; }
public long BufferUploadBytes { get; private set; }
public long BufferAllocationBytes { get; private set; }
public long BufferCopyBytes { get; private set; }
public int NewBufferCount { get; private set; }
public void Reset()
{
ObjectCount = 0;
SourceBytes = 0;
ArrayAllocationBytes = 0;
MipmapBytes = 0;
NewArrayCount = 0;
BufferUploadBytes = 0;
BufferAllocationBytes = 0;
BufferCopyBytes = 0;
NewBufferCount = 0;
}
public bool TryAdmit(MeshUploadCost cost)
{
Validate(cost);
if (cost.BufferAllocationBytes != 0
|| cost.BufferCopyBytes != 0
|| cost.NewBufferCount != 0)
{
throw new InvalidOperationException(
"Global-buffer allocation/copy work must be progressed as real maintenance before upload admission.");
}
bool oversizedHead = ObjectCount == 0 && ExceedsSingleFrame(cost);
if (oversizedHead)
{
if (cost.SourceBytes > _limits.MaximumSingleSourceBytes
|| cost.ArrayAllocationBytes > _limits.MaximumSingleArrayAllocationBytes
|| cost.MipmapBytes > _limits.MaximumSingleMipmapBytes
|| cost.NewArrayCount > _limits.MaximumSingleNewArrays
|| cost.BufferUploadBytes > _limits.MaximumSingleBufferUploadBytes)
{
throw new NotSupportedException(
$"Mesh upload generation {cost.AdmissionKey} exceeds the supported single-operation ceiling.");
}
Admit(cost);
return true;
}
if (ObjectCount >= _limits.MaximumObjects
|| WouldExceed(SourceBytes, cost.SourceBytes, _limits.MaximumSourceBytes)
|| WouldExceed(ArrayAllocationBytes, cost.ArrayAllocationBytes, _limits.MaximumArrayAllocationBytes)
|| WouldExceed(MipmapBytes, cost.MipmapBytes, _limits.MaximumMipmapBytes)
|| cost.NewArrayCount > _limits.MaximumNewArrays - NewArrayCount
|| WouldExceed(BufferUploadBytes, cost.BufferUploadBytes, _limits.MaximumBufferUploadBytes)
|| WouldExceed(BufferAllocationBytes, cost.BufferAllocationBytes, _limits.MaximumBufferAllocationBytes)
|| WouldExceed(BufferCopyBytes, cost.BufferCopyBytes, _limits.MaximumBufferCopyBytes)
|| cost.NewBufferCount > _limits.MaximumNewBuffers - NewBufferCount)
{
return false;
}
Admit(cost);
return true;
}
/// <summary>Records work that actually executed this frame.</summary>
public void RecordBufferMaintenance(long allocationBytes, long copyBytes, int newBufferCount)
{
ArgumentOutOfRangeException.ThrowIfNegative(allocationBytes);
ArgumentOutOfRangeException.ThrowIfNegative(copyBytes);
ArgumentOutOfRangeException.ThrowIfNegative(newBufferCount);
if (WouldExceed(BufferAllocationBytes, allocationBytes, _limits.MaximumBufferAllocationBytes)
|| WouldExceed(BufferCopyBytes, copyBytes, _limits.MaximumBufferCopyBytes)
|| newBufferCount > _limits.MaximumNewBuffers - NewBufferCount)
{
throw new InvalidOperationException(
"Executed global-buffer maintenance exceeded its real per-frame bound.");
}
BufferAllocationBytes = checked(BufferAllocationBytes + allocationBytes);
BufferCopyBytes = checked(BufferCopyBytes + copyBytes);
NewBufferCount = checked(NewBufferCount + newBufferCount);
}
private bool ExceedsSingleFrame(MeshUploadCost cost) =>
cost.SourceBytes > _limits.MaximumSourceBytes
|| cost.ArrayAllocationBytes > _limits.MaximumArrayAllocationBytes
|| cost.MipmapBytes > _limits.MaximumMipmapBytes
|| cost.NewArrayCount > _limits.MaximumNewArrays
|| cost.BufferUploadBytes > _limits.MaximumBufferUploadBytes
|| cost.BufferAllocationBytes > _limits.MaximumBufferAllocationBytes
|| cost.BufferCopyBytes > _limits.MaximumBufferCopyBytes
|| cost.NewBufferCount > _limits.MaximumNewBuffers;
private void Admit(MeshUploadCost cost)
{
ObjectCount++;
SourceBytes = checked(SourceBytes + cost.SourceBytes);
ArrayAllocationBytes = checked(ArrayAllocationBytes + cost.ArrayAllocationBytes);
MipmapBytes = checked(MipmapBytes + cost.MipmapBytes);
NewArrayCount = checked(NewArrayCount + cost.NewArrayCount);
BufferUploadBytes = checked(BufferUploadBytes + cost.BufferUploadBytes);
BufferAllocationBytes = checked(BufferAllocationBytes + cost.BufferAllocationBytes);
BufferCopyBytes = checked(BufferCopyBytes + cost.BufferCopyBytes);
NewBufferCount = checked(NewBufferCount + cost.NewBufferCount);
}
// Keep the comparison overflow-free even when a bounded indivisible head
// has truthfully exceeded a soft per-frame budget.
private static bool WouldExceed(long current, long added, long maximum) =>
added > maximum - Math.Min(current, maximum);
private static void Validate(MeshUploadCost cost)
{
ArgumentOutOfRangeException.ThrowIfNegative(cost.SourceBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.ArrayAllocationBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.MipmapBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.NewArrayCount);
ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferUploadBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferAllocationBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferCopyBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.NewBufferCount);
}
}

File diff suppressed because it is too large Load diff

View file

@ -22,17 +22,43 @@ namespace AcDream.App.Rendering.Wb {
public unsafe class OpenGLGraphicsDevice : BaseGraphicsDevice { public unsafe class OpenGLGraphicsDevice : BaseGraphicsDevice {
private readonly ILogger _log; private readonly ILogger _log;
private readonly DebugRenderSettings _renderSettings; private readonly DebugRenderSettings _renderSettings;
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue _resourceRetirement;
public GL GL { get; } public GL GL { get; }
public DebugRenderSettings RenderSettings => _renderSettings; public DebugRenderSettings RenderSettings => _renderSettings;
private readonly ConcurrentQueue<Action<GL>> _glThreadQueue = new(); private readonly ConcurrentQueue<Action<GL>> _glThreadQueue = new();
private readonly ConcurrentQueue<Action<GL>> _nextGlThreadQueue = new();
internal bool HasPendingGLWork =>
!_glThreadQueue.IsEmpty || !_nextGlThreadQueue.IsEmpty;
public void QueueGLAction(Action<GL> action) { public void QueueGLAction(Action<GL> action) {
_glThreadQueue.Enqueue(action); _glThreadQueue.Enqueue(action);
} }
internal void QueueGLActionForNextPass(Action<GL> action) {
ArgumentNullException.ThrowIfNull(action);
_nextGlThreadQueue.Enqueue(action);
}
public void ProcessGLQueue() { public void ProcessGLQueue() {
// Retry the prior pass before ordinary work (notably sampler
// deletion), but process only the captured generation so a
// persistent driver failure cannot spin this frame forever.
int retryCount = _nextGlThreadQueue.Count;
for (int i = 0; i < retryCount && _nextGlThreadQueue.TryDequeue(out Action<GL>? retry); i++) {
try {
retry(GL);
} catch (Exception ex) {
_log.LogError(ex, "Error processing retryable GL queue action");
}
}
// Normal actions retain drain-to-empty semantics because teardown
// actions intentionally enqueue dependent atlas releases here.
// A persistent retryable error must not starve unrelated uploads
// and releases forever: the retry generation remains bounded to
// one attempt per pass, while ordinary work still makes progress.
while (_glThreadQueue.TryDequeue(out var action)) { while (_glThreadQueue.TryDequeue(out var action)) {
try { try {
action(GL); action(GL);
@ -61,6 +87,7 @@ namespace AcDream.App.Rendering.Wb {
public uint WrapSampler { get; private set; } public uint WrapSampler { get; private set; }
/// <summary>OpenGL sampler object with TextureWrapMode.ClampToEdge (for meshes without wrapping UVs).</summary> /// <summary>OpenGL sampler object with TextureWrapMode.ClampToEdge (for meshes without wrapping UVs).</summary>
public uint ClampSampler { get; private set; } public uint ClampSampler { get; private set; }
internal float MaxSupportedAnisotropy { get; private set; }
private ManagedGLUniformBuffer? _sceneDataBuffer; private ManagedGLUniformBuffer? _sceneDataBuffer;
/// <summary>Shared SceneData UBO.</summary> /// <summary>Shared SceneData UBO.</summary>
@ -83,12 +110,23 @@ namespace AcDream.App.Rendering.Wb {
protected OpenGLGraphicsDevice() : base() { protected OpenGLGraphicsDevice() : base() {
_log = null!; _log = null!;
_renderSettings = null!; _renderSettings = null!;
_resourceRetirement = null!;
GL = null!; GL = null!;
} }
public OpenGLGraphicsDevice(GL gl, ILogger log, DebugRenderSettings renderSettings, bool allowBindless = true) : base() { public OpenGLGraphicsDevice(GL gl, ILogger log, DebugRenderSettings renderSettings, bool allowBindless = true)
: this(gl, log, renderSettings, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance, allowBindless) {
}
internal OpenGLGraphicsDevice(
GL gl,
ILogger log,
DebugRenderSettings renderSettings,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
bool allowBindless = true) : base() {
_log = log; _log = log;
_renderSettings = renderSettings; _renderSettings = renderSettings;
_resourceRetirement = resourceRetirement ?? throw new ArgumentNullException(nameof(resourceRetirement));
GL = gl; GL = gl;
GLHelpers.Init(this, log); GLHelpers.Init(this, log);
@ -114,26 +152,30 @@ namespace AcDream.App.Rendering.Wb {
GL.GenBuffers(1, out uint instanceVbo); GL.GenBuffers(1, out uint instanceVbo);
InstanceVBO = instanceVbo; InstanceVBO = instanceVbo;
// Query this immutable device limit once. Atlas construction can
// happen hundreds of times during portal streaming; repeating a
// driver GetFloat for every texture serialized the upload burst.
if (renderSettings.EnableAnisotropicFiltering) {
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso);
MaxSupportedAnisotropy = Math.Max(0f, maxAniso);
}
// Create sampler objects for wrap vs clamp // Create sampler objects for wrap vs clamp
WrapSampler = GL.GenSampler(); WrapSampler = GL.GenSampler();
GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.Repeat); GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.Repeat);
GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.Repeat); GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.Repeat);
GL.SamplerParameter(WrapSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear); GL.SamplerParameter(WrapSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear);
GL.SamplerParameter(WrapSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear); GL.SamplerParameter(WrapSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear);
if (renderSettings.EnableAnisotropicFiltering) { if (MaxSupportedAnisotropy > 0)
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso); GL.SamplerParameter(WrapSampler, GLEnum.TextureMaxAnisotropy, MaxSupportedAnisotropy);
if (maxAniso > 0) GL.SamplerParameter(WrapSampler, GLEnum.TextureMaxAnisotropy, maxAniso);
}
ClampSampler = GL.GenSampler(); ClampSampler = GL.GenSampler();
GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.ClampToEdge); GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.ClampToEdge);
GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.ClampToEdge); GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.ClampToEdge);
GL.SamplerParameter(ClampSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear); GL.SamplerParameter(ClampSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear);
GL.SamplerParameter(ClampSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear); GL.SamplerParameter(ClampSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear);
if (renderSettings.EnableAnisotropicFiltering) { if (MaxSupportedAnisotropy > 0)
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso); GL.SamplerParameter(ClampSampler, GLEnum.TextureMaxAnisotropy, MaxSupportedAnisotropy);
if (maxAniso > 0) GL.SamplerParameter(ClampSampler, GLEnum.TextureMaxAnisotropy, maxAniso);
}
_sceneDataBuffer = new ManagedGLUniformBuffer(this, BufferUsage.Dynamic, Marshal.SizeOf<SceneData>()); _sceneDataBuffer = new ManagedGLUniformBuffer(this, BufferUsage.Dynamic, Marshal.SizeOf<SceneData>());
@ -145,6 +187,15 @@ namespace AcDream.App.Rendering.Wb {
ParticleBatcher = null!; ParticleBatcher = null!;
} }
/// <summary>
/// Retires a GL resource only after every submitted draw that could
/// reference it has completed on the GPU.
/// </summary>
internal void RetireGpuResource(Action release) => _resourceRetirement.Retire(release);
internal AcDream.App.Rendering.IGpuResourceRetirementQueue ResourceRetirement =>
_resourceRetirement;
private void InitializeSharedDebugResources() { private void InitializeSharedDebugResources() {
// Unit quad vertices for two triangles (0 to 1 for length, -0.5 to 0.5 for thickness) // Unit quad vertices for two triangles (0 to 1 for length, -0.5 to 0.5 for thickness)
float[] quadVertices = { float[] quadVertices = {
@ -608,14 +659,26 @@ namespace AcDream.App.Rendering.Wb {
GpuMemoryTracker.TrackDeallocation(instanceBufferCapacity * instanceBufferStride); GpuMemoryTracker.TrackDeallocation(instanceBufferCapacity * instanceBufferStride);
} }
} }
if (wrapSampler != 0) {
gl.DeleteSampler(wrapSampler);
}
if (clampSampler != 0) {
gl.DeleteSampler(clampSampler);
}
}); });
// Bindless texture-array retirements embed these samplers in their
// resident handles. Ordinary GL work must keep flowing when one
// retry is sick, but sampler deletion itself is dependency-ordered
// behind the retry queue. Requeue into the next generation (never
// the drain-to-empty ordinary queue) to remain one attempt/frame.
Action<GL>? deleteSamplersWhenSafe = null;
deleteSamplersWhenSafe = gl => {
if (!_nextGlThreadQueue.IsEmpty) {
QueueGLActionForNextPass(deleteSamplersWhenSafe!);
return;
}
if (wrapSampler != 0)
gl.DeleteSampler(wrapSampler);
if (clampSampler != 0)
gl.DeleteSampler(clampSampler);
};
QueueGLActionForNextPass(deleteSamplersWhenSafe);
InstanceVBO = 0; InstanceVBO = 0;
InstanceVBOPtr = null; InstanceVBOPtr = null;
WrapSampler = 0; WrapSampler = 0;

View file

@ -58,9 +58,11 @@ namespace AcDream.App.Rendering.Wb {
if (emitter.HwGfxObjId.DataId != 0) { if (emitter.HwGfxObjId.DataId != 0) {
_meshManager.IncrementRefCount(emitter.HwGfxObjId.DataId); _meshManager.IncrementRefCount(emitter.HwGfxObjId.DataId);
_meshManager.PrepareMeshDataAsync(emitter.HwGfxObjId.DataId, isSetup: false);
} }
if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) { if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) {
_meshManager.IncrementRefCount(emitter.GfxObjId.DataId); _meshManager.IncrementRefCount(emitter.GfxObjId.DataId);
_meshManager.PrepareMeshDataAsync(emitter.GfxObjId.DataId, isSetup: false);
} }
} }
@ -69,10 +71,12 @@ namespace AcDream.App.Rendering.Wb {
if (_gfxRenderData == null) { if (_gfxRenderData == null) {
var gfxId = _emitter.HwGfxObjId.DataId != 0 ? _emitter.HwGfxObjId.DataId : _emitter.GfxObjId.DataId; var gfxId = _emitter.HwGfxObjId.DataId != 0 ? _emitter.HwGfxObjId.DataId : _emitter.GfxObjId.DataId;
if (gfxId != 0) { if (gfxId != 0) {
_meshManager.PrepareMeshDataAsync(gfxId, isSetup: false);
_gfxRenderData = _meshManager.TryGetRenderData(gfxId); _gfxRenderData = _meshManager.TryGetRenderData(gfxId);
} }
} }
if (_textureRenderData == null && _emitter.GfxObjId.DataId != 0) { if (_textureRenderData == null && _emitter.GfxObjId.DataId != 0) {
_meshManager.PrepareMeshDataAsync(_emitter.GfxObjId.DataId, isSetup: false);
_textureRenderData = _meshManager.TryGetRenderData(_emitter.GfxObjId.DataId); _textureRenderData = _meshManager.TryGetRenderData(_emitter.GfxObjId.DataId);
} }

View file

@ -0,0 +1,135 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Per-resource progress for a teardown which must attempt every independent
/// release even when an earlier release fails. Successful operations are never
/// replayed. A backend which throws after changing ownership reports that fact
/// with <see cref="MeshReferenceMutationException.MutationCommitted"/> so the
/// ledger can preserve the physical outcome while still surfacing the error.
/// </summary>
internal sealed class RetryableResourceReleaseLedger
{
private readonly Entry[] _entries;
private int _remaining;
private bool _advancing;
public RetryableResourceReleaseLedger(IEnumerable<(string Name, Action Release)> entries)
{
ArgumentNullException.ThrowIfNull(entries);
_entries = entries
.Select(entry => new Entry(entry.Name, entry.Release))
.ToArray();
_remaining = _entries.Length;
}
public bool IsComplete => _remaining == 0;
public int RemainingCount => _remaining;
/// <summary>
/// Attempts every unfinished operation once. Ordinary exceptions retain
/// the operation for a later attempt; committed-outcome exceptions mark it
/// complete. Either kind remains in the returned diagnostics.
/// </summary>
public ResourceReleaseAttempt Advance()
{
// Release callbacks may synchronously drain their owner. The active
// top-level pass already owns every unfinished entry; a nested pass
// must not give a failed later entry a second attempt in this frame.
if (_advancing)
return new ResourceReleaseAttempt(0, 0, []);
_advancing = true;
List<ResourceReleaseFailure>? failures = null;
int attempted = 0;
int completed = 0;
try
{
for (int i = 0; i < _entries.Length; i++)
{
Entry entry = _entries[i];
if (entry.Completed || entry.Running)
continue;
attempted++;
entry.Running = true;
try
{
entry.Release();
entry.Completed = true;
_remaining--;
completed++;
}
catch (Exception error)
{
bool committed = error is MeshReferenceMutationException
{
MutationCommitted: true,
};
if (committed)
{
entry.Completed = true;
_remaining--;
completed++;
}
(failures ??= []).Add(
new ResourceReleaseFailure(entry.Name, error, committed));
}
finally
{
entry.Running = false;
}
}
}
finally
{
_advancing = false;
}
return new ResourceReleaseAttempt(
attempted,
completed,
failures ?? []);
}
private sealed class Entry
{
public Entry(string name, Action release)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("A resource-release stage needs a name.", nameof(name));
ArgumentNullException.ThrowIfNull(release);
Name = name;
Release = release;
}
public string Name { get; }
public Action Release { get; }
public bool Completed { get; set; }
public bool Running { get; set; }
}
}
internal readonly record struct ResourceReleaseFailure(
string Stage,
Exception Error,
bool MutationCommitted);
internal readonly record struct ResourceReleaseAttempt(
int AttemptedCount,
int CompletedCount,
IReadOnlyList<ResourceReleaseFailure> Failures)
{
public bool HasFailures => Failures.Count != 0;
public AggregateException ToException(string message) =>
new(
message,
Failures.Select(failure =>
new InvalidOperationException(
$"Resource-release stage '{failure.Stage}' failed "
+ (failure.MutationCommitted
? "after committing its mutation."
: "before committing its mutation."),
failure.Error)));
}

View file

@ -6,8 +6,38 @@ using Silk.NET.OpenGL;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using PixelFormat = Silk.NET.OpenGL.PixelFormat; using PixelFormat = Silk.NET.OpenGL.PixelFormat;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb { namespace AcDream.App.Rendering.Wb {
internal sealed class TextureAtlasDisposeTransaction {
private bool _running;
public bool IsComplete { get; private set; }
public bool IsRunning => _running;
public void Advance(
Action retryLayerRetirements,
Action disposeTextureArray,
Action commitLogicalDisposal) {
ArgumentNullException.ThrowIfNull(retryLayerRetirements);
ArgumentNullException.ThrowIfNull(disposeTextureArray);
ArgumentNullException.ThrowIfNull(commitLogicalDisposal);
if (IsComplete || _running)
return;
_running = true;
try {
retryLayerRetirements();
disposeTextureArray();
commitLogicalDisposal();
IsComplete = true;
}
finally {
_running = false;
}
}
}
/// <summary> /// <summary>
/// Manages texture arrays grouped by (Width, Height, Format). /// Manages texture arrays grouped by (Width, Height, Format).
/// Deduplicates textures by a TextureKey and supports reference counting. /// Deduplicates textures by a TextureKey and supports reference counting.
@ -20,41 +50,99 @@ namespace AcDream.App.Rendering.Wb {
private readonly TextureFormat _format; private readonly TextureFormat _format;
private readonly Dictionary<TextureKey, int> _textureIndices = new(); private readonly Dictionary<TextureKey, int> _textureIndices = new();
private readonly Dictionary<int, int> _refCounts = new(); private readonly Dictionary<int, int> _refCounts = new();
private readonly Stack<int> _freeSlots = new(); private readonly TextureAtlasSlotAllocator _slots;
private int _nextIndex = 0; private readonly TextureAtlasLayerRetirement _layerRetirement;
private const int InitialCapacity = 32; private readonly TextureAtlasDisposeTransaction _disposeTransaction = new();
private readonly Action<TextureAtlasManager>? _onGpuSafeEmpty;
private bool _disposed;
internal const long TargetArrayBytes = 8L * 1024 * 1024;
internal const int MaximumArrayLayers = 32;
public uint Slot { get; } public uint Slot { get; }
public ManagedGLTextureArray TextureArray { get; private set; } = null!; public ManagedGLTextureArray TextureArray { get; private set; } = null!;
public int UsedSlots => _textureIndices.Count; public int UsedSlots => _textureIndices.Count;
public int TotalSlots => TextureArray?.Size ?? InitialCapacity; public int TotalSlots => TextureArray?.Size ?? 0;
public int FreeSlots => TotalSlots - UsedSlots; public int AvailableSlots => _slots.AvailableCount;
internal bool IsGpuSafeEmpty => UsedSlots == 0 && AvailableSlots == TotalSlots;
internal long AllocatedBytes {
get {
return TextureArray.TotalSizeInBytes;
}
}
internal long LastUseSequence { get; set; }
internal int Width => _textureWidth;
internal int Height => _textureHeight;
internal TextureFormat Format => _format;
public TextureAtlasManager(OpenGLGraphicsDevice graphicsDevice, int width, int height, TextureFormat format = TextureFormat.RGBA8) { public TextureAtlasManager(
OpenGLGraphicsDevice graphicsDevice,
int width,
int height,
TextureFormat format = TextureFormat.RGBA8,
Action<TextureAtlasManager>? onGpuSafeEmpty = null) {
Slot = _nextSlot++; Slot = _nextSlot++;
_graphicsDevice = graphicsDevice; _graphicsDevice = graphicsDevice;
_textureWidth = width; _textureWidth = width;
_textureHeight = height; _textureHeight = height;
_format = format; _format = format;
TextureArray = (ManagedGLTextureArray)graphicsDevice.CreateTextureArrayInternal(format, width, height, InitialCapacity, TextureParameters.ClampToEdge); _onGpuSafeEmpty = onGpuSafeEmpty;
_layerRetirement = new TextureAtlasLayerRetirement(graphicsDevice.ResourceRetirement);
int capacity = CalculateInitialCapacity(width, height, format);
TextureArray = (ManagedGLTextureArray)graphicsDevice.CreateTextureArrayInternal(format, width, height, capacity, TextureParameters.ClampToEdge);
_slots = new TextureAtlasSlotAllocator(TextureArray.Size);
} }
/// <summary>
/// Keeps each physical array near an eight-MiB target instead of
/// reserving 32 layers for every size class. The old fixed capacity
/// made a single 1024x1024 RGBA texture allocate roughly 171 MiB once
/// its mip chain was included, and made glGenerateMipmap process all
/// 32 layers during destination streaming.
/// </summary>
internal static int CalculateInitialCapacity(int width, int height, TextureFormat format) {
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
long bytesPerLayer = CalculateMipChainBytes(width, height, format);
long targetLayers = Math.Max(1L, TargetArrayBytes / bytesPerLayer);
return checked((int)Math.Min(targetLayers, MaximumArrayLayers));
}
internal static long CalculateMipChainBytes(int width, int height, TextureFormat format) {
long total = 0;
int w = width;
int h = height;
while (true) {
total = checked(total + CalculateLevelBytes(w, h, format));
if (w == 1 && h == 1) return total;
w = Math.Max(1, w >> 1);
h = Math.Max(1, h >> 1);
}
}
internal static long CalculateArrayBytes(int width, int height, TextureFormat format) =>
checked(CalculateMipChainBytes(width, height, format)
* CalculateInitialCapacity(width, height, format));
internal static long CalculateLevelBytes(int width, int height, TextureFormat format) => format switch {
TextureFormat.RGBA8 => checked((long)width * height * 4L),
TextureFormat.RGB8 => checked((long)width * height * 3L),
TextureFormat.A8 => checked((long)width * height),
TextureFormat.Rgba32f => checked((long)width * height * 16L),
TextureFormat.DXT1 => checked((long)Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 8L),
TextureFormat.DXT3 or TextureFormat.DXT5 => checked((long)Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 16L),
_ => throw new NotSupportedException($"Unsupported texture-atlas format {format}.")
};
public int AddTexture(TextureKey key, byte[] data, PixelFormat? uploadPixelFormat = null, PixelType? uploadPixelType = null) { public int AddTexture(TextureKey key, byte[] data, PixelFormat? uploadPixelFormat = null, PixelType? uploadPixelType = null) {
ObjectDisposedException.ThrowIf(_disposed || _disposeTransaction.IsRunning, this);
_layerRetirement.RetryPendingPublications();
if (_textureIndices.TryGetValue(key, out var existingIndex)) { if (_textureIndices.TryGetValue(key, out var existingIndex)) {
_refCounts[existingIndex]++; _refCounts[existingIndex]++;
return existingIndex; return existingIndex;
} }
int index; int index = _slots.Rent();
if (_freeSlots.Count > 0) {
index = _freeSlots.Pop();
}
else {
index = _nextIndex++;
if (index >= TextureArray.Size) {
throw new Exception($"Texture atlas is full! {TextureArray.Size} / {_nextIndex} used.");
}
}
try { try {
TextureArray.UpdateLayer(index, data, uploadPixelFormat, uploadPixelType); TextureArray.UpdateLayer(index, data, uploadPixelFormat, uploadPixelType);
@ -63,14 +151,15 @@ namespace AcDream.App.Rendering.Wb {
return index; return index;
} }
catch (Exception) { catch (Exception) {
if (!_textureIndices.ContainsKey(key)) { if (!_textureIndices.ContainsKey(key))
_freeSlots.Push(index); _slots.Return(index);
}
throw; throw;
} }
} }
public void ReleaseTexture(TextureKey key) { public void ReleaseTexture(TextureKey key) {
ObjectDisposedException.ThrowIf(_disposed || _disposeTransaction.IsRunning, this);
_layerRetirement.RetryPendingPublications();
if (!_textureIndices.TryGetValue(key, out var index)) return; if (!_textureIndices.TryGetValue(key, out var index)) return;
if (!_refCounts.ContainsKey(index)) return; if (!_refCounts.ContainsKey(index)) return;
@ -79,21 +168,74 @@ namespace AcDream.App.Rendering.Wb {
if (_refCounts[index] <= 0) { if (_refCounts[index] <= 0) {
_textureIndices.Remove(key); _textureIndices.Remove(key);
_refCounts.Remove(index); _refCounts.Remove(index);
_freeSlots.Push(index); // The CPU no longer references this layer, but previously
TextureArray?.RemoveLayer(index); // submitted draws may still sample it. Recycle the slot only
// after their GPU fence has signaled; no clear or whole-array
// mip regeneration is needed for an unreferenced layer.
_layerRetirement.Retire(
() => {
if (!_disposed)
_slots.Return(index);
},
() => {
if (!_disposed && IsGpuSafeEmpty)
_onGpuSafeEmpty?.Invoke(this);
});
} }
} }
internal void RetryPendingRetirements() =>
_layerRetirement.RetryPendingPublications();
public bool HasTexture(TextureKey key) => _textureIndices.ContainsKey(key); public bool HasTexture(TextureKey key) => _textureIndices.ContainsKey(key);
public int GetTextureIndex(TextureKey key) => public int GetTextureIndex(TextureKey key) =>
_textureIndices.TryGetValue(key, out var index) ? index : -1; _textureIndices.TryGetValue(key, out var index) ? index : -1;
public void Dispose() { public void Dispose() {
TextureArray?.Dispose(); if (_disposed) return;
_textureIndices.Clear(); _disposeTransaction.Advance(
_refCounts.Clear(); _layerRetirement.RetryPendingPublications,
_freeSlots.Clear(); () => {
TextureArray?.Dispose();
if (TextureArray is not null && !TextureArray.HasDurableDisposeOwnership)
throw new InvalidOperationException(
"Texture-array disposal returned without retaining or publishing its physical release.");
},
() => {
_textureIndices.Clear();
_refCounts.Clear();
_disposed = true;
});
} }
} }
/// <summary>
/// Owns the publication and callback cursor for returned atlas layers.
/// Removing the logical texture key commits immediately; this owner keeps
/// the physical layer reachable until the frame queue accepts it and keeps
/// the empty-atlas observer separate from the slot return so it cannot make
/// a callback retry return the same slot twice.
/// </summary>
internal sealed class TextureAtlasLayerRetirement
{
private readonly GpuRetirementLedger _ledger;
public TextureAtlasLayerRetirement(IGpuResourceRetirementQueue queue) =>
_ledger = new GpuRetirementLedger(queue);
internal int AwaitingPublicationCount => _ledger.AwaitingPublicationCount;
public void Retire(Action returnLayer, Action notifyGpuSafeEmpty)
{
ArgumentNullException.ThrowIfNull(returnLayer);
ArgumentNullException.ThrowIfNull(notifyGpuSafeEmpty);
_ledger.Retire(new RetryableGpuResourceRelease(
returnLayer,
notifyGpuSafeEmpty));
}
public void RetryPendingPublications() =>
_ledger.RetryPendingPublications();
}
} }

View file

@ -0,0 +1,55 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Owns the physical layer slots in one texture array. A released texture
/// remains rented until the GPU-retirement callback returns its layer, so
/// <see cref="AvailableCount"/> never advertises a CPU-unreferenced layer
/// that an in-flight draw may still sample.
/// </summary>
internal sealed class TextureAtlasSlotAllocator
{
private readonly bool[] _rented;
private readonly Stack<int> _returned = new();
private int _next;
public TextureAtlasSlotAllocator(int capacity)
{
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
_rented = new bool[capacity];
}
public int AvailableCount => _returned.Count + (_rented.Length - _next);
public int Rent()
{
int slot;
if (_returned.Count != 0)
{
slot = _returned.Pop();
}
else
{
if (_next == _rented.Length)
throw new InvalidOperationException(
$"Texture atlas has no GPU-safe layer available ({_rented.Length} layers)."
);
slot = _next++;
}
if (_rented[slot])
throw new InvalidOperationException($"Texture atlas layer {slot} was rented twice.");
_rented[slot] = true;
return slot;
}
public void Return(int slot)
{
ArgumentOutOfRangeException.ThrowIfNegative(slot);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(slot, _next);
if (!_rented[slot])
throw new InvalidOperationException($"Texture atlas layer {slot} was returned twice.");
_rented[slot] = false;
_returned.Push(slot);
}
}

View file

@ -0,0 +1,242 @@
using Silk.NET.OpenGL;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Always-on transaction boundary and accounting for raw dynamic GL objects.
/// Growth keeps the published CPU capacity unchanged until BufferData succeeds;
/// OpenGL leaves the previous data store intact when allocation reports an
/// error, so the caller can continue using the old capacity or unwind.
/// </summary>
internal static unsafe class TrackedGlResource
{
public static RetryableGpuResourceRelease CreateRetryableBufferDeletion(
GL gl,
uint buffer,
long capacityBytes,
string context)
{
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
return new RetryableGpuResourceRelease(
() => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"),
() =>
{
gl.DeleteBuffer(buffer);
// Per the GL error contract, a command which generates an
// error does not change object state. Keep mutation and its
// validation in one retryable stage so that failed deletion
// is issued again, while later accounting remains untouched.
GLHelpers.ThrowOnResourceError(gl, context);
},
() =>
{
if (capacityBytes != 0)
GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer);
},
() => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer));
}
public static RetryableGpuResourceRelease CreateRetryableVertexArrayDeletion(
GL gl,
uint vertexArray,
string context,
Action? trackDeallocation = null)
{
if (vertexArray == 0)
throw new ArgumentOutOfRangeException(nameof(vertexArray));
return new RetryableGpuResourceRelease(
() => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"),
() =>
{
gl.DeleteVertexArray(vertexArray);
GLHelpers.ThrowOnResourceError(gl, context);
},
trackDeallocation
?? (() => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO)));
}
public static void AllocateBufferStorage(
GL gl,
BufferTargetARB target,
uint buffer,
long previousBytes,
long newBytes,
BufferUsageARB usage,
string context)
=> AllocateBufferStorage(
gl,
(GLEnum)target,
buffer,
previousBytes,
newBytes,
(GLEnum)usage,
context);
public static void AllocateBufferStorage(
GL gl,
BufferTargetARB target,
uint buffer,
long previousBytes,
long newBytes,
BufferUsageARB usage,
void* data,
string context)
=> AllocateBufferStorage(
gl,
(GLEnum)target,
buffer,
previousBytes,
newBytes,
(GLEnum)usage,
data,
context);
public static uint CreateBuffer(GL gl, string context)
{
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
uint name = gl.GenBuffer();
try
{
if (name == 0)
throw new InvalidOperationException($"OpenGL returned buffer name zero. Context: {context}");
GLHelpers.ThrowOnResourceError(gl, context);
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
return name;
}
catch
{
if (name != 0)
gl.DeleteBuffer(name);
throw;
}
}
public static uint CreateVertexArray(GL gl, string context)
{
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
uint name = gl.GenVertexArray();
try
{
if (name == 0)
throw new InvalidOperationException($"OpenGL returned vertex-array name zero. Context: {context}");
GLHelpers.ThrowOnResourceError(gl, context);
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO);
return name;
}
catch
{
if (name != 0)
gl.DeleteVertexArray(name);
throw;
}
}
public static void AllocateBufferStorage(
GL gl,
GLEnum target,
uint buffer,
long previousBytes,
long newBytes,
GLEnum usage,
string context)
=> AllocateBufferStorage(
gl,
target,
buffer,
previousBytes,
newBytes,
usage,
null,
context);
public static void AllocateBufferStorage(
GL gl,
GLEnum target,
uint buffer,
long previousBytes,
long newBytes,
GLEnum usage,
void* data,
string context)
{
ArgumentOutOfRangeException.ThrowIfNegative(previousBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(newBytes, 1);
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.BindBuffer(target, buffer);
gl.BufferData(target, checked((nuint)newBytes), data, usage);
GLHelpers.ThrowOnResourceError(gl, context);
long delta = checked(newBytes - previousBytes);
if (delta > 0)
GpuMemoryTracker.TrackAllocation(delta, GpuResourceType.Buffer);
else if (delta < 0)
GpuMemoryTracker.TrackDeallocation(-delta, GpuResourceType.Buffer);
}
public static void UpdateBufferSubData(
GL gl,
BufferTargetARB target,
uint buffer,
nint byteOffset,
long byteCount,
void* data,
string context)
=> UpdateBufferSubData(
gl,
(GLEnum)target,
buffer,
byteOffset,
byteCount,
data,
context);
public static void UpdateBufferSubData(
GL gl,
GLEnum target,
uint buffer,
nint byteOffset,
long byteCount,
void* data,
string context)
{
ArgumentOutOfRangeException.ThrowIfNegative(byteOffset);
ArgumentOutOfRangeException.ThrowIfLessThan(byteCount, 1);
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
ArgumentNullException.ThrowIfNull(data);
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.BindBuffer(target, buffer);
gl.BufferSubData(target, byteOffset, checked((nuint)byteCount), data);
GLHelpers.ThrowOnResourceError(gl, context);
}
public static void DeleteBuffer(GL gl, uint buffer, long capacityBytes, string context)
{
if (buffer == 0)
return;
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.DeleteBuffer(buffer);
GLHelpers.ThrowOnResourceError(gl, context);
if (capacityBytes != 0)
GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
}
public static void DeleteVertexArray(GL gl, uint vertexArray, string context)
{
if (vertexArray == 0)
return;
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.DeleteVertexArray(vertexArray);
GLHelpers.ThrowOnResourceError(gl, context);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO);
}
}

File diff suppressed because it is too large Load diff

View file

@ -17,18 +17,56 @@ namespace AcDream.App.Rendering.Wb;
/// so the rest of the renderer doesn't need to know about WB's types directly. /// so the rest of the renderer doesn't need to know about WB's types directly.
/// ///
/// <para> /// <para>
/// As of Phase O-T7, all DAT I/O routes through <see cref="DatCollectionAdapter"/> /// As of Phase O-T7, all DAT I/O routes through the runtime-owned shared
/// (backed by our shared <see cref="DatCollection"/>) — the separate /// <see cref="IDatReaderWriter"/> facade — the separate
/// <c>DefaultDatReaderWriter</c> file-handle set has been removed. /// <c>DefaultDatReaderWriter</c> file-handle set has been removed.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
{ {
internal const int MaximumUploadsPerFrame = 8;
internal const long MaximumUploadBytesPerFrame = 8L * 1024 * 1024;
internal const long MaximumArrayAllocationBytesPerFrame = 8L * 1024 * 1024;
internal const long MaximumMipmapBytesPerFrame = 8L * 1024 * 1024;
internal const int MaximumNewArraysPerFrame = 1;
internal const long MaximumBufferUploadBytesPerFrame = 8L * 1024 * 1024;
// A GL buffer store is indivisible. The active vertex arena's explicit
// supported ceiling is therefore the truthful one-allocation bound; only
// its prefix copy is staged across frames.
internal const long MaximumBufferAllocationBytesPerFrame =
GlobalMeshBuffer.MaximumVertexBufferBytes;
internal const long MaximumBufferCopyBytesPerFrame = 32L * 1024 * 1024;
internal const int MaximumNewBuffersPerFrame = 1;
internal const long MaximumSingleUploadBytes = 128L * 1024 * 1024;
internal const long MaximumSingleArrayAllocationBytes = 128L * 1024 * 1024;
internal const long MaximumSingleMipmapBytes = 128L * 1024 * 1024;
internal const int MaximumSingleNewArrays = 16;
internal const long MaximumSingleBufferUploadBytes = 32L * 1024 * 1024;
internal const int MaximumReclaimedMeshesPerFrame = MaximumUploadsPerFrame;
internal const long MaximumReclaimedMeshBytesPerFrame = 64L * 1024 * 1024;
internal const int MaximumStaleDiscardsPerFrame = 64;
private readonly OpenGLGraphicsDevice? _graphicsDevice; private readonly OpenGLGraphicsDevice? _graphicsDevice;
private readonly ObjectMeshManager? _meshManager; private readonly ObjectMeshManager? _meshManager;
private readonly DatCollection? _dats; private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement;
private readonly IDatReaderWriter? _dats;
private readonly AcSurfaceMetadataTable _metadataTable = new(); private readonly AcSurfaceMetadataTable _metadataTable = new();
private readonly HashSet<ulong> _metadataPopulated = new(); private readonly HashSet<ulong> _metadataPopulated = new();
private readonly MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits(
MaximumUploadsPerFrame,
MaximumUploadBytesPerFrame,
MaximumArrayAllocationBytesPerFrame,
MaximumMipmapBytesPerFrame,
MaximumNewArraysPerFrame,
MaximumBufferUploadBytesPerFrame,
MaximumBufferAllocationBytesPerFrame,
MaximumBufferCopyBytesPerFrame,
MaximumNewBuffersPerFrame,
MaximumSingleUploadBytes,
MaximumSingleArrayAllocationBytes,
MaximumSingleMipmapBytes,
MaximumSingleNewArrays,
MaximumSingleBufferUploadBytes));
private readonly HashSet<TextureAtlasManager> _mipmapsBudgeted = new();
/// <summary> /// <summary>
/// True when this instance was created via <see cref="CreateUninitialized"/>; /// True when this instance was created via <see cref="CreateUninitialized"/>;
@ -37,32 +75,64 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
private readonly bool _isUninitialized; private readonly bool _isUninitialized;
private bool _disposed; private bool _disposed;
private AcDream.App.Rendering.OrderedResourceTeardown? _teardown;
internal int LastUploadCount { get; private set; }
internal long LastUploadBytes { get; private set; }
internal int LastStaleDiscardCount { get; private set; }
internal long LastArrayAllocationBytes { get; private set; }
internal long LastPlannedMipmapBytes { get; private set; }
internal int LastNewArrayCount { get; private set; }
internal long LastBufferUploadBytes { get; private set; }
internal long LastBufferAllocationBytes { get; private set; }
internal long LastBufferCopyBytes { get; private set; }
internal int LastNewBufferCount { get; private set; }
internal int LastMipmapArrayCount { get; private set; }
internal long LastMipmapBytes { get; private set; }
internal int StagedUploadBacklog => _meshManager?.StagedMeshCount ?? 0;
internal long StagedUploadBytes => _meshManager?.StagedMeshBytes ?? 0;
internal bool StagingAtHighWater => _meshManager?.StagingAtHighWater ?? false;
internal (int Count, long Bytes) CpuMeshCacheDiagnostics =>
_meshManager?.CpuCacheDiagnostics ?? default;
/// <summary> /// <summary>
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → DatCollectionAdapter /// Constructs the full WB pipeline: OpenGLGraphicsDevice → shared bounded
/// → ObjectMeshManager. /// DAT facade → ObjectMeshManager.
/// </summary> /// </summary>
/// <param name="gl">Active Silk.NET GL context. Must be bound to the current /// <param name="gl">Active Silk.NET GL context. Must be bound to the current
/// thread (construction runs GL queries; call from OnLoad).</param> /// thread (construction runs GL queries; call from OnLoad).</param>
/// <param name="dats">acdream's DatCollection, used to populate the surface /// <param name="dats">acdream's shared runtime DAT facade, used to populate the surface
/// metadata side-table via <c>GfxObjMesh.Build</c>. Shares file handles with /// metadata side-table via <c>GfxObjMesh.Build</c>. Shares file handles with
/// the rest of the client; read-only access from the render thread.</param> /// the rest of the client; read-only access from the render thread.</param>
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses /// <param name="logger">Logger for the adapter; ObjectMeshManager uses
/// NullLogger internally.</param> /// NullLogger internally.</param>
public WbMeshAdapter(GL gl, DatCollection dats, ILogger<WbMeshAdapter> logger) public WbMeshAdapter(GL gl, IDatReaderWriter dats, ILogger<WbMeshAdapter> logger)
: this(gl, dats, logger, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance)
{
}
internal WbMeshAdapter(
GL gl,
IDatReaderWriter dats,
ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement)
{ {
ArgumentNullException.ThrowIfNull(gl); ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(logger);
_dats = dats; _dats = dats;
_graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings()); _resourceRetirement = resourceRetirement;
_graphicsDevice = new OpenGLGraphicsDevice(
gl,
logger,
new DebugRenderSettings(),
resourceRetirement);
_graphicsDevice.ParticleBatcher = new ParticleBatcher(_graphicsDevice); _graphicsDevice.ParticleBatcher = new ParticleBatcher(_graphicsDevice);
// ConsoleErrorLogger surfaces WB's silently-caught exceptions // ConsoleErrorLogger surfaces WB's silently-caught exceptions
// (ObjectMeshManager.PrepareMeshData try/catch at line ~589). // (ObjectMeshManager.PrepareMeshData try/catch at line ~589).
_meshManager = new ObjectMeshManager( _meshManager = new ObjectMeshManager(
_graphicsDevice, _graphicsDevice,
new DatCollectionAdapter(dats), dats,
new ConsoleErrorLogger<ObjectMeshManager>()); new ConsoleErrorLogger<ObjectMeshManager>());
} }
@ -157,7 +227,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// An uninitialized adapter owns no render pipeline, so it must not // An uninitialized adapter owns no render pipeline, so it must not
// manufacture a readiness wait that can never complete. The modern // manufacture a readiness wait that can never complete. The modern
// production path is always initialized at startup. // production path is always initialized at startup.
return _isUninitialized || _meshManager?.TryGetRenderData(id) is not null; return _isUninitialized || (_meshManager?.EnsureRenderDataReady(id) ?? false);
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -166,9 +236,12 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
if (_isUninitialized || _meshManager is null) return; if (_isUninitialized || _meshManager is null) return;
_meshManager.IncrementRefCount(id); _meshManager.IncrementRefCount(id);
bool firstEver = _metadataPopulated.Add(id); bool metadataClaimed = false;
if (firstEver) try
PopulateMetadata(id); {
metadataClaimed = _metadataPopulated.Add(id);
if (metadataClaimed)
PopulateMetadata(id);
// WB's IncrementRefCount alone only bumps a usage counter; it does // WB's IncrementRefCount alone only bumps a usage counter; it does
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync // NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
@ -197,8 +270,37 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// isSetup: false — acdream's MeshRefs already carry expanded // isSetup: false — acdream's MeshRefs already carry expanded
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is // per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
// unused. // unused.
if (firstEver || _meshManager.TryGetRenderData(id) is null) if (metadataClaimed || _meshManager.TryGetRenderData(id) is null)
_meshManager.PrepareMeshDataAsync(id, isSetup: false); _meshManager.PrepareMeshDataAsync(id, isSetup: false);
}
catch (Exception acquireFailure)
{
if (metadataClaimed)
{
_metadataPopulated.Remove(id);
_metadataTable.Remove(id);
}
// IncrementRefCount is a public ownership boundary. Metadata/DAT
// preparation happens after ObjectMeshManager commits its count,
// so compensate before reporting failure. ObjectMeshManager's
// decrement has a strong exception guarantee; if compensation
// itself fails, expose that the increment remains committed so a
// higher-level transactional owner can retain its held marker.
try
{
_meshManager.DecrementRefCount(id);
}
catch (Exception rollbackFailure)
{
throw new MeshReferenceMutationException(
$"Mesh 0x{id:X10} reference acquisition failed and its committed increment could not be rolled back.",
mutationCommitted: true,
new AggregateException(acquireFailure, rollbackFailure));
}
throw;
}
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -263,6 +365,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
if (_isUninitialized) return; if (_isUninitialized) return;
if (_disposed) return; if (_disposed) return;
ObjectMeshManager meshManager = _meshManager!;
_graphicsDevice!.ProcessGLQueue(); _graphicsDevice!.ProcessGLQueue();
// #125: drain staged uploads; a FAILED upload (UploadMeshData returned // #125: drain staged uploads; a FAILED upload (UploadMeshData returned
// null from its catch) is re-staged for a LATER frame, not dropped. The // null from its catch) is re-staged for a LATER frame, not dropped. The
@ -270,43 +373,190 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// inside the while would let a deterministic failure spin the queue in a // inside the while would let a deterministic failure spin the queue in a
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so // single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
// a genuine defect surfaces loudly instead of the old silent sticky drop. // a genuine defect surfaces loudly instead of the old silent sticky drop.
List<ObjectMeshData>? requeue = null; List<MeshUploadQueueItem>? requeue = null;
while (_meshManager!.TryDequeueStagedMeshData(out var meshData)) _uploadBudget.Reset();
_mipmapsBudgeted.Clear();
int staleDiscardCount = 0;
bool arenaBackpressured = false;
GlobalMeshBuffer? globalBuffer = meshManager.GlobalBuffer;
// A backing-store migration is real GL work, not a cost estimate. One
// bounded copy chunk runs per frame while the old VAO remains active.
// Uploads stay queued until the atomic final swap has completed.
if (globalBuffer?.IsMigrationInProgress == true)
{ {
if (meshData is null) GlobalMeshMaintenanceStep maintenance =
continue; globalBuffer.AdvanceMigration(MaximumBufferCopyBytesPerFrame);
if (_meshManager.UploadOrRequeue(meshData)) _uploadBudget.RecordBufferMaintenance(
maintenance.AllocationBytes,
maintenance.CopyBytes,
maintenance.NewBufferCount);
arenaBackpressured = !maintenance.Completed;
}
while (globalBuffer?.IsMigrationInProgress != true
&& _uploadBudget.BufferCopyBytes == 0
&& _uploadBudget.ObjectCount < MaximumUploadsPerFrame)
{
staleDiscardCount += meshManager.DiscardUnownedStagedPrefix(
MaximumStaleDiscardsPerFrame - staleDiscardCount);
if (staleDiscardCount >= MaximumStaleDiscardsPerFrame)
break;
if (!meshManager.TryPeekStagedMeshData(out MeshUploadQueueItem next))
break;
GlobalMeshCapacityResult capacity;
GlobalMeshMaintenanceStep maintenance;
try
{
capacity = meshManager.EnsureGlobalBufferCapacity(next, out maintenance);
}
catch (NotSupportedException error)
{
RejectUnsupportedHead(meshManager, next, error);
throw;
}
if (capacity == GlobalMeshCapacityResult.MigrationStarted)
{
_uploadBudget.RecordBufferMaintenance(
maintenance.AllocationBytes,
maintenance.CopyBytes,
maintenance.NewBufferCount);
arenaBackpressured = true;
break;
}
if (capacity == GlobalMeshCapacityResult.MigrationInProgress)
{
arenaBackpressured = true;
break;
}
if (capacity == GlobalMeshCapacityResult.NeedsReclamation)
{
// Do not evict another batch while the frame-fence queue is
// already returning ranges or retiring an old backing store.
// Waiting for real allocator state prevents three frames of
// duplicate eviction before the first release becomes reusable.
if (globalBuffer?.HasPendingReclamation != true)
{
var reclaimed = meshManager.ReclaimUnusedResources(
MaximumReclaimedMeshesPerFrame,
MaximumReclaimedMeshBytesPerFrame,
forceArenaReclamation: true);
if (reclaimed.Count == 0)
{
throw new NotSupportedException(
$"The live global-mesh working set cannot fit the supported "
+ $"{GlobalMeshBuffer.MaximumVertexBufferBytes + GlobalMeshBuffer.MaximumIndexBufferBytes:N0}-byte arena "
+ $"(blocked staging generation {next.Generation}, object 0x{next.Data.ObjectId:X10}).");
}
}
arenaBackpressured = true;
break;
}
MeshUploadCost cost;
try
{
cost = meshManager.PlanUploadCost(
next.Data,
_mipmapsBudgeted,
next.Generation);
if (!_uploadBudget.TryAdmit(cost))
break;
}
catch (NotSupportedException error)
{
RejectUnsupportedHead(meshManager, next, error);
throw;
}
if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem meshData))
break;
if (meshData.Generation != next.Generation)
throw new InvalidOperationException("The staged mesh FIFO head changed during render-thread admission.");
if (meshManager.UploadOrRequeue(meshData))
(requeue ??= new()).Add(meshData); (requeue ??= new()).Add(meshData);
meshManager.AddDirtyAtlasesTo(_mipmapsBudgeted);
} }
if (requeue is not null) if (requeue is not null)
foreach (var m in requeue) foreach (var m in requeue)
_meshManager.RequeueStagedMeshData(m); meshManager.RequeueStagedMeshData(m);
meshManager.SetArenaBackpressure(arenaBackpressured);
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled; bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
var pendingBefore = texProbe var pendingBefore = texProbe
? _meshManager.GetPendingTextureUpdateStats() ? meshManager.GetPendingTextureUpdateStats()
: default; : default;
// #105 root cause (2026-06-10): TextureAtlasManager.AddTexture only STAGES // #105 root cause (2026-06-10): TextureAtlasManager.AddTexture only STAGES
// texture content (PBO write + ManagedGLTextureArray._pendingUpdates) — the // immutable decoded payloads in ManagedGLTextureArray._pendingUpdates — the
// actual TexSubImage3D copies + mipmap regeneration happen in // actual TexSubImage3D copies + mipmap regeneration happen in
// ProcessDirtyUpdates, which WB drives ONCE PER FRAME from its render loop // ProcessDirtyUpdates, which WB drives ONCE PER FRAME from its render loop
// (WB GameScene.cs:975 `_meshManager?.GenerateMipmaps()`, just before the // (WB GameScene.cs:975 `_meshManager?.GenerateMipmaps()`, just before the
// opaque pass). That call site lived in the GameScene file the N.4/O-T4 // opaque pass). That call site lived in the GameScene file the N.4/O-T4
// extraction replaced with GameWindow, so the driver was silently dropped: // extraction replaced with GameWindow, so the driver was silently dropped:
// staged updates only ever reached the GPU as a side effect of PBO growth, // staged updates never reached TexSubImage3D, leaving undefined
// and every layer staged after an array's LAST growth kept undefined // TexStorage3D content behind valid resident bindless handles — the
// TexStorage3D content behind a valid resident bindless handle — the
// intermittent white indoor walls (#105). Pre-fix evidence: 126 updates // intermittent white indoor walls (#105). Pre-fix evidence: 126 updates
// stuck across 34/34 arrays at standstill (texflush-prefix.log). Tick() // stuck across 34/34 arrays at standstill (texflush-prefix.log). Tick()
// runs before all draw passes (GameWindow OnRender), so this is the exact // runs before all draw passes (GameWindow OnRender), so this is the exact
// WB-equivalent position. // WB-equivalent position.
_meshManager.GenerateMipmaps(); (LastMipmapArrayCount, LastMipmapBytes) = meshManager.GenerateMipmaps();
if (globalBuffer?.HasPendingReclamation != true)
{
meshManager.ReclaimUnusedResources(
MaximumReclaimedMeshesPerFrame,
MaximumReclaimedMeshBytesPerFrame);
}
meshManager.EvictOneEmptyAtlas();
// Arena maintenance is capacity-driven and uses genuinely idle upload
// frames. It never competes with destination materialization, and the
// GlobalMeshBuffer policy retains enough hysteresis that portal-route
// revisits do not alternate shrink/grow every frame.
if (_uploadBudget.ObjectCount == 0
&& LastMipmapArrayCount == 0
&& meshManager.StagedMeshCount == 0
&& globalBuffer?.IsMigrationInProgress == false
&& globalBuffer.TryTrimUnusedTail(out GlobalMeshMaintenanceStep trim))
{
_uploadBudget.RecordBufferMaintenance(
trim.AllocationBytes,
trim.CopyBytes,
trim.NewBufferCount);
meshManager.SetArenaBackpressure(true);
}
LastUploadCount = _uploadBudget.ObjectCount;
LastUploadBytes = _uploadBudget.SourceBytes;
LastStaleDiscardCount = staleDiscardCount;
LastArrayAllocationBytes = _uploadBudget.ArrayAllocationBytes;
LastPlannedMipmapBytes = _uploadBudget.MipmapBytes;
LastNewArrayCount = _uploadBudget.NewArrayCount;
LastBufferUploadBytes = _uploadBudget.BufferUploadBytes;
LastBufferAllocationBytes = _uploadBudget.BufferAllocationBytes;
LastBufferCopyBytes = _uploadBudget.BufferCopyBytes;
LastNewBufferCount = _uploadBudget.NewBufferCount;
if (texProbe) if (texProbe)
EmitTexFlushProbe(pendingBefore); EmitTexFlushProbe(pendingBefore);
} }
private static void RejectUnsupportedHead(
ObjectMeshManager meshManager,
MeshUploadQueueItem expected,
NotSupportedException error)
{
if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem rejected)
|| rejected.Generation != expected.Generation)
{
throw new InvalidOperationException(
"The staged mesh FIFO head changed while rejecting an unsupported generation.",
error);
}
meshManager.RejectUnsupportedStagedUpload(rejected, error);
}
// #105 apparatus state — see RenderingDiagnostics.ProbeTexFlushEnabled. // #105 apparatus state — see RenderingDiagnostics.ProbeTexFlushEnabled.
private int _lastTexFlushBefore = -1; private int _lastTexFlushBefore = -1;
private int _texFlushHeartbeat; private int _texFlushHeartbeat;
@ -351,9 +601,46 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
/// <inheritdoc/> /// <inheritdoc/>
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (_disposed)
_disposed = true; return;
_meshManager?.Dispose();
_graphicsDevice?.Dispose(); // These are dependency edges, not merely a best-effort cleanup list.
// In particular, the sampler objects owned by the device must remain
// alive until every resident texture-array handle has been retired.
_teardown ??= new AcDream.App.Rendering.OrderedResourceTeardown(
() =>
{
// The current global arena is still directly owned by the mesh
// manager. Fence every submitted draw before manager teardown
// can delete that arena's VAO/VBO/IBO.
if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights)
frameFlights.WaitForSubmittedWork();
},
() => _meshManager?.Dispose(),
() => DrainGraphicsQueue("publishing mesh resource retirements"),
() =>
{
if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights)
frameFlights.WaitForSubmittedWork();
},
() => DrainGraphicsQueue("releasing retired mesh resources"),
() => _graphicsDevice?.Dispose(),
() => DrainGraphicsQueue("deleting mesh graphics-device resources"));
_teardown.Advance();
_disposed = _teardown.IsComplete;
}
private void DrainGraphicsQueue(string operation)
{
if (_graphicsDevice is null)
return;
_graphicsDevice.ProcessGLQueue();
if (_graphicsDevice.HasPendingGLWork)
{
throw new InvalidOperationException(
$"OpenGL work remains pending after {operation}; retry adapter disposal to continue the exact stage.");
}
} }
} }

View file

@ -0,0 +1,29 @@
namespace AcDream.App.Rendering.Wb;
internal enum WbTextureResolutionKind : byte
{
SharedAtlas,
OriginalTextureOverride,
PaletteComposite,
}
/// <summary>
/// Selects the retail texture ownership path for one rendered surface.
/// Retail creates a palette-combined <c>ImgTex</c> only for indexed source
/// images; ordinary surfaces continue to reference their shared image.
/// </summary>
internal static class WbTextureResolutionPolicy
{
public static WbTextureResolutionKind Select(
bool hasOriginalTextureOverride,
bool hasPaletteOverride,
bool sourceIsPaletteIndexed)
{
if (hasPaletteOverride && sourceIsPaletteIndexed)
return WbTextureResolutionKind.PaletteComposite;
return hasOriginalTextureOverride
? WbTextureResolutionKind.OriginalTextureOverride
: WbTextureResolutionKind.SharedAtlas;
}
}

View file

@ -0,0 +1,101 @@
using DatReaderWriter;
using DatReaderWriter.Options;
using AcDream.Content;
using DatReaderWriter.Lib.IO;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
namespace AcDream.App;
/// <summary>
/// Opens the read-only DAT collection owned for the lifetime of an App process.
/// </summary>
/// <remarks>
/// DatReaderWriter's default <see cref="FileCachingStrategy.OnDemand"/> retains
/// every unpacked file entry for the lifetime of the collection. The runtime has
/// bounded decoded-content caches of its own, so retaining that second,
/// unbounded copy makes memory depend on every landblock visited. Keep the small
/// B-tree lookup cache, but read file payloads on demand and let the owning
/// runtime caches decide their residency.
/// </remarks>
internal static class RuntimeDatCollectionFactory
{
internal static IDatReaderWriter OpenReadOnly(string datDirectory) =>
new RuntimeDatCollection(new DatCollection(CreateReadOnlyOptions(datDirectory)));
internal static DatCollectionOptions CreateReadOnlyOptions(string datDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(datDirectory);
return new DatCollectionOptions
{
DatDirectory = datDirectory,
AccessType = DatAccessType.Read,
IndexCachingStrategy = IndexCachingStrategy.OnDemand,
FileCachingStrategy = FileCachingStrategy.Never,
};
}
}
/// <summary>
/// Owns the runtime's one raw DAT handle set and its one shared bounded typed
/// object facade. Disposing this owner closes both layers exactly once.
/// </summary>
internal sealed class RuntimeDatCollection : IDatReaderWriter
{
private readonly DatCollection _raw;
private readonly DatCollectionAdapter _bounded;
private bool _disposed;
internal RuntimeDatCollection(DatCollection raw)
{
ArgumentNullException.ThrowIfNull(raw);
_raw = raw;
_bounded = new DatCollectionAdapter(raw);
}
public string SourceDirectory => _bounded.SourceDirectory;
public IDatDatabase Portal => _bounded.Portal;
public IDatDatabase Cell => _bounded.Cell;
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions => _bounded.CellRegions;
public IDatDatabase HighRes => _bounded.HighRes;
public IDatDatabase Language => _bounded.Language;
public IDatDatabase Local => _bounded.Local;
public ReadOnlyDictionary<uint, uint> RegionFileMap => _bounded.RegionFileMap;
public int PortalIteration => _bounded.PortalIteration;
public int CellIteration => _bounded.CellIteration;
public int HighResIteration => _bounded.HighResIteration;
public int LanguageIteration => _bounded.LanguageIteration;
[return: MaybeNull]
public T Get<T>(uint fileId) where T : IDBObj => _bounded.Get<T>(fileId);
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value)
where T : IDBObj =>
_bounded.TryGet(fileId, out value);
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
_bounded.GetAllIdsOfType<T>();
public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) =>
_bounded.TryGetFileBytes(regionId, fileId, ref bytes, out bytesRead);
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
_bounded.ResolveId(id);
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
_bounded.TrySave(obj, iteration);
public bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : IDBObj =>
_bounded.TrySave(regionId, obj, iteration);
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_bounded.Dispose();
_raw.Dispose();
}
}

View file

@ -32,6 +32,7 @@ public sealed record RuntimeOptions(
string? LiveUser, string? LiveUser,
string? LivePass, string? LivePass,
bool DevTools, bool DevTools,
bool UncappedRendering,
bool DumpMoveTruth, bool DumpMoveTruth,
bool NoAudio, bool NoAudio,
bool EnableSkyPesDebug, bool EnableSkyPesDebug,
@ -73,6 +74,10 @@ public sealed record RuntimeOptions(
LiveUser: NullIfEmpty(env("ACDREAM_TEST_USER")), LiveUser: NullIfEmpty(env("ACDREAM_TEST_USER")),
LivePass: NullIfEmpty(env("ACDREAM_TEST_PASS")), LivePass: NullIfEmpty(env("ACDREAM_TEST_PASS")),
DevTools: IsExactlyOne(env("ACDREAM_DEVTOOLS")), DevTools: IsExactlyOne(env("ACDREAM_DEVTOOLS")),
// Normal presentation is always bounded by VSync or a
// refresh-rate software pacer. This explicit diagnostic is the
// sole way to measure truly uncapped renderer throughput.
UncappedRendering: IsExactlyOne(env("ACDREAM_UNCAPPED_RENDER")),
DumpMoveTruth: IsExactlyOne(env("ACDREAM_DUMP_MOVE_TRUTH")), DumpMoveTruth: IsExactlyOne(env("ACDREAM_DUMP_MOVE_TRUTH")),
NoAudio: IsExactlyOne(env("ACDREAM_NO_AUDIO")), NoAudio: IsExactlyOne(env("ACDREAM_NO_AUDIO")),
EnableSkyPesDebug: IsExactlyOne(env("ACDREAM_ENABLE_SKY_PES")), EnableSkyPesDebug: IsExactlyOne(env("ACDREAM_ENABLE_SKY_PES")),

View file

@ -4,6 +4,7 @@ using System.Linq;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using CoreSpellTable = AcDream.Core.Spells.SpellTable; using CoreSpellTable = AcDream.Core.Spells.SpellTable;
@ -69,7 +70,7 @@ public sealed class MagicCatalog
_wcidByScid, _wcidByScid,
_magicPackWcidBySchool); _magicPackWcidBySchool);
public static MagicCatalog Load(DatCollection dats) public static MagicCatalog Load(IDatReaderWriter dats)
{ {
ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(dats);

View file

@ -0,0 +1,20 @@
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Immutable context retained after a landblock's spatial/world-state
/// membership has been detached. Presentation owners consume this snapshot
/// asynchronously from the state transition, so a failed renderer cleanup
/// cannot keep the old landblock logically resident.
/// </summary>
public sealed record GpuLandblockRetirement(
uint LandblockId,
LandblockRetirementKind Kind,
IReadOnlyList<WorldEntity> Entities);
public enum LandblockRetirementKind
{
Full,
NearLayer,
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,328 @@
using AcDream.Core.World;
namespace AcDream.App.Streaming;
[Flags]
public enum LandblockRetirementStage : ushort
{
None = 0,
MeshReferences = 1 << 0,
StaticScripts = 1 << 1,
Classification = 1 << 2,
EntityLighting = 1 << 3,
EntityTranslucency = 1 << 4,
PluginProjection = 1 << 5,
Terrain = 1 << 6,
Physics = 1 << 7,
CellVisibility = 1 << 8,
BuildingRegistry = 1 << 9,
EnvironmentCells = 1 << 10,
LegacyPresentation = 1 << 11,
}
/// <summary>
/// One retryable landblock-retirement ledger. Successful owner operations are
/// committed once; a later attempt resumes at the exact failed owner/entity.
/// </summary>
public sealed class LandblockRetirementTicket
{
private readonly Dictionary<LandblockRetirementStage, int> _entityCursors = new();
private readonly Dictionary<LandblockRetirementStage, Exception> _failures = new();
private Exception? _callbackFailure;
private Exception? _lastReportedFailure;
internal LandblockRetirementTicket(
GpuLandblockRetirement state,
LandblockRetirementStage requiredStages)
{
State = state;
RequiredStages = requiredStages;
}
public GpuLandblockRetirement State { get; }
public uint LandblockId => State.LandblockId;
public LandblockRetirementKind Kind => State.Kind;
public IReadOnlyList<WorldEntity> Entities => State.Entities;
public LandblockRetirementStage RequiredStages { get; }
public LandblockRetirementStage CompletedStages { get; private set; }
public bool IsComplete =>
(CompletedStages & RequiredStages) == RequiredStages
&& _callbackFailure is null;
public bool RunOnce(LandblockRetirementStage stage, Action operation)
{
ValidateSingleStage(stage);
ArgumentNullException.ThrowIfNull(operation);
if ((CompletedStages & stage) != 0)
return true;
try
{
operation();
CompletedStages |= stage;
_failures.Remove(stage);
return true;
}
catch (Exception error)
{
_failures[stage] = error;
return false;
}
}
public bool RunForEachEntity(
LandblockRetirementStage stage,
Func<WorldEntity, bool> predicate,
Action<WorldEntity> operation)
{
ValidateSingleStage(stage);
ArgumentNullException.ThrowIfNull(predicate);
ArgumentNullException.ThrowIfNull(operation);
if ((CompletedStages & stage) != 0)
return true;
_entityCursors.TryGetValue(stage, out int cursor);
while (cursor < Entities.Count)
{
WorldEntity entity = Entities[cursor];
if (predicate(entity))
{
try
{
operation(entity);
}
catch (Exception error)
{
_entityCursors[stage] = cursor;
_failures[stage] = error;
return false;
}
}
cursor++;
_entityCursors[stage] = cursor;
}
CompletedStages |= stage;
_entityCursors.Remove(stage);
_failures.Remove(stage);
return true;
}
internal void BeginAttempt() => _callbackFailure = null;
internal void RecordCallbackFailure(Exception error) =>
_callbackFailure = error;
internal bool TryTakeNewFailure(out Exception? error)
{
error = _callbackFailure ?? _failures.Values.FirstOrDefault();
if (error is null || ReferenceEquals(error, _lastReportedFailure))
return false;
_lastReportedFailure = error;
return true;
}
private static void ValidateSingleStage(LandblockRetirementStage stage)
{
ushort value = (ushort)stage;
if (value == 0 || (value & (value - 1)) != 0)
throw new ArgumentOutOfRangeException(
nameof(stage),
stage,
"A retirement operation must own exactly one stage bit.");
}
}
/// <summary>
/// Game-window-lifetime owner for landblock retirement. World-state detachment
/// commits first; renderer/cache/script cleanup advances afterward from a
/// per-owner ledger. The coordinator is intentionally shared by replacement
/// <see cref="StreamingController"/> instances so a quality-setting rebuild
/// cannot abandon an unfinished retirement.
/// </summary>
public sealed class LandblockRetirementCoordinator
{
private const LandblockRetirementStage CoreStages =
LandblockRetirementStage.MeshReferences
| LandblockRetirementStage.StaticScripts
| LandblockRetirementStage.Classification;
public const LandblockRetirementStage ProductionPresentationStages =
LandblockRetirementStage.EntityLighting
| LandblockRetirementStage.EntityTranslucency
| LandblockRetirementStage.PluginProjection
| LandblockRetirementStage.Terrain
| LandblockRetirementStage.Physics
| LandblockRetirementStage.CellVisibility
| LandblockRetirementStage.BuildingRegistry
| LandblockRetirementStage.EnvironmentCells;
private readonly GpuWorldState _state;
private readonly Action<LandblockRetirementTicket> _advancePresentation;
private readonly Func<LandblockRetirementKind, LandblockRetirementStage>
_requiredPresentationStages;
private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new();
private readonly List<uint> _completedIds = new();
public LandblockRetirementCoordinator(
GpuWorldState state,
Action<LandblockRetirementTicket> advancePresentation,
Func<LandblockRetirementKind, LandblockRetirementStage>? requiredPresentationStages = null)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(advancePresentation);
_state = state;
_advancePresentation = advancePresentation;
_requiredPresentationStages = requiredPresentationStages
?? (kind => kind == LandblockRetirementKind.Full
? ProductionPresentationStages
: ProductionPresentationStages & ~LandblockRetirementStage.Terrain);
}
public int PendingCount { get; private set; }
public bool IsPending(uint landblockId) =>
_pending.ContainsKey(Canonicalize(landblockId));
public void BeginFull(uint landblockId) =>
Begin(landblockId, LandblockRetirementKind.Full);
public void BeginNearLayer(uint landblockId) =>
Begin(landblockId, LandblockRetirementKind.NearLayer);
public void Advance()
{
if (_pending.Count == 0)
return;
_completedIds.Clear();
foreach ((uint id, List<LandblockRetirementTicket> tickets) in _pending)
{
for (int i = tickets.Count - 1; i >= 0; i--)
{
LandblockRetirementTicket ticket = tickets[i];
AdvanceTicket(ticket);
if (!ticket.IsComplete)
continue;
tickets.RemoveAt(i);
PendingCount--;
}
if (tickets.Count == 0)
_completedIds.Add(id);
}
for (int i = 0; i < _completedIds.Count; i++)
_pending.Remove(_completedIds[i]);
}
internal static LandblockRetirementCoordinator CreateLegacy(
GpuWorldState state,
Action<uint>? removeTerrain,
Action<uint>? demoteNearLayer)
{
LandblockRetirementStage Required(LandblockRetirementKind kind) =>
kind switch
{
LandblockRetirementKind.Full when removeTerrain is not null =>
LandblockRetirementStage.LegacyPresentation,
LandblockRetirementKind.NearLayer when demoteNearLayer is not null =>
LandblockRetirementStage.LegacyPresentation,
_ => LandblockRetirementStage.None,
};
void AdvanceLegacy(LandblockRetirementTicket ticket)
{
Action<uint>? callback = ticket.Kind == LandblockRetirementKind.Full
? removeTerrain
: demoteNearLayer;
if (callback is not null)
{
ticket.RunOnce(
LandblockRetirementStage.LegacyPresentation,
() => callback(ticket.LandblockId));
}
}
return new LandblockRetirementCoordinator(state, AdvanceLegacy, Required);
}
private void Begin(uint landblockId, LandblockRetirementKind kind)
{
uint canonical = Canonicalize(landblockId);
if (_pending.TryGetValue(canonical, out List<LandblockRetirementTicket>? existing))
{
for (int i = 0; i < existing.Count; i++)
{
if (existing[i].Kind == kind)
{
AdvanceTicket(existing[i]);
return;
}
}
}
GpuLandblockRetirement? stateRetirement = kind == LandblockRetirementKind.Full
? _state.DetachLandblock(canonical)
: _state.DetachNearLayer(canonical);
if (stateRetirement is null)
return;
LandblockRetirementStage required =
CoreStages | _requiredPresentationStages(kind);
var ticket = new LandblockRetirementTicket(stateRetirement, required);
if (existing is null)
{
existing = new List<LandblockRetirementTicket>(1);
_pending.Add(canonical, existing);
}
existing.Add(ticket);
PendingCount++;
AdvanceTicket(ticket);
if (ticket.IsComplete)
{
existing.Remove(ticket);
PendingCount--;
if (existing.Count == 0)
_pending.Remove(canonical);
}
}
private void AdvanceTicket(LandblockRetirementTicket ticket)
{
ticket.BeginAttempt();
ticket.RunOnce(
LandblockRetirementStage.MeshReferences,
() => _state.ReleaseLandblockMeshReferences(ticket.LandblockId));
ticket.RunForEachEntity(
LandblockRetirementStage.StaticScripts,
static entity => entity.ServerGuid == 0,
_state.StopStaticEntityScript);
ticket.RunOnce(
LandblockRetirementStage.Classification,
() => _state.InvalidateLandblockClassification(ticket.LandblockId));
try
{
_advancePresentation(ticket);
}
catch (Exception error)
{
ticket.RecordCallbackFailure(error);
}
if (ticket.TryTakeNewFailure(out Exception? failure))
{
Console.WriteLine(
$"streaming: retirement for 0x{ticket.LandblockId:X8} " +
$"({ticket.Kind}) remains pending: {failure}");
}
}
private static uint Canonicalize(uint id) =>
(id & 0xFFFF0000u) | 0xFFFFu;
}

View file

@ -57,8 +57,12 @@ public sealed class LandblockStreamer : IDisposable
private readonly Channel<LandblockStreamJob> _inbox; private readonly Channel<LandblockStreamJob> _inbox;
private readonly Channel<LandblockStreamResult> _outbox; private readonly Channel<LandblockStreamResult> _outbox;
private readonly CancellationTokenSource _cancel = new(); private readonly CancellationTokenSource _cancel = new();
private readonly object _inboxGate = new();
private Thread? _worker; private Thread? _worker;
private Exception? _workerFailure;
private int _disposed; private int _disposed;
private readonly object _disposeGate = new();
private bool _disposeCompleted;
/// <summary> /// <summary>
/// Primary ctor — the factory takes the job's <see cref="LandblockStreamJobKind"/> /// Primary ctor — the factory takes the job's <see cref="LandblockStreamJobKind"/>
@ -75,7 +79,7 @@ public sealed class LandblockStreamer : IDisposable
// Default: no mesh build (returns null → Failed result). Production // Default: no mesh build (returns null → Failed result). Production
// wires in LandblockMesh.Build via the T12 construction site. // wires in LandblockMesh.Build via the T12 construction site.
_buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null); _buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null);
_inbox = Channel.CreateUnbounded<LandblockStreamJob>( _inbox = Channel.CreateUnbounded<LandblockStreamJob>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
_outbox = Channel.CreateUnbounded<LandblockStreamResult>( _outbox = Channel.CreateUnbounded<LandblockStreamResult>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
@ -117,25 +121,26 @@ public sealed class LandblockStreamer : IDisposable
/// <summary> /// <summary>
/// Activate the dedicated background worker thread. Idempotent and /// Activate the dedicated background worker thread. Idempotent and
/// thread-safe: concurrent callers will only spawn one worker; subsequent /// thread-safe: concurrent callers will only spawn one worker; subsequent
/// calls are no-ops. Atomic via <see cref="Interlocked.CompareExchange{T}(ref T, T, T)"/>. /// calls are no-ops. Serialized with disposal so a worker can never start
/// after the owning DAT lifetime has been released.
/// </summary> /// </summary>
public void Start() public void Start()
{ {
if (System.Threading.Volatile.Read(ref _disposed) != 0) lock (_disposeGate)
throw new ObjectDisposedException(nameof(LandblockStreamer));
// A.5 T10-T12 follow-up: atomically install the worker so concurrent
// Start() callers don't both pass the null check and spawn duplicate
// threads. Construct the candidate; CAS it into _worker; if we lost
// the race, the candidate goes unstarted and is GCed.
var candidate = new Thread(WorkerLoop)
{ {
IsBackground = true, if (System.Threading.Volatile.Read(ref _disposed) != 0)
Name = "acdream.streaming.worker", throw new ObjectDisposedException(nameof(LandblockStreamer));
}; if (_worker is not null)
if (Interlocked.CompareExchange(ref _worker, candidate, null) == null) return;
candidate.Start();
// else: another caller won the race; their thread is running. var worker = new Thread(WorkerLoop)
{
IsBackground = true,
Name = "acdream.streaming.worker",
};
worker.Start();
_worker = worker;
}
} }
/// <summary> /// <summary>
@ -151,7 +156,7 @@ public sealed class LandblockStreamer : IDisposable
if (System.Threading.Volatile.Read(ref _disposed) != 0) if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer)); throw new ObjectDisposedException(nameof(LandblockStreamer));
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}"); AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}");
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind, generation)); WriteJob(new LandblockStreamJob.Load(landblockId, kind, generation));
} }
/// <summary> /// <summary>
@ -160,9 +165,7 @@ public sealed class LandblockStreamer : IDisposable
/// </summary> /// </summary>
public void EnqueueUnload(uint landblockId, ulong generation = 0) public void EnqueueUnload(uint landblockId, ulong generation = 0)
{ {
if (System.Threading.Volatile.Read(ref _disposed) != 0) WriteJob(new LandblockStreamJob.Unload(landblockId, generation));
throw new ObjectDisposedException(nameof(LandblockStreamer));
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId, generation));
} }
/// <summary> /// <summary>
@ -176,9 +179,25 @@ public sealed class LandblockStreamer : IDisposable
/// </summary> /// </summary>
public void ClearPendingLoads() public void ClearPendingLoads()
{ {
if (System.Threading.Volatile.Read(ref _disposed) != 0) WriteJob(new LandblockStreamJob.ClearLoads());
throw new ObjectDisposedException(nameof(LandblockStreamer)); }
_inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads());
private void WriteJob(LandblockStreamJob job)
{
// Serialize the writer's terminal transition with enqueue. Without
// this small lifecycle gate a worker crash could complete the channel
// between the caller's state check and TryWrite, silently dropping a
// landblock request. Enqueues are destination-boundary events, not a
// per-frame hot path.
lock (_inboxGate)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
if (_workerFailure is { } failure)
throw new InvalidOperationException("The landblock streaming worker has terminated.", failure);
if (!_inbox.Writer.TryWrite(job))
throw new InvalidOperationException("The landblock streaming inbox is no longer accepting work.");
}
} }
/// <summary> /// <summary>
@ -248,6 +267,11 @@ public sealed class LandblockStreamer : IDisposable
{ {
// Last-ditch: surface via outbox so the caller at least sees // Last-ditch: surface via outbox so the caller at least sees
// something. We never retry a crashed worker. // something. We never retry a crashed worker.
lock (_inboxGate)
{
_workerFailure = ex;
_inbox.Writer.TryComplete(ex);
}
_outbox.Writer.TryWrite(new LandblockStreamResult.WorkerCrashed(ex.ToString())); _outbox.Writer.TryWrite(new LandblockStreamResult.WorkerCrashed(ex.ToString()));
} }
finally finally
@ -397,18 +421,21 @@ public sealed class LandblockStreamer : IDisposable
public void Dispose() public void Dispose()
{ {
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return; lock (_disposeGate)
_cancel.Cancel(); {
_inbox.Writer.TryComplete(); if (_disposeCompleted)
// Generous join: the owner disposes the DatCollection after this, which return;
// unmaps the dats' memory-mapped views — an abandoned worker mid-dat-read
// would take the process down with an AccessViolation in System.Threading.Interlocked.Exchange(ref _disposed, 1);
// MemoryMappedBlockAllocator.ReadBlock (dat-race investigation 2026-06-09). _cancel.Cancel();
// Cancellation is honored between jobs, so the wait is bounded by one lock (_inboxGate)
// landblock load; 15s only ever elapses if the worker is genuinely hung. _inbox.Writer.TryComplete();
if (_worker is not null && !_worker.Join(TimeSpan.FromSeconds(15))) // The owner releases the memory-mapped DAT immediately after this
Console.Error.WriteLine( // object. Join the actual worker without a grace-period timeout so
"[streamer] worker did not stop within 15s — dat teardown may race an in-flight load"); // no native read can survive into that teardown.
_cancel.Dispose(); _worker?.Join();
_cancel.Dispose();
_disposeCompleted = true;
}
} }
} }

View file

@ -22,9 +22,8 @@ public sealed class StreamingController
private readonly Action<uint, ulong> _enqueueUnload; private readonly Action<uint, ulong> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions; private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain; private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _removeTerrain;
private readonly Action<uint>? _demoteNearLayer;
private readonly Action? _clearPendingLoads; private readonly Action? _clearPendingLoads;
private readonly LandblockRetirementCoordinator _retirements;
/// <summary> /// <summary>
/// #138: fired after a landblock's entity layer (re)loads — both the full /// #138: fired after a landblock's entity layer (re)loads — both the full
@ -42,6 +41,9 @@ public sealed class StreamingController
private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes; private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
private readonly GpuWorldState _state; private readonly GpuWorldState _state;
private StreamingRegion? _region; private StreamingRegion? _region;
private RadiiReconfiguration? _pendingRadiiReconfiguration;
private bool _advancingRadiiReconfiguration;
private (int NearRadius, int FarRadius)? _deferredRadiiRequest;
// Hard streaming boundaries advance this token. Worker completions carry // Hard streaming boundaries advance this token. Worker completions carry
// the generation captured at enqueue time, so an old overlapping load or // the generation captured at enqueue time, so an old overlapping load or
// unload cannot mutate the replacement window after a portal recenter. // unload cannot mutate the replacement window after a portal recenter.
@ -63,23 +65,15 @@ public sealed class StreamingController
/// <summary> /// <summary>
/// Near-tier radius (LBs from observer that load full detail: terrain + /// Near-tier radius (LBs from observer that load full detail: terrain +
/// scenery + entities). Set at construction; readable thereafter. /// scenery + entities). Runtime changes go through
/// <see cref="ReconfigureRadii"/>.
/// </summary> /// </summary>
/// <remarks> public int NearRadius { get; private set; }
/// Mutating after the first <see cref="Tick"/> has no effect — the
/// internal <see cref="StreamingRegion"/> snapshots both radii on its
/// constructor. Treat as init-only post-Tick.
/// </remarks>
public int NearRadius { get; }
/// <summary> /// <summary>
/// Far-tier radius (LBs from observer that load terrain only). Set at /// Far-tier radius (LBs from observer that load terrain only).
/// construction; readable thereafter.
/// </summary> /// </summary>
/// <remarks> public int FarRadius { get; private set; }
/// Mutating after the first <see cref="Tick"/> has no effect — see <see cref="NearRadius"/>.
/// </remarks>
public int FarRadius { get; }
/// <summary> /// <summary>
/// Cap on completions drained per <see cref="Tick"/> call. The cap is /// Cap on completions drained per <see cref="Tick"/> call. The cap is
@ -194,22 +188,122 @@ public sealed class StreamingController
Action<uint>? demoteNearLayer = null, Action<uint>? demoteNearLayer = null,
Action? clearPendingLoads = null, Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null, Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null) Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null)
{ {
_enqueueLoad = enqueueLoad; _enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload; _enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions; _drainCompletions = drainCompletions;
_applyTerrain = applyTerrain; _applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_demoteNearLayer = demoteNearLayer;
_clearPendingLoads = clearPendingLoads; _clearPendingLoads = clearPendingLoads;
_onLandblockLoaded = onLandblockLoaded; _onLandblockLoaded = onLandblockLoaded;
_ensureEnvCellMeshes = ensureEnvCellMeshes; _ensureEnvCellMeshes = ensureEnvCellMeshes;
_state = state; _state = state;
_retirements = retirementCoordinator
?? LandblockRetirementCoordinator.CreateLegacy(
state,
removeTerrain,
demoteNearLayer);
NearRadius = nearRadius; NearRadius = nearRadius;
FarRadius = farRadius; FarRadius = farRadius;
} }
/// <summary>
/// Reconciles an active outdoor streaming window to new quality radii
/// without replacing the worker/controller or blindly bootstrapping data
/// already published in <see cref="GpuWorldState"/>. Pending jobs from the
/// old window are invalidated by generation; resident blocks are retained,
/// promoted, demoted, loaded, or unloaded from their actual current tier.
/// A sealed dungeon remains radius-zero until its normal exit edge while
/// remembering the new outdoor radii for that exit.
/// </summary>
public void ReconfigureRadii(int nearRadius, int farRadius)
{
if (nearRadius < 0)
throw new ArgumentOutOfRangeException(nameof(nearRadius));
if (farRadius < nearRadius)
throw new ArgumentOutOfRangeException(
nameof(farRadius),
"Far radius must be greater than or equal to near radius.");
// Queue callbacks are injected seams and can synchronously reenter the
// controller. Last-request-wins deferral keeps the active mutation
// cursor stable; the request is applied after the current transaction
// converges instead of recursively replaying its active step.
if (_advancingRadiiReconfiguration)
{
_deferredRadiiRequest = (nearRadius, farRadius);
return;
}
// A prior callback may have failed after this controller accepted the
// request. Resume that exact transaction before considering another
// target; replacing it would orphan already-admitted generation work.
if (_pendingRadiiReconfiguration is not null)
AdvanceRadiiReconfiguration();
// This explicit call is newer than any request deferred by a prior
// failed attempt and therefore supersedes it.
_deferredRadiiRequest = null;
if (nearRadius == NearRadius && farRadius == FarRadius)
return;
if (_collapsed || _region is null)
{
NearRadius = nearRadius;
FarRadius = farRadius;
return;
}
var rebuilt = new StreamingRegion(
_region.CenterX,
_region.CenterY,
nearRadius,
farRadius);
TwoTierDiff bootstrap = rebuilt.ComputeFirstTickDiff();
var desiredNear = new HashSet<uint>(bootstrap.ToLoadNear);
var desiredFar = new HashSet<uint>(bootstrap.ToLoadFar);
var mutations = new List<RadiiMutation>();
uint[] loaded = [.. _state.LoadedLandblockIds];
for (int i = 0; i < loaded.Length; i++)
{
uint id = loaded[i];
if (desiredNear.Contains(id))
{
if (!_state.IsNearTier(id))
mutations.Add(new RadiiMutation(
() => EnqueueLoad(id, LandblockStreamJobKind.PromoteToNear)));
}
else if (desiredFar.Contains(id))
{
if (_state.IsNearTier(id))
mutations.Add(new RadiiMutation(() => DemoteLandblock(id)));
}
else
{
mutations.Add(new RadiiMutation(() => EnqueueUnload(id)));
}
}
foreach (uint id in desiredNear)
if (!_state.IsLoaded(id))
mutations.Add(new RadiiMutation(
() => EnqueueLoad(id, LandblockStreamJobKind.LoadNear)));
foreach (uint id in desiredFar)
if (!_state.IsLoaded(id))
mutations.Add(new RadiiMutation(
() => EnqueueLoad(id, LandblockStreamJobKind.LoadFar)));
_pendingRadiiReconfiguration = new RadiiReconfiguration(
nearRadius,
farRadius,
rebuilt,
mutations);
AdvanceRadiiReconfiguration();
}
/// <summary> /// <summary>
/// Compatibility constructor for deterministic tests and callers that do /// Compatibility constructor for deterministic tests and callers that do
/// not own an asynchronous worker. Production must use the generation-aware /// not own an asynchronous worker. Production must use the generation-aware
@ -227,7 +321,8 @@ public sealed class StreamingController
Action<uint>? demoteNearLayer = null, Action<uint>? demoteNearLayer = null,
Action? clearPendingLoads = null, Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null, Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null) Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null)
: this( : this(
(id, kind, _) => enqueueLoad(id, kind), (id, kind, _) => enqueueLoad(id, kind),
(id, _) => enqueueUnload(id), (id, _) => enqueueUnload(id),
@ -240,7 +335,8 @@ public sealed class StreamingController
demoteNearLayer, demoteNearLayer,
clearPendingLoads, clearPendingLoads,
onLandblockLoaded, onLandblockLoaded,
ensureEnvCellMeshes) ensureEnvCellMeshes,
retirementCoordinator)
{ {
} }
@ -252,11 +348,7 @@ public sealed class StreamingController
private void DemoteLandblock(uint id) private void DemoteLandblock(uint id)
{ {
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu; uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
// App-owned Near resources need the still-present static entity list _retirements.BeginNearLayer(canonical);
// for exact light/translucency owner cleanup. Retire them before
// GpuWorldState drops the static layer and its render pins.
_demoteNearLayer?.Invoke(canonical);
_state.RemoveEntitiesFromLandblock(canonical);
} }
private void AdvanceGeneration() private void AdvanceGeneration()
@ -280,6 +372,18 @@ public sealed class StreamingController
/// </summary> /// </summary>
public void Tick(int observerCx, int observerCy, bool insideDungeon = false) public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
{ {
if (_pendingRadiiReconfiguration is not null)
AdvanceRadiiReconfiguration();
else if (_deferredRadiiRequest is { } deferred)
{
_deferredRadiiRequest = null;
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
}
// Presentation-owner failures never roll back spatial state. Resume
// unfinished owner ledgers before accepting replacement publications.
_retirements.Advance();
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
if (_collapsed) if (_collapsed)
@ -313,6 +417,125 @@ public sealed class StreamingController
DrainAndApply(); DrainAndApply();
} }
private void AdvanceRadiiReconfiguration()
{
if (_advancingRadiiReconfiguration)
return;
RadiiReconfiguration pending = _pendingRadiiReconfiguration
?? throw new InvalidOperationException("No radii reconfiguration is pending.");
var failures = new List<Exception>();
_advancingRadiiReconfiguration = true;
try
{
if (!pending.GenerationAdvanced)
{
AdvanceGeneration();
pending.GenerationAdvanced = true;
}
if (!pending.PendingLoadsCleared)
{
try
{
_clearPendingLoads?.Invoke();
pending.PendingLoadsCleared = true;
}
catch (Exception error)
{
if (error is StreamingMutationException { MutationCommitted: true })
pending.PendingLoadsCleared = true;
failures.Add(error);
}
}
// Do not admit new-generation work until cancellation of the old
// inbox is durable. Otherwise a successful retry of ClearPendingLoads
// would also erase mutations already marked complete by this ledger.
for (int i = 0; pending.PendingLoadsCleared && i < pending.Mutations.Count; i++)
{
RadiiMutation mutation = pending.Mutations[i];
if (mutation.Completed)
continue;
try
{
mutation.Apply();
mutation.Completed = true;
}
catch (Exception error)
{
if (error is StreamingMutationException { MutationCommitted: true })
mutation.Completed = true;
failures.Add(error);
}
}
bool converged = pending.PendingLoadsCleared
&& pending.Mutations.All(static mutation => mutation.Completed);
if (converged)
{
// Publish the new logical window only after every queue/retirement
// admission is durable. A later Tick can now trust Resident as the
// complete desired set and will never strand a hole.
pending.Region.MarkResidentFromBootstrap();
_region = pending.Region;
NearRadius = pending.NearRadius;
FarRadius = pending.FarRadius;
_deferredApply.Clear();
_pendingRadiiReconfiguration = null;
}
}
finally
{
_advancingRadiiReconfiguration = false;
}
if (failures.Count != 0)
{
throw new AggregateException(
"Streaming quality reconfiguration did not complete cleanly.",
failures);
}
if (_pendingRadiiReconfiguration is null
&& _deferredRadiiRequest is { } deferred)
{
_deferredRadiiRequest = null;
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
}
}
private sealed class RadiiReconfiguration
{
public RadiiReconfiguration(
int nearRadius,
int farRadius,
StreamingRegion region,
List<RadiiMutation> mutations)
{
NearRadius = nearRadius;
FarRadius = farRadius;
Region = region;
Mutations = mutations;
}
public int NearRadius { get; }
public int FarRadius { get; }
public StreamingRegion Region { get; }
public List<RadiiMutation> Mutations { get; }
public bool GenerationAdvanced { get; set; }
public bool PendingLoadsCleared { get; set; }
}
private sealed class RadiiMutation
{
public RadiiMutation(Action apply) => Apply = apply;
public Action Apply { get; }
public bool Completed { get; set; }
}
/// <summary> /// <summary>
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first /// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
/// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called /// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called
@ -400,6 +623,7 @@ public sealed class StreamingController
AdvanceGeneration(); AdvanceGeneration();
_clearPendingLoads?.Invoke(); _clearPendingLoads?.Invoke();
_deferredApply.Clear(); _deferredApply.Clear();
_region = null;
foreach (var id in _state.LoadedLandblockIds) foreach (var id in _state.LoadedLandblockIds)
if (id != centerId) EnqueueUnload(id); if (id != centerId) EnqueueUnload(id);
@ -495,16 +719,15 @@ public sealed class StreamingController
AdvanceGeneration(); AdvanceGeneration();
_clearPendingLoads?.Invoke(); _clearPendingLoads?.Invoke();
_deferredApply.Clear(); _deferredApply.Clear();
// Commit the old region boundary before any presentation owner is
// advanced. New same-id publications are fenced by _retirements.
_region = null;
// Snapshot — RemoveLandblock mutates the loaded set we're iterating. // Snapshot — RemoveLandblock mutates the loaded set we're iterating.
var ids = new List<uint>(_state.LoadedLandblockIds); var ids = new List<uint>(_state.LoadedLandblockIds);
ForceReloadCount++; // [FRAME-DIAG] churn counter ForceReloadCount++; // [FRAME-DIAG] churn counter
LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume
foreach (var id in ids) foreach (var id in ids)
{ _retirements.BeginFull(id);
_state.RemoveLandblock(id);
_removeTerrain?.Invoke(id); // frees the render slot + physics + cell registries
}
_region = null; // NormalTick re-creates + bootstraps the full window next frame
} }
/// <summary> /// <summary>
@ -541,7 +764,9 @@ public sealed class StreamingController
// for direct callers and future drain paths. // for direct callers and future drain paths.
if (IsStaleGeneration(result)) if (IsStaleGeneration(result))
continue; continue;
if (result is LandblockStreamResult.Unloaded if (IsPublicationBlockedByRetirement(result))
_deferredApply.Add(result);
else if (result is LandblockStreamResult.Unloaded
|| IsWithinPriorityRing(ResultLandblockId(result))) || IsWithinPriorityRing(ResultLandblockId(result)))
ApplyResult(result); // free (unload) or behind-the-fade near ring ApplyResult(result); // free (unload) or behind-the-fade near ring
else else
@ -553,9 +778,39 @@ public sealed class StreamingController
// earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't // earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't
// spike. _deferredApply now only ever holds loads — unloads were applied in step 1. // spike. _deferredApply now only ever holds loads — unloads were applied in step 1.
int budget = MaxCompletionsPerFrame; int budget = MaxCompletionsPerFrame;
int i = 0; int applied = 0;
while (i < budget && i < _deferredApply.Count) { ApplyResult(_deferredApply[i]); i++; } int read = 0;
if (i > 0) _deferredApply.RemoveRange(0, i); int write = 0;
try
{
while (applied < budget && read < _deferredApply.Count)
{
LandblockStreamResult result = _deferredApply[read];
if (IsPublicationBlockedByRetirement(result))
{
if (write != read)
_deferredApply[write] = result;
write++;
read++;
continue;
}
// Advance read only after the apply commits. If it throws, the
// finally block removes prior committed entries but retains this
// exact result and the untouched tail for retry.
ApplyResult(result);
read++;
applied++;
}
}
finally
{
// One linear tail shift replaces budget-many RemoveAt shifts. The
// retained blocked prefix and the untouched FIFO tail keep their
// original relative order.
if (read > write)
_deferredApply.RemoveRange(write, read - write);
}
} }
/// <summary> /// <summary>
@ -683,8 +938,7 @@ public sealed class StreamingController
_onLandblockLoaded?.Invoke(promoted.LandblockId); _onLandblockLoaded?.Invoke(promoted.LandblockId);
break; break;
case LandblockStreamResult.Unloaded unloaded: case LandblockStreamResult.Unloaded unloaded:
_state.RemoveLandblock(unloaded.LandblockId); _retirements.BeginFull(unloaded.LandblockId);
_removeTerrain?.Invoke(unloaded.LandblockId);
break; break;
case LandblockStreamResult.Failed failed: case LandblockStreamResult.Failed failed:
Console.WriteLine( Console.WriteLine(
@ -722,6 +976,10 @@ public sealed class StreamingController
result is not LandblockStreamResult.WorkerCrashed result is not LandblockStreamResult.WorkerCrashed
&& result.Generation != _generation; && result.Generation != _generation;
private bool IsPublicationBlockedByRetirement(LandblockStreamResult result) =>
result is LandblockStreamResult.Loaded or LandblockStreamResult.Promoted
&& _retirements.IsPending(result.LandblockId);
/// <summary> /// <summary>
/// Returns the landblock id associated with <paramref name="result"/>. /// Returns the landblock id associated with <paramref name="result"/>.
/// For <see cref="LandblockStreamResult.WorkerCrashed"/> this is 0 by /// For <see cref="LandblockStreamResult.WorkerCrashed"/> this is 0 by

View file

@ -0,0 +1,20 @@
namespace AcDream.App.Streaming;
/// <summary>
/// Reports whether a streaming queue/retirement mutation crossed its commit
/// point before a callback failed. Retry ledgers use this to avoid submitting
/// the same generation operation twice after a post-commit diagnostic error.
/// </summary>
public sealed class StreamingMutationException : Exception
{
public StreamingMutationException(
string message,
bool mutationCommitted,
Exception? innerException = null)
: base(message, innerException)
{
MutationCommitted = mutationCommitted;
}
public bool MutationCommitted { get; }
}

View file

@ -6,6 +6,7 @@ using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Settings;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.Studio; namespace AcDream.App.Studio;
@ -51,7 +52,7 @@ public static class FixtureProvider
ImportedLayout layout, ImportedLayout layout,
RenderStack stack, RenderStack stack,
ClientObjectTable objects, ClientObjectTable objects,
DatCollection dats) IDatReaderWriter dats)
{ {
switch (layoutId) switch (layoutId)
{ {

View file

@ -1,6 +1,7 @@
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.Studio; namespace AcDream.App.Studio;
@ -18,7 +19,7 @@ public enum LayoutSourceKind { DatLayout, Markup }
/// </summary> /// </summary>
public sealed class LayoutSource public sealed class LayoutSource
{ {
private readonly DatCollection _dats; private readonly IDatReaderWriter _dats;
private readonly Func<uint, (uint, int, int)> _resolve; private readonly Func<uint, (uint, int, int)> _resolve;
private readonly UiDatFont? _datFont; private readonly UiDatFont? _datFont;
private readonly Func<uint, UiDatFont?>? _fontResolve; private readonly Func<uint, UiDatFont?>? _fontResolve;
@ -41,7 +42,7 @@ public sealed class LayoutSource
/// Pass null (default) for the original single-font behavior — the live /// Pass null (default) for the original single-font behavior — the live
/// <see cref="GameWindow"/> path passes null so it is provably unchanged.</param> /// <see cref="GameWindow"/> path passes null so it is provably unchanged.</param>
public LayoutSource( public LayoutSource(
DatCollection dats, IDatReaderWriter dats,
Func<uint, (uint, int, int)> resolve, Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont, UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null) Func<uint, UiDatFont?>? fontResolve = null)

View file

@ -5,6 +5,7 @@ using AcDream.Core.Chat;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Chat;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
using System.Numerics; using System.Numerics;
namespace AcDream.App.Studio; namespace AcDream.App.Studio;
@ -17,7 +18,7 @@ namespace AcDream.App.Studio;
/// </summary> /// </summary>
internal static class MockupDesktop internal static class MockupDesktop
{ {
public static void Load(DatCollection dats, RenderStack stack) public static void Load(IDatReaderWriter dats, RenderStack stack)
{ {
var objects = SampleData.BuildObjectTable(); var objects = SampleData.BuildObjectTable();
var windows = new List<MockupWindow>(); var windows = new List<MockupWindow>();
@ -36,11 +37,11 @@ internal static class MockupDesktop
MountControls(stack, windows); MountControls(stack, windows);
} }
private static ImportedLayout? Import(DatCollection dats, uint layoutId, RenderStack stack) private static ImportedLayout? Import(IDatReaderWriter dats, uint layoutId, RenderStack stack)
=> LayoutImporter.Import(dats, layoutId, stack.ResolveChrome, stack.VitalsDatFont, => LayoutImporter.Import(dats, layoutId, stack.ResolveChrome, stack.VitalsDatFont,
fontResolve: stack.ResolveDatFont); fontResolve: stack.ResolveDatFont);
private static RetailWindowHandle? MountVitals(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) private static RetailWindowHandle? MountVitals(IDatReaderWriter dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{ {
var layout = Import(dats, 0x2100006Cu, stack); var layout = Import(dats, 0x2100006Cu, stack);
if (layout is null) return null; if (layout is null) return null;
@ -66,7 +67,7 @@ internal static class MockupDesktop
}); });
} }
private static RetailWindowHandle? MountToolbar(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) private static RetailWindowHandle? MountToolbar(IDatReaderWriter dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{ {
var layout = Import(dats, 0x21000016u, stack); var layout = Import(dats, 0x21000016u, stack);
if (layout is null) return null; if (layout is null) return null;
@ -136,7 +137,7 @@ internal static class MockupDesktop
frame.MaxHeight = expandedH; frame.MaxHeight = expandedH;
} }
private static RetailWindowHandle? MountCharacter(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) private static RetailWindowHandle? MountCharacter(IDatReaderWriter dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{ {
var layout = Import(dats, 0x2100002Eu, stack); var layout = Import(dats, 0x2100002Eu, stack);
if (layout is null) return null; if (layout is null) return null;
@ -164,7 +165,7 @@ internal static class MockupDesktop
}); });
} }
private static RetailWindowHandle? MountInventory(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) private static RetailWindowHandle? MountInventory(IDatReaderWriter dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{ {
var layout = Import(dats, 0x21000023u, stack); var layout = Import(dats, 0x21000023u, stack);
if (layout is null) return null; if (layout is null) return null;
@ -219,7 +220,7 @@ internal static class MockupDesktop
element.Anchors = AnchorEdges.Left | AnchorEdges.Top; element.Anchors = AnchorEdges.Left | AnchorEdges.Top;
} }
private static RetailWindowHandle? MountChat(DatCollection dats, RenderStack stack) private static RetailWindowHandle? MountChat(IDatReaderWriter dats, RenderStack stack)
{ {
var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId); var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId);
if (rootInfo is null) return null; if (rootInfo is null) return null;

View file

@ -1,8 +1,8 @@
using System.Numerics; using System.Numerics;
using AcDream.Content;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.UI; using AcDream.App.UI;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.Options;
using Silk.NET.Input; using Silk.NET.Input;
using Silk.NET.Maths; using Silk.NET.Maths;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
@ -39,7 +39,7 @@ public sealed class StudioWindow : IDisposable
// Created in OnLoad, released in OnClosing. // Created in OnLoad, released in OnClosing.
private IWindow? _window; private IWindow? _window;
private DatCollection? _dats; private IDatReaderWriter? _dats;
private RenderStack? _stack; private RenderStack? _stack;
private LayoutSource? _source; private LayoutSource? _source;
@ -113,7 +113,7 @@ public sealed class StudioWindow : IDisposable
{ {
var gl = GL.GetApi(_window!); var gl = GL.GetApi(_window!);
_dats = new DatCollection(_opts.DatDir, DatAccessType.Read); _dats = RuntimeDatCollectionFactory.OpenReadOnly(_opts.DatDir);
// Build QualitySettings for RenderBootstrap (same as Run() above — re-read // Build QualitySettings for RenderBootstrap (same as Run() above — re-read
// after the GL context is confirmed, mirroring GameWindow.OnLoad). // after the GL context is confirmed, mirroring GameWindow.OnLoad).
@ -218,6 +218,10 @@ public sealed class StudioWindow : IDisposable
private void OnRender(double dt) private void OnRender(double dt)
{ {
if (_stack is null || _panelFbo is null) return; if (_stack is null || _panelFbo is null) return;
_stack.BeginFrame();
Exception? renderFailure = null;
try
{
// ── HEADLESS SCREENSHOT PATH ────────────────────────────────────────────── // ── HEADLESS SCREENSHOT PATH ──────────────────────────────────────────────
if (_opts.ScreenshotPath is not null) if (_opts.ScreenshotPath is not null)
@ -415,6 +419,26 @@ public sealed class StudioWindow : IDisposable
// 9. Finalise ImGui and flush draw data to the window. // 9. Finalise ImGui and flush draw data to the window.
_imgui.Render(); _imgui.Render();
}
catch (Exception ex)
{
renderFailure = ex;
throw;
}
finally
{
try
{
_stack.EndFrame();
}
catch (Exception closeFailure) when (renderFailure is not null)
{
throw new AggregateException(
"UI Studio rendering failed and its GPU frame could not be closed.",
renderFailure,
closeFailure);
}
}
} }
/// <summary> /// <summary>

View file

@ -6,6 +6,7 @@ using AcDream.Core.Items;
using AcDream.Core.Textures; using AcDream.Core.Textures;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
namespace AcDream.App.UI; namespace AcDream.App.UI;
@ -32,7 +33,7 @@ namespace AcDream.App.UI;
/// </summary> /// </summary>
public sealed class IconComposer public sealed class IconComposer
{ {
private readonly DatCollection _dats; private readonly IDatReaderWriter _dats;
private readonly TextureCache _cache; private readonly TextureCache _cache;
private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new(); private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new();
private readonly Dictionary<(uint, uint, uint), ComposedIcon> _dragByTuple = new(); private readonly Dictionary<(uint, uint, uint), ComposedIcon> _dragByTuple = new();
@ -59,7 +60,7 @@ public sealed class IconComposer
private readonly Dictionary<uint, uint> _effectDidByIndex = new(); private readonly Dictionary<uint, uint> _effectDidByIndex = new();
private readonly Dictionary<uint, DecodedTexture> _effectTileByDid = new(); private readonly Dictionary<uint, DecodedTexture> _effectTileByDid = new();
public IconComposer(DatCollection dats, TextureCache cache) public IconComposer(IDatReaderWriter dats, TextureCache cache)
{ {
_dats = dats; _dats = dats;
_cache = cache; _cache = cache;
@ -93,7 +94,7 @@ public sealed class IconComposer
{ {
if (_underlayResolveTried) return; if (_underlayResolveTried) return;
_underlayResolveTried = true; _underlayResolveTried = true;
uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000 uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId; // = 0x25000000
if (masterDid == 0) return; if (masterDid == 0) return;
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return; if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return;
if (!master.ClientEnumToID.TryGetValue(0x10000004u, out var subDid)) return; // → 0x25000008 if (!master.ClientEnumToID.TryGetValue(0x10000004u, out var subDid)) return; // → 0x25000008
@ -125,7 +126,7 @@ public sealed class IconComposer
{ {
if (_effectResolveTried) return; if (_effectResolveTried) return;
_effectResolveTried = true; _effectResolveTried = true;
uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000 uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId; // = 0x25000000
if (masterDid == 0) return; if (masterDid == 0) return;
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return; if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return;
if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009 if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Player; using AcDream.Core.Player;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -186,7 +187,7 @@ public sealed class CharacterSheetProvider
/// never silently swallowed — and leave raise costs unavailable (0). /// never silently swallowed — and leave raise costs unavailable (0).
/// </summary> /// </summary>
public static DatReaderWriter.DBObjs.ExperienceTable? LoadExperienceTable( public static DatReaderWriter.DBObjs.ExperienceTable? LoadExperienceTable(
DatCollection dats, Action<string>? log = null) IDatReaderWriter dats, Action<string>? log = null)
{ {
if (dats is null) return null; if (dats is null) return null;

View file

@ -75,6 +75,17 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
private ChatChannelKind _activeChannel = ChatChannelKind.Say; private ChatChannelKind _activeChannel = ChatChannelKind.Say;
// UiText polls LinesProvider while drawing and hit-testing. Keep the fully
// formatted + wrapped transcript until either its source revision or the
// metrics that determine wrapping change. This makes an idle chat window
// allocation-free instead of snapshotting/formatting/wrapping every frame.
private IReadOnlyList<UiText.Line> _cachedTranscriptLines = Array.Empty<UiText.Line>();
private long _cachedTranscriptRevision = -1;
private float _cachedTranscriptWrapWidth = float.NaN;
private UiDatFont? _cachedTranscriptDatFont;
private BitmapFont? _cachedTranscriptDebugFont;
internal int TranscriptLayoutBuildCount { get; private set; }
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ── // ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems = private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
@ -215,7 +226,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
c.Transcript.OneLine = false; c.Transcript.OneLine = false;
c.Transcript.Selectable = true; c.Transcript.Selectable = true;
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont); c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm);
// ── Input ──────────────────────────────────────────────────────── // ── Input ────────────────────────────────────────────────────────
// Editable/selectable/one-line semantics and state sprites came from the // Editable/selectable/one-line semantics and state sprites came from the
@ -415,20 +426,35 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
/// <see cref="UiText.Line"/> record format, applying retail-faithful /// <see cref="UiText.Line"/> record format, applying retail-faithful
/// per-<see cref="ChatKind"/> colors. /// per-<see cref="ChatKind"/> colors.
/// </summary> /// </summary>
private static IReadOnlyList<UiText.Line> BuildLines( private IReadOnlyList<UiText.Line> GetTranscriptLines(ChatVM vm)
ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont)
{ {
float maxW = Transcript.Width - 2f * Transcript.Padding;
UiDatFont? datFont = Transcript.DatFont;
BitmapFont? debugFont = Transcript.Font;
long revision = vm.Revision;
if (_cachedTranscriptRevision == revision
&& _cachedTranscriptWrapWidth.Equals(maxW)
&& ReferenceEquals(_cachedTranscriptDatFont, datFont)
&& ReferenceEquals(_cachedTranscriptDebugFont, debugFont))
{
return _cachedTranscriptLines;
}
var detailed = vm.RecentLinesDetailed(); var detailed = vm.RecentLinesDetailed();
if (detailed.Count == 0) return Array.Empty<UiText.Line>(); if (detailed.Count == 0)
{
return StoreTranscriptLayout(
Array.Empty<UiText.Line>(), revision, maxW, datFont, debugFont);
}
// Word-wrap each message to the transcript's current pixel width (ports retail // Word-wrap each message to the transcript's current pixel width (ports retail
// GlyphList::Recalculate @0x473800 — break at word boundaries when the line would // GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
// exceed wrapWidth). Re-evaluated each frame so wrapping follows window resize. // exceed wrapWidth). The cache key re-evaluates it after window resize.
float maxW = view.Width - 2f * view.Padding;
Func<string, float> measure = Func<string, float> measure =
datFont is { } df ? s => df.MeasureWidth(s) datFont is { } df ? s => df.MeasureWidth(s)
: debugFont is { } bf ? s => bf.MeasureWidth(s) : debugFont is { } bf ? s => bf.MeasureWidth(s)
: s => s.Length * 7f; : static s => s.Length * 7f;
var result = new List<UiText.Line>(detailed.Count); var result = new List<UiText.Line>(detailed.Count);
foreach (var d in detailed) foreach (var d in detailed)
@ -437,7 +463,23 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
foreach (var frag in WrapText(d.Text, maxW, measure)) foreach (var frag in WrapText(d.Text, maxW, measure))
result.Add(new UiText.Line(frag, color)); result.Add(new UiText.Line(frag, color));
} }
return result; return StoreTranscriptLayout(result, revision, maxW, datFont, debugFont);
}
private IReadOnlyList<UiText.Line> StoreTranscriptLayout(
IReadOnlyList<UiText.Line> lines,
long revision,
float wrapWidth,
UiDatFont? datFont,
BitmapFont? debugFont)
{
_cachedTranscriptRevision = revision;
_cachedTranscriptWrapWidth = wrapWidth;
_cachedTranscriptDatFont = datFont;
_cachedTranscriptDebugFont = debugFont;
_cachedTranscriptLines = lines;
TranscriptLayoutBuildCount++;
return lines;
} }
/// <summary> /// <summary>

View file

@ -1,4 +1,5 @@
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -62,7 +63,7 @@ public sealed class ComponentBookTemplateFactory
/// so later inventory refreshes instantiate rows without reading DAT files. /// so later inventory refreshes instantiate rows without reading DAT files.
/// </summary> /// </summary>
public static ComponentBookTemplateFactory? TryLoad( public static ComponentBookTemplateFactory? TryLoad(
DatCollection dats, IDatReaderWriter dats,
Func<uint, (uint tex, int w, int h)> resolveSprite, Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont, UiDatFont? defaultFont,
Func<uint, UiDatFont?>? resolveFont) Func<uint, UiDatFont?>? resolveFont)

View file

@ -1,4 +1,5 @@
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -14,10 +15,10 @@ namespace AcDream.App.UI.Layout;
/// </remarks> /// </remarks>
public sealed class DatStringResolver public sealed class DatStringResolver
{ {
private readonly DatCollection _dats; private readonly IDatReaderWriter _dats;
private readonly Dictionary<uint, StringTable?> _tables = new(); private readonly Dictionary<uint, StringTable?> _tables = new();
public DatStringResolver(DatCollection dats) public DatStringResolver(IDatReaderWriter dats)
=> _dats = dats ?? throw new ArgumentNullException(nameof(dats)); => _dats = dats ?? throw new ArgumentNullException(nameof(dats));
public string? Resolve(UiStringInfoValue info) public string? Resolve(UiStringInfoValue info)

View file

@ -1,4 +1,5 @@
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -31,7 +32,7 @@ public sealed class EffectRowTemplateFactory
public float Height => _template.Height; public float Height => _template.Height;
public static EffectRowTemplateFactory? TryLoad( public static EffectRowTemplateFactory? TryLoad(
DatCollection dats, IDatReaderWriter dats,
Func<uint, (uint tex, int w, int h)> resolveSprite, Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont, UiDatFont? defaultFont,
Func<uint, UiDatFont?>? resolveFont) Func<uint, UiDatFont?>? resolveFont)

Some files were not shown because too many files have changed in this diff Show more