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 "Tick" steps): /// /// /// Recompute DistSq from viewer to every registered /// point/spot light. /// /// /// Drop lights outside Range² * 1.1 (10% slack prevents /// pop as we walk across the boundary). /// /// /// Rank remaining lights by DistSq ascending. Pick top 7. /// /// /// Reserve slot 0 for the sun (directional, infinite range). /// /// /// /// /// /// Not thread-safe — the render thread owns the light list. /// /// public sealed class LightManager { public const int MaxActiveLights = 8; // D3D parity private const float RangeSlack = 1.1f; // 10% hysteresis around hard cutoff 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) { // Pass 1: compute DistSq + filter out lights outside the slack radius. var candidates = new List(_all.Count); foreach (var light in _all) { if (!light.IsLit) continue; if (light.Kind == LightKind.Directional) { // Directional lights don't participate in this ranking — // the sun is always slot 0. continue; } Vector3 delta = light.WorldPosition - viewerWorldPos; light.DistSq = delta.LengthSquared(); float rangeSq = light.Range * light.Range * RangeSlack * RangeSlack; if (light.DistSq > rangeSq) continue; candidates.Add(light); } // Pass 2: sort by DistSq ascending, take up to 7. candidates.Sort((a, b) => a.DistSq.CompareTo(b.DistSq)); Array.Clear(_active); _activeCount = 0; // Slot 0 = sun when present. if (Sun is not null) { _active[0] = Sun; _activeCount = 1; } int maxPoint = MaxActiveLights - _activeCount; int pointCount = Math.Min(maxPoint, candidates.Count); for (int i = 0; i < pointCount; i++) { _active[_activeCount + i] = candidates[i]; } _activeCount += pointCount; } }