perf(vfx): port retail particle visibility degradation
Resolve DAT-authored particle ranges from the hardware GfxObj, apply retail distance and completed-cell visibility gates, and preserve the exact finite/infinite off-view update semantics. This removes dense-world simulation work without shortening terrain, entity, fog, or streaming distance. Publish doorway-clipped outdoor cells through a focused frame controller, retain effect cell identity for outdoor statics, reject hidden emitters before particle-slot scans, and offer an explicit opt-in Extended particle range. Release build succeeds and all 5,857 tests pass with five intentional skips. Retail-conformance, architecture, and adversarial review cycles are clean; connected Aerlinthe visual/performance gate pending. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
82789eea88
commit
f1ba147ac5
29 changed files with 1810 additions and 125 deletions
|
|
@ -105,7 +105,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
|
||||
---
|
||||
|
||||
## 3. Documented approximation (AP) — 87 active rows
|
||||
## 3. Documented approximation (AP) — 89 active rows
|
||||
|
||||
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
|
||||
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
|
||||
|
|
@ -214,6 +214,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
|
||||
| ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
|
||||
| AP-115 | The DAT-authored portal-space viewport and its animation `SoundTweakedHook` are live, but the separate `ClientUISystem` enter/exit sound enums and centered repeating `"In Portal Space - Please Wait..."` display string are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; teleport event switch in `GameWindow.cs` | acdream has no retained cross-stack centered display-string owner or ClientUISystem sound-table-enum resolver yet; routing the line into chat or inventing direct wave IDs would be less faithful | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, and animation-authored sound, but lacks retail's short UI cue sounds and center notice | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` |
|
||||
| AP-116 | Optional Display setting `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; default `Retail` uses exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | Explicit opt-in for players who value distant VFX; it changes no terrain, scenery, entity, fog, or streaming distance and is reversible live | Extended can roughly enlarge the active particle area and reduce the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
|
||||
| AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
|
||||
|
||||
## 4. Temporary stopgap (TS) — 33 active rows (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,23 @@ loader when preloading emitter meshes. This is not a second DAT reader:
|
|||
`CreateBlockingParticleHook::Execute` `0x00526EF0` and
|
||||
`ParticleManager::CreateBlockingParticleEmitter` `0x0051B8A0`.
|
||||
|
||||
**Retail particle visibility/degradation seam (2026-07-17).** WorldBuilder's
|
||||
particle simulator remains a useful DAT-integrator and batching reference, but
|
||||
it does not carry the live retail client's `CObjCell::IsInView` degradation
|
||||
path. `EmitterDescRegistry` now resolves the hardware particle GfxObj and its
|
||||
ordered `GfxObjDegradeInfo` entries through the same sole `DatCollection`;
|
||||
`ParticleSystem` ports the retail finite/infinite degraded branches; App feeds
|
||||
the unified PView interior set plus the landscape renderer's independently
|
||||
computed outdoor landcell set through a focused
|
||||
`ParticleVisibilityController`. Examination and dedicated-pass emitters carry
|
||||
an explicit bypass policy; missing/portal world views are empty rather than
|
||||
fail-open.
|
||||
No WorldBuilder dependency or second DAT access layer was introduced. Retail
|
||||
anchors: `CPhysicsPart::GetMaxDegradeDistance` `0x0050D510`,
|
||||
`GfxObjDegradeInfo::get_max_degrade_distance` `0x0051E2D0`,
|
||||
`CPhysicsObj::ShouldDrawParticles` `0x0050FE60`, and
|
||||
`ParticleEmitter::UpdateParticles` `0x0051D180`.
|
||||
|
||||
**Retail portal-space viewport adapter (2026-07-15).**
|
||||
`src/AcDream.App/Rendering/PortalTunnelPresentation.cs` uses the extracted
|
||||
Setup/GfxObj mesh pipeline and mandatory `WbDrawDispatcher` for retail's
|
||||
|
|
|
|||
|
|
@ -1332,7 +1332,7 @@ hitch and follows.
|
|||
| **MP-Alloc (safe batch)** | Reuse per-frame buffers: anim pose, particle draw-list, interior partition, animatedIds/drawableCells sets | ✅ SHIPPED + USER-GATED 2026-07-05 — dense-town frame-time spikes 20–87ms → 6–10ms, alloc spikes (30–75MB single-frame) eliminated, gen2 GC 5–11/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` |
|
||||
| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` pooling — the residual steady ~1.6MB/frame | ⚪ OPTIONAL follow-up — would lower the median too, but the wild-swing complaint is already resolved; each needs its own careful gate (batch correctness / physics faithfulness) |
|
||||
| MP1c | Streaming cutover to pak + hitch gate (vs the 211 ms baseline) | ⚪ after MP-Alloc |
|
||||
| MP2 | Retail distance-degrade port, hide-only cut (retires a divergence row) | ⚪ |
|
||||
| MP2 | Retail particle distance/cell-view degradation: DAT-authored range, exact infinite/finite off-view update branches, emitter-first render scan, optional Extended range | ✅ SHIPPED 2026-07-17 — Aerlinthe hotspot `0x32000223` resolves to retail 64m; landscape/entity streaming distance unchanged |
|
||||
| MP3 | Arch ECS render world + delta submission (the 300-FPS lever) | ⚪ — note: does NOT fix the steady-state GC churn (that's MP-Alloc); MP3 is the draw-submission throughput lever |
|
||||
| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | ⚪ hard-queued behind M1.5 #137 |
|
||||
| MP5 | Job-system parallelism | ⚪ stretch, evidence-gated |
|
||||
|
|
|
|||
|
|
@ -633,8 +633,15 @@ include dungeons.
|
|||
ports `CPhysicsPart::Always2D`: mode-1/no-degrade particles now keep every
|
||||
authored mesh polygon through the modern global buffer, while other degrade
|
||||
modes retain the billboard path. This restores the four-piece apex of
|
||||
Armor/protection shield effects. The final two-client visual gate is still
|
||||
pending.
|
||||
Armor/protection shield effects. MP2's 2026-07-17 corrective performance
|
||||
slice now ports the complete retail particle distance/cell-view gate and
|
||||
degraded update branches: DAT-authored ranges (Aerlinthe's dominant swarm is
|
||||
64 world units), exact infinite-emitter freeze/finite-emitter conditional
|
||||
stop bookkeeping, and emitter-first render rejection. The landscape renderer
|
||||
publishes outdoor landcells independently of surviving object meshes.
|
||||
Terrain/entity streaming distance is
|
||||
unchanged; an explicit Extended setting doubles only particle range. The
|
||||
final two-client visual gate is still pending.
|
||||
- **L.1c local attack receive path (implemented and live-gated 2026-07-15)** —
|
||||
local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted
|
||||
funnel and action-stamp gate, so ACE's server-selected melee/missile action
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
# Retail particle visibility and degradation-distance pseudocode
|
||||
|
||||
Date: 2026-07-17
|
||||
Oracle: matching Sept-2013 `acclient.exe` + `acclient.pdb`
|
||||
|
||||
## Scope
|
||||
|
||||
This note records the retail mechanism that prevents distant or non-viewed
|
||||
particle systems from consuming the full simulation and draw cost. It corrects
|
||||
the older performance suggestion in `deepdives/r04-vfx-particles.md`: retail
|
||||
does not merely hide distant particles while continuing to simulate them.
|
||||
|
||||
## Named retail functions
|
||||
|
||||
- `CPhysicsPart::GetMaxDegradeDistance` at `0x0050D510`
|
||||
- `GfxObjDegradeInfo::get_max_degrade_distance` at `0x0051E2D0`
|
||||
- `ParticleEmitter::SetInfo` at `0x0051CE90`
|
||||
- `CPhysicsObj::ShouldDrawParticles` at `0x0050FE60`
|
||||
- `ParticleEmitter::UpdateParticles` at `0x0051D180`
|
||||
- `ParticleManager::UpdateParticles` at `0x0051B800`
|
||||
|
||||
The two short distance functions and the ambiguous x87 branch in
|
||||
`UpdateParticles` were verified against instructions from the matching retail
|
||||
binary, not inferred from the decompiler's generated types.
|
||||
|
||||
## Pseudocode
|
||||
|
||||
### `CPhysicsPart::GetMaxDegradeDistance`
|
||||
|
||||
```text
|
||||
if part.gfx_object.degrades == null:
|
||||
return 100.0
|
||||
return part.gfx_object.degrades.get_max_degrade_distance()
|
||||
```
|
||||
|
||||
The unit is the client's world unit (approximately one metre).
|
||||
|
||||
### `GfxObjDegradeInfo::get_max_degrade_distance`
|
||||
|
||||
```text
|
||||
if num_degrades <= 2:
|
||||
return degrades[0].max_dist
|
||||
return degrades[num_degrades - 2].max_dist
|
||||
```
|
||||
|
||||
`GfxObjInfo` is 20 bytes and `max_dist` is the field at offset 16. The
|
||||
`num_degrades > 2` instruction reads `base + num_degrades * 20 - 24`, which is
|
||||
exactly `degrades[num_degrades - 2].max_dist`.
|
||||
|
||||
### `ParticleEmitter::SetInfo`
|
||||
|
||||
```text
|
||||
if hw_gfxobj_id cannot be loaded:
|
||||
return false # no software-GfxObj substitution
|
||||
create max_particles PhysicsPart instances from hw_gfxobj_id
|
||||
degrade_distance = first_particle_part.GetMaxDegradeDistance()
|
||||
allocate particle records
|
||||
```
|
||||
|
||||
The distance belongs to the emitter's hardware particle GfxObj, not the owner
|
||||
Setup and not the visual size of a particle.
|
||||
|
||||
### `CPhysicsObj::ShouldDrawParticles`
|
||||
|
||||
```text
|
||||
if this is the examination object:
|
||||
return true
|
||||
if distance_from_viewer > emitter.degrade_distance:
|
||||
return false
|
||||
if cell == null:
|
||||
return false
|
||||
return cell.IsInView()
|
||||
```
|
||||
|
||||
Equality passes: a particle owner at exactly the authored degradation distance
|
||||
is still eligible. The x87 comparison also admits unordered input: a NaN
|
||||
distance or authored maximum follows the comparison's pass branch. Negative,
|
||||
NaN, and infinite authored `max_dist` values are not sanitized; the 100-unit
|
||||
default applies only when the loaded hardware GfxObj has no degradation table.
|
||||
|
||||
### `ParticleEmitter::UpdateParticles`
|
||||
|
||||
```text
|
||||
if owner.ShouldDrawParticles(degrade_distance):
|
||||
if degraded_out:
|
||||
degraded_out = false
|
||||
SetNoDraw(false)
|
||||
|
||||
update every live particle
|
||||
if parent-local:
|
||||
read the parent's current frame
|
||||
emit normally, observing total-particle and total-time limits
|
||||
last_update_time = now
|
||||
return whether emitter remains alive
|
||||
|
||||
if not degraded_out:
|
||||
SetNoDraw(true)
|
||||
degraded_out = true
|
||||
|
||||
last_update_time = now
|
||||
|
||||
if total_particles == 0 and total_seconds == 0:
|
||||
# Infinite emitter. Retail rewrites each particle's birth/update time to
|
||||
# now, freezing its age while degraded, and performs no normal emission.
|
||||
for each live particle:
|
||||
particle.birth_or_last_update_time = now
|
||||
return true
|
||||
|
||||
# Finite emitter: retire expired particles and advance emission bookkeeping,
|
||||
# but do not create/render a physical particle while degraded.
|
||||
for each live particle:
|
||||
particle.lifetime = now - particle.birth_time
|
||||
if expired:
|
||||
KillParticle(particle)
|
||||
|
||||
if not stopped:
|
||||
if ShouldEmitParticle():
|
||||
RecordParticleEmission() # num_particles++ and total_emitted++
|
||||
StopEmitter() # conditional duration/count test
|
||||
return true
|
||||
|
||||
return num_particles != 0
|
||||
```
|
||||
|
||||
`StopEmitter` is a test despite its imperative name. It sets `stopped` only
|
||||
after the authored total duration expires or `total_emitted` reaches the
|
||||
authored total-particle limit. A finite emitter that is merely outside the view
|
||||
therefore remains live and repeats this branch on later updates.
|
||||
|
||||
There is a counterintuitive retail lifetime edge case here that must not be
|
||||
"repaired": `RecordParticleEmission` increments `num_particles` without
|
||||
allocating a `PhysicsPart`. If that logical emission reaches the authored stop
|
||||
condition while the effect is fully degraded, the already-stopped branch keeps
|
||||
returning `num_particles != 0` even though there is no drawable particle to
|
||||
retire. The emitter consequently remains owned until its physics object tears
|
||||
the effect down. This follows directly from `RecordParticleEmission`
|
||||
(`0x0051C870`), `UpdateParticles` (`0x0051D180`), and the manager's false-return
|
||||
removal contract; the Core conformance test pins the quirk explicitly.
|
||||
|
||||
The x87 equality branch for `(total_particles == 0 && total_seconds == 0)` was
|
||||
checked from `0x0051D1E1`: `fcomp 0`, `fnstsw ax`, `test ah,0x44`, `jp finite`.
|
||||
For equality the parity jump is not taken, selecting the infinite-emitter
|
||||
freeze path.
|
||||
|
||||
## Reference cross-checks
|
||||
|
||||
- ACE's `Physics/Particles/ParticleEmitter.cs` preserves the two degraded
|
||||
update branches and birth-time reset, but deliberately assigns
|
||||
`DegradeDistance = float.MaxValue`; it is therefore useful for control-flow
|
||||
interpretation but not for the missing client distance rule.
|
||||
- WorldBuilder/ACME's particle simulator confirms the DAT emitter fields and
|
||||
integrators, but does not implement retail's live `CObjCell::IsInView` gate.
|
||||
- The in-tree `GfxObjDegradeResolver` confirms the DatReaderWriter representation
|
||||
of `DIDDegrade`, ordered `GfxObjInfo` entries, and `MaxDist`. `DatCollection`
|
||||
remains the sole runtime DAT reader.
|
||||
|
||||
Installed-data fixture: Aerlinthe's dominant Swarm emitter `0x32000223`
|
||||
(hardware GfxObj `0x01001358`) resolves to an authored maximum distance of
|
||||
64 world units. Extended mode therefore raises that effect to 128 units; it
|
||||
does not change landscape, scenery, entity, or fog distance.
|
||||
|
||||
## acdream integration mapping
|
||||
|
||||
- Core resolves the exact authored maximum distance into `EmitterDesc`.
|
||||
- Core owns degraded simulation semantics and the no-per-particle fast path for
|
||||
infinite emitters.
|
||||
- App publishes the previous completed world-visibility product: retail PView
|
||||
interior cells plus the landscape renderer's doorway-clipped outdoor
|
||||
landcell set and camera position. The landscape-only fallback publishes the
|
||||
same product without requiring an interior root. Outdoor dat stabs carry a
|
||||
separate effect cell so their intentionally-null render parent is unchanged.
|
||||
Login and portal-space frames publish an empty world view; explicit
|
||||
examination and dedicated-pass policies are the only bypasses.
|
||||
- The renderer filters an emitter before scanning its particle slots.
|
||||
- Default mode uses authored retail distances. The optional Extended setting is
|
||||
an explicit user-facing adaptation and is recorded in the divergence register.
|
||||
|
|
@ -638,21 +638,38 @@ totals ("Particles Rendered", "Particle Systems").
|
|||
Retail did not implement a global particle LRU. Instead, each emitter
|
||||
enforced its `MaxParticles` hard cap and relied on distance-degrade:
|
||||
|
||||
`PhysicsObj.ShouldDrawParticles(degradeDistance)` (ACE
|
||||
`PhysicsObj.cs:1540-1544`):
|
||||
`CPhysicsObj::ShouldDrawParticles` (`0x0050FE60`):
|
||||
|
||||
```csharp
|
||||
public bool ShouldDrawParticles(float degradeDistance) {
|
||||
if (!ExaminationObject) return true;
|
||||
return !(CYpt > degradeDistance || CurCell == null);
|
||||
}
|
||||
```text
|
||||
if ExaminationObject: return true
|
||||
if CYpt > degradeDistance: return false
|
||||
if CurCell == null: return false
|
||||
return CurCell.IsInView()
|
||||
```
|
||||
|
||||
Particles beyond a per-emitter `DegradeDistance` (ACE stubs it to
|
||||
`float.MaxValue`) get `SetNoDraw(true)` — they keep simulating (so the
|
||||
server-side script advances) but aren't rendered. Real retail probably
|
||||
used a per-`GfxObjDegradeInfo` value; for acdream we can set a sensible
|
||||
default (50 m outdoor, 15 m indoor) and override via a PluginAPI hook.
|
||||
The matching x87 `test ah,0x41` admits equal, less-than, and unordered
|
||||
comparisons. Authored negative/NaN/infinite values are preserved; 100 is used
|
||||
only when the hardware particle GfxObj has no degradation table.
|
||||
|
||||
The exact distance is not guessed. `CPhysicsPart::GetMaxDegradeDistance`
|
||||
(`0x0050D510`) returns 100 world units when the hardware particle GfxObj has
|
||||
no degradation table. `GfxObjDegradeInfo::get_max_degrade_distance`
|
||||
(`0x0051E2D0`) selects entry zero for one/two-entry tables and the
|
||||
second-to-last entry for larger tables. `ParticleEmitter::SetInfo`
|
||||
(`0x0051CE90`) stores that value from its first hardware particle part.
|
||||
|
||||
`ParticleEmitter::UpdateParticles` (`0x0051D180`) does more than hide the
|
||||
result. While degraded, an infinite emitter performs no normal particle
|
||||
integration or emission and freezes its live particle ages. A finite emitter
|
||||
ages/kills existing particles, records at most one due emission without making
|
||||
it drawable, and calls the conditional `StopEmitter` duration/count test. It
|
||||
does not stop merely because it is degraded. If that counter-only emission
|
||||
reaches the stop condition, retail's logical `num_particles` can remain
|
||||
nonzero without a drawable `PhysicsPart`; the emitter then remains owned until
|
||||
explicit physics-object teardown. ACE preserves those two update branches but stubs the
|
||||
distance to `float.MaxValue`; retail's named code and matching x86 are the
|
||||
oracle. Full pseudocode and the installed-data fixture are in
|
||||
`docs/research/2026-07-17-retail-particle-visibility-pseudocode.md`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -798,11 +815,11 @@ RenderFrame:
|
|||
7. UI
|
||||
```
|
||||
|
||||
Simulation runs every frame regardless of visibility (matches retail's
|
||||
`ShouldDrawParticles` behavior: hidden emitters still advance their
|
||||
script). This costs a bit of CPU but keeps semantics correct — when an
|
||||
ally buff expires behind a wall, it should still be gone when you see
|
||||
them again.
|
||||
Simulation obeys retail's completed-view and authored-distance gate. Visible
|
||||
emitters run their full update. Degraded infinite emitters freeze in place;
|
||||
finite effects still age/retire and run their authored duration/count stop
|
||||
test, so a time-bounded ally buff effect does not become immortal behind a
|
||||
wall.
|
||||
|
||||
### Silk.NET shader sketch
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue