fix(portal): synchronize destination presentation state
This commit is contained in:
parent
4b1bceefbb
commit
e95f55f25b
42 changed files with 2815 additions and 288 deletions
|
|
@ -67,6 +67,7 @@ public sealed class GpuWorldState
|
|||
}
|
||||
|
||||
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
|
||||
private readonly Dictionary<uint, LandblockStreamTier> _tierByLandblock = new();
|
||||
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -75,6 +76,12 @@ public sealed class GpuWorldState
|
|||
/// Drained into <see cref="_loaded"/> in <see cref="AddLandblock"/>.
|
||||
/// </summary>
|
||||
private readonly Dictionary<uint, List<WorldEntity>> _pendingByLandblock = new();
|
||||
// Far-to-near promotion can complete before the base far landblock is
|
||||
// published. Preserve its independently-prepared EnvCell geometry ids
|
||||
// alongside the parked entity layer so the later AddLandblock transaction
|
||||
// cannot open the render gate before those shells upload.
|
||||
private readonly Dictionary<uint, HashSet<ulong>> _pendingRenderIdsByLandblock = new();
|
||||
private readonly HashSet<uint> _pendingNearTierLandblocks = new();
|
||||
|
||||
/// <summary>
|
||||
/// Entities that must survive landblock unloads (e.g. the player character).
|
||||
|
|
@ -96,6 +103,14 @@ public sealed class GpuWorldState
|
|||
public event Action<uint, bool>? LiveProjectionVisibilityChanged;
|
||||
|
||||
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
|
||||
public bool IsNearTier(uint landblockId) =>
|
||||
_tierByLandblock.TryGetValue(landblockId, out var tier)
|
||||
&& tier == LandblockStreamTier.Near;
|
||||
public bool IsNearTierOrPending(uint landblockId) =>
|
||||
IsNearTier(landblockId) || _pendingNearTierLandblocks.Contains(landblockId);
|
||||
public bool IsRenderReady(uint landblockId) =>
|
||||
_loaded.ContainsKey(landblockId)
|
||||
&& (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true);
|
||||
public bool IsLiveEntityVisible(uint serverGuid) =>
|
||||
serverGuid != 0 && _visibleLiveGuids.Contains(serverGuid);
|
||||
|
||||
|
|
@ -204,8 +219,17 @@ public sealed class GpuWorldState
|
|||
public int PersistentGuidCount => _persistentGuids.Count;
|
||||
public int PendingVisibilityTransitionCount => _visibilityTransitions.Count;
|
||||
|
||||
public void AddLandblock(LoadedLandblock landblock)
|
||||
public void AddLandblock(
|
||||
LoadedLandblock landblock,
|
||||
IEnumerable<ulong>? additionalRenderIds = null,
|
||||
LandblockStreamTier tier = LandblockStreamTier.Near)
|
||||
{
|
||||
// A stale Far completion must never replace a newer Near entity layer.
|
||||
// StreamingController normally filters it before render-side apply;
|
||||
// keep the state boundary independently monotonic as well.
|
||||
if (tier == LandblockStreamTier.Far && IsNearTier(landblock.LandblockId))
|
||||
return;
|
||||
|
||||
// If pending live entities have been waiting for this landblock,
|
||||
// merge them into the LoadedLandblock record before storing. The
|
||||
// record's Entities field is IReadOnlyList; we replace the whole
|
||||
|
|
@ -222,9 +246,29 @@ public sealed class GpuWorldState
|
|||
_pendingByLandblock.Remove(landblock.LandblockId);
|
||||
}
|
||||
|
||||
HashSet<ulong>? mergedRenderIds = additionalRenderIds is null
|
||||
? null
|
||||
: new HashSet<ulong>(additionalRenderIds);
|
||||
if (_pendingRenderIdsByLandblock.Remove(landblock.LandblockId, out var pendingRenderIds))
|
||||
{
|
||||
mergedRenderIds ??= new HashSet<ulong>();
|
||||
mergedRenderIds.UnionWith(pendingRenderIds);
|
||||
}
|
||||
|
||||
bool pendingNear = _pendingNearTierLandblocks.Remove(landblock.LandblockId);
|
||||
if (pendingNear
|
||||
|| (_tierByLandblock.TryGetValue(landblock.LandblockId, out var currentTier)
|
||||
&& currentTier == LandblockStreamTier.Near))
|
||||
{
|
||||
tier = LandblockStreamTier.Near;
|
||||
}
|
||||
|
||||
_loaded[landblock.LandblockId] = landblock;
|
||||
_tierByLandblock[landblock.LandblockId] = tier;
|
||||
if (_wbSpawnAdapter is not null)
|
||||
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
|
||||
_wbSpawnAdapter.OnLandblockLoaded(
|
||||
_loaded[landblock.LandblockId],
|
||||
mergedRenderIds);
|
||||
|
||||
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
|
||||
// LiveEntityRuntime owns activation for live objects. This static-only
|
||||
|
|
@ -397,10 +441,13 @@ public sealed class GpuWorldState
|
|||
}
|
||||
|
||||
_pendingByLandblock.Remove(canonical);
|
||||
_pendingRenderIdsByLandblock.Remove(canonical);
|
||||
_pendingNearTierLandblocks.Remove(canonical);
|
||||
if (retainedLive.Count > 0)
|
||||
_pendingByLandblock[canonical] = retainedLive;
|
||||
_aabbs.Remove(canonical);
|
||||
|
||||
_tierByLandblock.Remove(canonical);
|
||||
if (_loaded.Remove(canonical))
|
||||
RebuildFlatView();
|
||||
}
|
||||
|
|
@ -612,6 +659,18 @@ public sealed class GpuWorldState
|
|||
// protects against future callers that mirror live projection placement's
|
||||
// cell-resolved-id pattern.
|
||||
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||
// A promotion may have parked its Near layer before the Far base load
|
||||
// exists. Demotion must retire that pending tier just as completely as
|
||||
// an installed tier, while preserving live server projections.
|
||||
_pendingNearTierLandblocks.Remove(canonical);
|
||||
_pendingRenderIdsByLandblock.Remove(canonical);
|
||||
if (_pendingByLandblock.TryGetValue(canonical, out var pending))
|
||||
{
|
||||
pending.RemoveAll(entity => entity.ServerGuid == 0);
|
||||
if (pending.Count == 0)
|
||||
_pendingByLandblock.Remove(canonical);
|
||||
}
|
||||
|
||||
if (!_loaded.TryGetValue(canonical, out var lb)) return;
|
||||
if (_wbSpawnAdapter is not null)
|
||||
_wbSpawnAdapter.OnLandblockUnloaded(canonical);
|
||||
|
|
@ -642,12 +701,7 @@ public sealed class GpuWorldState
|
|||
lb.LandblockId,
|
||||
lb.Heightmap,
|
||||
retainedLive);
|
||||
if (_pendingByLandblock.TryGetValue(canonical, out var pending))
|
||||
{
|
||||
pending.RemoveAll(entity => entity.ServerGuid == 0);
|
||||
if (pending.Count == 0)
|
||||
_pendingByLandblock.Remove(canonical);
|
||||
}
|
||||
_tierByLandblock[canonical] = LandblockStreamTier.Far;
|
||||
RebuildFlatView();
|
||||
}
|
||||
|
||||
|
|
@ -664,7 +718,10 @@ public sealed class GpuWorldState
|
|||
/// callers may pass cell-resolved ids and they will key correctly.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> entities)
|
||||
public bool AddEntitiesToExistingLandblock(
|
||||
uint landblockId,
|
||||
IReadOnlyList<WorldEntity> entities,
|
||||
IEnumerable<ulong>? additionalRenderIds = null)
|
||||
{
|
||||
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
|
||||
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||
|
|
@ -677,14 +734,25 @@ public sealed class GpuWorldState
|
|||
_pendingByLandblock[canonical] = bucket;
|
||||
}
|
||||
bucket.AddRange(entities);
|
||||
return;
|
||||
_pendingNearTierLandblocks.Add(canonical);
|
||||
if (additionalRenderIds is not null)
|
||||
{
|
||||
if (!_pendingRenderIdsByLandblock.TryGetValue(canonical, out var renderIds))
|
||||
{
|
||||
renderIds = new HashSet<ulong>();
|
||||
_pendingRenderIdsByLandblock[canonical] = renderIds;
|
||||
}
|
||||
renderIds.UnionWith(additionalRenderIds);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count);
|
||||
merged.AddRange(lb.Entities);
|
||||
merged.AddRange(entities);
|
||||
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
|
||||
_tierByLandblock[canonical] = LandblockStreamTier.Near;
|
||||
if (_wbSpawnAdapter is not null)
|
||||
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]);
|
||||
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical], additionalRenderIds);
|
||||
|
||||
// C.1.5b: fire DefaultScript for each promoted dat-hydrated entity.
|
||||
// All entities arriving via this path are atlas-tier by construction
|
||||
|
|
@ -697,6 +765,7 @@ public sealed class GpuWorldState
|
|||
}
|
||||
|
||||
RebuildFlatView();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void RebuildFlatView()
|
||||
|
|
|
|||
|
|
@ -12,8 +12,13 @@ namespace AcDream.App.Streaming;
|
|||
/// </summary>
|
||||
public abstract record LandblockStreamJob(uint LandblockId)
|
||||
{
|
||||
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
|
||||
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
|
||||
public sealed record Load(
|
||||
uint LandblockId,
|
||||
LandblockStreamJobKind Kind,
|
||||
ulong Generation = 0) : LandblockStreamJob(LandblockId);
|
||||
public sealed record Unload(
|
||||
uint LandblockId,
|
||||
ulong Generation = 0) : LandblockStreamJob(LandblockId);
|
||||
|
||||
/// <summary>
|
||||
/// Control job: drop every queued (not-yet-started) Load from the worker's
|
||||
|
|
@ -32,7 +37,7 @@ public abstract record LandblockStreamJob(uint LandblockId)
|
|||
/// an unload notification (tells the render thread to release GPU state
|
||||
/// for this landblock id).
|
||||
/// </summary>
|
||||
public abstract record LandblockStreamResult(uint LandblockId)
|
||||
public abstract record LandblockStreamResult(uint LandblockId, ulong Generation)
|
||||
{
|
||||
/// <summary>
|
||||
/// A landblock load completed. <see cref="Tier"/> distinguishes Far
|
||||
|
|
@ -43,15 +48,17 @@ public abstract record LandblockStreamResult(uint LandblockId)
|
|||
uint LandblockId,
|
||||
LandblockStreamTier Tier,
|
||||
LandblockBuild Build,
|
||||
LandblockMeshData MeshData
|
||||
) : LandblockStreamResult(LandblockId)
|
||||
LandblockMeshData MeshData,
|
||||
ulong Generation = 0
|
||||
) : LandblockStreamResult(LandblockId, Generation)
|
||||
{
|
||||
public Loaded(
|
||||
uint landblockId,
|
||||
LandblockStreamTier tier,
|
||||
LoadedLandblock landblock,
|
||||
LandblockMeshData meshData)
|
||||
: this(landblockId, tier, new LandblockBuild(landblock), meshData)
|
||||
LandblockMeshData meshData,
|
||||
ulong generation = 0)
|
||||
: this(landblockId, tier, new LandblockBuild(landblock), meshData, generation)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -69,14 +76,16 @@ public abstract record LandblockStreamResult(uint LandblockId)
|
|||
public sealed record Promoted(
|
||||
uint LandblockId,
|
||||
LandblockBuild Build,
|
||||
LandblockMeshData MeshData
|
||||
) : LandblockStreamResult(LandblockId)
|
||||
LandblockMeshData MeshData,
|
||||
ulong Generation = 0
|
||||
) : LandblockStreamResult(LandblockId, Generation)
|
||||
{
|
||||
public Promoted(
|
||||
uint landblockId,
|
||||
LoadedLandblock landblock,
|
||||
LandblockMeshData meshData)
|
||||
: this(landblockId, new LandblockBuild(landblock), meshData)
|
||||
LandblockMeshData meshData,
|
||||
ulong generation = 0)
|
||||
: this(landblockId, new LandblockBuild(landblock), meshData, generation)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -84,8 +93,13 @@ public abstract record LandblockStreamResult(uint LandblockId)
|
|||
public IReadOnlyList<WorldEntity> Entities => Landblock.Entities;
|
||||
}
|
||||
|
||||
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId);
|
||||
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId);
|
||||
public sealed record Failed(
|
||||
uint LandblockId,
|
||||
string Error,
|
||||
ulong Generation = 0) : LandblockStreamResult(LandblockId, Generation);
|
||||
public sealed record Unloaded(
|
||||
uint LandblockId,
|
||||
ulong Generation = 0) : LandblockStreamResult(LandblockId, Generation);
|
||||
|
||||
/// <summary>
|
||||
/// The worker loop itself crashed with an unhandled exception. Not tied
|
||||
|
|
@ -94,5 +108,5 @@ public abstract record LandblockStreamResult(uint LandblockId)
|
|||
/// than retrying a single landblock later. LandblockId is 0 by
|
||||
/// convention; readers should pattern-match on the type, not the id.
|
||||
/// </summary>
|
||||
public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0);
|
||||
public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0, 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,23 +143,26 @@ public sealed class LandblockStreamer : IDisposable
|
|||
/// <see cref="LandblockStreamResult.Loaded"/> (or
|
||||
/// <see cref="LandblockStreamResult.Failed"/>) to the outbox.
|
||||
/// </summary>
|
||||
public void EnqueueLoad(uint landblockId, LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear)
|
||||
public void EnqueueLoad(
|
||||
uint landblockId,
|
||||
LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear,
|
||||
ulong generation = 0)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}");
|
||||
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind));
|
||||
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind, generation));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-blocking enqueue. The worker posts a
|
||||
/// <see cref="LandblockStreamResult.Unloaded"/> to the outbox.
|
||||
/// </summary>
|
||||
public void EnqueueUnload(uint landblockId)
|
||||
public void EnqueueUnload(uint landblockId, ulong generation = 0)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
|
||||
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId, generation));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -335,7 +338,7 @@ public sealed class LandblockStreamer : IDisposable
|
|||
if (build is null)
|
||||
{
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||
load.LandblockId, "LandblockLoader.Load returned null"));
|
||||
load.LandblockId, "LandblockLoader.Load returned null", load.Generation));
|
||||
break;
|
||||
}
|
||||
var lb = build.Landblock;
|
||||
|
|
@ -345,18 +348,18 @@ public sealed class LandblockStreamer : IDisposable
|
|||
if (promotedMesh is null)
|
||||
{
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||
load.LandblockId, "buildMeshOrNull returned null"));
|
||||
load.LandblockId, "buildMeshOrNull returned null", load.Generation));
|
||||
break;
|
||||
}
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted(
|
||||
load.LandblockId, build, promotedMesh));
|
||||
load.LandblockId, build, promotedMesh, load.Generation));
|
||||
break;
|
||||
}
|
||||
var mesh = _buildMeshOrNull(load.LandblockId, lb);
|
||||
if (mesh is null)
|
||||
{
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||
load.LandblockId, "buildMeshOrNull returned null"));
|
||||
load.LandblockId, "buildMeshOrNull returned null", load.Generation));
|
||||
break;
|
||||
}
|
||||
var tier = load.Kind == LandblockStreamJobKind.LoadFar
|
||||
|
|
@ -375,17 +378,19 @@ public sealed class LandblockStreamer : IDisposable
|
|||
build = new LandblockBuild(lb);
|
||||
}
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
|
||||
load.LandblockId, tier, build, mesh));
|
||||
load.LandblockId, tier, build, mesh, load.Generation));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||
load.LandblockId, ex.ToString()));
|
||||
load.LandblockId, ex.ToString(), load.Generation));
|
||||
}
|
||||
break;
|
||||
|
||||
case LandblockStreamJob.Unload unload:
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded(unload.LandblockId));
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded(
|
||||
unload.LandblockId,
|
||||
unload.Generation));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
|
||||
|
|
@ -17,11 +18,12 @@ namespace AcDream.App.Streaming;
|
|||
/// </summary>
|
||||
public sealed class StreamingController
|
||||
{
|
||||
private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad;
|
||||
private readonly Action<uint> _enqueueUnload;
|
||||
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
|
||||
private readonly Action<uint, ulong> _enqueueUnload;
|
||||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||||
private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain;
|
||||
private readonly Action<uint>? _removeTerrain;
|
||||
private readonly Action<uint>? _demoteNearLayer;
|
||||
private readonly Action? _clearPendingLoads;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -34,8 +36,16 @@ public sealed class StreamingController
|
|||
/// tests that don't exercise re-hydration.
|
||||
/// </summary>
|
||||
private readonly Action<uint>? _onLandblockLoaded;
|
||||
// Invoked only after LandblockSpawnAdapter has pinned every synthetic
|
||||
// EnvCell geometry id. This re-arms the schema-aware preparation path if
|
||||
// an unowned cached mesh was evicted between worker schedule and publish.
|
||||
private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
|
||||
private readonly GpuWorldState _state;
|
||||
private StreamingRegion? _region;
|
||||
// Hard streaming boundaries advance this token. Worker completions carry
|
||||
// the generation captured at enqueue time, so an old overlapping load or
|
||||
// unload cannot mutate the replacement window after a portal recenter.
|
||||
private ulong _generation;
|
||||
|
||||
// True while streaming is collapsed to the single dungeon landblock the
|
||||
// player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
|
||||
|
|
@ -127,6 +137,43 @@ public sealed class StreamingController
|
|||
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
|
||||
public int DeferredApplyBacklog => _deferredApply.Count;
|
||||
|
||||
/// <summary>
|
||||
/// True once every in-bounds landblock in the requested Chebyshev ring has
|
||||
/// crossed the render-thread publication barrier. Worker completion and
|
||||
/// world-state registration are not sufficient: all static GfxObj and
|
||||
/// EnvCell shell meshes must have completed their render-thread upload.
|
||||
/// Portal-space exit uses this alongside physics residency so the world
|
||||
/// cannot be revealed while its render slots are still absent.
|
||||
/// </summary>
|
||||
public bool IsRenderNeighborhoodResident(uint cellOrLandblockId, int radius)
|
||||
{
|
||||
if (radius < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(radius));
|
||||
|
||||
// LandDefs::InboundValidCellId validates both map axes and the low-word
|
||||
// class (outdoor cell, EnvCell, or canonical landblock sentinel).
|
||||
if (!AcDream.Core.Physics.LandDefs.InboundValidCellId(cellOrLandblockId))
|
||||
return false;
|
||||
int cx = (int)((cellOrLandblockId >> 24) & 0xFFu);
|
||||
int cy = (int)((cellOrLandblockId >> 16) & 0xFFu);
|
||||
for (int dx = -radius; dx <= radius; dx++)
|
||||
for (int dy = -radius; dy <= radius; dy++)
|
||||
{
|
||||
int nx = cx + dx;
|
||||
int ny = cy + dy;
|
||||
// Match PhysicsEngine.IsNeighborhoodTerrainResident: the outer
|
||||
// 0xFF coordinate has no loadable neighbour beyond it.
|
||||
if (nx < 0 || nx > 254 || ny < 0 || ny > 254)
|
||||
continue;
|
||||
|
||||
uint canonical = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu;
|
||||
if (!_state.IsNearTier(canonical) || !_state.IsRenderReady(canonical))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// [FRAME-DIAG]: how many times ForceReloadWindow has fired (each one drops +
|
||||
// re-uploads the WHOLE window) and the landblock count it dropped last time.
|
||||
public int ForceReloadCount { get; private set; }
|
||||
|
|
@ -135,8 +182,40 @@ public sealed class StreamingController
|
|||
// Completions that were drained past a priority item get buffered here
|
||||
// so they still apply over subsequent frames without loss.
|
||||
private readonly List<LandblockStreamResult> _deferredApply = new();
|
||||
|
||||
public StreamingController(
|
||||
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
|
||||
Action<uint, ulong> enqueueUnload,
|
||||
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
||||
Action<LandblockBuild, LandblockMeshData> applyTerrain,
|
||||
GpuWorldState state,
|
||||
int nearRadius,
|
||||
int farRadius,
|
||||
Action<uint>? removeTerrain = null,
|
||||
Action<uint>? demoteNearLayer = null,
|
||||
Action? clearPendingLoads = null,
|
||||
Action<uint>? onLandblockLoaded = null,
|
||||
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null)
|
||||
{
|
||||
_enqueueLoad = enqueueLoad;
|
||||
_enqueueUnload = enqueueUnload;
|
||||
_drainCompletions = drainCompletions;
|
||||
_applyTerrain = applyTerrain;
|
||||
_removeTerrain = removeTerrain;
|
||||
_demoteNearLayer = demoteNearLayer;
|
||||
_clearPendingLoads = clearPendingLoads;
|
||||
_onLandblockLoaded = onLandblockLoaded;
|
||||
_ensureEnvCellMeshes = ensureEnvCellMeshes;
|
||||
_state = state;
|
||||
NearRadius = nearRadius;
|
||||
FarRadius = farRadius;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility constructor for deterministic tests and callers that do
|
||||
/// not own an asynchronous worker. Production must use the generation-aware
|
||||
/// overload above so hard-recenter results can be rejected by incarnation.
|
||||
/// </summary>
|
||||
internal StreamingController(
|
||||
Action<uint, LandblockStreamJobKind> enqueueLoad,
|
||||
Action<uint> enqueueUnload,
|
||||
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
||||
|
|
@ -145,19 +224,44 @@ public sealed class StreamingController
|
|||
int nearRadius,
|
||||
int farRadius,
|
||||
Action<uint>? removeTerrain = null,
|
||||
Action<uint>? demoteNearLayer = null,
|
||||
Action? clearPendingLoads = null,
|
||||
Action<uint>? onLandblockLoaded = null)
|
||||
Action<uint>? onLandblockLoaded = null,
|
||||
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null)
|
||||
: this(
|
||||
(id, kind, _) => enqueueLoad(id, kind),
|
||||
(id, _) => enqueueUnload(id),
|
||||
drainCompletions,
|
||||
applyTerrain,
|
||||
state,
|
||||
nearRadius,
|
||||
farRadius,
|
||||
removeTerrain,
|
||||
demoteNearLayer,
|
||||
clearPendingLoads,
|
||||
onLandblockLoaded,
|
||||
ensureEnvCellMeshes)
|
||||
{
|
||||
_enqueueLoad = enqueueLoad;
|
||||
_enqueueUnload = enqueueUnload;
|
||||
_drainCompletions = drainCompletions;
|
||||
_applyTerrain = applyTerrain;
|
||||
_removeTerrain = removeTerrain;
|
||||
_clearPendingLoads = clearPendingLoads;
|
||||
_onLandblockLoaded = onLandblockLoaded;
|
||||
_state = state;
|
||||
NearRadius = nearRadius;
|
||||
FarRadius = farRadius;
|
||||
}
|
||||
|
||||
private void EnqueueLoad(uint id, LandblockStreamJobKind kind) =>
|
||||
_enqueueLoad(id, kind, _generation);
|
||||
|
||||
private void EnqueueUnload(uint id) => _enqueueUnload(id, _generation);
|
||||
|
||||
private void DemoteLandblock(uint id)
|
||||
{
|
||||
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
|
||||
// App-owned Near resources need the still-present static entity list
|
||||
// for exact light/translucency owner cleanup. Retire them before
|
||||
// GpuWorldState drops the static layer and its render pins.
|
||||
_demoteNearLayer?.Invoke(canonical);
|
||||
_state.RemoveEntitiesFromLandblock(canonical);
|
||||
}
|
||||
|
||||
private void AdvanceGeneration()
|
||||
{
|
||||
_generation = unchecked(_generation + 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -263,18 +367,18 @@ public sealed class StreamingController
|
|||
{
|
||||
_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);
|
||||
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);
|
||||
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) DemoteLandblock(id);
|
||||
foreach (var id in diff.ToUnload) EnqueueUnload(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -293,10 +397,12 @@ public sealed class StreamingController
|
|||
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
|
||||
_collapsed = true;
|
||||
_collapsedCenter = centerId;
|
||||
AdvanceGeneration();
|
||||
_clearPendingLoads?.Invoke();
|
||||
_deferredApply.Clear();
|
||||
|
||||
foreach (var id in _state.LoadedLandblockIds)
|
||||
if (id != centerId) _enqueueUnload(id);
|
||||
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.
|
||||
|
|
@ -306,7 +412,9 @@ public sealed class StreamingController
|
|||
// 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);
|
||||
EnqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
|
||||
else if (!_state.IsNearTier(centerId))
|
||||
EnqueueLoad(centerId, LandblockStreamJobKind.PromoteToNear);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -321,7 +429,7 @@ public sealed class StreamingController
|
|||
// 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);
|
||||
if (id != _collapsedCenter) EnqueueUnload(id);
|
||||
}
|
||||
|
||||
/// <summary>Chebyshev distance in landblock cells between two landblock ids.</summary>
|
||||
|
|
@ -354,16 +462,17 @@ public sealed class StreamingController
|
|||
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
|
||||
$"(was collapsed on 0x{_collapsedCenter:X8})");
|
||||
_collapsed = false;
|
||||
AdvanceGeneration();
|
||||
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||||
|
||||
foreach (var id in _state.LoadedLandblockIds)
|
||||
if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id);
|
||||
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);
|
||||
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||||
foreach (var id in boot.ToLoadFar)
|
||||
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||
if (!_state.IsLoaded(id)) EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||
rebuilt.MarkResidentFromBootstrap();
|
||||
_region = rebuilt;
|
||||
}
|
||||
|
|
@ -383,6 +492,9 @@ public sealed class StreamingController
|
|||
public void ForceReloadWindow()
|
||||
{
|
||||
_collapsed = false;
|
||||
AdvanceGeneration();
|
||||
_clearPendingLoads?.Invoke();
|
||||
_deferredApply.Clear();
|
||||
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
|
||||
var ids = new List<uint>(_state.LoadedLandblockIds);
|
||||
ForceReloadCount++; // [FRAME-DIAG] churn counter
|
||||
|
|
@ -424,6 +536,11 @@ public sealed class StreamingController
|
|||
if (chunk.Count == 0) break;
|
||||
foreach (var result in chunk)
|
||||
{
|
||||
// Reject obsolete hard-window work before it can occupy the
|
||||
// metered deferred queue. ApplyResult repeats this invariant
|
||||
// for direct callers and future drain paths.
|
||||
if (IsStaleGeneration(result))
|
||||
continue;
|
||||
if (result is LandblockStreamResult.Unloaded
|
||||
|| IsWithinPriorityRing(ResultLandblockId(result)))
|
||||
ApplyResult(result); // free (unload) or behind-the-fade near ring
|
||||
|
|
@ -449,19 +566,120 @@ public sealed class StreamingController
|
|||
/// </summary>
|
||||
private void ApplyResult(LandblockStreamResult result)
|
||||
{
|
||||
if (IsStaleGeneration(result))
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (result is LandblockStreamResult.Unloaded
|
||||
&& _region?.TryGetDesiredTier(result.LandblockId, out _) == true)
|
||||
{
|
||||
// Normal recenter does not advance the hard-window generation. A
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (result is LandblockStreamResult.Loaded
|
||||
or LandblockStreamResult.Promoted)
|
||||
{
|
||||
if (_region is null
|
||||
|| !_region.TryGetDesiredTier(result.LandblockId, out var desiredTier))
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
bool isNearCompletion = result is LandblockStreamResult.Promoted
|
||||
or LandblockStreamResult.Loaded { Tier: LandblockStreamTier.Near };
|
||||
if (isNearCompletion && _state.IsNearTier(result.LandblockId))
|
||||
{
|
||||
// Normal recenter can enqueue a second high-priority Near job
|
||||
// while an earlier one is already in flight. The streamer
|
||||
// supersedes Far/Unload work but intentionally does not dedupe
|
||||
// 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;
|
||||
}
|
||||
if (desiredTier == LandblockStreamTier.Far && isNearCompletion)
|
||||
{
|
||||
// Recenter can demote a Near job before its first completion.
|
||||
// StreamingRegion already considers that landblock Far, so it
|
||||
// deliberately emits no second LoadFar job. If no terrain is
|
||||
// resident yet, publish the completed heightmap/mesh as a Far
|
||||
// result while discarding its entity and EnvCell layers. This
|
||||
// is the same payload a fresh LoadFar would have produced and
|
||||
// closes the in-flight Near -> desired Far lifecycle without a
|
||||
// duplicate worker read or a permanent terrain hole.
|
||||
if (!_state.IsLoaded(result.LandblockId))
|
||||
{
|
||||
switch (result)
|
||||
{
|
||||
case LandblockStreamResult.Loaded loaded:
|
||||
ApplyAsFar(loaded.Build, loaded.MeshData);
|
||||
break;
|
||||
case LandblockStreamResult.Promoted promoted:
|
||||
ApplyAsFar(promoted.Build, promoted.MeshData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (result)
|
||||
{
|
||||
case LandblockStreamResult.Loaded loaded:
|
||||
if (loaded.Tier == LandblockStreamTier.Far
|
||||
&& _state.IsNearTier(loaded.LandblockId))
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
_applyTerrain(loaded.Build, loaded.MeshData);
|
||||
_state.AddLandblock(loaded.Landblock);
|
||||
_state.AddLandblock(
|
||||
loaded.Landblock,
|
||||
loaded.Build.EnvCells?.Shells.Select(shell => shell.GeometryId),
|
||||
loaded.Tier);
|
||||
EnsureEnvCellMeshesAfterPin(loaded.Build);
|
||||
// #138: after the landblock is in _loaded (so live projection
|
||||
// hot-paths), restore any retained server objects ACE won't
|
||||
// re-send. Fired AFTER AddLandblock, never before.
|
||||
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
|
||||
break;
|
||||
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.
|
||||
_applyTerrain(promoted.Build, promoted.MeshData);
|
||||
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
|
||||
var promotedRenderIds =
|
||||
promoted.Build.EnvCells?.Shells.Select(shell => shell.GeometryId);
|
||||
if (_state.IsLoaded(promoted.LandblockId))
|
||||
{
|
||||
_state.AddEntitiesToExistingLandblock(
|
||||
promoted.LandblockId,
|
||||
promoted.Entities,
|
||||
promotedRenderIds);
|
||||
}
|
||||
else
|
||||
{
|
||||
_state.AddLandblock(
|
||||
promoted.Landblock,
|
||||
promotedRenderIds,
|
||||
LandblockStreamTier.Near);
|
||||
}
|
||||
EnsureEnvCellMeshesAfterPin(promoted.Build);
|
||||
_onLandblockLoaded?.Invoke(promoted.LandblockId);
|
||||
break;
|
||||
case LandblockStreamResult.Unloaded unloaded:
|
||||
|
|
@ -479,6 +697,31 @@ public sealed class StreamingController
|
|||
}
|
||||
}
|
||||
|
||||
private void ApplyAsFar(LandblockBuild completedBuild, LandblockMeshData meshData)
|
||||
{
|
||||
var completed = completedBuild.Landblock;
|
||||
var farLandblock = new LoadedLandblock(
|
||||
completed.LandblockId,
|
||||
completed.Heightmap,
|
||||
Array.Empty<WorldEntity>(),
|
||||
PhysicsDatBundle.Empty);
|
||||
var farBuild = new LandblockBuild(farLandblock);
|
||||
|
||||
_applyTerrain(farBuild, meshData);
|
||||
_state.AddLandblock(farLandblock, tier: LandblockStreamTier.Far);
|
||||
_onLandblockLoaded?.Invoke(farLandblock.LandblockId);
|
||||
}
|
||||
|
||||
private void EnsureEnvCellMeshesAfterPin(LandblockBuild build)
|
||||
{
|
||||
if (build.EnvCells is { Shells.Length: > 0 } envCells)
|
||||
_ensureEnvCellMeshes?.Invoke(envCells);
|
||||
}
|
||||
|
||||
private bool IsStaleGeneration(LandblockStreamResult result) =>
|
||||
result is not LandblockStreamResult.WorkerCrashed
|
||||
&& result.Generation != _generation;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the landblock id associated with <paramref name="result"/>.
|
||||
/// For <see cref="LandblockStreamResult.WorkerCrashed"/> this is 0 by
|
||||
|
|
|
|||
|
|
@ -171,6 +171,25 @@ public sealed class StreamingRegion
|
|||
internal static uint EncodeLandblockIdForTest(int lbX, int lbY)
|
||||
=> EncodeLandblockId(lbX, lbY);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the tier currently owned by this region after bootstrap/recenter
|
||||
/// hysteresis has been applied. Late worker completions use this to avoid
|
||||
/// resurrecting a Near layer after the region has demoted it to Far.
|
||||
/// </summary>
|
||||
internal bool TryGetDesiredTier(uint landblockId, out LandblockStreamTier tier)
|
||||
{
|
||||
if (_tierResidence.TryGetValue(landblockId, out var residence))
|
||||
{
|
||||
tier = residence == TierResidence.Near
|
||||
? LandblockStreamTier.Near
|
||||
: LandblockStreamTier.Far;
|
||||
return true;
|
||||
}
|
||||
|
||||
tier = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two-tier recenter: computes the 5-list diff per Phase A.5 spec §4.2.
|
||||
/// Hysteresis: NearRadius+2 for Near→Far demote; FarRadius+2 for Far→null
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue