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

@ -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(1d/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];