fix(render): A7 Fix B — per-OBJECT point-light selection (minimize_object_lighting)

Outdoor objects brightened as the camera approached: lighting selected the
nearest 8 lights to the VIEWER and fed that one global set to everything
(LightManager.Tick), so a building's wall torches only lit it once the camera
got close enough for them to win the global top-8. Probe confirmed the scale of
the problem: a single Holtburg view registers 129 point lights — the global cap
of 8 was hopeless.

Retail selects up to 8 lights PER OBJECT by the object's own position
(minimize_object_lighting 0x0054d480), so a torch always lights the wall it
sits on, camera-independent. Ported faithfully:

- LightManager.SelectForObject (pure, TDD, 8 new tests): candidacy
  (light.pos − center)² < (Range + radius)², nearest-8 among those. Plus
  BuildPointLightSnapshot for the per-frame stable-indexed light list.
- mesh_modern.vert: two SSBOs — binding=4 GLOBAL point-light array (the
  snapshot), binding=5 per-instance light SET (8 int indices into it, -1 =
  unused), parallel to the binding=0 instance buffer (mirrors the U.3 clip-slot
  mechanism). accumulateLights keeps ambient + sun from the SceneLighting UBO
  (cleared as faithful by the lighting audit) and loops THIS instance's point
  lights. pointContribution factored out (same calc_point_light wrap+norm shape).
- WbDrawDispatcher: per-entity light set computed ONCE at the isNewEntity site
  (constant across the entity's parts), by the entity's AABB sphere; threaded
  into grp.LightSets parallel to grp.Matrices; global + per-instance buffers
  uploaded in Phase 5. Camera-independent ⇒ stable for static buildings.
- GameWindow: BuildPointLightSnapshot + dispatcher.SetSceneLights each frame.

Tests: 17/17 LightManager + 36/36 dispatcher clip-slot/clip-frame green
(parallel-array lockstep preserved). Visually gated: the meeting hall now holds
steady as the camera approaches (was the popping symptom).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 22:47:40 +02:00
parent aa94cedc38
commit 4345e77d62
5 changed files with 473 additions and 50 deletions

View file

@ -144,4 +144,116 @@ public sealed class LightManagerTests
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);
}
[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]);
}
}