fix #176 #177: the camera-capped light snapshot evicted visible cells' torches — per-cell lighting popped at seams
The probe launch discriminated it: the user reproduced the purple floor flash while [light] (ambient branch) and [pv-input] (portal flood) read provably healthy — eliminating the last CPU-side theories and exposing the one channel the probes could not see: per-cell 8-light set composition. BuildPointLightSnapshot kept the MaxGlobalLights=128 point lights nearest THE CAMERA; the Facility Hub registers 366 fixtures, so 238 were evicted per frame by camera distance. SelectForObject (faithfully camera-independent, and unit-pinned as such) could only choose from the surviving 128 — an in-range torch of a visible cell that ranked past the cap dropped out of that cell's 8-set, so per-cell Gouraud lighting flipped as the chase boom swung the camera: - #176: the flipping unit is a CELL -> discontinuity lines at exactly cell-seam granularity; a torch-losing floor drops to dim blue-grey stone at 0.2 ambient (the perceived purple), camera-angle dependent. - #177: a stair room whose torches all ranked past the cap rendered at bare 0.2 ambient (near-black = 'not visible'); approach re-admitted them ('pops into existence'); the sweeping boundary dropped the ramp's lights mid-descent ('disappears on the last step'). The geometry never vanished - its lights did. Retail's minimize_object_lighting (0x0054d480) has NO global camera-nearest pool cap (lights register per cell, insert_light 0x0054d1b0). Fix: MaxGlobalLights 128 -> 1024, a non-biting safety valve (GlobalLightPacker grows to fit; 64 B/light). Register row AP-85. TDD pin: PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant (RED at 128 with a Hub-scale 401-light layout, GREEN at 1024). The pre-existing camera-independence pin covered the SELECTOR but not the SNAPSHOT it selects from - the pop re-entered one stage upstream. Suites: Core 2588 / App 719 / UI 425 / Net 385 green. Pending user gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b8e9e204ad
commit
4d25e04d83
5 changed files with 151 additions and 28 deletions
|
|
@ -256,4 +256,60 @@ public sealed class LightManagerTests
|
|||
Assert.Equal(na, nb);
|
||||
Assert.Equal(a[0], b[0]);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
[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.
|
||||
for (int i = 0; i < 400; i++)
|
||||
mgr.Register(MakePoint(new Vector3(i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1)));
|
||||
|
||||
// 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);
|
||||
mgr.Register(torch);
|
||||
|
||||
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);
|
||||
int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
|
||||
bool torchSelectedFar = SelectedContains(mgr.PointSnapshot, sel, n1, torch);
|
||||
|
||||
// Camera next to the cell — the reference behavior.
|
||||
mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0));
|
||||
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 — " +
|
||||
"per-cell lighting would pop with camera movement (the #176/#177 mechanism)");
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue