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

@ -209,13 +209,17 @@ public sealed class LightManager
/// <see cref="BuildPointLightSnapshot"/>.
/// </summary>
public IReadOnlyList<LightSource> PointSnapshot => _pointSnapshot;
internal bool LastPointSnapshotUsedBoundedSelection { get; private set; }
internal bool LastPointSnapshotUsedTieFallback { get; private set; }
// Pool-sort state for BuildPointLightSnapshot: the comparison delegate is
// cached (allocated once) and reads the anchor from a field so the per-frame
// over-cap sort allocates nothing beyond List.Sort's own wrapper — the same
// profile as the previous static-lambda sort (MP-Alloc discipline).
private Vector3 _poolAnchor;
private Comparison<LightSource>? _poolComparison;
// Slice H-b: keep only the best MaxGlobalLights entries in a retained
// max-heap. Rank includes qualifying registration order because retail
// insert_light (0x0054D1B0) advances past equal-distance residents.
private readonly List<RankedLight> _pointSelectionHeap =
new(MaxGlobalLights);
private Comparison<RankedLight>? _rankComparison;
private Vector3 _legacyPoolAnchor;
private Comparison<LightSource>? _legacyPoolComparison;
/// <summary>
/// Rebuild <see cref="PointSnapshot"/> from ALL registered lit point/spot
@ -278,26 +282,87 @@ public sealed class LightManager
public void BuildPointLightSnapshot(Vector3 playerWorldPos, IReadOnlySet<uint>? visibleCells = null)
{
_pointSnapshot.Clear();
_pointSelectionHeap.Clear();
int qualifyingOrdinal = 0;
bool overflow = false;
LastPointSnapshotUsedBoundedSelection = false;
LastPointSnapshotUsedTieFallback = false;
foreach (var light in _all)
{
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
if (visibleCells is not null && light.CellId != 0 && !visibleCells.Contains(light.CellId)) continue;
var ranked = new RankedLight(
light,
qualifyingOrdinal++,
Vector3.DistanceSquared(
light.WorldPosition,
playerWorldPos));
_pointSnapshot.Add(light);
}
if (_pointSnapshot.Count > MaxGlobalLights)
{
_poolAnchor = playerWorldPos;
_poolComparison ??= (a, b) =>
if (_pointSnapshot.Count <= MaxGlobalLights)
continue;
if (!overflow)
{
// Dynamics-first mirrors retail's separate dynamic pool; ties by
// player distance mirror insert_light's player-nearest sort.
if (a.IsDynamic != b.IsDynamic) return a.IsDynamic ? -1 : 1;
float da = (a.WorldPosition - _poolAnchor).LengthSquared();
float db = (b.WorldPosition - _poolAnchor).LengthSquared();
return da.CompareTo(db);
};
_pointSnapshot.Sort(_poolComparison);
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
for (int index = 0;
index < MaxGlobalLights;
index++)
{
LightSource existing = _pointSnapshot[index];
_pointSelectionHeap.Add(new RankedLight(
existing,
index,
Vector3.DistanceSquared(
existing.WorldPosition,
playerWorldPos)));
}
HeapifyWorstFirst(_pointSelectionHeap);
overflow = true;
}
// Root is the currently-worst selected rank. A later light at the
// same distance ranks after an earlier resident, matching retail.
if (CompareRankedLights(ranked, _pointSelectionHeap[0]) < 0)
{
_pointSelectionHeap[0] = ranked;
SiftWorstDown(_pointSelectionHeap, 0);
}
}
if (overflow)
{
_rankComparison ??= CompareRankedLights;
_pointSelectionHeap.Sort(_rankComparison);
bool comparatorTie =
SelectedRanksContainObservableTie(playerWorldPos);
if (comparatorTie)
{
LastPointSnapshotUsedTieFallback = true;
// The previous List.Sort comparator intentionally returned zero
// for equal pool/distance ranks. List.Sort is unstable, so its
// exact tie permutation is observable in shader indices. Keep
// that legacy oracle for tied frames; H-b is performance-only.
// AP-85's eventual dual-pool port can adopt retail's stable tie
// insertion as a separately visual-gated behavior change.
_legacyPoolAnchor = playerWorldPos;
_legacyPoolComparison ??= CompareLegacyPoolLights;
_pointSnapshot.Sort(_legacyPoolComparison);
_pointSnapshot.RemoveRange(
MaxGlobalLights,
_pointSnapshot.Count - MaxGlobalLights);
}
else
{
LastPointSnapshotUsedBoundedSelection = true;
_pointSnapshot.Clear();
for (int index = 0;
index < _pointSelectionHeap.Count;
index++)
{
_pointSnapshot.Add(
_pointSelectionHeap[index].Light);
}
}
}
// A7.L1 SET-COMPOSITION probe. Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1;
@ -306,6 +371,117 @@ public sealed class LightManager
AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(_all, _pointSnapshot);
}
private static int CompareRankedLights(
RankedLight left,
RankedLight right)
{
if (left.Light.IsDynamic != right.Light.IsDynamic)
return left.Light.IsDynamic ? -1 : 1;
int distance = left.DistanceSq.CompareTo(right.DistanceSq);
return distance != 0
? distance
: left.QualifyingOrdinal.CompareTo(right.QualifyingOrdinal);
}
private bool SelectedRanksContainObservableTie(Vector3 playerWorldPos)
{
for (int index = 1;
index < _pointSelectionHeap.Count;
index++)
{
if (HaveSameLegacyRank(
_pointSelectionHeap[index - 1],
_pointSelectionHeap[index]))
{
return true;
}
}
// A tie may straddle the cap with only one copy in the selected heap.
// Such a tie can change which light the old unstable List.Sort kept.
RankedLight cutoff = _pointSelectionHeap[^1];
int cutoffMatches = 0;
for (int index = 0; index < _pointSnapshot.Count; index++)
{
LightSource light = _pointSnapshot[index];
if (light.IsDynamic != cutoff.Light.IsDynamic)
continue;
float distance = Vector3.DistanceSquared(
light.WorldPosition,
playerWorldPos);
if (distance.CompareTo(cutoff.DistanceSq) != 0)
continue;
if (++cutoffMatches > 1)
return true;
}
return false;
}
private static bool HaveSameLegacyRank(
RankedLight left,
RankedLight right) =>
left.Light.IsDynamic == right.Light.IsDynamic
&& left.DistanceSq.CompareTo(right.DistanceSq) == 0;
private int CompareLegacyPoolLights(
LightSource left,
LightSource right)
{
if (left.IsDynamic != right.IsDynamic)
return left.IsDynamic ? -1 : 1;
float leftDistance = Vector3.DistanceSquared(
left.WorldPosition,
_legacyPoolAnchor);
float rightDistance = Vector3.DistanceSquared(
right.WorldPosition,
_legacyPoolAnchor);
return leftDistance.CompareTo(rightDistance);
}
private static void HeapifyWorstFirst(List<RankedLight> heap)
{
for (int index = heap.Count / 2 - 1;
index >= 0;
index--)
{
SiftWorstDown(heap, index);
}
}
private static void SiftWorstDown(
List<RankedLight> heap,
int index)
{
while (true)
{
int left = checked(index * 2 + 1);
if (left >= heap.Count)
return;
int right = left + 1;
int worse = right < heap.Count
&& CompareRankedLights(heap[right], heap[left]) > 0
? right
: left;
if (CompareRankedLights(heap[worse], heap[index]) <= 0)
return;
(heap[index], heap[worse]) =
(heap[worse], heap[index]);
index = worse;
}
}
private readonly record struct RankedLight(
LightSource Light,
int QualifyingOrdinal,
float DistanceSq);
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
// Retail adds a white fill light pinned to the player EVERY frame via
// Render::add_dynamic_light. It is the dominant INTERIOR fill: the outdoor