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>
259 lines
9 KiB
C#
259 lines
9 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)
|
|
=> new LightSource
|
|
{
|
|
Kind = LightKind.Point,
|
|
WorldPosition = pos,
|
|
Range = range,
|
|
IsLit = lit,
|
|
OwnerId = ownerId,
|
|
};
|
|
|
|
[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);
|
|
}
|
|
|
|
[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]);
|
|
}
|
|
}
|