TWO root causes for "character disappears when walking far": 1. MarkPersistent stored SERVER GUID but RemoveLandblock checked LOCAL entity.Id — different namespaces, never matched. Fixed by adding WorldEntity.ServerGuid field and checking it in RemoveLandblock. 2. Even with rescue working, the player entity stays in its SPAWN landblock's entity list forever. When the player walks to a new landblock and the spawn landblock gets frustum-culled, the entity disappears because neverCullLandblockId is computed from the player's current position (new landblock) but the entity is stored in the old landblock. Fixed by calling GpuWorldState.RelocateEntity every frame in the player-mode update loop. This moves the entity from whatever landblock it's currently in to the one matching its actual position. The scan is O(entities) but only runs for one entity per frame. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
260 lines
10 KiB
C#
260 lines
10 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.App.Streaming;
|
|
|
|
/// <summary>
|
|
/// Render-thread-owned registry of currently-loaded landblocks and their
|
|
/// entities. All mutation happens in <see cref="StreamingController.Tick"/>
|
|
/// on the render thread; the renderer reads <see cref="Entities"/> once per
|
|
/// frame.
|
|
///
|
|
/// <para>
|
|
/// Replaces the pre-streaming flat <c>_entities</c> list. This class is the
|
|
/// single point of truth for "what's in the world right now" and the only
|
|
/// thing that mutates it.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Pending live entities.</b> Live <c>CreateObject</c> spawns can race
|
|
/// against streaming: the server may send a spawn for landblock X before
|
|
/// X is loaded into <see cref="_loaded"/> (frequently true on the first
|
|
/// frame after login, where the entire post-login spawn flood drains
|
|
/// before the streaming controller has finished loading the visible
|
|
/// window). To survive this race, <see cref="AppendLiveEntity"/> stores
|
|
/// orphaned spawns in a per-landblock pending bucket. When
|
|
/// <see cref="AddLandblock"/> later loads the landblock, the matching
|
|
/// pending entries are merged into the loaded record before the flat
|
|
/// view rebuild. <see cref="RemoveLandblock"/> drops pending entries for
|
|
/// the same landblock — if the landblock just left the visible window,
|
|
/// any spawns that came with it are no longer relevant.
|
|
/// </para>
|
|
///
|
|
/// <remarks>
|
|
/// Threading: not thread-safe. All calls must happen on the render thread.
|
|
/// </remarks>
|
|
/// </summary>
|
|
public sealed class GpuWorldState
|
|
{
|
|
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
|
|
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
|
|
|
|
/// <summary>
|
|
/// Per-landblock buffer of live entities awaiting their landblock's
|
|
/// arrival. Keyed by canonical landblock id (<c>0xAAAA0xFFFF</c>).
|
|
/// Drained into <see cref="_loaded"/> in <see cref="AddLandblock"/>.
|
|
/// </summary>
|
|
private readonly Dictionary<uint, List<WorldEntity>> _pendingByLandblock = new();
|
|
|
|
/// <summary>
|
|
/// Entities that must survive landblock unloads (e.g. the player character).
|
|
/// On RemoveLandblock, these are rescued and re-parked as pending for their
|
|
/// current canonical landblock.
|
|
/// </summary>
|
|
private readonly HashSet<uint> _persistentGuids = new();
|
|
|
|
// Cached flat view over all entities across all loaded landblocks,
|
|
// rebuilt on each add/remove. The renderer holds a reference to this
|
|
// list, so rebuilding it replaces the reference atomically.
|
|
private IReadOnlyList<WorldEntity> _flatEntities = System.Array.Empty<WorldEntity>();
|
|
|
|
public IReadOnlyList<WorldEntity> Entities => _flatEntities;
|
|
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys;
|
|
|
|
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
|
|
|
|
/// <summary>
|
|
/// Store the axis-aligned bounding box for a loaded landblock. Called from
|
|
/// the render thread after the terrain mesh is built and uploaded.
|
|
/// </summary>
|
|
public void SetLandblockAabb(uint landblockId, Vector3 min, Vector3 max)
|
|
{
|
|
_aabbs[landblockId] = (min, max);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-landblock iteration with AABB data for use by the frustum-culling
|
|
/// draw path. Landblocks without a stored AABB yield <see cref="Vector3.Zero"/>
|
|
/// for both corners, which the culler will conservatively treat as visible.
|
|
/// </summary>
|
|
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> LandblockEntries
|
|
{
|
|
get
|
|
{
|
|
foreach (var kvp in _loaded)
|
|
{
|
|
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
|
|
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities);
|
|
else
|
|
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Total live entities currently parked in the pending bucket waiting
|
|
/// for their landblock to arrive. Useful diagnostic for verifying the
|
|
/// pending path is doing its job.
|
|
/// </summary>
|
|
public int PendingLiveEntityCount => _pendingByLandblock.Values.Sum(list => list.Count);
|
|
|
|
public void AddLandblock(LoadedLandblock landblock)
|
|
{
|
|
// 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
|
|
// list rather than try to mutate in place.
|
|
if (_pendingByLandblock.TryGetValue(landblock.LandblockId, out var pending) && pending.Count > 0)
|
|
{
|
|
var merged = new List<WorldEntity>(landblock.Entities.Count + pending.Count);
|
|
merged.AddRange(landblock.Entities);
|
|
merged.AddRange(pending);
|
|
landblock = new LoadedLandblock(landblock.LandblockId, landblock.Heightmap, merged);
|
|
_pendingByLandblock.Remove(landblock.LandblockId);
|
|
}
|
|
|
|
_loaded[landblock.LandblockId] = landblock;
|
|
RebuildFlatView();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mark a server-GUID as persistent — this entity survives landblock unloads
|
|
/// and gets re-parked as pending for its current canonical landblock.
|
|
/// </summary>
|
|
public void MarkPersistent(uint serverGuid)
|
|
{
|
|
_persistentGuids.Add(serverGuid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Move a persistent entity from its current landblock slot to a new one.
|
|
/// Called every frame for the player entity so it stays in the landblock
|
|
/// matching its actual position (not its spawn landblock). Without this,
|
|
/// the entity stays in the spawn landblock and gets frustum-culled when
|
|
/// the player walks away.
|
|
/// </summary>
|
|
public void RelocateEntity(WorldEntity entity, uint newCanonicalLb)
|
|
{
|
|
if (entity.ServerGuid == 0) return;
|
|
|
|
// Remove from current landblock (find it by scanning)
|
|
foreach (var kvp in _loaded)
|
|
{
|
|
var entities = kvp.Value.Entities;
|
|
for (int i = 0; i < entities.Count; i++)
|
|
{
|
|
if (ReferenceEquals(entities[i], entity))
|
|
{
|
|
if (kvp.Key == newCanonicalLb) return; // already in the right place
|
|
|
|
// Remove from old
|
|
var newList = new List<WorldEntity>(entities.Count - 1);
|
|
for (int j = 0; j < entities.Count; j++)
|
|
if (j != i) newList.Add(entities[j]);
|
|
_loaded[kvp.Key] = new LoadedLandblock(kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
|
|
|
|
// Add to new (via AppendLiveEntity which handles pending)
|
|
AppendLiveEntity(newCanonicalLb, entity);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public void RemoveLandblock(uint landblockId)
|
|
{
|
|
// Rescue persistent entities before removal. These get appended
|
|
// to the _persistentRescued list; the caller is responsible for
|
|
// re-injecting them (via AppendLiveEntity) into whatever landblock
|
|
// the player is currently on.
|
|
if (_loaded.TryGetValue(landblockId, out var lb))
|
|
{
|
|
foreach (var entity in lb.Entities)
|
|
{
|
|
if (entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid))
|
|
{
|
|
_persistentRescued.Add(entity);
|
|
}
|
|
}
|
|
}
|
|
|
|
_pendingByLandblock.Remove(landblockId);
|
|
_aabbs.Remove(landblockId);
|
|
|
|
if (_loaded.Remove(landblockId))
|
|
RebuildFlatView();
|
|
}
|
|
|
|
private readonly List<WorldEntity> _persistentRescued = new();
|
|
|
|
/// <summary>
|
|
/// Drain entities rescued from unloaded landblocks. The caller should
|
|
/// re-inject each via <see cref="AppendLiveEntity"/> with its current position.
|
|
/// </summary>
|
|
public List<WorldEntity> DrainRescued()
|
|
{
|
|
if (_persistentRescued.Count == 0) return _persistentRescued;
|
|
var result = new List<WorldEntity>(_persistentRescued);
|
|
_persistentRescued.Clear();
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Append an entity to a specific landblock's slot. Used by the live
|
|
/// CreateObject path where the server spawns entities at a server-side
|
|
/// position whose landblock may or may not be loaded yet.
|
|
///
|
|
/// <para>
|
|
/// The server's <c>landblockId</c> is in cell-resolved form
|
|
/// (<c>0xAAAA00CC</c>: high byte X, second byte Y, low 16 bits cell
|
|
/// index within the landblock). The streaming system stores landblocks
|
|
/// keyed by their canonical <c>0xAAAA0xFFFF</c> form. Canonicalize
|
|
/// on the way in so callers don't have to think about it.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Outcome:
|
|
/// <list type="bullet">
|
|
/// <item>If the landblock is already loaded, the entity is appended
|
|
/// to its <c>Entities</c> list and the flat view is rebuilt
|
|
/// immediately.</item>
|
|
/// <item>If the landblock is not yet loaded, the entity is parked
|
|
/// in <see cref="_pendingByLandblock"/> and will be merged
|
|
/// into the next <see cref="AddLandblock"/> for the same id.</item>
|
|
/// </list>
|
|
/// </para>
|
|
/// </summary>
|
|
public void AppendLiveEntity(uint landblockId, WorldEntity entity)
|
|
{
|
|
uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
|
|
|
if (_loaded.TryGetValue(canonicalLandblockId, out var lb))
|
|
{
|
|
// Hot path — landblock is already loaded. Rebuild the record
|
|
// with the new entity appended.
|
|
var newEntities = new List<WorldEntity>(lb.Entities.Count + 1);
|
|
newEntities.AddRange(lb.Entities);
|
|
newEntities.Add(entity);
|
|
_loaded[canonicalLandblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, newEntities);
|
|
RebuildFlatView();
|
|
return;
|
|
}
|
|
|
|
// Cold path — landblock not yet loaded. Park the entity in the
|
|
// pending bucket; AddLandblock will pick it up when the streamer
|
|
// delivers the matching landblock.
|
|
if (!_pendingByLandblock.TryGetValue(canonicalLandblockId, out var bucket))
|
|
{
|
|
bucket = new List<WorldEntity>();
|
|
_pendingByLandblock[canonicalLandblockId] = bucket;
|
|
}
|
|
bucket.Add(entity);
|
|
}
|
|
|
|
private void RebuildFlatView()
|
|
{
|
|
_flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();
|
|
}
|
|
}
|