Skip to content

2. Introduction to Particles

codex edited this page Jan 15, 2024 · 5 revisions

Hello World

The first step to creating a particle system is creating a ParticleGroup. A particle group is responsible for storing a number of particles.

ParticleGroup<ParticleData> group = new ParticleGroup<>(1);

In order to update, the group must be attached to the scene graph.

rootNode.attachChild(group);

Unlike other particle systems JMonkeyEngine offers, this system does not automatically spawn particles. You need to add particles to the group yourself, like so.

group.add(new ParticleData());

Important: this is the most basic method of spawning particles in this library. More automated methods exist which you will probably prefer for many applications (such as Emitter, see below).

A particle system that cannot be seen is pretty boring. Let's create a particle geometry that displays all the particles belonging to the particle group.

TriParticleGeometry geometry = new TriParticleGeometry(group, MeshPrototype.QUAD);
Material mat = new Material(assetManager, "MatDefs/particles.j3md");
geometry.setMaterial(mat);
group.attachChild(geometry);

Run the application. You should see a single white particle at position (0,0,0). The particle will survive for exactly one second before disappearing. If you want the particle to survive longer, you can set the particle lifetime using a constructor.

// create a particle that survives for ten seconds
new ParticleData(10);

Group Capacity

If you went ahead and tried other things with the particle system, you may have noticed that the system limits you to displaying one particle at a time. This is due to a system limitation. The particle geometry wants to allocate just enough memory for the maximum number of particles it must display at once in order to save resources. Therefore, ParticleGroup limits the maximum number of particles that it can hold at once. This is called the ParticleGroup's capacity. You can set the group's capacity using a constructor or a setter method.

// set the capacity to 10
ParticleGroup group = new ParticleGroup(10);
// set the capacity to 20
group.setCapacity(20);

Now the system is able to accept more particles. Go ahead and try it.

Note: it is discouraged to use setCapacity except during initialization, because it forces particle geometries to reload their buffers.

By default, added particles that make the group exceed its capacity are discarded. This behavior is sometimes undesirable since it can create weird "spawning artifacts." You can easily change this behavior by setting the overflow strategy on the group. The following snippet makes the group destroy the oldest particles in the group when an overflow occurs.

group.setOverflowStrategy(OverflowStrategy.CullOld);

You can also write your own OverflowStrategies to fit unique situations.

Updating Particles

Bringing particles to life is very easy with this system. For example, the following snippet makes each particle move 1 unit to the left every second.

for (ParticleData p : group) {
    p.getPosition().addLocal(tpf, 0, 0);
}

This makes particles accelerate downward at -0.2 units and gradually get darker in color.

for (ParticleData p : group) {
    p.linearVelocity.addLocal(0, -0.2f*tpf, 0);
    p.getPosition().addLocal(p.linearVelocity);
    p.color.set(p.color.get().mult(0.95f));
}

If at any point you'd like to destroy a particle yourself, you can call the particle's kill method.

for (ParticleData p : group) {
    p.kill();
}

The particle will be removed on the next group update.

Drivers

Although you can do everything with this system without them, ParticleDrivers are the real powerhouse of the system. They are responsible for managing the behavior of particles. Think of them as a JME Control for particles instead of spatials. You can add a driver to a ParticleGroup like so:

group.addDriver(myDriver);

The particle group will then call appropriate methods in the driver every frame or on specific events.

The ParticleDriver interface has 4 important methods:

  1. updateGroup(group, tpf) is called once per frame, and is intended to update the ParticleGroup the driver is driving (passed as the group argument). Although it is sometimes useful, and necessary, perform operations on contained particles in this method.

  2. updateParticle(particle, tpf) is called once per frame per particle in the driven group. This is dedicated to managing the behavior of existing particles. Don't attempt to perform operations on the driven group (such as spawning) in this method.

  3. particleAdded(group, particle) is called whenever a particle is added to the driven group. This is a very important method for spawning particles, which I will get to later.

  4. groupReset(group) is called when the driven group is reset. Resetting is restarting the particle group from the beginning of its "simulation," so drivers should also reset their properties when this occurs.

By implementing the ParticleDriver interface, you can write custom reusable behavior for your particles.

Important: in JME, the tpf argument more or less represents the seconds per frame. Not so here. The ParticleGroup is able to increase or descrease tpf in order to make drivers run faster or slower or not run at all.

Prebuilt Drivers

The library comes with a number of common prebuilt drivers. Most of them can be found as static instances in the ParticleDriver interface:

  • ParticleDriver.Position updates particle position based on linear velocity
  • ParticleDriver.Rotation updates particle rotation based on angular velocity
  • ParticleDriver.Angle updates particle screen-space angle based on angle speed (note: "rotation" is not the same as "angle")
  • ParticleDriver.TransformToVolume transforms newly spawned particles to inside an EmissionVolume originating at the world transform of the ParticleGroup.
  • ParticleDriver.force(vector) applies a constant force to existing particles throughout their lifetime.

To use any of these drivers, simply add them to a particle group.

group.addDriver(ParticleDriver.Position);

Emitter

Emitter is a driver that will automatically spawn particles at particular time intervals. It is useful for more traditional particle setups or for quick prototyping.

