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>
This commit is contained in:
Erik 2026-06-13 21:48:46 +02:00
parent 5872bcf075
commit 007e287309
6 changed files with 44 additions and 26 deletions

View file

@ -79,12 +79,15 @@ public static class LightInfoLoader
(info.Color?.Green ?? 255) / 255f,
(info.Color?.Blue ?? 255) / 255f),
Intensity = info.Intensity,
// Retail PrimD3DRender::config_hardware_light (0x0059ad30) sets the
// hardware light Range = Falloff * rangeAdjust, where rangeAdjust is
// the fixed global 1.5 (0x00820cc4). Our prior Range = Falloff reached
// only 2/3 of retail's distance → tight torch bubbles (the dungeon
// "candles/spotlights" report, #133 A7). Match retail's reach.
Range = info.Falloff * 1.5f,
// falloff_eff for the per-vertex point-light burn-in (calc_point_light
// 0x0059c8b0) is Falloff * static_light_factor, where static_light_factor
// is the fixed global 1.3 (0x00820e24). That is the path that lights
// STATIC walls — what the dungeon/house "spotlight" report (#133 A7) is
// about — so we match it, not the D3D-dynamic config_hardware_light
// rangeAdjust (1.5, a different path for moving objects). The shader ramp
// (1 - dist/Range) fades to exactly 0 at this Range, eliminating the hard
// disc edge that read as a spotlight.
Range = info.Falloff * 1.3f,
ConeAngle = info.ConeAngle,
OwnerId = ownerId,
IsLit = true,

View file

@ -11,23 +11,25 @@ namespace AcDream.Core.Lighting;
/// §12.2).
///
/// <para>
/// Active-light selection algorithm (r13 §12.2 "Tick" steps):
/// Active-light selection algorithm (r13 §12.2), as implemented by
/// <see cref="Tick"/>:
/// <list type="number">
/// <item><description>
/// Recompute <c>DistSq</c> from viewer to every registered
/// point/spot light.
/// Reserve slot 0 for the sun (directional, infinite range) when present.
/// </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).
/// 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>