fix(G.3): collapse streaming to the single dungeon landblock indoors (#133 FPS)
Dungeon FPS sat at ~30 (frame ~33ms) because the 25x25 streaming window around the dungeon landblock pulled in ~129 NEIGHBORING landblocks + their thousands of torch/particle emitters, all drawn though never visible. In AC all dungeons are packed adjacent in the unused "ocean" map grid, so those neighbors are unrelated dungeons. The FPS timeline proved it: 247 fps at login (lb 0/0, ~10K entities) → 17 → 30 as landblocks streamed in (lb 0→129) — the cost tracked LANDBLOCK count, not entities. Retail-faithful: ACE LandblockManager.GetAdjacentIDs returns ZERO adjacents for a dungeon (`if (landblock.IsDungeon) return adjacents;`, Landblock.cs:577-582) — every dungeon is a self-contained landblock you never see out of. Fix: when the player stands in a sealed indoor cell (CurrCell.IsEnv && !SeenOutside — the same predicate that kills the sun/sky), collapse streaming to just the player's dungeon landblock and unload the neighbors. Building interiors (cottage/inn) have SeenOutside cells, so they are NOT gated and keep their surrounding terrain (the frozen building/cellar demo is unaffected). Unloading the neighbors also tears down their lights (removeTerrain → UnregisterOwner), shrinking LightManager._all from ~2227 toward retail's ≤40 — which directly helps the A7 lighting bake landing next. Mechanics (StreamingController): - Edge IN: ClearPendingLoads() cancels the in-flight 25x25 window (new streamer ClearLoads control job — worker drops queued Loads, keeps Unloads), unload every resident neighbor, pin a radius-0 StreamingRegion, (re)load the dungeon block if needed. - Stay collapsed: sweep any straggler that finished loading after the edge (a Load the worker had already dequeued before ClearLoads). - Edge OUT (portal/teleport to outdoors): rebuild the full two-tier window at the new center, unload anything stale. AP-36 added to the divergence register (the gate uses the cheap SeenOutside cell predicate as an approximation of ACE's full landblock IsDungeon classification). GameWindow also carries a TEMP ACDREAM_LOG_FPS=1 headless FPS line (strip after the A7 FPS+lighting verification). Build green; 58 streaming tests green (6 new dungeon-gate tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
007e287309
commit
56860501b6
6 changed files with 324 additions and 6 deletions
|
|
@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Documented approximation (AP) — 35 rows
|
## 3. Documented approximation (AP) — 36 rows
|
||||||
|
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
|
@ -130,6 +130,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
|
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
|
||||||
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
|
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
|
||||||
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
|
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
|
||||||
|
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock | `src/AcDream.App/Rendering/GameWindow.cs:6895` (predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 |
|
||||||
| AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 |
|
| AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 − dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ −0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`−0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1914,6 +1914,7 @@ public sealed class GameWindow : IDisposable
|
||||||
state: _worldState,
|
state: _worldState,
|
||||||
nearRadius: _nearRadius,
|
nearRadius: _nearRadius,
|
||||||
farRadius: _farRadius,
|
farRadius: _farRadius,
|
||||||
|
clearPendingLoads: _streamer.ClearPendingLoads,
|
||||||
removeTerrain: id =>
|
removeTerrain: id =>
|
||||||
{
|
{
|
||||||
// Phase G.2: release any LightSources attached to entities
|
// Phase G.2: release any LightSources attached to entities
|
||||||
|
|
@ -6882,7 +6883,20 @@ public sealed class GameWindow : IDisposable
|
||||||
observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
|
observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
|
||||||
}
|
}
|
||||||
|
|
||||||
_streamingController.Tick(observerCx, observerCy);
|
// Dungeon gate (#133 FPS): when the player stands in a SEALED EnvCell
|
||||||
|
// (indoor cell that doesn't see outside — the same predicate that kills
|
||||||
|
// the sun/sky, playerInsideCell below), collapse streaming to the single
|
||||||
|
// dungeon landblock. AC dungeons have no adjacent landblocks; the 25×25
|
||||||
|
// window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building
|
||||||
|
// interiors (cottage/inn) have SeenOutside cells, so they are NOT gated
|
||||||
|
// and keep their surrounding terrain.
|
||||||
|
// Mirrors the playerInsideCell computation below (CurrCell → registry
|
||||||
|
// LoadedCell.SeenOutside): true only for a sealed indoor cell.
|
||||||
|
bool insideDungeon =
|
||||||
|
_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv
|
||||||
|
&& _cellVisibility.TryGetCell(pcEnv.Id, out var pcReg)
|
||||||
|
&& pcReg is { SeenOutside: false };
|
||||||
|
_streamingController.Tick(observerCx, observerCy, insideDungeon);
|
||||||
|
|
||||||
// Re-inject persistent entities rescued from unloaded landblocks
|
// Re-inject persistent entities rescued from unloaded landblocks
|
||||||
// into the current center landblock (the one the observer is in).
|
// into the current center landblock (the one the observer is in).
|
||||||
|
|
@ -8418,6 +8432,11 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
_lastFps = fps;
|
_lastFps = fps;
|
||||||
_lastFrameMs = avgFrameTime;
|
_lastFrameMs = avgFrameTime;
|
||||||
|
// TEMP (A7 FPS measurement, strip after): headless FPS/frame-time so the
|
||||||
|
// launch log can be correlated against the [WB-DIAG] draw stats.
|
||||||
|
if (Environment.GetEnvironmentVariable("ACDREAM_LOG_FPS") == "1")
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[FPS] {fps:F1} fps | {avgFrameTime:F2} ms | lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount} anim {animatedCount}");
|
||||||
_perfAccum = 0;
|
_perfAccum = 0;
|
||||||
_perfFrameCount = 0;
|
_perfFrameCount = 0;
|
||||||
}
|
}
|
||||||
|
|
@ -10589,6 +10608,7 @@ public sealed class GameWindow : IDisposable
|
||||||
state: _worldState,
|
state: _worldState,
|
||||||
nearRadius: _nearRadius,
|
nearRadius: _nearRadius,
|
||||||
farRadius: _farRadius,
|
farRadius: _farRadius,
|
||||||
|
clearPendingLoads: _streamer.ClearPendingLoads,
|
||||||
removeTerrain: id =>
|
removeTerrain: id =>
|
||||||
{
|
{
|
||||||
if (_lightingSink is not null &&
|
if (_lightingSink is not null &&
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,16 @@ public abstract record LandblockStreamJob(uint LandblockId)
|
||||||
{
|
{
|
||||||
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
|
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
|
||||||
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
|
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Control job: drop every queued (not-yet-started) Load from the worker's
|
||||||
|
/// priority queues, keeping Unloads. Posted by
|
||||||
|
/// <see cref="LandblockStreamer.ClearPendingLoads"/> when the player enters a
|
||||||
|
/// dungeon and the in-flight outdoor/neighbor window load must be cancelled
|
||||||
|
/// (#133 FPS — dungeons have no adjacent landblocks). LandblockId is 0 by
|
||||||
|
/// convention; readers pattern-match on the type.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ClearLoads() : LandblockStreamJob(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,22 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
|
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancel every queued-but-not-started Load. Posts a
|
||||||
|
/// <see cref="LandblockStreamJob.ClearLoads"/> control job which the worker
|
||||||
|
/// honours at read time, dropping all pending Loads from both priority
|
||||||
|
/// queues (Unloads survive). Used on the dungeon-entry edge to abort the
|
||||||
|
/// in-flight 25×25 neighbor window so the ~129 ocean-grid dungeons never
|
||||||
|
/// finish loading (#133 FPS). Loads the worker has ALREADY dequeued still
|
||||||
|
/// complete; the StreamingController's collapsed-sweep unloads those few.
|
||||||
|
/// </summary>
|
||||||
|
public void ClearPendingLoads()
|
||||||
|
{
|
||||||
|
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||||
|
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||||
|
_inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Drain up to <paramref name="maxBatchSize"/> completed results.
|
/// Drain up to <paramref name="maxBatchSize"/> completed results.
|
||||||
/// Non-blocking. Call from the render thread once per OnUpdate.
|
/// Non-blocking. Call from the render thread once per OnUpdate.
|
||||||
|
|
@ -180,7 +196,18 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
while (_inbox.Reader.TryRead(out var job))
|
while (_inbox.Reader.TryRead(out var job))
|
||||||
|
{
|
||||||
|
if (job is LandblockStreamJob.ClearLoads)
|
||||||
|
{
|
||||||
|
// Dungeon-entry cancellation: drop every queued Load,
|
||||||
|
// keep Unloads. Handled at read time so it supersedes
|
||||||
|
// Loads sitting in the priority queues ahead of it.
|
||||||
|
DropLoadJobs(highPriority);
|
||||||
|
DropLoadJobs(lowPriority);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
EnqueuePrioritized(job, highPriority, lowPriority);
|
EnqueuePrioritized(job, highPriority, lowPriority);
|
||||||
|
}
|
||||||
|
|
||||||
if (highPriority.Count == 0 && lowPriority.Count == 0)
|
if (highPriority.Count == 0 && lowPriority.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -233,6 +260,22 @@ public sealed class LandblockStreamer : IDisposable
|
||||||
lowPriority.Enqueue(job);
|
lowPriority.Enqueue(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drop every <see cref="LandblockStreamJob.Load"/> from a priority queue,
|
||||||
|
/// preserving Unloads (and any other control jobs). Rotates the queue once
|
||||||
|
/// in place. Used by the <see cref="LandblockStreamJob.ClearLoads"/> path.
|
||||||
|
/// </summary>
|
||||||
|
private static void DropLoadJobs(Queue<LandblockStreamJob> queue)
|
||||||
|
{
|
||||||
|
int count = queue.Count;
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var job = queue.Dequeue();
|
||||||
|
if (job is not LandblockStreamJob.Load)
|
||||||
|
queue.Enqueue(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void RemoveLowPriorityJobsForLandblock(
|
private static void RemoveLowPriorityJobsForLandblock(
|
||||||
Queue<LandblockStreamJob> queue,
|
Queue<LandblockStreamJob> queue,
|
||||||
uint landblockId,
|
uint landblockId,
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,16 @@ public sealed class StreamingController
|
||||||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||||||
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
|
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
|
||||||
private readonly Action<uint>? _removeTerrain;
|
private readonly Action<uint>? _removeTerrain;
|
||||||
|
private readonly Action? _clearPendingLoads;
|
||||||
private readonly GpuWorldState _state;
|
private readonly GpuWorldState _state;
|
||||||
private StreamingRegion? _region;
|
private StreamingRegion? _region;
|
||||||
|
|
||||||
|
// True while streaming is collapsed to the single dungeon landblock the
|
||||||
|
// player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
|
||||||
|
// adjacent landblocks — neighbors are unrelated ocean-grid dungeons that
|
||||||
|
// are never visible, so we stop loading the 25×25 window entirely.
|
||||||
|
private bool _collapsed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Near-tier radius (LBs from observer that load full detail: terrain +
|
/// Near-tier radius (LBs from observer that load full detail: terrain +
|
||||||
/// scenery + entities). Set at construction; readable thereafter.
|
/// scenery + entities). Set at construction; readable thereafter.
|
||||||
|
|
@ -71,13 +78,15 @@ public sealed class StreamingController
|
||||||
GpuWorldState state,
|
GpuWorldState state,
|
||||||
int nearRadius,
|
int nearRadius,
|
||||||
int farRadius,
|
int farRadius,
|
||||||
Action<uint>? removeTerrain = null)
|
Action<uint>? removeTerrain = null,
|
||||||
|
Action? clearPendingLoads = null)
|
||||||
{
|
{
|
||||||
_enqueueLoad = enqueueLoad;
|
_enqueueLoad = enqueueLoad;
|
||||||
_enqueueUnload = enqueueUnload;
|
_enqueueUnload = enqueueUnload;
|
||||||
_drainCompletions = drainCompletions;
|
_drainCompletions = drainCompletions;
|
||||||
_applyTerrain = applyTerrain;
|
_applyTerrain = applyTerrain;
|
||||||
_removeTerrain = removeTerrain;
|
_removeTerrain = removeTerrain;
|
||||||
|
_clearPendingLoads = clearPendingLoads;
|
||||||
_state = state;
|
_state = state;
|
||||||
NearRadius = nearRadius;
|
NearRadius = nearRadius;
|
||||||
FarRadius = farRadius;
|
FarRadius = farRadius;
|
||||||
|
|
@ -97,7 +106,32 @@ public sealed class StreamingController
|
||||||
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
|
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Tick(int observerCx, int observerCy)
|
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
|
||||||
|
{
|
||||||
|
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
|
||||||
|
|
||||||
|
if (insideDungeon)
|
||||||
|
{
|
||||||
|
if (!_collapsed)
|
||||||
|
EnterDungeonCollapse(observerCx, observerCy, centerId);
|
||||||
|
else
|
||||||
|
SweepCollapsed(centerId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (_collapsed)
|
||||||
|
ExitDungeonExpand(observerCx, observerCy);
|
||||||
|
else
|
||||||
|
NormalTick(observerCx, observerCy);
|
||||||
|
}
|
||||||
|
|
||||||
|
DrainAndApply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outdoor / building-interior streaming — the original two-tier model.
|
||||||
|
/// </summary>
|
||||||
|
private void NormalTick(int observerCx, int observerCy)
|
||||||
{
|
{
|
||||||
if (_region is null)
|
if (_region is null)
|
||||||
{
|
{
|
||||||
|
|
@ -116,9 +150,77 @@ public sealed class StreamingController
|
||||||
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
|
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
|
||||||
foreach (var id in diff.ToUnload) _enqueueUnload(id);
|
foreach (var id in diff.ToUnload) _enqueueUnload(id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Drain up to N completions per frame so a big diff doesn't spike
|
/// <summary>
|
||||||
// GPU upload time. Remaining completions wait for the next frame.
|
/// Dungeon-entry edge: cancel the in-flight window load, unload every
|
||||||
|
/// resident neighbor, and pin streaming to the player's single dungeon
|
||||||
|
/// landblock. Retail-faithful — AC dungeons have no adjacent landblocks
|
||||||
|
/// (ACE <c>LandblockManager.GetAdjacentIDs</c> returns empty for a dungeon);
|
||||||
|
/// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and
|
||||||
|
/// their thousands of emitters (#133 FPS). Unloading them also tears down
|
||||||
|
/// their lights, shrinking the static-light set toward retail's ≤40.
|
||||||
|
/// </summary>
|
||||||
|
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
|
||||||
|
{
|
||||||
|
_collapsed = true;
|
||||||
|
_clearPendingLoads?.Invoke();
|
||||||
|
|
||||||
|
foreach (var id in _state.LoadedLandblockIds)
|
||||||
|
if (id != centerId) _enqueueUnload(id);
|
||||||
|
|
||||||
|
// Pin a radius-0 region so RecenterTo never re-expands while inside,
|
||||||
|
// and so the post-exit rebuild starts from a clean, consistent state.
|
||||||
|
_region = new StreamingRegion(cx, cy, 0, 0);
|
||||||
|
_region.MarkResidentFromBootstrap();
|
||||||
|
|
||||||
|
// The dungeon landblock itself must be (or become) loaded. If a prior
|
||||||
|
// ClearPendingLoads cancelled its queued load, re-enqueue it.
|
||||||
|
if (!_state.IsLoaded(centerId))
|
||||||
|
_enqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// While collapsed, unload any landblock that finished loading after the
|
||||||
|
/// collapse edge — a Load the worker had already dequeued before the
|
||||||
|
/// <see cref="LandblockStreamer.ClearPendingLoads"/> control job took
|
||||||
|
/// effect. At steady state only the dungeon landblock is resident, so this
|
||||||
|
/// is a no-op.
|
||||||
|
/// </summary>
|
||||||
|
private void SweepCollapsed(uint centerId)
|
||||||
|
{
|
||||||
|
foreach (var id in _state.LoadedLandblockIds)
|
||||||
|
if (id != centerId) _enqueueUnload(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full
|
||||||
|
/// two-tier window at the new center and unload anything resident from the
|
||||||
|
/// collapsed state that falls outside it.
|
||||||
|
/// </summary>
|
||||||
|
private void ExitDungeonExpand(int observerCx, int observerCy)
|
||||||
|
{
|
||||||
|
_collapsed = false;
|
||||||
|
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||||||
|
|
||||||
|
foreach (var id in _state.LoadedLandblockIds)
|
||||||
|
if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id);
|
||||||
|
|
||||||
|
var boot = rebuilt.ComputeFirstTickDiff();
|
||||||
|
foreach (var id in boot.ToLoadNear)
|
||||||
|
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||||||
|
foreach (var id in boot.ToLoadFar)
|
||||||
|
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||||
|
rebuilt.MarkResidentFromBootstrap();
|
||||||
|
_region = rebuilt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drain up to N completions per frame so a big diff doesn't spike GPU
|
||||||
|
/// upload time. Remaining completions wait for the next frame.
|
||||||
|
/// </summary>
|
||||||
|
private void DrainAndApply()
|
||||||
|
{
|
||||||
var drained = _drainCompletions(MaxCompletionsPerFrame);
|
var drained = _drainCompletions(MaxCompletionsPerFrame);
|
||||||
foreach (var result in drained)
|
foreach (var result in drained)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The dungeon streaming gate (#133 FPS). AC dungeons have no adjacent
|
||||||
|
/// landblocks (ACE <c>LandblockManager.GetAdjacentIDs</c> returns empty for a
|
||||||
|
/// dungeon); they sit packed in the ocean grid, so the normal 25×25 window
|
||||||
|
/// pulls in ~129 unrelated neighbor dungeons + their emitters. When the player
|
||||||
|
/// is inside a sealed dungeon cell, <c>Tick(insideDungeon: true)</c> collapses
|
||||||
|
/// streaming to the single dungeon landblock and unloads the neighbors.
|
||||||
|
/// </summary>
|
||||||
|
public class StreamingControllerDungeonGateTests
|
||||||
|
{
|
||||||
|
private static uint Encode(int x, int y) => ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu;
|
||||||
|
|
||||||
|
private static LoadedLandblock MakeLb(int x, int y) => new LoadedLandblock(
|
||||||
|
Encode(x, y),
|
||||||
|
Heightmap: null!,
|
||||||
|
Entities: Array.Empty<WorldEntity>());
|
||||||
|
|
||||||
|
private sealed record Harness(
|
||||||
|
StreamingController Ctrl,
|
||||||
|
List<(uint Id, LandblockStreamJobKind Kind)> Loads,
|
||||||
|
List<uint> Unloads,
|
||||||
|
Func<int> ClearCalls,
|
||||||
|
GpuWorldState State);
|
||||||
|
|
||||||
|
private static Harness Make()
|
||||||
|
{
|
||||||
|
var loads = new List<(uint, LandblockStreamJobKind)>();
|
||||||
|
var unloads = new List<uint>();
|
||||||
|
int clearCalls = 0;
|
||||||
|
var state = new GpuWorldState();
|
||||||
|
var ctrl = new StreamingController(
|
||||||
|
enqueueLoad: (id, kind) => loads.Add((id, kind)),
|
||||||
|
enqueueUnload: unloads.Add,
|
||||||
|
drainCompletions: _ => Array.Empty<LandblockStreamResult>(),
|
||||||
|
applyTerrain: (_, _) => { },
|
||||||
|
state: state,
|
||||||
|
nearRadius: 4,
|
||||||
|
farRadius: 12,
|
||||||
|
clearPendingLoads: () => clearCalls++);
|
||||||
|
return new Harness(ctrl, loads, unloads, () => clearCalls, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EntersDungeon_CancelsPending_UnloadsNeighbors_KeepsCenter()
|
||||||
|
{
|
||||||
|
var h = Make();
|
||||||
|
uint center = Encode(0, 7);
|
||||||
|
h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock
|
||||||
|
h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon
|
||||||
|
h.State.AddLandblock(MakeLb(1, 7)); // another neighbor
|
||||||
|
|
||||||
|
h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true);
|
||||||
|
|
||||||
|
Assert.Equal(1, h.ClearCalls()); // in-flight window load cancelled
|
||||||
|
Assert.Contains(Encode(0, 8), h.Unloads); // neighbor unloaded
|
||||||
|
Assert.Contains(Encode(1, 7), h.Unloads); // neighbor unloaded
|
||||||
|
Assert.DoesNotContain(center, h.Unloads); // dungeon landblock kept
|
||||||
|
Assert.DoesNotContain(h.Loads, l => l.Id == center); // already loaded → no reload
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EntersDungeon_CenterNotLoaded_EnqueuesCenterLoad()
|
||||||
|
{
|
||||||
|
var h = Make(); // empty state — the dungeon landblock isn't resident yet
|
||||||
|
|
||||||
|
h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true);
|
||||||
|
|
||||||
|
Assert.Equal(1, h.ClearCalls());
|
||||||
|
Assert.Contains(h.Loads, l => l.Id == Encode(0, 7)
|
||||||
|
&& l.Kind == LandblockStreamJobKind.LoadNear);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StayingCollapsed_SweepsStragglerThatFinishedAfterTheEdge()
|
||||||
|
{
|
||||||
|
var h = Make();
|
||||||
|
h.State.AddLandblock(MakeLb(0, 7));
|
||||||
|
h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse edge
|
||||||
|
h.Unloads.Clear();
|
||||||
|
|
||||||
|
// A Load the worker had already dequeued before ClearLoads now completes.
|
||||||
|
h.State.AddLandblock(MakeLb(0, 8));
|
||||||
|
h.Ctrl.Tick(0, 7, insideDungeon: true); // sweep
|
||||||
|
|
||||||
|
Assert.Contains(Encode(0, 8), h.Unloads);
|
||||||
|
Assert.DoesNotContain(Encode(0, 7), h.Unloads);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StayingCollapsed_DoesNotReClearOrReloadCenter()
|
||||||
|
{
|
||||||
|
var h = Make();
|
||||||
|
h.State.AddLandblock(MakeLb(0, 7));
|
||||||
|
h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse (clear #1)
|
||||||
|
h.Loads.Clear();
|
||||||
|
|
||||||
|
h.Ctrl.Tick(0, 7, insideDungeon: true); // stay collapsed
|
||||||
|
|
||||||
|
Assert.Equal(1, h.ClearCalls()); // clear only fired on the edge
|
||||||
|
Assert.Empty(h.Loads); // no spurious center reloads
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExitsDungeon_RebuildsFullWindow_UnloadsStaleDungeonLandblock()
|
||||||
|
{
|
||||||
|
var h = Make();
|
||||||
|
h.State.AddLandblock(MakeLb(0, 7));
|
||||||
|
h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse
|
||||||
|
h.Loads.Clear();
|
||||||
|
h.Unloads.Clear();
|
||||||
|
|
||||||
|
// Exit through a portal to an outdoor location far from the dungeon block.
|
||||||
|
h.Ctrl.Tick(observerCx: 100, observerCy: 100, insideDungeon: false);
|
||||||
|
|
||||||
|
Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear);
|
||||||
|
Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
|
||||||
|
Assert.Contains(Encode(0, 7), h.Unloads); // stale dungeon block, outside new window
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalOutdoorTick_Unchanged_NoCollapseNoClear()
|
||||||
|
{
|
||||||
|
var h = Make();
|
||||||
|
|
||||||
|
h.Ctrl.Tick(observerCx: 100, observerCy: 100); // default insideDungeon: false
|
||||||
|
|
||||||
|
Assert.Equal(0, h.ClearCalls());
|
||||||
|
Assert.Empty(h.Unloads);
|
||||||
|
// 9 near (9×9? no — nearRadius 4 → 9×9=81) + far ring loads enqueued.
|
||||||
|
Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear);
|
||||||
|
Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue