From a28a69af71421f86c1315a5147d74a4739683e7c Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 18 Apr 2026 17:09:51 +0200 Subject: [PATCH] feat(lighting): Phase G.2 LightSource + LightManager (data + selection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail-faithful 8-light cap selection (r13 §12) — the fixed-function D3D pipeline's "hardware lights" constraint carried over to modern GL via UBO-per-draw. Core layer (AcDream.Core/Lighting): - LightSource: Kind (Directional/Point/Spot), WorldPosition, WorldForward, ColorLinear, Intensity, Range (hard cutoff), ConeAngle (spot), OwnerId (entity attachment), IsLit latch. - CellAmbientState: (AmbientColor, SunColor, SunDirection) sourced from R12 sky state for outdoor cells or EnvCell dat for indoor cells. - LightManager: Register/Unregister/UnregisterByOwner/Clear + Tick per frame. Selection matches r13 §12.2 exactly: 1) Skip unlit + directional. 2) Compute DistSq for every registered point/spot. 3) Drop lights outside Range² * 1.1 (10% slack prevents pop). 4) Sort by DistSq ascending; take up to 7 (slot 0 reserved for Sun). 5) Slot 0 = Sun (Directional); slots 1..7 = nearest in-range. Tests (9 new): - Register/Unregister/Idempotent register. - Tick picks top 8 by distance when 12 registered. - Range filter drops far lights (5.0 range, 20m away). - Range slack includes lights at exactly the boundary. - Sun reserved at slot 0 across ticks. - Unlit lights excluded; toggling IsLit brings them back. - UnregisterByOwner removes all owner's lights. - DistSq updated each tick for viewer movement. Build green, 596 tests pass (up from 587). Next: wire LightManager into the shader UBO pass (G.2 second commit) and feed Sun from WorldTimeService.CurrentSunDirection per frame. Ref: r13 §10.2 (D3D attenuation = none inside Range + hard cutoff), §12 (full port plan). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Lighting/LightManager.cs | 138 +++++++++++++++++ src/AcDream.Core/Lighting/LightSource.cs | 63 ++++++++ .../Lighting/LightManagerTests.cs | 139 ++++++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 src/AcDream.Core/Lighting/LightManager.cs create mode 100644 src/AcDream.Core/Lighting/LightSource.cs create mode 100644 tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs new file mode 100644 index 0000000..a9ba8df --- /dev/null +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -0,0 +1,138 @@ +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; + } +} diff --git a/src/AcDream.Core/Lighting/LightSource.cs b/src/AcDream.Core/Lighting/LightSource.cs new file mode 100644 index 0000000..7bfb7d4 --- /dev/null +++ b/src/AcDream.Core/Lighting/LightSource.cs @@ -0,0 +1,63 @@ +using System; +using System.Numerics; + +namespace AcDream.Core.Lighting; + +/// +/// Retail light-source kinds (r13 §12.1). +/// +public enum LightKind +{ + Directional = 0, // sun, moon — no position, infinite range + Point = 1, // torch, fireplace, spell aura + Spot = 2, // cone-shaped (rare in AC, used for a few specific lamps) +} + +/// +/// Per-frame light record. Used by and fed to +/// the shader UBO on every draw call. +/// +/// +/// Retail semantics (r13 §10.2): +/// +/// +/// Hard cutoff at — no smoothstep, no distance +/// attenuation inside the range. "Crisp bubble of illumination." +/// +/// +/// Max 8 active lights (), +/// ranked by distance-to-viewer. Slot 0 is reserved for the sun. +/// +/// +/// latches the SetLightHook value so +/// animations can toggle a light on/off (torch being lit, light +/// crystal activating). +/// +/// +/// +/// +public sealed class LightSource +{ + public LightKind Kind; + public Vector3 WorldPosition; + public Vector3 WorldForward; // for Spot/Directional + public Vector3 ColorLinear = Vector3.One; // R,G,B in [0,1], pre-brightness + public float Intensity = 1f; + public float Range = 10f; // metres, hard cutoff + public float ConeAngle = 0f; // radians, Spot only + public uint OwnerId; // attached entity id; 0 = world-global + public bool IsLit = true; // SetLightHook latch + + // Cached each frame by LightManager. + public float DistSq; +} + +/// +/// Per-cell ambient + sun state. For outdoor cells this comes from the +/// R12 sky state; for indoor cells the EnvCell dat carries a per-cell +/// ambient override (r13 §3). +/// +public readonly record struct CellAmbientState( + Vector3 AmbientColor, + Vector3 SunColor, + Vector3 SunDirection); diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs new file mode 100644 index 0000000..9df68a2 --- /dev/null +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -0,0 +1,139 @@ +using System.Numerics; +using AcDream.Core.Lighting; +using Xunit; + +namespace AcDream.Core.Tests.Lighting; + +public sealed class LightManagerTests +{ + private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true) + => new LightSource + { + Kind = LightKind.Point, + WorldPosition = pos, + Range = range, + IsLit = lit, + OwnerId = ownerId, + }; + + [Fact] + public void Register_Unregister_TracksList() + { + var mgr = new LightManager(); + var a = MakePoint(Vector3.Zero, 5f); + var b = MakePoint(new Vector3(10, 0, 0), 5f); + mgr.Register(a); + mgr.Register(b); + Assert.Equal(2, mgr.RegisteredCount); + + mgr.Unregister(a); + Assert.Equal(1, mgr.RegisteredCount); + } + + [Fact] + public void Register_DuplicateInstance_Idempotent() + { + var mgr = new LightManager(); + var light = MakePoint(Vector3.Zero, 5f); + mgr.Register(light); + mgr.Register(light); + Assert.Equal(1, mgr.RegisteredCount); + } + + [Fact] + public void Tick_SelectsByDistance_Top8() + { + var mgr = new LightManager(); + // 12 lights at varying distances, all with range 100 so none filter out. + for (int i = 0; i < 12; i++) + mgr.Register(MakePoint(new Vector3(i, 0, 0), 100f)); + + mgr.Tick(viewerWorldPos: Vector3.Zero); + + Assert.Equal(8, mgr.ActiveCount); + // Top 8 should be the closest (i=0..7). + foreach (var l in mgr.Active) + { + Assert.NotNull(l); + Assert.True(l!.WorldPosition.X <= 7f); + } + } + + [Fact] + public void Tick_DropsLightsOutsideRangeWithSlack() + { + var mgr = new LightManager(); + mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // far outside its own range + + mgr.Tick(viewerWorldPos: Vector3.Zero); + + Assert.Equal(0, mgr.ActiveCount); + } + + [Fact] + public void Tick_IncludesLightsNearRangeEdge_WithSlack() + { + var mgr = new LightManager(); + // Light at distance 5.0, range 5.0: distSq=25, rangeSq*1.1^2 = 25*1.21 = 30.25 → included. + mgr.Register(MakePoint(new Vector3(5, 0, 0), range: 5f)); + + mgr.Tick(viewerWorldPos: Vector3.Zero); + Assert.Equal(1, mgr.ActiveCount); + } + + [Fact] + public void Tick_SunSlot0_PreservedAcrossTicks() + { + var mgr = new LightManager(); + var sun = new LightSource { Kind = LightKind.Directional, WorldForward = -Vector3.UnitZ }; + mgr.Sun = sun; + + mgr.Register(MakePoint(Vector3.Zero, 100f)); + mgr.Tick(Vector3.Zero); + + Assert.Equal(2, mgr.ActiveCount); + Assert.Same(sun, mgr.Active[0]); + } + + [Fact] + public void Tick_UnlitLight_Excluded() + { + var mgr = new LightManager(); + var light = MakePoint(Vector3.Zero, 100f, lit: false); + mgr.Register(light); + + mgr.Tick(Vector3.Zero); + Assert.Equal(0, mgr.ActiveCount); + + // Toggle lit: should now appear. + light.IsLit = true; + mgr.Tick(Vector3.Zero); + Assert.Equal(1, mgr.ActiveCount); + } + + [Fact] + public void UnregisterByOwner_RemovesAttachedLights() + { + var mgr = new LightManager(); + mgr.Register(MakePoint(Vector3.Zero, 5f, ownerId: 42)); + mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, ownerId: 42)); + mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, ownerId: 99)); + + mgr.UnregisterByOwner(42); + Assert.Equal(1, mgr.RegisteredCount); + } + + [Fact] + public void DistSq_UpdatedEachTick() + { + var mgr = new LightManager(); + var light = MakePoint(new Vector3(3, 0, 4), 10f); // dist 5 + mgr.Register(light); + + mgr.Tick(Vector3.Zero); + Assert.Equal(25f, light.DistSq, 2); + + mgr.Tick(new Vector3(3, 0, 0)); // same x, same y, z diff 4 + Assert.Equal(16f, light.DistSq, 2); + } +}