fix(render): A7 Fix B — per-OBJECT point-light selection (minimize_object_lighting)
Outdoor objects brightened as the camera approached: lighting selected the nearest 8 lights to the VIEWER and fed that one global set to everything (LightManager.Tick), so a building's wall torches only lit it once the camera got close enough for them to win the global top-8. Probe confirmed the scale of the problem: a single Holtburg view registers 129 point lights — the global cap of 8 was hopeless. Retail selects 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. Ported faithfully: - LightManager.SelectForObject (pure, TDD, 8 new tests): candidacy (light.pos − center)² < (Range + radius)², nearest-8 among those. Plus BuildPointLightSnapshot for the per-frame stable-indexed light list. - mesh_modern.vert: two SSBOs — binding=4 GLOBAL point-light array (the snapshot), binding=5 per-instance light SET (8 int indices into it, -1 = unused), parallel to the binding=0 instance buffer (mirrors the U.3 clip-slot mechanism). accumulateLights keeps ambient + sun from the SceneLighting UBO (cleared as faithful by the lighting audit) and loops THIS instance's point lights. pointContribution factored out (same calc_point_light wrap+norm shape). - WbDrawDispatcher: per-entity light set computed ONCE at the isNewEntity site (constant across the entity's parts), by the entity's AABB sphere; threaded into grp.LightSets parallel to grp.Matrices; global + per-instance buffers uploaded in Phase 5. Camera-independent ⇒ stable for static buildings. - GameWindow: BuildPointLightSnapshot + dispatcher.SetSceneLights each frame. Tests: 17/17 LightManager + 36/36 dispatcher clip-slot/clip-frame green (parallel-array lockstep preserved). Visually gated: the meeting hall now holds steady as the camera approaches (was the popping symptom). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aa94cedc38
commit
4345e77d62
5 changed files with 473 additions and 50 deletions
|
|
@ -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)² < (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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue