fix #176: light pool tracked the camera via flood scoping - collect residents, anchor at player
The seam-floor purple flicker was NOT a draw z-fight. The in-engine
[seam-*] probe (ACDREAM_PROBE_SEAMDRAW - built because RenderDoc cannot
capture this pipeline: it hides GL_ARB_bindless_texture and the
mandatory-modern startup gate throws; AMD GPU rules out Nsight) killed
every double-draw suspect: ONE shell instance per seam cell at the
lifted z, no floor-coincident entity (portal entities sit at z=-12.05),
zero portal depth fans in the sealed Hub. What it caught instead: the
corridor floor's applied light set flipping wholesale with the flood.
Root cause: c500912b scoped BuildPointLightSnapshot by the per-frame
portal flood, on the research doc's gloss of CEnvCell::visible_cell_table
as "the portal-flood visible set". The named decomp refutes the gloss:
add_visible_cell (0x0052de40) DBObj-LOADS absent cells and inserts them;
a cell activation adds itself + its whole dat visible-cell list
(0x0052e228/0x0052e24a); entries leave only via the flush machinery.
It is the RESIDENT-cell registry - gaze can never remove a cell.
add_dynamic_lights (0x0052d410) walks the WHOLE table per frame
(caller 0x00452d30), and insert_light (0x0054d1b0) caps the pool by
distance to Render::player_pos (0x0054d1dd). Retail's pool is a function
of player position only. Ours followed the camera: turning changed the
flood (probe: 8..41 cells across one turn), the six intensity-100
under-room portal purples entered/left the pool, and the wedge blinked.
Fix: BuildPointLightSnapshot(playerWorldPos) collects ALL registered
(=resident) lit lights; over cap keeps dynamics FIRST (retail's separate
7-slot dynamic pool never competes with statics) then nearest-the-player;
the RebuildScopedLights callback is deleted. Live-verified with the probe:
full-circle turn, flood churning 8..41, the floor set held the same 8
identities on every post-spawn frame. The purple wedge SHAPE stays - it
is cdb-proven retail-faithful.
Residual deviation (AP-85 rewritten): single 128 pool vs retail's
7-dynamic/40-static degrade-scaled dual pools - the Hub now shows
7 purples + viewer where retail's cdb showed 4 + viewer + fixture slots;
if the gate reads the wedge as too purple, the A7 dual-pool cap is the
faithful trim.
Pins: PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant
(rewritten to the corrected model),
PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics,
PointSnapshot_OverCap_KeepsNearestThePlayer,
PointSnapshot_ResidentCollection_CellTagDoesNotFilter.
Suites: Core 2599+2skip / App 726+2skip / UI 425 / Net 385.
The [seam-*] probes stay until the visual gate passes, then strip.
Correction banner added to 2026-07-06-a7-per-cell-lighting-pseudocode.md;
outcome banner on the z-fight handoff; ISSUES #176 updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8cb3176daa
commit
d8984e877f
12 changed files with 525 additions and 194 deletions
|
|
@ -188,53 +188,72 @@ 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.
|
||||
// ── Resident collection (#176 corrected reading, 2026-07-06) ───────────────
|
||||
// Retail collects the pool from ALL RESIDENT EnvCells each frame:
|
||||
// CEnvCell::add_dynamic_lights (0x0052d410) walks the WHOLE static
|
||||
// CEnvCell::visible_cell_table — the loaded-cell registry add_visible_cell
|
||||
// (0x0052de40) fills from each activated cell + its dat visible-cell list. It
|
||||
// is NOT the per-frame portal flood; camera gaze cannot change the pool. The
|
||||
// earlier flood-scoped port (c500912b) made the under-room portal purples
|
||||
// enter/leave the pool as the camera turned — the #176 seam-floor blink.
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_VisibleScope_ExcludesLightsOfNonVisibleCells()
|
||||
public void PointSnapshot_ResidentCollection_CellTagDoesNotFilter()
|
||||
{
|
||||
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
|
||||
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u)); // "visible" room
|
||||
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // under-room
|
||||
mgr.Register(MakePoint(new Vector3(3, 0, 0), 5f, cellId: 0u)); // cell-less (viewer fill)
|
||||
|
||||
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u };
|
||||
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
|
||||
mgr.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
// 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);
|
||||
// ALL resident lights are candidates. The under-room portal light reaching
|
||||
// the corridor's pool is retail-correct — the live cdb capture
|
||||
// (tools/cdb/issue176-floor-light.cdb) showed retail applying the
|
||||
// intensity-100 purples to EVERY Hub cell; the faceted purple wedge is
|
||||
// faithful, only its gaze-coupled blinking was ours.
|
||||
Assert.Equal(3, mgr.PointSnapshot.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_VisibleScope_AlwaysIncludesCellLessGlobals()
|
||||
public void PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics()
|
||||
{
|
||||
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
|
||||
// More statics than the cap, ALL nearer the player than every dynamic.
|
||||
for (int i = 0; i < LightManager.MaxGlobalLights + 20; i++)
|
||||
mgr.Register(MakePoint(new Vector3(i * 0.01f, 0, 0), 5f, ownerId: (uint)(i + 1)));
|
||||
// 7 dynamics farther out (retail's dynamics live in their own 7-slot pool —
|
||||
// Render::add_dynamic_light 0x0054d420 — statics can never crowd them out).
|
||||
var dyns = new LightSource[7];
|
||||
for (int i = 0; i < dyns.Length; i++)
|
||||
{
|
||||
dyns[i] = MakeDynamic(new Vector3(50f + i, 0, 0), range: 9f);
|
||||
mgr.Register(dyns[i]);
|
||||
}
|
||||
|
||||
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u }; // does NOT contain 0102
|
||||
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
|
||||
mgr.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
// 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);
|
||||
Assert.Equal(LightManager.MaxGlobalLights, mgr.PointSnapshot.Count);
|
||||
foreach (var d in dyns)
|
||||
Assert.Contains(d, mgr.PointSnapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_NullScope_KeepsFullPool()
|
||||
public void PointSnapshot_OverCap_KeepsNearestThePlayer()
|
||||
{
|
||||
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));
|
||||
// A big cluster far from the player (where a chase camera might sit) and
|
||||
// one torch beside the player. Retail sorts by distance to
|
||||
// Render::player_pos (insert_light 0x0054d1b0) — the near-player torch
|
||||
// must survive the cap no matter how many far lights exist.
|
||||
for (int i = 0; i < LightManager.MaxGlobalLights + 50; i++)
|
||||
mgr.Register(MakePoint(new Vector3(200f + i * 0.05f, 0, 0), 5f, ownerId: (uint)(i + 1)));
|
||||
var torch = MakePoint(new Vector3(2f, 0, 0), range: 15f, ownerId: 0xF00Du);
|
||||
mgr.Register(torch);
|
||||
|
||||
// Null visible set = outdoor root / no flood → legacy full-pool behaviour.
|
||||
mgr.BuildPointLightSnapshot(Vector3.Zero, visibleCells: null);
|
||||
mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero);
|
||||
|
||||
Assert.Equal(2, mgr.PointSnapshot.Count);
|
||||
Assert.Contains(torch, mgr.PointSnapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -371,59 +390,55 @@ public sealed class LightManagerTests
|
|||
}
|
||||
|
||||
/// <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>.
|
||||
/// #176/#177 (2026-07-06, corrected same day) — the end-state pin. The pool is
|
||||
/// retail's RESIDENT collection anchored at the PLAYER: a light in range of an
|
||||
/// object near the player is selected no matter where a chase camera sits,
|
||||
/// because the camera is not an input to <c>BuildPointLightSnapshot</c> at all
|
||||
/// (the two prior camera-coupled pools — nearest-camera cap, then frame-flood
|
||||
/// scoping <c>c500912b</c> — were each a #176 flicker mechanism). Here the
|
||||
/// player stands by the torch while 400 fixtures cluster 200 m away where a
|
||||
/// camera might look: the torch must always survive the cap and light the
|
||||
/// object. See <c>docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md</c>
|
||||
/// (corrected §1.3) — <c>CEnvCell::visible_cell_table</c> is the resident-cell
|
||||
/// registry, and <c>Render::insert_light</c> (0x0054d1b0) sorts by distance to
|
||||
/// <c>Render::player_pos</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;
|
||||
// 400 fixtures clustered far away (in the direction a camera might sit),
|
||||
// all in another cell. Under either old camera-coupled pool these could
|
||||
// displace or gate the player-side torch; under the player anchor they are
|
||||
// simply the farthest candidates.
|
||||
const uint farRoom = 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));
|
||||
mgr.Register(MakePoint(new Vector3(200f + i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1), cellId: farRoom));
|
||||
|
||||
// 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);
|
||||
// The target torch: beside the player, in the player's room.
|
||||
const uint playerRoom = 0xAAAA0101u;
|
||||
var torch = MakePoint(new Vector3(2f, 0, 0), range: 15f, ownerId: 0xF00DF00Du, cellId: playerRoom);
|
||||
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++;
|
||||
// The player (the ONLY positional input) stands at the origin. Rebuild
|
||||
// twice to mirror consecutive frames of a rotating camera — the pool and
|
||||
// the selection must be identical (no camera input exists to vary).
|
||||
mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero);
|
||||
int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(0f, 0, 0), 6f, sel);
|
||||
bool torchSelected1 = SelectedContains(mgr.PointSnapshot, sel, n1, torch);
|
||||
|
||||
// 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);
|
||||
mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero);
|
||||
int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(0f, 0, 0), 6f, sel);
|
||||
bool torchSelected2 = 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
|
||||
Assert.True(torchSelected1,
|
||||
"an in-range light beside the player was evicted from the pool — " +
|
||||
"per-cell lighting would pop (the #176/#177 mechanism)");
|
||||
Assert.True(torchSelected2, "consecutive same-player builds must select identically");
|
||||
Assert.Equal(LightManager.MaxGlobalLights, mgr.PointSnapshot.Count); // cap applied to the far cluster
|
||||
|
||||
static bool SelectedContains(
|
||||
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue