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:
parent
6850d716a2
commit
a28a69af71
3 changed files with 340 additions and 0 deletions
138
src/AcDream.Core/Lighting/LightManager.cs
Normal file
138
src/AcDream.Core/Lighting/LightManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
63
src/AcDream.Core/Lighting/LightSource.cs
Normal file
63
src/AcDream.Core/Lighting/LightSource.cs
Normal 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);
|
||||
139
tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs
Normal file
139
tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue