From a80061b0c2a3c9821f126eaf6affdb09a65c9527 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 20:35:01 +0200 Subject: [PATCH] =?UTF-8?q?fix(G.3=20A7):=20dungeon=20lighting=20=E2=80=94?= =?UTF-8?q?=20select=208=20NEAREST=20lights,=20not=20viewer-in-range=20(#1?= =?UTF-8?q?33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active-light selection dropped any point light whose range didn't reach the VIEWER (DistSq > Range^2*slack -> skip). Retail's D3D-style fixed pipeline picks the 8 NEAREST lights and applies the hard range cutoff PER SURFACE in the shader (mesh_modern.frag: if (d < range)). The viewer-range candidacy filter suppressed a torch whenever the player stood outside its range, so a dungeon room with 2227 registered torches lit only the ~1 the player was standing in (activeLights ~= 1, rest of the room at flat 0.2 ambient = the "lighting off" report). Drop the filter; take the nearest 8 regardless of viewer range. Removed the now-unused RangeSlack const; updated the two tests that codified the old filter. Core lighting suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Lighting/LightManager.cs | 13 ++++++++++--- .../Lighting/LightManagerTests.cs | 18 +++++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index a9ba8dfc..98402ac7 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -37,7 +37,6 @@ namespace AcDream.Core.Lighting; 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]; @@ -109,8 +108,16 @@ public sealed class LightManager Vector3 delta = light.WorldPosition - viewerWorldPos; light.DistSq = delta.LengthSquared(); - float rangeSq = light.Range * light.Range * RangeSlack * RangeSlack; - if (light.DistSq > rangeSq) continue; + // Retail D3D-style fixed-pipeline lighting picks the 8 NEAREST point + // lights and applies each light's hard range-cutoff PER SURFACE in the + // shader (mesh_modern.frag: `if (d < range && range > 1e-3)`). The + // previous viewer-range candidacy filter (skip when DistSq > Range²·slack²) + // was wrong — it dropped a torch whenever the VIEWER stood outside that + // torch's range, so a dungeon room with 2227 registered torches lit only + // the ~1 the player was standing inside (activeLights≈1, the rest of the + // room at flat 0.2 ambient — the "dungeon lighting off" report). Take the + // nearest 8 regardless of viewer range; the shader's per-fragment + // `d < range` does the actual hard cutoff. candidates.Add(light); } diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs index 9df68a2b..1bb225a2 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -60,21 +60,29 @@ public sealed class LightManagerTests } [Fact] - public void Tick_DropsLightsOutsideRangeWithSlack() + 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)); // far outside its own range + mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // viewer outside the torch's range mgr.Tick(viewerWorldPos: Vector3.Zero); - Assert.Equal(0, mgr.ActiveCount); + Assert.Equal(1, mgr.ActiveCount); // selected by distance; the shader culls per-surface } [Fact] - public void Tick_IncludesLightsNearRangeEdge_WithSlack() + public void Tick_IncludesNearbyLight() { var mgr = new LightManager(); - // Light at distance 5.0, range 5.0: distSq=25, rangeSq*1.1^2 = 25*1.21 = 30.25 → included. + // 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);