18.
Particle Behavior
Written by Caroline Begbie & Marius Horga
As you learned in the previous chapter, particles have been at the foundation of computer animation for years. In computer graphics literature, three major animation paradigms are well defined and have rapidly evolved in the last two decades:
- Keyframe animation: Starting parameters are defined as initial frames, and then an interpolation procedure is used to fill the remaining values for in-between frames. You’ll cover this topic in Chapter 23, “Animation”.
- Physically based animation: Starting values are defined as animation parameters, such as a particle’s initial position and velocity, but intermediate values are not specified externally. This topic was covered in Chapter 17, “Particle Systems”.
- Behavioral animation: Starting values are defined as animation parameters. In addition, a cognitive process model describes and influences the way intermediate values are later determined.
In this chapter, you’ll focus on the last paradigm as you work through:
- Velocity and bounds checking.
- Swarming behavior.
- Behavioral animation.
- Behavioral rules.
By the end of the chapter, you’ll build and control a swarm exhibiting basic behaviors you might see in nature.
Behavioral Animation
You can broadly split behavioral animation into two major categories:
- Cognitive behavior: This is the foundation of artificial life which differs from artificial intelligence in that AI objects do not exhibit behaviors or have their own preferences. It can range from a simple cause-and-effect based system to more complex systems, known as agents, that have a psychological profile influenced by the surrounding environment.
- Aggregate behavior: Think of this as the overall outcome of a group of agents. This behavior is based on the individual rules of each agent and can influence the behavior of neighbors.
In this chapter, you’ll keep your focus on aggregate behavior.
There’s a strict correlation between the various types of aggregate behavior entities and their characteristics. In the following table, notice how the presence of a physics system or intelligence varies between entity types.
- Particles are the largest aggregate entities and are mostly governed by the laws of physics, but they lack intelligence.
- Flocks are an entity that’s well-balanced between size, physics and intelligence.
- Crowds are smaller entities that are rarely driven by physics rules and are highly intelligent.
Working with crowd animation is both a challenging and rewarding experience. However, the purpose of this chapter is to describe and implement a flocking-like system, or to be more precise, a swarm of insects.
Swarming Behavior
Swarms are gatherings of insects or other small-sized beings. The swarming behavior of insects can be modeled in a similar fashion as the flocking behavior of birds, the herding behavior of animals or the shoaling behavior of fish.
You know from the previous chapter that particle systems are fuzzy objects whose dynamics are mostly governed by the laws of physics. There are no interactions between particles, and usually, they are unaware of their neighboring particles. In contrast, swarming behavior uses the concept of neighboring quite heavily.
The swarming behavior follows a set of basic movement rules developed in 1986 by Craig Reynolds in an artificial flocking simulation program known as Boids. Since this chapter is heavily based on his work, the term boid will be used throughout the chapter instead of particle.
Initially, this basic set only included three rules: cohesion, separation and alignment. Later, more rules were added to extend the set to include a new type of agent; one that has autonomous behavior and is characterized by the fact that it has more intelligence than the rest of the swarm. This led to defining new models such as follow-the-leader and predator-prey.
Time to transform all of this knowledge into a swarm of quality code.
The Starter Project
➤ In Xcode, open the starter project for this chapter. There are only a few files in this project.
- In the Flocking group, Emitter.swift creates a buffer containing the particles. But each particle has only a position attribute.
-
Renderercalls aFlockingPasson every frame and supplies the view’s current drawable texture as the GPU texture to update. -
FlockingPassfirst clears the texture using theclearScreencompute shader from the previous project. It then dispatches threads for the number of particles. The dispatch code inFlockingPass.draw(in:commandBuffer:)contains an example of both macOS code and iOS code where non-uniform threads are not supported. - In the Shaders group, Flocking.metal has two kernel functions. One clears the drawable texture, and the other writes a pixel, representing a boid, to the given texture.
➤ Build and run the project, and you’ll see this:
There’s a problem: a visibility issue. In its current state, the boids are barely distinguishable despite being white on a black background.
There’s a neat trick you can apply in cases like this when you don’t want to use a texture for boids (like you used in the previous chapter). In fact, scientific simulations and computational fluid dynamics projects very rarely use textures, if ever.
You can’t use the [[point_size]] attribute here because you’re not rendering in the traditional sense. Instead, you’re writing pixels in a kernel function directly to the drawable’s texture.
The trick is to “paint” the surrounding neighbors of each boid, which makes the current boid seem larger than it really is.
➤ In Flocking.metal, add this code at the end of the boids kernel function:
output.write(color, location + uint2(-1, 1));
output.write(color, location + uint2( 0, 1));
output.write(color, location + uint2( 1, 1));
output.write(color, location + uint2(-1, 0));
output.write(color, location + uint2( 1, 0));
output.write(color, location + uint2(-1, -1));
output.write(color, location + uint2( 0, -1));
output.write(color, location + uint2( 1, -1));
This code modifies the neighboring pixels around all sides of the boid which causes the boid to appear larger.
➤ Build and run the app, and you’ll see that the boids are more distinguishable now.
That’s a good start, but how do you get them to move around? For that, you need to look into velocity.
Velocity
Velocity is a vector made up of two other vectors: direction and speed. The speed is the magnitude or length of the vector, and the direction is given by the linear equation of the line on which the vector lies.
➤ In the Flocking group, open Emitter.swift, add a new member to the end of the Particle structure:
var velocity: float2
➤ In init(particleCount:size:), inside the particle loop, add this before the last line where you advance the pointer:
let velocity: float2 = [
Float.random(in: -5...5),
Float.random(in: -5...5)
]
pointer.pointee.velocity = velocity
This gives the particle (boid) a random direction and speed that ranges between -5 and 5.
➤ In the Shaders group, open Flocking.metal, and add velocity as a new member of the Boid structure:
float2 velocity;
➤ In boids, add this code after the line where you define position:
float2 velocity = boid.velocity;
position += velocity;
boid.position = position;
boid.velocity = velocity;
boids[id] = boid;
This code gets the current velocity, updates the current position with the velocity, and then updates the boid data before storing the new values.
Build and run the app, and you’ll see that the boids are now moving everywhere on the screen and… uh, wait! It looks like they’re disappearing from the screen too. What happened?
Although you set the velocity to random values, you still need a way to force the boids to stay on the screen. Essentially, you need a way to make the boids bounce back when they hit any of the edges.
For this function to work, you need to add checks for X and Y to make sure the boids stay in the rectangle defined by the origin and the size of the window, in other words, the width and height of your scene.
➤ In boids, add this code after float2 velocity = boid.velocity;:
if (position.x < 0 || position.x > output.get_width()) {
velocity.x *= -1;
}
if (position.y < 0 || position.y > output.get_height()) {
velocity.y *= -1;
}
Here, you check whether a boid coordinate gets outside the screen. If it does, you change the velocity sign, which changes the direction of the moving boid.
➤ Build and run the app, and you’ll see that the boids are now bouncing back when hitting an edge.
Currently, the boids only obey the laws of physics. They’ll travel to random locations with random velocities, and they’ll stay on the window screen because of a few strict physical rules you’re imposing on them.
The next stage is to make the boids behave as if they are able to think for themselves.
Behavioral Rules
There’s a basic set of steering rules that swarms and flocks can adhere to, and it includes:
- Cohesion
- Separation
- Alignment
- Escaping
- Dampening
You’ll learn about each of these rules as you implement them in your project.
Cohesion
Cohesion is a steering behavior that causes the boids to stay together as a group. To determine how cohesion works, you need to find the average position of the group, known as the center of gravity. Each neighboring boid will then apply a steering force in the direction of this center and converge near the center.
➤ In Flocking.metal, at the top of the file, add three global constants:
constant float average = 100;
constant float attenuation = 0.1;
constant float cohesionWeight = 2.0;
With these constants, you defined:
-
average: A value that represents a smaller group of the swarm that stays cohesive. -
attenuation: A toning down factor that lets you relax the cohesion rule. -
cohesionWeight: The contribution made to the final cumulative behavior.
➤ Create a new function for cohesion before boids:
float2 cohesion(
uint index,
device Boid* boids,
uint particleCount)
{
// 1
Boid thisBoid = boids[index];
float2 position = float2(0);
// 2
for (uint i = 0; i < particleCount; i++) {
Boid boid = boids[i];
if (i != index) {
position += boid.position;
}
}
// 3
position /= (particleCount - 1);
position = (position - thisBoid.position) / average;
return position;
}
Going through the code:
- Isolate the current boid at the given index from the rest of the group. Define and initialize
position. - Loop through all of the boids in the swarm, and accumulate each boid’s position to the
positionvariable. - Get an average position value for the entire swarm, and calculate another averaged position based on the current boid position and the fixed value
averagethat preserves average locality.
➤ In boids, add this code immediately before position += velocity:
float2 cohesionVector =
cohesion(id, boids, particleCount) * attenuation;
// velocity accumulation
velocity += cohesionVector * cohesionWeight;
Here, you determine the cohesion vector for the current boid and then attenuate its force. You’ll build upon the velocity accumulation line as you go ahead with new behavioral rules. For now, you give cohesion a weight of 2 and add it to the total velocity.
➤ Build and run the app. Notice how the boids are initially trying to get away — following their random directions. Moments later, they’re pulled back toward the center of the flock.
Separation
Separation is another steering behavior that allows a boid to stay a certain distance from nearby neighbors. This is accomplished by applying a repulsion force to the current boid when the set threshold for proximity is reached.
➤ Add two more global constants:
constant float limit = 20;
constant float separationWeight = 1.0;
Here’s what they’re for:
-
limit: A value that represents the proximity threshold that triggers the repulsion force. -
separationWeight: The contribution made by the separation rule to the final cumulative behavior.
➤ Then, add the new separation function before boids:
float2 separation(
uint index,
device Boid* boids,
uint particleCount)
{
// 1
Boid thisBoid = boids[index];
float2 position = float2(0);
// 2
for (uint i = 0; i < particleCount; i++) {
Boid boid = boids[i];
if (i != index) {
if (abs(distance(boid.position, thisBoid.position))
< limit) {
position =
position - (boid.position - thisBoid.position);
}
}
}
return position;
}
Going through the code:
- Isolate the current boid at the given index from the rest of the group. Define and initialize
position. - Loop through all of the boids in the swarm; if this is a boid other than the isolated one, check the distance between the current and isolated boids. If the distance is smaller than the proximity threshold, update the position to keep the isolated boid within a safe distance.
➤ In boids, before the // velocity accumulation comment, add this:
float2 separationVector = separation(id, boids, particleCount)
* attenuation;
➤ Then, update the velocity accumulation to include the separation contribution by adding this code immediately after the last:
velocity += cohesionVector * cohesionWeight
+ separationVector * separationWeight;
➤ Build and run the project. Notice that now there’s a counter-effect of pushing back from cohesion as a result of the separation contribution.
Alignment
Alignment is the last of the three steering behaviors Reynolds used for his flocking simulation. The main idea is to calculate an average of the velocities for a limited number of neighbors. The resulting average is often referred to as the desired velocity.
With alignment, a steering force gets applied to the current boid’s velocity to make it align with the group.
➤ To get this working, add two global constants:
constant float neighbors = 8;
constant float alignmentWeight = 3.0;
With these constants, you define:
-
neighbors: A value that represents the size of the local group that determines the “desired velocity”. -
alignmentWeight: The contribution made by the alignment rule to the final cumulative behavior.
➤ Then, add the new alignment function before boids:
float2 alignment(
uint index,
device Boid* boids,
uint particleCount)
{
// 1
Boid thisBoid = boids[index];
float2 velocity = float2(0);
// 2
for (uint i = 0; i < particleCount; i++) {
Boid boid = boids[i];
if (i != index) {
velocity += boid.velocity;
}
}
// 3
velocity /= (particleCount - 1);
velocity = (velocity - thisBoid.velocity) / neighbors;
return velocity;
}
Going through the code:
- Isolate the current boid at the given index from the rest of the group. Define and initialize
velocity. - Loop through all of the boids in the swarm, and accumulate each boid’s velocity to the
velocityvariable. - Get an average velocity value for the entire swarm, and then calculate another averaged velocity based on the current boid velocity and the size of the local group,
neighbors, which preserves locality.
➤ In boids, before the // velocity accumulation comment, add this code:
float2 alignmentVector = alignment(id, boids, particleCount)
* attenuation;
➤ Then, add this to update the velocity accumulation to include the alignment contribution:
velocity += cohesionVector * cohesionWeight
+ separationVector * separationWeight
+ alignmentVector * alignmentWeight;
➤ Build and run the app. The flock is homogeneous now because the alignment contribution brings balance to the previous two opposed contributions.
Escaping
Escaping is a new type of steering behavior that introduces an agent with autonomous behavior and slightly more intelligence — the predator.
In the predator-prey behavior, the predator tries to approach the closest prey on one side, while on the other side, the neighboring boids try to escape.
➤ Like before, add new global constants to indicate the weight of the escaping force and the speed of reaction to the predator:
constant float escapingWeight = 0.01;
constant float predatorWeight = 10.0;
➤ Then, add the new escaping function before boids:
float2 escaping(Boid predator, Boid boid) {
return -predatorWeight * (predator.position - boid.position)
/ average;
}
You return the averaged position of neighboring boids relative to the predator position. The final result is then adjusted and negated because the escaping direction is the opposite of where the predator is located.
➤ At the top of boids, replace:
Boid boid = boids[id];
➤ With the following code:
Boid predator = boids[0];
Boid boid;
if (id != 0) {
boid = boids[id];
}
Here, you isolate the first boid in the buffer and label it as the predator. For the rest of the boids, you create a new boid object.
➤ Toward the end of boids, after defining color, add this:
if (id == 0) {
color = half4(1.0, 0.0, 0.0, 1.0);
location = uint2(boids[0].position);
}
The predator will stand out by coloring it red. You also save its current position.
➤ Before the line where you define location, add this:
// 1
if (predator.position.x < 0
|| predator.position.x > output.get_width()) {
predator.velocity.x *= -1;
}
if (predator.position.y < 0
|| predator.position.y > output.get_height()) {
predator.velocity.y *= -1;
}
// 2
predator.position += predator.velocity / 2.0;
boids[0] = predator;
With this code, you:
- Check for collisions with the edges of the screen, and change the velocity when that happens.
- Update the predator position with the current velocity, attenuated to half value to slow it down. Finally, save the predator position and velocity to preserve them for later use.
➤ Before the // velocity accumulation comment, add this:
float2 escapingVector = escaping(predator, boid) * attenuation;
➤ Then, add this to update the velocity accumulation to include the escaping contribution:
velocity += cohesionVector * cohesionWeight
+ separationVector * separationWeight
+ alignmentVector * alignmentWeight
+ escapingVector * escapingWeight;
➤ Build and run the app. Notice that some of the boids are steering away from the group and avoiding the predator.
Dampening
Dampening is the last steering behavior you’ll looking at in this chapter. Its purpose is to dampen the effect of the escaping behavior, because at some point, the predator will stop its pursuit.
➤ Add one more global constant to represent the weight for the dampening:
constant float dampeningWeight = 1.0;
➤ Then, add the new dampening function before boids:
float2 dampening(Boid boid) {
// 1
float2 velocity = float2(0);
// 2
if (abs(boid.velocity.x) > limit) {
velocity.x += boid.velocity.x / abs(boid.velocity.x)
* attenuation;
}
if (abs(boid.velocity.y) > limit) {
velocity.y = boid.velocity.y / abs(boid.velocity.y)
* attenuation;
}
return velocity;
}
With this code, you:
- Define and initialize the
velocityvariable. - Check if the velocity gets larger than the separation threshold. If it does, attenuate the velocity in the same direction.
➤ In boids, before the // velocity accumulation comment, add this:
float2 dampeningVector = dampening(boid) * attenuation;
➤ Then, add this to update the velocity accumulation to include the dampening contribution:
velocity += cohesionVector * cohesionWeight
+ separationVector * separationWeight
+ alignmentVector * alignmentWeight
+ escapingVector * escapingWeight
+ dampeningVector * dampeningWeight;
➤ Build and run the app. Notice the boids are staying together with the group again after the predator breaks pursuit.
Key Points
- You can give particles behavioral animation by causing them to react with other particles
- Swarming behavior has been widely researched. The Boids simulation describes basic movement rules.
- The behavioral rules for boids include cohesion, separation and alignment.
- Adding a predator to the particle mass requires an escaping algorithm.
Where to Go From Here?
In this chapter, you learned how to construct basic behaviors and apply them to a small flock. Continue developing your project by adding a colorful background and textures for the boids. Or make it a 3D flocking app by adding projection to the scene. When you’re done, add the flock animation to your engine. Whatever you do, the sky is the limit.
This chapter barely scratched the surface of what is widely known as behavioral animation. Be sure to review the references.markdown file in the chapter directory for links to more resources about this wonderful topic.