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,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);