bugfix(fx): Destroy slave particle systems with their master instead of orphaning them - #3071
Conversation
…of orphaning them ~ParticleSystem() cleared the slave's master pointer but left the slave alive. A slave is never positioned or attached itself - the master merges positions into it on every burst - and it is kept from emitting on its own only by the m_masterSystem == NULL check in update(). Orphaning it therefore produces a live system with no transform at all, which emits its particles at raw local coordinates, i.e. the world origin. destroy() already propagates to the slave for this reason; the destructor did not. Verified on a VC6 release build against Golden Replay 1: emissions at the world origin drop from 59 to 0 over a full playthrough, with no CRC mismatch before or after.
|
I think the fix works, but would like to have a better way to verify. I think it'd be good to find out and report why there's run-to-run variance, and use that for an easier reproduction. |
|
I think the fix is good - RE exploring why there's run to run variance, you could:
Maybe Reword “the slave is meant to outlive the master” to “the slave is configured with a longer lifetime.” The former implies design intent that isn’t established. Nit: The large source comment could be shortened, most of that explanation belongs in the PR description. |
Per review: the long rationale belongs in the pull request description, not inline.
|
Thanks both — addressed all three. @bobtista on variance: measured it. Three runs of the same replay on the same VC6 binary, fix disabled:
No CRC mismatch in any run, so the simulation is identical and the variance is purely client-side. Three inputs, none of them lockstepped: Correction to something I had implied earlier: Also took the wording fix — "configured to outlive its master" rather than "meant to", since the design intent isn't established — and moved the long rationale out of the source comment into the description. @Caball009 on an easier reproduction: the variance analysis points at one. Pinning the Left as draft — that's yours to flip. |
What happens
~ParticleSystem()clears a slave system's master pointer but leaves the slave alive:That one line turns a well-behaved slave into a system that emits at the world origin, for two
reasons that only combine once the master is gone:
every burst (
mergeRelatedParticleSystems), so the slave has no attachment and no localtransform. With no master,
m_isIdentityis true andgenerateParticleInfoplaces itsparticles at raw local coordinates -
(0,0,0).update():if (!isShrouded && m_isStopped == false && m_masterSystem == nullptr). Clearing the master isexactly what opens that gate.
destroy()already handles this correctly and propagates to the slave, with a comment saying why.The destructor did not.
Why this is not a one-off entry
The exposure exists whenever a slave has a longer lifetime than its master. For the pair observed
in Golden Replay 1:
SpectreHotPillarArmFlame(master)SpectreHotPillarArmFlameSlave(slave)The slave is configured to outlive its master by 110 frames, and that window is the exposure.
Any master/slave pair with the same shape has it - worth grepping
ParticleSystem.inifor othersrather than treating this as a single bad entry.
Both are
CRITICALpriority, so LOD/priority culling is not what removes the master here.Evidence
Measured on a VC6 Release build (
RTS_BUILD_OPTION_DEBUG=OFF) playing back!Golden Replay #1.rep, with a temporary probe logging any particle system whose resolved emittertransform lands within 250 world units of the origin.
Every logged emission before the fix was identical in shape:
identity=1with both attachment ids zero is the signature described above: no transform, noowner. ~550,000 particle systems were created over the run, so the probe was not starved.
The replay stays CRC-clean before and after, so retail compatibility is unaffected - expected,
since this is client-side FX state that consumes no logic random values.
The file lives in
Core/, so the fix covers both Generals and Zero Hour.Run-to-run variance
The issue notes the explosions are not reproducible at fixed timestamps. That is measurable.
Three runs of the same replay on the same binary, fix disabled:
Nearly a 10x spread in count, and the first event moves by ~2,400 frames. The simulation is
identical in all three (no CRC mismatch), so this is entirely client-side. Three inputs, none of
them lockstepped:
GameClientRandomValuedrives burst counts, emission positions and particle lifetimes, andis deliberately decoupled from the logic seed.
createParticle()early-returns onpriority < TheGameLODManager->getMinDynamicParticlePriority(), documented in the header as"priority at which particles will still render at current FPS", plus
isParticleSkipped().m_maxParticleCount/removeOldestParticles), which fires basedon how many particles happen to be alive at that instant.
The last two decide whether a particle exists, which decides how long a system keeps particles
alive, which decides when the master is finally deleted - and that deletion is the trigger. So
whether a given master is deleted while its slave still has lifetime is a client-side race.
For the record,
ParticleSystemManager::update()is throttled to once per logic frame, so thestepping itself is not framerate-dependent; the LOD gate is.
This also suggests an easier reproduction than waiting on a long replay: pinning the client seed
and disabling the LOD/cap early-returns should make it deterministic, at the cost of unbounded
particle counts - worth doing behind a debug flag rather than shipping.
Reproduction notes
around frame 111 and the simulation is effectively dead by frame ~700, which still presents as a
clean run.
produced exactly one particle template - so headless is blind to anything downstream of the
Drawable.