perf(lighting): bound global light selection
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.
This commit is contained in:
parent
b3427554c3
commit
a2a1e5916d
5 changed files with 615 additions and 21 deletions
|
|
@ -101,7 +101,7 @@ with all optional consumers disabled.
|
|||
Landed evidence:
|
||||
[`../research/2026-07-25-slice-h-a4-frame-scratch.md`](../research/2026-07-25-slice-h-a4-frame-scratch.md).
|
||||
|
||||
## 3. H-b — exact light top-k
|
||||
## 3. H-b — exact light top-k — COMPLETE
|
||||
|
||||
Retail anchors:
|
||||
|
||||
|
|
@ -126,6 +126,13 @@ Gate: identical selected IDs and submission order for every fixture, identical
|
|||
Town Network screenshot, reduced overflow CPU/allocation, and AP-85 retired
|
||||
only after the visual evidence passes.
|
||||
|
||||
Landed evidence:
|
||||
[`../research/2026-07-25-slice-h-b-light-top-k-report.md`](../research/2026-07-25-slice-h-b-light-top-k-report.md).
|
||||
The deterministic differential fixtures preserve exact accepted output; the
|
||||
463-light diagnostic microbenchmark reduced selection CPU time by 29.1% with
|
||||
zero warmed allocations. AP-85 remains open because its retail dual-pool
|
||||
behavior is outside this performance-only unit.
|
||||
|
||||
## 4. H-c — ordered, allocation-conscious network I/O
|
||||
|
||||
Retail/transport invariants:
|
||||
|
|
|
|||
150
docs/research/2026-07-25-slice-h-b-light-top-k-pseudocode.md
Normal file
150
docs/research/2026-07-25-slice-h-b-light-top-k-pseudocode.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Slice H-b — retail light insertion and bounded top-k
|
||||
|
||||
## Scope
|
||||
|
||||
This slice changes only the overflow-selection mechanism inside acdream's
|
||||
existing approved point-light snapshot. It preserves:
|
||||
|
||||
- the resident `_all` registry;
|
||||
- the optional last-rendered-visible-cell candidate filter;
|
||||
- dynamic-before-static priority;
|
||||
- squared distance from the player;
|
||||
- `MaxGlobalLights = 128`;
|
||||
- final nearest-first snapshot order and stable shader indices.
|
||||
|
||||
It does **not** claim to port retail's separate 7-dynamic/40-static pools or
|
||||
its DBObj-resident cell lifecycle. Those known differences remain AP-85.
|
||||
|
||||
## Retail oracle
|
||||
|
||||
Named source:
|
||||
|
||||
- `CEnvCell::add_dynamic_lights` `0x0052D410`
|
||||
- `Render::insert_light` `0x0054D1B0`
|
||||
- `Render::add_static_light` `0x0054D3E0`
|
||||
- `Render::add_dynamic_light` `0x0054D420`
|
||||
- cap globals `0x0081EC94` / `0x0081EC98`
|
||||
|
||||
`CEnvCell::add_dynamic_lights` walks the resident
|
||||
`CEnvCell::visible_cell_table` and submits each light in registry traversal
|
||||
order. Static and dynamic callers feed separate bounded pools.
|
||||
|
||||
Readable pseudocode for `Render::insert_light`:
|
||||
|
||||
```text
|
||||
distanceSq = point-light distance from Render::player_pos
|
||||
|
||||
insertIndex = 0
|
||||
while insertIndex < count
|
||||
and sorted[insertIndex].distanceSq <= distanceSq:
|
||||
insertIndex++
|
||||
|
||||
if count < capacity:
|
||||
count++
|
||||
else if insertIndex == count:
|
||||
return # new light ranks beyond the cap
|
||||
|
||||
reuse the prior farthest storage record
|
||||
shift sorted pointers [insertIndex .. count-2] one place right
|
||||
sorted[insertIndex] = reused record
|
||||
populate the reused record from LIGHTINFO
|
||||
```
|
||||
|
||||
Equal-distance ordering was ambiguous in the pseudo-C because Binary Ninja
|
||||
left `test ah, 0x5` untranslated. The exact matching retail executable
|
||||
(`check_exe_pdb.py` GUID
|
||||
`9e847e2f-777c-4bd9-886c-22256bb87f32`) disassembles at
|
||||
`0x0054D253` as:
|
||||
|
||||
```text
|
||||
fld newDistanceSq
|
||||
fcomp existingDistanceSq
|
||||
fnstsw ax
|
||||
test ah, 5
|
||||
jnp break
|
||||
```
|
||||
|
||||
For equality, x87 sets C3 while the mask reads C2/C0 as zero; parity is even,
|
||||
so `jnp` is not taken and traversal continues. A later equal-distance light
|
||||
therefore follows earlier residents. Retail's semantic total rank is:
|
||||
|
||||
```text
|
||||
(pool priority, player distance squared, qualifying registration ordinal)
|
||||
```
|
||||
|
||||
The ACE server, ACViewer, Holtburger, and extracted WorldBuilder paths do not
|
||||
implement this retail client-side bounded render-light selection. They provide
|
||||
no competing algorithm; named retail plus the matching executable are the
|
||||
oracle.
|
||||
|
||||
The accepted acdream implementation used .NET `List.Sort` with a comparator
|
||||
that returned zero for equal pool/distance ranks. `List.Sort` is unstable, so
|
||||
its tie permutation differs from retail registration order and is observable
|
||||
through snapshot/shader indices. Slice H-b is explicitly performance-only: a
|
||||
tied overflow frame therefore retains that exact full-sort oracle. AP-85's
|
||||
eventual dual-pool visual gate is the correct place to change tie behavior to
|
||||
retail.
|
||||
|
||||
## Modern bounded implementation
|
||||
|
||||
Retail's capacities are tiny enough for insertion. Acdream's adaptation keeps
|
||||
128 lights, so repeated insertion would do more movement than a bounded
|
||||
max-heap.
|
||||
|
||||
```text
|
||||
snapshot = empty
|
||||
heap = empty
|
||||
snapshot temporarily retains all candidates, as before
|
||||
ordinal = 0
|
||||
overflow = false
|
||||
|
||||
for each registered light:
|
||||
if unlit or directional or outside optional cell filter:
|
||||
continue
|
||||
|
||||
rank = (dynamic first, squared player distance, ordinal++)
|
||||
snapshot.add(light)
|
||||
|
||||
if snapshot.count <= cap:
|
||||
continue
|
||||
|
||||
if first overflow:
|
||||
convert the first cap snapshot items to ranked heap entries
|
||||
heapify with WORST rank at root
|
||||
overflow = true
|
||||
|
||||
if rank is better than heap.root:
|
||||
heap.root = rank
|
||||
sift root down
|
||||
|
||||
if overflow:
|
||||
sort only the cap heap entries by the total rank
|
||||
comparatorTie =
|
||||
any equal (pool, distance) ranks within the selected heap
|
||||
OR more than one complete candidate has the cutoff rank
|
||||
|
||||
if overflow and comparatorTie:
|
||||
run the prior complete List.Sort comparator over snapshot
|
||||
keep its first cap entries exactly
|
||||
else if overflow:
|
||||
replace snapshot contents with their lights
|
||||
```
|
||||
|
||||
Complexity changes from `O(N log N)` to `O(N log K + K log K)`, where
|
||||
`K = 128`, for the ordinary no-tie path. Tie detection is post-selection:
|
||||
equal ranks that are entirely below the cutoff cannot affect the submitted
|
||||
snapshot and therefore do not force a fallback. Equal ranks within the
|
||||
selected set or straddling its cutoff intentionally fall back to
|
||||
`O(N log N)` so this optimization cannot alter accepted presentation.
|
||||
Retained lists and cached comparison delegates make both warmed paths
|
||||
allocation-free.
|
||||
|
||||
## Mandatory equivalence
|
||||
|
||||
- randomized differential comparison against the accepted complete-sort
|
||||
comparator;
|
||||
- all-static, all-dynamic, mixed, filtered, equal-distance, and cap-boundary
|
||||
cases;
|
||||
- a deterministic 463-light Town Network scale fixture;
|
||||
- identical final object references and order, not only an equal set;
|
||||
- zero stable allocations after retained scratch reaches capacity.
|
||||
61
docs/research/2026-07-25-slice-h-b-light-top-k-report.md
Normal file
61
docs/research/2026-07-25-slice-h-b-light-top-k-report.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Slice H-b — exact bounded point-light selection
|
||||
|
||||
## Result
|
||||
|
||||
`LightManager.BuildPointLightSnapshot` no longer sorts every qualifying light
|
||||
when more than 128 lights are eligible. It keeps the best 128 in a retained
|
||||
worst-first heap, sorts only those 128 for submission, and performs no
|
||||
allocation after warm-up.
|
||||
|
||||
The optimization preserves the accepted pre-slice output exactly:
|
||||
|
||||
- dynamic lights still precede static lights;
|
||||
- each pool is still ordered by squared player distance;
|
||||
- the visible-cell, lit-state, and directional-light filters are unchanged;
|
||||
- in-budget snapshots remain in registration order;
|
||||
- equal-rank overflow frames which could expose .NET's unstable sort retain
|
||||
the previous complete-sort path and exact object-reference order.
|
||||
|
||||
The retail oracle and readable pseudocode are in
|
||||
`2026-07-25-slice-h-b-light-top-k-pseudocode.md`. Retail's own selector is a
|
||||
bounded ordered insertion into separate static and dynamic pools. This slice
|
||||
ports the bounded-work principle without claiming to close AP-85's larger
|
||||
pool/cap/lifecycle divergence.
|
||||
|
||||
## Evidence
|
||||
|
||||
Focused conformance covers:
|
||||
|
||||
- exact randomized differential comparison against the previous full-sort
|
||||
oracle;
|
||||
- equal-distance overflow and cap-boundary behavior;
|
||||
- lit, directional, dynamic, static, and visible-cell filtering;
|
||||
- a deterministic 463-light Town Network-scale candidate set;
|
||||
- zero warmed allocations on both bounded and equal-rank fallback routes.
|
||||
|
||||
A Release diagnostic microbenchmark ran 100,000 snapshot builds with 463
|
||||
eligible uniquely ranked lights:
|
||||
|
||||
| Route | Elapsed | Managed allocation |
|
||||
|---|---:|---:|
|
||||
| previous complete sort | 1,377.578 ms | 0 bytes |
|
||||
| retained bounded selector | 976.993 ms | 0 bytes |
|
||||
|
||||
That sample is a 1.410x throughput improvement, or about 29.1% less CPU time
|
||||
inside selection. It is a narrow microbenchmark rather than a whole-frame FPS
|
||||
claim; its purpose is to prove that the replacement removes work rather than
|
||||
merely moving it.
|
||||
|
||||
## Safety boundary
|
||||
|
||||
No shader, light parameters, candidate membership, draw order, or production
|
||||
scene ownership changed. AP-85 remains open because retail's exact
|
||||
7-dynamic/40-static pools and DBObj-resident cell lifecycle are outside this
|
||||
performance-only slice.
|
||||
|
||||
G4's independent visual rollback remains:
|
||||
|
||||
```text
|
||||
git revert ef1d263337997bb030eadb7b8e71d73dc659907a
|
||||
```
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue