acdream/src/AcDream.Core/Lighting/LightManager.cs
Erik a2a1e5916d 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.
2026-07-25 05:40:32 +02:00

675 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Lighting;
/// <summary>
/// Manages the registered dynamic lights in the world and picks the 8
/// most relevant ones each frame for the shader to consume. Matches
/// retail's fixed-function-era "8 hardware lights" constraint (r13
/// §12.2).
///
/// <para>
/// Active-light selection algorithm (r13 §12.2), as implemented by
/// <see cref="Tick"/>:
/// <list type="number">
/// <item><description>
/// Reserve slot 0 for the sun (directional, infinite range) when present.
/// </description></item>
/// <item><description>
/// For every registered lit point/spot light, recompute <c>DistSq</c>
/// from the viewer and keep the nearest <c>(MaxActiveLights sunSlot)</c>
/// directly in the active window via an allocation-free insertion
/// partial-select (no per-frame list/sort).
/// </description></item>
/// </list>
/// There is deliberately NO viewer-range candidacy filter: each light's
/// own range cutoff is applied PER SURFACE in the shader
/// (<c>mesh_modern.frag</c>: <c>d &lt; range</c>), so a torch the viewer
/// stands outside the range of must still light the wall it sits on. The
/// earlier <c>Range² × 1.1</c> slack filter wrongly dropped exactly those
/// lights (the #133 "lighting off" report).
/// </para>
///
/// <para>
/// Not thread-safe — the render thread owns the light list.
/// </para>
/// </summary>
public sealed class LightManager
{
public const int MaxActiveLights = 8; // D3D parity
private readonly List<LightSource> _all = new();
private readonly LightSource?[] _active = new LightSource?[MaxActiveLights];
private int _activeCount;
private LightSource? _viewerLight; // retail SmartBox::viewer_light (see UpdateViewerLight)
/// <summary>Current cell ambient state applied to everything.</summary>
public CellAmbientState CurrentAmbient { get; set; }
/// <summary>
/// The sun (or "global directional") — always slot 0 of the active
/// list. Set this from the <see cref="AcDream.Core.World.WorldTimeService"/>
/// each frame.
/// </summary>
public LightSource? Sun { get; set; }
/// <summary>Snapshot of the currently-active lights (up to 8).</summary>
public ReadOnlySpan<LightSource?> Active => _active.AsSpan(0, _activeCount);
public int ActiveCount => _activeCount;
public int RegisteredCount => _all.Count;
/// <summary>Add a light. Idempotent — adding the same instance twice is a no-op.</summary>
public void Register(LightSource light)
{
ArgumentNullException.ThrowIfNull(light);
foreach (var existing in _all)
if (ReferenceEquals(existing, light)) return;
_all.Add(light);
}
/// <summary>Remove by reference.</summary>
public void Unregister(LightSource light)
{
_all.Remove(light);
}
/// <summary>Remove every light attached to a specific entity.</summary>
public void UnregisterByOwner(uint ownerId)
{
_all.RemoveAll(l => l.OwnerId == ownerId);
}
public void Clear()
{
_all.Clear();
Array.Clear(_active);
_activeCount = 0;
_viewerLight = null; // re-created + re-registered by the next UpdateViewerLight
}
/// <summary>
/// Refresh the active-light list for the current viewer position.
/// Called once per render frame from the render thread; the shader
/// reads <see cref="Active"/> and uploads to the light UBO.
/// </summary>
public void Tick(Vector3 viewerWorldPos)
{
// 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 (directional; never ranked by distance).
int baseSlot = 0;
if (Sun is not null)
{
_active[0] = Sun;
baseSlot = 1;
}
int maxPoint = MaxActiveLights - baseSlot;
int filled = 0;
if (maxPoint > 0)
{
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 = baseSlot + filled;
}
// ── Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ──
//
// The single global nearest-8-to-VIEWER set above (Tick) is camera-relative:
// a wall's brightness changes as the camera moves because the wall's torches
// swap in/out of that global top-8. Retail instead picks up-to-8 lights PER
// OBJECT by the OBJECT's own position (minimize_object_lighting, 0x0054d480),
// so a torch always lights the wall it sits on, camera-independent. The two
// members below feed the per-instance light path in WbDrawDispatcher; Tick
// remains the source of the legacy single-UBO path + the sun slot.
/// <summary>Max point/spot lights any one object can be lit by — retail's
/// D3D fixed-function 8-light cap (<c>minimize_object_lighting</c>). The sun
/// is global, not part of an object's per-object set, so all 8 are point/spot.</summary>
public const int MaxLightsPerObject = 8;
/// <summary>Hard cap on the per-frame global point-light snapshot the shader
/// indexes. #176 root-cause history (2026-07-06, corrected): retail's pool is
/// collected from ALL RESIDENT EnvCells (<c>CEnvCell::add_dynamic_lights</c>
/// 0x0052d410 walks the static <c>CEnvCell::visible_cell_table</c> — the
/// loaded-cell registry that <c>add_visible_cell</c> 0x0052de40 fills from each
/// activated cell + its dat visible-cell list; NOT the per-frame portal flood)
/// and capped nearest-THE-PLAYER (<c>Render::insert_light</c> 0x0054d1b0 sorts
/// by distance to <c>Render::player_pos</c>) with small caps (7 dynamic + 40
/// static, <c>0x0081ec94/98</c>). Two prior acdream models both flickered
/// because their pool was CAMERA-coupled: (1) nearest-CAMERA-128 over all
/// registered lights (chase-boom swing churned the eviction boundary), then
/// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal
/// purples entered/left the pool as the camera turned — the seam-floor
/// blink; probe: [seam-blk]/[seam-snap]). Current model: all registered
/// (=resident) lit lights optionally FILTERED by last frame's rendered
/// visible-cell set (A7.L1, 2026-07-09 — <see cref="BuildPointLightSnapshot"/>'s
/// <c>visibleCells</c> param; fixes Town Network starvation without
/// reproducing c500912b — see that method's doc), then dynamics-first nearest-
/// player, capped here. 128 is wider than retail's 40+7 — a documented backstop
/// that in a properly cell-scoped room only ever evicts far-out-of-range
/// statics; adopting retail's exact dual-pool caps + degrade levels is A7-arc
/// work. The 1024 uncap remains refuted (striped-floor artifact + the unported
/// static 1/d³ fixture curve, A7 fix #2). Register row AP-85.</summary>
public const int MaxGlobalLights = 128;
private readonly List<LightSource> _pointSnapshot = new();
/// <summary>
/// Per-frame snapshot of lit point/spot lights, stable-indexed for the global
/// shader light buffer and for per-object selection: the index of a light here
/// IS the index the per-instance light-set SSBO references. Built by
/// <see cref="BuildPointLightSnapshot"/>.
/// </summary>
public IReadOnlyList<LightSource> PointSnapshot => _pointSnapshot;
internal bool LastPointSnapshotUsedBoundedSelection { get; private set; }
internal bool LastPointSnapshotUsedTieFallback { get; private set; }
// 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
/// lights — retail's per-frame collection over the RESIDENT-cell registry.
/// The sun and unlit lights are excluded (the sun is global ambient-path;
/// unlit torches contribute nothing).
/// <para>
/// Retail anchors (#176 corrected reading, 2026-07-06):
/// <c>CEnvCell::add_dynamic_lights</c> (0x0052d410) walks the WHOLE static
/// <c>CEnvCell::visible_cell_table</c> — the resident-EnvCell registry that
/// <c>CEnvCell::add_visible_cell</c> (0x0052de40) populates from each activated
/// cell plus its dat visible-cell list (it <c>DBObj::Get</c>-loads absent cells;
/// entries leave only via the flush machinery). It is NOT the per-frame portal
/// flood: camera gaze cannot remove a cell from it. acdream's <c>_all</c>
/// (register at hydration, unregister at unload) is that resident set, so the
/// collection is simply every registered lit light. The under-room portal
/// purples reaching the corridor's pool is retail-correct (cdb: retail applies
/// them to every Hub cell) — the faceted purple wedge is faithful.
/// </para>
/// <para>
/// When more than <see cref="MaxGlobalLights"/> qualify, DYNAMICS are kept
/// first (retail's dynamic lights live in their own 7-slot pool —
/// <c>Render::add_dynamic_light</c> 0x0054d420 — and never compete with
/// statics), then the nearest THE PLAYER (<c>Render::insert_light</c>
/// 0x0054d1b0 insertion-sorts by squared distance to <c>Render::player_pos</c>,
/// set from <c>player-&gt;m_position</c>, SmartBox 0x00453d3a, with the
/// viewer-cell fallback 0x00455ab6). The distance SORT is therefore a function
/// of PLAYER position and light registration ONLY — camera rotation/position
/// cannot change it (both prior camera-ANCHORED pools — nearest-camera cap;
/// <c>c500912b</c>'s camera-seeded re-flood — produced the #176 seam-floor
/// purple blink by making the SORT itself camera-dependent). The optional
/// <paramref name="visibleCells"/> candidacy FILTER (A7.L1) does not change
/// this: it narrows the input set before the player-anchored sort runs, using
/// a value the caller captured from last frame's already-rendered draw list,
/// not a fresh camera-seeded computation performed here. Call once per frame
/// before per-object selection.
/// </para>
/// </summary>
/// <param name="playerWorldPos">The player's world position (render position;
/// callers pass the camera position only when no player exists — retail's
/// player/viewer branch).</param>
/// <param name="visibleCells">
/// A7.L1 (2026-07-09) — optional visible-cell scoping. When non-null, a light
/// is a candidate only if it is cell-less (<c>CellId == 0</c> — the viewer fill,
/// always in scope) or its <c>CellId</c> is in this set. Fixes the Town Network
/// starvation case (463 registered fixtures): the player-nearest cap sorts by
/// raw Euclidean distance, which is not a reliable proxy for "same room" in a
/// dense, maze-like hub — a fixture on the other side of a wall can be
/// geometrically closer than the player's own room's torches and win the cap,
/// leaving the visible room dark. Scoping candidacy to the frame's actual
/// visible cells (the render already computes this — callers pass last frame's
/// <c>RetailPViewFrameResult.DrawableCells</c>, one frame of latency, to avoid
/// re-threading a mid-render callback) removes those from contention before the
/// cap ever applies. The distance-sort anchor stays the PLAYER either way — this
/// parameter only narrows candidacy, it does not change the sort (the #176
/// correction: CAMERA anchoring, not cell scoping itself, caused the earlier
/// seam-floor flicker regression, c500912b). Null (the default) preserves the
/// legacy unscoped behavior — outdoor / no-clipRoot callers pass null.
/// </param>
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)
continue;
if (!overflow)
{
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;
// the flag check keeps it zero-cost off.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled)
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
// stage runs useSunlightSet(1) (sun only — dynamics are NOT enabled), but the
// interior stage runs minimize_envcell_lighting, which enables the dynamic
// lights, so EnvCell walls + indoor objects are lit by this viewer light while
// the player is inside. acdream registered NO dynamic lights at all, so
// interiors had only flat ambient and read dark/cool vs retail's lit rooms.
//
// It rides the existing point-light path: registered in _all ⇒ included in
// BuildPointLightSnapshot ⇒ selected by SelectForObject for nearby cells /
// objects. The AP-43 indoor gate (WbDrawDispatcher.IndoorObjectReceivesTorches)
// already restricts per-object point lights to EnvCell-parented objects, and
// only EnvCellRenderer selects per-cell lights, so the viewer light lights
// ONLY indoor draws — matching retail's interior-stage-only dynamic enable.
// Terrain has no point-light path, so it is unaffected.
//
// Params from the live cdb capture (reference_retail_ambient_values.md):
// intensity 2.25, falloff 10, colour white, offset (0,0,2) above the player
// (SmartBox::set_viewer player branch). Dynamic lights use rangeAdjust 1.5
// (config_hardware_light 0x0059ad30) ⇒ Range = 10 × 1.5 = 15 m.
public const float ViewerLightIntensity = 2.25f; // GRV SmartBox.ViewerLightIntensity
public const float ViewerLightFalloff = 10f; // GRV SmartBox.ViewerLightFalloff
private const uint ViewerLightOwnerId = 0xFFFFFFFFu; // sentinel; never an entity id
/// <summary>
/// Reposition the always-on viewer fill light at the player (offset +2 m up),
/// registering it on first call. Call once per frame BEFORE
/// <see cref="BuildPointLightSnapshot"/> / <see cref="Tick"/>. Mirrors retail's
/// per-frame <c>SmartBox::set_viewer</c> add_dynamic_light; here the light lives
/// in <c>_all</c> and is repositioned so the existing snapshot + per-object
/// selection light the cell around the player. Indoor-only via the AP-43 gate
/// (see the note above).
/// </summary>
public void UpdateViewerLight(Vector3 playerWorldPos)
{
if (_viewerLight is null)
{
_viewerLight = new LightSource
{
Kind = LightKind.Point,
ColorLinear = Vector3.One, // white (1,1,1)
Intensity = ViewerLightIntensity,
Range = ViewerLightFalloff * 1.5f, // dynamic rangeAdjust 1.5
OwnerId = ViewerLightOwnerId,
IsLit = true,
IsDynamic = true, // #143: D3D 1/d attenuation (soft fill, not 1/d³)
};
_all.Add(_viewerLight);
}
_viewerLight.WorldPosition = playerWorldPos + new Vector3(0f, 0f, 2f);
}
/// <summary>
/// Select up to <see cref="MaxLightsPerObject"/> point/spot lights from
/// <paramref name="snapshot"/> that reach the object sphere
/// (<paramref name="center"/>, <paramref name="radius"/>), nearest-first.
/// Faithful to retail's <c>minimize_object_lighting</c> (0x0054d480): a light
/// is a candidate iff its falloff sphere overlaps the object sphere —
/// <c>(light.pos center)² &lt; (light.Range + radius)²</c> — and when more
/// than 8 candidates qualify, the 8 NEAREST the object centre are kept (the
/// farthest fall off). <paramref name="light.Range"/> already folds
/// <c>static_light_factor</c> (1.3), matching the per-vertex cutoff so a
/// selected light always actually contributes in the shader.
/// <para>
/// Writes indices INTO <paramref name="snapshot"/> to
/// <paramref name="outIndices"/> (ascending by distance) and returns the count.
/// Pure + static: camera-INDEPENDENT (depends only on the object centre), so a
/// static object's set is stable and may be computed once. Unit-testable
/// without GL.
/// </para>
/// </summary>
public static int SelectForObject(
IReadOnlyList<LightSource> snapshot,
Vector3 center,
float radius,
Span<int> outIndices)
{
int cap = Math.Min(outIndices.Length, MaxLightsPerObject);
if (cap <= 0) return 0;
Span<float> keptDistSq = stackalloc float[MaxLightsPerObject];
int count = 0;
for (int li = 0; li < snapshot.Count; li++)
{
var light = snapshot[li];
float reach = light.Range + radius;
float dsq = (light.WorldPosition - center).LengthSquared();
if (dsq >= reach * reach) continue; // light's sphere doesn't reach the object
if (count < cap)
{
int j = count;
while (j > 0 && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
count++;
}
else if (dsq < keptDistSq[cap - 1])
{
int j = cap - 1;
while (j > 0 && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
}
}
return count;
}
/// <summary>
/// Per-CELL light selection — retail <c>minimize_envcell_lighting</c> (0x0054c170).
/// Unlike <see cref="SelectForObject"/> (per-object sphere-overlap cull), retail enables
/// the ENTIRE dynamic subset for EVERY EnvCell it draws (verified by a live cdb trace of
/// <c>config_hardware_light</c>: the same 4 intensity-100 portal lights are applied to
/// every Facility Hub cell, every frame). So here: ALL dynamic lights are added
/// unconditionally (the shader's per-light range cutoff zeroes ones that don't reach —
/// same as D3D's hardware range), THEN remaining slots fill with the nearest STATIC lights
/// that reach the cell sphere. This is what makes a cell's floor lighting STABLE as the
/// portal flood shifts — a per-cell sphere-overlap cull of the dynamics is what made the
/// floor lighting FLAP (#176). Objects keep <see cref="SelectForObject"/>
/// (retail minimize_object_lighting).
/// </summary>
public static int SelectForCell(
IReadOnlyList<LightSource> snapshot,
Vector3 center,
float radius,
Span<int> outIndices)
{
int cap = Math.Min(outIndices.Length, MaxLightsPerObject);
if (cap <= 0) return 0;
int count = 0;
// 1) ALL dynamic lights, unconditionally (retail applies the whole dynamic subset to
// every cell — stable regardless of the cell's relation to each light).
for (int li = 0; li < snapshot.Count && count < cap; li++)
if (snapshot[li].IsDynamic)
outIndices[count++] = li;
// 2) Fill remaining slots with the nearest STATIC lights that reach the cell sphere,
// insertion-sorted among the static slots only (dynamic slots [0..staticStart) are fixed).
int staticStart = count;
Span<float> keptDistSq = stackalloc float[MaxLightsPerObject];
for (int li = 0; li < snapshot.Count; li++)
{
var light = snapshot[li];
if (light.IsDynamic) continue; // dynamics already added
float reach = light.Range + radius;
float dsq = (light.WorldPosition - center).LengthSquared();
if (dsq >= reach * reach) continue;
if (count < cap)
{
int j = count;
while (j > staticStart && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
count++;
}
else if (staticStart < cap && dsq < keptDistSq[cap - 1])
{
int j = cap - 1;
while (j > staticStart && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
}
}
return count;
}
}