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:
Erik 2026-06-15 22:47:40 +02:00
parent aa94cedc38
commit 4345e77d62
5 changed files with 473 additions and 50 deletions

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.Core.Lighting;
using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using AcDream.Core.Terrain;
@ -132,6 +133,24 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private uint _clipSlotSsbo;
private uint[] _clipSlotData = new uint[256];
// Fix B (A7 #3): per-OBJECT light selection (minimize_object_lighting). Two
// SSBOs replace the single global nearest-8-to-CAMERA UBO set for point/spot
// lights — see mesh_modern.vert binding=4/5. _globalLightsSsbo (binding=4)
// holds the per-frame point-light snapshot (LightManager.PointSnapshot);
// _instLightSetSsbo (binding=5) holds MaxLightsPerObject int indices per
// instance INTO it (-1 = unused), laid out parallel to _instanceSsbo.
private uint _globalLightsSsbo;
private uint _instLightSetSsbo;
private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject];
private float[] _globalLightData = new float[16 * 16]; // 16 floats (4 vec4) per GlobalLight
// This frame's point-light snapshot, handed in by GameWindow before Draw via
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
private IReadOnlyList<LightSource>? _pointSnapshot;
// This entity's selected point/spot light set — computed ONCE per entity at
// the isNewEntity site (constant across the entity's parts/tuples), exactly
// like _currentEntitySlot. -1 = unused slot.
private readonly int[] _currentEntityLightSet = new int[LightManager.MaxLightsPerObject];
// Phase U.3: the SHARED per-cell clip-region SSBO (binding=2), owned by the
// GameWindow-level ClipFrame and handed to us via SetClipRegionSsbo. When 0
// (not yet wired), we bind our OWN fallback no-clip region buffer below so the
@ -329,8 +348,21 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_batchSsbo = _gl.GenBuffer();
_indirectBuffer = _gl.GenBuffer();
_clipSlotSsbo = _gl.GenBuffer(); // Phase U.3 binding=3
_globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4
_instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5
}
/// <summary>
/// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot
/// (<see cref="LightManager.PointSnapshot"/>). Call once per frame BEFORE
/// <see cref="Draw"/>. The dispatcher uploads it to binding=4 and selects each
/// object's up-to-8 lights from it (<see cref="LightManager.SelectForObject"/>)
/// by the object's bounding sphere — camera-independent. Pass null/empty to
/// disable per-object point lights (only ambient + sun render).
/// </summary>
public void SetSceneLights(IReadOnlyList<LightSource>? pointSnapshot)
=> _pointSnapshot = pointSnapshot;
/// <summary>
/// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO
/// (binding=2) that <see cref="ClipFrame.UploadShared"/> created. The
@ -888,7 +920,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
camPos = invView.Translation;
// ── Phase 1: clear groups, walk entities, build groups ──────────────
foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); }
foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); }
var metaTable = _meshAdapter.MetadataTable;
uint anyVao = 0;
@ -1053,6 +1085,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_currentEntityCulled)
probeCulledEntities++;
// Fix B: select this entity's up-to-8 point/spot lights ONCE (the set
// is constant across the entity's parts/tuples), by the entity's
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
ComputeEntityLightSet(entity);
// #119 decisive probe: one-shot dump (+ change re-emission) for
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
// so a routed-out entity still reports its state.
@ -1350,6 +1387,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_clipSlotData.Length < totalInstances)
_clipSlotData = new uint[totalInstances + 256];
// Fix B: per-instance light-set buffer, MaxLightsPerObject ints per
// instance, laid out in the SAME group order / cursor as _instanceData
// so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks
// Instances[instanceIndex] (binding=0).
if (_lightSetData.Length < totalInstances * LightManager.MaxLightsPerObject)
_lightSetData = new int[(totalInstances + 256) * LightManager.MaxLightsPerObject];
_opaqueDraws.Clear();
_translucentDraws.Clear();
@ -1375,6 +1419,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Slots[] is parallel to Matrices[] within the group; write the
// slot at the same cursor so binding=3 stays aligned with binding=0.
_clipSlotData[cursor] = grp.Slots[i];
// Fix B: LightSets[] holds 8 ints per instance, parallel to
// Matrices[]; copy this instance's block to the same cursor so
// binding=5 stays aligned with binding=0.
int lsDst = cursor * LightManager.MaxLightsPerObject;
int lsSrc = i * LightManager.MaxLightsPerObject;
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
_lightSetData[lsDst + k] = grp.LightSets[lsSrc + k];
cursor++;
}
@ -1460,6 +1511,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
fixed (uint* sp = _clipSlotData)
UploadSsbo(_clipSlotSsbo, 3, sp, totalInstances * sizeof(uint));
// Fix B: global point-light buffer (binding=4) + per-instance light-set
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the
// per-instance buffer holds 8 int indices into it per instance, laid out
// parallel to _instanceData in Phase 3. Both bound with ≥1 element so the
// shader never reads an unbound SSBO on a no-lights frame.
UploadGlobalLights();
fixed (int* lp = _lightSetData)
UploadSsbo(_instLightSetSsbo, 5, lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int));
fixed (DrawElementsIndirectCommand* cp = _indirectCommands)
{
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
@ -1743,6 +1803,50 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo);
}
/// <summary>
/// Fix B: pack <see cref="_pointSnapshot"/> into the binding=4 global light
/// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes,
/// matching mesh_modern.vert's <c>GlobalLight</c>). Always uploads ≥1 element
/// so the shader never reads an unbound SSBO — on a no-lights frame index 0 is
/// a zeroed dummy that no instance set references (all sets are -1).
/// </summary>
private unsafe void UploadGlobalLights()
{
var snap = _pointSnapshot;
int n = snap?.Count ?? 0;
int count = n > 0 ? n : 1; // never zero-size
int floatsNeeded = count * 16;
if (_globalLightData.Length < floatsNeeded)
_globalLightData = new float[floatsNeeded + 16 * 16];
Array.Clear(_globalLightData, 0, floatsNeeded);
for (int i = 0; i < n; i++)
{
var L = snap![i];
int o = i * 16;
// posAndKind (xyz world pos, w kind)
_globalLightData[o + 0] = L.WorldPosition.X;
_globalLightData[o + 1] = L.WorldPosition.Y;
_globalLightData[o + 2] = L.WorldPosition.Z;
_globalLightData[o + 3] = (int)L.Kind;
// dirAndRange (xyz forward, w range = Falloff×1.3)
_globalLightData[o + 4] = L.WorldForward.X;
_globalLightData[o + 5] = L.WorldForward.Y;
_globalLightData[o + 6] = L.WorldForward.Z;
_globalLightData[o + 7] = L.Range;
// colorAndIntensity (xyz linear colour, w intensity)
_globalLightData[o + 8] = L.ColorLinear.X;
_globalLightData[o + 9] = L.ColorLinear.Y;
_globalLightData[o + 10] = L.ColorLinear.Z;
_globalLightData[o + 11] = L.Intensity;
// coneAngleEtc (x cone radians; yzw reserved)
_globalLightData[o + 12] = L.ConeAngle;
}
fixed (float* gp = _globalLightData)
UploadSsbo(_globalLightsSsbo, 4, gp, count * 16 * sizeof(float));
}
/// <summary>
/// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the
/// shared <see cref="ClipFrame"/> buffer (set via <see cref="SetClipRegionSsbo"/>);
@ -1936,6 +2040,38 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
grp.Matrices.Add(model);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
}
/// <summary>
/// Fix B: choose the up-to-8 point/spot lights for THIS entity (the result
/// reused by every part/instance of it), by the entity's world bounding
/// sphere. Camera-independent (<see cref="LightManager.SelectForObject"/>), so
/// a static building's torches stay constant as the viewer moves. Fills
/// <see cref="_currentEntityLightSet"/>; unused slots are -1. On the no-lights
/// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light.
/// </summary>
private void ComputeEntityLightSet(WorldEntity entity)
{
Array.Fill(_currentEntityLightSet, -1);
var snap = _pointSnapshot;
if (snap is null || snap.Count == 0) return;
if (entity.AabbDirty) entity.RefreshAabb();
Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f;
float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f;
LightManager.SelectForObject(snap, center, radius, _currentEntityLightSet);
}
/// <summary>
/// Fix B: append the current entity's 8-slot light set to a group's
/// <see cref="InstanceGroup.LightSets"/>, parallel to its Matrices (one
/// 8-int block per instance), mirroring <c>grp.Slots.Add</c>.
/// </summary>
private void AppendCurrentLightSet(InstanceGroup grp)
{
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
grp.LightSets.Add(_currentEntityLightSet[k]);
}
private void ClassifyBatches(
@ -1993,6 +2129,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
grp.Matrices.Add(model);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
collector?.Add(new CachedBatch(key, texHandle, restPose));
}
}
@ -2072,6 +2209,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_gl.DeleteBuffer(_indirectBuffer);
if (_clipSlotSsbo != 0) _gl.DeleteBuffer(_clipSlotSsbo); // Phase U.3
if (_fallbackClipRegionSsbo != 0) _gl.DeleteBuffer(_fallbackClipRegionSsbo); // Phase U.3
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5
if (_gpuQueriesInitialized)
{
for (int i = 0; i < GpuQueryRingDepth; i++)
@ -2257,5 +2396,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// _clipSlotData at the same cursor it writes Matrices[i] into _instanceData,
// so the binding=3 instanceClipSlot[] tracks the binding=0 instance.
public readonly List<uint> Slots = new();
// Fix B (A7 #3): per-instance light SET, MaxLightsPerObject(8) ints per
// instance, parallel to Matrices (LightSets[i*8 .. i*8+8) is the selected
// light index block for the instance whose matrix is Matrices[i]). At
// layout time the dispatcher copies each block into _lightSetData at the
// same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0
// instance. -1 = unused slot.
public readonly List<int> LightSets = new();
}
}