Particles
ParticleSystem is roxlap’s effects engine: fountains, smoke, sparks,
debris. It is deliberately a host-side system built on the sprite
API from chapter 7 — every particle is an ordinary kv6
sprite instance, so particles are lit, shadowed and materialed like
everything else, and they collide with the actual voxel world.
The snippets come from a runnable example — a bouncing water fountain, a buoyant smoke column, and a scripted explosion every four seconds that carves real craters:
cargo run --release -p roxlap-render --example book_particles
The system and its emitters
One ParticleSystem per scene, seeded — the same seed replays the
same effects. Effects are emitters, described declaratively by a
ParticleEmitterDef: construct with new(model) and override what
the effect needs. The two ambient effects show most of the vocabulary:
// One system per scene, seeded for reproducible effects. The
// particles instantiate ordinary kv6 sprite models.
let mut particles = ParticleSystem::new(SEED);
let spark = renderer.add_sprite_model(&spark_kv6());
let puff = renderer.add_sprite_model(&puff_kv6());
let debris = renderer.add_sprite_model(&debris_kv6());
// A water fountain: a tight upward cone of translucent
// droplets that fall back and bounce. +z is down, so the
// fountain fires along −z and gravity (+z) pulls it back.
particles.add_emitter(ParticleEmitterDef {
pos: [0.0, 0.0, 199.0],
spawn: SpawnMode::Rate(140.0),
lifetime: 2.2..3.2,
velocity: VelocityDef {
cone: Some(ConeDef {
axis: [0.0, 0.0, -1.0], // straight up
half_angle_deg: 11.0,
speed: 26.0..34.0,
}),
..VelocityDef::default()
},
collision: CollisionMode::Bounce { restitution: 0.45 },
scale: 0.7,
fade_out_frac: 0.3,
tint: Rgb(0x0060_a8ff),
material: MAT_WATER,
..ParticleEmitterDef::new(spark)
});
// A smoke column: buoyant (negative-z "gravity"), dragged,
// spinning, growing while it condenses in and thins out.
particles.add_emitter(ParticleEmitterDef {
pos: [-40.0, 20.0, 198.0],
shape: EmitterShape::Sphere { radius: 2.0 },
spawn: SpawnMode::Rate(16.0),
lifetime: 3.5..5.0,
velocity: VelocityDef {
base: [0.0, 0.0, -7.0], // rises: −z is up
spread: 1.0,
..VelocityDef::default()
},
gravity: [1.2, 0.0, -1.5], // buoyant, drifting east
drag: 0.9,
spin: -0.6..0.6,
scale: 0.8,
scale_end: Some(2.8),
fade_in_frac: 0.25,
fade_out_frac: 0.45,
tint: Rgb(0x00b8_b8b8),
tint_end: Some(Rgb(0x0050_5050)),
material: MAT_SMOKE,
// WorldUp: stable shading that doesn't swim as the camera
// orbits (FaceNormal is the default; FullBright for glows).
lighting: BillboardLighting::WorldUp,
..ParticleEmitterDef::new(puff)
});
The def’s fields group into three concerns:
- Spawning —
spawn(Rate(n)per second with fractional accumulation,Burst(n)once on add,Manual+ explicitburst()calls),shape(point / sphere / box aroundpos),lifetime(a sampled range). - Motion —
velocity(a fixedbase+ isotropicspread+ an optionalConeDef, composed by addition),gravity(positive z is down: the default[0, 0, 22]falls; smoke rises with a negative-z term),drag,spin. - Look —
scale→scale_end(growing smoke, shrinking sparks),fade_in_frac/fade_out_frac(alpha ramps at the ends of life),tint→tint_end(white-hot → ember),material(the palette from chapter 6 — smoke wants alpha-blend, sparks additive),lighting,shadows.
Note the model trick: all three effects share plain white models — the per-particle tint does the colouring, so one 2-voxel cube serves as droplet and spark both.
Shadows default to off in both directions for particles — hundreds of shadow-casting instances is a perf trap; opt back in per effect.
The per-frame protocol
// The whole per-frame particle protocol is one call:
// simulate, collide against the scene's voxels, and
// mirror live particles into sprite instances.
particles.tick_with_scene(renderer, dt, scene);
One call simulates, collides, and mirrors live particles into sprite
instances (through the same batched-transform path as any bulk sprite
motion). Use tick(renderer, dt) when nothing collides —
tick_with_scene is what enables collision:
CollisionMode::None— pass through (default).Kill— die on contact: impact sparks, raindrops.Bounce { restitution }— arcade reflection off voxel faces.
The collision test is a point sample nudged along the velocity — cheap and honest about what it is: fast particles can tunnel through one-voxel walls, resting particles stop being tested. It’s an effects system, not a physics engine.
Explosions that change the world: carve_debris
The signature roxlap effect — because particles and world share one voxel vocabulary, an explosion can become its debris:
/// One explosion: `carve_debris` samples the floor's own voxel
/// colours, carves the crater out of the grid, and bursts the
/// removed voxels back as tumbling, bouncing, tinted debris — the
/// world visibly becomes the particles. A transient spark burst
/// goes on top (`Burst` spawns on add; removing the emitter lets
/// the sparks live out their lifetimes).
fn explode(&mut self, world: [f32; 3], voxel: IVec3) {
let (Some(particles), Some(scene), Some(grid)) =
(self.particles.as_mut(), self.scene.as_mut(), self.grid)
else {
return;
};
if let Some(debris) = self.debris {
particles.carve_debris(
scene,
grid,
voxel,
4, // crater radius, voxels
8.0..16.0, // radial kick away from the crater centre
&ParticleEmitterDef {
lifetime: 1.4..2.4,
collision: CollisionMode::Bounce { restitution: 0.35 },
spin: -7.0..7.0,
scale: 0.9,
scale_end: Some(0.4),
fade_out_frac: 0.2,
..ParticleEmitterDef::new(debris)
},
);
}
if let Some(spark) = self.spark {
let em = particles.add_emitter(ParticleEmitterDef {
pos: [world[0], world[1], world[2] - 0.5],
spawn: SpawnMode::Burst(24),
lifetime: 0.5..1.1,
velocity: VelocityDef {
spread: 24.0,
..VelocityDef::default()
},
collision: CollisionMode::Kill,
scale: 0.6,
fade_out_frac: 0.5,
tint: Rgb(0x00ff_c840), // white-hot…
tint_end: Some(Rgb(0x00ff_3000)), // …to ember red
material: MAT_SPARK,
lighting: BillboardLighting::FullBright,
..ParticleEmitterDef::new(spark)
});
particles.remove_emitter(em);
}
}
carve_debris samples the crater’s voxel colours, carves the sphere
out of the grid (the same edit path as chapter 3,
lighting re-bake included), and spawns one tinted debris particle per
removed surface voxel with a radial kick. The floor’s own colours
tumble away — no artist-authored debris needed.
Budgets keep it bounded: set_carve_debris_cap limits debris per
carve (default CARVE_DEBRIS_CAP), set_max_particles caps the
whole system (default DEFAULT_MAX_PARTICLES; overflow spawns are
dropped and counted in dropped_spawns, never reallocated). For HUD
counters read particle_count() / particles().
Performance envelope: the PS-stage benchmark holds ~10 000 live particles at ≈225 µs of simulation per frame — effects budget-level, not gameplay-limiting. The knobs that matter when a scene gets heavy: crater radius (a radius-4 carve samples ~70 voxels), burst sizes, and keeping particle shadows off.
Further reading
- The Particles demo scene — this chapter’s effects plus
crosshair-aimed interactive explosions and explosion light flashes
(
ROXLAP_SCENE=Particles cargo run --release -p roxlap-scene-demo). PORTING-PARTICLES.md— the PS-stage design history and benchmarks.