The Emitter class is abstract, because it supports the emission many different types of particles, but you can create a non-abstract instance using Emitter.create().

Emitter e = Emitter.create();
group.addDriver(e);

Important: an Emitter does not configure the properties of spawned particles, it just spawns them.

ParticleFactory

To quickly write a ParticleDriver implementation that only messes with newly-spawned particles, use the ParticleFactory class. ParticleFactory "de-abstracts" all ParticleDriver methods except particleAdded() so you can quickly and cleanly write something to configure spawned particles.

group.addDriver(new ParticleFactory<ParticleData>() {
    @Override
    public void particleAdded(ParticleGroup group, ParticleData particle) {
        particle.color.set(ColorRGBA.Red);
    }
});

This is often paired with Emitter. Emitter creates the bare particles, and ParticleFactory configures them.

EmissionVolume

EmissionVolumes are used to spawn particle in a random location within a defined space. ParticleGroup contains an EmissionVolume which can be accessed by drivers (like ParticleDriver.TransformToVolume) to correctly position particles when they are spawned. You can set the volume used by a group (and subsequent drivers) like so:

group.setVolume(new EmissionPoint());

Using an EmissionVolume is easy. Pass in a transform, and it returns a position to spawn the particle at. For example, to position a particle at the world transform of a group:

Vector3f pos = group.getVolume().getNextPosition(group.getWorldTransform());
particle.setPosition(pos);

Note: drivers need not use the EmissionVolume attached to the driven group. They can use their own if they please.

Particle Geometries and Materials

There are four built-in particle geometries to choose from:

  1. PointParticleGeometry
  2. TriParticleGeometry
  3. InstancedParticleGeometry
  4. TrailingGeometry

PointParticleGeometry is the simplest to use, but requires a particular material.

PointParticleGeometry geometry = new PointParticleGeometry(group);
Material mat = new Material(assetManager, "MatDefs/particles.j3md");
mat.setBoolean("PointSprite", true);
geometry.setMaterial(mat);

"particles.j3md" is a special material built specifically for both point and tri particle geometries. To use the material on a point particle geometry, you must set the "PointSprite" material parameter to true.

TriParticleGeometry is used almost the same way. A mesh prototype must be specified, which defines each particle's shape and texture coordinates.

TriParticleGeometry geometry = new TriParticleGeometry(group, MeshPrototype.QUAD);
Material mat = new Material(assetManager, "MatDefs/particles.j3md");
geometry.setMaterial(mat);

Both point and tri particle geometries are billboarded, meaning their meshes always face the camera.

InstancedParticleGeometry is the most useful. Given a mesh -- any mesh -- it will place an instance of that mesh at each particle. Also, unlike the previous two, it supports the use of any material definition that supports instancing.

Box mesh = new Box(1, 1, 1);
InstancedParticleGeometry geometry = new InstancedParticleGeometry(group, mesh);
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", ColorRGBA.Blue);
mat.setBoolean("UseInstancing", true);
geometry.setMaterial(mat);

TrailingGeometry is also useful, but its use is beyond the scope of the introduction. You can see a code example of it here.

Full Example

public class TestBasicParticles extends SimpleApplication {
    
    public static void main(String[] args) {
        new TestBasicParticles().start();
    }
    
    @Override
    public void simpleInitApp() {
        
        // create a particle group
        ParticleGroup<ParticleData> group = new ParticleGroup(200);
        group.setLocalTranslation(0, 3, 0);
        group.setOverflowStrategy(OverflowStrategy.CullNew);
        group.addDriver(ParticleDriver.force(new Vector3f(0f, -3f, 0f)));
        group.addDriver(ParticleDriver.Position);
        group.addDriver(ParticleDriver.Angle);
        rootNode.attachChild(group);

        // make an emitter to emit particles at certain intervals
        Emitter e = Emitter.create();
        e.setParticlesPerEmission(Value.constant(5));
        e.setEmissionRate(Value.constant(.1f));
        group.addDriver(e);

        // make a particle factory to configure particles created by the emitter
        group.addDriver(new ParticleFactory<ParticleData>() {
            @Override
            public void particleAdded(ParticleGroup<ParticleData> group, ParticleData p) {
                p.setLife(4f);
                p.setPosition(group.getVolume().getNextPosition(group.getWorldTransform()));
                p.color.set(new ColorHSBA(FastMath.nextRandomFloat(), 1f, .5f, 1f).toRGBA());
                p.linearVelocity = VfxUtils.gen.nextUnitVector3f().multLocal(VfxUtils.gen.nextFloat(4));
                p.setScale(FastMath.rand.nextFloat(.05f, .2f));
                p.angleSpeed.set(FastMath.rand.nextFloat(-5f, 5f));
            }
        });
        
        // create a geometry to display the particles
        TriParticleGeometry geometry = new TriParticleGeometry(group, MeshPrototype.QUAD);
        Material mat = new Material(assetManager, "MatDefs/particles.j3md");
        geometry.setMaterial(mat);
        rootNode.attachChild(geometry);
    
    }
    @Override
    public void simpleUpdate(float tpf) {}
    
}