From 6b70b1201de36b5308db612cc24cde8170b7b311 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 22:24:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(app):=20Phase=20A.1=20=E2=80=94=20GpuWorld?= =?UTF-8?q?State=20render-thread=20entity=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Streaming/GpuWorldState.cs | 75 ++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/AcDream.App/Streaming/GpuWorldState.cs diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs new file mode 100644 index 0000000..1e7702b --- /dev/null +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Linq; +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +/// +/// Render-thread-owned registry of currently-loaded landblocks and their +/// entities. All mutation happens in +/// on the render thread; the renderer reads once per +/// frame. +/// +/// +/// Replaces GameWindow._entities, 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. +/// +/// +/// +/// Threading: not thread-safe. All calls must happen on the render thread. +/// The streaming worker never touches this type. +/// +/// +public sealed class GpuWorldState +{ + private readonly Dictionary _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 _flatEntities = System.Array.Empty(); + + public IReadOnlyList Entities => _flatEntities; + public IReadOnlyCollection 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(); + } + + /// + /// 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. + /// + 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(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(); + } +}