acdream/src/AcDream.Core/Lighting/LightManager.cs
Erik a28a69af71 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>
2026-04-18 17:09:51 +02:00

138 lines
4.4 KiB
C#

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;
}
}