Replace over-cap full sorting with a retained exact top-k heap while preserving the accepted tie-order fallback. Differential tests lock randomized and Town Network-scale output, and the measured 463-light path cuts selector CPU by 29 percent without warmed allocations.
675 lines
30 KiB
C#
675 lines
30 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.Core.Lighting;
|
||
|
||
/// <summary>
|
||
/// Manages the registered dynamic lights in the world and picks the 8
|
||
/// most relevant ones each frame for the shader to consume. Matches
|
||
/// retail's fixed-function-era "8 hardware lights" constraint (r13
|
||
/// §12.2).
|
||
///
|
||
/// <para>
|
||
/// Active-light selection algorithm (r13 §12.2), as implemented by
|
||
/// <see cref="Tick"/>:
|
||
/// <list type="number">
|
||
/// <item><description>
|
||
/// Reserve slot 0 for the sun (directional, infinite range) when present.
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// For every registered lit point/spot light, recompute <c>DistSq</c>
|
||
/// from the viewer and keep the nearest <c>(MaxActiveLights − sunSlot)</c>
|
||
/// directly in the active window via an allocation-free insertion
|
||
/// partial-select (no per-frame list/sort).
|
||
/// </description></item>
|
||
/// </list>
|
||
/// There is deliberately NO viewer-range candidacy filter: each light's
|
||
/// own range cutoff is applied PER SURFACE in the shader
|
||
/// (<c>mesh_modern.frag</c>: <c>d < range</c>), so a torch the viewer
|
||
/// stands outside the range of must still light the wall it sits on. The
|
||
/// earlier <c>Range² × 1.1</c> slack filter wrongly dropped exactly those
|
||
/// lights (the #133 "lighting off" report).
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Not thread-safe — the render thread owns the light list.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class LightManager
|
||
{
|
||
public const int MaxActiveLights = 8; // D3D parity
|
||
|
||
private readonly List<LightSource> _all = new();
|
||
private readonly LightSource?[] _active = new LightSource?[MaxActiveLights];
|
||
private int _activeCount;
|
||
private LightSource? _viewerLight; // retail SmartBox::viewer_light (see UpdateViewerLight)
|
||
|
||
/// <summary>Current cell ambient state applied to everything.</summary>
|
||
public CellAmbientState CurrentAmbient { get; set; }
|
||
|
||
/// <summary>
|
||
/// The sun (or "global directional") — always slot 0 of the active
|
||
/// list. Set this from the <see cref="AcDream.Core.World.WorldTimeService"/>
|
||
/// each frame.
|
||
/// </summary>
|
||
public LightSource? Sun { get; set; }
|
||
|
||
/// <summary>Snapshot of the currently-active lights (up to 8).</summary>
|
||
public ReadOnlySpan<LightSource?> Active => _active.AsSpan(0, _activeCount);
|
||
|
||
public int ActiveCount => _activeCount;
|
||
public int RegisteredCount => _all.Count;
|
||
|
||
/// <summary>Add a light. Idempotent — adding the same instance twice is a no-op.</summary>
|
||
public void Register(LightSource light)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(light);
|
||
foreach (var existing in _all)
|
||
if (ReferenceEquals(existing, light)) return;
|
||
_all.Add(light);
|
||
}
|
||
|
||
/// <summary>Remove by reference.</summary>
|
||
public void Unregister(LightSource light)
|
||
{
|
||
_all.Remove(light);
|
||
}
|
||
|
||
/// <summary>Remove every light attached to a specific entity.</summary>
|
||
public void UnregisterByOwner(uint ownerId)
|
||
{
|
||
_all.RemoveAll(l => l.OwnerId == ownerId);
|
||
}
|
||
|
||
public void Clear()
|
||
{
|
||
_all.Clear();
|
||
Array.Clear(_active);
|
||
_activeCount = 0;
|
||
_viewerLight = null; // re-created + re-registered by the next UpdateViewerLight
|
||
}
|
||
|
||
/// <summary>
|
||
/// Refresh the active-light list for the current viewer position.
|
||
/// Called once per render frame from the render thread; the shader
|
||
/// reads <see cref="Active"/> and uploads to the light UBO.
|
||
/// </summary>
|
||
public void Tick(Vector3 viewerWorldPos)
|
||
{
|
||
// Retail D3D-style fixed-pipeline lighting takes the nearest (MaxActiveLights-1)
|
||
// point lights (slot 0 is the sun) and applies each light's hard range cutoff
|
||
// PER SURFACE in the shader (mesh_modern.frag: `if (d < range && range > 1e-3)`),
|
||
// NOT a viewer-range candidacy filter — a torch the viewer stands outside the
|
||
// range of must still light the wall it sits on.
|
||
//
|
||
// Allocation-free partial selection: the old path built `new List<>(N)` and
|
||
// ran an O(N log N) Sort EVERY FRAME; in a dungeon N is thousands of torches,
|
||
// so that allocated a large list per frame (GC pressure → FPS). Instead keep
|
||
// the nearest maxPoint directly in the _active window, maintained sorted by
|
||
// insertion. O(N · maxPoint), maxPoint ≤ 8, zero allocation.
|
||
Array.Clear(_active);
|
||
_activeCount = 0;
|
||
|
||
// Slot 0 = sun when present (directional; never ranked by distance).
|
||
int baseSlot = 0;
|
||
if (Sun is not null)
|
||
{
|
||
_active[0] = Sun;
|
||
baseSlot = 1;
|
||
}
|
||
|
||
int maxPoint = MaxActiveLights - baseSlot;
|
||
int filled = 0;
|
||
if (maxPoint > 0)
|
||
{
|
||
foreach (var light in _all)
|
||
{
|
||
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
|
||
|
||
Vector3 delta = light.WorldPosition - viewerWorldPos;
|
||
light.DistSq = delta.LengthSquared();
|
||
|
||
// Maintain _active[baseSlot .. baseSlot+filled) sorted ascending by
|
||
// DistSq. Insert if there's room or this light is nearer than the
|
||
// current farthest (then the farthest falls off the end).
|
||
if (filled < maxPoint)
|
||
{
|
||
int j = baseSlot + filled;
|
||
while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq)
|
||
{
|
||
_active[j] = _active[j - 1];
|
||
j--;
|
||
}
|
||
_active[j] = light;
|
||
filled++;
|
||
}
|
||
else if (light.DistSq < _active[baseSlot + maxPoint - 1]!.DistSq)
|
||
{
|
||
int j = baseSlot + maxPoint - 1;
|
||
while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq)
|
||
{
|
||
_active[j] = _active[j - 1];
|
||
j--;
|
||
}
|
||
_active[j] = light;
|
||
}
|
||
}
|
||
}
|
||
|
||
_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. #176 root-cause history (2026-07-06, corrected): retail's pool is
|
||
/// collected from ALL RESIDENT EnvCells (<c>CEnvCell::add_dynamic_lights</c>
|
||
/// 0x0052d410 walks the static <c>CEnvCell::visible_cell_table</c> — the
|
||
/// loaded-cell registry that <c>add_visible_cell</c> 0x0052de40 fills from each
|
||
/// activated cell + its dat visible-cell list; NOT the per-frame portal flood)
|
||
/// and capped nearest-THE-PLAYER (<c>Render::insert_light</c> 0x0054d1b0 sorts
|
||
/// by distance to <c>Render::player_pos</c>) with small caps (7 dynamic + 40
|
||
/// static, <c>0x0081ec94/98</c>). Two prior acdream models both flickered
|
||
/// because their pool was CAMERA-coupled: (1) nearest-CAMERA-128 over all
|
||
/// registered lights (chase-boom swing churned the eviction boundary), then
|
||
/// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal
|
||
/// purples entered/left the pool as the camera turned — the seam-floor
|
||
/// blink; probe: [seam-blk]/[seam-snap]). Current model: all registered
|
||
/// (=resident) lit lights optionally FILTERED by last frame's rendered
|
||
/// visible-cell set (A7.L1, 2026-07-09 — <see cref="BuildPointLightSnapshot"/>'s
|
||
/// <c>visibleCells</c> param; fixes Town Network starvation without
|
||
/// reproducing c500912b — see that method's doc), then dynamics-first nearest-
|
||
/// player, capped here. 128 is wider than retail's 40+7 — a documented backstop
|
||
/// that in a properly cell-scoped room only ever evicts far-out-of-range
|
||
/// statics; adopting retail's exact dual-pool caps + degrade levels is A7-arc
|
||
/// work. The 1024 uncap remains refuted (striped-floor artifact + the unported
|
||
/// static 1/d³ fixture curve, A7 fix #2). Register row AP-85.</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;
|
||
internal bool LastPointSnapshotUsedBoundedSelection { get; private set; }
|
||
internal bool LastPointSnapshotUsedTieFallback { get; private set; }
|
||
|
||
// Slice H-b: keep only the best MaxGlobalLights entries in a retained
|
||
// max-heap. Rank includes qualifying registration order because retail
|
||
// insert_light (0x0054D1B0) advances past equal-distance residents.
|
||
private readonly List<RankedLight> _pointSelectionHeap =
|
||
new(MaxGlobalLights);
|
||
private Comparison<RankedLight>? _rankComparison;
|
||
private Vector3 _legacyPoolAnchor;
|
||
private Comparison<LightSource>? _legacyPoolComparison;
|
||
|
||
/// <summary>
|
||
/// Rebuild <see cref="PointSnapshot"/> from ALL registered lit point/spot
|
||
/// lights — retail's per-frame collection over the RESIDENT-cell registry.
|
||
/// The sun and unlit lights are excluded (the sun is global ambient-path;
|
||
/// unlit torches contribute nothing).
|
||
/// <para>
|
||
/// Retail anchors (#176 corrected reading, 2026-07-06):
|
||
/// <c>CEnvCell::add_dynamic_lights</c> (0x0052d410) walks the WHOLE static
|
||
/// <c>CEnvCell::visible_cell_table</c> — the resident-EnvCell registry that
|
||
/// <c>CEnvCell::add_visible_cell</c> (0x0052de40) populates from each activated
|
||
/// cell plus its dat visible-cell list (it <c>DBObj::Get</c>-loads absent cells;
|
||
/// entries leave only via the flush machinery). It is NOT the per-frame portal
|
||
/// flood: camera gaze cannot remove a cell from it. acdream's <c>_all</c>
|
||
/// (register at hydration, unregister at unload) is that resident set, so the
|
||
/// collection is simply every registered lit light. The under-room portal
|
||
/// purples reaching the corridor's pool is retail-correct (cdb: retail applies
|
||
/// them to every Hub cell) — the faceted purple wedge is faithful.
|
||
/// </para>
|
||
/// <para>
|
||
/// When more than <see cref="MaxGlobalLights"/> qualify, DYNAMICS are kept
|
||
/// first (retail's dynamic lights live in their own 7-slot pool —
|
||
/// <c>Render::add_dynamic_light</c> 0x0054d420 — and never compete with
|
||
/// statics), then the nearest THE PLAYER (<c>Render::insert_light</c>
|
||
/// 0x0054d1b0 insertion-sorts by squared distance to <c>Render::player_pos</c>,
|
||
/// set from <c>player->m_position</c>, SmartBox 0x00453d3a, with the
|
||
/// viewer-cell fallback 0x00455ab6). The distance SORT is therefore a function
|
||
/// of PLAYER position and light registration ONLY — camera rotation/position
|
||
/// cannot change it (both prior camera-ANCHORED pools — nearest-camera cap;
|
||
/// <c>c500912b</c>'s camera-seeded re-flood — produced the #176 seam-floor
|
||
/// purple blink by making the SORT itself camera-dependent). The optional
|
||
/// <paramref name="visibleCells"/> candidacy FILTER (A7.L1) does not change
|
||
/// this: it narrows the input set before the player-anchored sort runs, using
|
||
/// a value the caller captured from last frame's already-rendered draw list,
|
||
/// not a fresh camera-seeded computation performed here. Call once per frame
|
||
/// before per-object selection.
|
||
/// </para>
|
||
/// </summary>
|
||
/// <param name="playerWorldPos">The player's world position (render position;
|
||
/// callers pass the camera position only when no player exists — retail's
|
||
/// player/viewer branch).</param>
|
||
/// <param name="visibleCells">
|
||
/// A7.L1 (2026-07-09) — optional visible-cell scoping. When non-null, a light
|
||
/// is a candidate only if it is cell-less (<c>CellId == 0</c> — the viewer fill,
|
||
/// always in scope) or its <c>CellId</c> is in this set. Fixes the Town Network
|
||
/// starvation case (463 registered fixtures): the 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 and win the cap,
|
||
/// leaving the visible room dark. Scoping candidacy to the frame's actual
|
||
/// visible cells (the render already computes this — callers pass last frame's
|
||
/// <c>RetailPViewFrameResult.DrawableCells</c>, one frame of latency, to avoid
|
||
/// re-threading a mid-render callback) removes those from contention before the
|
||
/// cap ever applies. The distance-sort anchor stays the PLAYER either way — this
|
||
/// parameter only narrows candidacy, it does not change the sort (the #176
|
||
/// correction: CAMERA anchoring, not cell scoping itself, caused the earlier
|
||
/// seam-floor flicker regression, c500912b). Null (the default) preserves the
|
||
/// legacy unscoped behavior — outdoor / no-clipRoot callers pass null.
|
||
/// </param>
|
||
public void BuildPointLightSnapshot(Vector3 playerWorldPos, IReadOnlySet<uint>? visibleCells = null)
|
||
{
|
||
_pointSnapshot.Clear();
|
||
_pointSelectionHeap.Clear();
|
||
int qualifyingOrdinal = 0;
|
||
bool overflow = false;
|
||
LastPointSnapshotUsedBoundedSelection = false;
|
||
LastPointSnapshotUsedTieFallback = false;
|
||
foreach (var light in _all)
|
||
{
|
||
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
|
||
if (visibleCells is not null && light.CellId != 0 && !visibleCells.Contains(light.CellId)) continue;
|
||
|
||
var ranked = new RankedLight(
|
||
light,
|
||
qualifyingOrdinal++,
|
||
Vector3.DistanceSquared(
|
||
light.WorldPosition,
|
||
playerWorldPos));
|
||
_pointSnapshot.Add(light);
|
||
if (_pointSnapshot.Count <= MaxGlobalLights)
|
||
continue;
|
||
|
||
if (!overflow)
|
||
{
|
||
for (int index = 0;
|
||
index < MaxGlobalLights;
|
||
index++)
|
||
{
|
||
LightSource existing = _pointSnapshot[index];
|
||
_pointSelectionHeap.Add(new RankedLight(
|
||
existing,
|
||
index,
|
||
Vector3.DistanceSquared(
|
||
existing.WorldPosition,
|
||
playerWorldPos)));
|
||
}
|
||
HeapifyWorstFirst(_pointSelectionHeap);
|
||
overflow = true;
|
||
}
|
||
|
||
// Root is the currently-worst selected rank. A later light at the
|
||
// same distance ranks after an earlier resident, matching retail.
|
||
if (CompareRankedLights(ranked, _pointSelectionHeap[0]) < 0)
|
||
{
|
||
_pointSelectionHeap[0] = ranked;
|
||
SiftWorstDown(_pointSelectionHeap, 0);
|
||
}
|
||
}
|
||
|
||
if (overflow)
|
||
{
|
||
_rankComparison ??= CompareRankedLights;
|
||
_pointSelectionHeap.Sort(_rankComparison);
|
||
bool comparatorTie =
|
||
SelectedRanksContainObservableTie(playerWorldPos);
|
||
if (comparatorTie)
|
||
{
|
||
LastPointSnapshotUsedTieFallback = true;
|
||
// The previous List.Sort comparator intentionally returned zero
|
||
// for equal pool/distance ranks. List.Sort is unstable, so its
|
||
// exact tie permutation is observable in shader indices. Keep
|
||
// that legacy oracle for tied frames; H-b is performance-only.
|
||
// AP-85's eventual dual-pool port can adopt retail's stable tie
|
||
// insertion as a separately visual-gated behavior change.
|
||
_legacyPoolAnchor = playerWorldPos;
|
||
_legacyPoolComparison ??= CompareLegacyPoolLights;
|
||
_pointSnapshot.Sort(_legacyPoolComparison);
|
||
_pointSnapshot.RemoveRange(
|
||
MaxGlobalLights,
|
||
_pointSnapshot.Count - MaxGlobalLights);
|
||
}
|
||
else
|
||
{
|
||
LastPointSnapshotUsedBoundedSelection = true;
|
||
_pointSnapshot.Clear();
|
||
for (int index = 0;
|
||
index < _pointSelectionHeap.Count;
|
||
index++)
|
||
{
|
||
_pointSnapshot.Add(
|
||
_pointSelectionHeap[index].Light);
|
||
}
|
||
}
|
||
}
|
||
|
||
// A7.L1 SET-COMPOSITION probe. Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1;
|
||
// the flag check keeps it zero-cost off.
|
||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled)
|
||
AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(_all, _pointSnapshot);
|
||
}
|
||
|
||
private static int CompareRankedLights(
|
||
RankedLight left,
|
||
RankedLight right)
|
||
{
|
||
if (left.Light.IsDynamic != right.Light.IsDynamic)
|
||
return left.Light.IsDynamic ? -1 : 1;
|
||
|
||
int distance = left.DistanceSq.CompareTo(right.DistanceSq);
|
||
return distance != 0
|
||
? distance
|
||
: left.QualifyingOrdinal.CompareTo(right.QualifyingOrdinal);
|
||
}
|
||
|
||
private bool SelectedRanksContainObservableTie(Vector3 playerWorldPos)
|
||
{
|
||
for (int index = 1;
|
||
index < _pointSelectionHeap.Count;
|
||
index++)
|
||
{
|
||
if (HaveSameLegacyRank(
|
||
_pointSelectionHeap[index - 1],
|
||
_pointSelectionHeap[index]))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// A tie may straddle the cap with only one copy in the selected heap.
|
||
// Such a tie can change which light the old unstable List.Sort kept.
|
||
RankedLight cutoff = _pointSelectionHeap[^1];
|
||
int cutoffMatches = 0;
|
||
for (int index = 0; index < _pointSnapshot.Count; index++)
|
||
{
|
||
LightSource light = _pointSnapshot[index];
|
||
if (light.IsDynamic != cutoff.Light.IsDynamic)
|
||
continue;
|
||
|
||
float distance = Vector3.DistanceSquared(
|
||
light.WorldPosition,
|
||
playerWorldPos);
|
||
if (distance.CompareTo(cutoff.DistanceSq) != 0)
|
||
continue;
|
||
|
||
if (++cutoffMatches > 1)
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private static bool HaveSameLegacyRank(
|
||
RankedLight left,
|
||
RankedLight right) =>
|
||
left.Light.IsDynamic == right.Light.IsDynamic
|
||
&& left.DistanceSq.CompareTo(right.DistanceSq) == 0;
|
||
|
||
private int CompareLegacyPoolLights(
|
||
LightSource left,
|
||
LightSource right)
|
||
{
|
||
if (left.IsDynamic != right.IsDynamic)
|
||
return left.IsDynamic ? -1 : 1;
|
||
|
||
float leftDistance = Vector3.DistanceSquared(
|
||
left.WorldPosition,
|
||
_legacyPoolAnchor);
|
||
float rightDistance = Vector3.DistanceSquared(
|
||
right.WorldPosition,
|
||
_legacyPoolAnchor);
|
||
return leftDistance.CompareTo(rightDistance);
|
||
}
|
||
|
||
private static void HeapifyWorstFirst(List<RankedLight> heap)
|
||
{
|
||
for (int index = heap.Count / 2 - 1;
|
||
index >= 0;
|
||
index--)
|
||
{
|
||
SiftWorstDown(heap, index);
|
||
}
|
||
}
|
||
|
||
private static void SiftWorstDown(
|
||
List<RankedLight> heap,
|
||
int index)
|
||
{
|
||
while (true)
|
||
{
|
||
int left = checked(index * 2 + 1);
|
||
if (left >= heap.Count)
|
||
return;
|
||
|
||
int right = left + 1;
|
||
int worse = right < heap.Count
|
||
&& CompareRankedLights(heap[right], heap[left]) > 0
|
||
? right
|
||
: left;
|
||
if (CompareRankedLights(heap[worse], heap[index]) <= 0)
|
||
return;
|
||
|
||
(heap[index], heap[worse]) =
|
||
(heap[worse], heap[index]);
|
||
index = worse;
|
||
}
|
||
}
|
||
|
||
private readonly record struct RankedLight(
|
||
LightSource Light,
|
||
int QualifyingOrdinal,
|
||
float DistanceSq);
|
||
|
||
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
|
||
// Retail adds a white fill light pinned to the player EVERY frame via
|
||
// Render::add_dynamic_light. It is the dominant INTERIOR fill: the outdoor
|
||
// stage runs useSunlightSet(1) (sun only — dynamics are NOT enabled), but the
|
||
// interior stage runs minimize_envcell_lighting, which enables the dynamic
|
||
// lights, so EnvCell walls + indoor objects are lit by this viewer light while
|
||
// the player is inside. acdream registered NO dynamic lights at all, so
|
||
// interiors had only flat ambient and read dark/cool vs retail's lit rooms.
|
||
//
|
||
// It rides the existing point-light path: registered in _all ⇒ included in
|
||
// BuildPointLightSnapshot ⇒ selected by SelectForObject for nearby cells /
|
||
// objects. The AP-43 indoor gate (WbDrawDispatcher.IndoorObjectReceivesTorches)
|
||
// already restricts per-object point lights to EnvCell-parented objects, and
|
||
// only EnvCellRenderer selects per-cell lights, so the viewer light lights
|
||
// ONLY indoor draws — matching retail's interior-stage-only dynamic enable.
|
||
// Terrain has no point-light path, so it is unaffected.
|
||
//
|
||
// Params from the live cdb capture (reference_retail_ambient_values.md):
|
||
// intensity 2.25, falloff 10, colour white, offset (0,0,2) above the player
|
||
// (SmartBox::set_viewer player branch). Dynamic lights use rangeAdjust 1.5
|
||
// (config_hardware_light 0x0059ad30) ⇒ Range = 10 × 1.5 = 15 m.
|
||
public const float ViewerLightIntensity = 2.25f; // GRV SmartBox.ViewerLightIntensity
|
||
public const float ViewerLightFalloff = 10f; // GRV SmartBox.ViewerLightFalloff
|
||
private const uint ViewerLightOwnerId = 0xFFFFFFFFu; // sentinel; never an entity id
|
||
|
||
/// <summary>
|
||
/// Reposition the always-on viewer fill light at the player (offset +2 m up),
|
||
/// registering it on first call. Call once per frame BEFORE
|
||
/// <see cref="BuildPointLightSnapshot"/> / <see cref="Tick"/>. Mirrors retail's
|
||
/// per-frame <c>SmartBox::set_viewer</c> add_dynamic_light; here the light lives
|
||
/// in <c>_all</c> and is repositioned so the existing snapshot + per-object
|
||
/// selection light the cell around the player. Indoor-only via the AP-43 gate
|
||
/// (see the note above).
|
||
/// </summary>
|
||
public void UpdateViewerLight(Vector3 playerWorldPos)
|
||
{
|
||
if (_viewerLight is null)
|
||
{
|
||
_viewerLight = new LightSource
|
||
{
|
||
Kind = LightKind.Point,
|
||
ColorLinear = Vector3.One, // white (1,1,1)
|
||
Intensity = ViewerLightIntensity,
|
||
Range = ViewerLightFalloff * 1.5f, // dynamic rangeAdjust 1.5
|
||
OwnerId = ViewerLightOwnerId,
|
||
IsLit = true,
|
||
IsDynamic = true, // #143: D3D 1/d attenuation (soft fill, not 1/d³)
|
||
};
|
||
_all.Add(_viewerLight);
|
||
}
|
||
_viewerLight.WorldPosition = playerWorldPos + new Vector3(0f, 0f, 2f);
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Per-CELL light selection — retail <c>minimize_envcell_lighting</c> (0x0054c170).
|
||
/// Unlike <see cref="SelectForObject"/> (per-object sphere-overlap cull), retail enables
|
||
/// the ENTIRE dynamic subset for EVERY EnvCell it draws (verified by a live cdb trace of
|
||
/// <c>config_hardware_light</c>: the same 4 intensity-100 portal lights are applied to
|
||
/// every Facility Hub cell, every frame). So here: ALL dynamic lights are added
|
||
/// unconditionally (the shader's per-light range cutoff zeroes ones that don't reach —
|
||
/// same as D3D's hardware range), THEN remaining slots fill with the nearest STATIC lights
|
||
/// that reach the cell sphere. This is what makes a cell's floor lighting STABLE as the
|
||
/// portal flood shifts — a per-cell sphere-overlap cull of the dynamics is what made the
|
||
/// floor lighting FLAP (#176). Objects keep <see cref="SelectForObject"/>
|
||
/// (retail minimize_object_lighting).
|
||
/// </summary>
|
||
public static int SelectForCell(
|
||
IReadOnlyList<LightSource> snapshot,
|
||
Vector3 center,
|
||
float radius,
|
||
Span<int> outIndices)
|
||
{
|
||
int cap = Math.Min(outIndices.Length, MaxLightsPerObject);
|
||
if (cap <= 0) return 0;
|
||
|
||
int count = 0;
|
||
|
||
// 1) ALL dynamic lights, unconditionally (retail applies the whole dynamic subset to
|
||
// every cell — stable regardless of the cell's relation to each light).
|
||
for (int li = 0; li < snapshot.Count && count < cap; li++)
|
||
if (snapshot[li].IsDynamic)
|
||
outIndices[count++] = li;
|
||
|
||
// 2) Fill remaining slots with the nearest STATIC lights that reach the cell sphere,
|
||
// insertion-sorted among the static slots only (dynamic slots [0..staticStart) are fixed).
|
||
int staticStart = count;
|
||
Span<float> keptDistSq = stackalloc float[MaxLightsPerObject];
|
||
for (int li = 0; li < snapshot.Count; li++)
|
||
{
|
||
var light = snapshot[li];
|
||
if (light.IsDynamic) continue; // dynamics already added
|
||
float reach = light.Range + radius;
|
||
float dsq = (light.WorldPosition - center).LengthSquared();
|
||
if (dsq >= reach * reach) continue;
|
||
|
||
if (count < cap)
|
||
{
|
||
int j = count;
|
||
while (j > staticStart && keptDistSq[j - 1] > dsq)
|
||
{
|
||
keptDistSq[j] = keptDistSq[j - 1];
|
||
outIndices[j] = outIndices[j - 1];
|
||
j--;
|
||
}
|
||
keptDistSq[j] = dsq;
|
||
outIndices[j] = li;
|
||
count++;
|
||
}
|
||
else if (staticStart < cap && dsq < keptDistSq[cap - 1])
|
||
{
|
||
int j = cap - 1;
|
||
while (j > staticStart && keptDistSq[j - 1] > dsq)
|
||
{
|
||
keptDistSq[j] = keptDistSq[j - 1];
|
||
outIndices[j] = outIndices[j - 1];
|
||
j--;
|
||
}
|
||
keptDistSq[j] = dsq;
|
||
outIndices[j] = li;
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
}
|