acdream/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs
Erik c500912bf8 feat(lighting): A7 visible-cell light scoping + [indoor-light] probe (NOT the #176/#177 fix)
Port retail's per-frame light collection: the point-light pool is built from ONLY the
currently-visible cells' lights, matching CObjCell::add_*_to_global_lights
(0x0052b350/0x0052b390) walked over CEnvCell::visible_cell_table (0x0052d410) — not a
flat world-space set capped at 128-nearest-camera.

- LightSource.CellId (retail insert_light arg6 -> RenderLight +0x6c); tagged at both
  registration sites from entity.ParentCellId (live weenie fixtures + dat EnvCell statics).
- LightManager.BuildPointLightSnapshot(camPos, visibleCells): a light joins the pool iff
  CellId==0 (viewer/global) or its cell is in the flood. 128 cap kept as a now-non-biting
  backstop (retail's is 40 static + 7 dynamic, 0x0081ec94/8).
- Threaded via RetailPViewDrawContext.RebuildScopedLights, invoked in DrawInside after the
  flood resolves prepareCells and before the draws (renderers select from the same
  in-place-rebuilt PointSnapshot; EnvCellRenderer clears its per-cell cache each pass).
- [indoor-light] probe (ACDREAM_PROBE_INDOOR_LIGHT=1) dumps the scoped-pool SET COMPOSITION.
  Un-skips LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant.

CORRECTION: the handoff called the camera-cap the "confirmed" #176/#177 mechanism. The probe
PROVES scoping works (291 Hub fixtures -> pool of 1-9, ~285 through-floor lights dropped/frame,
CellIds match the flood), but the user's VISUAL GATE showed BOTH symptoms unchanged. So pool
composition is NOT the cause. #176 real cause = an over-bright purple point light
(intensity=100, color 0.784,0,0.784 -- from [light-detail]); #177 = a portal-visibility miss
(stairs not drawn looking back). Both stay OPEN. This change is retail-faithful and retires the
camera-eviction latent bug; kept as such, not as the symptom fix. Register AP-85 corrected;
ISSUES #176/#177 re-diagnosed; render digest banner updated.

Decomp: insert_light 0x0054d1b0, minimize_object_lighting 0x0054d480, calc_point_light
0x0059c8b0; pseudocode docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md.
Suites green: Core 2595 + 2 skip, App 719 + 2 skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 00:35:01 +02:00

374 lines
15 KiB
C#

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, uint cellId = 0)
=> new LightSource
{
Kind = LightKind.Point,
WorldPosition = pos,
Range = range,
IsLit = lit,
OwnerId = ownerId,
CellId = cellId,
};
[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);
}
// ── Visible-cell scoping (retail: add_*_lights over visible_cell_table) ────
// A7 #176/#177: the per-frame pool is built from ONLY the lights of currently-
// visible cells (plus cell-less globals), not a flat world-space set.
[Fact]
public void BuildPointLightSnapshot_VisibleScope_ExcludesLightsOfNonVisibleCells()
{
var mgr = new LightManager();
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u)); // visible cell
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // under-room, NOT visible
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u };
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
// Only the visible cell's light survives — the under-room light can't wash
// through the floor (retail: its cell isn't in visible_cell_table).
Assert.Single(mgr.PointSnapshot);
Assert.Equal(0xAAAA0101u, mgr.PointSnapshot[0].CellId);
}
[Fact]
public void BuildPointLightSnapshot_VisibleScope_AlwaysIncludesCellLessGlobals()
{
var mgr = new LightManager();
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0u)); // viewer/global — CellId 0
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // non-visible cell
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u }; // does NOT contain 0102
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
// The cell-less light (viewer fill) is always a candidate; the non-visible
// cell's light is dropped.
Assert.Single(mgr.PointSnapshot);
Assert.Equal(0u, mgr.PointSnapshot[0].CellId);
}
[Fact]
public void BuildPointLightSnapshot_NullScope_KeepsFullPool()
{
var mgr = new LightManager();
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u));
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u));
// Null visible set = outdoor root / no flood → legacy full-pool behaviour.
mgr.BuildPointLightSnapshot(Vector3.Zero, visibleCells: null);
Assert.Equal(2, mgr.PointSnapshot.Count);
}
[Fact]
public void SelectForObject_EmptySnapshot_ReturnsZero()
{
Span<int> idx = stackalloc int[8];
int n = LightManager.SelectForObject(System.Array.Empty<LightSource>(), 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<int> 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<int> 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<int> 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<int> 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<int> a = stackalloc int[8];
Span<int> 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]);
}
/// <summary>
/// #176/#177 (2026-07-06) — the end-state pin, via the SHIPPED fix (visible-cell
/// scoping, not "uncap"). Before: <c>BuildPointLightSnapshot</c> kept only the
/// <c>MaxGlobalLights</c> nearest THE CAMERA over the WHOLE registered set, so in
/// the Facility Hub (366 fixtures) an in-range torch of a VISIBLE cell could rank
/// past the cap and be evicted → the cell's 8-set (and its Gouraud vertex lighting)
/// flipped as the camera moved (#176 seam flash / #177 stair-room pop-in). The fix
/// is retail's per-frame collection: the pool is built from ONLY the lights of the
/// currently-VISIBLE cells (<c>CObjCell::add_*_to_global_lights</c> over
/// <c>CEnvCell::visible_cell_table</c>), so the visible pool is a handful of cells,
/// the cap never bites, and a visible cell's in-range light is never camera-evicted.
/// The same scoping keeps a NON-visible cell's light out of the pool entirely
/// (through-floor prevention). See <c>docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md</c>.
/// </summary>
[Fact]
public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
{
var mgr = new LightManager();
// 400 fixtures clustered near the origin, all in the UNDER-ROOM cell (not
// visible from the target room). These would have filled every low
// camera-distance rank under the old camera-nearest cap.
const uint underRoom = 0xAAAA0102u;
for (int i = 0; i < 400; i++)
mgr.Register(MakePoint(new Vector3(i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1), cellId: underRoom));
// The target torch: far from the origin-side camera, in the VISIBLE room
// cell, squarely in range of the target object around (200, 0, 0).
const uint targetRoom = 0xAAAA0101u;
var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du, cellId: targetRoom);
mgr.Register(torch);
// The portal flood says only the target room is visible.
var visible = new System.Collections.Generic.HashSet<uint> { targetRoom };
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
// Camera parked at the origin end — the torch must still light the visible cell.
mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero, visible);
int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
bool torchSelectedFar = SelectedContains(mgr.PointSnapshot, sel, n1, torch);
// The 400 under-room lights are NOT in the pool (their cell isn't visible).
int underRoomInPool = 0;
foreach (var l in mgr.PointSnapshot) if (l.CellId == underRoom) underRoomInPool++;
// Camera next to the cell — the reference behaviour.
mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0), visible);
int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
bool torchSelectedNear = SelectedContains(mgr.PointSnapshot, sel, n2, torch);
Assert.True(torchSelectedNear, "sanity: the torch reaches the cell when the camera is beside it");
Assert.True(torchSelectedFar,
"an in-range light of a VISIBLE cell was evicted by the snapshot cap — " +
"per-cell lighting would pop with camera movement (the #176/#177 mechanism)");
Assert.Equal(0, underRoomInPool); // through-floor prevention: non-visible cell's lights excluded
static bool SelectedContains(
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
Span<int> indices, int count, LightSource target)
{
for (int i = 0; i < count; i++)
if (ReferenceEquals(snapshot[indices[i]], target)) return true;
return false;
}
}
}