"It looks different on my machine" is the worst bug report you can get about a particle effect, and it is also the most common one.
Nobody can reproduce it, nothing is thrown, and the only artefact is a teammate insisting the explosion is "wetter" on their laptop. Then someone records a video, and they are right.
Two separate causes wearing the same costume
Almost every one of these turns out to be one of two things, and they are worth separating because the fixes are unrelated.
Cause one: you are feeding the simulation the wrong unit.
Every runtime picks a convention for its update step, and the two popular ones differ by a factor of 1000. If the API wants seconds and your host loop hands it milliseconds, the effect does not error — it just runs a thousand times too fast, which reads as "the particles vanish instantly" or, at low emission rates, as "it looks kind of aggressive". On a fast machine with a 4 ms frame you get one appearance; on a throttled laptop at 30 ms you get another. Same code, different vibe, no exception.
// three.js: THREE.Clock.getDelta() already returns seconds
vfx.update(clock.getDelta());
// but a raw rAF timestamp does not
let last = performance.now();
function frame(now) {
vfx.update((now - last) / 1000); // <- the /1000 is the whole bug
last = now;
requestAnimationFrame(frame);
}
I have written that missing / 1000 more than once. It is worth a comment on the line, because it is invisible in review — both versions are a number being passed to a function that takes a number.
Cause two: the simulation is genuinely random per run.
Particle systems are stochastic by design. If the randomness is seeded from the clock, then two machines, two reloads, and two QA passes all produce different motion. That is fine for ambient smoke and actively hostile for anything you need to verify, screenshot, or regression-test.
Seeded runs are the feature to look for
The property you want from a runtime is: same effect, same seed, same motion — on every machine and every backend. The NixieFX Three.js runtime exposes it directly on effect creation:
const effect = vfx.createEffect(fireBurst, { seed: 1234 });
Pass a seed and the run is reproducible. Leave it out and you get variety.
That single parameter changes what is possible downstream:
- Screenshot tests work. Fixed seed plus fixed delta means frame 30 is byte-comparable across CI runs. You can diff VFX like you diff a component render.
- Bug reports become artefacts. "Seed 1234, frame 30" is a reproduction. "It looks wetter on my laptop" is not.
- A/B comparisons are honest. Comparing two parameter sets under different random draws tells you nothing; under the same seed it tells you exactly what the parameter did.
- Variety stays available. Seed per spawn from your own counter and every explosion differs, but deterministically — replayable from the same game state.
The discipline, compressed
Two rules cover the whole class of bug:
- Advance the simulation with seconds, exactly once per frame, from the host's own clock — never from a value you computed twice or reused across systems.
- Seed anything you intend to verify. Ambient effects can float; anything in a test, a screenshot, or a bug report gets a number.
Neither is clever. Both are the kind of thing you only write down after losing an afternoon to a "wetter" explosion.
Related trap, for anyone chasing frame time rather than appearance: updating the simulation inside a loop that already runs per-object means you advance it N times per frame. It looks like a performance problem and it is actually a correctness one.
Top comments (0)