perf(streaming): cursor publication across frame budgets
This commit is contained in:
parent
bb16f74fd4
commit
98f1ac8934
32 changed files with 2215 additions and 823 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -140,10 +140,12 @@ explicit bake/equivalence/UI-Studio tooling. Capped, uncapped, dense-Arwic,
|
|||
lifetime, full-suite, and user-visual gates passed. Process allocation fell
|
||||
41.8–61.9%, GC pause time fell 48.4–54.1%, uncapped CPU/GPU p99 improved
|
||||
17.0%/20.6%, and invalid Setup-probe exceptions fell to zero. Slice D unified
|
||||
typed residency policy/accounting and bounded retained caches. Slice E0–E3 now
|
||||
typed residency policy/accounting and bounded retained caches. Slice E0–E4 now
|
||||
enforce typed completion admission, generation-scoped old-world quiescence,
|
||||
and metered full-window/recenter retirement; E4 cursor-budgeted publication is
|
||||
next. Physical GPU/FPS gates are paused while the desktop is RDP. Slices F/G
|
||||
metered full-window/recenter retirement, and retained cursor-budgeted
|
||||
render/physics/static/building/EnvCell publication; E5 destination reservation
|
||||
and reveal-generation joining is next. The physical console is available and
|
||||
reserved for E6 connected gates. Slices F/G
|
||||
remain behind the explicit ECS decision gate and Slice J behind its separate
|
||||
gameplay-owner gate. Evidence:
|
||||
`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md` and
|
||||
|
|
@ -168,7 +170,7 @@ uncapped Release presentation is smooth, while physics, collision, and wire
|
|||
truth remain correct.
|
||||
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
|
||||
`docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining
|
||||
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E4+, and #225's
|
||||
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E5+, and #225's
|
||||
lifestone/particle alpha visual gate.
|
||||
|
||||
Start structural work at `memory/project_gamewindow_decomposition.md` and
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -138,10 +138,12 @@ explicit bake/equivalence/UI-Studio tooling. Capped, uncapped, dense-Arwic,
|
|||
lifetime, full-suite, and user-visual gates passed. Process allocation fell
|
||||
41.8–61.9%, GC pause time fell 48.4–54.1%, uncapped CPU/GPU p99 improved
|
||||
17.0%/20.6%, and invalid Setup-probe exceptions fell to zero. Slice D unified
|
||||
typed residency policy/accounting and bounded retained caches. Slice E0–E3 now
|
||||
typed residency policy/accounting and bounded retained caches. Slice E0–E4 now
|
||||
enforce typed completion admission, generation-scoped old-world quiescence,
|
||||
and metered full-window/recenter retirement; E4 cursor-budgeted publication is
|
||||
next. Physical GPU/FPS gates are paused while the desktop is RDP. Slices F/G
|
||||
metered full-window/recenter retirement, and retained cursor-budgeted
|
||||
render/physics/static/building/EnvCell publication; E5 destination reservation
|
||||
and reveal-generation joining is next. The physical console is available and
|
||||
reserved for E6 connected gates. Slices F/G
|
||||
remain behind the explicit ECS decision gate and Slice J behind its separate
|
||||
gameplay-owner gate. Evidence:
|
||||
`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md` and
|
||||
|
|
@ -166,7 +168,7 @@ uncapped Release presentation is smooth, while physics, collision, and wire
|
|||
truth remain correct.
|
||||
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
|
||||
`docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining
|
||||
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E4+, and #225's
|
||||
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E5+, and #225's
|
||||
lifestone/particle alpha visual gate.
|
||||
|
||||
Start structural work at `memory/project_gamewindow_decomposition.md` and
|
||||
|
|
|
|||
|
|
@ -44,11 +44,12 @@ What does NOT go here:
|
|||
smooth and physics, collision, and wire state remain correct.
|
||||
- **Build hygiene:** `#228` records 17 clean-Release test-project warnings;
|
||||
production compilation and all tests pass.
|
||||
- **Performance side track:** Slices A–D and Slice E0–E3 are complete. Typed
|
||||
- **Performance side track:** Slices A–D and Slice E0–E4 are complete. Typed
|
||||
completion admission, immediate generation-scoped old-world quiescence, and
|
||||
budgeted full-window/recenter retirement are live; E4 cursor-budgeted
|
||||
publication is next. Physical performance/visual evidence is paused while
|
||||
the desktop is RDP. Evidence:
|
||||
budgeted full-window/recenter retirement are live. Publication now advances
|
||||
from retained render/physics/static/building/EnvCell cursors without replay;
|
||||
E5 destination reservation/reveal joining is next. The physical console is
|
||||
available and remains reserved for the E6 connected gate. Evidence:
|
||||
[`research/2026-07-24-slice-d-unified-residency-report.md`](research/2026-07-24-slice-d-unified-residency-report.md).
|
||||
|
||||
The [documentation map](README.md) defines how this tactical ledger relates to
|
||||
|
|
|
|||
|
|
@ -14,13 +14,15 @@ banner cannot silently override the current program state.
|
|||
[`plans/2026-07-23-world-interaction-completion.md`](plans/2026-07-23-world-interaction-completion.md).
|
||||
The eight-slice `GameWindow` decomposition and its connected visual matrix
|
||||
are complete.
|
||||
- **Current active engineering program:** Slices A–D and Slice E0–E3 of
|
||||
- **Current active engineering program:** Slices A–D and Slice E0–E4 of
|
||||
[`plans/2026-07-24-modern-runtime-architecture.md`](plans/2026-07-24-modern-runtime-architecture.md)
|
||||
are complete. Production world-mesh streaming consumes the validated
|
||||
prepared package, typed residency is bounded/accounted, and streaming now
|
||||
has typed admission plus immediate old-generation quiescence and metered
|
||||
retirement cursors. E4 cursor-budgeted publication is next. Physical
|
||||
GPU/FPS gates wait for the physical console because the desktop is RDP. See
|
||||
has typed admission plus immediate old-generation quiescence, metered
|
||||
retirement, and retained cursor-budgeted publication across render, physics,
|
||||
statics, building registries, collision/reflood, and EnvCells. E5 destination
|
||||
reservation/reveal joining is next. The physical console is available; its
|
||||
connected performance and visual evidence remains the E6 gate. See
|
||||
the [Slice D residency report](research/2026-07-24-slice-d-unified-residency-report.md).
|
||||
- **Completed gates:** R6 locomotion/collision/projectile/teleport/radar and
|
||||
the final two-client portal-out/materialization observer comparison (`#218`).
|
||||
|
|
@ -32,7 +34,7 @@ banner cannot silently override the current program state.
|
|||
user decision gate; Linux/headless automation (Track LH) remains future work.
|
||||
- **Divergence audit:** 178 active rows — IA 17, AD 37, AP 85, TS 34,
|
||||
and UN 5 — plus the retained retired TS-37 history note.
|
||||
- **Latest automated baseline:** Release build succeeds; 8,148 tests pass and
|
||||
- **Latest automated baseline:** Release build succeeds; 8,152 tests pass and
|
||||
five are intentionally skipped. The capped, uncapped, and dense-Arwic
|
||||
physical prepared-asset routes all completed their portal/input/lifetime
|
||||
assertions and disconnected gracefully.
|
||||
|
|
|
|||
|
|
@ -731,8 +731,18 @@ readiness continue. Canonical live records remain retained until physical
|
|||
teardown converges, so quiescence neither reconstructs server identity nor
|
||||
invents Hidden/UnHide transitions. Full-window and shared-origin retirement
|
||||
advance only from `StreamingController.Tick` through stable resident and
|
||||
per-owner entity/stage cursors on the frame's one meter. Publication remains
|
||||
transaction-atomic until Slice E4.
|
||||
per-owner entity/stage cursors on the frame's one meter.
|
||||
|
||||
Accepted publication is likewise retained at its exact priority-queue head.
|
||||
`LandblockPresentationPipeline` meters render, physics, static, building, and
|
||||
EnvCell receipts one operation at a time; each owner captures stable ordered
|
||||
inputs once and retries only its unfinished cursor. Building and EnvCell
|
||||
replacements are constructed off-side and atomically replace their complete
|
||||
owner snapshots. The final `GpuWorldState.MutationBatch` is intentionally one
|
||||
observer-atomic boundary so bucket identity, render pins, activation, and the
|
||||
outer visibility notice cannot be observed partially. Settings and native
|
||||
callbacks may defer policy changes, but cannot bypass the frame meter to drain
|
||||
pending publication.
|
||||
|
||||
### World-reveal readiness ownership
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|---|---|---|---|---|---|
|
||||
| AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` |
|
||||
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
|
||||
| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the priority near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud forced-placement path. `WorldRevealCoordinator` owns this shared lifetime; diagnostic telemetry observes its generation without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (owner wiring + draw eligibility); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
|
||||
| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the priority near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud forced-placement path. `WorldRevealCoordinator` owns this shared lifetime; diagnostic telemetry observes its generation without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (owner wiring + draw eligibility); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
|
||||
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
|
||||
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
|
||||
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
|
||||
|
|
|
|||
|
|
@ -1613,7 +1613,10 @@ port in any phase — no separate listing here.
|
|||
> generation unavailable at the hard reveal edge and freezes its world
|
||||
> simulation/audio while full-window and shared-origin teardown advance through
|
||||
> metered resident, entity, and owner-stage cursors with exact retry receipts.
|
||||
> E4 cursor-budgets publication next. F/G and J retain their explicit approval
|
||||
> E4 now retains the exact accepted queue head and cursor-budgets render,
|
||||
> physics, static, building-registry, EnvCell, collision, and reflood
|
||||
> publication before one observer-atomic spatial swap. E5 destination
|
||||
> reservation/reveal joining is next. F/G and J retain their explicit approval
|
||||
> gates.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
|
||||
|
|
|
|||
|
|
@ -93,12 +93,14 @@ The separately authorized modern-runtime performance program has completed
|
|||
Slices A–D: corrected measurement, prepared-package bake/dedup, package-only
|
||||
production streaming, and typed unified residency. Its physical capped,
|
||||
uncapped, and dense connected gates pass without a visual-quality change.
|
||||
Slice E0–E3 now define and enforce typed completion admission through explicit
|
||||
Slice E0–E4 now define and enforce typed completion admission through explicit
|
||||
stable priority queues, publish an immediate generation-scoped old-world
|
||||
quiesce at login/portal boundaries, and advance full-window/recenter teardown
|
||||
through exact metered retirement cursors. E4 next makes publication itself
|
||||
cursor-budgeted. Slices F/G remain behind their explicit ECS approval gate, and
|
||||
Slice J remains behind its gameplay-owner approval gate.
|
||||
through exact metered retirement cursors. Render, physics, static, building,
|
||||
and EnvCell publication now resume from retained exact cursors under the same
|
||||
frame budget. E5 destination reservation and reveal-generation joining is
|
||||
next. Slices F/G remain behind their explicit ECS approval gate, and Slice J
|
||||
remains behind its gameplay-owner approval gate.
|
||||
|
||||
Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2's
|
||||
status hand selected-object availability and existing
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Date:** 2026-07-24
|
||||
|
||||
**Status:** Slices A–D complete. Slice E is active (E0–E3 complete).
|
||||
**Status:** Slices A–D complete. Slice E is active (E0–E4 complete).
|
||||
|
||||
**Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and
|
||||
Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit.
|
||||
|
|
@ -728,9 +728,10 @@ checkpoint retained staged or retiring bytes. Evidence:
|
|||
|
||||
**Checkpoint 2026-07-24:** E0 fixed the retail/adaptation contract and current
|
||||
owner inventory. E1 landed the validated work profile, deterministic meter and
|
||||
completion charge model, plus lifecycle-artifact shadow diagnostics while
|
||||
preserving the prior scheduler's execution. E2 is the first enforcement
|
||||
cutover.
|
||||
completion charge model. E2 cut execution over to stable priority FIFOs. E3
|
||||
added immediate generation quiescence and cursored retirement. E4 now advances
|
||||
render/physics/static publication from retained exact receipts; E5 destination
|
||||
reservation and reveal-generation joining is next.
|
||||
|
||||
**Purpose:** Remove update-thread portal transactions.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Modern Runtime Slice E — Cost-budgeted streaming and retirement
|
||||
|
||||
**Status:** active — E0/E1/E2/E3 complete; E4 cursor-budgeted publication next
|
||||
**Status:** active — E0/E1/E2/E3/E4 complete; E5 destination reservation next
|
||||
**Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md`
|
||||
**Baseline:** Slice D closeout commit `66690805`
|
||||
**Behavior contract:** no visual-quality or retail-behavior reduction
|
||||
|
|
@ -325,6 +325,29 @@ valid GPU/FPS evidence.
|
|||
- Feed GPU requested-byte and retirement-operation facts from renderer owners.
|
||||
- Prove every failure resumes the exact unfinished operation without replay.
|
||||
|
||||
**Complete 2026-07-24.** One accepted completion now remains at the exact queue
|
||||
head while `LandblockPresentationPipeline` advances retained render, physics,
|
||||
and static receipts through the frame's single meter. Stable entity/Gfx/building
|
||||
and prior-owner arrays are captured once. Terrain upload bytes are charged at
|
||||
the render owner; physics cells/buildings, static collision/light/plugin work,
|
||||
prior static teardown, shadow reflood, building-registry construction, and
|
||||
EnvCell shell construction advance one bounded operation at a time. Building
|
||||
and EnvCell replacements are prepared off-side and become visible only when
|
||||
complete. Failures retain the unfinished cursor and never replay a committed
|
||||
prefix. Settings/native callbacks can defer policy changes but cannot drain
|
||||
publication outside `StreamingController.Tick`.
|
||||
|
||||
The final `GpuWorldState.MutationBatch` remains one deliberate observer-atomic
|
||||
operation: bucket mutation, render-id ownership, activation, and the outer
|
||||
visibility notification cannot span frames without exposing a partial spatial
|
||||
identity. It is time-metered and named for E6 overrun evidence; all iterable
|
||||
owner work now occurs before it.
|
||||
|
||||
Validation: focused render/building/publication/controller tests passed; the
|
||||
complete App suite passed 3,669 / 3 skipped; the complete Release solution
|
||||
passed 8,152 / 5 skipped; Release solution build green. Physical connected
|
||||
performance and visual evidence remains an E6 gate.
|
||||
|
||||
### E5 — Destination reservation and reveal generation
|
||||
|
||||
- Replace eager `PriorityRadius` execution with destination reservations.
|
||||
|
|
|
|||
|
|
@ -789,11 +789,11 @@ internal sealed class LivePresentationCompositionPhase
|
|||
foundation.Terrain.RemoveLandblock,
|
||||
d.CellVisibility,
|
||||
worldState,
|
||||
envCellLease.Resource.CommitLandblock,
|
||||
build => EnvCellMeshPreparationScheduler.Schedule(
|
||||
prepareEnvCells: build => EnvCellMeshPreparationScheduler.Schedule(
|
||||
build,
|
||||
foundation.MeshAdapter.MeshManager!),
|
||||
envCellLease.Resource.RemoveLandblock);
|
||||
removeEnvCells: envCellLease.Resource.RemoveLandblock,
|
||||
envCellPublisher: envCellLease.Resource);
|
||||
var landblockPhysicsPublisher = new LandblockPhysicsPublisher(
|
||||
d.PhysicsEngine,
|
||||
world.TerrainBuild.HeightTable);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Collections.Generic;
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
|
|
@ -32,6 +33,30 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// <c>docs/research/named-retail/acclient.h:32035</c> (<c>BuildInfo</c>) and
|
||||
/// <c>:32094</c> (<c>CBldPortal</c>).</para>
|
||||
/// </summary>
|
||||
internal sealed class BuildingRegistryPublication
|
||||
{
|
||||
internal BuildingRegistryPublication(
|
||||
BuildingInfo[] buildings,
|
||||
uint landblockId,
|
||||
IReadOnlyDictionary<uint, LoadedCell> cellsByCellId)
|
||||
{
|
||||
Buildings = buildings;
|
||||
LandblockId = landblockId;
|
||||
CellsByCellId = cellsByCellId;
|
||||
PreparationCommitted = buildings.Length == 0;
|
||||
}
|
||||
|
||||
internal BuildingInfo[] Buildings { get; }
|
||||
internal uint LandblockId { get; }
|
||||
internal IReadOnlyDictionary<uint, LoadedCell> CellsByCellId { get; }
|
||||
internal BuildingRegistry Registry { get; } = new();
|
||||
internal List<(LoadedCell Cell, uint BuildingId)> CellStamps { get; } = new();
|
||||
internal int BuildingCursor { get; set; }
|
||||
internal uint NextBuildingId { get; set; } = 1;
|
||||
internal bool PreparationCommitted { get; set; }
|
||||
internal bool PublicationCommitted { get; set; }
|
||||
}
|
||||
|
||||
public static class BuildingLoader
|
||||
{
|
||||
/// <summary>
|
||||
|
|
@ -39,122 +64,167 @@ public static class BuildingLoader
|
|||
/// Building IDs are allocated sequentially starting at 1 (0 is reserved for
|
||||
/// "no building" semantics used by <c>LoadedCell.BuildingId</c> in RR4).
|
||||
/// </summary>
|
||||
/// <param name="info">The dat-loaded <see cref="LandBlockInfo"/> for this landblock.</param>
|
||||
/// <param name="landblockId">The 32-bit landblock id (e.g. <c>0xA9B40000</c>).
|
||||
/// The high 16 bits are ORed with each 16-bit <c>OtherCellId</c> to produce
|
||||
/// the full cell id.</param>
|
||||
/// <param name="cellsByCellId">Pre-loaded cells keyed by full 32-bit cell id.
|
||||
/// Used for BFS extension (Step B) and exit-polygon collection (Step C).
|
||||
/// An empty dict is valid for unit tests; Step B and C are skipped per cell.</param>
|
||||
/// <returns>A fully populated <see cref="BuildingRegistry"/>; never null.</returns>
|
||||
public static BuildingRegistry Build(
|
||||
LandBlockInfo info,
|
||||
uint landblockId,
|
||||
IReadOnlyDictionary<uint, LoadedCell> cellsByCellId)
|
||||
{
|
||||
var reg = new BuildingRegistry();
|
||||
if (info.Buildings is null || info.Buildings.Count == 0)
|
||||
return reg;
|
||||
|
||||
uint lbMask = landblockId & 0xFFFF0000u;
|
||||
uint nextId = 1;
|
||||
|
||||
foreach (var bInfo in info.Buildings)
|
||||
BuildingRegistryPublication publication = PreparePublication(
|
||||
info,
|
||||
landblockId,
|
||||
cellsByCellId);
|
||||
while (!AdvancePreparationOne(publication))
|
||||
{
|
||||
var envCellIds = new HashSet<uint>();
|
||||
var exitPortalPolys = new List<Vector3[]>();
|
||||
}
|
||||
CommitPublication(publication);
|
||||
return publication.Registry;
|
||||
}
|
||||
|
||||
// Step A: seed the cell set from BuildingInfo.Portals (entry portals).
|
||||
// Each BuildingPortal.OtherCellId is a 16-bit cell-local id; OR with
|
||||
// the landblock prefix for the full id.
|
||||
// Defensive: skip OtherCellId == 0xFFFF (exit-portal sentinel in
|
||||
// BuildingInfo — rare but WB guards against it too at PortalService.cs:58).
|
||||
if (bInfo.Portals is not null)
|
||||
internal static BuildingRegistryPublication PreparePublication(
|
||||
LandBlockInfo info,
|
||||
uint landblockId,
|
||||
IReadOnlyDictionary<uint, LoadedCell> cellsByCellId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
ArgumentNullException.ThrowIfNull(cellsByCellId);
|
||||
|
||||
int buildingCount = info.Buildings?.Count ?? 0;
|
||||
var buildings = new BuildingInfo[buildingCount];
|
||||
for (int index = 0; index < buildingCount; index++)
|
||||
buildings[index] = info.Buildings![index];
|
||||
return new BuildingRegistryPublication(
|
||||
buildings,
|
||||
landblockId,
|
||||
cellsByCellId);
|
||||
}
|
||||
|
||||
internal static bool AdvancePreparationOne(
|
||||
BuildingRegistryPublication publication)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publication);
|
||||
if (publication.PreparationCommitted)
|
||||
return true;
|
||||
if (publication.BuildingCursor >= publication.Buildings.Length)
|
||||
{
|
||||
publication.PreparationCommitted = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
AddBuilding(
|
||||
publication,
|
||||
publication.Buildings[publication.BuildingCursor]);
|
||||
publication.BuildingCursor++;
|
||||
if (publication.BuildingCursor >= publication.Buildings.Length)
|
||||
publication.PreparationCommitted = true;
|
||||
return publication.PreparationCommitted;
|
||||
}
|
||||
|
||||
internal static void CommitPublication(
|
||||
BuildingRegistryPublication publication)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publication);
|
||||
if (!publication.PreparationCommitted)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A building registry cannot publish before preparation completes.");
|
||||
}
|
||||
if (publication.PublicationCommitted)
|
||||
return;
|
||||
|
||||
foreach ((LoadedCell cell, uint buildingId) in publication.CellStamps)
|
||||
cell.BuildingId = buildingId;
|
||||
publication.PublicationCommitted = true;
|
||||
}
|
||||
|
||||
private static void AddBuilding(
|
||||
BuildingRegistryPublication publication,
|
||||
BuildingInfo bInfo)
|
||||
{
|
||||
uint lbMask = publication.LandblockId & 0xFFFF0000u;
|
||||
var envCellIds = new HashSet<uint>();
|
||||
var exitPortalPolys = new List<Vector3[]>();
|
||||
|
||||
// Step A: seed the cell set from BuildingInfo.Portals.
|
||||
if (bInfo.Portals is not null)
|
||||
{
|
||||
foreach (var portal in bInfo.Portals)
|
||||
{
|
||||
foreach (var portal in bInfo.Portals)
|
||||
{
|
||||
if (portal.OtherCellId == 0xFFFF) continue;
|
||||
envCellIds.Add(lbMask | portal.OtherCellId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step B: BFS through interior CellPortals to find the full cell set.
|
||||
// Uses pre-loaded LoadedCell.Portals (avoids a duplicate dat fetch per
|
||||
// BFS step). Mirrors WB PortalService.cs:67-79.
|
||||
// When cellsByCellId is empty (unit-test path), BFS immediately exits.
|
||||
var queue = new Queue<uint>(envCellIds);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (!cellsByCellId.TryGetValue(current, out var cell)) continue;
|
||||
foreach (var p in cell.Portals)
|
||||
{
|
||||
if (p.OtherCellId == 0xFFFF) continue; // exit portal — stop BFS here
|
||||
uint neighbourId = lbMask | p.OtherCellId;
|
||||
if (envCellIds.Add(neighbourId))
|
||||
queue.Enqueue(neighbourId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step C: collect exit portal polygons in world space.
|
||||
// For each interior cell, iterate its portals; for each exit portal
|
||||
// (OtherCellId == 0xFFFF), transform the portal polygon vertices from
|
||||
// cell-local space to world space via WorldTransform.
|
||||
// Mirrors WB PortalService.cs:81-86 (GetPortalsForCell return path).
|
||||
foreach (var cellId in envCellIds)
|
||||
{
|
||||
if (!cellsByCellId.TryGetValue(cellId, out var cell)) continue;
|
||||
for (int pi = 0; pi < cell.Portals.Count; pi++)
|
||||
{
|
||||
if (cell.Portals[pi].OtherCellId != 0xFFFF) continue;
|
||||
if (pi >= cell.PortalPolygons.Count) continue;
|
||||
var localPoly = cell.PortalPolygons[pi];
|
||||
if (localPoly.Length < 3) continue;
|
||||
var worldPoly = new Vector3[localPoly.Length];
|
||||
for (int v = 0; v < localPoly.Length; v++)
|
||||
worldPoly[v] = Vector3.Transform(localPoly[v], cell.WorldTransform);
|
||||
exitPortalPolys.Add(worldPoly);
|
||||
}
|
||||
}
|
||||
|
||||
bool hasPortalBounds = false;
|
||||
var portalMin = new Vector3(float.MaxValue);
|
||||
var portalMax = new Vector3(float.MinValue);
|
||||
foreach (var poly in exitPortalPolys)
|
||||
{
|
||||
foreach (var v in poly)
|
||||
{
|
||||
hasPortalBounds = true;
|
||||
portalMin = Vector3.Min(portalMin, v);
|
||||
portalMax = Vector3.Max(portalMax, v);
|
||||
}
|
||||
}
|
||||
|
||||
// WB PortalService.cs:89: skip buildings with no interior cells.
|
||||
if (envCellIds.Count == 0) continue;
|
||||
|
||||
var building = new Building
|
||||
{
|
||||
BuildingId = nextId++,
|
||||
EnvCellIds = envCellIds,
|
||||
ExitPortalPolygons = exitPortalPolys,
|
||||
HasPortalBounds = hasPortalBounds,
|
||||
PortalBounds = hasPortalBounds
|
||||
? new WbBoundingBox(portalMin, portalMax)
|
||||
: default,
|
||||
};
|
||||
reg.Add(building);
|
||||
|
||||
// Step 4: stamp BuildingId on each cell (Option C — both directions
|
||||
// O(1)). The internal setter on LoadedCell.BuildingId is accessible
|
||||
// because this class lives in the same assembly (AcDream.App).
|
||||
foreach (var cellId in envCellIds)
|
||||
{
|
||||
if (cellsByCellId.TryGetValue(cellId, out var cell))
|
||||
cell.BuildingId = building.BuildingId;
|
||||
if (portal.OtherCellId == 0xFFFF) continue;
|
||||
envCellIds.Add(lbMask | portal.OtherCellId);
|
||||
}
|
||||
}
|
||||
|
||||
return reg;
|
||||
// Step B: BFS through interior portals.
|
||||
var queue = new Queue<uint>(envCellIds);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
uint current = queue.Dequeue();
|
||||
if (!publication.CellsByCellId.TryGetValue(current, out var cell))
|
||||
continue;
|
||||
foreach (var portal in cell.Portals)
|
||||
{
|
||||
if (portal.OtherCellId == 0xFFFF) continue;
|
||||
uint neighbourId = lbMask | portal.OtherCellId;
|
||||
if (envCellIds.Add(neighbourId))
|
||||
queue.Enqueue(neighbourId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step C: collect exit portal polygons in world space.
|
||||
foreach (uint cellId in envCellIds)
|
||||
{
|
||||
if (!publication.CellsByCellId.TryGetValue(cellId, out var cell))
|
||||
continue;
|
||||
for (int portalIndex = 0; portalIndex < cell.Portals.Count; portalIndex++)
|
||||
{
|
||||
if (cell.Portals[portalIndex].OtherCellId != 0xFFFF) continue;
|
||||
if (portalIndex >= cell.PortalPolygons.Count) continue;
|
||||
Vector3[] localPolygon = cell.PortalPolygons[portalIndex];
|
||||
if (localPolygon.Length < 3) continue;
|
||||
var worldPolygon = new Vector3[localPolygon.Length];
|
||||
for (int vertexIndex = 0; vertexIndex < localPolygon.Length; vertexIndex++)
|
||||
{
|
||||
worldPolygon[vertexIndex] = Vector3.Transform(
|
||||
localPolygon[vertexIndex],
|
||||
cell.WorldTransform);
|
||||
}
|
||||
exitPortalPolys.Add(worldPolygon);
|
||||
}
|
||||
}
|
||||
|
||||
bool hasPortalBounds = false;
|
||||
var portalMin = new Vector3(float.MaxValue);
|
||||
var portalMax = new Vector3(float.MinValue);
|
||||
foreach (Vector3[] polygon in exitPortalPolys)
|
||||
{
|
||||
foreach (Vector3 vertex in polygon)
|
||||
{
|
||||
hasPortalBounds = true;
|
||||
portalMin = Vector3.Min(portalMin, vertex);
|
||||
portalMax = Vector3.Max(portalMax, vertex);
|
||||
}
|
||||
}
|
||||
|
||||
if (envCellIds.Count == 0)
|
||||
return;
|
||||
|
||||
uint buildingId = publication.NextBuildingId++;
|
||||
publication.Registry.Add(new Building
|
||||
{
|
||||
BuildingId = buildingId,
|
||||
EnvCellIds = envCellIds,
|
||||
ExitPortalPolygons = exitPortalPolys,
|
||||
HasPortalBounds = hasPortalBounds,
|
||||
PortalBounds = hasPortalBounds
|
||||
? new WbBoundingBox(portalMin, portalMax)
|
||||
: default,
|
||||
});
|
||||
|
||||
// Stamps remain off-side until the complete registry commits.
|
||||
foreach (uint cellId in envCellIds)
|
||||
{
|
||||
if (publication.CellsByCellId.TryGetValue(cellId, out var cell))
|
||||
publication.CellStamps.Add((cell, buildingId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
src/AcDream.App/Rendering/Wb/EnvCellLandblockPublication.cs
Normal file
45
src/AcDream.App/Rendering/Wb/EnvCellLandblockPublication.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Narrow update-thread publication seam used by streaming. Preparation builds
|
||||
/// one private shell at a time; commit publishes the completed immutable
|
||||
/// landblock snapshot with one owner dictionary replacement.
|
||||
/// </summary>
|
||||
public interface IEnvCellLandblockPublisher
|
||||
{
|
||||
EnvCellLandblockPublication PreparePublication(
|
||||
EnvCellLandblockBuild build);
|
||||
|
||||
bool AdvancePreparationOne(EnvCellLandblockPublication publication);
|
||||
|
||||
void CommitPublication(EnvCellLandblockPublication publication);
|
||||
}
|
||||
|
||||
public sealed class EnvCellLandblockPublication
|
||||
{
|
||||
internal EnvCellLandblockPublication(
|
||||
object owner,
|
||||
EnvCellLandblockBuild build)
|
||||
{
|
||||
Owner = owner;
|
||||
Build = build;
|
||||
Replacement = new EnvCellLandblock
|
||||
{
|
||||
GridX = (int)((build.LandblockId >> 24) & 0xFFu),
|
||||
GridY = (int)((build.LandblockId >> 16) & 0xFFu),
|
||||
};
|
||||
TotalBounds = new WbBoundingBox(
|
||||
new Vector3(float.MaxValue),
|
||||
new Vector3(float.MinValue));
|
||||
}
|
||||
|
||||
internal object Owner { get; }
|
||||
internal EnvCellLandblockBuild Build { get; }
|
||||
internal EnvCellLandblock Replacement { get; }
|
||||
internal WbBoundingBox TotalBounds { get; set; }
|
||||
internal int ShellCursor { get; set; }
|
||||
internal bool PreparationCommitted { get; set; }
|
||||
internal bool PublicationCommitted { get; set; }
|
||||
}
|
||||
|
|
@ -29,8 +29,11 @@ using Silk.NET.OpenGL;
|
|||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
public sealed unsafe class EnvCellRenderer : IDisposable
|
||||
public sealed unsafe class EnvCellRenderer :
|
||||
IDisposable,
|
||||
IEnvCellLandblockPublisher
|
||||
{
|
||||
private readonly object _publicationOwner = new();
|
||||
private readonly GL _gl;
|
||||
private readonly ObjectMeshManager _meshManager;
|
||||
private readonly WbFrustum _frustum;
|
||||
|
|
@ -381,8 +384,80 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
/// </summary>
|
||||
public void CommitLandblock(EnvCellLandblockBuild build)
|
||||
{
|
||||
_landblocks[build.LandblockId] = CreateCommittedSnapshot(build);
|
||||
EnvCellLandblockPublication publication = PreparePublication(build);
|
||||
while (!AdvancePreparationOne(publication))
|
||||
{
|
||||
}
|
||||
CommitPublication(publication);
|
||||
}
|
||||
|
||||
EnvCellLandblockPublication IEnvCellLandblockPublisher.PreparePublication(
|
||||
EnvCellLandblockBuild build) =>
|
||||
PreparePublication(build);
|
||||
|
||||
bool IEnvCellLandblockPublisher.AdvancePreparationOne(
|
||||
EnvCellLandblockPublication publication) =>
|
||||
AdvancePreparationOne(publication);
|
||||
|
||||
void IEnvCellLandblockPublisher.CommitPublication(
|
||||
EnvCellLandblockPublication publication) =>
|
||||
CommitPublication(publication);
|
||||
|
||||
internal EnvCellLandblockPublication PreparePublication(
|
||||
EnvCellLandblockBuild build)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(build);
|
||||
return new EnvCellLandblockPublication(_publicationOwner, build);
|
||||
}
|
||||
|
||||
internal bool AdvancePreparationOne(
|
||||
EnvCellLandblockPublication publication)
|
||||
{
|
||||
ValidatePublication(publication);
|
||||
if (publication.PreparationCommitted)
|
||||
return true;
|
||||
|
||||
if (publication.ShellCursor < publication.Build.Shells.Length)
|
||||
{
|
||||
AddShell(
|
||||
publication.Replacement,
|
||||
publication.Build.Shells[publication.ShellCursor]);
|
||||
WbBoundingBox bounds =
|
||||
publication.Build.Shells[publication.ShellCursor].WorldBounds;
|
||||
publication.TotalBounds =
|
||||
publication.ShellCursor == 0
|
||||
? bounds
|
||||
: WbBoundingBox.Union(
|
||||
publication.TotalBounds,
|
||||
bounds);
|
||||
publication.ShellCursor++;
|
||||
return false;
|
||||
}
|
||||
|
||||
publication.Replacement.TotalEnvCellBounds =
|
||||
publication.Replacement.EnvCellBounds.Count == 0
|
||||
? new WbBoundingBox(Vector3.Zero, Vector3.Zero)
|
||||
: publication.TotalBounds;
|
||||
publication.Replacement.InstancesReady = true;
|
||||
publication.Replacement.MeshDataReady = true;
|
||||
publication.Replacement.GpuReady = true;
|
||||
publication.PreparationCommitted = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void CommitPublication(
|
||||
EnvCellLandblockPublication publication)
|
||||
{
|
||||
ValidatePublication(publication);
|
||||
if (!publication.PreparationCommitted)
|
||||
throw new InvalidOperationException(
|
||||
"EnvCell landblock publication cannot commit before preparation.");
|
||||
if (publication.PublicationCommitted)
|
||||
return;
|
||||
|
||||
_landblocks[publication.Build.LandblockId] = publication.Replacement;
|
||||
NeedsPrepare = true;
|
||||
publication.PublicationCommitted = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -398,35 +473,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
};
|
||||
|
||||
foreach (var shell in build.Shells)
|
||||
{
|
||||
replacement.Instances.Add(new EnvCellSceneryInstance
|
||||
{
|
||||
ObjectId = shell.GeometryId,
|
||||
InstanceId = shell.CellId,
|
||||
IsBuilding = true,
|
||||
IsEntryCell = false,
|
||||
WorldPosition = shell.WorldPosition,
|
||||
LocalPosition = Vector3.Zero,
|
||||
Rotation = shell.Rotation,
|
||||
Scale = Vector3.One,
|
||||
Transform = shell.Transform,
|
||||
LocalBoundingBox = shell.LocalBounds,
|
||||
BoundingBox = shell.WorldBounds,
|
||||
});
|
||||
replacement.EnvCellBounds[shell.CellId] = shell.WorldBounds;
|
||||
|
||||
if (!replacement.BuildingPartGroups.TryGetValue(shell.GeometryId, out var instances))
|
||||
{
|
||||
instances = new List<InstanceData>();
|
||||
replacement.BuildingPartGroups[shell.GeometryId] = instances;
|
||||
}
|
||||
instances.Add(new InstanceData
|
||||
{
|
||||
Transform = shell.Transform,
|
||||
CellId = shell.CellId,
|
||||
Flags = 0,
|
||||
});
|
||||
}
|
||||
AddShell(replacement, shell);
|
||||
|
||||
var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
|
||||
foreach (var bounds in replacement.EnvCellBounds.Values)
|
||||
|
|
@ -441,6 +488,53 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
return replacement;
|
||||
}
|
||||
|
||||
private static void AddShell(
|
||||
EnvCellLandblock replacement,
|
||||
EnvCellShellPlacement shell)
|
||||
{
|
||||
replacement.Instances.Add(new EnvCellSceneryInstance
|
||||
{
|
||||
ObjectId = shell.GeometryId,
|
||||
InstanceId = shell.CellId,
|
||||
IsBuilding = true,
|
||||
IsEntryCell = false,
|
||||
WorldPosition = shell.WorldPosition,
|
||||
LocalPosition = Vector3.Zero,
|
||||
Rotation = shell.Rotation,
|
||||
Scale = Vector3.One,
|
||||
Transform = shell.Transform,
|
||||
LocalBoundingBox = shell.LocalBounds,
|
||||
BoundingBox = shell.WorldBounds,
|
||||
});
|
||||
replacement.EnvCellBounds[shell.CellId] = shell.WorldBounds;
|
||||
|
||||
if (!replacement.BuildingPartGroups.TryGetValue(
|
||||
shell.GeometryId,
|
||||
out var instances))
|
||||
{
|
||||
instances = new List<InstanceData>();
|
||||
replacement.BuildingPartGroups[shell.GeometryId] = instances;
|
||||
}
|
||||
instances.Add(new InstanceData
|
||||
{
|
||||
Transform = shell.Transform,
|
||||
CellId = shell.CellId,
|
||||
Flags = 0,
|
||||
});
|
||||
}
|
||||
|
||||
private void ValidatePublication(
|
||||
EnvCellLandblockPublication publication)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publication);
|
||||
if (!ReferenceEquals(publication.Owner, _publicationOwner))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The EnvCell publication receipt belongs to another renderer.",
|
||||
nameof(publication));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a landblock from the renderer. Future PrepareRenderBatches will exclude it.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Diagnostics;
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
|
|
@ -15,15 +16,45 @@ public sealed class LandblockPhysicsPublication
|
|||
internal LandblockPhysicsPublication(
|
||||
object owner,
|
||||
LandblockBuild build,
|
||||
Vector3 origin)
|
||||
Vector3 origin,
|
||||
uint currentCellId,
|
||||
BuildingInfo[] buildings,
|
||||
uint[] priorStaticOwnerIds)
|
||||
{
|
||||
Owner = owner;
|
||||
Build = build;
|
||||
Origin = origin;
|
||||
CurrentCellId = currentCellId;
|
||||
Buildings = buildings;
|
||||
PriorStaticOwnerIds = priorStaticOwnerIds;
|
||||
}
|
||||
|
||||
internal object Owner { get; }
|
||||
internal LandblockBuild Build { get; }
|
||||
internal uint CurrentCellId { get; }
|
||||
internal BuildingInfo[] Buildings { get; }
|
||||
internal uint[] PriorStaticOwnerIds { get; }
|
||||
internal SortedSet<uint> GfxObjectIdSet { get; } = new();
|
||||
internal uint[] GfxObjectIds { get; set; } = Array.Empty<uint>();
|
||||
internal int PreparationCursor { get; set; }
|
||||
internal bool PreparationCommitted { get; set; }
|
||||
internal bool PriorCacheRemoved { get; set; }
|
||||
internal TerrainSurface? TerrainSurface { get; set; }
|
||||
internal List<CellSurface> CellSurfaces { get; } = new();
|
||||
internal List<PortalPlane> PortalPlanes { get; } = new();
|
||||
internal uint CellCursor { get; set; }
|
||||
internal int BuildingCursor { get; set; }
|
||||
internal bool BaseCommitted { get; set; }
|
||||
internal int GfxCursor { get; set; }
|
||||
internal int PriorStaticCursor { get; set; }
|
||||
internal int StaticCursor { get; set; }
|
||||
internal int BspOwnerCount { get; set; }
|
||||
internal int CylinderOwnerCount { get; set; }
|
||||
internal int NoCollisionCount { get; set; }
|
||||
internal int SceneryTried { get; set; }
|
||||
internal uint[]? RefloodOwnerIds { get; set; }
|
||||
internal int RefloodCursor { get; set; }
|
||||
internal bool RefloodCommitted { get; set; }
|
||||
internal bool BeginCommitted { get; set; }
|
||||
internal bool CompletionCommitted { get; set; }
|
||||
|
||||
|
|
@ -142,6 +173,21 @@ public sealed class LandblockPhysicsPublisher
|
|||
/// </summary>
|
||||
public LandblockPhysicsPublication PreparePublication(
|
||||
LandblockRenderPublication renderPublication)
|
||||
{
|
||||
LandblockPhysicsPublication publication =
|
||||
CreatePublication(renderPublication);
|
||||
while (!AdvancePreparationOne(publication))
|
||||
{
|
||||
}
|
||||
return publication;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the immutable physics root without walking the entity suffix.
|
||||
/// The concrete pipeline advances that walk under its frame meter.
|
||||
/// </summary>
|
||||
internal LandblockPhysicsPublication CreatePublication(
|
||||
LandblockRenderPublication renderPublication)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(renderPublication);
|
||||
LandblockBuild build = renderPublication.Build;
|
||||
|
|
@ -149,10 +195,50 @@ public sealed class LandblockPhysicsPublisher
|
|||
if (!IsFinite(origin))
|
||||
throw new ArgumentOutOfRangeException(nameof(renderPublication));
|
||||
|
||||
PhysicsDatBundle datBundle =
|
||||
build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
|
||||
BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray()
|
||||
?? Array.Empty<BuildingInfo>();
|
||||
return new LandblockPhysicsPublication(
|
||||
_receiptOwner,
|
||||
build,
|
||||
origin);
|
||||
origin,
|
||||
_physicsDataCache.CellGraph.CurrCell?.Id ?? 0u,
|
||||
buildings,
|
||||
_physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock(
|
||||
build.Landblock.LandblockId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances one entity's stable GfxObj-index preparation. No external
|
||||
/// physics state is mutated until this cursor and the static-validation
|
||||
/// cursor both complete.
|
||||
/// </summary>
|
||||
internal bool AdvancePreparationOne(
|
||||
LandblockPhysicsPublication publication)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (publication.PreparationCommitted)
|
||||
return true;
|
||||
|
||||
IReadOnlyList<WorldEntity> entities =
|
||||
publication.Build.Landblock.Entities;
|
||||
if (publication.PreparationCursor < entities.Count)
|
||||
{
|
||||
WorldEntity entity = entities[publication.PreparationCursor];
|
||||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||||
{
|
||||
uint id = entity.MeshRefs[i].GfxObjId;
|
||||
if ((id & 0xFF000000u) == 0x01000000u)
|
||||
publication.GfxObjectIdSet.Add(id);
|
||||
}
|
||||
publication.PreparationCursor++;
|
||||
return false;
|
||||
}
|
||||
|
||||
publication.GfxObjectIds = publication.GfxObjectIdSet.ToArray();
|
||||
publication.PreparationCommitted = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -160,10 +246,26 @@ public sealed class LandblockPhysicsPublisher
|
|||
/// retained receipt.
|
||||
/// </summary>
|
||||
public void BeginPublication(LandblockPhysicsPublication publication)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (!publication.PreparationCommitted)
|
||||
throw new InvalidOperationException(
|
||||
"Physics publication cannot begin before preparation commits.");
|
||||
while (!AdvanceBeginOne(publication))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances one exact physics-prefix operation: prior-cache withdrawal,
|
||||
/// terrain construction, one EnvCell, one building, or the final canonical
|
||||
/// physics-engine replacement. All arrays and cursors live on the receipt.
|
||||
/// </summary>
|
||||
internal bool AdvanceBeginOne(LandblockPhysicsPublication publication)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (publication.BeginCommitted)
|
||||
return;
|
||||
return true;
|
||||
|
||||
long started = Stopwatch.GetTimestamp();
|
||||
LandblockBuild build = publication.Build;
|
||||
|
|
@ -171,157 +273,76 @@ public sealed class LandblockPhysicsPublisher
|
|||
LoadedLandblock landblock = build.Landblock;
|
||||
PhysicsDatBundle datBundle =
|
||||
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
|
||||
uint currentCellId = _physicsDataCache.CellGraph.CurrCell?.Id ?? 0u;
|
||||
uint landblockX = (landblock.LandblockId >> 24) & 0xFFu;
|
||||
uint landblockY = (landblock.LandblockId >> 16) & 0xFFu;
|
||||
|
||||
// A publication is an exact replacement for this landblock prefix.
|
||||
// CacheCellStruct/CacheBuilding intentionally use retail first-wins
|
||||
// semantics within one pass, so retire the prior pass before adding
|
||||
// the replacement. Adjacent prefixes remain untouched.
|
||||
_physicsDataCache.RemoveCellsForLandblock(landblock.LandblockId);
|
||||
_physicsDataCache.RemoveBuildingsForLandblock(landblock.LandblockId);
|
||||
_physicsDataCache.CellGraph.RemoveEnvCellsForLandblock(
|
||||
landblock.LandblockId);
|
||||
|
||||
// TerrainInfo.Type occupies bits 2-6. The low byte retains those bits
|
||||
// plus Road (bits 0-1), which TerrainSurface masks independently.
|
||||
var terrainBytes = new byte[81];
|
||||
for (int i = 0; i < terrainBytes.Length; i++)
|
||||
terrainBytes[i] = (byte)(ushort)landblock.Heightmap.Terrain[i];
|
||||
|
||||
var terrainSurface = new TerrainSurface(
|
||||
landblock.Heightmap.Height,
|
||||
_heightTable,
|
||||
landblockX,
|
||||
landblockY,
|
||||
terrainBytes);
|
||||
var cellSurfaces = new List<CellSurface>();
|
||||
var portalPlanes = new List<PortalPlane>();
|
||||
|
||||
DatReaderWriter.DBObjs.LandBlockInfo? landblockInfo = datBundle.Info;
|
||||
if (landblockInfo is not null && landblockInfo.NumCells > 0)
|
||||
|
||||
if (!publication.PriorCacheRemoved)
|
||||
{
|
||||
uint firstCellId =
|
||||
(landblock.LandblockId & 0xFFFF0000u) | 0x0100u;
|
||||
for (uint offset = 0; offset < landblockInfo.NumCells; offset++)
|
||||
// CacheCellStruct/CacheBuilding use first-wins semantics within one
|
||||
// publication, so the replacement pass starts with one exact
|
||||
// landblock-scoped withdrawal.
|
||||
_physicsDataCache.RemoveCellsForLandblock(landblock.LandblockId);
|
||||
_physicsDataCache.RemoveBuildingsForLandblock(landblock.LandblockId);
|
||||
_physicsDataCache.CellGraph.RemoveEnvCellsForLandblock(
|
||||
landblock.LandblockId);
|
||||
publication.PriorCacheRemoved = true;
|
||||
}
|
||||
else if (publication.TerrainSurface is null)
|
||||
{
|
||||
uint landblockX = (landblock.LandblockId >> 24) & 0xFFu;
|
||||
uint landblockY = (landblock.LandblockId >> 16) & 0xFFu;
|
||||
var terrainBytes = new byte[81];
|
||||
for (int i = 0; i < terrainBytes.Length; i++)
|
||||
terrainBytes[i] = (byte)(ushort)landblock.Heightmap.Terrain[i];
|
||||
publication.TerrainSurface = new TerrainSurface(
|
||||
landblock.Heightmap.Height,
|
||||
_heightTable,
|
||||
landblockX,
|
||||
landblockY,
|
||||
terrainBytes);
|
||||
}
|
||||
else if (landblockInfo is not null
|
||||
&& publication.CellCursor < landblockInfo.NumCells)
|
||||
{
|
||||
PublishCell(publication, datBundle, publication.CellCursor);
|
||||
publication.CellCursor++;
|
||||
}
|
||||
else if (publication.BuildingCursor < publication.Buildings.Length)
|
||||
{
|
||||
PublishBuilding(
|
||||
landblock,
|
||||
datBundle,
|
||||
publication.TerrainSurface,
|
||||
origin,
|
||||
publication.Buildings[publication.BuildingCursor]);
|
||||
publication.BuildingCursor++;
|
||||
}
|
||||
else if (!publication.BaseCommitted)
|
||||
{
|
||||
_physicsEngine.AddLandblock(
|
||||
landblock.LandblockId,
|
||||
publication.TerrainSurface,
|
||||
publication.CellSurfaces,
|
||||
publication.PortalPlanes,
|
||||
origin.X,
|
||||
origin.Y);
|
||||
if ((publication.CurrentCellId & 0xFFFF0000u)
|
||||
== (landblock.LandblockId & 0xFFFF0000u))
|
||||
{
|
||||
uint envCellId = firstCellId + offset;
|
||||
if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell))
|
||||
continue;
|
||||
if (envCell.EnvironmentId == 0)
|
||||
continue;
|
||||
if (!datBundle.Environments.TryGetValue(
|
||||
0x0D000000u | envCell.EnvironmentId,
|
||||
out var environment))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!environment.Cells.TryGetValue(
|
||||
envCell.CellStructure,
|
||||
out var cellStruct))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Quaternion rotation = envCell.Position.Orientation;
|
||||
Vector3 cellOriginWorld = envCell.Position.Origin + origin;
|
||||
Matrix4x4 physicsCellTransform =
|
||||
Matrix4x4.CreateFromQuaternion(rotation)
|
||||
* Matrix4x4.CreateTranslation(cellOriginWorld);
|
||||
|
||||
_physicsDataCache.CacheCellStruct(
|
||||
envCellId,
|
||||
envCell,
|
||||
cellStruct,
|
||||
physicsCellTransform);
|
||||
|
||||
var worldVertices = new Dictionary<ushort, Vector3>(
|
||||
cellStruct.VertexArray.Vertices.Count);
|
||||
foreach ((ushort vertexId, var vertex) in
|
||||
cellStruct.VertexArray.Vertices)
|
||||
{
|
||||
Vector3 worldPosition =
|
||||
Vector3.Transform(vertex.Origin, rotation)
|
||||
+ cellOriginWorld;
|
||||
worldVertices[(ushort)vertexId] = worldPosition;
|
||||
}
|
||||
|
||||
var polygonVertexIds = new List<List<short>>(
|
||||
cellStruct.PhysicsPolygons.Count);
|
||||
foreach (var polygon in cellStruct.PhysicsPolygons.Values)
|
||||
{
|
||||
var vertexIds = new List<short>(polygon.VertexIds.Count);
|
||||
foreach (short vertexId in polygon.VertexIds)
|
||||
vertexIds.Add(vertexId);
|
||||
polygonVertexIds.Add(vertexIds);
|
||||
}
|
||||
cellSurfaces.Add(new CellSurface(
|
||||
envCellId,
|
||||
worldVertices,
|
||||
polygonVertexIds));
|
||||
|
||||
// CellPortal.PolygonId indexes rendering Polygons, not
|
||||
// PhysicsPolygons (ACViewer EnvCell.find_transit_cells).
|
||||
foreach (var portal in envCell.CellPortals)
|
||||
{
|
||||
if (!cellStruct.Polygons.TryGetValue(
|
||||
portal.PolygonId,
|
||||
out var polygon)
|
||||
|| polygon.VertexIds.Count < 3)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var portalVertices = new Vector3[polygon.VertexIds.Count];
|
||||
bool allFound = true;
|
||||
for (int index = 0; index < polygon.VertexIds.Count; index++)
|
||||
{
|
||||
if (!worldVertices.TryGetValue(
|
||||
(ushort)polygon.VertexIds[index],
|
||||
out portalVertices[index]))
|
||||
{
|
||||
allFound = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!allFound)
|
||||
continue;
|
||||
|
||||
portalPlanes.Add(PortalPlane.FromVertices(
|
||||
portalVertices.AsSpan(),
|
||||
portal.OtherCellId,
|
||||
envCellId & 0xFFFFu,
|
||||
(ushort)portal.Flags));
|
||||
}
|
||||
_physicsEngine.UpdatePlayerCurrCell(publication.CurrentCellId);
|
||||
}
|
||||
publication.BaseCommitted = true;
|
||||
}
|
||||
|
||||
int buildingCount = PublishBuildings(
|
||||
landblock,
|
||||
datBundle,
|
||||
landblockInfo,
|
||||
terrainSurface,
|
||||
origin);
|
||||
_physicsEngine.AddLandblock(
|
||||
landblock.LandblockId,
|
||||
terrainSurface,
|
||||
cellSurfaces,
|
||||
portalPlanes,
|
||||
origin.X,
|
||||
origin.Y);
|
||||
if ((currentCellId & 0xFFFF0000u)
|
||||
== (landblock.LandblockId & 0xFFFF0000u))
|
||||
else
|
||||
{
|
||||
_physicsEngine.UpdatePlayerCurrCell(currentCellId);
|
||||
_cellSurfaceCount += publication.CellSurfaces.Count;
|
||||
_portalPlaneCount += publication.PortalPlanes.Count;
|
||||
_buildingCount += publication.Buildings.Length;
|
||||
publication.BeginCommitted = true;
|
||||
_beginCount++;
|
||||
}
|
||||
|
||||
_cellSurfaceCount += cellSurfaces.Count;
|
||||
_portalPlaneCount += portalPlanes.Count;
|
||||
_buildingCount += buildingCount;
|
||||
publication.BeginCommitted = true;
|
||||
_beginCount++;
|
||||
_basePublishTicks += Stopwatch.GetTimestamp() - started;
|
||||
return publication.BeginCommitted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -338,209 +359,93 @@ public sealed class LandblockPhysicsPublisher
|
|||
if (!publication.BeginCommitted)
|
||||
throw new InvalidOperationException(
|
||||
"Physics publication cannot complete before its prefix commits.");
|
||||
if (publication.CompletionCommitted)
|
||||
return;
|
||||
while (!AdvanceCompleteOne(publication, beforeStaticCollision))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
long completedStarted = Stopwatch.GetTimestamp();
|
||||
/// <summary>
|
||||
/// Advances one exact physics-suffix operation: one GfxObj cache entry,
|
||||
/// prior static withdrawal, one static light/collision entity, or reflood.
|
||||
/// </summary>
|
||||
internal bool AdvanceCompleteOne(
|
||||
LandblockPhysicsPublication publication,
|
||||
Action<WorldEntity>? beforeStaticCollision = null)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (!publication.BeginCommitted)
|
||||
throw new InvalidOperationException(
|
||||
"Physics publication cannot complete before its prefix commits.");
|
||||
if (publication.CompletionCommitted)
|
||||
return true;
|
||||
|
||||
long started = Stopwatch.GetTimestamp();
|
||||
LoadedLandblock landblock = publication.Build.Landblock;
|
||||
PhysicsDatBundle datBundle =
|
||||
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
|
||||
|
||||
long cacheStarted = Stopwatch.GetTimestamp();
|
||||
foreach (WorldEntity entity in landblock.Entities)
|
||||
if (publication.GfxCursor < publication.GfxObjectIds.Length)
|
||||
{
|
||||
foreach (MeshRef meshRef in entity.MeshRefs)
|
||||
{
|
||||
if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u)
|
||||
continue;
|
||||
if (datBundle.GfxObjs.TryGetValue(meshRef.GfxObjId, out var gfx))
|
||||
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
|
||||
}
|
||||
long cacheStarted = Stopwatch.GetTimestamp();
|
||||
uint gfxObjectId = publication.GfxObjectIds[publication.GfxCursor];
|
||||
if (datBundle.GfxObjs.TryGetValue(gfxObjectId, out var gfx))
|
||||
_physicsDataCache.CacheGfxObj(gfxObjectId, gfx);
|
||||
publication.GfxCursor++;
|
||||
_gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted;
|
||||
}
|
||||
_gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted;
|
||||
|
||||
// The immutable entity list is an exact Near snapshot. Retire every
|
||||
// static owner created by the prior snapshot before registering this
|
||||
// one; RegisterMultiPart can replace matching IDs but cannot discover
|
||||
// owners omitted by a ForceReload/reapply. Logical-owner removal also
|
||||
// clears footprints flooded across an adjacent landblock seam.
|
||||
_physicsEngine.ShadowObjects.DeregisterStaticOwnersForLandblock(
|
||||
landblock.LandblockId);
|
||||
|
||||
int landblockBspCount = 0;
|
||||
int landblockCylinderCount = 0;
|
||||
int landblockNoCollisionCount = 0;
|
||||
int sceneryTried = 0;
|
||||
foreach (WorldEntity entity in landblock.Entities)
|
||||
else if (publication.PriorStaticCursor
|
||||
< publication.PriorStaticOwnerIds.Length)
|
||||
{
|
||||
_physicsEngine.ShadowObjects.DeregisterStaticOwnerForLandblock(
|
||||
publication.PriorStaticOwnerIds[
|
||||
publication.PriorStaticCursor],
|
||||
landblock.LandblockId);
|
||||
publication.PriorStaticCursor++;
|
||||
}
|
||||
else if (publication.StaticCursor < landblock.Entities.Count)
|
||||
{
|
||||
WorldEntity entity = landblock.Entities[publication.StaticCursor];
|
||||
beforeStaticCollision?.Invoke(entity);
|
||||
|
||||
// Retail building shells collide exclusively through the
|
||||
// per-landcell building channel. They never become ordinary
|
||||
// shadow objects, even when their source Setup has cylinders.
|
||||
if (entity.IsBuildingShell)
|
||||
continue;
|
||||
|
||||
int entityBspCount = 0;
|
||||
int entityCylinderCount = 0;
|
||||
uint sourcePrefix =
|
||||
entity.SourceGfxObjOrSetupId & 0xFF000000u;
|
||||
bool isOutdoorMesh =
|
||||
(entity.Id & 0x80000000u) != 0
|
||||
|| (entity.Id < 0x40000000u
|
||||
&& (sourcePrefix == 0x01000000u
|
||||
|| sourcePrefix == 0x02000000u));
|
||||
if (isOutdoorMesh)
|
||||
sceneryTried++;
|
||||
|
||||
IReadOnlyList<ShadowShape> bspShapes =
|
||||
ShadowShapeBuilder.FromLandblockBspParts(
|
||||
entity.MeshRefs,
|
||||
entity.IsBuildingShell,
|
||||
_physicsDataCache.GetGfxObj);
|
||||
entityBspCount = bspShapes.Count;
|
||||
if (entityBspCount > 0)
|
||||
{
|
||||
_physicsEngine.ShadowObjects.RegisterMultiPart(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
bspShapes,
|
||||
0u,
|
||||
EntityCollisionFlags.None,
|
||||
publication.Origin.X,
|
||||
publication.Origin.Y,
|
||||
landblock.LandblockId,
|
||||
seedCellId: entity.ParentCellId ?? 0u,
|
||||
isStatic: true);
|
||||
LogMultipartRegistration(landblock, entity, bspShapes);
|
||||
}
|
||||
|
||||
SetupPhysics? setup =
|
||||
_physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
|
||||
if (setup is not null && entityBspCount == 0)
|
||||
{
|
||||
float scale = entity.Scale > 0f ? entity.Scale : 1f;
|
||||
var setupShapes = new List<ShadowShape>();
|
||||
for (int cylinderIndex = 0;
|
||||
cylinderIndex < setup.CylSpheres.Count;
|
||||
cylinderIndex++)
|
||||
{
|
||||
DatReaderWriter.Types.CylSphere cylinder =
|
||||
setup.CylSpheres[cylinderIndex];
|
||||
float radius = cylinder.Radius * scale;
|
||||
float baseHeight = cylinder.Height > 0f
|
||||
? cylinder.Height
|
||||
: cylinder.Radius * 4f;
|
||||
float height = baseHeight * scale;
|
||||
if (radius <= 0f)
|
||||
continue;
|
||||
|
||||
Vector3 localOffset = cylinder.Origin * scale;
|
||||
setupShapes.Add(new ShadowShape(
|
||||
GfxObjId: entity.SourceGfxObjOrSetupId,
|
||||
LocalPosition: localOffset,
|
||||
LocalRotation: Quaternion.Identity,
|
||||
Scale: scale,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: radius,
|
||||
CylHeight: height));
|
||||
}
|
||||
|
||||
if (setup.CylSpheres.Count == 0)
|
||||
{
|
||||
for (int sphereIndex = 0;
|
||||
sphereIndex < setup.Spheres.Count;
|
||||
sphereIndex++)
|
||||
{
|
||||
DatReaderWriter.Types.Sphere sphere =
|
||||
setup.Spheres[sphereIndex];
|
||||
if (sphere.Radius <= 0f)
|
||||
continue;
|
||||
|
||||
float radius = sphere.Radius * scale;
|
||||
Vector3 localOffset = sphere.Origin * scale;
|
||||
Vector3 localBaseOffset = localOffset
|
||||
+ Vector3.Transform(
|
||||
-Vector3.UnitZ * radius,
|
||||
Quaternion.Inverse(entity.Rotation));
|
||||
setupShapes.Add(new ShadowShape(
|
||||
GfxObjId: entity.SourceGfxObjOrSetupId,
|
||||
LocalPosition: localBaseOffset,
|
||||
LocalRotation: Quaternion.Identity,
|
||||
Scale: scale,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: radius,
|
||||
CylHeight: radius * 2f));
|
||||
}
|
||||
}
|
||||
|
||||
if (setup.CylSpheres.Count == 0
|
||||
&& setup.Spheres.Count == 0
|
||||
&& setup.Radius > 0f)
|
||||
{
|
||||
float radius = setup.Radius * scale;
|
||||
float height = (setup.Height > 0f
|
||||
? setup.Height
|
||||
: setup.Radius * 2f) * scale;
|
||||
setupShapes.Add(new ShadowShape(
|
||||
GfxObjId: entity.SourceGfxObjOrSetupId,
|
||||
LocalPosition: Vector3.Zero,
|
||||
LocalRotation: Quaternion.Identity,
|
||||
Scale: scale,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: radius,
|
||||
CylHeight: height));
|
||||
}
|
||||
|
||||
if (setupShapes.Count > 0)
|
||||
{
|
||||
_physicsEngine.ShadowObjects.RegisterMultiPart(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
setupShapes,
|
||||
0u,
|
||||
EntityCollisionFlags.None,
|
||||
publication.Origin.X,
|
||||
publication.Origin.Y,
|
||||
landblock.LandblockId,
|
||||
seedCellId: entity.ParentCellId ?? 0u,
|
||||
isStatic: true);
|
||||
LogSetupRegistration(landblock, entity, setupShapes);
|
||||
entityCylinderCount = setupShapes.Count;
|
||||
}
|
||||
}
|
||||
|
||||
if (entityBspCount > 0)
|
||||
landblockBspCount++;
|
||||
if (entityCylinderCount > 0)
|
||||
landblockCylinderCount++;
|
||||
if (entityBspCount == 0 && entityCylinderCount == 0
|
||||
&& (sourcePrefix == 0x01000000u
|
||||
|| sourcePrefix == 0x02000000u))
|
||||
{
|
||||
landblockNoCollisionCount++;
|
||||
}
|
||||
PublishStaticEntity(publication, entity);
|
||||
publication.StaticCursor++;
|
||||
}
|
||||
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled && sceneryTried > 0)
|
||||
else if (publication.RefloodOwnerIds is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"lb 0x{landblock.LandblockId:X8}: scenery tried={sceneryTried} " +
|
||||
$"(outdoorNone={landblockNoCollisionCount})");
|
||||
publication.RefloodOwnerIds =
|
||||
_physicsEngine.ShadowObjects
|
||||
.CaptureRefloodOwnersForLandblock(landblock.LandblockId);
|
||||
}
|
||||
else if (publication.RefloodCursor
|
||||
< publication.RefloodOwnerIds.Length)
|
||||
{
|
||||
_physicsEngine.ShadowObjects.RefloodOwnerForLandblock(
|
||||
publication.RefloodOwnerIds[publication.RefloodCursor],
|
||||
landblock.LandblockId);
|
||||
publication.RefloodCursor++;
|
||||
}
|
||||
else if (!publication.RefloodCommitted)
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled
|
||||
&& publication.SceneryTried > 0)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"lb 0x{landblock.LandblockId:X8}: scenery tried={publication.SceneryTried} " +
|
||||
$"(outdoorNone={publication.NoCollisionCount})");
|
||||
}
|
||||
LogMissingSceneryBounds(landblock);
|
||||
_refloodCount++;
|
||||
publication.RefloodCommitted = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_staticBspOwnerCount += publication.BspOwnerCount;
|
||||
_staticCylinderOwnerCount += publication.CylinderOwnerCount;
|
||||
publication.CompletionCommitted = true;
|
||||
_completeCount++;
|
||||
}
|
||||
LogMissingSceneryBounds(landblock);
|
||||
|
||||
_physicsEngine.ShadowObjects.RefloodLandblock(landblock.LandblockId);
|
||||
_refloodCount++;
|
||||
|
||||
_staticBspOwnerCount += landblockBspCount;
|
||||
_staticCylinderOwnerCount += landblockCylinderCount;
|
||||
publication.CompletionCommitted = true;
|
||||
_completeCount++;
|
||||
_completePublishTicks +=
|
||||
Stopwatch.GetTimestamp() - completedStarted;
|
||||
_completePublishTicks += Stopwatch.GetTimestamp() - started;
|
||||
return publication.CompletionCommitted;
|
||||
}
|
||||
|
||||
public void DemoteToTerrain(uint landblockId)
|
||||
|
|
@ -555,54 +460,292 @@ public sealed class LandblockPhysicsPublisher
|
|||
_fullRemovalCount++;
|
||||
}
|
||||
|
||||
private int PublishBuildings(
|
||||
private void PublishCell(
|
||||
LandblockPhysicsPublication publication,
|
||||
PhysicsDatBundle datBundle,
|
||||
uint offset)
|
||||
{
|
||||
LoadedLandblock landblock = publication.Build.Landblock;
|
||||
uint envCellId =
|
||||
(landblock.LandblockId & 0xFFFF0000u) | (0x0100u + offset);
|
||||
if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell)
|
||||
|| envCell.EnvironmentId == 0
|
||||
|| !datBundle.Environments.TryGetValue(
|
||||
0x0D000000u | envCell.EnvironmentId,
|
||||
out var environment)
|
||||
|| !environment.Cells.TryGetValue(
|
||||
envCell.CellStructure,
|
||||
out var cellStruct))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Quaternion rotation = envCell.Position.Orientation;
|
||||
Vector3 cellOriginWorld = envCell.Position.Origin + publication.Origin;
|
||||
Matrix4x4 physicsCellTransform =
|
||||
Matrix4x4.CreateFromQuaternion(rotation)
|
||||
* Matrix4x4.CreateTranslation(cellOriginWorld);
|
||||
|
||||
_physicsDataCache.CacheCellStruct(
|
||||
envCellId,
|
||||
envCell,
|
||||
cellStruct,
|
||||
physicsCellTransform);
|
||||
|
||||
var worldVertices = new Dictionary<ushort, Vector3>(
|
||||
cellStruct.VertexArray.Vertices.Count);
|
||||
foreach ((ushort vertexId, var vertex) in
|
||||
cellStruct.VertexArray.Vertices)
|
||||
{
|
||||
Vector3 worldPosition =
|
||||
Vector3.Transform(vertex.Origin, rotation)
|
||||
+ cellOriginWorld;
|
||||
worldVertices[(ushort)vertexId] = worldPosition;
|
||||
}
|
||||
|
||||
var polygonVertexIds = new List<List<short>>(
|
||||
cellStruct.PhysicsPolygons.Count);
|
||||
foreach (var polygon in cellStruct.PhysicsPolygons.Values)
|
||||
{
|
||||
var vertexIds = new List<short>(polygon.VertexIds.Count);
|
||||
foreach (short vertexId in polygon.VertexIds)
|
||||
vertexIds.Add(vertexId);
|
||||
polygonVertexIds.Add(vertexIds);
|
||||
}
|
||||
|
||||
var portalPlanes = new List<PortalPlane>();
|
||||
// CellPortal.PolygonId indexes rendering Polygons, not
|
||||
// PhysicsPolygons (ACViewer EnvCell.find_transit_cells).
|
||||
foreach (var portal in envCell.CellPortals)
|
||||
{
|
||||
if (!cellStruct.Polygons.TryGetValue(
|
||||
portal.PolygonId,
|
||||
out var polygon)
|
||||
|| polygon.VertexIds.Count < 3)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var portalVertices = new Vector3[polygon.VertexIds.Count];
|
||||
bool allFound = true;
|
||||
for (int index = 0; index < polygon.VertexIds.Count; index++)
|
||||
{
|
||||
if (!worldVertices.TryGetValue(
|
||||
(ushort)polygon.VertexIds[index],
|
||||
out portalVertices[index]))
|
||||
{
|
||||
allFound = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!allFound)
|
||||
continue;
|
||||
|
||||
portalPlanes.Add(PortalPlane.FromVertices(
|
||||
portalVertices.AsSpan(),
|
||||
portal.OtherCellId,
|
||||
envCellId & 0xFFFFu,
|
||||
(ushort)portal.Flags));
|
||||
}
|
||||
|
||||
publication.CellSurfaces.Add(new CellSurface(
|
||||
envCellId,
|
||||
worldVertices,
|
||||
polygonVertexIds));
|
||||
publication.PortalPlanes.AddRange(portalPlanes);
|
||||
}
|
||||
|
||||
private void PublishBuilding(
|
||||
LoadedLandblock landblock,
|
||||
PhysicsDatBundle datBundle,
|
||||
DatReaderWriter.DBObjs.LandBlockInfo? landblockInfo,
|
||||
TerrainSurface terrainSurface,
|
||||
Vector3 origin)
|
||||
Vector3 origin,
|
||||
BuildingInfo building)
|
||||
{
|
||||
if (landblockInfo is null || landblockInfo.Buildings.Count == 0)
|
||||
return 0;
|
||||
|
||||
uint landblockPrefix = landblock.LandblockId & 0xFFFF0000u;
|
||||
foreach (var building in landblockInfo.Buildings)
|
||||
var portals = new List<BldPortalInfo>(building.Portals.Count);
|
||||
foreach (var portal in building.Portals)
|
||||
{
|
||||
var portals = new List<BldPortalInfo>(building.Portals.Count);
|
||||
foreach (var portal in building.Portals)
|
||||
{
|
||||
portals.Add(new BldPortalInfo(
|
||||
otherCellId: landblockPrefix | (uint)portal.OtherCellId,
|
||||
otherPortalId: unchecked((short)portal.OtherPortalId),
|
||||
flags: (ushort)portal.Flags));
|
||||
}
|
||||
|
||||
Vector3 buildingOrigin = building.Frame.Origin + origin;
|
||||
Matrix4x4 buildingTransform =
|
||||
Matrix4x4.CreateFromQuaternion(building.Frame.Orientation)
|
||||
* Matrix4x4.CreateTranslation(buildingOrigin);
|
||||
uint landcellLow = terrainSurface.ComputeOutdoorCellId(
|
||||
building.Frame.Origin.X,
|
||||
building.Frame.Origin.Y);
|
||||
uint landcellId = landblockPrefix | landcellLow;
|
||||
|
||||
uint shellPartZero = building.ModelId;
|
||||
if ((shellPartZero & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
datBundle.Setups.TryGetValue(
|
||||
building.ModelId,
|
||||
out var setup);
|
||||
shellPartZero = setup is not null && setup.Parts.Count > 0
|
||||
? setup.Parts[0]
|
||||
: 0u;
|
||||
}
|
||||
_physicsDataCache.CacheBuilding(
|
||||
landcellId,
|
||||
portals,
|
||||
buildingTransform,
|
||||
modelId: shellPartZero);
|
||||
portals.Add(new BldPortalInfo(
|
||||
otherCellId: landblockPrefix | (uint)portal.OtherCellId,
|
||||
otherPortalId: unchecked((short)portal.OtherPortalId),
|
||||
flags: (ushort)portal.Flags));
|
||||
}
|
||||
|
||||
Vector3 buildingOrigin = building.Frame.Origin + origin;
|
||||
Matrix4x4 buildingTransform =
|
||||
Matrix4x4.CreateFromQuaternion(building.Frame.Orientation)
|
||||
* Matrix4x4.CreateTranslation(buildingOrigin);
|
||||
uint landcellLow = terrainSurface.ComputeOutdoorCellId(
|
||||
building.Frame.Origin.X,
|
||||
building.Frame.Origin.Y);
|
||||
uint landcellId = landblockPrefix | landcellLow;
|
||||
|
||||
uint shellPartZero = building.ModelId;
|
||||
if ((shellPartZero & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
datBundle.Setups.TryGetValue(
|
||||
building.ModelId,
|
||||
out var setup);
|
||||
shellPartZero = setup is not null && setup.Parts.Count > 0
|
||||
? setup.Parts[0]
|
||||
: 0u;
|
||||
}
|
||||
_physicsDataCache.CacheBuilding(
|
||||
landcellId,
|
||||
portals,
|
||||
buildingTransform,
|
||||
modelId: shellPartZero);
|
||||
}
|
||||
|
||||
private void PublishStaticEntity(
|
||||
LandblockPhysicsPublication publication,
|
||||
WorldEntity entity)
|
||||
{
|
||||
LoadedLandblock landblock = publication.Build.Landblock;
|
||||
// Retail building shells collide exclusively through the per-landcell
|
||||
// building channel. They never become ordinary shadow objects.
|
||||
if (entity.IsBuildingShell)
|
||||
return;
|
||||
|
||||
int entityBspCount = 0;
|
||||
int entityCylinderCount = 0;
|
||||
uint sourcePrefix = entity.SourceGfxObjOrSetupId & 0xFF000000u;
|
||||
bool isOutdoorMesh =
|
||||
(entity.Id & 0x80000000u) != 0
|
||||
|| (entity.Id < 0x40000000u
|
||||
&& (sourcePrefix == 0x01000000u
|
||||
|| sourcePrefix == 0x02000000u));
|
||||
if (isOutdoorMesh)
|
||||
publication.SceneryTried++;
|
||||
|
||||
IReadOnlyList<ShadowShape> bspShapes =
|
||||
ShadowShapeBuilder.FromLandblockBspParts(
|
||||
entity.MeshRefs,
|
||||
entity.IsBuildingShell,
|
||||
_physicsDataCache.GetGfxObj);
|
||||
entityBspCount = bspShapes.Count;
|
||||
if (entityBspCount > 0)
|
||||
{
|
||||
_physicsEngine.ShadowObjects.RegisterMultiPart(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
bspShapes,
|
||||
0u,
|
||||
EntityCollisionFlags.None,
|
||||
publication.Origin.X,
|
||||
publication.Origin.Y,
|
||||
landblock.LandblockId,
|
||||
seedCellId: entity.ParentCellId ?? 0u,
|
||||
isStatic: true);
|
||||
LogMultipartRegistration(landblock, entity, bspShapes);
|
||||
}
|
||||
|
||||
SetupPhysics? setup =
|
||||
_physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
|
||||
if (setup is not null && entityBspCount == 0)
|
||||
{
|
||||
float scale = entity.Scale > 0f ? entity.Scale : 1f;
|
||||
var setupShapes = new List<ShadowShape>();
|
||||
for (int cylinderIndex = 0;
|
||||
cylinderIndex < setup.CylSpheres.Count;
|
||||
cylinderIndex++)
|
||||
{
|
||||
CylSphere cylinder = setup.CylSpheres[cylinderIndex];
|
||||
float radius = cylinder.Radius * scale;
|
||||
float baseHeight = cylinder.Height > 0f
|
||||
? cylinder.Height
|
||||
: cylinder.Radius * 4f;
|
||||
float height = baseHeight * scale;
|
||||
if (radius <= 0f)
|
||||
continue;
|
||||
|
||||
Vector3 localOffset = cylinder.Origin * scale;
|
||||
setupShapes.Add(new ShadowShape(
|
||||
GfxObjId: entity.SourceGfxObjOrSetupId,
|
||||
LocalPosition: localOffset,
|
||||
LocalRotation: Quaternion.Identity,
|
||||
Scale: scale,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: radius,
|
||||
CylHeight: height));
|
||||
}
|
||||
|
||||
if (setup.CylSpheres.Count == 0)
|
||||
{
|
||||
for (int sphereIndex = 0;
|
||||
sphereIndex < setup.Spheres.Count;
|
||||
sphereIndex++)
|
||||
{
|
||||
Sphere sphere = setup.Spheres[sphereIndex];
|
||||
if (sphere.Radius <= 0f)
|
||||
continue;
|
||||
|
||||
float radius = sphere.Radius * scale;
|
||||
Vector3 localOffset = sphere.Origin * scale;
|
||||
Vector3 localBaseOffset = localOffset
|
||||
+ Vector3.Transform(
|
||||
-Vector3.UnitZ * radius,
|
||||
Quaternion.Inverse(entity.Rotation));
|
||||
setupShapes.Add(new ShadowShape(
|
||||
GfxObjId: entity.SourceGfxObjOrSetupId,
|
||||
LocalPosition: localBaseOffset,
|
||||
LocalRotation: Quaternion.Identity,
|
||||
Scale: scale,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: radius,
|
||||
CylHeight: radius * 2f));
|
||||
}
|
||||
}
|
||||
|
||||
if (setup.CylSpheres.Count == 0
|
||||
&& setup.Spheres.Count == 0
|
||||
&& setup.Radius > 0f)
|
||||
{
|
||||
float radius = setup.Radius * scale;
|
||||
float height = (setup.Height > 0f
|
||||
? setup.Height
|
||||
: setup.Radius * 2f) * scale;
|
||||
setupShapes.Add(new ShadowShape(
|
||||
GfxObjId: entity.SourceGfxObjOrSetupId,
|
||||
LocalPosition: Vector3.Zero,
|
||||
LocalRotation: Quaternion.Identity,
|
||||
Scale: scale,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: radius,
|
||||
CylHeight: height));
|
||||
}
|
||||
|
||||
if (setupShapes.Count > 0)
|
||||
{
|
||||
_physicsEngine.ShadowObjects.RegisterMultiPart(
|
||||
entity.Id,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
setupShapes,
|
||||
0u,
|
||||
EntityCollisionFlags.None,
|
||||
publication.Origin.X,
|
||||
publication.Origin.Y,
|
||||
landblock.LandblockId,
|
||||
seedCellId: entity.ParentCellId ?? 0u,
|
||||
isStatic: true);
|
||||
LogSetupRegistration(landblock, entity, setupShapes);
|
||||
entityCylinderCount = setupShapes.Count;
|
||||
}
|
||||
}
|
||||
|
||||
if (entityBspCount > 0)
|
||||
publication.BspOwnerCount++;
|
||||
if (entityCylinderCount > 0)
|
||||
publication.CylinderOwnerCount++;
|
||||
if (entityBspCount == 0 && entityCylinderCount == 0
|
||||
&& (sourcePrefix == 0x01000000u
|
||||
|| sourcePrefix == 0x02000000u))
|
||||
{
|
||||
publication.NoCollisionCount++;
|
||||
}
|
||||
return landblockInfo.Buildings.Count;
|
||||
}
|
||||
|
||||
private static void LogSetupRegistration(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ public readonly record struct LandblockPresentationDiagnostics(
|
|||
LandblockPhysicsPublisherDiagnostics Physics,
|
||||
LandblockStaticPresentationDiagnostics Statics);
|
||||
|
||||
internal readonly record struct LandblockPublicationAdvance(
|
||||
bool Completed,
|
||||
bool Progressed);
|
||||
|
||||
/// <summary>
|
||||
/// Render-thread transaction coordinator for one accepted landblock streaming
|
||||
/// result. Streaming policy (desired tier, generation, priority, and apply
|
||||
|
|
@ -48,6 +52,7 @@ public sealed class LandblockPresentationPipeline
|
|||
public required LandblockBuild Build;
|
||||
public required LandblockMeshData MeshData;
|
||||
public required uint LandblockId;
|
||||
public required LandblockStreamCostEstimate Cost;
|
||||
public LandblockStreamTier Tier;
|
||||
public bool PresentationCommitted;
|
||||
public LandblockRenderPublication? RenderPublication;
|
||||
|
|
@ -186,7 +191,19 @@ public sealed class LandblockPresentationPipeline
|
|||
ArgumentNullException.ThrowIfNull(result);
|
||||
if (!_publications.TryGetValue(result, out PublicationTransaction? transaction))
|
||||
return;
|
||||
Advance(result, transaction);
|
||||
Advance(result, transaction, meter: null, ensureProgress: false);
|
||||
}
|
||||
|
||||
internal LandblockPublicationAdvance ResumePublication(
|
||||
LandblockStreamResult result,
|
||||
StreamingWorkMeter meter,
|
||||
bool ensureProgress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(meter);
|
||||
if (!_publications.TryGetValue(result, out PublicationTransaction? transaction))
|
||||
return new LandblockPublicationAdvance(Completed: true, Progressed: false);
|
||||
return Advance(result, transaction, meter, ensureProgress);
|
||||
}
|
||||
|
||||
public void AdvanceRetirements() => _retirements.Advance();
|
||||
|
|
@ -223,8 +240,28 @@ public sealed class LandblockPresentationPipeline
|
|||
loaded.Build,
|
||||
loaded.MeshData,
|
||||
loaded.LandblockId,
|
||||
loaded.Tier);
|
||||
Advance(loaded, transaction);
|
||||
loaded.Tier,
|
||||
estimate: null);
|
||||
Advance(loaded, transaction, meter: null, ensureProgress: false);
|
||||
}
|
||||
|
||||
internal LandblockPublicationAdvance PublishLoaded(
|
||||
LandblockStreamResult.Loaded loaded,
|
||||
LandblockStreamCostEstimate estimate,
|
||||
StreamingWorkMeter meter,
|
||||
bool ensureProgress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(loaded);
|
||||
ArgumentNullException.ThrowIfNull(meter);
|
||||
PublicationTransaction transaction = GetOrCreate(
|
||||
loaded,
|
||||
PublicationKind.Loaded,
|
||||
loaded.Build,
|
||||
loaded.MeshData,
|
||||
loaded.LandblockId,
|
||||
loaded.Tier,
|
||||
estimate);
|
||||
return Advance(loaded, transaction, meter, ensureProgress);
|
||||
}
|
||||
|
||||
public void PublishPromoted(
|
||||
|
|
@ -241,8 +278,32 @@ public sealed class LandblockPresentationPipeline
|
|||
promoted.Build,
|
||||
promoted.MeshData,
|
||||
promoted.LandblockId,
|
||||
LandblockStreamTier.Near);
|
||||
Advance(promoted, transaction);
|
||||
LandblockStreamTier.Near,
|
||||
estimate: null);
|
||||
Advance(promoted, transaction, meter: null, ensureProgress: false);
|
||||
}
|
||||
|
||||
internal LandblockPublicationAdvance PublishPromoted(
|
||||
LandblockStreamResult.Promoted promoted,
|
||||
bool mergeIntoExistingLandblock,
|
||||
LandblockStreamCostEstimate estimate,
|
||||
StreamingWorkMeter meter,
|
||||
bool ensureProgress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(promoted);
|
||||
ArgumentNullException.ThrowIfNull(meter);
|
||||
PublicationKind kind = mergeIntoExistingLandblock
|
||||
? PublicationKind.PromoteExisting
|
||||
: PublicationKind.PromoteSelfContained;
|
||||
PublicationTransaction transaction = GetOrCreate(
|
||||
promoted,
|
||||
kind,
|
||||
promoted.Build,
|
||||
promoted.MeshData,
|
||||
promoted.LandblockId,
|
||||
LandblockStreamTier.Near,
|
||||
estimate);
|
||||
return Advance(promoted, transaction, meter, ensureProgress);
|
||||
}
|
||||
|
||||
public void PublishAsFar(
|
||||
|
|
@ -270,6 +331,11 @@ public sealed class LandblockPresentationPipeline
|
|||
Origin: completedBuild.Origin),
|
||||
MeshData = meshData,
|
||||
LandblockId = farLandblock.LandblockId,
|
||||
Cost = LandblockStreamResultCost.Estimate(
|
||||
new LandblockBuild(
|
||||
farLandblock,
|
||||
Origin: completedBuild.Origin),
|
||||
meshData),
|
||||
Tier = LandblockStreamTier.Far,
|
||||
};
|
||||
_publications.Add(acceptedResult, transaction);
|
||||
|
|
@ -280,7 +346,56 @@ public sealed class LandblockPresentationPipeline
|
|||
$"Landblock publication 0x{transaction.LandblockId:X8} changed kind while pending.");
|
||||
}
|
||||
|
||||
Advance(acceptedResult, transaction);
|
||||
Advance(
|
||||
acceptedResult,
|
||||
transaction,
|
||||
meter: null,
|
||||
ensureProgress: false);
|
||||
}
|
||||
|
||||
internal LandblockPublicationAdvance PublishAsFar(
|
||||
LandblockStreamResult acceptedResult,
|
||||
LandblockBuild completedBuild,
|
||||
LandblockMeshData meshData,
|
||||
StreamingWorkMeter meter,
|
||||
bool ensureProgress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(acceptedResult);
|
||||
ArgumentNullException.ThrowIfNull(completedBuild);
|
||||
ArgumentNullException.ThrowIfNull(meshData);
|
||||
ArgumentNullException.ThrowIfNull(meter);
|
||||
|
||||
if (!_publications.TryGetValue(
|
||||
acceptedResult,
|
||||
out PublicationTransaction? transaction))
|
||||
{
|
||||
LoadedLandblock completed = completedBuild.Landblock;
|
||||
var farLandblock = new LoadedLandblock(
|
||||
completed.LandblockId,
|
||||
completed.Heightmap,
|
||||
Array.Empty<WorldEntity>(),
|
||||
PhysicsDatBundle.Empty);
|
||||
LandblockBuild farBuild = new(
|
||||
farLandblock,
|
||||
Origin: completedBuild.Origin);
|
||||
transaction = new PublicationTransaction
|
||||
{
|
||||
Kind = PublicationKind.Far,
|
||||
Build = farBuild,
|
||||
MeshData = meshData,
|
||||
LandblockId = farLandblock.LandblockId,
|
||||
Cost = LandblockStreamResultCost.Estimate(farBuild, meshData),
|
||||
Tier = LandblockStreamTier.Far,
|
||||
};
|
||||
_publications.Add(acceptedResult, transaction);
|
||||
}
|
||||
else if (transaction.Kind != PublicationKind.Far)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Landblock publication 0x{transaction.LandblockId:X8} changed kind while pending.");
|
||||
}
|
||||
|
||||
return Advance(acceptedResult, transaction, meter, ensureProgress);
|
||||
}
|
||||
|
||||
private PublicationTransaction GetOrCreate(
|
||||
|
|
@ -289,7 +404,8 @@ public sealed class LandblockPresentationPipeline
|
|||
LandblockBuild build,
|
||||
LandblockMeshData meshData,
|
||||
uint landblockId,
|
||||
LandblockStreamTier tier)
|
||||
LandblockStreamTier tier,
|
||||
LandblockStreamCostEstimate? estimate)
|
||||
{
|
||||
if (_publications.TryGetValue(result, out PublicationTransaction? existing))
|
||||
{
|
||||
|
|
@ -311,71 +427,317 @@ public sealed class LandblockPresentationPipeline
|
|||
Build = build,
|
||||
MeshData = meshData,
|
||||
LandblockId = landblockId,
|
||||
Cost = estimate
|
||||
?? LandblockStreamResultCost.Estimate(build, meshData),
|
||||
Tier = tier,
|
||||
};
|
||||
_publications.Add(result, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private void Advance(
|
||||
private LandblockPublicationAdvance Advance(
|
||||
LandblockStreamResult result,
|
||||
PublicationTransaction transaction)
|
||||
PublicationTransaction transaction,
|
||||
StreamingWorkMeter? meter,
|
||||
bool ensureProgress)
|
||||
{
|
||||
bool progressed = false;
|
||||
bool TryRun(
|
||||
StreamingWorkCost cost,
|
||||
string stage,
|
||||
Action operation)
|
||||
{
|
||||
if (meter is null)
|
||||
{
|
||||
operation();
|
||||
progressed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
StreamingWorkAdmission admission = meter.TryReserve(
|
||||
cost,
|
||||
stage,
|
||||
ensureProgress && !progressed);
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
return false;
|
||||
try
|
||||
{
|
||||
operation();
|
||||
meter.Complete();
|
||||
progressed = true;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
meter.Fail();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
if (!transaction.PresentationCommitted)
|
||||
{
|
||||
if (_renderPublisher is not null
|
||||
&& _physicsPublisher is not null
|
||||
&& _staticPublisher is not null)
|
||||
{
|
||||
transaction.RenderPublication ??=
|
||||
_renderPublisher.PreparePublication(
|
||||
transaction.Build,
|
||||
transaction.MeshData);
|
||||
transaction.PhysicsPublication ??=
|
||||
_physicsPublisher.PreparePublication(
|
||||
transaction.RenderPublication);
|
||||
transaction.StaticPublication ??=
|
||||
_staticPublisher.PreparePublication(
|
||||
transaction.PhysicsPublication);
|
||||
while (transaction.RenderPublication is null)
|
||||
{
|
||||
if (!TryRun(
|
||||
new StreamingWorkCost(EntityOperations: 1),
|
||||
"publication-prepare-render",
|
||||
() => transaction.RenderPublication =
|
||||
_renderPublisher.PreparePublication(
|
||||
transaction.Build,
|
||||
transaction.MeshData)))
|
||||
{
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
while (transaction.PhysicsPublication is null)
|
||||
{
|
||||
if (!TryRun(
|
||||
default,
|
||||
"publication-prepare-physics",
|
||||
() => transaction.PhysicsPublication =
|
||||
_physicsPublisher.CreatePublication(
|
||||
transaction.RenderPublication)))
|
||||
{
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
while (!transaction.PhysicsPublication.PreparationCommitted)
|
||||
{
|
||||
int entityOperations =
|
||||
transaction.PhysicsPublication.PreparationCursor
|
||||
< transaction.Build.Landblock.Entities.Count
|
||||
? 1
|
||||
: 0;
|
||||
if (!TryRun(
|
||||
new StreamingWorkCost(
|
||||
EntityOperations: entityOperations),
|
||||
"publication-index-physics",
|
||||
() => _physicsPublisher.AdvancePreparationOne(
|
||||
transaction.PhysicsPublication)))
|
||||
{
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
while (transaction.StaticPublication is null)
|
||||
{
|
||||
if (!TryRun(
|
||||
default,
|
||||
"publication-prepare-statics",
|
||||
() => transaction.StaticPublication =
|
||||
_staticPublisher.CreatePublication(
|
||||
transaction.PhysicsPublication)))
|
||||
{
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
while (!transaction.StaticPublication.PreparationCommitted)
|
||||
{
|
||||
int entityOperations =
|
||||
transaction.StaticPublication.PreparationCursor
|
||||
< transaction.Build.Landblock.Entities.Count
|
||||
? 1
|
||||
: 0;
|
||||
if (!TryRun(
|
||||
new StreamingWorkCost(
|
||||
EntityOperations: entityOperations),
|
||||
"publication-validate-statics",
|
||||
() => _staticPublisher.AdvancePreparationOne(
|
||||
transaction.StaticPublication)))
|
||||
{
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare every immutable receipt before the first external
|
||||
// mutation. Cross-owner identity/data errors therefore block
|
||||
// the publication without leaving a committed prefix.
|
||||
_renderPublisher.BeginPublication(transaction.RenderPublication);
|
||||
_physicsPublisher.BeginPublication(transaction.PhysicsPublication);
|
||||
while (!transaction.RenderPublication.BeginCommitted)
|
||||
{
|
||||
StreamingWorkCost cost =
|
||||
!transaction.RenderPublication.TerrainCommitted
|
||||
? new StreamingWorkCost(
|
||||
GpuUploadBytes:
|
||||
transaction.Cost.TerrainPayloadBytes)
|
||||
: !transaction.RenderPublication.VisibilityCommitted
|
||||
? new StreamingWorkCost(
|
||||
EntityOperations:
|
||||
transaction.Cost.VisibilityCells)
|
||||
: default;
|
||||
if (!TryRun(
|
||||
cost,
|
||||
"publication-render-prefix",
|
||||
() => _renderPublisher.AdvanceBeginOne(
|
||||
transaction.RenderPublication)))
|
||||
{
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
while (!transaction.PhysicsPublication.BeginCommitted)
|
||||
{
|
||||
int entityOperations =
|
||||
transaction.PhysicsPublication.TerrainSurface is not null
|
||||
&& transaction.PhysicsPublication.Build.Landblock.PhysicsDats?
|
||||
.Info is { } info
|
||||
&& transaction.PhysicsPublication.CellCursor < info.NumCells
|
||||
? 1
|
||||
: transaction.PhysicsPublication.BuildingCursor
|
||||
< transaction.PhysicsPublication.Buildings.Length
|
||||
? 1
|
||||
: 0;
|
||||
if (!TryRun(
|
||||
new StreamingWorkCost(
|
||||
EntityOperations: entityOperations),
|
||||
"publication-physics-prefix",
|
||||
() => _physicsPublisher.AdvanceBeginOne(
|
||||
transaction.PhysicsPublication)))
|
||||
{
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
|
||||
// Retail establishes the building/visible-cell suffix before
|
||||
// each static object publishes lights, collision, and scripts.
|
||||
_renderPublisher.CompletePublication(
|
||||
transaction.RenderPublication);
|
||||
while (!transaction.RenderPublication.CompletionCommitted)
|
||||
{
|
||||
int entityOperations =
|
||||
transaction.RenderPublication.BuildingPublication is
|
||||
{ PreparationCommitted: false } buildingPublication
|
||||
&& buildingPublication.BuildingCursor
|
||||
< buildingPublication.Buildings.Length
|
||||
? 1
|
||||
: transaction.RenderPublication.EnvCellPublication is
|
||||
{ PreparationCommitted: false } envCellPublication
|
||||
&& envCellPublication.ShellCursor
|
||||
< envCellPublication.Build.Shells.Length
|
||||
? 1
|
||||
: !transaction.RenderPublication.EnvCellsCommitted
|
||||
&& transaction.RenderPublication
|
||||
.EnvCellPublication is null
|
||||
? transaction.Cost.EnvCellShells
|
||||
: 0;
|
||||
if (!TryRun(
|
||||
new StreamingWorkCost(
|
||||
EntityOperations: entityOperations),
|
||||
"publication-render-suffix",
|
||||
() => _renderPublisher.AdvanceCompleteOne(
|
||||
transaction.RenderPublication)))
|
||||
{
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
|
||||
_staticPublisher.BeginPublication(transaction.StaticPublication);
|
||||
_physicsPublisher.CompletePublication(
|
||||
transaction.PhysicsPublication,
|
||||
entity => _staticPublisher.PrepareEntityBeforeCollision(
|
||||
transaction.StaticPublication,
|
||||
entity));
|
||||
_staticPublisher.CompletePublication(
|
||||
transaction.StaticPublication);
|
||||
while (!transaction.StaticPublication.BeginCommitted)
|
||||
{
|
||||
int entityOperations =
|
||||
transaction.StaticPublication.PriorCleanupCursor
|
||||
< transaction.StaticPublication
|
||||
.OrderedPreviouslyActiveIds.Count
|
||||
? 1
|
||||
: 0;
|
||||
if (!TryRun(
|
||||
new StreamingWorkCost(
|
||||
EntityOperations: entityOperations),
|
||||
"publication-static-cleanup",
|
||||
() => _staticPublisher.AdvanceBeginOne(
|
||||
transaction.StaticPublication)))
|
||||
{
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
while (!transaction.PhysicsPublication.CompletionCommitted)
|
||||
{
|
||||
int entityOperations =
|
||||
transaction.PhysicsPublication.GfxCursor
|
||||
< transaction.PhysicsPublication.GfxObjectIds.Length
|
||||
? 1
|
||||
: transaction.PhysicsPublication.PriorStaticCursor
|
||||
< transaction.PhysicsPublication
|
||||
.PriorStaticOwnerIds.Length
|
||||
? 1
|
||||
: transaction.PhysicsPublication.StaticCursor
|
||||
< transaction.Build.Landblock.Entities.Count
|
||||
? 1
|
||||
: transaction.PhysicsPublication
|
||||
.RefloodOwnerIds is not null
|
||||
&& transaction.PhysicsPublication
|
||||
.RefloodCursor
|
||||
< transaction.PhysicsPublication
|
||||
.RefloodOwnerIds.Length
|
||||
? 1
|
||||
: 0;
|
||||
if (!TryRun(
|
||||
new StreamingWorkCost(
|
||||
EntityOperations: entityOperations),
|
||||
"publication-physics-statics",
|
||||
() => _physicsPublisher.AdvanceCompleteOne(
|
||||
transaction.PhysicsPublication,
|
||||
entity =>
|
||||
_staticPublisher
|
||||
.PrepareEntityBeforeCollision(
|
||||
transaction.StaticPublication,
|
||||
entity))))
|
||||
{
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
while (!transaction.StaticPublication.CompletionCommitted)
|
||||
{
|
||||
int entityOperations =
|
||||
transaction.StaticPublication.PluginCursor
|
||||
< transaction.StaticPublication.OrderedSnapshots.Count
|
||||
? 1
|
||||
: 0;
|
||||
if (!TryRun(
|
||||
new StreamingWorkCost(
|
||||
EntityOperations: entityOperations),
|
||||
"publication-plugin-projection",
|
||||
() => _staticPublisher.AdvanceCompleteOne(
|
||||
transaction.StaticPublication)))
|
||||
{
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
StreamingWorkCost legacyCost = new(
|
||||
EntityOperations: transaction.Cost.Entities,
|
||||
GpuUploadBytes: transaction.Cost.TerrainPayloadBytes);
|
||||
bool ran = TryRun(
|
||||
legacyCost,
|
||||
"publication-legacy-presentation",
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
(_publishBeforeSpatialCommit
|
||||
?? throw new InvalidOperationException(
|
||||
"The presentation pipeline has no publication owners."))(
|
||||
transaction.Build,
|
||||
transaction.MeshData);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// Compatibility callbacks cannot report an exact retained
|
||||
// receipt. Preserve the established fail-fast rule only
|
||||
// for callbacks that explicitly report committed mutation.
|
||||
if (error is StreamingMutationException
|
||||
{
|
||||
MutationCommitted: true,
|
||||
})
|
||||
{
|
||||
_publications.Remove(result);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
});
|
||||
if (!ran)
|
||||
{
|
||||
(_publishBeforeSpatialCommit
|
||||
?? throw new InvalidOperationException(
|
||||
"The presentation pipeline has no publication owners."))(
|
||||
transaction.Build,
|
||||
transaction.MeshData);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// Compatibility callbacks cannot report an exact retained
|
||||
// receipt. Preserve the established fail-fast rule only
|
||||
// for callbacks that explicitly report committed mutation.
|
||||
if (error is StreamingMutationException { MutationCommitted: true })
|
||||
_publications.Remove(result);
|
||||
throw;
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
transaction.PresentationCommitted = true;
|
||||
|
|
@ -387,46 +749,70 @@ public sealed class LandblockPresentationPipeline
|
|||
// boundary: bucket mutation, pin/script reconciliation, then the
|
||||
// outer spatial observer commit. If pinning throws, the receipt is
|
||||
// retained and only that suffix retries.
|
||||
using GpuWorldState.MutationBatch mutation = _state.BeginMutationBatch();
|
||||
if (!transaction.SpatialCommitted)
|
||||
{
|
||||
IEnumerable<ulong>? renderIds =
|
||||
transaction.Build.EnvCells?.Shells.Select(static shell => shell.GeometryId);
|
||||
transaction.SpatialPublication = transaction.Kind switch
|
||||
{
|
||||
PublicationKind.Loaded
|
||||
or PublicationKind.PromoteSelfContained
|
||||
or PublicationKind.Far =>
|
||||
_state.CommitLandblockSpatial(
|
||||
transaction.Build.Landblock,
|
||||
renderIds,
|
||||
transaction.Tier),
|
||||
PublicationKind.PromoteExisting =>
|
||||
_state.CommitEntitiesToExistingLandblockSpatial(
|
||||
transaction.LandblockId,
|
||||
transaction.Build.Landblock.Entities,
|
||||
renderIds),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unknown landblock publication kind {transaction.Kind}."),
|
||||
};
|
||||
transaction.SpatialCommitted = true;
|
||||
}
|
||||
if (!TryRun(
|
||||
default,
|
||||
"publication-spatial-commit",
|
||||
() =>
|
||||
{
|
||||
using GpuWorldState.MutationBatch mutation =
|
||||
_state.BeginMutationBatch();
|
||||
if (!transaction.SpatialCommitted)
|
||||
{
|
||||
IEnumerable<ulong>? renderIds =
|
||||
transaction.Build.EnvCells?.Shells.Select(
|
||||
static shell => shell.GeometryId);
|
||||
transaction.SpatialPublication = transaction.Kind switch
|
||||
{
|
||||
PublicationKind.Loaded
|
||||
or PublicationKind.PromoteSelfContained
|
||||
or PublicationKind.Far =>
|
||||
_state.CommitLandblockSpatial(
|
||||
transaction.Build.Landblock,
|
||||
renderIds,
|
||||
transaction.Tier),
|
||||
PublicationKind.PromoteExisting =>
|
||||
_state.CommitEntitiesToExistingLandblockSpatial(
|
||||
transaction.LandblockId,
|
||||
transaction.Build.Landblock.Entities,
|
||||
renderIds),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unknown landblock publication kind {transaction.Kind}."),
|
||||
};
|
||||
transaction.SpatialCommitted = true;
|
||||
}
|
||||
|
||||
_state.ActivateLandblockPresentation(
|
||||
transaction.SpatialPublication
|
||||
?? throw new InvalidOperationException(
|
||||
"A committed spatial publication has no activation receipt."));
|
||||
transaction.SpatialPresentationCommitted = true;
|
||||
_state.ActivateLandblockPresentation(
|
||||
transaction.SpatialPublication
|
||||
?? throw new InvalidOperationException(
|
||||
"A committed spatial publication has no activation receipt."));
|
||||
transaction.SpatialPresentationCommitted = true;
|
||||
}))
|
||||
{
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
|
||||
if (!transaction.EnvCellReplayCommitted)
|
||||
{
|
||||
if (transaction.Kind != PublicationKind.Far
|
||||
&& transaction.Build.EnvCells is { Shells.Length: > 0 } envCells)
|
||||
if (!TryRun(
|
||||
new StreamingWorkCost(
|
||||
EntityOperations: transaction.Cost.EnvCellShells),
|
||||
"publication-envcell-replay",
|
||||
() =>
|
||||
{
|
||||
if (transaction.Kind != PublicationKind.Far
|
||||
&& transaction.Build.EnvCells is
|
||||
{
|
||||
Shells.Length: > 0,
|
||||
} envCells)
|
||||
{
|
||||
_ensureEnvCellMeshes?.Invoke(envCells);
|
||||
}
|
||||
transaction.EnvCellReplayCommitted = true;
|
||||
}))
|
||||
{
|
||||
_ensureEnvCellMeshes?.Invoke(envCells);
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
transaction.EnvCellReplayCommitted = true;
|
||||
}
|
||||
|
||||
if (!transaction.LiveRecoveryCommitted)
|
||||
|
|
@ -434,10 +820,20 @@ public sealed class LandblockPresentationPipeline
|
|||
// Retained live projections can materialize only after the
|
||||
// canonical bucket exists. Landblock load never reconstructs their
|
||||
// identity or replays create-time resources.
|
||||
_onLandblockLoaded?.Invoke(transaction.LandblockId);
|
||||
transaction.LiveRecoveryCommitted = true;
|
||||
if (!TryRun(
|
||||
default,
|
||||
"publication-live-recovery",
|
||||
() =>
|
||||
{
|
||||
_onLandblockLoaded?.Invoke(transaction.LandblockId);
|
||||
transaction.LiveRecoveryCommitted = true;
|
||||
}))
|
||||
{
|
||||
return new LandblockPublicationAdvance(false, progressed);
|
||||
}
|
||||
}
|
||||
|
||||
_publications.Remove(result);
|
||||
return new LandblockPublicationAdvance(true, progressed);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ public sealed class LandblockRenderPublication
|
|||
Vector3 origin,
|
||||
Dictionary<uint, LoadedCell> visibilityCells,
|
||||
Vector3 aabbMin,
|
||||
Vector3 aabbMax)
|
||||
Vector3 aabbMax,
|
||||
BuildingRegistryPublication? buildingPublication,
|
||||
EnvCellLandblockPublication? envCellPublication)
|
||||
{
|
||||
Owner = owner;
|
||||
Build = build;
|
||||
|
|
@ -33,6 +35,8 @@ public sealed class LandblockRenderPublication
|
|||
_visibilityCells = new ReadOnlyDictionary<uint, LoadedCell>(visibilityCells);
|
||||
AabbMin = aabbMin;
|
||||
AabbMax = aabbMax;
|
||||
BuildingPublication = buildingPublication;
|
||||
EnvCellPublication = envCellPublication;
|
||||
}
|
||||
|
||||
internal object Owner { get; }
|
||||
|
|
@ -40,6 +44,8 @@ public sealed class LandblockRenderPublication
|
|||
internal LandblockMeshData MeshData { get; }
|
||||
internal Vector3 AabbMin { get; }
|
||||
internal Vector3 AabbMax { get; }
|
||||
internal BuildingRegistryPublication? BuildingPublication { get; }
|
||||
internal EnvCellLandblockPublication? EnvCellPublication { get; }
|
||||
internal bool TerrainCommitted { get; set; }
|
||||
internal bool VisibilityCommitted { get; set; }
|
||||
internal bool AabbCommitted { get; set; }
|
||||
|
|
@ -92,6 +98,7 @@ public sealed class LandblockRenderPublisher
|
|||
private readonly CellVisibility _cellVisibility;
|
||||
private readonly GpuWorldState _worldState;
|
||||
private readonly Action<EnvCellLandblockBuild>? _commitEnvCells;
|
||||
private readonly IEnvCellLandblockPublisher? _envCellPublisher;
|
||||
private readonly Action<EnvCellLandblockBuild>? _prepareEnvCells;
|
||||
private readonly Action<uint>? _removeEnvCells;
|
||||
private readonly Dictionary<uint, BuildingRegistry> _buildingRegistries = new();
|
||||
|
|
@ -114,7 +121,8 @@ public sealed class LandblockRenderPublisher
|
|||
GpuWorldState worldState,
|
||||
Action<EnvCellLandblockBuild>? commitEnvCells = null,
|
||||
Action<EnvCellLandblockBuild>? prepareEnvCells = null,
|
||||
Action<uint>? removeEnvCells = null)
|
||||
Action<uint>? removeEnvCells = null,
|
||||
IEnvCellLandblockPublisher? envCellPublisher = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publishTerrain);
|
||||
ArgumentNullException.ThrowIfNull(removeTerrain);
|
||||
|
|
@ -126,6 +134,13 @@ public sealed class LandblockRenderPublisher
|
|||
_cellVisibility = cellVisibility;
|
||||
_worldState = worldState;
|
||||
_commitEnvCells = commitEnvCells;
|
||||
_envCellPublisher = envCellPublisher;
|
||||
if (_commitEnvCells is not null && _envCellPublisher is not null)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Supply either the retained EnvCell publisher or the compatibility callback, not both.",
|
||||
nameof(envCellPublisher));
|
||||
}
|
||||
_prepareEnvCells = prepareEnvCells;
|
||||
_removeEnvCells = removeEnvCells;
|
||||
}
|
||||
|
|
@ -197,6 +212,17 @@ public sealed class LandblockRenderPublisher
|
|||
visibilityCells[cell.CellId] = cell;
|
||||
}
|
||||
(Vector3 aabbMin, Vector3 aabbMax) = ComputeAabb(meshData, origin);
|
||||
BuildingRegistryPublication? buildingPublication =
|
||||
build.Landblock.PhysicsDats?.Info is { } info
|
||||
? BuildingLoader.PreparePublication(
|
||||
info,
|
||||
landblockId,
|
||||
visibilityCells)
|
||||
: null;
|
||||
EnvCellLandblockPublication? envCellPublication =
|
||||
build.EnvCells is { } retainedEnvCells
|
||||
? _envCellPublisher?.PreparePublication(retainedEnvCells)
|
||||
: null;
|
||||
return new LandblockRenderPublication(
|
||||
_receiptOwner,
|
||||
build,
|
||||
|
|
@ -204,7 +230,9 @@ public sealed class LandblockRenderPublisher
|
|||
origin,
|
||||
visibilityCells,
|
||||
aabbMin,
|
||||
aabbMax);
|
||||
aabbMax,
|
||||
buildingPublication,
|
||||
envCellPublication);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -214,13 +242,25 @@ public sealed class LandblockRenderPublisher
|
|||
public void BeginPublication(LandblockRenderPublication publication)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (publication.BeginCommitted)
|
||||
return;
|
||||
while (!AdvanceBeginOne(publication))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
long beginStarted = Stopwatch.GetTimestamp();
|
||||
/// <summary>
|
||||
/// Advances one exact render-prefix mutation from the retained receipt.
|
||||
/// Terrain, visibility, and AABB publication remain ordered and retries do
|
||||
/// not replay an already committed prefix.
|
||||
/// </summary>
|
||||
internal bool AdvanceBeginOne(LandblockRenderPublication publication)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (publication.BeginCommitted)
|
||||
return true;
|
||||
|
||||
long started = Stopwatch.GetTimestamp();
|
||||
LandblockBuild build = publication.Build;
|
||||
uint landblockId = publication.LandblockId;
|
||||
|
||||
if (!publication.TerrainCommitted)
|
||||
{
|
||||
long terrainStarted = Stopwatch.GetTimestamp();
|
||||
|
|
@ -228,15 +268,13 @@ public sealed class LandblockRenderPublisher
|
|||
_terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted;
|
||||
publication.TerrainCommitted = true;
|
||||
}
|
||||
|
||||
if (!publication.VisibilityCommitted)
|
||||
else if (!publication.VisibilityCommitted)
|
||||
{
|
||||
if (build.EnvCells is { } envCells)
|
||||
_cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells);
|
||||
publication.VisibilityCommitted = true;
|
||||
}
|
||||
|
||||
if (!publication.AabbCommitted)
|
||||
else if (!publication.AabbCommitted)
|
||||
{
|
||||
_worldState.SetLandblockAabb(
|
||||
landblockId,
|
||||
|
|
@ -244,10 +282,14 @@ public sealed class LandblockRenderPublisher
|
|||
publication.AabbMax);
|
||||
publication.AabbCommitted = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
publication.BeginCommitted = true;
|
||||
_beginCount++;
|
||||
}
|
||||
|
||||
publication.BeginCommitted = true;
|
||||
_beginCount++;
|
||||
_beginPublishTicks += Stopwatch.GetTimestamp() - beginStarted;
|
||||
_beginPublishTicks += Stopwatch.GetTimestamp() - started;
|
||||
return publication.BeginCommitted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -256,40 +298,77 @@ public sealed class LandblockRenderPublisher
|
|||
/// receives the complete immutable transaction.
|
||||
/// </summary>
|
||||
public void CompletePublication(LandblockRenderPublication publication)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (!publication.BeginCommitted)
|
||||
throw new InvalidOperationException(
|
||||
"Render publication cannot complete before its prefix commits.");
|
||||
while (!AdvanceCompleteOne(publication))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances one exact render-suffix mutation. The building registry is
|
||||
/// visible before the EnvCell renderer snapshot, matching the existing
|
||||
/// retail-rooted ordering.
|
||||
/// </summary>
|
||||
internal bool AdvanceCompleteOne(LandblockRenderPublication publication)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (!publication.BeginCommitted)
|
||||
throw new InvalidOperationException(
|
||||
"Render publication cannot complete before its prefix commits.");
|
||||
if (publication.CompletionCommitted)
|
||||
return;
|
||||
return true;
|
||||
|
||||
long started = Stopwatch.GetTimestamp();
|
||||
LandblockBuild build = publication.Build;
|
||||
uint landblockId = publication.LandblockId;
|
||||
if (!publication.BuildingRegistryCommitted)
|
||||
if (publication.BuildingPublication is { PreparationCommitted: false }
|
||||
buildingPublication)
|
||||
{
|
||||
if (build.Landblock.PhysicsDats?.Info is { } info)
|
||||
BuildingLoader.AdvancePreparationOne(buildingPublication);
|
||||
}
|
||||
else if (!publication.BuildingRegistryCommitted)
|
||||
{
|
||||
if (publication.BuildingPublication is { } completedBuildings)
|
||||
{
|
||||
BuildingLoader.CommitPublication(completedBuildings);
|
||||
uint registryKey = landblockId & 0xFFFF0000u;
|
||||
_buildingRegistries[registryKey] = BuildingLoader.Build(
|
||||
info,
|
||||
landblockId,
|
||||
publication.VisibilityCells);
|
||||
_buildingRegistries[registryKey] =
|
||||
completedBuildings.Registry;
|
||||
}
|
||||
publication.BuildingRegistryCommitted = true;
|
||||
}
|
||||
|
||||
if (!publication.EnvCellsCommitted)
|
||||
else if (publication.EnvCellPublication is { } envCellPublication
|
||||
&& !envCellPublication.PreparationCommitted)
|
||||
{
|
||||
if (build.EnvCells is { } envCells)
|
||||
_envCellPublisher?.AdvancePreparationOne(envCellPublication);
|
||||
}
|
||||
else if (!publication.EnvCellsCommitted)
|
||||
{
|
||||
if (publication.EnvCellPublication is { } retained)
|
||||
{
|
||||
(_envCellPublisher
|
||||
?? throw new InvalidOperationException(
|
||||
"A retained EnvCell receipt has no owning publisher."))
|
||||
.CommitPublication(retained);
|
||||
}
|
||||
else if (build.EnvCells is { } envCells)
|
||||
{
|
||||
_commitEnvCells?.Invoke(envCells);
|
||||
}
|
||||
publication.EnvCellsCommitted = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
publication.CompletionCommitted = true;
|
||||
_completeCount++;
|
||||
}
|
||||
|
||||
publication.CompletionCommitted = true;
|
||||
_completeCount++;
|
||||
_completePublishTicks += Stopwatch.GetTimestamp() - started;
|
||||
return publication.CompletionCommitted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -13,26 +13,46 @@ namespace AcDream.App.Streaming;
|
|||
/// </summary>
|
||||
public sealed class LandblockStaticPresentationPublication
|
||||
{
|
||||
private readonly IReadOnlyDictionary<uint, WorldEntity> _entities;
|
||||
private readonly IReadOnlyDictionary<uint, WorldEntitySnapshot> _snapshots;
|
||||
private readonly Dictionary<uint, WorldEntity> _entities;
|
||||
private readonly Dictionary<uint, WorldEntitySnapshot> _snapshots;
|
||||
private readonly uint[] _orderedPreviouslyActiveIds;
|
||||
private readonly Dictionary<uint, WorldEntitySnapshot> _replacementActive;
|
||||
|
||||
internal LandblockStaticPresentationPublication(
|
||||
object owner,
|
||||
LandblockPhysicsPublication physicsPublication,
|
||||
Dictionary<uint, WorldEntity> entities,
|
||||
Dictionary<uint, WorldEntitySnapshot> snapshots,
|
||||
HashSet<uint> previouslyActiveIds)
|
||||
IReadOnlyDictionary<uint, WorldEntitySnapshot> priorActive,
|
||||
HashSet<uint> previouslyActiveIds,
|
||||
uint[] orderedPreviouslyActiveIds)
|
||||
{
|
||||
Owner = owner;
|
||||
PhysicsPublication = physicsPublication;
|
||||
_entities = entities;
|
||||
_snapshots = snapshots;
|
||||
PriorActive = priorActive;
|
||||
PreviouslyActiveIds = previouslyActiveIds;
|
||||
_orderedPreviouslyActiveIds = orderedPreviouslyActiveIds;
|
||||
_replacementActive = new Dictionary<uint, WorldEntitySnapshot>(
|
||||
physicsPublication.Build.Landblock.Entities.Count);
|
||||
}
|
||||
|
||||
internal object Owner { get; }
|
||||
internal LandblockPhysicsPublication PhysicsPublication { get; }
|
||||
internal IReadOnlyDictionary<uint, WorldEntitySnapshot> PriorActive { get; }
|
||||
internal Dictionary<uint, WorldEntity> MutableEntities => _entities;
|
||||
internal Dictionary<uint, WorldEntitySnapshot> MutableSnapshots => _snapshots;
|
||||
internal HashSet<uint> PreviouslyActiveIds { get; }
|
||||
internal IReadOnlyList<uint> OrderedPreviouslyActiveIds =>
|
||||
_orderedPreviouslyActiveIds;
|
||||
internal IReadOnlyList<KeyValuePair<uint, WorldEntitySnapshot>>
|
||||
OrderedSnapshots { get; set; } =
|
||||
Array.Empty<KeyValuePair<uint, WorldEntitySnapshot>>();
|
||||
internal Dictionary<uint, WorldEntitySnapshot> ReplacementActive =>
|
||||
_replacementActive;
|
||||
internal int PreparationCursor { get; set; }
|
||||
internal bool PreparationCommitted { get; set; }
|
||||
internal int PriorCleanupCursor { get; set; }
|
||||
internal int PluginCursor { get; set; }
|
||||
internal bool BeginCommitted { get; set; }
|
||||
|
|
@ -116,6 +136,22 @@ public sealed class LandblockStaticPresentationPublisher
|
|||
|
||||
public LandblockStaticPresentationPublication PreparePublication(
|
||||
LandblockPhysicsPublication physicsPublication)
|
||||
{
|
||||
LandblockStaticPresentationPublication publication =
|
||||
CreatePublication(physicsPublication);
|
||||
while (!AdvancePreparationOne(publication))
|
||||
{
|
||||
}
|
||||
return publication;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures prior ownership without validating the replacement entity
|
||||
/// list. The concrete pipeline advances replacement validation one entity
|
||||
/// at a time before any render/physics owner mutates external state.
|
||||
/// </summary>
|
||||
internal LandblockStaticPresentationPublication CreatePublication(
|
||||
LandblockPhysicsPublication physicsPublication)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(physicsPublication);
|
||||
|
||||
|
|
@ -125,11 +161,43 @@ public sealed class LandblockStaticPresentationPublisher
|
|||
out Dictionary<uint, WorldEntitySnapshot>? active);
|
||||
var entities = new Dictionary<uint, WorldEntity>();
|
||||
var snapshots = new Dictionary<uint, WorldEntitySnapshot>();
|
||||
foreach (WorldEntity entity in physicsPublication.Build.Landblock.Entities)
|
||||
HashSet<uint> previous = active is not null
|
||||
? active.Keys.ToHashSet()
|
||||
: new HashSet<uint>();
|
||||
uint[] orderedPrevious = previous.Order().ToArray();
|
||||
return new LandblockStaticPresentationPublication(
|
||||
_receiptOwner,
|
||||
physicsPublication,
|
||||
entities,
|
||||
snapshots,
|
||||
active ?? new Dictionary<uint, WorldEntitySnapshot>(),
|
||||
previous,
|
||||
orderedPrevious);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates and snapshots one replacement entity. The final stable plugin
|
||||
/// order is materialized only after the cursor reaches the immutable tail.
|
||||
/// </summary>
|
||||
internal bool AdvancePreparationOne(
|
||||
LandblockStaticPresentationPublication publication)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (publication.PreparationCommitted)
|
||||
return true;
|
||||
|
||||
IReadOnlyList<WorldEntity> source =
|
||||
publication.PhysicsPublication.Build.Landblock.Entities;
|
||||
uint canonical = Canonicalize(publication.LandblockId);
|
||||
if (publication.PreparationCursor < source.Count)
|
||||
{
|
||||
WorldEntity entity = source[publication.PreparationCursor];
|
||||
if (entity.ServerGuid != 0)
|
||||
continue;
|
||||
if (!entities.TryAdd(entity.Id, entity))
|
||||
{
|
||||
publication.PreparationCursor++;
|
||||
return false;
|
||||
}
|
||||
if (publication.Entities.ContainsKey(entity.Id))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Landblock 0x{canonical:X8} contains duplicate DAT-static ID " +
|
||||
|
|
@ -142,8 +210,9 @@ public sealed class LandblockStaticPresentationPublisher
|
|||
$"DAT-static ID 0x{entity.Id:X8} is already owned by " +
|
||||
$"landblock 0x{owner:X8}.");
|
||||
}
|
||||
if (active is not null
|
||||
&& active.TryGetValue(entity.Id, out WorldEntitySnapshot retained)
|
||||
if (publication.PriorActive.TryGetValue(
|
||||
entity.Id,
|
||||
out WorldEntitySnapshot retained)
|
||||
&& retained.SourceId != entity.SourceGfxObjOrSetupId)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
|
@ -152,18 +221,17 @@ public sealed class LandblockStaticPresentationPublisher
|
|||
$"0x{entity.SourceGfxObjOrSetupId:X8}.");
|
||||
}
|
||||
|
||||
snapshots.Add(entity.Id, Snapshot(entity));
|
||||
publication.MutableEntities.Add(entity.Id, entity);
|
||||
publication.MutableSnapshots.Add(entity.Id, Snapshot(entity));
|
||||
publication.PreparationCursor++;
|
||||
return false;
|
||||
}
|
||||
|
||||
HashSet<uint> previous = active is not null
|
||||
? active.Keys.ToHashSet()
|
||||
: new HashSet<uint>();
|
||||
return new LandblockStaticPresentationPublication(
|
||||
_receiptOwner,
|
||||
physicsPublication,
|
||||
entities,
|
||||
snapshots,
|
||||
previous);
|
||||
publication.OrderedSnapshots = publication.Snapshots
|
||||
.OrderBy(static pair => pair.Key)
|
||||
.ToArray();
|
||||
publication.PreparationCommitted = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -174,13 +242,31 @@ public sealed class LandblockStaticPresentationPublisher
|
|||
public void BeginPublication(LandblockStaticPresentationPublication publication)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (publication.BeginCommitted)
|
||||
return;
|
||||
|
||||
uint[] priorIds = publication.PreviouslyActiveIds.Order().ToArray();
|
||||
while (publication.PriorCleanupCursor < priorIds.Length)
|
||||
if (!publication.PreparationCommitted)
|
||||
throw new InvalidOperationException(
|
||||
"Static presentation cannot begin before preparation commits.");
|
||||
while (!AdvanceBeginOne(publication))
|
||||
{
|
||||
uint id = priorIds[publication.PriorCleanupCursor];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances exactly one retained prior-owner cleanup. The cursor advances
|
||||
/// only after the complete owner operation succeeds, so a failure retries
|
||||
/// the same ID without rebuilding or reordering the work list.
|
||||
/// </summary>
|
||||
internal bool AdvanceBeginOne(
|
||||
LandblockStaticPresentationPublication publication)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (publication.BeginCommitted)
|
||||
return true;
|
||||
|
||||
if (publication.PriorCleanupCursor
|
||||
< publication.OrderedPreviouslyActiveIds.Count)
|
||||
{
|
||||
uint id = publication.OrderedPreviouslyActiveIds[
|
||||
publication.PriorCleanupCursor];
|
||||
bool retained = publication.Entities.ContainsKey(id);
|
||||
_lighting.UnregisterOwner(id, forgetState: !retained);
|
||||
if (!retained)
|
||||
|
|
@ -192,10 +278,12 @@ public sealed class LandblockStaticPresentationPublisher
|
|||
_pluginRemovalCount++;
|
||||
}
|
||||
publication.PriorCleanupCursor++;
|
||||
return false;
|
||||
}
|
||||
|
||||
publication.BeginCommitted = true;
|
||||
_beginCount++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -258,15 +346,32 @@ public sealed class LandblockStaticPresentationPublisher
|
|||
throw new InvalidOperationException(
|
||||
"Static presentation requires completed physics publication.");
|
||||
}
|
||||
if (publication.CompletionCommitted)
|
||||
return;
|
||||
|
||||
KeyValuePair<uint, WorldEntitySnapshot>[] snapshots = publication.Snapshots
|
||||
.OrderBy(pair => pair.Key)
|
||||
.ToArray();
|
||||
while (publication.PluginCursor < snapshots.Length)
|
||||
while (!AdvanceCompleteOne(publication))
|
||||
{
|
||||
(uint id, WorldEntitySnapshot snapshot) = snapshots[publication.PluginCursor];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances exactly one plugin projection, then atomically publishes the
|
||||
/// retained replacement dictionary after the cursor reaches its tail.
|
||||
/// </summary>
|
||||
internal bool AdvanceCompleteOne(
|
||||
LandblockStaticPresentationPublication publication)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (!publication.BeginCommitted
|
||||
|| !publication.PhysicsPublication.CompletionCommitted)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Static presentation requires completed physics publication.");
|
||||
}
|
||||
if (publication.CompletionCommitted)
|
||||
return true;
|
||||
|
||||
if (publication.PluginCursor < publication.OrderedSnapshots.Count)
|
||||
{
|
||||
(uint id, WorldEntitySnapshot snapshot) =
|
||||
publication.OrderedSnapshots[publication.PluginCursor];
|
||||
_worldState.Add(snapshot);
|
||||
if (publication.PreviouslyActiveIds.Contains(id))
|
||||
{
|
||||
|
|
@ -279,21 +384,19 @@ public sealed class LandblockStaticPresentationPublisher
|
|||
_pluginSpawnCount++;
|
||||
}
|
||||
_landblockByEntityId[id] = Canonicalize(publication.LandblockId);
|
||||
publication.ReplacementActive[id] = snapshot;
|
||||
publication.PluginCursor++;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint canonical = Canonicalize(publication.LandblockId);
|
||||
if (publication.Snapshots.Count == 0)
|
||||
{
|
||||
if (publication.ReplacementActive.Count == 0)
|
||||
_activeByLandblock.Remove(canonical);
|
||||
}
|
||||
else
|
||||
{
|
||||
_activeByLandblock[canonical] = publication.Snapshots
|
||||
.ToDictionary(pair => pair.Key, pair => pair.Value);
|
||||
}
|
||||
_activeByLandblock[canonical] = publication.ReplacementActive;
|
||||
publication.CompletionCommitted = true;
|
||||
_completeCount++;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void RemoveLighting(WorldEntity entity)
|
||||
|
|
|
|||
|
|
@ -29,18 +29,7 @@ internal readonly record struct StreamingQueuedCompletion(
|
|||
StreamingCompletionPriority Priority,
|
||||
ulong Generation,
|
||||
long Sequence,
|
||||
long EnqueuedTimestamp)
|
||||
{
|
||||
public StreamingWorkCost AdmissionCost => new(
|
||||
CompletionAdmissions: Estimate.Work.CompletionAdmissions,
|
||||
AdoptedCpuBytes: Estimate.Work.AdoptedCpuBytes);
|
||||
|
||||
public StreamingWorkCost ExecutionCost => new(
|
||||
EntityOperations: Estimate.Work.EntityOperations,
|
||||
GpuUploadBytes: Estimate.Work.GpuUploadBytes,
|
||||
GlRetireOperations:
|
||||
Result is LandblockStreamResult.Unloaded ? 1 : 0);
|
||||
}
|
||||
long EnqueuedTimestamp);
|
||||
|
||||
internal readonly record struct StreamingCompletionQueueSnapshot(
|
||||
int Count,
|
||||
|
|
|
|||
|
|
@ -370,7 +370,11 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
// the desired-tier snapshot below is computed. Otherwise a landblock
|
||||
// that becomes spatially resident during convergence is absent from
|
||||
// the reconfiguration mutation ledger.
|
||||
ConvergePendingPublications();
|
||||
if (!ConvergePendingPublications())
|
||||
{
|
||||
_deferredRadiiRequest = (nearRadius, farRadius);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_collapsed || _region is null)
|
||||
{
|
||||
|
|
@ -490,6 +494,12 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
_presentation.GetPendingPublicationResults();
|
||||
if (pending.Count == 0)
|
||||
return true;
|
||||
if (_activeWorkMeter is null)
|
||||
{
|
||||
// Settings/native callbacks may request policy changes between
|
||||
// frames, but publication remains owned by the frame-scoped meter.
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -497,15 +507,14 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
for (int i = 0; i < pending.Count; i++)
|
||||
{
|
||||
LandblockStreamResult result = pending[i];
|
||||
if (!TryObserveResultOperation(
|
||||
result,
|
||||
"publication-retry",
|
||||
() => _presentation.ResumePublication(result),
|
||||
ensureProgress: !progressed))
|
||||
{
|
||||
LandblockPublicationAdvance advance =
|
||||
_presentation.ResumePublication(
|
||||
result,
|
||||
_activeWorkMeter,
|
||||
ensureProgress: !progressed);
|
||||
progressed |= advance.Progressed;
|
||||
if (!advance.Completed)
|
||||
return false;
|
||||
}
|
||||
progressed = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1487,23 +1496,16 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
StreamingQueuedCompletion work = completion
|
||||
?? throw new InvalidOperationException(
|
||||
"The completion queue returned a null head.");
|
||||
StreamingWorkCost executionCost =
|
||||
IsStaleGeneration(work.Result)
|
||||
? default
|
||||
: work.ExecutionCost;
|
||||
StreamingWorkAdmission admission = meter.TryReserve(
|
||||
executionCost,
|
||||
$"execute-{work.Priority}",
|
||||
ensureProgress: !executed);
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
ApplyResult(work.Result);
|
||||
LandblockPublicationAdvance advance = ApplyResult(
|
||||
work,
|
||||
meter,
|
||||
ensureProgress: !executed);
|
||||
executed |= advance.Progressed;
|
||||
if (!advance.Completed)
|
||||
break;
|
||||
_completionQueue.RemoveHead(work);
|
||||
meter.Complete();
|
||||
executed = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -1512,7 +1514,6 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
// receipt cannot safely replay, so consume only that result.
|
||||
if (!_presentation.HasPendingPublication(work.Result))
|
||||
_completionQueue.RemoveHead(work);
|
||||
meter.Fail();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
|
@ -1625,39 +1626,24 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
};
|
||||
}
|
||||
|
||||
private bool TryObserveResultOperation(
|
||||
LandblockStreamResult result,
|
||||
private static LandblockPublicationAdvance RunSimpleResultOperation(
|
||||
StreamingWorkMeter meter,
|
||||
string stage,
|
||||
Action operation,
|
||||
StreamingWorkCost? knownCost = null,
|
||||
bool ensureProgress = false)
|
||||
bool ensureProgress,
|
||||
Action operation)
|
||||
{
|
||||
StreamingWorkMeter? meter = _activeWorkMeter;
|
||||
if (meter is null)
|
||||
{
|
||||
operation();
|
||||
return true;
|
||||
}
|
||||
|
||||
StreamingWorkCost aggregate = knownCost
|
||||
?? LandblockStreamResultCost.Estimate(result).Work;
|
||||
StreamingWorkCost cost = new(
|
||||
EntityOperations: aggregate.EntityOperations,
|
||||
GpuUploadBytes: aggregate.GpuUploadBytes,
|
||||
GlRetireOperations:
|
||||
result is LandblockStreamResult.Unloaded ? 1 : 0);
|
||||
StreamingWorkAdmission admission = meter.TryReserve(
|
||||
cost,
|
||||
default,
|
||||
stage,
|
||||
ensureProgress);
|
||||
if (admission == StreamingWorkAdmission.Yielded)
|
||||
return false;
|
||||
return new LandblockPublicationAdvance(false, false);
|
||||
|
||||
try
|
||||
{
|
||||
operation();
|
||||
meter.Complete();
|
||||
return true;
|
||||
return new LandblockPublicationAdvance(true, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -1671,13 +1657,22 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
/// effects: terrain upload, GPU state, and the re-hydration callback.
|
||||
/// All priority queues route through this one publication path.
|
||||
/// </summary>
|
||||
private void ApplyResult(LandblockStreamResult result)
|
||||
private LandblockPublicationAdvance ApplyResult(
|
||||
StreamingQueuedCompletion work,
|
||||
StreamingWorkMeter meter,
|
||||
bool ensureProgress)
|
||||
{
|
||||
LandblockStreamResult result = work.Result;
|
||||
if (_presentation.HasPendingPublication(result))
|
||||
{
|
||||
_presentation.ResumePublication(result);
|
||||
ReconcileCompletedPendingPublication(result.LandblockId);
|
||||
return;
|
||||
LandblockPublicationAdvance resumed =
|
||||
_presentation.ResumePublication(
|
||||
result,
|
||||
meter,
|
||||
ensureProgress);
|
||||
if (resumed.Completed)
|
||||
ReconcileCompletedPendingPublication(result.LandblockId);
|
||||
return resumed;
|
||||
}
|
||||
|
||||
if (IsStaleGeneration(result))
|
||||
|
|
@ -1685,7 +1680,11 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
// A worker can finish one old load/unload after a hard recenter.
|
||||
// Landblock id membership cannot distinguish overlapping windows;
|
||||
// generation is the logical streaming incarnation boundary.
|
||||
return;
|
||||
return RunSimpleResultOperation(
|
||||
meter,
|
||||
"execute-stale-generation",
|
||||
ensureProgress,
|
||||
static () => { });
|
||||
}
|
||||
|
||||
if (result is LandblockStreamResult.Unloaded
|
||||
|
|
@ -1695,7 +1694,11 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
// rapid away->back can therefore re-own an id while its same-
|
||||
// generation unload is already in the worker outbox. Current
|
||||
// region ownership wins; the old unload must not tear it down.
|
||||
return;
|
||||
return RunSimpleResultOperation(
|
||||
meter,
|
||||
"execute-reowned-unload",
|
||||
ensureProgress,
|
||||
static () => { });
|
||||
}
|
||||
|
||||
if (result is LandblockStreamResult.Loaded
|
||||
|
|
@ -1707,7 +1710,11 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
// A hard recenter/collapse can leave one worker job already in
|
||||
// flight. Its completion belongs to the old region and must not
|
||||
// resurrect a landblock the new StreamingRegion never owns.
|
||||
return;
|
||||
return RunSimpleResultOperation(
|
||||
meter,
|
||||
"execute-undesired-load",
|
||||
ensureProgress,
|
||||
static () => { });
|
||||
}
|
||||
|
||||
bool isNearCompletion = result is LandblockStreamResult.Promoted
|
||||
|
|
@ -1722,7 +1729,11 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
// high-priority jobs. Publication is idempotent at this owner:
|
||||
// never append statics, replay defaults, or reapply the complete
|
||||
// Near transaction to an already-Near landblock.
|
||||
return;
|
||||
return RunSimpleResultOperation(
|
||||
meter,
|
||||
"execute-duplicate-near",
|
||||
ensureProgress,
|
||||
static () => { });
|
||||
}
|
||||
if (desiredTier == LandblockStreamTier.Far && isNearCompletion)
|
||||
{
|
||||
|
|
@ -1739,14 +1750,26 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
switch (result)
|
||||
{
|
||||
case LandblockStreamResult.Loaded loaded:
|
||||
_presentation.PublishAsFar(loaded, loaded.Build, loaded.MeshData);
|
||||
break;
|
||||
return _presentation.PublishAsFar(
|
||||
loaded,
|
||||
loaded.Build,
|
||||
loaded.MeshData,
|
||||
meter,
|
||||
ensureProgress);
|
||||
case LandblockStreamResult.Promoted promoted:
|
||||
_presentation.PublishAsFar(promoted, promoted.Build, promoted.MeshData);
|
||||
break;
|
||||
return _presentation.PublishAsFar(
|
||||
promoted,
|
||||
promoted.Build,
|
||||
promoted.MeshData,
|
||||
meter,
|
||||
ensureProgress);
|
||||
}
|
||||
}
|
||||
return;
|
||||
return RunSimpleResultOperation(
|
||||
meter,
|
||||
"execute-already-far",
|
||||
ensureProgress,
|
||||
static () => { });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1759,31 +1782,54 @@ public sealed class StreamingController : IStreamingFrameBackend
|
|||
// This Far completion was queued before a newer Near load
|
||||
// or promotion. Applying it would erase the entity/cell
|
||||
// layer that now owns the landblock.
|
||||
break;
|
||||
return RunSimpleResultOperation(
|
||||
meter,
|
||||
"execute-stale-far-tier",
|
||||
ensureProgress,
|
||||
static () => { });
|
||||
}
|
||||
_presentation.PublishLoaded(loaded);
|
||||
break;
|
||||
return _presentation.PublishLoaded(
|
||||
loaded,
|
||||
work.Estimate,
|
||||
meter,
|
||||
ensureProgress);
|
||||
case LandblockStreamResult.Promoted promoted:
|
||||
// PromoteToNear carries a complete build and mesh because the
|
||||
// streamer deliberately lets it supersede a queued LoadFar. If
|
||||
// that Far job never started, publish this as the real Near
|
||||
// landblock; if the base is already resident, merge only the
|
||||
// Near layer so existing live projections retain identity.
|
||||
_presentation.PublishPromoted(
|
||||
return _presentation.PublishPromoted(
|
||||
promoted,
|
||||
mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId));
|
||||
break;
|
||||
mergeIntoExistingLandblock:
|
||||
_state.IsLoaded(promoted.LandblockId),
|
||||
work.Estimate,
|
||||
meter,
|
||||
ensureProgress);
|
||||
case LandblockStreamResult.Unloaded unloaded:
|
||||
_presentation.EnqueueFullRetirement(unloaded.LandblockId);
|
||||
break;
|
||||
return RunSimpleResultOperation(
|
||||
meter,
|
||||
"execute-unload",
|
||||
ensureProgress,
|
||||
() => _presentation.EnqueueFullRetirement(
|
||||
unloaded.LandblockId));
|
||||
case LandblockStreamResult.Failed failed:
|
||||
Console.WriteLine(
|
||||
$"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}");
|
||||
break;
|
||||
return RunSimpleResultOperation(
|
||||
meter,
|
||||
"execute-load-failure",
|
||||
ensureProgress,
|
||||
() => Console.WriteLine(
|
||||
$"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}"));
|
||||
case LandblockStreamResult.WorkerCrashed crashed:
|
||||
Console.WriteLine(
|
||||
$"streaming: worker CRASHED: {crashed.Error}");
|
||||
break;
|
||||
return RunSimpleResultOperation(
|
||||
meter,
|
||||
"execute-worker-crash",
|
||||
ensureProgress,
|
||||
() => Console.WriteLine(
|
||||
$"streaming: worker CRASHED: {crashed.Error}"));
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported streaming result {result.GetType().Name}.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -455,6 +455,18 @@ public sealed class ShadowObjectRegistry
|
|||
/// cells hydrated after a server spawn landed.
|
||||
/// </summary>
|
||||
public void RefloodLandblock(uint landblockId)
|
||||
{
|
||||
uint[] owners = CaptureRefloodOwnersForLandblock(landblockId);
|
||||
for (int i = 0; i < owners.Length; i++)
|
||||
RefloodOwnerForLandblock(owners[i], landblockId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the stable ordered owner set touched by one landblock reflood.
|
||||
/// App-layer streaming can retain this receipt and advance one owner per
|
||||
/// frame without changing the collision registry's ownership rules.
|
||||
/// </summary>
|
||||
public uint[] CaptureRefloodOwnersForLandblock(uint landblockId)
|
||||
{
|
||||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||||
var toReflood = new HashSet<uint>();
|
||||
|
|
@ -487,39 +499,81 @@ public sealed class ShadowObjectRegistry
|
|||
}
|
||||
}
|
||||
|
||||
foreach (uint entityId in toReflood)
|
||||
uint[] ordered = toReflood.ToArray();
|
||||
Array.Sort(ordered);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-runs one owner from a retained landblock-reflood receipt. A removed,
|
||||
/// suspended, or otherwise superseded owner is an idempotent no-op.
|
||||
/// </summary>
|
||||
public void RefloodOwnerForLandblock(uint entityId, uint landblockId)
|
||||
{
|
||||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||||
if (_suspendedEntities.Contains(entityId)
|
||||
|| !_entityReg.TryGetValue(
|
||||
entityId,
|
||||
out RegistrationRecord? reg))
|
||||
{
|
||||
var reg = _entityReg[entityId];
|
||||
_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawnBeforeReflood);
|
||||
if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes))
|
||||
{
|
||||
RegisterMultiPart(entityId, reg.EntityWorldPos, reg.EntityWorldRot, shapes,
|
||||
reg.State, reg.Flags, 0f, 0f, lbPrefix,
|
||||
reg.SeedCellId, reg.IsStatic);
|
||||
}
|
||||
else
|
||||
{
|
||||
Register(entityId, reg.GfxObjId, reg.EntityWorldPos, reg.EntityWorldRot,
|
||||
reg.Radius, 0f, 0f, lbPrefix,
|
||||
reg.CollisionType, reg.CylHeight, reg.Scale,
|
||||
reg.State, reg.Flags, reg.SeedCellId, reg.IsStatic);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Register is also the authoritative movement/replacement API and
|
||||
// therefore clears obsolete markers. Only this streaming reflood
|
||||
// operation preserves the still-missing prefixes across replacement.
|
||||
if (withdrawnBeforeReflood is not null)
|
||||
_withdrawnPrefixesByOwner[entityId] = withdrawnBeforeReflood;
|
||||
_withdrawnPrefixesByOwner.TryGetValue(
|
||||
entityId,
|
||||
out var withdrawnBeforeReflood);
|
||||
if (reg.IsMultiPart
|
||||
&& _entityShapes.TryGetValue(entityId, out var shapes))
|
||||
{
|
||||
RegisterMultiPart(
|
||||
entityId,
|
||||
reg.EntityWorldPos,
|
||||
reg.EntityWorldRot,
|
||||
shapes,
|
||||
reg.State,
|
||||
reg.Flags,
|
||||
0f,
|
||||
0f,
|
||||
lbPrefix,
|
||||
reg.SeedCellId,
|
||||
reg.IsStatic);
|
||||
}
|
||||
else
|
||||
{
|
||||
Register(
|
||||
entityId,
|
||||
reg.GfxObjId,
|
||||
reg.EntityWorldPos,
|
||||
reg.EntityWorldRot,
|
||||
reg.Radius,
|
||||
0f,
|
||||
0f,
|
||||
lbPrefix,
|
||||
reg.CollisionType,
|
||||
reg.CylHeight,
|
||||
reg.Scale,
|
||||
reg.State,
|
||||
reg.Flags,
|
||||
reg.SeedCellId,
|
||||
reg.IsStatic);
|
||||
}
|
||||
|
||||
if (_entityToCells.TryGetValue(entityId, out var refreshedCells)
|
||||
&& refreshedCells.Exists(cell =>
|
||||
(cell & 0xFFFF0000u) == lbPrefix)
|
||||
&& _withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn))
|
||||
{
|
||||
withdrawn.Remove(lbPrefix);
|
||||
if (withdrawn.Count == 0)
|
||||
_withdrawnPrefixesByOwner.Remove(entityId);
|
||||
}
|
||||
// Register is also the authoritative movement/replacement API and
|
||||
// therefore clears obsolete markers. Only this streaming reflood
|
||||
// operation preserves the still-missing prefixes across replacement.
|
||||
if (withdrawnBeforeReflood is not null)
|
||||
_withdrawnPrefixesByOwner[entityId] = withdrawnBeforeReflood;
|
||||
|
||||
if (_entityToCells.TryGetValue(entityId, out var refreshedCells)
|
||||
&& refreshedCells.Exists(cell =>
|
||||
(cell & 0xFFFF0000u) == lbPrefix)
|
||||
&& _withdrawnPrefixesByOwner.TryGetValue(
|
||||
entityId,
|
||||
out var withdrawn))
|
||||
{
|
||||
withdrawn.Remove(lbPrefix);
|
||||
if (withdrawn.Count == 0)
|
||||
_withdrawnPrefixesByOwner.Remove(entityId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -596,6 +650,17 @@ public sealed class ShadowObjectRegistry
|
|||
/// are deliberately retained for spatial reflood after streaming changes.
|
||||
/// </summary>
|
||||
public void DeregisterStaticOwnersForLandblock(uint landblockId)
|
||||
{
|
||||
uint[] owners = CaptureStaticOwnersForLandblock(landblockId);
|
||||
for (int i = 0; i < owners.Length; i++)
|
||||
DeregisterStaticOwnerForLandblock(owners[i], landblockId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures a stable ordered receipt for static collision owners rooted in
|
||||
/// one landblock.
|
||||
/// </summary>
|
||||
public uint[] CaptureStaticOwnersForLandblock(uint landblockId)
|
||||
{
|
||||
uint prefix = landblockId & 0xFFFF0000u;
|
||||
var owners = new List<uint>();
|
||||
|
|
@ -607,9 +672,27 @@ public sealed class ShadowObjectRegistry
|
|||
owners.Add(entityId);
|
||||
}
|
||||
}
|
||||
owners.Sort();
|
||||
return owners.ToArray();
|
||||
}
|
||||
|
||||
foreach (uint entityId in owners)
|
||||
/// <summary>
|
||||
/// Removes one static owner from a retained landblock receipt. If the
|
||||
/// owner was already removed or rebound elsewhere, the operation no-ops.
|
||||
/// </summary>
|
||||
public void DeregisterStaticOwnerForLandblock(
|
||||
uint entityId,
|
||||
uint landblockId)
|
||||
{
|
||||
uint prefix = landblockId & 0xFFFF0000u;
|
||||
if (_entityReg.TryGetValue(
|
||||
entityId,
|
||||
out RegistrationRecord? registration)
|
||||
&& registration.IsStatic
|
||||
&& (registration.SeedCellId & 0xFFFF0000u) == prefix)
|
||||
{
|
||||
Deregister(entityId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
|
@ -86,6 +87,43 @@ public class BuildingLoaderTests
|
|||
Assert.Equal(new SortedSet<uint> { 1, 2 }, ids); // sequential 1, 2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedPublication_AdvancesOneBuildingAndCommitsCellStampsAtomically()
|
||||
{
|
||||
LandBlockInfo info = MakeInfo(
|
||||
(0x02000001u, new[] { 0x0150u }),
|
||||
(0x02000002u, new[] { 0x0160u }));
|
||||
LoadedCell cell150 = MinimalCell(0xA9B40150u);
|
||||
LoadedCell cell160 = MinimalCell(0xA9B40160u);
|
||||
var cells = new Dictionary<uint, LoadedCell>
|
||||
{
|
||||
[cell150.CellId] = cell150,
|
||||
[cell160.CellId] = cell160,
|
||||
};
|
||||
|
||||
BuildingRegistryPublication publication =
|
||||
BuildingLoader.PreparePublication(info, 0xA9B40000u, cells);
|
||||
|
||||
Assert.False(publication.PreparationCommitted);
|
||||
Assert.False(BuildingLoader.AdvancePreparationOne(publication));
|
||||
Assert.Equal(1, publication.BuildingCursor);
|
||||
Assert.Equal(1, publication.Registry.Count);
|
||||
Assert.Null(cell150.BuildingId);
|
||||
Assert.Null(cell160.BuildingId);
|
||||
|
||||
Assert.True(BuildingLoader.AdvancePreparationOne(publication));
|
||||
Assert.Equal(2, publication.BuildingCursor);
|
||||
Assert.Equal(2, publication.Registry.Count);
|
||||
Assert.Null(cell150.BuildingId);
|
||||
Assert.Null(cell160.BuildingId);
|
||||
|
||||
BuildingLoader.CommitPublication(publication);
|
||||
|
||||
Assert.Equal(1u, cell150.BuildingId);
|
||||
Assert.Equal(2u, cell160.BuildingId);
|
||||
Assert.True(publication.PublicationCommitted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_StampsLoadedCellBuildingId()
|
||||
{
|
||||
|
|
@ -166,4 +204,16 @@ public class BuildingLoaderTests
|
|||
Assert.Equal(new Vector3(9, 12, 33), b.PortalBounds.Min);
|
||||
Assert.Equal(new Vector3(17, 25, 39), b.PortalBounds.Max);
|
||||
}
|
||||
|
||||
private static LoadedCell MinimalCell(uint cellId) => new()
|
||||
{
|
||||
CellId = cellId,
|
||||
Portals = new List<AcDream.App.Rendering.CellPortalInfo>(),
|
||||
PortalPolygons = new List<Vector3[]>(),
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
LocalBoundsMin = new Vector3(-5, -5, -5),
|
||||
LocalBoundsMax = new Vector3(5, 5, 5),
|
||||
ClipPlanes = new List<AcDream.App.Rendering.PortalClipPlane>(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,73 @@ public sealed class LandblockConcretePresentationPipelineTests
|
|||
Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeteredLoaded_RetainsExactHeadAndPublishesSpatiallyOnlyAfterOwnerSuffix()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
ConcreteFixture fixture = Fixture(
|
||||
calls,
|
||||
commitEnvCells: _ => calls.Add("envcell"));
|
||||
fixture.Events.EntitySpawned += _ => calls.Add("plugin");
|
||||
var pipeline = new LandblockPresentationPipeline(
|
||||
fixture.Render,
|
||||
fixture.Physics,
|
||||
fixture.Static,
|
||||
fixture.State,
|
||||
fixture.RetirementOwner,
|
||||
onLandblockLoaded: _ => calls.Add("live-recovery"));
|
||||
LandblockStreamResult.Loaded result =
|
||||
Result(Build(Entity(0x80A9B401u), Entity(0x80A9B402u)));
|
||||
LandblockStreamCostEstimate estimate =
|
||||
LandblockStreamResultCost.Estimate(result);
|
||||
var budget = new StreamingWorkBudget(
|
||||
TimeSpan.FromSeconds(1),
|
||||
maxCompletionAdmissions: 64,
|
||||
maxAdoptedCpuBytes: 1_000_000,
|
||||
maxEntityOperations: 1,
|
||||
maxGpuUploadBytes: 1_000_000,
|
||||
maxGlRetireOperations: 64,
|
||||
destinationReserveFraction: 0.75f);
|
||||
|
||||
LandblockPublicationAdvance advance = default;
|
||||
for (int frame = 0; frame < 64; frame++)
|
||||
{
|
||||
var meter = new StreamingWorkMeter(budget);
|
||||
advance = frame == 0
|
||||
? pipeline.PublishLoaded(
|
||||
result,
|
||||
estimate,
|
||||
meter,
|
||||
ensureProgress: false)
|
||||
: pipeline.ResumePublication(
|
||||
result,
|
||||
meter,
|
||||
ensureProgress: false);
|
||||
meter.FinishFrame();
|
||||
|
||||
Assert.True(
|
||||
meter.Snapshot.Used.EntityOperations <= 1,
|
||||
$"stage={meter.Snapshot.LastStage}, " +
|
||||
$"entities={meter.Snapshot.Used.EntityOperations}, " +
|
||||
$"oversized={meter.Snapshot.OversizedProgressCount}");
|
||||
if (fixture.Static.Diagnostics.CompleteCount == 0)
|
||||
Assert.False(fixture.State.IsLoaded(LandblockId));
|
||||
if (advance.Completed)
|
||||
break;
|
||||
}
|
||||
|
||||
Assert.True(advance.Completed);
|
||||
Assert.False(pipeline.HasPendingPublication(result));
|
||||
Assert.True(fixture.State.IsNearTier(LandblockId));
|
||||
Assert.Equal(
|
||||
["terrain", "envcell", "plugin", "plugin", "pin", "live-recovery"],
|
||||
calls);
|
||||
Assert.Equal(1, fixture.Render.Diagnostics.BeginCount);
|
||||
Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount);
|
||||
Assert.Equal(1, fixture.Physics.Diagnostics.CompleteCount);
|
||||
Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderSuffixFailure_ResumesConcreteReceiptsWithoutReplayingPrefixes()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1119,7 +1119,15 @@ public sealed class LandblockPresentationPipelineTests
|
|||
failReplayOnce = false;
|
||||
throw new InvalidOperationException("injected replay failure");
|
||||
}
|
||||
});
|
||||
},
|
||||
workBudgetOptions: new StreamingWorkBudgetOptions(
|
||||
MaxUpdateMilliseconds: 100,
|
||||
MaxCompletionAdmissions: 64,
|
||||
MaxAdoptedCpuBytes: 1_000_000,
|
||||
MaxEntityOperations: 1_000,
|
||||
MaxGpuUploadBytes: 1_000_000,
|
||||
MaxGlRetireOperations: 1_000,
|
||||
DestinationReserveFraction: 0.75f));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => controller.Tick(0x90, 0x90));
|
||||
Assert.True(state.IsNearTier(landblockId));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using System.Collections.Immutable;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Streaming;
|
||||
|
|
@ -194,6 +195,47 @@ public sealed class LandblockRenderPublisherTests
|
|||
Assert.Equal(1, first.Diagnostics.CompleteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedEnvCellPublisher_AdvancesOneShellBeforeAtomicCommit()
|
||||
{
|
||||
var envPublisher = new RecordingEnvCellPublisher();
|
||||
EnvCellLandblockBuild envCells = new(
|
||||
LandblockId,
|
||||
Array.Empty<LoadedCell>(),
|
||||
[
|
||||
Shell(0xA9B40100u, 1),
|
||||
Shell(0xA9B40101u, 2),
|
||||
Shell(0xA9B40102u, 3),
|
||||
]);
|
||||
var publisher = new LandblockRenderPublisher(
|
||||
publishTerrain: static (_, _, _) => { },
|
||||
removeTerrain: static _ => { },
|
||||
cellVisibility: new CellVisibility(),
|
||||
worldState: new GpuWorldState(),
|
||||
envCellPublisher: envPublisher);
|
||||
LandblockRenderPublication receipt = publisher.PreparePublication(
|
||||
Build(envCells),
|
||||
EmptyMesh());
|
||||
publisher.BeginPublication(receipt);
|
||||
|
||||
Assert.False(publisher.AdvanceCompleteOne(receipt));
|
||||
Assert.Equal(0, envPublisher.AdvancedShells);
|
||||
Assert.Equal(0, envPublisher.CommitCount);
|
||||
for (int shell = 1; shell <= 3; shell++)
|
||||
{
|
||||
Assert.False(publisher.AdvanceCompleteOne(receipt));
|
||||
Assert.Equal(shell, envPublisher.AdvancedShells);
|
||||
Assert.Equal(0, envPublisher.CommitCount);
|
||||
}
|
||||
|
||||
Assert.False(publisher.AdvanceCompleteOne(receipt));
|
||||
Assert.Equal(0, envPublisher.CommitCount);
|
||||
Assert.False(publisher.AdvanceCompleteOne(receipt));
|
||||
Assert.Equal(1, envPublisher.CommitCount);
|
||||
Assert.True(publisher.AdvanceCompleteOne(receipt));
|
||||
Assert.Equal(1, publisher.Diagnostics.CompleteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrepareAndRetirementOperationsStayBalancedAtTheirOwnerBoundary()
|
||||
{
|
||||
|
|
@ -341,6 +383,19 @@ public sealed class LandblockRenderPublisherTests
|
|||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
|
||||
private static EnvCellShellPlacement Shell(uint cellId, ulong geometryId) =>
|
||||
new(
|
||||
cellId,
|
||||
geometryId,
|
||||
EnvironmentId: 1u,
|
||||
CellStructure: 0,
|
||||
Surfaces: ImmutableArray<ushort>.Empty,
|
||||
WorldPosition: Vector3.Zero,
|
||||
Rotation: Quaternion.Identity,
|
||||
Transform: Matrix4x4.Identity,
|
||||
LocalBounds: new WbBoundingBox(Vector3.Zero, Vector3.One),
|
||||
WorldBounds: new WbBoundingBox(Vector3.Zero, Vector3.One));
|
||||
|
||||
private static LandblockMeshData MeshAtHeights(float first, float second) => new(
|
||||
[
|
||||
new TerrainVertex(new Vector3(0f, 0f, first), Vector3.UnitZ, 0, 0, 0, 0),
|
||||
|
|
@ -362,4 +417,42 @@ public sealed class LandblockRenderPublisherTests
|
|||
}
|
||||
throw new DirectoryNotFoundException("Could not locate repository root.");
|
||||
}
|
||||
|
||||
private sealed class RecordingEnvCellPublisher :
|
||||
IEnvCellLandblockPublisher
|
||||
{
|
||||
private readonly object _owner = new();
|
||||
|
||||
public int AdvancedShells { get; private set; }
|
||||
public int CommitCount { get; private set; }
|
||||
|
||||
public EnvCellLandblockPublication PreparePublication(
|
||||
EnvCellLandblockBuild build) =>
|
||||
new(_owner, build);
|
||||
|
||||
public bool AdvancePreparationOne(
|
||||
EnvCellLandblockPublication publication)
|
||||
{
|
||||
Assert.Same(_owner, publication.Owner);
|
||||
if (publication.PreparationCommitted)
|
||||
return true;
|
||||
if (publication.ShellCursor < publication.Build.Shells.Length)
|
||||
{
|
||||
publication.ShellCursor++;
|
||||
AdvancedShells++;
|
||||
return false;
|
||||
}
|
||||
|
||||
publication.PreparationCommitted = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void CommitPublication(
|
||||
EnvCellLandblockPublication publication)
|
||||
{
|
||||
Assert.True(publication.PreparationCommitted);
|
||||
publication.PublicationCommitted = true;
|
||||
CommitCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -507,7 +507,16 @@ public sealed class LandblockRetirementCoordinatorTests
|
|||
nearRadius: 0,
|
||||
farRadius: 0,
|
||||
retirementCoordinator: coordinator,
|
||||
workBudgetOptions: workBudgetOptions);
|
||||
workBudgetOptions: workBudgetOptions ?? GenerousWorkBudget());
|
||||
|
||||
private static StreamingWorkBudgetOptions GenerousWorkBudget() => new(
|
||||
MaxUpdateMilliseconds: 100,
|
||||
MaxCompletionAdmissions: 64,
|
||||
MaxAdoptedCpuBytes: 64 * StreamingWorkBudgetOptions.MiB,
|
||||
MaxEntityOperations: 1_024,
|
||||
MaxGpuUploadBytes: 64 * StreamingWorkBudgetOptions.MiB,
|
||||
MaxGlRetireOperations: 256,
|
||||
DestinationReserveFraction: 0.75f);
|
||||
|
||||
private static IReadOnlyList<LandblockStreamResult> Drain(
|
||||
Queue<LandblockStreamResult> pending,
|
||||
|
|
|
|||
|
|
@ -238,7 +238,8 @@ public sealed class StreamingControllerReadinessTests
|
|||
ensureCalls++;
|
||||
});
|
||||
|
||||
controller.Tick(0x12, 0x36);
|
||||
for (int frame = 0; frame < 32 && ensureCalls == 0; frame++)
|
||||
controller.Tick(0x12, 0x36);
|
||||
|
||||
Assert.Equal(1, ensureCalls);
|
||||
}
|
||||
|
|
@ -294,7 +295,8 @@ public sealed class StreamingControllerReadinessTests
|
|||
ensureCalls++;
|
||||
});
|
||||
|
||||
controller.Tick(0x12, 0x36);
|
||||
for (int frame = 0; frame < 32 && ensureCalls == 0; frame++)
|
||||
controller.Tick(0x12, 0x36);
|
||||
|
||||
Assert.Equal(1, ensureCalls);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[geometryId]);
|
||||
|
|
|
|||
|
|
@ -337,7 +337,7 @@ public sealed class StreamingFrameControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamingPublicationMakesSameFrameInboundProjectionResident()
|
||||
public void CompletedStreamingPublicationMakesInboundProjectionResident()
|
||||
{
|
||||
const uint landblock = 0x0A14FFFFu;
|
||||
const uint cell = 0x0A140001u;
|
||||
|
|
@ -359,7 +359,8 @@ public sealed class StreamingFrameControllerTests
|
|||
farRadius: 0);
|
||||
var fixture = new Fixture(streamingBackend: streaming);
|
||||
|
||||
fixture.Controller.Tick();
|
||||
for (int frame = 0; frame < 32 && !state.IsLoaded(landblock); frame++)
|
||||
fixture.Controller.Tick();
|
||||
Assert.True(state.IsLoaded(landblock));
|
||||
|
||||
var runtime = Runtime(state);
|
||||
|
|
|
|||
|
|
@ -347,8 +347,8 @@ public sealed class StreamingWorkBudgetTests
|
|||
Assert.Equal(1, first.DeferredCompletions);
|
||||
Assert.Equal(44, first.DeferredAdoptedCpuBytes);
|
||||
Assert.True(first.OldestDeferredAgeMilliseconds >= 0);
|
||||
Assert.Equal(3, first.LastFrame.Operations);
|
||||
Assert.Equal(3, first.LastFrame.CompletedOperations);
|
||||
Assert.Equal(6, first.LastFrame.Operations);
|
||||
Assert.Equal(6, first.LastFrame.CompletedOperations);
|
||||
Assert.Equal(1, first.LastFrame.YieldCount);
|
||||
Assert.Equal(88, first.LastFrame.Used.AdoptedCpuBytes);
|
||||
|
||||
|
|
@ -358,7 +358,7 @@ public sealed class StreamingWorkBudgetTests
|
|||
Assert.Equal(0, second.DeferredCompletions);
|
||||
Assert.Equal(0, second.DeferredAdoptedCpuBytes);
|
||||
Assert.Equal(0, second.OldestDeferredAgeMilliseconds);
|
||||
Assert.Equal(1, second.LastFrame.CompletedOperations);
|
||||
Assert.Equal(4, second.LastFrame.CompletedOperations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -409,7 +409,7 @@ public sealed class StreamingWorkBudgetTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void DestinationAndUnloadPriorityNeverBypassExecutionBudgets()
|
||||
public void DestinationAndEmptyUnloadPriorityNeverBypassPublicationBudget()
|
||||
{
|
||||
uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32);
|
||||
uint ordinary = StreamingRegion.EncodeLandblockIdForTest(33, 33);
|
||||
|
|
@ -432,16 +432,16 @@ public sealed class StreamingWorkBudgetTests
|
|||
controller.Tick(32, 32);
|
||||
|
||||
Assert.Equal([center], applied);
|
||||
Assert.Equal(2, controller.WorkDiagnostics.DeferredCompletions);
|
||||
Assert.Equal(1, controller.WorkDiagnostics.DeferredCompletions);
|
||||
Assert.Equal(1, controller.WorkDiagnostics.LastFrame.YieldCount);
|
||||
Assert.Equal(44, controller.WorkDiagnostics.LastFrame.Used.GpuUploadBytes);
|
||||
Assert.Equal(1, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
|
||||
Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
|
||||
|
||||
controller.Tick(32, 32);
|
||||
|
||||
Assert.Equal([center, ordinary], applied);
|
||||
Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions);
|
||||
Assert.Equal(1, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
|
||||
Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -330,6 +330,65 @@ public class ShadowObjectRegistryTests
|
|||
entry => entry.EntityId == entityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamingReceipts_AreStableOrderedAndAdvanceOneStaticOwner()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
reg.Register(
|
||||
9u,
|
||||
0x01000009u,
|
||||
new Vector3(12f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
1f,
|
||||
OffX,
|
||||
OffY,
|
||||
LbId,
|
||||
seedCellId: LbId | 1u,
|
||||
isStatic: true);
|
||||
reg.Register(
|
||||
2u,
|
||||
0x01000002u,
|
||||
new Vector3(14f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
1f,
|
||||
OffX,
|
||||
OffY,
|
||||
LbId,
|
||||
seedCellId: LbId | 1u,
|
||||
isStatic: true);
|
||||
reg.Register(
|
||||
5u,
|
||||
0x01000005u,
|
||||
new Vector3(16f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
1f,
|
||||
OffX,
|
||||
OffY,
|
||||
LbId,
|
||||
seedCellId: LbId | 1u,
|
||||
isStatic: false);
|
||||
|
||||
Assert.Equal(
|
||||
[2u, 9u],
|
||||
reg.CaptureStaticOwnersForLandblock(LbId));
|
||||
Assert.Equal(
|
||||
[2u, 5u, 9u],
|
||||
reg.CaptureRefloodOwnersForLandblock(LbId));
|
||||
|
||||
reg.DeregisterStaticOwnerForLandblock(2u, LbId);
|
||||
|
||||
Assert.Equal(2, reg.RetainedRegistrationCount);
|
||||
Assert.DoesNotContain(
|
||||
reg.GetObjectsInCell(LbId | 1u),
|
||||
entry => entry.EntityId == 2u);
|
||||
Assert.Contains(
|
||||
reg.GetObjectsInCell(LbId | 1u),
|
||||
entry => entry.EntityId == 5u);
|
||||
Assert.Contains(
|
||||
reg.GetObjectsInCell(LbId | 1u),
|
||||
entry => entry.EntityId == 9u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefloodLandblock_RestoresAdjacentOwnedFootprintWithdrawnByUnload()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -372,7 +372,8 @@ public class StreamingControllerTests
|
|||
System.Array.Empty<uint>());
|
||||
fake.Pending.Enqueue(new LandblockStreamResult.Loaded(landblockId, LandblockStreamTier.Near, lb, stubMesh));
|
||||
|
||||
controller.Tick(50, 50);
|
||||
for (int frame = 0; frame < 32 && !state.IsLoaded(landblockId); frame++)
|
||||
controller.Tick(50, 50);
|
||||
|
||||
Assert.Single(applied);
|
||||
Assert.True(state.IsLoaded(landblockId));
|
||||
|
|
|
|||
|
|
@ -136,7 +136,15 @@ public class StreamingControllerTwoTierTests
|
|||
},
|
||||
state: state,
|
||||
nearRadius: 2,
|
||||
farRadius: 2);
|
||||
farRadius: 2,
|
||||
workBudgetOptions: new StreamingWorkBudgetOptions(
|
||||
MaxUpdateMilliseconds: 100,
|
||||
MaxCompletionAdmissions: 64,
|
||||
MaxAdoptedCpuBytes: 16 * StreamingWorkBudgetOptions.MiB,
|
||||
MaxEntityOperations: 512,
|
||||
MaxGpuUploadBytes: 16 * StreamingWorkBudgetOptions.MiB,
|
||||
MaxGlRetireOperations: 128,
|
||||
DestinationReserveFraction: 0.75f));
|
||||
|
||||
ctrl.Tick(50, 50); // drains the Promoted result
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue