Slimes on the GPU

Super Slime Simulation

This project is a simulation of slime based on pheromone trails and heavily inspired by this video by Sebastian Lague. Many thanks to him for his work.

TLDR: Slimes leave a trail of pheromone on the ground and generally move in the direction they sense the most pheromones.

I decided to implement this simulation like most of my work in python using moderngl which allows for extremly fast boilerplate-less prototyping, all while staying pythonic and easy to read. As with all my projects you can find the code on GitHub.

Basic Idea

The goal is to simulate a colony of slimes. For this we first need to define a slime:

Each slime has a position and an orientation. The orientation is just an angle in radians while the position is a 2D vector. Furthermore, each slime has 3 sensors which are used to sense the pheromone trail. They are evenly spaced infront of the slime at some distance and an angle α. The slime moves through space with a fixed speed and leaves a trail of pheromones on the ground. Over time these pheromones will diffuse and decay.

Each sensor has a range of r in which it can pick up the average pheromone count. The slimes like to moves in the direction they senses the most pheromones, but the ammount they turns is slightly randomized to allow for more natural behaviour.

Implementation

Since computers don’t play well with infinite continous world our slimes live on a grid with a fixed size. This grid format lends itself to be stored as a texture on the GPU.

Since we now are working with gird world we convert the smooth sensing radius into a nxn cell.

In our compute shader we can define the slimes like so, with one padding value, and update their positions:

struct Slime {
    float x, y, angle, padding;
};

layout(std430, binding=2) restrict buffer inslimes {
    Slime slimes[];
} SlimeBuffer;


void main() {
    int index = int(gl_GlobalInvocationID);

    Slime slime = SlimeBuffer.slimes[index];

    vec2 direction = vec2(cos(slime.angle), sin(slime.angle));
    vec2 newPos = vec2(slime.x, slime.y) + (direction * moveSpeed * dt);
}

We can also draw a pheromone trail like so:

void drawSlime(Slime slime) {
    imageStore(destTex, ivec2(slime.x, slime.y), vec4(1.));
}
Now we only have to diffuse the pheromones to avoid a positive feedbackloop filling the whole world with pheromones. To do this we can take advantage of the image-like nature of our world and simply blurr our world with a simple averaging kernel.
layout (local_size_x = 16, local_size_y = 16) in;

layout(r8, location=0) restrict readonly uniform image2D fromTex;
layout(r8, location=1) uniform image2D destTex;

float fetchValue(ivec2 co) {
    return imageLoad(fromTex, co).r;
}

float blured(ivec2 co) {
    float sum = 
        fetchValue(co) +
        fetchValue(co + ivec2(-1, -1)) +
        fetchValue(co + ivec2( 0, -1)) +
        fetchValue(co + ivec2( 1, -1)) +
        fetchValue(co + ivec2( 1,  0)) +
        fetchValue(co + ivec2( 1,  1)) +
        fetchValue(co + ivec2( 0,  1)) +
        fetchValue(co + ivec2(-1,  1)) +
        fetchValue(co + ivec2(-1,  0));
    
    return sum / 9.;
}

uniform float diffuseSpeed;
uniform float evaporateSpeed;

#define dt 0.0166

void main() {
    ivec2 texelPos = ivec2(gl_GlobalInvocationID.xy);
    float original_value = imageLoad(fromTex, texelPos).r;
    float v = blured(texelPos);
    
    float diffused = mix(original_value, v, diffuseSpeed * dt);
    float evaporated = max(0, diffused - evaporateSpeed * dt);

    imageStore(destTex, texelPos, vec4(evaporated));
}

Finally we can implement the pheromone “following”

float weightForward = sense(slime, 0);
float weightLeft = sense(slime, senorAngleSpacing);
float weightRight = sense(slime, -senorAngleSpacing);

float randomSteerStrength = rand(vec2(slime.x,slime.y)*dt*slime.angle);


if (weightForward > weightLeft && weightForward > weightRight) {}
else if (weightForward < weightLeft && weightForward < weightRight) {
    slime.angle += (randomSteerStrength - 0.5) * 2.0 * turnSpeed * dt;
} else if (weightRight > weightLeft) {
    slime.angle -= randomSteerStrength * turnSpeed * dt;
} else if (weightLeft > weightRight) {
    slime.angle += randomSteerStrength * turnSpeed * dt;
}

Conclusion

With all the logic implemented all that is left is to invoke the compute shader and play with our new slimes!

group_size = int(math.ceil(SlimeConfig.N / self.local_size))
self.compute_shader.run(group_size, 1, 1)

Thanks to the great performance and high scaleablity of compute shaders we can simuate millions of slimes in realtime. (15 million slimes at 60fps on a gtx 1070) Feel free to try it out yourself and play around with the settings. The code on github includes a simple user interface to control the simulation parameters as well as recording functionality.