From 4345e77d62d6385e9264326344cecad0dfd2c626 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:47:40 +0200 Subject: [PATCH] =?UTF-8?q?fix(render):=20A7=20Fix=20B=20=E2=80=94=20per-O?= =?UTF-8?q?BJECT=20point-light=20selection=20(minimize=5Fobject=5Flighting?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/GameWindow.cs | 10 ++ .../Rendering/Shaders/mesh_modern.vert | 131 +++++++++------ .../Rendering/Wb/WbDrawDispatcher.cs | 149 +++++++++++++++++- src/AcDream.Core/Lighting/LightManager.cs | 121 ++++++++++++++ .../Lighting/LightManagerTests.cs | 112 +++++++++++++ 5 files changed, 473 insertions(+), 50 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8f27733a..3735979e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7766,6 +7766,16 @@ public sealed class GameWindow : IDisposable // frame — terrain, static mesh, instanced mesh, sky. UpdateSunFromSky(kf, playerInsideCell); Lighting.Tick(camPos); + + // Fix B (A7 #3): build this frame's point-light snapshot and hand it to + // the entity dispatcher for per-OBJECT light selection + // (minimize_object_lighting). Replaces the single global nearest-8-to- + // camera UBO set for point/spot lights so a wall's torches stay tied to + // the wall as the camera moves. The SUN + ambient still flow through the + // SceneLighting UBO built below (binding=1) — terrain/sky read those. + Lighting.BuildPointLightSnapshot(camPos); + _wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot); + var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( Lighting, in atmo, camPos, (float)WorldTime.DayFraction); diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index fa150cbc..2efd4a96 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -69,6 +69,33 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf { uint instanceClipSlot[]; }; +// === Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ===== +// retail picks up-to-8 point/spot lights PER OBJECT by the object's own position +// (minimize_object_lighting 0x0054d480), so a torch always lights the wall it +// sits on, camera-INDEPENDENTLY. The previous single global nearest-8-to-CAMERA +// UBO set (LightManager.Tick) made a wall brighten as the camera approached +// (its torches swapping into the global top-8). Two SSBOs replace that for +// point/spot lights (the SUN + ambient still come from the SceneLighting UBO): +// +// binding=4 — GLOBAL point/spot light array, uploaded once per frame from +// LightManager.PointSnapshot. The index of a light here is stable for the frame. +// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per +// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0 +// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills +// it once per entity (the set is constant across the entity's parts/tuples). +struct GlobalLight { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std430, binding = 4) readonly buffer GlobalLightBuf { + GlobalLight gLights[]; +}; +layout(std430, binding = 5) readonly buffer InstanceLightSetBuf { + int instanceLightIdx[]; // 8 per instance; -1 = unused +}; + // Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal // alongside gl_Position. The array is sized 8 to match the CellClip plane budget // and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables @@ -119,58 +146,64 @@ layout(std140, binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; -vec3 accumulateLights(vec3 N, vec3 worldPos) { +// Faithful calc_point_light (0x0059c8b0) contribution from ONE point/spot light — +// the wrap + norm shape, factored out so the per-object SSBO loop shares it. D = +// light − vertex, used UN-normalised (length = dist); N is the unit vertex normal. +// Returns the RGB to ADD, already per-channel capped to the light's own colour. +vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) { + int kind = int(L.posAndKind.w); + vec3 toL = L.posAndKind.xyz - worldPos; // D (un-normalised) + float distsq = dot(toL, toL); + float d = sqrt(distsq); + float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3 + if (d >= range || range <= 1e-4) return vec3(0.0); + // Half-Lambert WRAP: (1/1.5)·(N·D + 0.5·d). N·D = d·cosθ (D un-normalised); the + // +0.5·d bias lets a face angled AWAY from the torch still catch light (retail's + // soft terminator). wrap≤0 = fully shadowed. TwoLpr=1.5, WrapBias=0.5. + float wrap = (1.0 / 1.5) * (dot(N, toL) + 0.5 * d); + if (wrap <= 0.0) return vec3(0.0); + // NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo; + // <1 m → just d (dodge the near singularity). "Punchy near, soft far." + float norm = (distsq > 1.0) ? (distsq * d) : d; + float intensity = L.colorAndIntensity.w; + float scale = (1.0 - d / range) * intensity * (wrap / norm); + if (kind == 2) { + // Spotlight: hard-edged cos-cone gate layered on the point ramp. + vec3 Ldir = toL / max(d, 1e-4); + float cos_edge = cos(L.coneAngleEtc.x * 0.5); + float cos_l = dot(-Ldir, L.dirAndRange.xyz); + if (cos_l <= cos_edge) scale = 0.0; + } + // Per-channel no-blowout cap to the light's OWN colour (un-intensity-scaled): + // a single light can't push a channel past its colour. Summed lit clamped in frag. + vec3 baseCol = L.colorAndIntensity.xyz; + return min(scale * baseCol, baseCol); +} + +vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) { vec3 lit = uCellAmbient.xyz; + + // SUN / directional — from the SceneLighting UBO (global; the audit cleared + // the ambient + sun chain as already faithful). Any point/spot entries still + // present in the UBO from LightManager.Tick are IGNORED here — point lights + // now come per-object from the SSBO below, so there's no double-count. int activeLights = int(uCellAmbient.w); for (int i = 0; i < 8; ++i) { if (i >= activeLights) break; - int kind = int(uLights[i].posAndKind.w); - vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; - if (kind == 0) { - // Directional (sun): forward points INTO the scene; N·(-forward) = light-facing. - vec3 Ldir = -uLights[i].dirAndRange.xyz; - float ndl = max(0.0, dot(N, Ldir)); - lit += Lcol * ndl; - } else { - // Point / spot — FAITHFUL port of calc_point_light (0x0059c8b0) via our - // verified LightBake.PointContribution (LightBake.cs:46-77). D = light − - // vertex, used UN-normalised (length = dist); N is the unit vertex normal. - // (A7 2026-06-15 #2: the prior model was a simplification — plain - // max(0,N·L) × linear(1−d/range) — which gave a harsher terminator and a - // flatter falloff than retail. The two terms below are the fix.) - vec3 toL = uLights[i].posAndKind.xyz - worldPos; // D (un-normalised) - float distsq = dot(toL, toL); - float d = sqrt(distsq); - float range = uLights[i].dirAndRange.w; // falloff_eff = Falloff × 1.3 - if (d < range && range > 1e-4) { - // Half-Lambert WRAP: (1/1.5)·(N·D + 0.5·d). D is un-normalised so - // N·D = d·cosθ; the +0.5·d bias lets a face angled AWAY from the torch - // still catch light — retail's soft terminator. wrap≤0 = fully shadowed - // (retail early-out at 0x0059c8b0). TwoLpr=1.5, WrapBias=0.5. - float wrap = (1.0 / 1.5) * (dot(N, toL) + 0.5 * d); - if (wrap > 0.0) { - // NORM branch (the distance-cube term): beyond 1 m, divide by - // distsq·d ≈ inverse-square (soft far halo); within 1 m, divide by - // d only, to dodge a near singularity. This is the "punchy near, - // soft far" shape the flat linear ramp was flattening. - float norm = (distsq > 1.0) ? (distsq * d) : d; - float intensity = uLights[i].colorAndIntensity.w; - float scale = (1.0 - d / range) * intensity * (wrap / norm); - if (kind == 2) { - // Spotlight: hard-edged cos-cone gate layered on the point ramp. - vec3 Ldir = toL / max(d, 1e-4); - float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); - float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); - if (cos_l <= cos_edge) scale = 0.0; - } - // Per-channel no-blowout cap to the light's OWN colour (un-intensity- - // scaled): a single light can't push a channel past its colour - // (dat torch intensity ~100 would saturate). Summed lit clamped in frag. - vec3 baseCol = uLights[i].colorAndIntensity.xyz; - lit += min(scale * baseCol, baseCol); - } - } - } + if (int(uLights[i].posAndKind.w) != 0) continue; // directional only + vec3 Ldir = -uLights[i].dirAndRange.xyz; // forward points INTO the scene + float ndl = max(0.0, dot(N, Ldir)); + lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; + } + + // POINT / SPOT — THIS object's selected set (minimize_object_lighting): 8 int + // slots per instance into the global light buffer, -1 = unused. Camera- + // independent, so a wall's torches light it the same regardless of viewer pos. + int base = instanceIndex * 8; + for (int k = 0; k < 8; ++k) { + int gi = instanceLightIdx[base + k]; + if (gi < 0) continue; + lit += pointContribution(N, worldPos, gLights[gi]); } return lit; } @@ -203,7 +236,7 @@ void main() { vWorldPos = worldPos.xyz; vNormal = normalize(mat3(model) * aNormal); - vLit = accumulateLights(vNormal, vWorldPos); // A7: per-vertex Gouraud lighting + vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights) vTexCoord = aTexCoord; BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB]; diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index e266be8c..6fbc3cd6 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -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? _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 } + /// + /// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot + /// (). Call once per frame BEFORE + /// . The dispatcher uploads it to binding=4 and selects each + /// object's up-to-8 lights from it () + /// by the object's bounding sphere — camera-independent. Pass null/empty to + /// disable per-object point lights (only ambient + sun render). + /// + public void SetSceneLights(IReadOnlyList? pointSnapshot) + => _pointSnapshot = pointSnapshot; + /// /// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO /// (binding=2) that 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); } + /// + /// Fix B: pack into the binding=4 global light + /// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes, + /// matching mesh_modern.vert's GlobalLight). 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). + /// + 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)); + } + /// /// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the /// shared buffer (set via ); @@ -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 + } + + /// + /// 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 (), so + /// a static building's torches stay constant as the viewer moves. Fills + /// ; unused slots are -1. On the no-lights + /// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light. + /// + 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); + } + + /// + /// Fix B: append the current entity's 8-slot light set to a group's + /// , parallel to its Matrices (one + /// 8-int block per instance), mirroring grp.Slots.Add. + /// + 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 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 LightSets = new(); } } diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index 24769c6e..95ea1edf 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -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. + + /// Max point/spot lights any one object can be lit by — retail's + /// D3D fixed-function 8-light cap (minimize_object_lighting). The sun + /// is global, not part of an object's per-object set, so all 8 are point/spot. + public const int MaxLightsPerObject = 8; + + /// 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). + public const int MaxGlobalLights = 128; + + private readonly List _pointSnapshot = new(); + + /// + /// 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 + /// . + /// + public IReadOnlyList PointSnapshot => _pointSnapshot; + + /// + /// Rebuild 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 + /// qualify, keeps the nearest the camera so the + /// most relevant lights survive the cap. Call once per frame before + /// per-object selection. + /// + 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); + } + } + + /// + /// Select up to point/spot lights from + /// that reach the object sphere + /// (, ), nearest-first. + /// Faithful to retail's minimize_object_lighting (0x0054d480): a light + /// is a candidate iff its falloff sphere overlaps the object sphere — + /// (light.pos − center)² < (light.Range + radius)² — and when more + /// than 8 candidates qualify, the 8 NEAREST the object centre are kept (the + /// farthest fall off). already folds + /// static_light_factor (1.3), matching the per-vertex cutoff so a + /// selected light always actually contributes in the shader. + /// + /// Writes indices INTO to + /// (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. + /// + /// + public static int SelectForObject( + IReadOnlyList snapshot, + Vector3 center, + float radius, + Span outIndices) + { + int cap = Math.Min(outIndices.Length, MaxLightsPerObject); + if (cap <= 0) return 0; + + Span 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; + } } diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs index 1bb225a2..264c498c 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -144,4 +144,116 @@ public sealed class LightManagerTests mgr.Tick(new Vector3(3, 0, 0)); // same x, same y, z diff 4 Assert.Equal(16f, light.DistSq, 2); } + + // ── Fix B: per-object selection (minimize_object_lighting) ──────────────── + + [Fact] + public void BuildPointLightSnapshot_ExcludesDirectionalAndUnlit() + { + var mgr = new LightManager(); + mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // in + mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, lit: false)); // unlit → out + mgr.Register(new LightSource { Kind = LightKind.Directional }); // sun → out + + mgr.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Single(mgr.PointSnapshot); + Assert.Equal(1f, mgr.PointSnapshot[0].WorldPosition.X, 3); + } + + [Fact] + public void BuildPointLightSnapshot_IndexStable_InBudget() + { + var mgr = new LightManager(); + // Registration order preserved when under MaxGlobalLights (no sort). + mgr.Register(MakePoint(new Vector3(100, 0, 0), 5f)); // far + mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // near + + mgr.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Equal(2, mgr.PointSnapshot.Count); + Assert.Equal(100f, mgr.PointSnapshot[0].WorldPosition.X, 3); // index 0 = first registered + Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3); + } + + [Fact] + public void SelectForObject_EmptySnapshot_ReturnsZero() + { + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(System.Array.Empty(), Vector3.Zero, 1f, idx); + Assert.Equal(0, n); + } + + [Fact] + public void SelectForObject_InRange_Selected() + { + var snapshot = new[] { MakePoint(new Vector3(3, 0, 0), range: 5f) }; // dist 3 < range 5 + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); + Assert.Equal(1, n); + Assert.Equal(0, idx[0]); + } + + [Fact] + public void SelectForObject_OutOfRange_Excluded() + { + // dist 10, range 5, radius 0 → 10 >= 5 → excluded. + var snapshot = new[] { MakePoint(new Vector3(10, 0, 0), range: 5f) }; + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); + Assert.Equal(0, n); + } + + [Fact] + public void SelectForObject_ObjectRadiusExtendsReach() + { + // dist 7, range 5: out of reach at radius 0, but a radius-3 object sphere + // overlaps (7 < 5+3). The whole object catches the light — retail uses the + // object's bounding sphere, not its centre point. + var snapshot = new[] { MakePoint(new Vector3(7, 0, 0), range: 5f) }; + Span idx = stackalloc int[8]; + + Assert.Equal(0, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx)); + Assert.Equal(1, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 3f, idx)); + } + + [Fact] + public void SelectForObject_MoreThan8_KeepsNearest8() + { + // 10 candidate lights all in range; expect the 8 nearest the object centre, + // ascending by distance, with the two farthest dropped. + var snapshot = new LightSource[10]; + for (int i = 0; i < 10; i++) + snapshot[i] = MakePoint(new Vector3(i + 1, 0, 0), range: 100f); // dist i+1, all in range + + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); + + Assert.Equal(8, n); + // Nearest-first: index 0 (dist 1) … index 7 (dist 8). The two farthest + // (indices 8,9 / dist 9,10) are evicted. + for (int k = 0; k < 8; k++) + Assert.Equal(k, idx[k]); + } + + [Fact] + public void SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre() + { + // Same snapshot, same object centre → identical selection regardless of + // where any "camera" is (the method takes no camera). This is the property + // that kills the "lights up as I approach" popping. + var snapshot = new[] + { + MakePoint(new Vector3(2, 0, 0), range: 10f), + MakePoint(new Vector3(20, 0, 0), range: 10f), // out of reach of centre 0 + }; + Span a = stackalloc int[8]; + Span b = stackalloc int[8]; + int na = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, a); + int nb = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, b); + + Assert.Equal(1, na); + Assert.Equal(na, nb); + Assert.Equal(a[0], b[0]); + } }