using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Lighting;
///
/// 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).
///
///
/// Active-light selection algorithm (r13 §12.2), as implemented by
/// :
///
/// -
/// Reserve slot 0 for the sun (directional, infinite range) when present.
///
/// -
/// For every registered lit point/spot light, recompute DistSq
/// from the viewer and keep the nearest (MaxActiveLights − sunSlot)
/// directly in the active window via an allocation-free insertion
/// partial-select (no per-frame list/sort).
///
///
/// There is deliberately NO viewer-range candidacy filter: each light's
/// own range cutoff is applied PER SURFACE in the shader
/// (mesh_modern.frag: d < range), so a torch the viewer
/// stands outside the range of must still light the wall it sits on. The
/// earlier Range² × 1.1 slack filter wrongly dropped exactly those
/// lights (the #133 "lighting off" report).
///
///
///
/// Not thread-safe — the render thread owns the light list.
///
///
public sealed class LightManager
{
public const int MaxActiveLights = 8; // D3D parity
private readonly List _all = new();
private readonly LightSource?[] _active = new LightSource?[MaxActiveLights];
private int _activeCount;
private LightSource? _viewerLight; // retail SmartBox::viewer_light (see UpdateViewerLight)
/// Current cell ambient state applied to everything.
public CellAmbientState CurrentAmbient { get; set; }
///
/// The sun (or "global directional") — always slot 0 of the active
/// list. Set this from the
/// each frame.
///
public LightSource? Sun { get; set; }
/// Snapshot of the currently-active lights (up to 8).
public ReadOnlySpan Active => _active.AsSpan(0, _activeCount);
public int ActiveCount => _activeCount;
public int RegisteredCount => _all.Count;
/// Add a light. Idempotent — adding the same instance twice is a no-op.
public void Register(LightSource light)
{
ArgumentNullException.ThrowIfNull(light);
foreach (var existing in _all)
if (ReferenceEquals(existing, light)) return;
_all.Add(light);
}
/// Remove by reference.
public void Unregister(LightSource light)
{
_all.Remove(light);
}
/// Remove every light attached to a specific entity.
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
}
///
/// Refresh the active-light list for the current viewer position.
/// Called once per render frame from the render thread; the shader
/// reads and uploads to the light UBO.
///
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.
/// Max point/spot lights any one object can be lit by — retail's
/// D3D fixed-function 8-light cap (minimize_object_lighting). The sun
/// is global, not part of an object's per-object set, so all 8 are point/spot.
public const int MaxLightsPerObject = 8;
/// 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 (CEnvCell::add_dynamic_lights
/// 0x0052d410 walks the static CEnvCell::visible_cell_table — the
/// loaded-cell registry that add_visible_cell 0x0052de40 fills from each
/// activated cell + its dat visible-cell list; NOT the per-frame portal flood)
/// and capped nearest-THE-PLAYER (Render::insert_light 0x0054d1b0 sorts
/// by distance to Render::player_pos) with small caps (7 dynamic + 40
/// static, 0x0081ec94/98). 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 — 's
/// visibleCells 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.
public const int MaxGlobalLights = 128;
private readonly List _pointSnapshot = new();
///
/// 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
/// .
///
public IReadOnlyList 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 _pointSelectionHeap =
new(MaxGlobalLights);
private Comparison? _rankComparison;
private Vector3 _legacyPoolAnchor;
private Comparison? _legacyPoolComparison;
///
/// Rebuild 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).
///
/// Retail anchors (#176 corrected reading, 2026-07-06):
/// CEnvCell::add_dynamic_lights (0x0052d410) walks the WHOLE static
/// CEnvCell::visible_cell_table — the resident-EnvCell registry that
/// CEnvCell::add_visible_cell (0x0052de40) populates from each activated
/// cell plus its dat visible-cell list (it DBObj::Get-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 _all
/// (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.
///
///
/// When more than qualify, DYNAMICS are kept
/// first (retail's dynamic lights live in their own 7-slot pool —
/// Render::add_dynamic_light 0x0054d420 — and never compete with
/// statics), then the nearest THE PLAYER (Render::insert_light
/// 0x0054d1b0 insertion-sorts by squared distance to Render::player_pos,
/// set from player->m_position, 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;
/// c500912b's camera-seeded re-flood — produced the #176 seam-floor
/// purple blink by making the SORT itself camera-dependent). The optional
/// 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.
///
///
/// The player's world position (render position;
/// callers pass the camera position only when no player exists — retail's
/// player/viewer branch).
///
/// A7.L1 (2026-07-09) — optional visible-cell scoping. When non-null, a light
/// is a candidate only if it is cell-less (CellId == 0 — the viewer fill,
/// always in scope) or its CellId 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
/// RetailPViewFrameResult.DrawableCells, 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.
///
public void BuildPointLightSnapshot(Vector3 playerWorldPos, IReadOnlySet? 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 heap)
{
for (int index = heap.Count / 2 - 1;
index >= 0;
index--)
{
SiftWorstDown(heap, index);
}
}
private static void SiftWorstDown(
List 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
///
/// Reposition the always-on viewer fill light at the player (offset +2 m up),
/// registering it on first call. Call once per frame BEFORE
/// / . Mirrors retail's
/// per-frame SmartBox::set_viewer add_dynamic_light; here the light lives
/// in _all 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).
///
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);
}
///
/// Select up to point/spot lights from
/// that reach the object sphere
/// (, ), nearest-first.
/// Faithful to retail's minimize_object_lighting (0x0054d480): a light
/// is a candidate iff its falloff sphere overlaps the object sphere —
/// (light.pos − center)² < (light.Range + radius)² — and when more
/// than 8 candidates qualify, the 8 NEAREST the object centre are kept (the
/// farthest fall off). already folds
/// static_light_factor (1.3), matching the per-vertex cutoff so a
/// selected light always actually contributes in the shader.
///
/// Writes indices INTO to
/// (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.
///
///
public static int SelectForObject(
IReadOnlyList snapshot,
Vector3 center,
float radius,
Span outIndices)
{
int cap = Math.Min(outIndices.Length, MaxLightsPerObject);
if (cap <= 0) return 0;
Span 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;
}
///
/// Per-CELL light selection — retail minimize_envcell_lighting (0x0054c170).
/// Unlike (per-object sphere-overlap cull), retail enables
/// the ENTIRE dynamic subset for EVERY EnvCell it draws (verified by a live cdb trace of
/// config_hardware_light: 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
/// (retail minimize_object_lighting).
///
public static int SelectForCell(
IReadOnlyList snapshot,
Vector3 center,
float radius,
Span 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 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;
}
}