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