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:
parent
b3427554c3
commit
a2a1e5916d
5 changed files with 615 additions and 21 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue