Introduction
After watching Sebastian Lague's most recent coding adventure, I was inspired to try my hand at the slime simulations in the 2nd half of the video. This page was another great resource and source of inspiration. I've messed around with generative art before (maybe a future post..) and I think it's one of the coolest things to work on.
The Basics
The previous links explain it pretty well, but just to sum it up:
- a collection of "slime" particles move around and leave a trail everywhere they go
- other particles are attracted to this trail
- the slime trail spreads out and dissipates over time
- the results are quite stunning for such a simple algorithm
The Code
I spent the last few hours writing the code and playing around with settings. It's still pretty basic and probably needs a lot of work, but that's what most of my projects look like. 😉
I tried to leave useful comments (denoted by #$%
) and a few options to play around with. It doesn't run very fast because it's just one thread on the CPU, so maybe I'll adapt it with Numba's CUDA functionality later. Anyway, here it is:
import mathimport randomimport numpy as npfrom numba import jit, stencilfrom PIL import Imageseed = 0 #$% random seedrandom.seed(seed)step_size = 2 #$% forward motion per timestepsensor_dist = 10 #$% sensor distance from particlesensor_angle = math.pi / 8 #$% right and left sensor anglerotation_angle = math.pi / 8 #$% particle turn angledeposit_amount = 32 #$% additional intensity per particle per pixel per timestepdecay_factor = 0.75 #$% decay multiplierstep_fudge = 0.05 #$% randomize particle stepangle_fudge = math.pi / 32 #$% randomize particle anglewidth = 400 #$% image widthheight = 400 #$% image heightparticle_count = 2 * width * heightdef init_data(): data = np.zeros((particle_count, 3), dtype=np.float32) for i in range(particle_count): #$% uniform distribution data[i] = [ random.uniform(0, width-0), #$% x random.uniform(0, height-0), #$% y random.uniform(0, 2*math.pi) #$% angle ] #$% optional adjustable distribution # data[i] = [ # random.uniform(4 * width / 10, 6 * width / 10), # random.uniform(4 * height / 10, 6 * height / 10), # random.uniform(0, 2*math.pi) # ] return data@jitdef sense(d, trail): x, y, phi = d #$% middle sensor dx1 = sensor_dist * math.cos(phi) dy1 = sensor_dist * math.sin(phi) x1 = round((x + dx1) % width) y1 = round((y + dy1) % height) s1 = trail[y1, x1] #$% left sensor dx2 = sensor_dist * math.cos(phi - sensor_angle) dy2 = sensor_dist * math.sin(phi - sensor_angle) x2 = round((x + dx2) % width) y2 = round((y + dy2) % height) s2 = trail[y2, x2] #$% right sensor dx3 = sensor_dist * math.cos(phi + sensor_angle) dy3 = sensor_dist * math.sin(phi + sensor_angle) x3 = round((x + dx3) % width) y3 = round((y + dy3) % height) s3 = trail[y3, x3] return (s1, s2, s3)@jitdef move(data, trail): for d in data: s1, s2, s3 = sense(d, trail) if (s1 > s2) and (s1 > s3): #$% keep going straight pass elif (s2 > s1) and (s3 > s1): #$% random turn d[2] += random.uniform(-rotation_angle, rotation_angle) elif (s2 > s3) and (s2 > s1): #$% left turn d[2] -= rotation_angle elif (s3 > s2) and (s3 > s1): #$% right turn d[2] += rotation_angle #$% step forward & randomize step d[0] += step_size * math.cos(d[2]) + random.uniform(0, step_fudge) d[1] += step_size * math.sin(d[2]) + random.uniform(0, step_fudge) d[2] += random.uniform(-angle_fudge, angle_fudge) #$% randomize angle #$% wrap around d[0] %= width d[1] %= height@jitdef deposit(data, trail): for d in data: x = min(max(round(d[0]), 0), width - 1) y = min(max(round(d[1]), 0), height - 1) #$% increase trail intensity up to max 255 after decay trail[y, x] = min(trail[y, x] + deposit_amount, int(255 / decay_factor))@stencildef kernel(a): #$% 3x3 average return (1 / 9) * ( a[0, 0] + a[-1, 0] + a[0, -1] + a[1, 0] + a[0, 1] + a[-1, -1] + a[-1, 1] + a[1, -1] + a[1, 1] )@jitdef diffuse(a): #$% apply stencil to the whole array return kernel(a).astype(np.int16)@jitdef decay(a): #$% multiplicative decay return (a * decay_factor).astype(np.int16)def do_image(trail): #$% create image from np array img = Image.fromarray(trail.astype(np.uint8)) palette = [] for i in range(256): palette.append(i) #$% red palette.append(i) #$% green palette.append(i) #$% blue # palette.reverse() #$% optional inversion img.putpalette(palette) return imgdef make_gif(images, name): #$% make and save animated gif images[0].save(f'gifs/{name}.gif', save_all=True, append_images=images[1:], optimize=False, duration=40, loop=0)def main(): images = [] data = init_data() trail = np.zeros((height, width), dtype=np.int16) for i in range(500): move(data, trail) deposit(data, trail) trail = diffuse(trail) trail = decay(trail) images.append(do_image(trail)) name = f'{seed}_{step_size}_{sensor_dist}_{sensor_angle:.2f}_{rotation_angle:.2f}_{deposit_amount}_{decay_factor}_{width}_{height}_{particle_count}.png' make_gif(images, name) images[-1].save(f'pics/{name}') #$% save last imageif __name__ == '__main__': main()
The Results
Here are a few of the resulting images. There are infinite variations, and I certainly haven't found the best settings yet.
The real magic is in the animations, which are unfortunately hard to show in a post like this. Here's a small sample though. It's so creepily organic. I'll try to render a much larger video and post it in a comment when I can.
Show me what you got
I'd love to see other people play around with the settings, as well as more of this kind of procedural art. Enjoy!