perf(lighting): bound global light selection

Replace over-cap full sorting with a retained exact top-k heap while preserving the accepted tie-order fallback. Differential tests lock randomized and Town Network-scale output, and the measured 463-light path cuts selector CPU by 29 percent without warmed allocations.
This commit is contained in:
Erik 2026-07-25 05:40:32 +02:00
parent b3427554c3
commit a2a1e5916d
5 changed files with 615 additions and 21 deletions

View file

@ -257,6 +257,164 @@ public sealed class LightManagerTests
Assert.Contains(torch, mgr.PointSnapshot);
}
[Fact]
public void PointSnapshot_OverCap_EqualDistancesPreserveLegacyOrderExactly()
{
var manager = new LightManager();
var registered = new List<LightSource>();
for (int index = 0;
index < LightManager.MaxGlobalLights + 9;
index++)
{
LightSource light = MakePoint(
new Vector3(3f, 4f, 0f),
range: 10f,
ownerId: checked((uint)index + 1));
registered.Add(light);
manager.Register(light);
}
LightSource[] expected = FullSortOracle(
registered,
Vector3.Zero,
visibleCells: null);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(expected, manager.PointSnapshot);
Assert.True(manager.LastPointSnapshotUsedTieFallback);
Assert.False(manager.LastPointSnapshotUsedBoundedSelection);
manager.BuildPointLightSnapshot(Vector3.Zero);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 25; iteration++)
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(
0,
GC.GetAllocatedBytesForCurrentThread() - before);
}
[Fact]
public void PointSnapshot_BoundedSelectorMatchesCompleteSortRandomized()
{
var random = new Random(0x54D1B0);
for (int scenario = 0; scenario < 64; scenario++)
{
var manager = new LightManager();
var registered = new List<LightSource>();
int count = 180 + random.Next(420);
for (int index = 0; index < count; index++)
{
// Discrete coordinates deliberately create many distance ties.
var position = new Vector3(
random.Next(-12, 13),
random.Next(-12, 13),
random.Next(-3, 4));
LightSource light = MakePoint(
position,
range: 20f,
ownerId: checked((uint)index + 1),
lit: random.Next(13) != 0,
cellId: random.Next(5) == 0
? 0u
: checked((uint)(0xAAAA0100 + random.Next(1, 4))));
light.IsDynamic = random.Next(7) == 0;
if (random.Next(17) == 0)
light.Kind = LightKind.Directional;
registered.Add(light);
manager.Register(light);
}
IReadOnlySet<uint>? visibleCells = scenario % 2 == 0
? new HashSet<uint>
{
0xAAAA0101u,
0xAAAA0103u,
}
: null;
Vector3 player = new(
random.Next(-4, 5),
random.Next(-4, 5),
random.Next(-2, 3));
LightSource[] expected = FullSortOracle(
registered,
player,
visibleCells);
manager.BuildPointLightSnapshot(player, visibleCells);
Assert.Equal(expected, manager.PointSnapshot);
}
}
[Fact]
public void PointSnapshot_TownNetworkScale463_MatchesCompleteSort()
{
var manager = new LightManager();
var registered = new List<LightSource>(463);
const uint fountainRoom = 0x00070144u;
const uint corridor = 0x00070145u;
for (int index = 0; index < 463; index++)
{
LightSource light = MakePoint(
new Vector3(
index + 0.125f,
index * 0.001f,
index * 0.0001f),
range: 15f,
ownerId: checked((uint)index + 1),
cellId: index % 3 == 0
? fountainRoom
: corridor);
light.IsDynamic = index % 61 == 0;
registered.Add(light);
manager.Register(light);
}
IReadOnlySet<uint> visibleCells =
new HashSet<uint> { fountainRoom, corridor };
Vector3 player = new(4.25f, -1.5f, 0.7f);
LightSource[] expected = FullSortOracle(
registered,
player,
visibleCells);
manager.BuildPointLightSnapshot(player, visibleCells);
Assert.Equal(LightManager.MaxGlobalLights, manager.PointSnapshot.Count);
Assert.Equal(expected, manager.PointSnapshot);
Assert.True(manager.LastPointSnapshotUsedBoundedSelection);
Assert.False(manager.LastPointSnapshotUsedTieFallback);
}
[Fact]
public void PointSnapshot_WarmedOverflowPathAllocatesZero()
{
var manager = new LightManager();
for (int index = 0; index < 463; index++)
{
LightSource light = MakePoint(
new Vector3(
index + 0.125f,
index * 0.001f,
index * 0.0001f),
range: 15f,
ownerId: checked((uint)index + 1));
light.IsDynamic = index % 53 == 0;
manager.Register(light);
}
manager.BuildPointLightSnapshot(Vector3.Zero);
manager.BuildPointLightSnapshot(Vector3.Zero);
Assert.True(manager.LastPointSnapshotUsedBoundedSelection);
Assert.False(manager.LastPointSnapshotUsedTieFallback);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 100; iteration++)
manager.BuildPointLightSnapshot(Vector3.Zero);
long allocated =
GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
}
// ── Visible-cell scoping (A7.L1, 2026-07-09 — the Town Network starvation fix) ──
// BuildPointLightSnapshot's player-nearest cap sorts by raw Euclidean distance,
// which is not a reliable proxy for "same room" in a dense, maze-like hub: a
@ -524,4 +682,46 @@ public sealed class LightManagerTests
return false;
}
}
private static LightSource[] FullSortOracle(
IReadOnlyList<LightSource> registered,
Vector3 player,
IReadOnlySet<uint>? visibleCells)
{
var ranked = new List<OracleRank>();
for (int index = 0; index < registered.Count; index++)
{
LightSource light = registered[index];
if (!light.IsLit || light.Kind == LightKind.Directional)
continue;
if (visibleCells is not null
&& light.CellId != 0
&& !visibleCells.Contains(light.CellId))
{
continue;
}
ranked.Add(new OracleRank(
light,
Vector3.DistanceSquared(light.WorldPosition, player)));
}
if (ranked.Count <= LightManager.MaxGlobalLights)
return ranked.Select(static item => item.Light).ToArray();
ranked.Sort(static (left, right) =>
{
if (left.Light.IsDynamic != right.Light.IsDynamic)
return left.Light.IsDynamic ? -1 : 1;
return left.DistanceSq.CompareTo(right.DistanceSq);
});
return ranked
.Take(LightManager.MaxGlobalLights)
.Select(static item => item.Light)
.ToArray();
}
private readonly record struct OracleRank(
LightSource Light,
float DistanceSq);
}