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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue