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>
138 lines
4.4 KiB
C#
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;
|
|
}
|
|
}
|