feat(A.5 T14): GpuWorldState RemoveEntitiesFromLandblock + AddEntitiesToExisting

Two new methods on GpuWorldState, used by two-tier streaming (T13):

- RemoveEntitiesFromLandblock(id): drop all entities from an LB while
  keeping the terrain. Used for Near->Far demote (player walks past the
  inner ring; LB stays loaded but entities leave).
- AddEntitiesToExistingLandblock(id, entities): merge new entities into
  an already-loaded LB record. Used for Far->Near promote (terrain is
  already on the GPU; just streaming the entity layer in).

Falls back to the pending bucket if the LB hasn't loaded yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-10 07:53:34 +02:00
parent 774a7070a8
commit fb10c3fa8c
2 changed files with 121 additions and 0 deletions

View file

@ -339,6 +339,51 @@ public sealed class GpuWorldState
bucket.Add(entity); bucket.Add(entity);
} }
/// <summary>
/// Drop all entities from a landblock without removing the terrain. Used
/// by two-tier streaming when a landblock crosses Near→Far hysteresis.
/// Per Phase A.5 spec §4.4.
/// </summary>
public void RemoveEntitiesFromLandblock(uint landblockId)
{
if (!_loaded.TryGetValue(landblockId, out var lb)) return;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(landblockId);
_loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty<WorldEntity>());
_pendingByLandblock.Remove(landblockId);
RebuildFlatView();
}
/// <summary>
/// Merge entities into an existing-loaded landblock. Used by two-tier
/// streaming for the Far→Near promotion case (terrain already loaded;
/// entity layer streaming in). Falls back to the pending bucket if the
/// landblock isn't loaded yet (handles the rare "promote arrives before
/// far load completes" race).
/// Per Phase A.5 spec §4.4.
/// </summary>
public void AddEntitiesToExistingLandblock(uint landblockId, System.Collections.Generic.IReadOnlyList<WorldEntity> entities)
{
if (!_loaded.TryGetValue(landblockId, out var lb))
{
// Park as pending — same pattern as AppendLiveEntity for not-yet-loaded LBs.
if (!_pendingByLandblock.TryGetValue(landblockId, out var bucket))
{
bucket = new List<WorldEntity>();
_pendingByLandblock[landblockId] = bucket;
}
bucket.AddRange(entities);
return;
}
var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count);
merged.AddRange(lb.Entities);
merged.AddRange(entities);
_loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblockId]);
RebuildFlatView();
}
private void RebuildFlatView() private void RebuildFlatView()
{ {
_flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray(); _flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();

View file

@ -0,0 +1,76 @@
using System.Linq;
using AcDream.App.Streaming;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
public class GpuWorldStateTwoTierTests
{
private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities)
=> new(canonicalId, new LandBlock(), entities);
private static WorldEntity MakeStubEntity(uint id)
=> new()
{
Id = id,
SourceGfxObjOrSetupId = 0x01000001u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
[Fact]
public void RemoveEntitiesFromLandblock_KeepsLandblockButDropsEntities()
{
var state = new GpuWorldState();
var lb = MakeStubLandblock(0xAAAAFFFFu,
MakeStubEntity(1),
MakeStubEntity(2));
state.AddLandblock(lb);
Assert.Equal(2, state.Entities.Count);
state.RemoveEntitiesFromLandblock(0xAAAAFFFFu);
Assert.Empty(state.Entities);
Assert.True(state.IsLoaded(0xAAAAFFFFu)); // landblock still resident
}
[Fact]
public void AddEntitiesToExistingLandblock_MergesIntoExistingRecord()
{
var state = new GpuWorldState();
var lb = MakeStubLandblock(0xAAAAFFFFu, MakeStubEntity(1));
state.AddLandblock(lb);
state.AddEntitiesToExistingLandblock(0xAAAAFFFFu, new[]
{
MakeStubEntity(2),
MakeStubEntity(3),
});
Assert.Equal(3, state.Entities.Count);
}
[Fact]
public void AddEntitiesToExistingLandblock_LandblockNotYetLoaded_ParksInPending()
{
var state = new GpuWorldState();
// Landblock not loaded yet.
state.AddEntitiesToExistingLandblock(0xAAAAFFFFu, new[]
{
MakeStubEntity(1),
MakeStubEntity(2),
});
// Nothing in the flat view yet.
Assert.Empty(state.Entities);
Assert.Equal(2, state.PendingLiveEntityCount);
// Now load the landblock — pending entities should merge in.
state.AddLandblock(MakeStubLandblock(0xAAAAFFFFu));
Assert.Equal(2, state.Entities.Count);
}
}