The Town Network hub (498 registered fixtures) starved the player's own
room: BuildPointLightSnapshot's player-nearest-128 cap sorts by raw
Euclidean distance, which isn't a reliable proxy for "same room" in a
dense maze — a fixture on the other side of a wall can be geometrically
closer than the room's own torches and win the cap. LightSource.CellId
tagging and the [indoor-light] membership probe already existed from the
c500912b/#176 arc; the missing piece was a candidacy filter.
BuildPointLightSnapshot(playerWorldPos, visibleCells) now narrows
candidates to the frame's actual visible cells before the existing
dynamics-first player-nearest cap runs (cell-less lights, e.g. the viewer
fill, always included). GameWindow feeds LAST FRAME's already-rendered
RetailPViewFrameResult.DrawableCells — one frame of latency instead of
re-threading a mid-DrawInside callback, which was the exact mechanism
(c500912b) that caused the earlier #176 seam-floor flicker regression.
The distance-sort anchor stays the player, unchanged.
AP-85 updated in place (third revision) rather than adding a new row —
same underlying divergence, now with the render-visibility approximation
of retail's true DBObj-load/flush-bounded resident registry documented
alongside its residual risk (one unscoped frame on portal re-entry).
Core 2652+2skip / App 741+2skip / UI 425 / Net 385 green. Pending: user
visual gate at the Town Network fountain, and a #176 corridor-seam
non-regression recheck (Facility Hub, different landblock).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
527 lines
22 KiB
C#
527 lines
22 KiB
C#
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.Lighting;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Lighting;
|
|
|
|
public sealed class LightManagerTests
|
|
{
|
|
private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true, uint cellId = 0)
|
|
=> new LightSource
|
|
{
|
|
Kind = LightKind.Point,
|
|
WorldPosition = pos,
|
|
Range = range,
|
|
IsLit = lit,
|
|
OwnerId = ownerId,
|
|
CellId = cellId,
|
|
};
|
|
|
|
private static LightSource MakeDynamic(Vector3 pos, float range, uint cellId = 0)
|
|
=> new LightSource
|
|
{
|
|
Kind = LightKind.Point,
|
|
WorldPosition = pos,
|
|
Range = range,
|
|
IsLit = true,
|
|
IsDynamic = true,
|
|
CellId = cellId,
|
|
};
|
|
|
|
[Fact]
|
|
public void Register_Unregister_TracksList()
|
|
{
|
|
var mgr = new LightManager();
|
|
var a = MakePoint(Vector3.Zero, 5f);
|
|
var b = MakePoint(new Vector3(10, 0, 0), 5f);
|
|
mgr.Register(a);
|
|
mgr.Register(b);
|
|
Assert.Equal(2, mgr.RegisteredCount);
|
|
|
|
mgr.Unregister(a);
|
|
Assert.Equal(1, mgr.RegisteredCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Register_DuplicateInstance_Idempotent()
|
|
{
|
|
var mgr = new LightManager();
|
|
var light = MakePoint(Vector3.Zero, 5f);
|
|
mgr.Register(light);
|
|
mgr.Register(light);
|
|
Assert.Equal(1, mgr.RegisteredCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_SelectsByDistance_Top8()
|
|
{
|
|
var mgr = new LightManager();
|
|
// 12 lights at varying distances, all with range 100 so none filter out.
|
|
for (int i = 0; i < 12; i++)
|
|
mgr.Register(MakePoint(new Vector3(i, 0, 0), 100f));
|
|
|
|
mgr.Tick(viewerWorldPos: Vector3.Zero);
|
|
|
|
Assert.Equal(8, mgr.ActiveCount);
|
|
// Top 8 should be the closest (i=0..7).
|
|
foreach (var l in mgr.Active)
|
|
{
|
|
Assert.NotNull(l);
|
|
Assert.True(l!.WorldPosition.X <= 7f);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_SelectsByDistance_RegardlessOfViewerRange()
|
|
{
|
|
// Retail D3D-style: candidacy is distance-only (the nearest 8). A torch
|
|
// lights its OWN surfaces — the shader applies the hard `d < range` cutoff
|
|
// PER FRAGMENT (mesh_modern.frag) — so a torch the VIEWER is standing
|
|
// outside the range of is still selected; it lights the wall it sits on.
|
|
// Replaces the old viewer-range candidacy filter that suppressed it, which
|
|
// left dungeon rooms (2227 registered torches) at activeLights≈1 / flat 0.2
|
|
// ambient — the "dungeon lighting off" report (#133 A7).
|
|
var mgr = new LightManager();
|
|
mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // viewer outside the torch's range
|
|
|
|
mgr.Tick(viewerWorldPos: Vector3.Zero);
|
|
|
|
Assert.Equal(1, mgr.ActiveCount); // selected by distance; the shader culls per-surface
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_IncludesNearbyLight()
|
|
{
|
|
var mgr = new LightManager();
|
|
// A nearby point light is selected (distance-only candidacy; the shader
|
|
// applies the per-fragment range cutoff).
|
|
mgr.Register(MakePoint(new Vector3(5, 0, 0), range: 5f));
|
|
|
|
mgr.Tick(viewerWorldPos: Vector3.Zero);
|
|
Assert.Equal(1, mgr.ActiveCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_SunSlot0_PreservedAcrossTicks()
|
|
{
|
|
var mgr = new LightManager();
|
|
var sun = new LightSource { Kind = LightKind.Directional, WorldForward = -Vector3.UnitZ };
|
|
mgr.Sun = sun;
|
|
|
|
mgr.Register(MakePoint(Vector3.Zero, 100f));
|
|
mgr.Tick(Vector3.Zero);
|
|
|
|
Assert.Equal(2, mgr.ActiveCount);
|
|
Assert.Same(sun, mgr.Active[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_UnlitLight_Excluded()
|
|
{
|
|
var mgr = new LightManager();
|
|
var light = MakePoint(Vector3.Zero, 100f, lit: false);
|
|
mgr.Register(light);
|
|
|
|
mgr.Tick(Vector3.Zero);
|
|
Assert.Equal(0, mgr.ActiveCount);
|
|
|
|
// Toggle lit: should now appear.
|
|
light.IsLit = true;
|
|
mgr.Tick(Vector3.Zero);
|
|
Assert.Equal(1, mgr.ActiveCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnregisterByOwner_RemovesAttachedLights()
|
|
{
|
|
var mgr = new LightManager();
|
|
mgr.Register(MakePoint(Vector3.Zero, 5f, ownerId: 42));
|
|
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, ownerId: 42));
|
|
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, ownerId: 99));
|
|
|
|
mgr.UnregisterByOwner(42);
|
|
Assert.Equal(1, mgr.RegisteredCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void DistSq_UpdatedEachTick()
|
|
{
|
|
var mgr = new LightManager();
|
|
var light = MakePoint(new Vector3(3, 0, 4), 10f); // dist 5
|
|
mgr.Register(light);
|
|
|
|
mgr.Tick(Vector3.Zero);
|
|
Assert.Equal(25f, light.DistSq, 2);
|
|
|
|
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);
|
|
}
|
|
|
|
// ── Resident collection (#176 corrected reading, 2026-07-06) ───────────────
|
|
// Retail collects the pool from ALL RESIDENT EnvCells each frame:
|
|
// CEnvCell::add_dynamic_lights (0x0052d410) walks the WHOLE static
|
|
// CEnvCell::visible_cell_table — the loaded-cell registry add_visible_cell
|
|
// (0x0052de40) fills from each activated cell + its dat visible-cell list. It
|
|
// is NOT the per-frame portal flood; camera gaze cannot change the pool. The
|
|
// earlier flood-scoped port (c500912b) made the under-room portal purples
|
|
// enter/leave the pool as the camera turned — the #176 seam-floor blink.
|
|
|
|
[Fact]
|
|
public void PointSnapshot_ResidentCollection_CellTagDoesNotFilter()
|
|
{
|
|
var mgr = new LightManager();
|
|
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u)); // "visible" room
|
|
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // under-room
|
|
mgr.Register(MakePoint(new Vector3(3, 0, 0), 5f, cellId: 0u)); // cell-less (viewer fill)
|
|
|
|
mgr.BuildPointLightSnapshot(Vector3.Zero);
|
|
|
|
// ALL resident lights are candidates. The under-room portal light reaching
|
|
// the corridor's pool is retail-correct — the live cdb capture
|
|
// (tools/cdb/issue176-floor-light.cdb) showed retail applying the
|
|
// intensity-100 purples to EVERY Hub cell; the faceted purple wedge is
|
|
// faithful, only its gaze-coupled blinking was ours.
|
|
Assert.Equal(3, mgr.PointSnapshot.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics()
|
|
{
|
|
var mgr = new LightManager();
|
|
// More statics than the cap, ALL nearer the player than every dynamic.
|
|
for (int i = 0; i < LightManager.MaxGlobalLights + 20; i++)
|
|
mgr.Register(MakePoint(new Vector3(i * 0.01f, 0, 0), 5f, ownerId: (uint)(i + 1)));
|
|
// 7 dynamics farther out (retail's dynamics live in their own 7-slot pool —
|
|
// Render::add_dynamic_light 0x0054d420 — statics can never crowd them out).
|
|
var dyns = new LightSource[7];
|
|
for (int i = 0; i < dyns.Length; i++)
|
|
{
|
|
dyns[i] = MakeDynamic(new Vector3(50f + i, 0, 0), range: 9f);
|
|
mgr.Register(dyns[i]);
|
|
}
|
|
|
|
mgr.BuildPointLightSnapshot(Vector3.Zero);
|
|
|
|
Assert.Equal(LightManager.MaxGlobalLights, mgr.PointSnapshot.Count);
|
|
foreach (var d in dyns)
|
|
Assert.Contains(d, mgr.PointSnapshot);
|
|
}
|
|
|
|
[Fact]
|
|
public void PointSnapshot_OverCap_KeepsNearestThePlayer()
|
|
{
|
|
var mgr = new LightManager();
|
|
// A big cluster far from the player (where a chase camera might sit) and
|
|
// one torch beside the player. Retail sorts by distance to
|
|
// Render::player_pos (insert_light 0x0054d1b0) — the near-player torch
|
|
// must survive the cap no matter how many far lights exist.
|
|
for (int i = 0; i < LightManager.MaxGlobalLights + 50; i++)
|
|
mgr.Register(MakePoint(new Vector3(200f + i * 0.05f, 0, 0), 5f, ownerId: (uint)(i + 1)));
|
|
var torch = MakePoint(new Vector3(2f, 0, 0), range: 15f, ownerId: 0xF00Du);
|
|
mgr.Register(torch);
|
|
|
|
mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero);
|
|
|
|
Assert.Contains(torch, mgr.PointSnapshot);
|
|
}
|
|
|
|
// ── Visible-cell scoping (A7.L1, 2026-07-09 — the Town Network starvation fix) ──
|
|
// BuildPointLightSnapshot's player-nearest cap sorts by raw Euclidean distance,
|
|
// which is not a reliable proxy for "same room" in a dense, maze-like hub: a
|
|
// fixture on the other side of a wall can be geometrically closer than the
|
|
// player's own room's torches. The Town Network fountain room (463 registered
|
|
// fixtures, cap 128) went dark because far-denser, closer-in-a-straight-line
|
|
// corridor fixtures won the cap over the room's own lights. Filtering candidacy
|
|
// by the frame's actual visible-cell set (the render already computes this)
|
|
// fixes it without touching the distance-sort anchor (still the PLAYER, per the
|
|
// #176 correction — camera anchoring is what caused the earlier flicker).
|
|
|
|
[Fact]
|
|
public void BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell()
|
|
{
|
|
var mgr = new LightManager();
|
|
|
|
// A different, NOT-visible cell packed with fixtures that are, in raw
|
|
// straight-line distance, closer to the player than the room's own
|
|
// torches (e.g. a corridor on the other side of a wall).
|
|
const uint otherCellId = 0xAAAA0102u;
|
|
for (int i = 0; i < LightManager.MaxGlobalLights + 50; i++)
|
|
mgr.Register(MakePoint(new Vector3(1f + i * 0.001f, 1f, 0), range: 5f, ownerId: (uint)(i + 1), cellId: otherCellId));
|
|
|
|
// The player's own room: a handful of torches, each FARTHER in raw
|
|
// distance than every "other cell" fixture above, but the only cell
|
|
// actually visible from the player's viewpoint this frame.
|
|
const uint roomCellId = 0xAAAA0101u;
|
|
var roomTorches = new LightSource[5];
|
|
for (int i = 0; i < roomTorches.Length; i++)
|
|
{
|
|
roomTorches[i] = MakePoint(new Vector3(50f + i, 0, 0), range: 15f, cellId: roomCellId);
|
|
mgr.Register(roomTorches[i]);
|
|
}
|
|
|
|
var visibleCells = new HashSet<uint> { roomCellId };
|
|
mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero, visibleCells);
|
|
|
|
foreach (var torch in roomTorches)
|
|
Assert.Contains(torch, mgr.PointSnapshot);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded()
|
|
{
|
|
// The viewer fill light (CellId==0) must survive scoping unconditionally —
|
|
// retail's per-frame add_dynamic_light(&viewer_light, ...) is unconditional
|
|
// (LightManager.UpdateViewerLight's doc comment).
|
|
var mgr = new LightManager();
|
|
var viewerFill = MakePoint(new Vector3(0, 0, 2), range: 15f, cellId: 0u);
|
|
mgr.Register(viewerFill);
|
|
var otherRoom = MakePoint(new Vector3(2, 0, 0), range: 5f, cellId: 0xBEEFu);
|
|
mgr.Register(otherRoom);
|
|
|
|
var visibleCells = new HashSet<uint> { 0xF00Du }; // neither light's cell
|
|
mgr.BuildPointLightSnapshot(Vector3.Zero, visibleCells);
|
|
|
|
Assert.Contains(viewerFill, mgr.PointSnapshot);
|
|
Assert.DoesNotContain(otherRoom, mgr.PointSnapshot);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildPointLightSnapshot_NoVisibleCellsArg_UnscopedLegacyBehavior()
|
|
{
|
|
// Outdoor / no-clipRoot callers omit visibleCells — every registered lit
|
|
// light stays a candidate, exactly the pre-A7.L1 behavior.
|
|
var mgr = new LightManager();
|
|
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAAu));
|
|
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xBBBBu));
|
|
|
|
mgr.BuildPointLightSnapshot(Vector3.Zero);
|
|
|
|
Assert.Equal(2, mgr.PointSnapshot.Count);
|
|
}
|
|
|
|
[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]);
|
|
}
|
|
|
|
// ── SelectForCell — retail minimize_envcell_lighting (all dynamics on every cell) ──
|
|
|
|
[Fact]
|
|
public void SelectForCell_AppliesAllDynamicLights_EvenOutOfReach()
|
|
{
|
|
// Retail enables the WHOLE dynamic subset for every cell (cdb-verified: the same
|
|
// portal lights on every Facility Hub cell) — including ones that don't reach it,
|
|
// since the shader's range cutoff zeroes those. Static lights still cull by reach.
|
|
var snapshot = new[]
|
|
{
|
|
MakePoint(new Vector3(1, 0, 0), range: 5f), // 0: static, reaches
|
|
MakeDynamic(new Vector3(100, 0, 0), range: 5f), // 1: dynamic, FAR (out of reach)
|
|
MakeDynamic(new Vector3(2, 0, 0), range: 5f), // 2: dynamic, near
|
|
MakePoint(new Vector3(50, 0, 0), range: 5f), // 3: static, far (out of reach)
|
|
};
|
|
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
|
|
int n = LightManager.SelectForCell(snapshot, Vector3.Zero, radius: 1f, sel);
|
|
|
|
bool d1 = false, d2 = false, s0 = false, s3 = false;
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
if (sel[i] == 1) d1 = true;
|
|
if (sel[i] == 2) d2 = true;
|
|
if (sel[i] == 0) s0 = true;
|
|
if (sel[i] == 3) s3 = true;
|
|
}
|
|
Assert.True(d1, "the FAR dynamic light must still be applied — retail enables all dynamics");
|
|
Assert.True(d2, "the near dynamic light is applied");
|
|
Assert.True(s0, "the near static light reaches the cell → selected");
|
|
Assert.False(s3, "the far static light doesn't reach → not selected");
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectForCell_SameDynamicSet_ForCellsFarApart_NoFlap()
|
|
{
|
|
// The stability retail has and we lacked: two cells far apart get the SAME dynamic
|
|
// set. A per-cell sphere-overlap cull of dynamics (the old SelectForObject path) let
|
|
// that set differ/flip as the flood shifted → the floor lighting FLAPPED (#176).
|
|
var snapshot = new[]
|
|
{
|
|
MakeDynamic(new Vector3(0, 0, 0), range: 5f),
|
|
MakeDynamic(new Vector3(100, 0, 0), range: 5f),
|
|
};
|
|
Span<int> a = stackalloc int[8];
|
|
Span<int> b = stackalloc int[8];
|
|
int na = LightManager.SelectForCell(snapshot, new Vector3(0, 0, 0), 1f, a);
|
|
int nb = LightManager.SelectForCell(snapshot, new Vector3(500, 0, 0), 1f, b);
|
|
|
|
Assert.Equal(2, na); // both dynamics on the near cell
|
|
Assert.Equal(2, nb); // both dynamics on the far cell too — identical, no flap
|
|
}
|
|
|
|
/// <summary>
|
|
/// #176/#177 (2026-07-06, corrected same day) — the end-state pin. The pool is
|
|
/// retail's RESIDENT collection anchored at the PLAYER: a light in range of an
|
|
/// object near the player is selected no matter where a chase camera sits,
|
|
/// because the camera is not an input to <c>BuildPointLightSnapshot</c> at all
|
|
/// (the two prior camera-coupled pools — nearest-camera cap, then frame-flood
|
|
/// scoping <c>c500912b</c> — were each a #176 flicker mechanism). Here the
|
|
/// player stands by the torch while 400 fixtures cluster 200 m away where a
|
|
/// camera might look: the torch must always survive the cap and light the
|
|
/// object. See <c>docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md</c>
|
|
/// (corrected §1.3) — <c>CEnvCell::visible_cell_table</c> is the resident-cell
|
|
/// registry, and <c>Render::insert_light</c> (0x0054d1b0) sorts by distance to
|
|
/// <c>Render::player_pos</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
|
|
{
|
|
var mgr = new LightManager();
|
|
|
|
// 400 fixtures clustered far away (in the direction a camera might sit),
|
|
// all in another cell. Under either old camera-coupled pool these could
|
|
// displace or gate the player-side torch; under the player anchor they are
|
|
// simply the farthest candidates.
|
|
const uint farRoom = 0xAAAA0102u;
|
|
for (int i = 0; i < 400; i++)
|
|
mgr.Register(MakePoint(new Vector3(200f + i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1), cellId: farRoom));
|
|
|
|
// The target torch: beside the player, in the player's room.
|
|
const uint playerRoom = 0xAAAA0101u;
|
|
var torch = MakePoint(new Vector3(2f, 0, 0), range: 15f, ownerId: 0xF00DF00Du, cellId: playerRoom);
|
|
mgr.Register(torch);
|
|
|
|
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
|
|
|
|
// The player (the ONLY positional input) stands at the origin. Rebuild
|
|
// twice to mirror consecutive frames of a rotating camera — the pool and
|
|
// the selection must be identical (no camera input exists to vary).
|
|
mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero);
|
|
int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(0f, 0, 0), 6f, sel);
|
|
bool torchSelected1 = SelectedContains(mgr.PointSnapshot, sel, n1, torch);
|
|
|
|
mgr.BuildPointLightSnapshot(playerWorldPos: Vector3.Zero);
|
|
int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(0f, 0, 0), 6f, sel);
|
|
bool torchSelected2 = SelectedContains(mgr.PointSnapshot, sel, n2, torch);
|
|
|
|
Assert.True(torchSelected1,
|
|
"an in-range light beside the player was evicted from the pool — " +
|
|
"per-cell lighting would pop (the #176/#177 mechanism)");
|
|
Assert.True(torchSelected2, "consecutive same-player builds must select identically");
|
|
Assert.Equal(LightManager.MaxGlobalLights, mgr.PointSnapshot.Count); // cap applied to the far cluster
|
|
|
|
static bool SelectedContains(
|
|
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
|
|
Span<int> indices, int count, LightSource target)
|
|
{
|
|
for (int i = 0; i < count; i++)
|
|
if (ReferenceEquals(snapshot[indices[i]], target)) return true;
|
|
return false;
|
|
}
|
|
}
|
|
}
|