DEV Community

Hiroshi TK
Hiroshi TK

Posted on

Footstep dust in Three.js with a pool of four effects

A footstep effect has two separate rates: how often the character asks for a puff, and how many particles each puff emits. Keeping those separate makes it possible to budget a repeated effect without tying it to the display refresh rate.

This NixieFX tutorial uses an intentionally simple character proxy, a moving box, with a dust request every 15 simulation frames. At a fixed step of 1/60 second, that is four requests per simulated second. Each request restarts one available effect containing a 12-particle burst.

The effect contract

The tested scene uses nixie-fx 0.1.20 and Three.js. The emitter uses procedural unlit billboards, world-space simulation, no continuous emission, a burst at time zero, and a particle lifetime of 0.7 seconds. The effect timeline lasts 0.8 seconds; the emitter's emission duration is 0.05 seconds. There are no texture or mesh downloads.

That short emission duration matters. A pool member is available only after its effect is inactive. Leaving a long emitter duration on a short visual puff can keep the pool occupied after the visible particles have gone away.

const pool = Array.from({ length: 4 }, (_, i) =>
  vfx.createEffect(dustDefinition, { autoStart: false, seed: 42 + i })
);
let requests = 0, reused = 0, dropped = 0;

function onFootstep(position) {
  requests++;
  const puff = pool.find(instance => !instance.isActive);
  if (!puff) { dropped++; return; }
  puff.setTransform({ position });
  puff.restart();
  reused++;
}

// In this test harness, frame advances at a fixed simulation step.
if (frame % 15 === 0) onFootstep([characterX, 0, 0]);
vfx.update(1 / 60);
renderer.render(scene, camera);
Enter fullscreen mode Exit fullscreen mode

The fragment assumes a constructed renderer and the dust definition described above. In an actual game, trigger on the animation's foot-contact event instead of a frame counter. Update the owning renderer once per simulation step; do not also update its instances individually.

The position belongs to the contact at emission time. Moving an active puff to follow the character would create a different effect. The test moves only an available member immediately before restarting it.

What the run measured

In one Chrome run on macOS, 180 simulation frames produced 12 requests, 12 pool reuses, zero dropped requests, and four retained effect instances. The final frame contained 24 active particles. The recorded p95 CPU duration around the runtime update was 2.0 ms, while the p95 animation-frame interval was 17.2 ms.

These are observations from a three-second demonstration with other browser tabs open. They include startup samples and debug-transform capture. They are not GPU measurements, a mobile-device budget, or evidence that pooling is faster than allocating a fresh instance. The useful functional result is narrower: the four-member pool served the requested cadence without allocating another effect instance after setup.

Saturation is a behavior to choose

This implementation drops a cosmetic puff when the pool is full. That avoids stealing an active instance and visibly cutting off its particles. A different game may choose a larger pool or allow a controlled fallback allocation. Measure the number of dropped requests so the decision is visible.

Four members are a test parameter, not a universal recommendation. A faster character, multiple characters, longer particle lifetimes, or a slower completion condition can change occupancy. Test those cases separately before selecting the production capacity.

Limits and cleanup

The runtime reported the tested Three.js effect as supported with no warnings or blockers. This example does not implement ground collision, terrain normals, animated character feet, lighting, or mesh particles. Those settings were not validated by this run and should not be inferred from the result. The Three.js runtime documentation covers integration and backend support checks.

Remove every retained instance when the owning scene is released, or destroy the renderer if that scene owns it. An inactive effect is still a retained instance. The pool has to be bounded and its lifetime explicit even when nothing is visible.

Top comments (0)