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>
This commit is contained in:
parent
e1746ca10d
commit
c500912bf8
10 changed files with 516 additions and 35 deletions
|
|
@ -6,7 +6,7 @@ namespace AcDream.Core.Tests.Lighting;
|
|||
|
||||
public sealed class LightManagerTests
|
||||
{
|
||||
private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true)
|
||||
private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true, uint cellId = 0)
|
||||
=> new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
|
|
@ -14,6 +14,7 @@ public sealed class LightManagerTests
|
|||
Range = range,
|
||||
IsLit = lit,
|
||||
OwnerId = ownerId,
|
||||
CellId = cellId,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
|
|
@ -176,6 +177,55 @@ public sealed class LightManagerTests
|
|||
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()
|
||||
{
|
||||
|
|
@ -258,56 +308,59 @@ public sealed class LightManagerTests
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// #176/#177 (2026-07-06): SelectForObject is camera-independent (the test
|
||||
/// above), but the SNAPSHOT it selects from was not — BuildPointLightSnapshot
|
||||
/// kept only the MaxGlobalLights nearest THE CAMERA. In the Facility Hub
|
||||
/// (366 registered fixtures vs the old cap of 128), an in-range torch of a
|
||||
/// VISIBLE cell could rank past the cap and be evicted, so the cell's 8-set
|
||||
/// (and its Gouraud vertex lighting) flipped as the camera moved — per-cell
|
||||
/// lighting pops at seam granularity (#176's flash), and a whole room's
|
||||
/// torches vanishing until approach (#177's pop-in). Retail's
|
||||
/// minimize_object_lighting (0x0054d480) has NO global camera-nearest cap —
|
||||
/// every registered light reaching the object is a candidate. This pins the
|
||||
/// end-to-end property: a light in range of an object stays selected no
|
||||
/// matter where the camera is, at Facility-Hub-scale light counts.
|
||||
/// #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(Skip = "#176/#177: the camera-invariant pool is the DESIRED retail end-state " +
|
||||
"(minimize_object_lighting has no global cap), but uncapping was live-tested " +
|
||||
"2026-07-06 and reverted — it exposes unported per-cell light-reach semantics " +
|
||||
"(through-floor light), the dynamic-vs-static falloff misassignment for weenie " +
|
||||
"fixtures, and an unexplained striped floor artifact. Un-skip when the A7 " +
|
||||
"dungeon-lighting arc lands per-cell registration (insert_light 0x0054d1b0) " +
|
||||
"and raises MaxGlobalLights. See ISSUES #176/#177 + register row AP-85.")]
|
||||
[Fact]
|
||||
public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
|
||||
{
|
||||
var mgr = new LightManager();
|
||||
|
||||
// 400 fixtures clustered near the origin (the "camera side" of the
|
||||
// dungeon) — these fill every low camera-distance rank.
|
||||
// 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)));
|
||||
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 (rank ~401), but
|
||||
// squarely in range of the target cell around (200, 0, 0).
|
||||
var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du);
|
||||
// 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 cell.
|
||||
mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero);
|
||||
// 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 behavior.
|
||||
mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0));
|
||||
// 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 camera-nearest snapshot cap — " +
|
||||
"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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue