feat(app): Phase A.1 — GpuWorldState render-thread entity registry

Replaces GameWindow._entities flat list with a per-landblock dict
keyed by landblock id. The flat entity view is rebuilt on add/remove
so the renderer keeps its simple "iterate Entities" loop. Also
provides AppendLiveEntity for the CreateObject path that spawns
entities into an already-loaded landblock after hydration.

Not thread-safe — all mutation is render-thread only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 22:24:26 +02:00
parent 495f87a4ad
commit 6b70b1201d

View file

@ -0,0 +1,75 @@
using System.Collections.Generic;
using System.Linq;
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 <c>GameWindow._entities</c>, which was a flat list updated in
/// multiple places. This class is the single point of truth for "what's in
/// the world right now" and the only thing that mutates it.
/// </para>
///
/// <remarks>
/// Threading: not thread-safe. All calls must happen on the render thread.
/// The streaming worker never touches this type.
/// </remarks>
/// </summary>
public sealed class GpuWorldState
{
private readonly Dictionary<uint, LoadedLandblock> _loaded = 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);
public void AddLandblock(LoadedLandblock landblock)
{
_loaded[landblock.LandblockId] = landblock;
RebuildFlatView();
}
public void RemoveLandblock(uint landblockId)
{
if (_loaded.Remove(landblockId))
RebuildFlatView();
}
/// <summary>
/// Append an entity to a specific landblock's slot. Used by the live
/// CreateObject path where the server spawns entities into an already-
/// loaded landblock after the initial hydration pass.
/// </summary>
public void AppendLiveEntity(uint landblockId, WorldEntity entity)
{
if (!_loaded.TryGetValue(landblockId, out var lb))
return;
// LoadedLandblock.Entities is an IReadOnlyList. Rebuild the
// landblock record with the new entity appended. We accept the
// allocation here because live spawns are rare compared to frame
// iteration.
var newEntities = new List<WorldEntity>(lb.Entities.Count + 1);
newEntities.AddRange(lb.Entities);
newEntities.Add(entity);
_loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, newEntities);
RebuildFlatView();
}
private void RebuildFlatView()
{
_flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();
}
}