perf(lighting): allocation-free nearest-N light selection (#133 FPS)

Tick built a new List<>(N) and ran an O(N log N) Sort every frame; in a dungeon
N is thousands of torches, so it allocated a large list per frame (GC pressure ->
FPS). Replace with an insertion partial-select that keeps the nearest maxPoint
directly in the _active window — O(N * maxPoint), maxPoint<=8, zero allocation.
Same selection result (nearest 8); lighting suite 20/20 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-13 21:26:17 +02:00
parent 0fe479ba06
commit 5872bcf075

View file

@ -93,53 +93,66 @@ public sealed class LightManager
/// </summary> /// </summary>
public void Tick(Vector3 viewerWorldPos) public void Tick(Vector3 viewerWorldPos)
{ {
// Pass 1: compute DistSq + filter out lights outside the slack radius. // Retail D3D-style fixed-pipeline lighting takes the nearest (MaxActiveLights-1)
var candidates = new List<LightSource>(_all.Count); // point lights (slot 0 is the sun) and applies each light's hard range cutoff
foreach (var light in _all) // PER SURFACE in the shader (mesh_modern.frag: `if (d < range && range > 1e-3)`),
{ // NOT a viewer-range candidacy filter — a torch the viewer stands outside the
if (!light.IsLit) continue; // range of must still light the wall it sits on.
if (light.Kind == LightKind.Directional) //
{ // Allocation-free partial selection: the old path built `new List<>(N)` and
// Directional lights don't participate in this ranking — // ran an O(N log N) Sort EVERY FRAME; in a dungeon N is thousands of torches,
// the sun is always slot 0. // so that allocated a large list per frame (GC pressure → FPS). Instead keep
continue; // the nearest maxPoint directly in the _active window, maintained sorted by
} // insertion. O(N · maxPoint), maxPoint ≤ 8, zero allocation.
Vector3 delta = light.WorldPosition - viewerWorldPos;
light.DistSq = delta.LengthSquared();
// Retail D3D-style fixed-pipeline lighting picks the 8 NEAREST point
// lights and applies each light's hard range-cutoff PER SURFACE in the
// shader (mesh_modern.frag: `if (d < range && range > 1e-3)`). The
// previous viewer-range candidacy filter (skip when DistSq > Range²·slack²)
// was wrong — it dropped a torch whenever the VIEWER stood outside that
// torch's range, so a dungeon room with 2227 registered torches lit only
// the ~1 the player was standing inside (activeLights≈1, the rest of the
// room at flat 0.2 ambient — the "dungeon lighting off" report). Take the
// nearest 8 regardless of viewer range; the shader's per-fragment
// `d < range` does the actual hard cutoff.
candidates.Add(light);
}
// Pass 2: sort by DistSq ascending, take up to 7.
candidates.Sort((a, b) => a.DistSq.CompareTo(b.DistSq));
Array.Clear(_active); Array.Clear(_active);
_activeCount = 0; _activeCount = 0;
// Slot 0 = sun when present. // Slot 0 = sun when present (directional; never ranked by distance).
int baseSlot = 0;
if (Sun is not null) if (Sun is not null)
{ {
_active[0] = Sun; _active[0] = Sun;
_activeCount = 1; baseSlot = 1;
} }
int maxPoint = MaxActiveLights - _activeCount; int maxPoint = MaxActiveLights - baseSlot;
int pointCount = Math.Min(maxPoint, candidates.Count); int filled = 0;
for (int i = 0; i < pointCount; i++) if (maxPoint > 0)
{ {
_active[_activeCount + i] = candidates[i]; foreach (var light in _all)
{
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
Vector3 delta = light.WorldPosition - viewerWorldPos;
light.DistSq = delta.LengthSquared();
// Maintain _active[baseSlot .. baseSlot+filled) sorted ascending by
// DistSq. Insert if there's room or this light is nearer than the
// current farthest (then the farthest falls off the end).
if (filled < maxPoint)
{
int j = baseSlot + filled;
while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq)
{
_active[j] = _active[j - 1];
j--;
}
_active[j] = light;
filled++;
}
else if (light.DistSq < _active[baseSlot + maxPoint - 1]!.DistSq)
{
int j = baseSlot + maxPoint - 1;
while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq)
{
_active[j] = _active[j - 1];
j--;
}
_active[j] = light;
}
}
} }
_activeCount += pointCount;
_activeCount = baseSlot + filled;
} }
} }