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_SelectsByDistance_RegardlessOfViewerRange() { // Retail D3D-style: candidacy is distance-only (the nearest 8). A torch // lights its OWN surfaces — the shader applies the hard `d < range` cutoff // PER FRAGMENT (mesh_modern.frag) — so a torch the VIEWER is standing // outside the range of is still selected; it lights the wall it sits on. // Replaces the old viewer-range candidacy filter that suppressed it, which // left dungeon rooms (2227 registered torches) at activeLights≈1 / flat 0.2 // ambient — the "dungeon lighting off" report (#133 A7). var mgr = new LightManager(); mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // viewer outside the torch's range mgr.Tick(viewerWorldPos: Vector3.Zero); Assert.Equal(1, mgr.ActiveCount); // selected by distance; the shader culls per-surface } [Fact] public void Tick_IncludesNearbyLight() { var mgr = new LightManager(); // A nearby point light is selected (distance-only candidacy; the shader // applies the per-fragment range cutoff). 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); } // ── Fix B: per-object selection (minimize_object_lighting) ──────────────── [Fact] public void BuildPointLightSnapshot_ExcludesDirectionalAndUnlit() { var mgr = new LightManager(); mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // in mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, lit: false)); // unlit → out mgr.Register(new LightSource { Kind = LightKind.Directional }); // sun → out mgr.BuildPointLightSnapshot(Vector3.Zero); Assert.Single(mgr.PointSnapshot); Assert.Equal(1f, mgr.PointSnapshot[0].WorldPosition.X, 3); } [Fact] public void BuildPointLightSnapshot_IndexStable_InBudget() { var mgr = new LightManager(); // Registration order preserved when under MaxGlobalLights (no sort). mgr.Register(MakePoint(new Vector3(100, 0, 0), 5f)); // far mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // near mgr.BuildPointLightSnapshot(Vector3.Zero); Assert.Equal(2, mgr.PointSnapshot.Count); Assert.Equal(100f, mgr.PointSnapshot[0].WorldPosition.X, 3); // index 0 = first registered Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3); } [Fact] public void SelectForObject_EmptySnapshot_ReturnsZero() { Span idx = stackalloc int[8]; int n = LightManager.SelectForObject(System.Array.Empty(), Vector3.Zero, 1f, idx); Assert.Equal(0, n); } [Fact] public void SelectForObject_InRange_Selected() { var snapshot = new[] { MakePoint(new Vector3(3, 0, 0), range: 5f) }; // dist 3 < range 5 Span idx = stackalloc int[8]; int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); Assert.Equal(1, n); Assert.Equal(0, idx[0]); } [Fact] public void SelectForObject_OutOfRange_Excluded() { // dist 10, range 5, radius 0 → 10 >= 5 → excluded. var snapshot = new[] { MakePoint(new Vector3(10, 0, 0), range: 5f) }; Span idx = stackalloc int[8]; int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); Assert.Equal(0, n); } [Fact] public void SelectForObject_ObjectRadiusExtendsReach() { // dist 7, range 5: out of reach at radius 0, but a radius-3 object sphere // overlaps (7 < 5+3). The whole object catches the light — retail uses the // object's bounding sphere, not its centre point. var snapshot = new[] { MakePoint(new Vector3(7, 0, 0), range: 5f) }; Span idx = stackalloc int[8]; Assert.Equal(0, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx)); Assert.Equal(1, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 3f, idx)); } [Fact] public void SelectForObject_MoreThan8_KeepsNearest8() { // 10 candidate lights all in range; expect the 8 nearest the object centre, // ascending by distance, with the two farthest dropped. var snapshot = new LightSource[10]; for (int i = 0; i < 10; i++) snapshot[i] = MakePoint(new Vector3(i + 1, 0, 0), range: 100f); // dist i+1, all in range Span idx = stackalloc int[8]; int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); Assert.Equal(8, n); // Nearest-first: index 0 (dist 1) … index 7 (dist 8). The two farthest // (indices 8,9 / dist 9,10) are evicted. for (int k = 0; k < 8; k++) Assert.Equal(k, idx[k]); } [Fact] public void SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre() { // Same snapshot, same object centre → identical selection regardless of // where any "camera" is (the method takes no camera). This is the property // that kills the "lights up as I approach" popping. var snapshot = new[] { MakePoint(new Vector3(2, 0, 0), range: 10f), MakePoint(new Vector3(20, 0, 0), range: 10f), // out of reach of centre 0 }; Span a = stackalloc int[8]; Span b = stackalloc int[8]; int na = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, a); int nb = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, b); Assert.Equal(1, na); Assert.Equal(na, nb); Assert.Equal(a[0], b[0]); } }