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>
160 lines
5.9 KiB
C#
160 lines
5.9 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), 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 < 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;
|
||
}
|
||
}
|