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:
parent
aa94cedc38
commit
4345e77d62
5 changed files with 473 additions and 50 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)² < (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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<int> idx = stackalloc int[8];
|
||||
int n = LightManager.SelectForObject(System.Array.Empty<LightSource>(), 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<int> 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<int> 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<int> 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<int> 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<int> a = stackalloc int[8];
|
||||
Span<int> 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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue