flutter3d
Showcase Changelog 38 packages API reference

A particle pool in one draw call

since 0.2.0 Physics and particles

A game does not want a hundred sparks to cost a hundred draw calls. This engine keeps every live particle in one pool and draws the whole pool in a single instanced call, whatever effect put each particle there.

Step 1: A pool with a seed #

ParticleSystem owns a fixed number of particle slots, reused for the life of the application. Passing a seed makes the whole simulation reproducible: the same seed and the same sequence of calls give byte-identical particles, which is what lets the check at the end of this page count on an exact number rather than a range.

// A fixed seed, so the same burst comes out byte for byte on every run.
// That is what a `ParticleRandom` buys: not randomness, but a stream a
// particle owns from the moment it is born.
_particles = ParticleSystem(capacity: _capacity, seed: 20260919);

Step 2: A recipe, not an object #

A ParticleEffect is data: how many particles a burst makes, where they start (an emitter), how long they live, how big they are, and what colour. Nothing here is a specific effect. An explosion and a puff of dust are the same four fields with different numbers.

_sprayEffect = ParticleEffect(
  count: _burstCount,
  emitter: const SphereEmitter(speed: Range(1.5, 3.0)),
  lifetime: const Range(0.8, 1.6),
  size: const Range(0.05, 0.12),
  color: Vector4(1.0, 0.7, 0.3, 1.0),
);

Step 3: One burst, one contributor #

burst fills as many slots as the effect asks for, all at once. ParticleContributor is what actually draws them: it reads every live particle out of the pool and writes one batch of camera-facing quads, so the system can hold a thousand particles from a dozen different effects and still cost one draw.

_particles.burst(_sprayEffect, Vector3.zero());
_contributor = context.renderer.addContributor(
  ParticleContributor(_particles),
);

Step 4: Keep it moving #

A particle system does nothing by itself. Every frame, advance steps the simulation forward: it ages each particle, applies whatever affectors it carries, and moves it by its velocity.

_particles.advance(dt);

Note. advance divides its input into fixed sub-steps of a hundred-and-twentieth of a second, so the same effect looks the same whether the frame is running at thirty hertz or a hundred and twenty.

Step 5: What the page checks #

The seed from step one is what makes this an exact number rather than a guess: the same burst always emits the same count, so the check is that the pool still holds exactly that many, and that the contributor actually drew.

if (_particles.aliveCount != _burstCount) {
  throw StateError(
    'expected $_burstCount live particles, got ${_particles.aliveCount}',
  );
}
if (!_contributor.isActive || frame.drawCalls < 1) {
  throw StateError('the pool did not reach the frame');
}