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>
The active-light selection dropped any point light whose range didn't reach the
VIEWER (DistSq > Range^2*slack -> skip). Retail's D3D-style fixed pipeline picks
the 8 NEAREST lights and applies the hard range cutoff PER SURFACE in the shader
(mesh_modern.frag: if (d < range)). The viewer-range candidacy filter suppressed
a torch whenever the player stood outside its range, so a dungeon room with 2227
registered torches lit only the ~1 the player was standing in (activeLights ~= 1,
rest of the room at flat 0.2 ambient = the "lighting off" report). Drop the filter;
take the nearest 8 regardless of viewer range. Removed the now-unused RangeSlack
const; updated the two tests that codified the old filter. Core lighting suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retail-faithful 8-light cap selection (r13 §12) — the fixed-function
D3D pipeline's "hardware lights" constraint carried over to modern GL
via UBO-per-draw.
Core layer (AcDream.Core/Lighting):
- LightSource: Kind (Directional/Point/Spot), WorldPosition,
WorldForward, ColorLinear, Intensity, Range (hard cutoff),
ConeAngle (spot), OwnerId (entity attachment), IsLit latch.
- CellAmbientState: (AmbientColor, SunColor, SunDirection) sourced from
R12 sky state for outdoor cells or EnvCell dat for indoor cells.
- LightManager: Register/Unregister/UnregisterByOwner/Clear + Tick
per frame. Selection matches r13 §12.2 exactly:
1) Skip unlit + directional.
2) Compute DistSq for every registered point/spot.
3) Drop lights outside Range² * 1.1 (10% slack prevents pop).
4) Sort by DistSq ascending; take up to 7 (slot 0 reserved for Sun).
5) Slot 0 = Sun (Directional); slots 1..7 = nearest in-range.
Tests (9 new):
- Register/Unregister/Idempotent register.
- Tick picks top 8 by distance when 12 registered.
- Range filter drops far lights (5.0 range, 20m away).
- Range slack includes lights at exactly the boundary.
- Sun reserved at slot 0 across ticks.
- Unlit lights excluded; toggling IsLit brings them back.
- UnregisterByOwner removes all owner's lights.
- DistSq updated each tick for viewer movement.
Build green, 596 tests pass (up from 587).
Next: wire LightManager into the shader UBO pass (G.2 second commit)
and feed Sun from WorldTimeService.CurrentSunDirection per frame.
Ref: r13 §10.2 (D3D attenuation = none inside Range + hard cutoff),
§12 (full port plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>