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:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
|
|
@ -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 60–80 FPS.
|
revisit, and held its normal 60–80 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 5–12 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 25–30 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 125–153 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 60–80 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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -561,7 +561,7 @@ behavior. Estimated 17–26 days focused work, 3–5 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 3–12 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 125–153 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 25–30 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 125–153 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.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,19 +73,28 @@ 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);
|
||||||
|
// 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);
|
publishRootPose(entity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #184 Slice 2a — the per-remote DR tick (retail <c>UpdateObjectInternal</c>
|
/// #184 Slice 2a — the per-remote DR tick (retail <c>UpdateObjectInternal</c>
|
||||||
|
|
|
||||||
121
src/AcDream.App/Rendering/BoundedUnownedResourceCache.cs
Normal file
121
src/AcDream.App/Rendering/BoundedUnownedResourceCache.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
}
|
||||||
{
|
|
||||||
_gl = gl; // captured for Dispose (single context for the frame's lifetime)
|
/// <summary>
|
||||||
_regionSsbo = gl.GenBuffer();
|
/// Allocates the current frame slot's terrain arena before any draw uses it.
|
||||||
_terrainUbo = gl.GenBuffer();
|
/// The arena has one alignment-safe range per subsequent
|
||||||
_glInitialized = true;
|
/// <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)
|
||||||
|
{
|
||||||
|
GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query (precondition)");
|
||||||
|
gl.GetInteger(GetPName.UniformBufferOffsetAlignment, out int alignment);
|
||||||
|
GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query");
|
||||||
|
_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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) >= 0</c> for every plane.</summary>
|
/// A clip-space point <c>clip</c> is inside iff <c>Vector4.Dot(plane, clip) >= 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"/> > 0 or when the region is empty.</summary>
|
/// instead (draw the box). Always false when <see cref="Count"/> > 0 or when the region is empty.</summary>
|
||||||
public bool UseScissorFallback { get; }
|
public bool UseScissorFallback { get; }
|
||||||
|
|
@ -119,25 +124,53 @@ 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);
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reduce one convex view polygon without wrapping it in a temporary <see cref="CellView"/>.
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
// Exactly one polygon: normalize winding to CCW and merge collinear edges in reusable
|
||||||
|
// scratch. The surviving vertices are consumed immediately to build the actual plane
|
||||||
|
// payload; there is no reason to allocate a second exact-sized polygon array per slice.
|
||||||
|
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);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int count = NormalizeAndMerge(input, verts);
|
||||||
|
|
||||||
// Fewer than 3 distinct edges survive ⇒ a sliver/line with no area. There is no
|
// Fewer than 3 distinct edges survive ⇒ a sliver/line with no area. There is no
|
||||||
// meaningful AABB to over-include (a zero-area region), so treat it as nothing visible.
|
// meaningful AABB to over-include (a zero-area region), so treat it as nothing visible.
|
||||||
if (verts.Length < 3)
|
if (count < 3)
|
||||||
return Empty;
|
return Empty;
|
||||||
|
|
||||||
|
ReadOnlySpan<Vector2> normalized = verts[..count];
|
||||||
|
|
||||||
// A single convex polygon with too many edges to fit the hardware budget ⇒ scissor
|
// 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).
|
// on ITS own AABB (still a superset of the polygon → over-include, safe).
|
||||||
if (verts.Length > MaxPlanes)
|
if (count > MaxPlanes)
|
||||||
return Scissor(verts);
|
return Scissor(normalized);
|
||||||
|
|
||||||
// 3..8 edges: emit one inward half-space plane per edge (CCW formula).
|
// 3..8 edges: emit one inward half-space plane per edge (CCW formula). This array is
|
||||||
var planes = new Vector4[verts.Length];
|
// the retained GPU-routing payload and therefore the one necessary allocation.
|
||||||
for (int i = 0; i < verts.Length; i++)
|
var planes = new Vector4[count];
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
{
|
{
|
||||||
Vector2 p = verts[i];
|
Vector2 p = normalized[i];
|
||||||
Vector2 q = verts[(i + 1) % verts.Length];
|
Vector2 q = normalized[(i + 1) % count];
|
||||||
Vector2 dir = q - p;
|
Vector2 dir = q - p;
|
||||||
// Inward normal for CCW winding: perp(dir) = (-dir.y, dir.x) points to the polygon's
|
// 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).
|
// interior (the "left" side of the directed edge p→q).
|
||||||
|
|
@ -148,6 +181,12 @@ public readonly struct ClipPlaneSet
|
||||||
}
|
}
|
||||||
return new ClipPlaneSet(planes, useScissorFallback: false, isNothingVisible: false, scissorNdcAabb: DegenerateAabb);
|
return new ClipPlaneSet(planes, useScissorFallback: false, isNothingVisible: false, scissorNdcAabb: DegenerateAabb);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (rented is not null)
|
||||||
|
ArrayPool<Vector2>.Shared.Return(rented);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static ClipPlaneSet Scissor(float minX, float minY, float maxX, float maxY) =>
|
private static ClipPlaneSet Scissor(float minX, float minY, float maxX, float maxY) =>
|
||||||
new(Array.Empty<Vector4>(), useScissorFallback: true, isNothingVisible: false,
|
new(Array.Empty<Vector4>(), useScissorFallback: true, isNothingVisible: false,
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
933
src/AcDream.App/Rendering/CompositeTextureArrayCache.cs
Normal file
933
src/AcDream.App/Rendering/CompositeTextureArrayCache.cs
Normal 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);
|
||||||
|
}
|
||||||
30
src/AcDream.App/Rendering/DynamicBufferCapacity.cs
Normal file
30
src/AcDream.App/Rendering/DynamicBufferCapacity.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
31
src/AcDream.App/Rendering/FixedEntityTextureOwnerLease.cs
Normal file
31
src/AcDream.App/Rendering/FixedEntityTextureOwnerLease.cs
Normal 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);
|
||||||
|
}
|
||||||
155
src/AcDream.App/Rendering/FramePacingController.cs
Normal file
155
src/AcDream.App/Rendering/FramePacingController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/AcDream.App/Rendering/FramePacingPolicy.cs
Normal file
31
src/AcDream.App/Rendering/FramePacingPolicy.cs
Normal 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
486
src/AcDream.App/Rendering/GpuFrameFlightController.cs
Normal file
486
src/AcDream.App/Rendering/GpuFrameFlightController.cs
Normal 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);
|
||||||
|
}
|
||||||
89
src/AcDream.App/Rendering/GpuRetiredTerrainSlotAllocator.cs
Normal file
89
src/AcDream.App/Rendering/GpuRetiredTerrainSlotAllocator.cs
Normal 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();
|
||||||
|
}
|
||||||
36
src/AcDream.App/Rendering/OrderedResourceTeardown.cs
Normal file
36
src/AcDream.App/Rendering/OrderedResourceTeardown.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/AcDream.App/Rendering/OwnerScopedResourceRegistry.cs
Normal file
61
src/AcDream.App/Rendering/OwnerScopedResourceRegistry.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
139
src/AcDream.App/Rendering/ParticleEmitterRetirementTracker.cs
Normal file
139
src/AcDream.App/Rendering/ParticleEmitterRetirementTracker.cs
Normal 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
|
|
@ -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;
|
||||||
|
|
||||||
|
_disposeRequested = true;
|
||||||
|
List<Exception>? failures = null;
|
||||||
|
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;
|
_disposed = true;
|
||||||
foreach (uint gfxObjId in _gfxByEmitter.Values)
|
|
||||||
_decrement(gfxObjId);
|
if (failures is not null)
|
||||||
_gfxByEmitter.Clear();
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
/// <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
|
||||||
|
{
|
||||||
|
vao = TrackedGlResource.CreateVertexArray(
|
||||||
|
_gl,
|
||||||
|
"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
|
unsafe
|
||||||
{
|
{
|
||||||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
|
||||||
(nuint)(MaxFanVerts * 3 * sizeof(float)), null, BufferUsageARB.DynamicDraw);
|
|
||||||
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), (void*)0);
|
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), (void*)0);
|
||||||
}
|
}
|
||||||
_gl.EnableVertexAttribArray(0);
|
_gl.EnableVertexAttribArray(0);
|
||||||
_gl.BindVertexArray(0);
|
_gl.BindVertexArray(0);
|
||||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
154
src/AcDream.App/Rendering/PortalMeshReferenceOwner.cs
Normal file
154
src/AcDream.App/Rendering/PortalMeshReferenceOwner.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,23 +129,48 @@ 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;
|
return ndc;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
vectorPool.Return(first);
|
||||||
|
if (second is not null)
|
||||||
|
vectorPool.Return(second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Faithful homogeneous projection (retail PrimD3DRender::xformStart + the W=0 clip of
|
/// <summary>Faithful homogeneous projection (retail PrimD3DRender::xformStart + the W=0 clip of
|
||||||
/// ACRender::polyClipFinish, decomp 424310 / 702749): transform the portal to clip space and clip
|
/// ACRender::polyClipFinish, decomp 424310 / 702749): transform the portal to clip space and clip
|
||||||
|
|
@ -89,24 +191,83 @@ public static class PortalProjection
|
||||||
/// when some vertex has w < 0; <3 survivors → reject (empty).</para></summary>
|
/// when some vertex has w < 0; <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).
|
||||||
|
ReadOnlySpan<Vector4> result = transformed.AsSpan(0, localPoly.Count);
|
||||||
if (anyBehind)
|
if (anyBehind)
|
||||||
clip = ClipPlane(clip, v => v.W);
|
{
|
||||||
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,56 +281,129 @@ 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>();
|
return System.Array.Empty<Vector2>();
|
||||||
ndc[i] = v;
|
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>();
|
||||||
|
|
||||||
|
// 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 vertex merge — Render::copy_view
|
// T2 (BR-4): retail's post-divide ~1-pixel vertex merge. Compact in place; only a
|
||||||
// (Ghidra 0x0054dfc0) collapses consecutive vertices closer than ~1
|
// genuinely shortened result needs a second exactly-sized output array.
|
||||||
// PIXEL (|dx|<=1 && |dy|<=1 screen units) after the perspective divide.
|
int mergedCount = MergeSubPixelVertices(ndc);
|
||||||
// This is the flood's physical fixpoint floor: re-clipping a view can
|
if (mergedCount < 3)
|
||||||
// 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>();
|
return System.Array.Empty<Vector2>();
|
||||||
|
Vector2[] merged = vertexStore?.Rent(mergedCount)
|
||||||
|
?? GC.AllocateUninitializedArray<Vector2>(mergedCount);
|
||||||
|
ndc[..mergedCount].CopyTo(merged);
|
||||||
|
|
||||||
EnsureCcw(merged);
|
EnsureCcw(merged);
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
vector4Pool.Return(first);
|
||||||
|
if (second is not null)
|
||||||
|
vector4Pool.Return(second);
|
||||||
|
if (ndcScratch is not null)
|
||||||
|
vector2Pool.Return(ndcScratch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Retail copy_view's ~1-pixel vertex merge (see ClipToRegion). Collapses
|
// Retail copy_view's ~1-pixel vertex merge (see ClipToRegion). Collapses
|
||||||
// runs of consecutive near-identical vertices, including across the
|
// runs of consecutive near-identical vertices, including across the
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
|
_disposeRequested = true;
|
||||||
|
_disposing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_visible = false;
|
||||||
_animationHooks.Clear();
|
_animationHooks.Clear();
|
||||||
foreach (uint gfxObjId in _registeredGfxObjects)
|
_sequence.ClearAnimations();
|
||||||
_meshAdapter.DecrementRefCount(gfxObjId);
|
_meshReferences.Dispose();
|
||||||
|
if (_meshReferences.IsDisposed)
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_disposing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
{
|
||||||
|
var point = new SnappedPoint(
|
||||||
|
(int)MathF.Round(vertex.X / DedupGridNdc),
|
||||||
|
(int)MathF.Round(vertex.Y / DedupGridNdc));
|
||||||
|
if (count == 0 || points[count - 1] != point)
|
||||||
|
points[count++] = point;
|
||||||
}
|
}
|
||||||
if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1);
|
if (count >= 2 && points[count - 1] == points[0])
|
||||||
|
count--;
|
||||||
|
if (count < 2)
|
||||||
|
return CanonicalKeyResult.Degenerate;
|
||||||
|
|
||||||
// Snapshot the distinct snapped points BEFORE collinear removal — the all-collinear
|
SnappedPoint lo = points[0];
|
||||||
// fallback keys off the segment EXTREMES of the full point set (stable across
|
SnappedPoint hi = points[0];
|
||||||
// re-emissions regardless of the removal loop's order).
|
for (int i = 1; i < count; i++)
|
||||||
List<(int X, int Y)>? preCollinear = pts.Count >= 2 ? new List<(int, int)>(pts) : null;
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// 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;
|
bool removed = true;
|
||||||
while (removed && pts.Count >= 3)
|
while (removed && count >= 3)
|
||||||
{
|
{
|
||||||
removed = false;
|
removed = false;
|
||||||
for (int i = 0; i < pts.Count && pts.Count >= 3; i++)
|
for (int i = 0; i < count && count >= 3; i++)
|
||||||
{
|
{
|
||||||
var prev = pts[(i + pts.Count - 1) % pts.Count];
|
SnappedPoint previous = points[(i + count - 1) % count];
|
||||||
var cur = pts[i];
|
SnappedPoint current = points[i];
|
||||||
var next = pts[(i + 1) % pts.Count];
|
SnappedPoint next = points[(i + 1) % count];
|
||||||
long cross = (long)(cur.X - prev.X) * (next.Y - cur.Y)
|
long cross = (long)(current.X - previous.X) * (next.Y - current.Y)
|
||||||
- (long)(cur.Y - prev.Y) * (next.X - cur.X);
|
- (long)(current.Y - previous.Y) * (next.X - current.X);
|
||||||
if (cross == 0)
|
if (cross != 0)
|
||||||
{
|
continue;
|
||||||
pts.RemoveAt(i);
|
|
||||||
|
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
|
if (lo == hi)
|
||||||
// degenerate-view propagation works (see method doc). Extremes are the
|
return CanonicalKeyResult.Degenerate;
|
||||||
// lexicographic min/max of the full snapped point set.
|
Span<SnappedPoint> segment = stackalloc SnappedPoint[2] { lo, hi };
|
||||||
if (preCollinear is null) return null;
|
return AddCanonicalKey(PolygonKeyKind.Line, segment, start: 0);
|
||||||
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 (q.X > hi.X || (q.X == hi.X && q.Y > hi.Y)) hi = q;
|
|
||||||
}
|
|
||||||
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 n = pts.Count;
|
|
||||||
int best = 0;
|
int best = 0;
|
||||||
for (int s = 1; s < n; s++)
|
for (int start = 1; start < count; start++)
|
||||||
if (RotationLess(pts, s, best, n)) best = s;
|
if (RotationLess(points, start, best, count)) best = start;
|
||||||
|
|
||||||
var sb = new StringBuilder(n * 10);
|
return AddCanonicalKey(PolygonKeyKind.Polygon, points[..count], best);
|
||||||
for (int i = 0; i < n; i++)
|
}
|
||||||
{
|
finally
|
||||||
var q = pts[(best + i) % n];
|
{
|
||||||
sb.Append(q.X).Append(',').Append(q.Y).Append(';');
|
if (rented is not null)
|
||||||
|
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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
public int Count => _items.Count;
|
index--;
|
||||||
|
items.Insert(index, (cell, distance));
|
||||||
public void Insert(LoadedCell cell, float distance)
|
|
||||||
{
|
|
||||||
// Find the slot: scan from the tail (nearest) toward the head while existing entries are
|
|
||||||
// strictly nearer than `distance`, so the newcomer lands just ABOVE every entry that is
|
|
||||||
// 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()
|
private static LoadedCell PopNearest(List<(LoadedCell Cell, float Distance)> items)
|
||||||
{
|
{
|
||||||
int last = _items.Count - 1;
|
int last = items.Count - 1;
|
||||||
var cell = _items[last].Cell;
|
LoadedCell cell = items[last].Cell;
|
||||||
_items.RemoveAt(last);
|
items.RemoveAt(last);
|
||||||
return cell;
|
return cell;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
104
src/AcDream.App/Rendering/ResourceShutdownTransaction.cs
Normal file
104
src/AcDream.App/Rendering/ResourceShutdownTransaction.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
242
src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs
Normal file
242
src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
_gl,
|
||||||
|
"creating terrain global VAO");
|
||||||
|
_globalVbo = TrackedGlResource.CreateBuffer(
|
||||||
|
_gl,
|
||||||
|
"creating terrain global vertex buffer");
|
||||||
|
_globalEbo = TrackedGlResource.CreateBuffer(
|
||||||
|
_gl,
|
||||||
|
"creating terrain global index buffer");
|
||||||
AllocateGpuBuffers(initialSlotCapacity);
|
AllocateGpuBuffers(initialSlotCapacity);
|
||||||
ConfigureVao();
|
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,10 +268,16 @@ 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);
|
||||||
|
bool published = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
if (needsGrow)
|
if (needsGrow)
|
||||||
{
|
{
|
||||||
int newCap = Math.Max(_alloc.Capacity * 2, slot + 1);
|
int newCap = Math.Max(_alloc.Capacity * 2, slot + 1);
|
||||||
|
|
@ -179,21 +307,29 @@ 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)
|
||||||
{
|
{
|
||||||
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboByteOffset,
|
TrackedGlResource.UpdateBufferSubData(
|
||||||
(nuint)(VertsPerLandblock * VertexSize), p);
|
_gl,
|
||||||
|
BufferTargetARB.ArrayBuffer,
|
||||||
|
_globalVbo,
|
||||||
|
vboByteOffset,
|
||||||
|
VertsPerLandblock * VertexSize,
|
||||||
|
p,
|
||||||
|
$"uploading terrain vertices for 0x{landblockId:X8}");
|
||||||
}
|
}
|
||||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
|
||||||
|
|
||||||
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo);
|
|
||||||
fixed (uint* p = bakedIndices)
|
fixed (uint* p = bakedIndices)
|
||||||
{
|
{
|
||||||
_gl.BufferSubData(BufferTargetARB.ElementArrayBuffer, eboByteOffset,
|
TrackedGlResource.UpdateBufferSubData(
|
||||||
(nuint)(IndicesPerLandblock * IndexSize), p);
|
_gl,
|
||||||
|
BufferTargetARB.ElementArrayBuffer,
|
||||||
|
_globalEbo,
|
||||||
|
eboByteOffset,
|
||||||
|
IndicesPerLandblock * IndexSize,
|
||||||
|
p,
|
||||||
|
$"uploading terrain indices for 0x{landblockId:X8}");
|
||||||
}
|
}
|
||||||
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
|
|
||||||
|
|
||||||
_slots[slot] = new SlotData
|
_slots[slot] = new SlotData
|
||||||
{
|
{
|
||||||
|
|
@ -205,15 +341,32 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
|
||||||
AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax),
|
AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax),
|
||||||
};
|
};
|
||||||
_idToSlot[landblockId] = slot;
|
_idToSlot[landblockId] = slot;
|
||||||
|
published = true;
|
||||||
|
|
||||||
|
if (replacing)
|
||||||
|
{
|
||||||
|
_slots[replacedSlot] = null;
|
||||||
|
_alloc.FreeAfterGpuUse(replacedSlot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!published)
|
||||||
|
_alloc.ReleaseUnsubmitted(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,
|
||||||
|
"creating grown terrain index buffer");
|
||||||
|
TrackedGlResource.AllocateBufferStorage(
|
||||||
|
_gl,
|
||||||
|
BufferTargetARB.ElementArrayBuffer,
|
||||||
|
newEbo,
|
||||||
|
0,
|
||||||
|
newEboBytes,
|
||||||
|
BufferUsageARB.DynamicDraw,
|
||||||
|
"allocating grown terrain index buffer");
|
||||||
|
allocatedNewEboBytes = newEboBytes;
|
||||||
|
|
||||||
|
GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers (precondition)");
|
||||||
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo);
|
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo);
|
||||||
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo);
|
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo);
|
||||||
_gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer,
|
_gl.CopyBufferSubData(
|
||||||
0, 0, oldVboBytes);
|
CopyBufferSubDataTarget.CopyReadBuffer,
|
||||||
_gl.DeleteBuffer(_globalVbo);
|
CopyBufferSubDataTarget.CopyWriteBuffer,
|
||||||
_globalVbo = newVbo;
|
0,
|
||||||
|
0,
|
||||||
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, newEbo);
|
checked((nuint)_globalVboCapacityBytes));
|
||||||
_gl.BufferData(BufferTargetARB.ElementArrayBuffer, newEboBytes, null, BufferUsageARB.DynamicDraw);
|
|
||||||
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo);
|
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo);
|
||||||
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo);
|
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo);
|
||||||
_gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer,
|
_gl.CopyBufferSubData(
|
||||||
0, 0, oldEboBytes);
|
CopyBufferSubDataTarget.CopyReadBuffer,
|
||||||
_gl.DeleteBuffer(_globalEbo);
|
CopyBufferSubDataTarget.CopyWriteBuffer,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
checked((nuint)_globalEboCapacityBytes));
|
||||||
|
GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers");
|
||||||
|
|
||||||
|
newVao = TrackedGlResource.CreateVertexArray(
|
||||||
|
_gl,
|
||||||
|
"creating grown terrain VAO");
|
||||||
|
ConfigureVao(newVao, newVbo, newEbo);
|
||||||
|
|
||||||
|
uint oldVao = _globalVao;
|
||||||
|
uint oldVbo = _globalVbo;
|
||||||
|
uint oldEbo = _globalEbo;
|
||||||
|
long oldVboBytes = _globalVboCapacityBytes;
|
||||||
|
long oldEboBytes = _globalEboCapacityBytes;
|
||||||
|
|
||||||
|
_globalVao = newVao;
|
||||||
|
_globalVbo = newVbo;
|
||||||
_globalEbo = newEbo;
|
_globalEbo = newEbo;
|
||||||
|
_globalVboCapacityBytes = newVboBytes;
|
||||||
// Recreate VAO with new buffer bindings.
|
_globalEboCapacityBytes = newEboBytes;
|
||||||
_gl.DeleteVertexArray(_globalVao);
|
_slots = grownSlots;
|
||||||
_globalVao = _gl.GenVertexArray();
|
|
||||||
ConfigureVao();
|
|
||||||
|
|
||||||
// Grow slot tracking array.
|
|
||||||
Array.Resize(ref _slots, newCapacity);
|
|
||||||
_alloc.GrowTo(newCapacity);
|
_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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
_gl,
|
||||||
|
GLEnum.ArrayBuffer,
|
||||||
|
_vbo,
|
||||||
|
_vboCapacityBytes,
|
||||||
|
newCapacity,
|
||||||
|
GLEnum.DynamicDraw,
|
||||||
|
"TextRenderer frame VBO growth");
|
||||||
|
_vboCapacityBytes = newCapacity;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
fixed (float* p = CollectionsMarshal.AsSpan(buf))
|
fixed (float* p = CollectionsMarshal.AsSpan(buf))
|
||||||
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)bytes, p);
|
_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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,6 +749,10 @@ 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();
|
||||||
|
if (tex == 0)
|
||||||
|
throw new InvalidOperationException("OpenGL did not create a 2D texture.");
|
||||||
|
try
|
||||||
|
{
|
||||||
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
||||||
|
|
||||||
fixed (byte* p = decoded.Rgba8)
|
fixed (byte* p = decoded.Rgba8)
|
||||||
|
|
@ -593,11 +774,23 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
||||||
_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>
|
||||||
/// Variant of <see cref="UploadRgba8"/> that uploads pixel data as a 1-layer
|
/// Variant of <see cref="UploadRgba8"/> that uploads pixel data as a 1-layer
|
||||||
|
|
@ -607,6 +800,10 @@ 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();
|
||||||
|
if (tex == 0)
|
||||||
|
throw new InvalidOperationException("OpenGL did not create a one-layer texture array.");
|
||||||
|
try
|
||||||
|
{
|
||||||
_gl.BindTexture(TextureTarget.Texture2DArray, tex);
|
_gl.BindTexture(TextureTarget.Texture2DArray, tex);
|
||||||
|
|
||||||
fixed (byte* p = decoded.Rgba8)
|
fixed (byte* p = decoded.Rgba8)
|
||||||
|
|
@ -626,69 +823,76 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
|
||||||
_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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,18 +244,52 @@ 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 (!_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|
||||||
|
|| !ReferenceEquals(current, record)
|
||||||
|
|| !TryGetReadyLocalId(record.ServerGuid, out uint localId)
|
||||||
|
|| record.WorldEntity?.Id != localId)
|
||||||
{
|
{
|
||||||
if (!TryGetReadyLocalId(serverGuid, out uint 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)
|
||||||
|
|
@ -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)
|
||||||
|
{
|
||||||
|
uint previousPublish = _posePublishLocalId;
|
||||||
|
_posePublishLocalId = localId;
|
||||||
|
try
|
||||||
|
{
|
||||||
_poses.UpdateRoot(entity);
|
_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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<WorldEntity, AnimationSequencer></c> factory so tests can
|
/// <c>Func<WorldEntity, AnimationSequencer></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)
|
||||||
{
|
{
|
||||||
foreach (var id in ids) _meshAdapter.DecrementRefCount(id);
|
if (!_ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
|
||||||
_meshIdsByGuid.Remove(serverGuid);
|
|| (expectedEntity is not null && !ReferenceEquals(owner.Entity, expectedEntity)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
103
src/AcDream.App/Rendering/Wb/GpuRetiredRangeAllocator.cs
Normal file
103
src/AcDream.App/Rendering/Wb/GpuRetiredRangeAllocator.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
12
src/AcDream.App/Rendering/Wb/IEntityTextureLifetime.cs
Normal file
12
src/AcDream.App/Rendering/Wb/IEntityTextureLifetime.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,44 +70,45 @@ 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;
|
||||||
|
ulong clampHandle = 0;
|
||||||
|
bool textureTracked = false;
|
||||||
|
bool textureBytesTracked = false;
|
||||||
|
bool wrapResident = false;
|
||||||
|
bool clampResident = false;
|
||||||
|
long textureBytes = CalculateTotalSize();
|
||||||
|
|
||||||
|
try {
|
||||||
|
textureName = GL.GenTexture();
|
||||||
|
if (textureName == 0)
|
||||||
throw new InvalidOperationException("Failed to generate texture array.");
|
throw new InvalidOperationException("Failed to generate texture array.");
|
||||||
}
|
|
||||||
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture);
|
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture);
|
||||||
|
textureTracked = true;
|
||||||
|
|
||||||
GLHelpers.CheckErrors(GL);
|
GL.BindTexture(GLEnum.Texture2DArray, textureName);
|
||||||
|
|
||||||
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
|
|
||||||
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,
|
|
||||||
$"Creating texture array storage (Format={format}, Size={width}x{height}x{size}, MipLevels={mipLevels})");
|
|
||||||
|
|
||||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter,
|
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter,
|
||||||
(int)p.MinFilter);
|
(int)p.MinFilter);
|
||||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, (int)mipLevels - 1);
|
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, mipLevels - 1);
|
||||||
|
|
||||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter);
|
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter);
|
||||||
|
|
||||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS);
|
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS);
|
||||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT);
|
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT);
|
||||||
|
|
||||||
if (p.EnableAnisotropicFiltering && graphicsDevice.RenderSettings.EnableAnisotropicFiltering) {
|
if (p.EnableAnisotropicFiltering
|
||||||
float maxAnisotropy = 0f;
|
&& graphicsDevice.RenderSettings.EnableAnisotropicFiltering
|
||||||
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out maxAnisotropy);
|
&& graphicsDevice.MaxSupportedAnisotropy > 0) {
|
||||||
|
GL.TexParameter(
|
||||||
if (maxAnisotropy > 0) {
|
GLEnum.Texture2DArray,
|
||||||
GL.TexParameter(GLEnum.Texture2DArray, GLEnum.TextureMaxAnisotropy, maxAnisotropy);
|
GLEnum.TextureMaxAnisotropy,
|
||||||
}
|
graphicsDevice.MaxSupportedAnisotropy);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set texture swizzle for single-channel formats
|
|
||||||
if (format == TextureFormat.A8) {
|
if (format == TextureFormat.A8) {
|
||||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One);
|
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One);
|
||||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One);
|
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One);
|
||||||
|
|
@ -114,22 +116,75 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red);
|
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red);
|
||||||
}
|
}
|
||||||
|
|
||||||
GLHelpers.CheckErrors(GL);
|
GLHelpers.ThrowOnResourceError(
|
||||||
|
GL,
|
||||||
GpuMemoryTracker.TrackAllocation(CalculateTotalSize(), GpuResourceType.Texture);
|
$"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) {
|
if (_device.HasBindless && _device.BindlessExtension != null) {
|
||||||
BindlessHandle = _device.BindlessExtension.GetTextureHandle((uint)NativePtr);
|
wrapHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.WrapSampler);
|
||||||
BindlessWrapHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.WrapSampler);
|
clampHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.ClampSampler);
|
||||||
BindlessClampHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.ClampSampler);
|
_device.BindlessExtension.MakeTextureHandleResident(wrapHandle);
|
||||||
|
wrapResident = true;
|
||||||
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
|
_device.BindlessExtension.MakeTextureHandleResident(clampHandle);
|
||||||
_device.BindlessExtension.MakeTextureHandleResident(BindlessWrapHandle);
|
clampResident = true;
|
||||||
_device.BindlessExtension.MakeTextureHandleResident(BindlessClampHandle);
|
GLHelpers.ThrowOnResourceError(GL, "making texture-array sampler handles resident");
|
||||||
}
|
}
|
||||||
|
|
||||||
_pboId = GL.GenBuffer();
|
NativePtr = (nint)textureName;
|
||||||
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
|
BindlessWrapHandle = wrapHandle;
|
||||||
|
BindlessClampHandle = clampHandle;
|
||||||
|
}
|
||||||
|
catch (Exception constructionFailure) {
|
||||||
|
// Constructor failure cannot use Dispose: the object was never
|
||||||
|
// published and queued teardown would make retries accumulate
|
||||||
|
// invalid resident handles. Attempt every independent cleanup.
|
||||||
|
List<Exception>? cleanupFailures = null;
|
||||||
|
void Attempt(Action cleanup) {
|
||||||
|
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 {
|
||||||
|
GL.ActiveTexture(TextureUnit.Texture0);
|
||||||
|
GL.BindTexture(GLEnum.Texture2DArray, 0);
|
||||||
|
RenderStateCache.CurrentAtlas = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
if (existingIndex < 0)
|
||||||
_mipmapDirtyCount++;
|
_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;
|
||||||
|
try {
|
||||||
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
|
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
|
||||||
|
|
||||||
bool wasResident = false;
|
|
||||||
if (BindlessHandle != 0 && _device.BindlessExtension != null && _device.BindlessExtension.IsTextureHandleResident(BindlessHandle)) {
|
|
||||||
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
|
|
||||||
wasResident = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_pendingUpdates.Count > 0) {
|
if (_pendingUpdates.Count > 0) {
|
||||||
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
|
// A non-zero pixel-unpack binding changes pointer arguments
|
||||||
|
// into byte offsets. Direct client-memory uploads therefore
|
||||||
|
// 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);
|
||||||
|
|
||||||
foreach (var update in _pendingUpdates) {
|
foreach (var update in _pendingUpdates) {
|
||||||
|
fixed (byte* data = update.Data) {
|
||||||
if (_isCompressed) {
|
if (_isCompressed) {
|
||||||
var internalFormat = Format.ToCompressedGL();
|
var internalFormat = Format.ToCompressedGL();
|
||||||
GL.CompressedTexSubImage3D(GLEnum.Texture2DArray, 0, 0, 0, update.Layer,
|
GL.CompressedTexSubImage3D(
|
||||||
(uint)Width, (uint)Height, 1, internalFormat, (uint)update.Size, (void*)update.Offset);
|
GLEnum.Texture2DArray,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
update.Layer,
|
||||||
|
(uint)Width,
|
||||||
|
(uint)Height,
|
||||||
|
1,
|
||||||
|
internalFormat,
|
||||||
|
(uint)update.Data.Length,
|
||||||
|
data);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat();
|
var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat();
|
||||||
var pixelType = update.UploadPixelType ?? Format.ToPixelType();
|
var pixelType = update.UploadPixelType ?? Format.ToPixelType();
|
||||||
GL.TexSubImage3D(GLEnum.Texture2DArray, 0, 0, 0, update.Layer, (uint)Width, (uint)Height, 1,
|
GL.TexSubImage3D(
|
||||||
pixelFormat, pixelType, (void*)update.Offset);
|
GLEnum.Texture2DArray,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
update.Layer,
|
||||||
|
(uint)Width,
|
||||||
|
(uint)Height,
|
||||||
|
1,
|
||||||
|
pixelFormat,
|
||||||
|
pixelType,
|
||||||
|
data);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
|
if (generateMipmaps && _needsMipmapRegeneration && _mipmapDirtyCount > 0) {
|
||||||
_pendingUpdates.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_needsMipmapRegeneration && _mipmapDirtyCount > 0) {
|
|
||||||
if (_isCompressed) {
|
if (_isCompressed) {
|
||||||
_logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot);
|
_logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot);
|
||||||
}
|
}
|
||||||
else if (!GLHelpers.ValidateTextureMipmapStatus(GL, GLEnum.Texture2DArray, out var errorMessage)) {
|
|
||||||
_logger.LogWarning("Mipmap validation failed for texture array (Slot={Slot}): {Error}", Slot, errorMessage);
|
|
||||||
}
|
|
||||||
else {
|
else {
|
||||||
try {
|
try {
|
||||||
|
// Width, height and format were validated when the
|
||||||
|
// immutable storage was allocated. Re-reading them
|
||||||
|
// here forced three CPU/GPU synchronization points
|
||||||
|
// for every dirty atlas without adding safety.
|
||||||
GL.GenerateMipmap(GLEnum.Texture2DArray);
|
GL.GenerateMipmap(GLEnum.Texture2DArray);
|
||||||
|
generatedBytes = TotalSizeInBytes;
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
_logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}).", Slot);
|
_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.BindTexture(GLEnum.Texture2DArray, 0);
|
||||||
|
GL.ActiveTexture(TextureUnit.Texture0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit CPU-side completion only after glGetError confirms the
|
||||||
|
// uploads/mipmap work succeeded. If the driver rejects an
|
||||||
|
// operation, the retained payloads and dirty flags remain intact and the
|
||||||
|
// atlas stays in ObjectMeshManager's dirty set for a later retry.
|
||||||
|
_pendingUpdates.Clear();
|
||||||
|
if (mipmapWorkCompleted) {
|
||||||
_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,19 +435,53 @@ namespace AcDream.App.Rendering.Wb {
|
||||||
}
|
}
|
||||||
|
|
||||||
private int GetExpectedDataSize() {
|
private int GetExpectedDataSize() {
|
||||||
if (_isCompressed) {
|
return CalculateExpectedDataSize(Format, Width, Height);
|
||||||
return TextureHelpers.GetCompressedLayerSize(Width, Height, Format);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Format switch {
|
internal static int CalculateExpectedDataSize(TextureFormat format, int width, int height) {
|
||||||
TextureFormat.RGBA8 => Width * Height * 4,
|
if (IsCompressedFormat(format))
|
||||||
TextureFormat.RGB8 => Width * Height * 3,
|
return TextureHelpers.GetCompressedLayerSize(width, height, format);
|
||||||
TextureFormat.A8 => Width * Height * 1,
|
|
||||||
TextureFormat.Rgba32f => Width * Height * 16,
|
return format switch {
|
||||||
_ => throw new NotSupportedException($"Unsupported format {Format}")
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsCompressedFormat(format)) {
|
||||||
|
if (uploadPixelFormat.HasValue || uploadPixelType.HasValue)
|
||||||
|
throw new ArgumentException("Compressed texture uploads cannot specify pixel format/type overrides.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
if (layer < 0 || layer >= Size) {
|
if (layer < 0 || layer >= Size) {
|
||||||
throw new ArgumentOutOfRangeException(nameof(layer),
|
throw new ArgumentOutOfRangeException(nameof(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;
|
|
||||||
}
|
}
|
||||||
if (BindlessWrapHandle != 0) {
|
|
||||||
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessWrapHandle);
|
uint textureName = (uint)NativePtr;
|
||||||
BindlessWrapHandle = 0;
|
ulong bindlessWrapHandle = BindlessWrapHandle;
|
||||||
}
|
ulong bindlessClampHandle = BindlessClampHandle;
|
||||||
if (BindlessClampHandle != 0) {
|
long textureBytes = CalculateTotalSize();
|
||||||
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessClampHandle);
|
|
||||||
BindlessClampHandle = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (NativePtr != 0) {
|
|
||||||
GL.DeleteTexture((uint)NativePtr);
|
|
||||||
GLHelpers.CheckErrors(GL);
|
|
||||||
GpuMemoryTracker.TrackDeallocation(CalculateTotalSize(), GpuResourceType.Texture);
|
|
||||||
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
|
|
||||||
NativePtr = 0;
|
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 (_pboId != 0) {
|
},
|
||||||
GL.DeleteBuffer(_pboId);
|
() => {
|
||||||
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
|
if (_device.BindlessExtension != null && bindlessClampHandle != 0)
|
||||||
if (_pboSize > 0) {
|
GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle (precondition)");
|
||||||
GpuMemoryTracker.TrackDeallocation(_pboSize, GpuResourceType.Buffer);
|
},
|
||||||
|
() => {
|
||||||
|
if (_device.BindlessExtension != null && bindlessClampHandle != 0) {
|
||||||
|
_device.BindlessExtension.MakeTextureHandleNonResident(bindlessClampHandle);
|
||||||
|
GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle");
|
||||||
}
|
}
|
||||||
_pboId = 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 (textureName != 0)
|
||||||
|
GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (textureName != 0)
|
||||||
|
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
|
||||||
|
},
|
||||||
|
() => _disposeRelease = null);
|
||||||
|
|
||||||
|
ScheduleDisposeRelease();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
186
src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs
Normal file
186
src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs
Normal 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
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
135
src/AcDream.App/Rendering/Wb/RetryableResourceReleaseLedger.cs
Normal file
135
src/AcDream.App/Rendering/Wb/RetryableResourceReleaseLedger.cs
Normal 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)));
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposeTransaction.Advance(
|
||||||
|
_layerRetirement.RetryPendingPublications,
|
||||||
|
() => {
|
||||||
TextureArray?.Dispose();
|
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();
|
_textureIndices.Clear();
|
||||||
_refCounts.Clear();
|
_refCounts.Clear();
|
||||||
_freeSlots.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs
Normal file
55
src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
242
src/AcDream.App/Rendering/Wb/TrackedGlResource.cs
Normal file
242
src/AcDream.App/Rendering/Wb/TrackedGlResource.cs
Normal 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
|
|
@ -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,8 +236,11 @@ 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
|
||||||
|
{
|
||||||
|
metadataClaimed = _metadataPopulated.Add(id);
|
||||||
|
if (metadataClaimed)
|
||||||
PopulateMetadata(id);
|
PopulateMetadata(id);
|
||||||
|
|
||||||
// WB's IncrementRefCount alone only bumps a usage counter; it does
|
// WB's IncrementRefCount alone only bumps a usage counter; it does
|
||||||
|
|
@ -197,9 +270,38 @@ 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/>
|
||||||
public void DecrementRefCount(ulong id)
|
public void DecrementRefCount(ulong id)
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
src/AcDream.App/Rendering/Wb/WbTextureResolutionPolicy.cs
Normal file
29
src/AcDream.App/Rendering/Wb/WbTextureResolutionPolicy.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/AcDream.App/RuntimeDatCollectionFactory.cs
Normal file
101
src/AcDream.App/RuntimeDatCollectionFactory.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
20
src/AcDream.App/Streaming/GpuLandblockRetirement.cs
Normal file
20
src/AcDream.App/Streaming/GpuLandblockRetirement.cs
Normal 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
328
src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs
Normal file
328
src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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"/>
|
||||||
|
|
@ -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()
|
||||||
|
{
|
||||||
|
lock (_disposeGate)
|
||||||
{
|
{
|
||||||
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));
|
||||||
|
if (_worker is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
// A.5 T10-T12 follow-up: atomically install the worker so concurrent
|
var worker = new Thread(WorkerLoop)
|
||||||
// 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,
|
IsBackground = true,
|
||||||
Name = "acdream.streaming.worker",
|
Name = "acdream.streaming.worker",
|
||||||
};
|
};
|
||||||
if (Interlocked.CompareExchange(ref _worker, candidate, null) == null)
|
worker.Start();
|
||||||
candidate.Start();
|
_worker = worker;
|
||||||
// else: another caller won the race; their thread is running.
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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>
|
||||||
|
|
@ -175,10 +178,26 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
/// complete; the StreamingController's collapsed-sweep unloads those few.
|
/// complete; the StreamingController's collapsed-sweep unloads those few.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void ClearPendingLoads()
|
public void ClearPendingLoads()
|
||||||
|
{
|
||||||
|
WriteJob(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)
|
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||||
_inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads());
|
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)
|
||||||
|
{
|
||||||
|
if (_disposeCompleted)
|
||||||
|
return;
|
||||||
|
|
||||||
|
System.Threading.Interlocked.Exchange(ref _disposed, 1);
|
||||||
_cancel.Cancel();
|
_cancel.Cancel();
|
||||||
|
lock (_inboxGate)
|
||||||
_inbox.Writer.TryComplete();
|
_inbox.Writer.TryComplete();
|
||||||
// Generous join: the owner disposes the DatCollection after this, which
|
// The owner releases the memory-mapped DAT immediately after this
|
||||||
// unmaps the dats' memory-mapped views — an abandoned worker mid-dat-read
|
// object. Join the actual worker without a grace-period timeout so
|
||||||
// would take the process down with an AccessViolation in
|
// no native read can survive into that teardown.
|
||||||
// MemoryMappedBlockAllocator.ReadBlock (dat-race investigation 2026-06-09).
|
_worker?.Join();
|
||||||
// Cancellation is honored between jobs, so the wait is bounded by one
|
|
||||||
// landblock load; 15s only ever elapses if the worker is genuinely hung.
|
|
||||||
if (_worker is not null && !_worker.Join(TimeSpan.FromSeconds(15)))
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
"[streamer] worker did not stop within 15s — dat teardown may race an in-flight load");
|
|
||||||
_cancel.Dispose();
|
_cancel.Dispose();
|
||||||
|
_disposeCompleted = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
20
src/AcDream.App/Streaming/StreamingMutationException.cs
Normal file
20
src/AcDream.App/Streaming/StreamingMutationException.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -416,6 +420,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>
|
||||||
/// Load a different dump panel at runtime (no relaunch required).
|
/// Load a different dump panel at runtime (no relaunch required).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue