On login (or teleport) into a dungeon, FPS started ~10 and climbed over ~30 s. Root cause: the dungeon "collapse" (which shrinks the 25x25 streaming window to the player's single dungeon landblock — AC dungeons have no neighbours) only fires once the per-frame `insideDungeon` gate reads true, and that gate keys on the physics CurrCell, which isn't set until the player is PLACED, which waits for the dungeon landblock to hydrate. So during the whole hydration window NormalTick bootstraps the full window — ~24 unrelated ocean-grid neighbour dungeons + their ~19k entities each — and the collapse only mops them up afterward. That mop-up is the ramp. Fix: trigger the SAME collapse early, the instant we recenter the streaming center onto a sealed dungeon cell, before the first NormalTick. - StreamingController.PreCollapseToDungeon(cx,cy): fires EnterDungeonCollapse early (idempotent). The expensive neighbour window is never enqueued. - GameWindow.IsSealedDungeonCell(cellId): reads the EnvCell dat SeenOutside flag (CurrCell is null pre-placement) — the same flag ObjCell.SeenOutside and the per-frame gate use, so the early decision matches the eventual one. Distinguishes a real dungeon from a cottage/inn interior (SeenOutside → keeps its outdoor surround). Excludes the 0xFFFE/0xFFFF structural shell ids so an outdoor spawn id can't type-confuse a LandBlock record as an EnvCell. - Hooks: OnLiveEntitySpawnedLocked (login) + OnLivePositionUpdated (teleport). - Observer robustness: during a teleport PortalSpace hold the streaming observer follows the recentered destination, not the frozen pre-teleport position (which could drift >=2 landblocks off and trip ExitDungeonExpand). And _lastLivePlayerLandblockId is now filtered to the player guid (resolves the Phase A.1 TODO) so a stray NPC UpdatePosition can't drift the login-hold observer off the dungeon. Faithful EARLY trigger of the existing AP-36 collapse mechanism, not a new workaround — AP-36 amended in the same commit. Adversarially reviewed across timing / threading / faithfulness lenses; 5 new tests including the real runtime ordering (Tick bootstraps, then PreCollapse cancels). Core suite green (1463). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
315 lines
14 KiB
C#
315 lines
14 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using AcDream.Core.Terrain;
|
||
using AcDream.Core.World;
|
||
|
||
namespace AcDream.App.Streaming;
|
||
|
||
/// <summary>
|
||
/// Called once per frame from <c>GameWindow.OnUpdate</c>. Owns the
|
||
/// <see cref="StreamingRegion"/> and uses delegates into
|
||
/// <see cref="LandblockStreamer"/> so tests can inject fakes. All work
|
||
/// happens on the render thread; the streamer itself is background.
|
||
///
|
||
/// <remarks>
|
||
/// Threading: not thread-safe. All calls must happen on the render thread.
|
||
/// </remarks>
|
||
/// </summary>
|
||
public sealed class StreamingController
|
||
{
|
||
private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad;
|
||
private readonly Action<uint> _enqueueUnload;
|
||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
|
||
private readonly Action<uint>? _removeTerrain;
|
||
private readonly Action? _clearPendingLoads;
|
||
private readonly GpuWorldState _state;
|
||
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;
|
||
|
||
// The dungeon landblock id we collapsed onto. Once collapsed we key the
|
||
// gate on this STABLE landblock, not the per-frame insideDungeon signal:
|
||
// CurrCell can momentarily resolve to null/outdoor mid-frame, and gating
|
||
// expand on that flicker thrashes collapse↔expand (reload storms + a light
|
||
// leak). We only expand when the observer actually moves to a different
|
||
// landblock (teleport/portal out).
|
||
private uint _collapsedCenter;
|
||
|
||
/// <summary>
|
||
/// Near-tier radius (LBs from observer that load full detail: terrain +
|
||
/// scenery + entities). Set at construction; readable thereafter.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Mutating after the first <see cref="Tick"/> has no effect — the
|
||
/// internal <see cref="StreamingRegion"/> snapshots both radii on its
|
||
/// constructor. Treat as init-only post-Tick.
|
||
/// </remarks>
|
||
public int NearRadius { get; }
|
||
|
||
/// <summary>
|
||
/// Far-tier radius (LBs from observer that load terrain only). Set at
|
||
/// construction; readable thereafter.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Mutating after the first <see cref="Tick"/> has no effect — see <see cref="NearRadius"/>.
|
||
/// </remarks>
|
||
public int FarRadius { get; }
|
||
|
||
/// <summary>
|
||
/// Cap on completions drained per <see cref="Tick"/> call. The cap is
|
||
/// the GPU upload budget for one frame: terrain mesh + per-entity GfxObj
|
||
/// sub-mesh uploads + texture uploads for one landblock take a few ms;
|
||
/// applying 25 of them in a single frame produces a memory spike
|
||
/// (observed: out-of-memory crash on the 5×5 first-frame load).
|
||
///
|
||
/// <para>
|
||
/// 4 is the original async-streamer value; it spreads a 5×5 first-frame
|
||
/// load over ~7 frames (~116ms at 60fps), which is below the human
|
||
/// perception threshold. Spawn races that previously dropped entities
|
||
/// while landblocks were in flight are now handled by
|
||
/// <see cref="GpuWorldState"/>'s pending-spawn list, so spreading
|
||
/// completions doesn't lose any data.
|
||
/// </para>
|
||
/// </summary>
|
||
public int MaxCompletionsPerFrame { get; set; } = 4;
|
||
|
||
public StreamingController(
|
||
Action<uint, LandblockStreamJobKind> enqueueLoad,
|
||
Action<uint> enqueueUnload,
|
||
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
||
Action<LoadedLandblock, LandblockMeshData> applyTerrain,
|
||
GpuWorldState state,
|
||
int nearRadius,
|
||
int farRadius,
|
||
Action<uint>? removeTerrain = null,
|
||
Action? clearPendingLoads = null)
|
||
{
|
||
_enqueueLoad = enqueueLoad;
|
||
_enqueueUnload = enqueueUnload;
|
||
_drainCompletions = drainCompletions;
|
||
_applyTerrain = applyTerrain;
|
||
_removeTerrain = removeTerrain;
|
||
_clearPendingLoads = clearPendingLoads;
|
||
_state = state;
|
||
NearRadius = nearRadius;
|
||
FarRadius = farRadius;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Advance one frame. <paramref name="observerCx"/>/<paramref name="observerCy"/>
|
||
/// are landblock coordinates (0..255) of the current viewer — the camera
|
||
/// in offline mode, the server-sent player position in live.
|
||
///
|
||
/// <para>Two-tier model (Phase A.5 T13):</para>
|
||
/// <list type="bullet">
|
||
/// <item><see cref="TwoTierDiff.ToLoadFar"/> → enqueue LoadFar (terrain only, no entities)</item>
|
||
/// <item><see cref="TwoTierDiff.ToLoadNear"/> → enqueue LoadNear (terrain + entities)</item>
|
||
/// <item><see cref="TwoTierDiff.ToPromote"/> → enqueue PromoteToNear (entity layer for already-loaded terrain)</item>
|
||
/// <item><see cref="TwoTierDiff.ToDemote"/> → drop entities on render thread immediately (terrain stays)</item>
|
||
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
|
||
/// </list>
|
||
/// </summary>
|
||
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
|
||
{
|
||
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
|
||
|
||
if (_collapsed)
|
||
{
|
||
// Hysteresis. Cases:
|
||
// - Still in the SAME dungeon landblock → hold (sweep stragglers).
|
||
// - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
|
||
// → re-collapse onto it.
|
||
// - CurrCell flickered null but the player hasn't gone anywhere: the
|
||
// observer landblock reverts to the position-derived value, which for a
|
||
// dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
|
||
// local Y). Hold — never expand on an adjacent flicker.
|
||
// - Genuinely left to a DISTANT landblock (portal/teleport out, always far
|
||
// from the ocean-grid dungeon block) → expand.
|
||
if (insideDungeon && centerId != _collapsedCenter)
|
||
EnterDungeonCollapse(observerCx, observerCy, centerId);
|
||
else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
|
||
ExitDungeonExpand(observerCx, observerCy);
|
||
else
|
||
SweepCollapsed();
|
||
}
|
||
else if (insideDungeon)
|
||
{
|
||
EnterDungeonCollapse(observerCx, observerCy, centerId);
|
||
}
|
||
else
|
||
{
|
||
NormalTick(observerCx, observerCy);
|
||
}
|
||
|
||
DrainAndApply();
|
||
}
|
||
|
||
/// <summary>
|
||
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
|
||
/// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called
|
||
/// from the login / teleport spawn path the instant the streaming center is
|
||
/// recentered onto a SEALED dungeon landblock.
|
||
///
|
||
/// <para>The per-frame <c>insideDungeon</c> gate keys on the physics
|
||
/// <c>CurrCell</c>, which is only set once the player is PLACED — and placement
|
||
/// waits for the dungeon landblock to hydrate. So for the whole hydration window
|
||
/// (tens of seconds for a ~200-cell dungeon) the gate reads false and
|
||
/// <see cref="NormalTick"/> would enqueue the ~24 unrelated ocean-grid neighbor
|
||
/// dungeons (+ ~19k entities each); the collapse then only mops them up after
|
||
/// placement. That mop-up is the 10→high FPS ramp users see at a dungeon login.</para>
|
||
///
|
||
/// <para>Pre-collapsing means the EXPENSIVE dungeon-neighbour window is never
|
||
/// enqueued. On teleport nothing is enqueued at all (this fires before the next
|
||
/// Tick recenters). On login a brief Holtburg outdoor window may be enqueued by the
|
||
/// frame-1 NormalTick (before the player's spawn arrives) and is immediately
|
||
/// cancelled by <c>_clearPendingLoads</c> here — cheap outdoor terrain, not the
|
||
/// ocean-grid dungeons, and a handful of already-dequeued loads get swept next
|
||
/// frame. Idempotent: a no-op when already collapsed onto this same landblock, so a
|
||
/// re-sent spawn or a same-frame double call costs nothing. Render-thread only,
|
||
/// same as <see cref="Tick"/>.</para>
|
||
/// </summary>
|
||
public void PreCollapseToDungeon(int cx, int cy)
|
||
{
|
||
uint centerId = StreamingRegion.EncodeLandblockId(cx, cy);
|
||
if (_collapsed && _collapsedCenter == centerId) return;
|
||
EnterDungeonCollapse(cx, cy, centerId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Outdoor / building-interior streaming — the original two-tier model.
|
||
/// </summary>
|
||
private void NormalTick(int observerCx, int observerCy)
|
||
{
|
||
if (_region is null)
|
||
{
|
||
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||
var bootstrap = _region.ComputeFirstTickDiff();
|
||
foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||
foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||
_region.MarkResidentFromBootstrap();
|
||
}
|
||
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
|
||
{
|
||
var diff = _region.RecenterTo(observerCx, observerCy);
|
||
foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
|
||
foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||
foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
|
||
foreach (var id in diff.ToUnload) _enqueueUnload(id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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;
|
||
_collapsedCenter = centerId;
|
||
_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()
|
||
{
|
||
// Always preserve the true dungeon landblock (_collapsedCenter), never the
|
||
// per-frame observer landblock — a CurrCell flicker must not unload the dungeon.
|
||
foreach (var id in _state.LoadedLandblockIds)
|
||
if (id != _collapsedCenter) _enqueueUnload(id);
|
||
}
|
||
|
||
/// <summary>Chebyshev distance in landblock cells between two landblock ids.</summary>
|
||
private static int ChebyshevLandblocks(uint a, uint b)
|
||
{
|
||
int ax = (int)((a >> 24) & 0xFFu), ay = (int)((a >> 16) & 0xFFu);
|
||
int bx = (int)((b >> 24) & 0xFFu), by = (int)((b >> 16) & 0xFFu);
|
||
return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
|
||
}
|
||
|
||
/// <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);
|
||
foreach (var result in drained)
|
||
{
|
||
switch (result)
|
||
{
|
||
case LandblockStreamResult.Loaded loaded:
|
||
_applyTerrain(loaded.Landblock, loaded.MeshData);
|
||
_state.AddLandblock(loaded.Landblock);
|
||
break;
|
||
case LandblockStreamResult.Promoted promoted:
|
||
_applyTerrain(promoted.Landblock, promoted.MeshData);
|
||
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
|
||
break;
|
||
case LandblockStreamResult.Unloaded unloaded:
|
||
_state.RemoveLandblock(unloaded.LandblockId);
|
||
_removeTerrain?.Invoke(unloaded.LandblockId);
|
||
break;
|
||
case LandblockStreamResult.Failed failed:
|
||
Console.WriteLine(
|
||
$"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}");
|
||
break;
|
||
case LandblockStreamResult.WorkerCrashed crashed:
|
||
Console.WriteLine(
|
||
$"streaming: worker CRASHED: {crashed.Error}");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|