feat(lighting): Phase G.2 LightSource + LightManager (data + selection)

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>
This commit is contained in:
Erik 2026-04-18 17:09:51 +02:00
parent 6850d716a2
commit a28a69af71
3 changed files with 340 additions and 0 deletions

View file

@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Lighting;
/// <summary>
/// Manages the registered dynamic lights in the world and picks the 8
/// most relevant ones each frame for the shader to consume. Matches
/// retail's fixed-function-era "8 hardware lights" constraint (r13
/// §12.2).
///
/// <para>
/// Active-light selection algorithm (r13 §12.2 "Tick" steps):
/// <list type="number">
/// <item><description>
/// Recompute <c>DistSq</c> from viewer to every registered
/// point/spot light.
/// </description></item>
/// <item><description>
/// Drop lights outside <c>Range² * 1.1</c> (10% slack prevents
/// pop as we walk across the boundary).
/// </description></item>
/// <item><description>
/// Rank remaining lights by <c>DistSq</c> ascending. Pick top 7.
/// </description></item>
/// <item><description>
/// Reserve slot 0 for the sun (directional, infinite range).
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Not thread-safe — the render thread owns the light list.
/// </para>
/// </summary>
public sealed class LightManager
{
public const int MaxActiveLights = 8; // D3D parity
private const float RangeSlack = 1.1f; // 10% hysteresis around hard cutoff
private readonly List<LightSource> _all = new();
private readonly LightSource?[] _active = new LightSource?[MaxActiveLights];
private int _activeCount;
/// <summary>Current cell ambient state applied to everything.</summary>
public CellAmbientState CurrentAmbient { get; set; }
/// <summary>
/// The sun (or "global directional") — always slot 0 of the active
/// list. Set this from the <see cref="AcDream.Core.World.WorldTimeService"/>
/// each frame.
/// </summary>
public LightSource? Sun { get; set; }
/// <summary>Snapshot of the currently-active lights (up to 8).</summary>
public ReadOnlySpan<LightSource?> Active => _active.AsSpan(0, _activeCount);
public int ActiveCount => _activeCount;
public int RegisteredCount => _all.Count;
/// <summary>Add a light. Idempotent — adding the same instance twice is a no-op.</summary>
public void Register(LightSource light)
{
ArgumentNullException.ThrowIfNull(light);
foreach (var existing in _all)
if (ReferenceEquals(existing, light)) return;
_all.Add(light);
}
/// <summary>Remove by reference.</summary>
public void Unregister(LightSource light)
{
_all.Remove(light);
}
/// <summary>Remove every light attached to a specific entity.</summary>
public void UnregisterByOwner(uint ownerId)
{
_all.RemoveAll(l => l.OwnerId == ownerId);
}
public void Clear()
{
_all.Clear();
Array.Clear(_active);
_activeCount = 0;
}
/// <summary>
/// Refresh the active-light list for the current viewer position.
/// Called once per render frame from the render thread; the shader
/// reads <see cref="Active"/> and uploads to the light UBO.
/// </summary>
public void Tick(Vector3 viewerWorldPos)
{
// Pass 1: compute DistSq + filter out lights outside the slack radius.
var candidates = new List<LightSource>(_all.Count);
foreach (var light in _all)
{
if (!light.IsLit) continue;
if (light.Kind == LightKind.Directional)
{
// Directional lights don't participate in this ranking —
// the sun is always slot 0.
continue;
}
Vector3 delta = light.WorldPosition - viewerWorldPos;
light.DistSq = delta.LengthSquared();
float rangeSq = light.Range * light.Range * RangeSlack * RangeSlack;
if (light.DistSq > rangeSq) continue;
candidates.Add(light);
}
// Pass 2: sort by DistSq ascending, take up to 7.
candidates.Sort((a, b) => a.DistSq.CompareTo(b.DistSq));
Array.Clear(_active);
_activeCount = 0;
// Slot 0 = sun when present.
if (Sun is not null)
{
_active[0] = Sun;
_activeCount = 1;
}
int maxPoint = MaxActiveLights - _activeCount;
int pointCount = Math.Min(maxPoint, candidates.Count);
for (int i = 0; i < pointCount; i++)
{
_active[_activeCount + i] = candidates[i];
}
_activeCount += pointCount;
}
}

View file

@ -0,0 +1,63 @@
using System;
using System.Numerics;
namespace AcDream.Core.Lighting;
/// <summary>
/// Retail light-source kinds (r13 §12.1).
/// </summary>
public enum LightKind
{
Directional = 0, // sun, moon — no position, infinite range
Point = 1, // torch, fireplace, spell aura
Spot = 2, // cone-shaped (rare in AC, used for a few specific lamps)
}
/// <summary>
/// Per-frame light record. Used by <see cref="LightManager"/> and fed to
/// the shader UBO on every draw call.
///
/// <para>
/// Retail semantics (r13 §10.2):
/// <list type="bullet">
/// <item><description>
/// Hard cutoff at <see cref="Range"/> — no smoothstep, no distance
/// attenuation inside the range. "Crisp bubble of illumination."
/// </description></item>
/// <item><description>
/// Max 8 active lights (<see cref="LightManager.MaxActiveLights"/>),
/// ranked by distance-to-viewer. Slot 0 is reserved for the sun.
/// </description></item>
/// <item><description>
/// <see cref="IsLit"/> latches the <c>SetLightHook</c> value so
/// animations can toggle a light on/off (torch being lit, light
/// crystal activating).
/// </description></item>
/// </list>
/// </para>
/// </summary>
public sealed class LightSource
{
public LightKind Kind;
public Vector3 WorldPosition;
public Vector3 WorldForward; // for Spot/Directional
public Vector3 ColorLinear = Vector3.One; // R,G,B in [0,1], pre-brightness
public float Intensity = 1f;
public float Range = 10f; // metres, hard cutoff
public float ConeAngle = 0f; // radians, Spot only
public uint OwnerId; // attached entity id; 0 = world-global
public bool IsLit = true; // SetLightHook latch
// Cached each frame by LightManager.
public float DistSq;
}
/// <summary>
/// Per-cell ambient + sun state. For outdoor cells this comes from the
/// R12 sky state; for indoor cells the EnvCell dat carries a per-cell
/// ambient override (r13 §3).
/// </summary>
public readonly record struct CellAmbientState(
Vector3 AmbientColor,
Vector3 SunColor,
Vector3 SunDirection);

View file

@ -0,0 +1,139 @@
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);
}
}