From 5872bcf075849382757675848555422fdd8ec793 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 21:26:17 +0200 Subject: [PATCH] perf(lighting): allocation-free nearest-N light selection (#133 FPS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.Core/Lighting/LightManager.cs | 89 +++++++++++++---------- 1 file changed, 51 insertions(+), 38 deletions(-) diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index 98402ac7..0f4a73c9 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -93,53 +93,66 @@ public sealed class LightManager /// public void Tick(Vector3 viewerWorldPos) { - // Pass 1: compute DistSq + filter out lights outside the slack radius. - var candidates = new List(_all.Count); - foreach (var light in _all) - { - if (!light.IsLit) continue; - if (light.Kind == LightKind.Directional) - { - // Directional lights don't participate in this ranking — - // the sun is always slot 0. - continue; - } - - 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)); - + // Retail D3D-style fixed-pipeline lighting takes the nearest (MaxActiveLights-1) + // point lights (slot 0 is the sun) and applies each light's hard range cutoff + // 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 + // range of must still light the wall it sits on. + // + // Allocation-free partial selection: the old path built `new List<>(N)` and + // ran an O(N log N) Sort EVERY FRAME; in a dungeon N is thousands of torches, + // so that allocated a large list per frame (GC pressure → FPS). Instead keep + // the nearest maxPoint directly in the _active window, maintained sorted by + // insertion. O(N · maxPoint), maxPoint ≤ 8, zero allocation. Array.Clear(_active); _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) { _active[0] = Sun; - _activeCount = 1; + baseSlot = 1; } - int maxPoint = MaxActiveLights - _activeCount; - int pointCount = Math.Min(maxPoint, candidates.Count); - for (int i = 0; i < pointCount; i++) + int maxPoint = MaxActiveLights - baseSlot; + int filled = 0; + 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; } }