merge: A7 lighting (Fix A point-light shape + Fix B per-object selection) into main

Brings the worktree branch claude/thirsty-goldberg-51bb9b into main:
- aa94ced  per-vertex Gouraud + faithful calc_point_light (wrap + norm)
- 4345e77  per-OBJECT point-light selection (minimize_object_lighting)

Auto-merged cleanly against the D.2b retail-UI line (only GameWindow.cs
overlapped, resolved by git). Merged tree builds green; 35/35 Core lighting
tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-16 20:50:22 +02:00
commit 37911ed510
7 changed files with 517 additions and 42 deletions

View file

@ -157,4 +157,125 @@ public sealed class LightManager
_activeCount = baseSlot + filled;
}
// ── Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ──
//
// The single global nearest-8-to-VIEWER set above (Tick) is camera-relative:
// a wall's brightness changes as the camera moves because the wall's torches
// swap in/out of that global top-8. Retail instead picks up-to-8 lights PER
// OBJECT by the OBJECT's own position (minimize_object_lighting, 0x0054d480),
// so a torch always lights the wall it sits on, camera-independent. The two
// members below feed the per-instance light path in WbDrawDispatcher; Tick
// remains the source of the legacy single-UBO path + the sun slot.
/// <summary>Max point/spot lights any one object can be lit by — retail's
/// D3D fixed-function 8-light cap (<c>minimize_object_lighting</c>). The sun
/// is global, not part of an object's per-object set, so all 8 are point/spot.</summary>
public const int MaxLightsPerObject = 8;
/// <summary>Hard cap on the per-frame global point-light snapshot the shader
/// indexes. AC scenes rarely exceed a few dozen lit point lights in view; 128
/// is generous. If exceeded, the nearest-to-camera are kept (cold path).</summary>
public const int MaxGlobalLights = 128;
private readonly List<LightSource> _pointSnapshot = new();
/// <summary>
/// Per-frame snapshot of lit point/spot lights, stable-indexed for the global
/// shader light buffer and for per-object selection: the index of a light here
/// IS the index the per-instance light-set SSBO references. Built by
/// <see cref="BuildPointLightSnapshot"/>.
/// </summary>
public IReadOnlyList<LightSource> PointSnapshot => _pointSnapshot;
/// <summary>
/// Rebuild <see cref="PointSnapshot"/> from the registered lit point/spot
/// lights. The sun and unlit lights are excluded (the sun is global ambient-
/// path; unlit torches contribute nothing). When more than
/// <see cref="MaxGlobalLights"/> qualify, keeps the nearest the camera so the
/// most relevant lights survive the cap. Call once per frame before
/// per-object selection.
/// </summary>
public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
{
_pointSnapshot.Clear();
foreach (var light in _all)
{
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
_pointSnapshot.Add(light);
}
if (_pointSnapshot.Count > MaxGlobalLights)
{
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
}
}
/// <summary>
/// Select up to <see cref="MaxLightsPerObject"/> point/spot lights from
/// <paramref name="snapshot"/> that reach the object sphere
/// (<paramref name="center"/>, <paramref name="radius"/>), nearest-first.
/// Faithful to retail's <c>minimize_object_lighting</c> (0x0054d480): a light
/// is a candidate iff its falloff sphere overlaps the object sphere —
/// <c>(light.pos center)² &lt; (light.Range + radius)²</c> — and when more
/// than 8 candidates qualify, the 8 NEAREST the object centre are kept (the
/// farthest fall off). <paramref name="light.Range"/> already folds
/// <c>static_light_factor</c> (1.3), matching the per-vertex cutoff so a
/// selected light always actually contributes in the shader.
/// <para>
/// Writes indices INTO <paramref name="snapshot"/> to
/// <paramref name="outIndices"/> (ascending by distance) and returns the count.
/// Pure + static: camera-INDEPENDENT (depends only on the object centre), so a
/// static object's set is stable and may be computed once. Unit-testable
/// without GL.
/// </para>
/// </summary>
public static int SelectForObject(
IReadOnlyList<LightSource> snapshot,
Vector3 center,
float radius,
Span<int> outIndices)
{
int cap = Math.Min(outIndices.Length, MaxLightsPerObject);
if (cap <= 0) return 0;
Span<float> keptDistSq = stackalloc float[MaxLightsPerObject];
int count = 0;
for (int li = 0; li < snapshot.Count; li++)
{
var light = snapshot[li];
float reach = light.Range + radius;
float dsq = (light.WorldPosition - center).LengthSquared();
if (dsq >= reach * reach) continue; // light's sphere doesn't reach the object
if (count < cap)
{
int j = count;
while (j > 0 && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
count++;
}
else if (dsq < keptDistSq[cap - 1])
{
int j = cap - 1;
while (j > 0 && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
}
}
return count;
}
}