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; } }