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
|
|
@ -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