Particle System

Implement a simple 3D particle system in Utomata.

A Particle system, as its name suggests, is an algorithm that features a collection of independent — or inter-dependent — units. This may refer to a large class of systems and use cases which includes anything from simple graphic effects to physics simulations and swarm dynamics.

In this guide we will implement a 3D system where each particle moves independently and bounces off the sides of an imaginary cubic volume. Utomata's parallel model of computation approaches this idea slightly differently from other graphics and simulation environments.

In a traditional programming language, one might create a collection of objects and assign properties, such as position and velocity, to each one. But in Utomata, positions and velocities are fields that update over time. Individual particles are just one possible visual projection of the data stored in fields.

Setup

&dim = (16);

#positions {
   dim = &dim;
   set = srand(1,2,3);
}

~particles {
   dim = &dim;
   i_pos = #positions;
}

The above code declares a field called #positions and a formation called ~particles. Each cell in the positions field is initialized with srand(1,2,3), which generates a signed seeded random value between -1.0 and 1.0 for x, y and z. The three arguments are arbitrary seeds, one for each component; using different seed values simply gives us a different reproducible random sequence.

Note the use of the &dim macro to define the dimension of both the field and the formation, ensuring both have the exact same number of entities - for now just 16 particles - a 1D field.

Adding motion

Let's add a second field:

#velocities {
   dim = &dim;
   set = mlt(srand(2,4,5), 0.01);
}

Just like with positions, we initialize this field with the same shared dimension and set all cells with a signed random value. This time, however, we scale values down significantly so that they range between -0.01 and 0.01 one each axis: x, y and z.

By using a shared &dim we ensure the same indexing structure for both fields, as well as the formation. This means that for any given instance in ~particles, there is exactly one cell in #positions and one in #velocities that conforms to the same coordinate. This is what allows properties such as i_pos = #positions; to stay unambiguous.

We can now apply motion to our particle system by adding the values stored in #velocities to the values stored in #positions:

#positions {
   dim = &dim;
   set = srand(1,2,3);
   run = add(#, #velocities); // <-- changes positions over time
}

With the addition of the run property to #positions, each particle should now start to move according to its uniquely assigned velocity.

Adjustments

Try Changing the system's dimension:

&dim = (64, 64);

The number of particles should instantly change as both fields and the formation adapt to contain and reflect more values. using two values instead of one has altered the field dimension from 1D to 2D, but keep in mind — our simulation was, and remained 3D. This is because ~particles uses the value stored in #positions cells, and not their index. As long as the same dim is used, each particle instance nonetheless conforms to exactly one cell in each field.

Notice how we can already see the implicit boundary of a cubic volume to which particles are confined. By default, field state values in utomata are clamped to a SIGNED value domain, meaning they cannot exceed ±1 on each axis. This can be changed by setting the value domain to accept any real value:

val_dom = REAL;

However, this will now allow our particles to run off screen in all directions. Let's instead handle the bounds more gracefully.

Bounds detection

Instead of allowing the particles to exceed the ±1 bounds, let's make them change direction when they hit it. In practice this means adding a run rule to #velocities. It comes in two parts:

A. Identify if a particle is about to exceed ±1 on a given axis - x, y or z. B. If it does - the value stored in velocity should be flipped (multiplied by -1) on that axis.

let's first unpack A.

// "a particle is about to":
add(#positions, #velocities)
// "exceed +-1":
lrg( abs( X ) , 1)

The two statements above describe both parts of A. The first expression describes what each particle is about to do. This also happens to be the exact calculation run by #positions - but crucially this does not mean we can just use the value stored in #positions, because we need to predict the next value rather than the current one. The second expression tests whether a value exceed +-1 by asking about its absolute value. Put together, they look like this:

&hit = lrg(abs(add(#positions, #velocities)), 1);

Note that this expression describes not only an operation on each cell in the fields, but also operates on all three axes at the same time, with the calculation performed independently on x, y and z. For example, lrg may return a value such as (0, 1, 0), which means that a for a given cell in #velocities a particle should now flip its direction on the y axis.

Reversing velocity

now that we have a &hit vector that tells us when a particle is about to hit a wall on a given axis, we now have to use that information to flip the velocity on that particle for that axis.

sub(1, mlt(&hit, 2))
;

first we need to remap the boolean vector returned by lrg into a different range. Instead of 0 or 1 per axis, we want to each axis to be: +1 -> stay the same ; -1 -> flip direction. This can be done by simple arithmetic - multiply by 2 and then subtract the result from 1.

sub(1, mlt(&hit, 2));

Now we just need to apply that value to our velocity. This way, in each step, each axis of each cell in#velocities is multiplied by +1... unless when hitting a wall, in which case it is multiplied by -1.

run = mlt(#,  sub(1, mlt(&hit, 2)) );

This describes a complete boundary collision response — applied to every single particle on each one of its axes.

Extending the system

Color variation

Let's add another field:

#colors {
   dim = &dim;
   set = rand(7,8,9);
}

Just like before, #colors share the same dimension as the others. We can assign a random value to each one, this time using rand() to give a range between 0 and 1 on each axis, which better conforms to RGB color values.

Let's adjust how particles are visualized:

~particles {
   dim = &dim;
   i_pos = #positions;
   col = #colors;
   shape = ICO;
   i_scl = (0.5);
}

Note that we are not forced to give every particle its own unique color value. If #colors uses a different dim, particle instances still sample from the values available in that field according to Utomata's normal field-mapping rules. For example, setting dim = (8); gives us just eight color values that are distributed across the larger particle formation.

The same principle also applies to fields such as positions and velocities, although changing their dimensions independently would alter the one-to-one correspondence used by this simulation. In other systems, deliberately using fields at different resolutions can be useful, be it for sharing values, reducing detail, or experimenting with different relationships between data and instances.

Size variation

So far, every particle has been rendered at the same scale. We can vary particle size in exactly the same way as color or velocity: by storing one value per particle in another field.

#radiuses {
   dim = &dim;
   set = add(mlt(rand(10), 0.1), 0.02); // between 0.02 and 0.12
}

~particles {
   ...
   i_scl = #radiuses;
}

#radiuses assigns each particle a seeded random size between 0.02 and 0.12. Because it shares the same dim, each radius corresponds directly to one particle instance.

Changing particle size also affects when particles should be considered to have reached the boundary. Until now, our bounds test treated each particle as though its position alone determined the collision:

&hit = lrg(abs(add(#positions, #velocities)), 1);

For particles with visible size, we can move the collision threshold inward by part of the particle radius:

&hit = lrg(
   abs(add(#positions, #velocities)),
   sub(1, mlt(#radiuses, 0.5))
);

Larger particles will therefore reverse direction slightly earlier than smaller ones, helping keep their visible geometry inside the cubic volume rather than waiting for their center point to reach the boundary.

Where to go next

This guide only uses a small part of what can be expressed with the same structure. Additional fields can introduce forces, constraints, attraction or repulsion, orientation, or other evolving properties.

The velocity field does not need to remain constant or neutral either. It can itself be modified over time — pulled toward a point, perturbed by another field, influenced by neighboring particles, or coupled to entirely different simulations.

From here, the basic particle system can therefore be extended in many directions without changing its underlying organization: fields hold the evolving state, while formations provide one possible way of seeing it.