feat(lighting): A7 visible-cell light scoping + [indoor-light] probe (NOT the #176/#177 fix)
Port retail's per-frame light collection: the point-light pool is built from ONLY the currently-visible cells' lights, matching CObjCell::add_*_to_global_lights (0x0052b350/0x0052b390) walked over CEnvCell::visible_cell_table (0x0052d410) — not a flat world-space set capped at 128-nearest-camera. - LightSource.CellId (retail insert_light arg6 -> RenderLight +0x6c); tagged at both registration sites from entity.ParentCellId (live weenie fixtures + dat EnvCell statics). - LightManager.BuildPointLightSnapshot(camPos, visibleCells): a light joins the pool iff CellId==0 (viewer/global) or its cell is in the flood. 128 cap kept as a now-non-biting backstop (retail's is 40 static + 7 dynamic, 0x0081ec94/8). - Threaded via RetailPViewDrawContext.RebuildScopedLights, invoked in DrawInside after the flood resolves prepareCells and before the draws (renderers select from the same in-place-rebuilt PointSnapshot; EnvCellRenderer clears its per-cell cache each pass). - [indoor-light] probe (ACDREAM_PROBE_INDOOR_LIGHT=1) dumps the scoped-pool SET COMPOSITION. Un-skips LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant. CORRECTION: the handoff called the camera-cap the "confirmed" #176/#177 mechanism. The probe PROVES scoping works (291 Hub fixtures -> pool of 1-9, ~285 through-floor lights dropped/frame, CellIds match the flood), but the user's VISUAL GATE showed BOTH symptoms unchanged. So pool composition is NOT the cause. #176 real cause = an over-bright purple point light (intensity=100, color 0.784,0,0.784 -- from [light-detail]); #177 = a portal-visibility miss (stairs not drawn looking back). Both stay OPEN. This change is retail-faithful and retires the camera-eviction latent bug; kept as such, not as the symptom fix. Register AP-85 corrected; ISSUES #176/#177 re-diagnosed; render digest banner updated. Decomp: insert_light 0x0054d1b0, minimize_object_lighting 0x0054d480, calc_point_light 0x0059c8b0; pseudocode docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md. Suites green: Core 2595 + 2 skip, App 719 + 2 skip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e1746ca10d
commit
c500912bf8
10 changed files with 516 additions and 35 deletions
|
|
@ -94,6 +94,13 @@ investigation (`docs/research/2026-07-06-176-177-render-pair-investigation.md`).
|
||||||
|
|
||||||
## #177 — Dungeon stairs pop in/out across levels (invisible until entering the room; last step vanishes running down)
|
## #177 — Dungeon stairs pop in/out across levels (invisible until entering the room; last step vanishes running down)
|
||||||
|
|
||||||
|
**⚠️ UPDATE 2026-07-06 (visual gate) — this is NOT lighting.** The A7 visible-cell
|
||||||
|
light-scoping fix shipped + was probe-validated, but the user's gate showed the stairs
|
||||||
|
STILL not visible looking back from the corridor (zoom-out changes the last-step case).
|
||||||
|
Eye-position/flood behavior ⇒ a portal-VISIBILITY miss at the stair cells
|
||||||
|
(0178/0182/0183), NOT the "its LIGHTS went dark" attribution recorded below. Re-diagnose
|
||||||
|
as visibility. See the render digest banner.
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** OPEN
|
||||||
**Severity:** MEDIUM (visible geometry churn in the M1.5 dungeon)
|
**Severity:** MEDIUM (visible geometry churn in the M1.5 dungeon)
|
||||||
**Filed:** 2026-07-06
|
**Filed:** 2026-07-06
|
||||||
|
|
@ -142,6 +149,14 @@ the connecting opening, and stays rendered through the full descent.
|
||||||
|
|
||||||
## #176 — Purple flashing on dungeon floors at cell seams, camera-angle dependent
|
## #176 — Purple flashing on dungeon floors at cell seams, camera-angle dependent
|
||||||
|
|
||||||
|
**⚠️ UPDATE 2026-07-06 (visual gate) — the light-set/camera-cap theory is REFUTED.** The
|
||||||
|
A7 scoping fix shipped + was probe-validated (~285 through-floor lights dropped/frame),
|
||||||
|
yet the purple flash was UNCHANGED. `[light-detail]` names the real cause: a single
|
||||||
|
over-bright purple POINT light — `kind=Point range=9 intensity=100 color=(0.784,0,0.784)`
|
||||||
|
(200/255,0,200/255) — washing the floor. NEXT: identify its owning entity/Setup; decide
|
||||||
|
whether `intensity=100` is a dat mis-parse or a portal/effect light we render wrong. Not
|
||||||
|
set-composition, not through-floor. See the render digest banner.
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** OPEN
|
||||||
**Severity:** MEDIUM (visible artifact along every corridor seam in the M1.5 dungeon)
|
**Severity:** MEDIUM (visible artifact along every corridor seam in the M1.5 dungeon)
|
||||||
**Filed:** 2026-07-06
|
**Filed:** 2026-07-06
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
|
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
|
||||||
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
|
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
|
||||||
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
|
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
|
||||||
| AP-85 | **Per-frame flat point-light snapshot capped at the 128 lights nearest THE CAMERA** (`BuildPointLightSnapshot`); retail registers lights per-CELL (`insert_light` 0x0054d1b0) and `minimize_object_lighting` (0x0054d480) consults the reaching set with NO global pool cap. The cap BITES in the Facility Hub (366 registered fixtures → 238 evictions/frame) and the eviction is the CONFIRMED mechanism of #176 (purple seam flash — an in-range torch of a visible cell ranks past the cap and drops from that cell's 8-set; per-cell Gouraud pops as the camera moves) + #177 (a stair room's fixtures all past the cap render it 0.2-ambient-dark until approach). ⚠️ Raising to 1024 was live-tested 2026-07-06 and REVERTED: the uncapped pool exposes (a) light-through-solid-floors (no per-cell reach/occlusion — the under-room portal light washes the corridor above), (b) stationary weenie fixtures on the DYNAMIC 1/d falloff (~9× retail's static 1/d³ at 3 m; #143 misassignment for ACE-served fixtures), (c) an unexplained striped floor artifact. Fix = the A7 arc: per-cell light registration + static curve for fixtures + the stripe hunt, THEN uncap | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights` — the load-bearing-stopgap comment); desired-end-state pin (Skip) `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The 128 cap keeps the light pool local to the camera, which accidentally APPROXIMATES per-cell reach (far lights can't leak through floors into view) — the least-wrong state until A7 ports real per-cell registration | The #176/#177 pop class stays live until A7 (purple flashes at seams; unlit rooms popping lit on approach); any dungeon with >128 fixtures has camera-dependent per-cell lighting | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration); `calc_point_light` 0x0059c8b0 (static 1/d³ bake curve) |
|
| AP-85 | **⚠️ CORRECTED 2026-07-06: visible-cell scoping SHIPPED (`LightSource.CellId` from `entity.ParentCellId` + `BuildPointLightSnapshot(camPos, visibleCells)` over the portal-flood set; probe-proven to drop ~285 through-floor lights/frame in the Hub; end-state pin un-skipped). The visual gate REFUTED the light-cap #176/#177 attribution stated below — BOTH symptoms were UNCHANGED, so #176 = an intensity-100 purple point light `(0.784,0,0.784)` and #177 = a portal-visibility miss (see digest banner). Residual deviation now: 128 backstop vs retail 40+7 (0x0081ec94/8), no dynamic-priority split — benign (visible-scoped pool is 1–9). HISTORICAL claim below.** **Per-frame flat point-light snapshot capped at the 128 lights nearest THE CAMERA** (`BuildPointLightSnapshot`); retail registers lights per-CELL (`insert_light` 0x0054d1b0) and `minimize_object_lighting` (0x0054d480) consults the reaching set with NO global pool cap. The cap BITES in the Facility Hub (366 registered fixtures → 238 evictions/frame) and the eviction is the CONFIRMED mechanism of #176 (purple seam flash — an in-range torch of a visible cell ranks past the cap and drops from that cell's 8-set; per-cell Gouraud pops as the camera moves) + #177 (a stair room's fixtures all past the cap render it 0.2-ambient-dark until approach). ⚠️ Raising to 1024 was live-tested 2026-07-06 and REVERTED: the uncapped pool exposes (a) light-through-solid-floors (no per-cell reach/occlusion — the under-room portal light washes the corridor above), (b) stationary weenie fixtures on the DYNAMIC 1/d falloff (~9× retail's static 1/d³ at 3 m; #143 misassignment for ACE-served fixtures), (c) an unexplained striped floor artifact. Fix = the A7 arc: per-cell light registration + static curve for fixtures + the stripe hunt, THEN uncap | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights` — the load-bearing-stopgap comment); desired-end-state pin (Skip) `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The 128 cap keeps the light pool local to the camera, which accidentally APPROXIMATES per-cell reach (far lights can't leak through floors into view) — the least-wrong state until A7 ports real per-cell registration | The #176/#177 pop class stays live until A7 (purple flashes at seams; unlit rooms popping lit on approach); any dungeon with >128 fixtures has camera-dependent per-cell lighting | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration); `calc_point_light` 0x0059c8b0 (static 1/d³ bake curve) |
|
||||||
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
|
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
|
||||||
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
|
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
|
||||||
|
|
||||||
|
|
|
||||||
268
docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md
Normal file
268
docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
# A7 dungeon lighting — retail per-cell light model (source-confirmed pseudocode)
|
||||||
|
|
||||||
|
**Date:** 2026-07-06 (continuation of the #176/#177 arc)
|
||||||
|
**Purpose:** the mandated `grep named → decompile → pseudocode → port` step 3 for
|
||||||
|
the A7 per-cell lighting fix. Captures the RETAIL light-selection model exactly as
|
||||||
|
read from `docs/research/named-retail/acclient_2013_pseudo_c.txt`, so the port can
|
||||||
|
match it line-for-line.
|
||||||
|
|
||||||
|
> ⚠️ **This document CORRECTS the #176/#177 handoff's framing.** The handoff
|
||||||
|
> (`2026-07-06-176-177-handoff-A7-lighting.md`) and the digest banner state that
|
||||||
|
> "retail registers lights per-CELL via `insert_light` 0x0054d1b0" and that
|
||||||
|
> "retail's `minimize_object_lighting` has NO global camera-nearest pool cap."
|
||||||
|
> **Both are imprecise.** Reading the source: `insert_light` maintains a GLOBAL
|
||||||
|
> player-nearest sorted pool with a SMALL cap (40 static + 7 dynamic), functionally
|
||||||
|
> analogous to acdream's `BuildPointLightSnapshot`. The real per-cell mechanism is
|
||||||
|
> the *collection phase*: retail rebuilds that global pool **each frame from only
|
||||||
|
> the currently-VISIBLE cells** (`CEnvCell::add_*_lights` walks the portal-flood
|
||||||
|
> `visible_cell_table`). That is why retail's tiny cap never bites — the candidate
|
||||||
|
> pool is pre-scoped by visibility, not by camera distance over the whole dungeon.
|
||||||
|
> This is a *better* fit for acdream than the handoff's framing, because acdream
|
||||||
|
> already computes the visible-cell set every frame (the portal flood).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The retail model, as source-confirmed
|
||||||
|
|
||||||
|
### 1.1 Each cell owns a light list (`CObjCell` / `CEnvCell`)
|
||||||
|
|
||||||
|
- `CObjCell::add_light(this, LIGHTOBJ*)` (`0x0052b1d0`) — appends a light to the
|
||||||
|
cell's own `light_list` (a `DArray<LIGHTOBJ const*>`), `num_lights` counter.
|
||||||
|
Populated at cell load: `CEnvCell::UnPack` (`0x0052d470`) unpacks `num_lights`
|
||||||
|
(line ~310877) and the light list straight from the dat CellStruct; the outdoor
|
||||||
|
path feeds it from the landblock's static object lights (caller at line ~285976,
|
||||||
|
`CObjCell::add_light(cell, lights->lightobj + i)`).
|
||||||
|
- So a light is DATA owned by the cell it sits in — dungeon torches live in the
|
||||||
|
EnvCell's `light_list`; a landblock's lamp-posts live in the LandCell's list.
|
||||||
|
|
||||||
|
### 1.2 A cell pushes its own lights to the global pool
|
||||||
|
|
||||||
|
```
|
||||||
|
CObjCell::add_static_to_global_lights(cell): # 0x0052b350
|
||||||
|
for lightobj in cell.light_list[0 .. cell.num_lights):
|
||||||
|
if (lightobj.flags & 1) != 0: # bit 0 set = STATIC light
|
||||||
|
Render::add_static_light(lightobj.info, cell.m_DID.id, lightobj.frame)
|
||||||
|
|
||||||
|
CObjCell::add_dynamic_to_global_lights(cell): # 0x0052b390
|
||||||
|
for lightobj in cell.light_list[0 .. cell.num_lights):
|
||||||
|
if (lightobj.flags & 1) == 0: # bit 0 clear = DYNAMIC light
|
||||||
|
Render::add_dynamic_light(lightobj.info, cell.m_DID.id, lightobj.frame)
|
||||||
|
```
|
||||||
|
|
||||||
|
The cell id (`cell.m_DID.id`) is passed through as `arg6` so the light carries its
|
||||||
|
owning cell (stored at `+0x6c` on the RenderLight; used by `insert_light` for the
|
||||||
|
block-offset distance math).
|
||||||
|
|
||||||
|
### 1.3 Per frame, ONLY visible cells contribute (the crux)
|
||||||
|
|
||||||
|
```
|
||||||
|
CEnvCell::add_dynamic_lights(): # 0x0052d410
|
||||||
|
for cell in CEnvCell::visible_cell_table: # the PORTAL-FLOOD visible set
|
||||||
|
CObjCell::add_dynamic_to_global_lights(cell)
|
||||||
|
|
||||||
|
# static counterpart — same function that ends at 0x0052def0 (line ~311650):
|
||||||
|
for cell in CEnvCell::visible_cell_table: # SAME visible set
|
||||||
|
cell.init_static_objects()
|
||||||
|
CObjCell::init_objects(cell)
|
||||||
|
CObjCell::add_static_to_global_lights(cell)
|
||||||
|
```
|
||||||
|
|
||||||
|
`visible_cell_table` is the set of cells reached by the portal flood from the
|
||||||
|
viewer's cell (retail `CEnvCell::find_visible_cells` / the `PView` gather). **A
|
||||||
|
dungeon with 366 fixtures but only 5 visible cells contributes only those 5 cells'
|
||||||
|
lights to the global pool.** This is the entire reason retail doesn't churn.
|
||||||
|
|
||||||
|
### 1.4 The global pool is small and player-sorted (`insert_light`)
|
||||||
|
|
||||||
|
```
|
||||||
|
Render::insert_light(maxCount, &num, lights[], sorted[], info, cellId, frame, base): # 0x0054d1b0
|
||||||
|
distsq = 0
|
||||||
|
if info.type == 0: # point light
|
||||||
|
# squared distance from THIS light to the PLAYER, across the cell block offset
|
||||||
|
blockOff = LandDefs::get_block_offset(player_pos.objcell_id, cellId)
|
||||||
|
distsq = |(frame.origin + blockOff) - player_pos.frame.origin|²
|
||||||
|
# ... write RenderLight fields (color/255, intensity, falloff, cone, distancesq=distsq)
|
||||||
|
# insertion-sort into sorted[] ascending by distancesq (nearest player first),
|
||||||
|
# capped at maxCount; when full, evict the farthest-from-player.
|
||||||
|
|
||||||
|
Render::add_static_light(info, cellId, frame): # 0x0054d3e0
|
||||||
|
insert_light(max_static_lights, &world_lights.num_static_lights,
|
||||||
|
world_lights.static_lights, world_lights.sorted_static_lights,
|
||||||
|
info, cellId, frame, max_dynamic_lights + 1)
|
||||||
|
|
||||||
|
Render::add_dynamic_light(info, cellId, frame): # 0x0054d420
|
||||||
|
insert_light(max_dynamic_lights, &world_lights.num_dynamic_lights,
|
||||||
|
world_lights.dynamic_lights, world_lights.sorted_dynamic_lights,
|
||||||
|
info, cellId, frame, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cap values:** `max_static_lights` / `max_dynamic_lights` (`0x0081ec94` / `0x0081ec98`)
|
||||||
|
init to **0x28 = 40** and **0x7 = 7**. Recomputed in `Render::SetDegradeLevelInternal`
|
||||||
|
(`0x0054c3c0`) as a function of the graphics degrade level (constants 25/50/8/16) —
|
||||||
|
always small (tens of static, single-digit dynamic). Retail deliberately keeps the
|
||||||
|
global pool tiny; it can, because §1.3 pre-scopes the input by visibility.
|
||||||
|
|
||||||
|
### 1.5 Per-object selection (`minimize_object_lighting`) — this IS acdream's `SelectForObject`
|
||||||
|
|
||||||
|
```
|
||||||
|
Render::minimize_object_lighting(): # 0x0054d480
|
||||||
|
reset_active_lights_state()
|
||||||
|
used = 0
|
||||||
|
# DYNAMIC lights first (priority), pre-sorted nearest-player:
|
||||||
|
for i in 0 .. num_dynamic_lights:
|
||||||
|
if used < 8 and remove_object_light(sorted_dynamic_lights[i].info) == keep:
|
||||||
|
add_active_light(i, 2); used += 1
|
||||||
|
else: dynamic_light_used[i] = 0
|
||||||
|
# STATIC lights fill remaining slots:
|
||||||
|
for i in 0 .. num_static_lights:
|
||||||
|
if used >= 8: static_light_used[i] = 0; continue
|
||||||
|
L = sorted_static_lights[i]
|
||||||
|
if L.info.type != 0: # non-point (directional): always use
|
||||||
|
add_active_light(i, 1); used += 1
|
||||||
|
else: # point: sphere-overlap test
|
||||||
|
reach = L.range + local_object_radius
|
||||||
|
if |L.pos - local_object_center|² - reach² < 0.0002: # spheres overlap
|
||||||
|
add_active_light(i, 1); used += 1
|
||||||
|
else: static_light_used[i] = 0
|
||||||
|
enable_active_lights()
|
||||||
|
```
|
||||||
|
|
||||||
|
acdream's `LightManager.SelectForObject` already does the sphere-overlap + 8-cap.
|
||||||
|
The one fidelity gap: retail fills **dynamic-first (priority), then static**, from two
|
||||||
|
separate player-sorted arrays; acdream selects from one camera-sorted snapshot.
|
||||||
|
Minor — parity item, not the #176/#177 cause.
|
||||||
|
|
||||||
|
### 1.6 Static falloff curve (`calc_point_light`) — fix #2 reference
|
||||||
|
|
||||||
|
`calc_point_light` (`0x0059c8b0`) is retail's CPU per-vertex software lighting for
|
||||||
|
static geometry (accumulates into `CUSTOM_D3D_VERTEX2` r/g/b). Structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
calc_point_light(vertex, &r, &g, &b, info):
|
||||||
|
d = |info.offset.origin - vertex.pos|
|
||||||
|
range = info.falloff * static_light_factor # static_light_factor ≈ 1.3
|
||||||
|
if d < range:
|
||||||
|
# N·L diffuse gate: 0.5*d + dot(vertex.normal, info.pos - vertex.pos) > 0
|
||||||
|
if faces_light:
|
||||||
|
atten = <1/d-ish curve, x87 — SEE WARNING>
|
||||||
|
f = atten * (1 - d/range) * info.intensity
|
||||||
|
r += clamp(f * info.color.r, .. info.color.r) # per-channel clamp to the light's own colour
|
||||||
|
g += clamp(f * info.color.g, ..)
|
||||||
|
b += clamp(f * info.color.b, ..)
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Do NOT port the exact `atten` curve from this BN pseudo-C.** Lines
|
||||||
|
> 425331–425341 are dense x87 FPU register juggling (`distsq/dist` vs
|
||||||
|
> `1.5/(distsq·dist)` branch on `distsq ≷ 1`), exactly the "x87 dropout / misread"
|
||||||
|
> class the project has been burned by twice (see `feedback_bn_decomp_field_names`,
|
||||||
|
> `feedback_retail_binary_dispatch`). When implementing fix #2, cross-reference a
|
||||||
|
> SECOND source (ACE / ACViewer static-light port, or the Ghidra decomp) and pin
|
||||||
|
> the curve with a conformance test before trusting it. The STRUCTURE above
|
||||||
|
> (range = falloff × static_light_factor, per-vertex N·L, intensity scale, colour
|
||||||
|
> clamp) is solid; the attenuation exponent is the part to verify.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Why #176/#177 happen in acdream (refined root cause)
|
||||||
|
|
||||||
|
acdream `LightManager` registers **every** fixture permanently into `_all` (server
|
||||||
|
weenie spawns + EnvCell static hydration), then `BuildPointLightSnapshot` caps at
|
||||||
|
`MaxGlobalLights=128` **nearest-CAMERA** over the WHOLE registered set. In the
|
||||||
|
Facility Hub (366 fixtures) that evicts 238/frame by camera distance; `SelectForObject`
|
||||||
|
can only choose from the surviving 128, so an in-range torch of a *visible* cell that
|
||||||
|
ranks past the cap drops from that cell's 8-set and the per-cell Gouraud lighting pops
|
||||||
|
as the camera moves (#176 seam flash / #177 stair-room pop-in).
|
||||||
|
|
||||||
|
**Retail never has 366 candidates.** It rebuilds `world_lights` each frame from ONLY
|
||||||
|
the visible cells' `light_list`s (§1.3), so the candidate pool is a handful of cells —
|
||||||
|
under the 40+7 cap — and nothing gets evicted. The camera-distance cap is a backstop
|
||||||
|
that essentially never fires because the input is already visibility-scoped.
|
||||||
|
|
||||||
|
This also explains the **through-floor purple wash** the cap-raise exposed: acdream's
|
||||||
|
flat world-space sphere-overlap of all 366 lights let an under-room portal light reach
|
||||||
|
up through a solid floor. Retail's under-room cell isn't in the corridor's
|
||||||
|
`visible_cell_table` (the flood doesn't pass through the solid floor), so its light
|
||||||
|
never enters the pool. Per-cell reach = *the light is only a candidate when its cell
|
||||||
|
is visibly flooded.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The fix (materially different from "just uncap MaxGlobalLights")
|
||||||
|
|
||||||
|
**Port the visibility-scoped per-frame collection**, not a bigger cap:
|
||||||
|
|
||||||
|
1. **Tag each `LightSource` with its owning cell id** (add `CellId` to `LightSource`;
|
||||||
|
populate at every registration site from the cell/landblock in scope). Retail's
|
||||||
|
`add_*_light(info, cellId, frame)` carries exactly this.
|
||||||
|
2. **Build the per-frame point-light pool from ONLY the currently-visible cells** —
|
||||||
|
the portal-flood set the renderer already computes — instead of the whole `_all`
|
||||||
|
set. This is retail's `add_*_lights over visible_cell_table`. The pool is then
|
||||||
|
naturally bounded; `MaxGlobalLights` stops biting (can keep 128 or adopt retail's
|
||||||
|
40+7 as a documented backstop). The Skip'd end-state pin
|
||||||
|
(`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`)
|
||||||
|
asserts exactly this: an in-range light of a visible cell is never camera-evicted.
|
||||||
|
3. **Fix #2 — static curve for stationary fixtures.** Decide `isDynamic` by whether
|
||||||
|
the light MOVES, not by dat-static-vs-weenie origin. A server-spawned wall lantern
|
||||||
|
is stationary → static 1/d³ (range × 1.3), reserving `isDynamic` (range × 1.5, 1/d)
|
||||||
|
for genuinely moving lights (portal swirls, projectiles). See §1.6 warning.
|
||||||
|
4. **Fix #3 — hunt the striped floor artifact** with the full (now visibility-scoped)
|
||||||
|
pool on. Invisible at cap 128; see the handoff for the two leading guesses.
|
||||||
|
5. **THEN uncap / adopt the retail cap** and un-skip the end-state pin.
|
||||||
|
|
||||||
|
### 3.1 acdream integration surface — as SHIPPED (slice 1: visible-cell scoping)
|
||||||
|
|
||||||
|
The renderers already select per-cell (`EnvCellRenderer.cs:1088`) and per-object
|
||||||
|
(`WbDrawDispatcher.cs:2095`) from `LightManager.PointSnapshot`; the ONLY defect was
|
||||||
|
that `PointSnapshot` was built by capping the whole `_all` set at 128 nearest-CAMERA.
|
||||||
|
The fix scopes that pool to visible cells. Concretely:
|
||||||
|
|
||||||
|
1. **`LightSource.CellId`** (new `uint`, 0 = cell-less/global). Retail's per-light cell
|
||||||
|
(insert_light arg6 → RenderLight +0x6c).
|
||||||
|
2. **`LightInfoLoader.Load(..., uint cellId = 0)`** propagates it onto each light.
|
||||||
|
3. **Both registration sites tag the owning cell** from `entity.ParentCellId`:
|
||||||
|
- Site A live weenie fixtures — `GameWindow.cs:~3682` (`cellId: entity.ParentCellId ?? 0u`).
|
||||||
|
- Site B dat EnvCell statics — `GameWindow.cs:~7696` (same).
|
||||||
|
- Viewer fill light keeps `CellId == 0` (always in the pool — retail's per-frame
|
||||||
|
`add_dynamic_light(&viewer_light, objcell_id)` is unconditional).
|
||||||
|
4. **`LightManager.BuildPointLightSnapshot(camPos, IReadOnlySet<uint>? visibleCells)`** —
|
||||||
|
a light joins the pool iff `CellId == 0` OR `visibleCells == null` (outdoor) OR
|
||||||
|
`visibleCells.Contains(CellId)`. The 128 cap stays as a now-non-biting backstop.
|
||||||
|
5. **The seam.** The per-frame order is `UpdateViewerLight → Tick → BuildPointLightSnapshot
|
||||||
|
(null-scope) → SceneLightingUbo.Build → Upload` (`GameWindow.cs:9058-9095`), and the
|
||||||
|
portal flood + all cell/entity draws happen LATER, INSIDE
|
||||||
|
`RetailPViewRenderer.DrawInside`. So the scoped rebuild is threaded via a new context
|
||||||
|
callback: `RetailPViewDrawContext.RebuildScopedLights`, invoked in `DrawInside` right
|
||||||
|
after `prepareCells` (every cell drawn this frame) is finalized and BEFORE
|
||||||
|
`PrepareRenderBatches` / the draws (`RetailPViewRenderer.cs:~131`). GameWindow wires it
|
||||||
|
to `visible => Lighting.BuildPointLightSnapshot(camPos, visible)` (`GameWindow.cs:~9371`).
|
||||||
|
The renderers hold a reference to the same `_pointSnapshot` list (rebuilt in place), and
|
||||||
|
`EnvCellRenderer._cellLightSetCache` is `.Clear()`'d every pass, so no stale indices.
|
||||||
|
`SceneLightingUbo.Build` reads `lights.Active` (Tick), not the snapshot, so it is
|
||||||
|
unaffected by the relocation. The outdoor `else` path (clipRoot == null: pre-login /
|
||||||
|
fly) never invokes the callback and keeps the legacy null-scope full pool.
|
||||||
|
6. **Validation apparatus** — `ACDREAM_PROBE_INDOOR_LIGHT=1` → one rate-limited
|
||||||
|
`[indoor-light]` line per second with the scoped-pool SET COMPOSITION
|
||||||
|
(`RenderingDiagnostics.EmitIndoorLight`): `visibleCells / pool / cellLess / registered /
|
||||||
|
droppedNonVisible / byCell[]`. This is the discriminator the `[light]` COUNTS couldn't
|
||||||
|
give (#176/#177 lived in set membership).
|
||||||
|
|
||||||
|
Fixes #2 (static curve) + #3 (stripe hunt) + the cap decision are follow-on slices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Source anchors (for the register + future sessions)
|
||||||
|
|
||||||
|
| Retail fn | Addr | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `CObjCell::add_light` | 0x0052b1d0 | append light to a cell's own list |
|
||||||
|
| `CObjCell::add_static_to_global_lights` | 0x0052b350 | push a cell's static lights to the global pool |
|
||||||
|
| `CObjCell::add_dynamic_to_global_lights` | 0x0052b390 | push a cell's dynamic lights to the global pool |
|
||||||
|
| `CEnvCell::add_dynamic_lights` | 0x0052d410 | per-frame: walk `visible_cell_table`, collect dynamic |
|
||||||
|
| (static collector, ends) | 0x0052def0 | per-frame: walk `visible_cell_table`, collect static |
|
||||||
|
| `CEnvCell::UnPack` | 0x0052d470 | unpack a cell's `num_lights` + `light_list` from dat |
|
||||||
|
| `Render::insert_light` | 0x0054d1b0 | player-nearest sorted insert into `world_lights`, capped |
|
||||||
|
| `Render::add_static_light` / `add_dynamic_light` | 0x0054d3e0 / 0x0054d420 | thin wrappers → insert_light |
|
||||||
|
| `Render::minimize_object_lighting` | 0x0054d480 | per-object ≤8 pick (dynamic-priority, then static sphere-overlap) |
|
||||||
|
| `Render::SetDegradeLevelInternal` | 0x0054c3c0 | recomputes `max_static/dynamic_lights` from degrade level |
|
||||||
|
| `calc_point_light` | 0x0059c8b0 | CPU per-vertex static light curve (fix #2 ref) |
|
||||||
|
| `max_static_lights` / `max_dynamic_lights` | 0x0081ec94 / 0x0081ec98 | init 40 / 7 |
|
||||||
|
|
@ -3684,7 +3684,8 @@ public sealed class GameWindow : IDisposable
|
||||||
ownerId: entity.Id,
|
ownerId: entity.Id,
|
||||||
entityPosition: entity.Position,
|
entityPosition: entity.Position,
|
||||||
entityRotation: entity.Rotation,
|
entityRotation: entity.Rotation,
|
||||||
isDynamic: true); // #143: server-object lights take the D3D dynamic path (1/d att, range×1.5)
|
isDynamic: true, // #143: server-object lights take the D3D dynamic path (1/d att, range×1.5)
|
||||||
|
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
|
||||||
foreach (var ls in loaded)
|
foreach (var ls in loaded)
|
||||||
_lightingSink.RegisterOwnedLight(ls);
|
_lightingSink.RegisterOwnedLight(ls);
|
||||||
}
|
}
|
||||||
|
|
@ -7697,7 +7698,8 @@ public sealed class GameWindow : IDisposable
|
||||||
datSetup,
|
datSetup,
|
||||||
ownerId: entity.Id,
|
ownerId: entity.Id,
|
||||||
entityPosition: entity.Position,
|
entityPosition: entity.Position,
|
||||||
entityRotation: entity.Rotation);
|
entityRotation: entity.Rotation,
|
||||||
|
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
|
||||||
foreach (var ls in loaded)
|
foreach (var ls in loaded)
|
||||||
_lightingSink.RegisterOwnedLight(ls);
|
_lightingSink.RegisterOwnedLight(ls);
|
||||||
}
|
}
|
||||||
|
|
@ -9368,6 +9370,11 @@ public sealed class GameWindow : IDisposable
|
||||||
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
||||||
Camera = camera,
|
Camera = camera,
|
||||||
CameraWorldPosition = camPos,
|
CameraWorldPosition = camPos,
|
||||||
|
// A7 #176/#177: once DrawInside has resolved the visible-cell set,
|
||||||
|
// rebuild the point-light pool from ONLY those cells' lights (retail's
|
||||||
|
// per-frame add_*_lights over visible_cell_table). The renderers hold a
|
||||||
|
// reference to the same PointSnapshot list, rebuilt in place here.
|
||||||
|
RebuildScopedLights = visible => Lighting.BuildPointLightSnapshot(camPos, visible),
|
||||||
Frustum = frustum,
|
Frustum = frustum,
|
||||||
PlayerLandblockId = playerLb,
|
PlayerLandblockId = playerLb,
|
||||||
AnimatedEntityIds = animatedIds,
|
AnimatedEntityIds = animatedIds,
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,12 @@ public sealed class RetailPViewRenderer
|
||||||
prepareCells = _lookInPrepareScratch;
|
prepareCells = _lookInPrepareScratch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A7 #176/#177: scope this frame's point-light pool to the cells actually being
|
||||||
|
// drawn, NOW that the flood has resolved the visible set (retail collects lights
|
||||||
|
// per-frame over visible_cell_table). Must run before the cell/entity draws below
|
||||||
|
// that select from LightManager.PointSnapshot.
|
||||||
|
ctx.RebuildScopedLights?.Invoke(prepareCells);
|
||||||
|
|
||||||
_envCells.PrepareRenderBatches(
|
_envCells.PrepareRenderBatches(
|
||||||
ctx.ViewProjection,
|
ctx.ViewProjection,
|
||||||
ctx.CameraWorldPosition,
|
ctx.CameraWorldPosition,
|
||||||
|
|
@ -1084,6 +1090,16 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
|
||||||
public Action? DrawUnattachedSceneParticles { get; init; }
|
public Action? DrawUnattachedSceneParticles { get; init; }
|
||||||
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
|
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
|
||||||
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
||||||
|
|
||||||
|
/// <summary>A7 #176/#177: rebuild the point-light snapshot scoped to the cells
|
||||||
|
/// this frame actually draws — invoked AFTER the portal flood resolves the visible
|
||||||
|
/// set and BEFORE any cell/entity draw (the faithful port of retail's per-frame
|
||||||
|
/// light collection: <c>CObjCell::add_*_to_global_lights</c> walked over
|
||||||
|
/// <c>CEnvCell::visible_cell_table</c>). The argument is every cell drawn this frame
|
||||||
|
/// (main flood + interior-root look-ins). A cell-less light (viewer fill) is kept
|
||||||
|
/// regardless. Null-safe: outdoor/no-flood callers leave it unset and keep the
|
||||||
|
/// legacy full-pool snapshot.</summary>
|
||||||
|
public Action<IReadOnlySet<uint>>? RebuildScopedLights { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RetailPViewFrameResult
|
public sealed class RetailPViewFrameResult
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ public static class LightInfoLoader
|
||||||
uint ownerId,
|
uint ownerId,
|
||||||
Vector3 entityPosition,
|
Vector3 entityPosition,
|
||||||
Quaternion entityRotation,
|
Quaternion entityRotation,
|
||||||
bool isDynamic = false)
|
bool isDynamic = false,
|
||||||
|
uint cellId = 0)
|
||||||
{
|
{
|
||||||
var results = new List<LightSource>();
|
var results = new List<LightSource>();
|
||||||
if (setup?.Lights is null || setup.Lights.Count == 0) return results;
|
if (setup?.Lights is null || setup.Lights.Count == 0) return results;
|
||||||
|
|
@ -89,6 +90,7 @@ public static class LightInfoLoader
|
||||||
Range = info.Falloff * (isDynamic ? 1.5f : 1.3f),
|
Range = info.Falloff * (isDynamic ? 1.5f : 1.3f),
|
||||||
ConeAngle = info.ConeAngle,
|
ConeAngle = info.ConeAngle,
|
||||||
OwnerId = ownerId,
|
OwnerId = ownerId,
|
||||||
|
CellId = cellId, // owning cell — scopes the per-frame visible-cell pool (A7 #176/#177)
|
||||||
IsLit = true,
|
IsLit = true,
|
||||||
IsDynamic = isDynamic,
|
IsDynamic = isDynamic,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -218,11 +218,33 @@ public sealed class LightManager
|
||||||
/// per-object selection.
|
/// per-object selection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
|
public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
|
||||||
|
=> BuildPointLightSnapshot(cameraWorldPos, visibleCells: null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visible-cell-scoped snapshot build — the faithful port of retail's per-frame
|
||||||
|
/// light collection (<c>CObjCell::add_*_to_global_lights</c> 0x0052b350/0x0052b390
|
||||||
|
/// walked over <c>CEnvCell::visible_cell_table</c> 0x0052d410). When
|
||||||
|
/// <paramref name="visibleCells"/> is non-null (an indoor root with a portal
|
||||||
|
/// flood), a cell-tagged light is a candidate ONLY when its <see cref="LightSource.CellId"/>
|
||||||
|
/// is in the visible set — a cell-less light (<c>CellId == 0</c>: the viewer fill,
|
||||||
|
/// global lights) is always included. This is what (a) stops an under-room light
|
||||||
|
/// washing THROUGH a solid floor (its cell isn't visibly flooded → excluded) and
|
||||||
|
/// (b) bounds the pool to the handful of visible cells so <see cref="MaxGlobalLights"/>
|
||||||
|
/// never evicts a visible cell's in-range light (the #176/#177 mechanism). When
|
||||||
|
/// <paramref name="visibleCells"/> is null (outdoor root / no flood) the behaviour
|
||||||
|
/// is unchanged from the legacy full-pool path.
|
||||||
|
/// </summary>
|
||||||
|
public void BuildPointLightSnapshot(Vector3 cameraWorldPos, IReadOnlySet<uint>? visibleCells)
|
||||||
{
|
{
|
||||||
_pointSnapshot.Clear();
|
_pointSnapshot.Clear();
|
||||||
foreach (var light in _all)
|
foreach (var light in _all)
|
||||||
{
|
{
|
||||||
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
|
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
|
||||||
|
// Visible-cell scoping (retail add_*_lights over visible_cell_table). A
|
||||||
|
// cell-less light (CellId == 0: viewer fill / global) is always a candidate;
|
||||||
|
// a cell-tagged light joins the pool ONLY when its cell is visibly flooded.
|
||||||
|
if (visibleCells is not null && light.CellId != 0 && !visibleCells.Contains(light.CellId))
|
||||||
|
continue;
|
||||||
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
|
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
|
||||||
_pointSnapshot.Add(light);
|
_pointSnapshot.Add(light);
|
||||||
}
|
}
|
||||||
|
|
@ -231,6 +253,11 @@ public sealed class LightManager
|
||||||
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
|
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
|
||||||
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
|
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A7.L1 SET-COMPOSITION probe — only meaningful on the scoped (indoor) path.
|
||||||
|
// Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1; the flag check keeps it zero-cost off.
|
||||||
|
if (visibleCells is not null && AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled)
|
||||||
|
AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(visibleCells.Count, _all, _pointSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
|
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,12 @@ public sealed class LightSource
|
||||||
public float Range = 10f; // metres, hard cutoff
|
public float Range = 10f; // metres, hard cutoff
|
||||||
public float ConeAngle = 0f; // radians, Spot only
|
public float ConeAngle = 0f; // radians, Spot only
|
||||||
public uint OwnerId; // attached entity id; 0 = world-global
|
public uint OwnerId; // attached entity id; 0 = world-global
|
||||||
|
public uint CellId; // owning cell id (0xLLLLNNNN); 0 = cell-less/global (viewer fill, sun).
|
||||||
|
// Retail carries this on the RenderLight (insert_light arg6, +0x6c) so the
|
||||||
|
// per-frame pool can be built from only the VISIBLE cells' lights
|
||||||
|
// (CObjCell::add_*_to_global_lights over CEnvCell::visible_cell_table).
|
||||||
|
// acdream uses it to scope BuildPointLightSnapshot — a cell-tagged light is
|
||||||
|
// only a candidate when its cell is visibly flooded (#176/#177 A7 fix).
|
||||||
public bool IsLit = true; // SetLightHook latch
|
public bool IsLit = true; // SetLightHook latch
|
||||||
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
|
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
|
||||||
// false = static dat-baked bake (1/d³, range×1.3)
|
// false = static dat-baked bake (1/d³, range×1.3)
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,33 @@ public static class RenderingDiagnostics
|
||||||
public static bool ProbeLightEnabled { get; set; } =
|
public static bool ProbeLightEnabled { get; set; } =
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1";
|
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A7.L1 (2026-07-06) per-cell light SET-COMPOSITION probe — the apparatus the
|
||||||
|
/// <c>[light]</c> counts could not provide (the #176/#177 discriminator: the bug
|
||||||
|
/// lived in set MEMBERSHIP, not counts). When true, the scoped
|
||||||
|
/// <c>LightManager.BuildPointLightSnapshot</c> emits ONE rate-limited
|
||||||
|
/// <c>[indoor-light]</c> line describing the visible-cell-scoped point-light pool
|
||||||
|
/// (see <see cref="EmitIndoorLight"/>):
|
||||||
|
/// <code>
|
||||||
|
/// [indoor-light] visibleCells=<N> pool=<M> cellLess=<K> registered=<R>
|
||||||
|
/// droppedNonVisible=<R-M> byCell=[0x<id>:<count>,...]
|
||||||
|
/// </code>
|
||||||
|
/// This validates the A7 fix's load-bearing assumption end-to-end:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>cellLess==pool</c> (every pool light is CellId 0) ⇒
|
||||||
|
/// cell tagging FAILED (ParentCellId not flowing) — scoping is a silent no-op.</description></item>
|
||||||
|
/// <item><description><c>pool==cellLess</c> while <c>registered</c> is large in a
|
||||||
|
/// LIT room ⇒ tagged CellIds never match the visible set (wrong id form) — the
|
||||||
|
/// room would go dark.</description></item>
|
||||||
|
/// <item><description><c>droppedNonVisible>0</c> with <c>byCell</c> tracking the
|
||||||
|
/// visible rooms ⇒ scoping WORKING (the under-room/through-floor lights are the
|
||||||
|
/// dropped ones).</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_INDOOR_LIGHT=1</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ProbeIndoorLightEnabled { get; set; } =
|
||||||
|
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LIGHT") == "1";
|
||||||
|
|
||||||
// Cell-change gate for EmitVis. The probe fires once per distinct root cell
|
// Cell-change gate for EmitVis. The probe fires once per distinct root cell
|
||||||
// so launch.log stays readable under motion (the per-frame call is a no-op
|
// so launch.log stays readable under motion (the per-frame call is a no-op
|
||||||
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real
|
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real
|
||||||
|
|
@ -451,6 +478,66 @@ public static class RenderingDiagnostics
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wall-clock rate-limit gate for EmitIndoorLight (shares the 1 s interval).
|
||||||
|
private static long _lastIndoorLightEmitTicks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A7.L1 — emit ONE rate-limited <c>[indoor-light]</c> line describing the
|
||||||
|
/// visible-cell-scoped point-light pool: the SET COMPOSITION the <c>[light]</c>
|
||||||
|
/// counts can't show. Cheap no-op when <see cref="ProbeIndoorLightEnabled"/> is
|
||||||
|
/// false; otherwise fires at most once per second. Called from the scoped
|
||||||
|
/// <c>LightManager.BuildPointLightSnapshot</c> (visibleCells != null path).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="visibleCellCount">Size of the portal-flood visible-cell set this frame.</param>
|
||||||
|
/// <param name="allRegistered">Every registered light (<c>LightManager._all</c>).</param>
|
||||||
|
/// <param name="scopedSnapshot">The visible-cell-scoped point-light pool just built.</param>
|
||||||
|
public static void EmitIndoorLight(int visibleCellCount,
|
||||||
|
IReadOnlyList<AcDream.Core.Lighting.LightSource> allRegistered,
|
||||||
|
IReadOnlyList<AcDream.Core.Lighting.LightSource> scopedSnapshot)
|
||||||
|
{
|
||||||
|
if (!ProbeIndoorLightEnabled) return;
|
||||||
|
|
||||||
|
long now = DateTime.UtcNow.Ticks;
|
||||||
|
if (_lastIndoorLightEmitTicks != 0 && (now - _lastIndoorLightEmitTicks) < LightEmitIntervalTicks)
|
||||||
|
return;
|
||||||
|
_lastIndoorLightEmitTicks = now;
|
||||||
|
|
||||||
|
int registeredLitPoints = 0;
|
||||||
|
foreach (var l in allRegistered)
|
||||||
|
if (l.IsLit && l.Kind != AcDream.Core.Lighting.LightKind.Directional) registeredLitPoints++;
|
||||||
|
|
||||||
|
int pool = scopedSnapshot.Count;
|
||||||
|
int cellLess = 0;
|
||||||
|
var hist = new Dictionary<uint, int>();
|
||||||
|
foreach (var l in scopedSnapshot)
|
||||||
|
{
|
||||||
|
if (l.CellId == 0) cellLess++;
|
||||||
|
hist.TryGetValue(l.CellId, out var c);
|
||||||
|
hist[l.CellId] = c + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder(220);
|
||||||
|
sb.Append("[indoor-light] visibleCells=").Append(visibleCellCount);
|
||||||
|
sb.Append(" pool=").Append(pool);
|
||||||
|
sb.Append(" cellLess=").Append(cellLess);
|
||||||
|
sb.Append(" registered=").Append(registeredLitPoints);
|
||||||
|
// Lights excluded by visibility scoping (retail: cells not in visible_cell_table
|
||||||
|
// contribute nothing) — the through-floor/under-room lights kept out of the pool.
|
||||||
|
sb.Append(" droppedNonVisible=").Append(registeredLitPoints - pool);
|
||||||
|
sb.Append(" byCell=[");
|
||||||
|
const int MaxCells = 12;
|
||||||
|
int shown = 0;
|
||||||
|
foreach (var kv in hist)
|
||||||
|
{
|
||||||
|
if (shown >= MaxCells) { sb.Append(",..."); break; }
|
||||||
|
if (shown > 0) sb.Append(',');
|
||||||
|
sb.Append("0x").Append(kv.Key.ToString("X8")).Append(':').Append(kv.Value);
|
||||||
|
shown++;
|
||||||
|
}
|
||||||
|
sb.Append(']');
|
||||||
|
Console.WriteLine(sb.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
private static bool _probeEnvCellEnabled =
|
private static bool _probeEnvCellEnabled =
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";
|
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ namespace AcDream.Core.Tests.Lighting;
|
||||||
|
|
||||||
public sealed class LightManagerTests
|
public sealed class LightManagerTests
|
||||||
{
|
{
|
||||||
private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true)
|
private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true, uint cellId = 0)
|
||||||
=> new LightSource
|
=> new LightSource
|
||||||
{
|
{
|
||||||
Kind = LightKind.Point,
|
Kind = LightKind.Point,
|
||||||
|
|
@ -14,6 +14,7 @@ public sealed class LightManagerTests
|
||||||
Range = range,
|
Range = range,
|
||||||
IsLit = lit,
|
IsLit = lit,
|
||||||
OwnerId = ownerId,
|
OwnerId = ownerId,
|
||||||
|
CellId = cellId,
|
||||||
};
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -176,6 +177,55 @@ public sealed class LightManagerTests
|
||||||
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3);
|
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Visible-cell scoping (retail: add_*_lights over visible_cell_table) ────
|
||||||
|
// A7 #176/#177: the per-frame pool is built from ONLY the lights of currently-
|
||||||
|
// visible cells (plus cell-less globals), not a flat world-space set.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPointLightSnapshot_VisibleScope_ExcludesLightsOfNonVisibleCells()
|
||||||
|
{
|
||||||
|
var mgr = new LightManager();
|
||||||
|
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u)); // visible cell
|
||||||
|
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // under-room, NOT visible
|
||||||
|
|
||||||
|
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u };
|
||||||
|
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
|
||||||
|
|
||||||
|
// Only the visible cell's light survives — the under-room light can't wash
|
||||||
|
// through the floor (retail: its cell isn't in visible_cell_table).
|
||||||
|
Assert.Single(mgr.PointSnapshot);
|
||||||
|
Assert.Equal(0xAAAA0101u, mgr.PointSnapshot[0].CellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPointLightSnapshot_VisibleScope_AlwaysIncludesCellLessGlobals()
|
||||||
|
{
|
||||||
|
var mgr = new LightManager();
|
||||||
|
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0u)); // viewer/global — CellId 0
|
||||||
|
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // non-visible cell
|
||||||
|
|
||||||
|
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u }; // does NOT contain 0102
|
||||||
|
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
|
||||||
|
|
||||||
|
// The cell-less light (viewer fill) is always a candidate; the non-visible
|
||||||
|
// cell's light is dropped.
|
||||||
|
Assert.Single(mgr.PointSnapshot);
|
||||||
|
Assert.Equal(0u, mgr.PointSnapshot[0].CellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPointLightSnapshot_NullScope_KeepsFullPool()
|
||||||
|
{
|
||||||
|
var mgr = new LightManager();
|
||||||
|
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u));
|
||||||
|
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u));
|
||||||
|
|
||||||
|
// Null visible set = outdoor root / no flood → legacy full-pool behaviour.
|
||||||
|
mgr.BuildPointLightSnapshot(Vector3.Zero, visibleCells: null);
|
||||||
|
|
||||||
|
Assert.Equal(2, mgr.PointSnapshot.Count);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SelectForObject_EmptySnapshot_ReturnsZero()
|
public void SelectForObject_EmptySnapshot_ReturnsZero()
|
||||||
{
|
{
|
||||||
|
|
@ -258,56 +308,59 @@ public sealed class LightManagerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #176/#177 (2026-07-06): SelectForObject is camera-independent (the test
|
/// #176/#177 (2026-07-06) — the end-state pin, via the SHIPPED fix (visible-cell
|
||||||
/// above), but the SNAPSHOT it selects from was not — BuildPointLightSnapshot
|
/// scoping, not "uncap"). Before: <c>BuildPointLightSnapshot</c> kept only the
|
||||||
/// kept only the MaxGlobalLights nearest THE CAMERA. In the Facility Hub
|
/// <c>MaxGlobalLights</c> nearest THE CAMERA over the WHOLE registered set, so in
|
||||||
/// (366 registered fixtures vs the old cap of 128), an in-range torch of a
|
/// the Facility Hub (366 fixtures) an in-range torch of a VISIBLE cell could rank
|
||||||
/// VISIBLE cell could rank past the cap and be evicted, so the cell's 8-set
|
/// past the cap and be evicted → the cell's 8-set (and its Gouraud vertex lighting)
|
||||||
/// (and its Gouraud vertex lighting) flipped as the camera moved — per-cell
|
/// flipped as the camera moved (#176 seam flash / #177 stair-room pop-in). The fix
|
||||||
/// lighting pops at seam granularity (#176's flash), and a whole room's
|
/// is retail's per-frame collection: the pool is built from ONLY the lights of the
|
||||||
/// torches vanishing until approach (#177's pop-in). Retail's
|
/// currently-VISIBLE cells (<c>CObjCell::add_*_to_global_lights</c> over
|
||||||
/// minimize_object_lighting (0x0054d480) has NO global camera-nearest cap —
|
/// <c>CEnvCell::visible_cell_table</c>), so the visible pool is a handful of cells,
|
||||||
/// every registered light reaching the object is a candidate. This pins the
|
/// the cap never bites, and a visible cell's in-range light is never camera-evicted.
|
||||||
/// end-to-end property: a light in range of an object stays selected no
|
/// The same scoping keeps a NON-visible cell's light out of the pool entirely
|
||||||
/// matter where the camera is, at Facility-Hub-scale light counts.
|
/// (through-floor prevention). See <c>docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact(Skip = "#176/#177: the camera-invariant pool is the DESIRED retail end-state " +
|
[Fact]
|
||||||
"(minimize_object_lighting has no global cap), but uncapping was live-tested " +
|
|
||||||
"2026-07-06 and reverted — it exposes unported per-cell light-reach semantics " +
|
|
||||||
"(through-floor light), the dynamic-vs-static falloff misassignment for weenie " +
|
|
||||||
"fixtures, and an unexplained striped floor artifact. Un-skip when the A7 " +
|
|
||||||
"dungeon-lighting arc lands per-cell registration (insert_light 0x0054d1b0) " +
|
|
||||||
"and raises MaxGlobalLights. See ISSUES #176/#177 + register row AP-85.")]
|
|
||||||
public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
|
public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
|
||||||
{
|
{
|
||||||
var mgr = new LightManager();
|
var mgr = new LightManager();
|
||||||
|
|
||||||
// 400 fixtures clustered near the origin (the "camera side" of the
|
// 400 fixtures clustered near the origin, all in the UNDER-ROOM cell (not
|
||||||
// dungeon) — these fill every low camera-distance rank.
|
// visible from the target room). These would have filled every low
|
||||||
|
// camera-distance rank under the old camera-nearest cap.
|
||||||
|
const uint underRoom = 0xAAAA0102u;
|
||||||
for (int i = 0; i < 400; i++)
|
for (int i = 0; i < 400; i++)
|
||||||
mgr.Register(MakePoint(new Vector3(i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1)));
|
mgr.Register(MakePoint(new Vector3(i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1), cellId: underRoom));
|
||||||
|
|
||||||
// The target torch: far from the origin-side camera (rank ~401), but
|
// The target torch: far from the origin-side camera, in the VISIBLE room
|
||||||
// squarely in range of the target cell around (200, 0, 0).
|
// cell, squarely in range of the target object around (200, 0, 0).
|
||||||
var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du);
|
const uint targetRoom = 0xAAAA0101u;
|
||||||
|
var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du, cellId: targetRoom);
|
||||||
mgr.Register(torch);
|
mgr.Register(torch);
|
||||||
|
|
||||||
|
// The portal flood says only the target room is visible.
|
||||||
|
var visible = new System.Collections.Generic.HashSet<uint> { targetRoom };
|
||||||
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
|
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
|
||||||
|
|
||||||
// Camera parked at the origin end — the torch must still light the cell.
|
// Camera parked at the origin end — the torch must still light the visible cell.
|
||||||
mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero);
|
mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero, visible);
|
||||||
int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
|
int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
|
||||||
bool torchSelectedFar = SelectedContains(mgr.PointSnapshot, sel, n1, torch);
|
bool torchSelectedFar = SelectedContains(mgr.PointSnapshot, sel, n1, torch);
|
||||||
|
// The 400 under-room lights are NOT in the pool (their cell isn't visible).
|
||||||
|
int underRoomInPool = 0;
|
||||||
|
foreach (var l in mgr.PointSnapshot) if (l.CellId == underRoom) underRoomInPool++;
|
||||||
|
|
||||||
// Camera next to the cell — the reference behavior.
|
// Camera next to the cell — the reference behaviour.
|
||||||
mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0));
|
mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0), visible);
|
||||||
int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
|
int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
|
||||||
bool torchSelectedNear = SelectedContains(mgr.PointSnapshot, sel, n2, torch);
|
bool torchSelectedNear = SelectedContains(mgr.PointSnapshot, sel, n2, torch);
|
||||||
|
|
||||||
Assert.True(torchSelectedNear, "sanity: the torch reaches the cell when the camera is beside it");
|
Assert.True(torchSelectedNear, "sanity: the torch reaches the cell when the camera is beside it");
|
||||||
Assert.True(torchSelectedFar,
|
Assert.True(torchSelectedFar,
|
||||||
"an in-range light of a visible cell was evicted by the camera-nearest snapshot cap — " +
|
"an in-range light of a VISIBLE cell was evicted by the snapshot cap — " +
|
||||||
"per-cell lighting would pop with camera movement (the #176/#177 mechanism)");
|
"per-cell lighting would pop with camera movement (the #176/#177 mechanism)");
|
||||||
|
Assert.Equal(0, underRoomInPool); // through-floor prevention: non-visible cell's lights excluded
|
||||||
|
|
||||||
static bool SelectedContains(
|
static bool SelectedContains(
|
||||||
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
|
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue