acdream/src/AcDream.Core/Lighting/LightManager.cs
Erik 007e287309 fix(A7): port retail calc_point_light (1-dist/falloff) ramp — kill the "spotlight" hard edge (#133)
The dungeon/house/outdoor lights read as hard-edged blown discs ("spotlights")
because our point/spot shader used `atten = 1.0` flat inside a hard `d < range`
cutoff. The mesh.frag comment claimed this was retail-faithful ("no attenuation
inside Range... the bubble-of-light look relies on crisp boundaries", citing
r13 10.2) — that was a misread and the literal cause of the symptom.

Verified against the decomp (not guessed): calc_point_light (0x0059c8b0, the
PER-VERTEX point-light path that lights static walls) scales each light's
contribution by (1 - dist/falloff_eff) — a LINEAR ramp that fades to exactly 0
at the edge, eliminating the hard disc. falloff_eff = Falloff * static_light_factor,
and static_light_factor = 1.3 (0x00820e24), NOT the 1.5 config_hardware_light
rangeAdjust (that 1.5 is the D3D-dynamic path for moving objects, a different
path). The Ghidra port (acclient.c:808639) is more garbled — BN pseudo-C is the
oracle here; the exact normalization factor + a half-Lambert wrap (0.5*dist+N*L)
are x87-obscured (same artifact class as GetPowerBarLevel) and left unported.

Changes:
- mesh_modern.frag + mesh.frag: replace flat atten with clamp(1 - d/range, 0, 1);
  Range now carries falloff_eff so the ramp fades to 0 at the cutoff. Fix the
  false "no attenuation / crisp bubble" comment in mesh.frag.
- LightInfoLoader: Range = Falloff * 1.3 (static_light_factor), was * 1.5.
- LightManager: correct the stale class doc comment (Tick is now nearest-8
  allocation-free partial-select with NO viewer-range slack filter).
- divergence register: AP-16 updated (slack filter removed), AP-35 added
  (per-pixel vs per-vertex Gouraud; dropped half-Lambert wrap + normalization).
- test: LightingHookSinkTests Range 8*1.3 = 10.4.

Build + 20 lighting tests green. Visual gate pending (game-wide lighting change:
dungeon torches, house candles, outdoor braziers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:48:46 +02:00

160 lines
5.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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), as implemented by
/// <see cref="Tick"/>:
/// <list type="number">
/// <item><description>
/// Reserve slot 0 for the sun (directional, infinite range) when present.
/// </description></item>
/// <item><description>
/// For every registered lit point/spot light, recompute <c>DistSq</c>
/// from the viewer and keep the nearest <c>(MaxActiveLights sunSlot)</c>
/// directly in the active window via an allocation-free insertion
/// partial-select (no per-frame list/sort).
/// </description></item>
/// </list>
/// There is deliberately NO viewer-range candidacy filter: each light's
/// own range cutoff is applied PER SURFACE in the shader
/// (<c>mesh_modern.frag</c>: <c>d &lt; range</c>), so a torch the viewer
/// stands outside the range of must still light the wall it sits on. The
/// earlier <c>Range² × 1.1</c> slack filter wrongly dropped exactly those
/// lights (the #133 "lighting off" report).
/// </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 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)
{
// Retail D3D-style fixed-pipeline lighting takes the nearest (MaxActiveLights-1)
// point lights (slot 0 is the sun) and applies each light's hard range cutoff
// PER SURFACE in the shader (mesh_modern.frag: `if (d < range && range > 1e-3)`),
// NOT a viewer-range candidacy filter — a torch the viewer stands outside the
// range of must still light the wall it sits on.
//
// Allocation-free partial selection: the old path built `new List<>(N)` and
// ran an O(N log N) Sort EVERY FRAME; in a dungeon N is thousands of torches,
// so that allocated a large list per frame (GC pressure → FPS). Instead keep
// the nearest maxPoint directly in the _active window, maintained sorted by
// insertion. O(N · maxPoint), maxPoint ≤ 8, zero allocation.
Array.Clear(_active);
_activeCount = 0;
// Slot 0 = sun when present (directional; never ranked by distance).
int baseSlot = 0;
if (Sun is not null)
{
_active[0] = Sun;
baseSlot = 1;
}
int maxPoint = MaxActiveLights - baseSlot;
int filled = 0;
if (maxPoint > 0)
{
foreach (var light in _all)
{
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
Vector3 delta = light.WorldPosition - viewerWorldPos;
light.DistSq = delta.LengthSquared();
// Maintain _active[baseSlot .. baseSlot+filled) sorted ascending by
// DistSq. Insert if there's room or this light is nearer than the
// current farthest (then the farthest falls off the end).
if (filled < maxPoint)
{
int j = baseSlot + filled;
while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq)
{
_active[j] = _active[j - 1];
j--;
}
_active[j] = light;
filled++;
}
else if (light.DistSq < _active[baseSlot + maxPoint - 1]!.DistSq)
{
int j = baseSlot + maxPoint - 1;
while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq)
{
_active[j] = _active[j - 1];
j--;
}
_active[j] = light;
}
}
}
_activeCount = baseSlot + filled;
}
}