Retail-faithful 8-light cap selection (r13 §12) — the fixed-function D3D pipeline's "hardware lights" constraint carried over to modern GL via UBO-per-draw. Core layer (AcDream.Core/Lighting): - LightSource: Kind (Directional/Point/Spot), WorldPosition, WorldForward, ColorLinear, Intensity, Range (hard cutoff), ConeAngle (spot), OwnerId (entity attachment), IsLit latch. - CellAmbientState: (AmbientColor, SunColor, SunDirection) sourced from R12 sky state for outdoor cells or EnvCell dat for indoor cells. - LightManager: Register/Unregister/UnregisterByOwner/Clear + Tick per frame. Selection matches r13 §12.2 exactly: 1) Skip unlit + directional. 2) Compute DistSq for every registered point/spot. 3) Drop lights outside Range² * 1.1 (10% slack prevents pop). 4) Sort by DistSq ascending; take up to 7 (slot 0 reserved for Sun). 5) Slot 0 = Sun (Directional); slots 1..7 = nearest in-range. Tests (9 new): - Register/Unregister/Idempotent register. - Tick picks top 8 by distance when 12 registered. - Range filter drops far lights (5.0 range, 20m away). - Range slack includes lights at exactly the boundary. - Sun reserved at slot 0 across ticks. - Unlit lights excluded; toggling IsLit brings them back. - UnregisterByOwner removes all owner's lights. - DistSq updated each tick for viewer movement. Build green, 596 tests pass (up from 587). Next: wire LightManager into the shader UBO pass (G.2 second commit) and feed Sun from WorldTimeService.CurrentSunDirection per frame. Ref: r13 §10.2 (D3D attenuation = none inside Range + hard cutoff), §12 (full port plan). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
3.9 KiB
C#
139 lines
3.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_DropsLightsOutsideRangeWithSlack()
|
|
{
|
|
var mgr = new LightManager();
|
|
mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // far outside its own range
|
|
|
|
mgr.Tick(viewerWorldPos: Vector3.Zero);
|
|
|
|
Assert.Equal(0, mgr.ActiveCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_IncludesLightsNearRangeEdge_WithSlack()
|
|
{
|
|
var mgr = new LightManager();
|
|
// Light at distance 5.0, range 5.0: distSq=25, rangeSq*1.1^2 = 25*1.21 = 30.25 → included.
|
|
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);
|
|
}
|
|
}
|