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

@ -7949,6 +7949,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);

View file

@ -84,7 +84,10 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
}
lit += Lcol * ndl * atten;
// Retail per-channel "no-blowout" cap (calc_point_light 0x0059c8b0): a single
// point/spot light can't push a channel past its own colour, regardless of
// intensity (~100) — kills the close-torch overblow (#93). See mesh_modern.frag.
lit += min(Lcol * ndl * atten, uLights[i].colorAndIntensity.xyz);
}
}
}

View file

@ -4,6 +4,7 @@
in vec3 vNormal;
in vec2 vTexCoord;
in vec3 vWorldPos;
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
in flat uvec2 vTextureHandle;
in flat uint vTextureLayer;
@ -31,44 +32,11 @@ layout(std140, binding = 1) uniform SceneLighting {
vec4 uCameraAndTime;
};
vec3 accumulateLights(vec3 N, vec3 worldPos) {
vec3 lit = uCellAmbient.xyz;
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) {
vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
lit += Lcol * ndl;
} else {
vec3 toL = uLights[i].posAndKind.xyz - worldPos;
float d = length(toL);
float range = uLights[i].dirAndRange.w;
if (d < range && range > 1e-3) {
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0,
// line 0x0059c9a2): contribution scales by (1 - dist/falloff_eff), a
// LINEAR fade to exactly 0 at the edge. That is what makes a torch a
// smooth glow that blends into the ambient instead of a flat disc with
// a hard edge — the dungeon/house/outdoor "spotlight" look (#133 A7).
// falloff_eff = Falloff * static_light_factor (1.3, 0x00820e24) is folded
// into the shader Range (dirAndRange.w) by LightInfoLoader, so the ramp
// denominator is just Range and fades to 0 exactly at the cutoff.
float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
if (kind == 2) {
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
}
lit += Lcol * ndl * atten;
}
}
}
return lit;
}
// A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match
// retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight"
// pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/
// uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed
// in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload.
vec3 applyFog(vec3 lit, vec3 worldPos) {
int mode = int(uFogParams.w);
@ -114,8 +82,8 @@ void main() {
if (color.a < 0.05) discard;
}
vec3 N = normalize(vNormal);
vec3 lit = accumulateLights(N, vWorldPos);
// Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights).
vec3 lit = vLit;
// Lightning flash — additive scene bump (matches mesh_instanced.frag).
lit += uFogParams.z * vec3(0.6, 0.6, 0.75);

View file

@ -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
@ -96,9 +123,95 @@ uniform mat4 uViewProjection;
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
uniform int uDrawIDOffset;
// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO
// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO
// above). IDENTICAL std140 layout to mesh_modern.frag.
//
// A7 (2026-06-15): lighting moved from the FRAGMENT shader to HERE (per-VERTEX) so
// torch/point lights Gouraud-interpolate across each triangle the way retail's
// fixed-function T&L does (D3D DrawEnvCell vertex bake + minimize_object_lighting for
// objects). A per-PIXEL evaluation made a tight bright "spotlight" pool on flat walls;
// per-vertex spreads it into a soft, broad gradient with no hard edge.
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams;
vec4 uFogColor;
vec4 uCameraAndTime;
};
// 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;
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;
}
out vec3 vNormal;
out vec2 vTexCoord;
out vec3 vWorldPos;
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
out flat uvec2 vTextureHandle;
out flat uint vTextureLayer;
@ -123,6 +236,7 @@ void main() {
vWorldPos = worldPos.xyz;
vNormal = normalize(mat3(model) * aNormal);
vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights)
vTexCoord = aTexCoord;
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];

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();
}
}

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;
}
}