This week I finally released my “Circle Of Life” project on Alba. The feedback has been unbelievable so far !
I have received a lot of questions about the code, in this post I will try to answer the most frequent ones.
Inspiration
The project got largely inspired by this blog post by Gorilla Sun. “A Recursive Circle Packing Algorithm for Organic Growth Patterns”. Even though I didn’t use any of Gorilla’s code, the beautiful examples made me think, “what if I made a system of circular cells, growing organically using circle collision” ? That’s how “Circle Of Life” was born.
The project is really about starting with a circular cell, and applying a set of rules, driven by probabilities, to let this cell grow, die, or spawn new cells. As the project evolved, more complex rules were added, cells were rendered in fancier ways instead of using plain circles, but the core principle stayed the same: grow circular shapes until they don’t have any room left to grow.
Growing circles
Growing a circle is usually just a matter of increasing its radius. However, in our case we want the cell to grow from whatever location it was spawned from – so when the circle grows, its radius increases and its center moves in the opposite direction of the spawn point. The formula is:center = spawnPoint + k * (center-spawnPoint)
where k is the ratio between the old radius and the new radius.
Even though the project ended up using vanilla js and WebGL, it is often useful, in the early stages, to prototype using p5.js.
The code below is a p5.js sketch that forms the core, non-optimized algorithm of “Circle Of Life”.
const W = 800;
const H = 800;
const START_RADIUS = 3.0; // start radius
const DIVIDE_RADIUS = START_RADIUS * 3; // start dividing after that radius
const GROWTH_DELTA = 1; // increase radius by that amount at each step
const SPAWN_PROBABILITY = 0.05; // probability to spawn a child circle
var circles = [];
// circles intersect if the distance between the centers is less than the sum of radiuses
var circlesIntersect = (c1, c2) =>
{
var dx = c1.x - c2.x;
var dy = c1.y - c2.y;
var d2 = dx * dx + dy * dy;
var sr = c1.r + c2.r;
return d2 < sr * sr;
};
// check if circle c intersects another circle, ignoring the one at index 'except'
var intersectAny = (c, except) =>
{
for (var i = 0; i < circles.length; ++i)
{
if (i != except && circlesIntersect(circles[i], c)) return true;
}
return false;
};
// create a new circle of radius r, tangent to circle c0 at angle alpha
var newCircle = (c0, alpha, r) =>
{
var c =
{
x: c0.x + (r + c0.r) * Math.cos(alpha),
y: c0.y - (r + c0.r) * Math.sin(alpha),
r: r,
canGrow: true
};
c.ox = c0.x + c0.r * Math.cos(alpha);
c.oy = c0.y - c0.r * Math.sin(alpha);
return c;
};
function setup()
{
createCanvas(W, H);
circles.push(newCircle({ x: W / 2, y: H / 2, r: 0 }, Math.random() * TAU, START_RADIUS));
}
var drawCircles = () =>
{
fill(255);
stroke(0);
strokeWeight(1);
for (var i = 0; i < circles.length; ++i)
{
var c = circles[i];
circle(c.x, c.y, 2 * c.r);
}
};
var inFrame = (c) =>
{
return c.x >= 0 && c.x < W && c.y >= 0 && c.y < H;
};
function draw()
{
background(0xff);
drawCircles();
var done = true;
for (var i = 0; i < circles.length; ++i)
{
var c = circles[i];
// attempt to grow this circle
if (c.canGrow)
{
var oldx = c.x, oldy = c.y, oldr = c.r; // save these in case growing the circle causes a collision
var k = (c.r + GROWTH_DELTA) / c.r; // growth factor
c.x = c.ox + k * (c.x - c.ox);
c.y = c.oy + k * (c.y - c.oy);
c.r *= k;
// if the new circle intersects another circle, revert
if (intersectAny(c, i))
{
c.canGrow = false;
c.x = oldx; c.y = oldy; c.r = oldr;
}
else
{
// if the new circle is in frame, we're not done
// otherwise it can't grow any more
if (inFrame(c)) done = false;
else c.canGrow = false;
}
}
// if this circle is large enough, it has a probability to spawn a child
if (c.r >= DIVIDE_RADIUS && Math.random() < SPAWN_PROBABILITY)
{
// If after 100 attempts, we couldn't spawn a child that doesn't collide
// with other circles, give up.
for (var n = 0; n < 100; ++n)
{
// note the 1.02 factor. Without it, rounding errors cause the 2 circles to be detected as intersecting too early.
var child = newCircle(c, Math.random() * TAU, START_RADIUS * 1.02);
if (!intersectAny(child, i))
{
// this child doesn't collide with other circles. Add it to the list
child.r = START_RADIUS;
circles.push(child);
c.canGrow = false; // parent circle can't grow any more
done = false;
break;
}
}
}
}
if (done)
{
console.log("done");
noLoop();
}
}
Life cycle of a cell
In the example above, cells grow and spawn until they run out of space. For this project I really wanted to focus on death as a necessary part of life, so the life cycle of cells was based on actual living organisms:
- Cells grow until they run out of space, or reach a predefined ‘old age” size.
- Once cells reach a predefined “maturity” size, they have a probability of spawning new cells.
- Once a cell stops growing, it has a predefined lifespan. Once this lifespan is exceeded, the cell starts dying.
- Dying cells shrink at a predefined rate, until they reach a 0 radius, at which point they are removed from the list.
With nothing more than the above parameters, very interesting results were achieved. Depending on old age / maturity / lifespan, the system would colonize the whole canvas and quickly die, or slowly evolve and move through the available space in mesmerizing wave-like patterns.
Adding variety
In order to make things visually more interesting, I defined several types of cells:
- “Default” cells behave as described above
- Sometimes, instead of spawning a child cell, a cell creates “Spore” cells. These are tiny cells that move away quickly from the parent in a explosion-style fashion. Friction forces are applied so that after reaching a certain distances, these spore cells turn into Default cells, and start growing as well.
- Another feature is called “Side-shoots” cells. When a parent spawns a side shoot, this new cell doesn’t grow in size, but in turn, spawns another side shoot. This goes on until a given length has been reached, and the last side shoot turns into a default cell.
Parents can spawn any number of side shoots, from 1 to 64. Large number of side shoots create flower-like patterns.
To complicate things further, side shoots also have a probability to split into 2 side shoots, creating branches.
Side shoots are not spawn at random locations, but instead, follow a noise field.
Each piece has its own unique probability for spores, side shoots, branching, number of side shoots per parent, …
Optimizing collisions
As the piece was taking shape it quickly became obvious that the naive O(n^2) collision detection system in the code above, was a big bottleneck and had to be optimized.
I first made a version using quadtree-js. It worked great, but had 2 drawbacks: first, it added almost 2Kb to the code (an important thing to consider when releasing on ETH). And second, it was actually too general. Quadtrees are great for managing arbitrary sized rectangles with random distributions. But in the case of “Circle Of Life”, the shapes are all circles, and are always distributed evenly (since they aren’t allowed to overlap). So a simpler structure can be used !
After trying out various options, I settled for the simplest one: subgrid partitioning. The main grid is divided in 100×100 smaller grids, and each grid stores a reference to the circles that intersect with it. Then, testing if a circle overlaps with other circles is limited to all the grids containing the considered circle. This allowed me to achieve close to 60fps, even with very busy grids.
Rendering with WebGL
At that point the drawing itself was starting to be the bottleneck. WebGL to the rescue !
The rendering is very much inspired by this example: the GL_POINTS primitive is used to pass all the circles in one single call to the GPU. Each point has its own gl_PointSize, allowing to draw many different sized cells at once. Then a fragment shader renders all these points, usually by overlapping various circles drawn using signed distance functions. In the case of the “Amoeba” style, the point is rendered using the classical “metaball” effect, which is achieved by combining the SDF’s of various moving circles.
Generative music
As the visuals were very relaxing and meditative, I decided to add a generative ambient soundtrack to the piece. I used Tone.js (mostly because it was available on ETHFS).
As you may know, I’m not new to ambient music, having released a couple albums on Bandcamp and Spotify. In that case, it was all about working with limitations. Size was the main constraint, ruling out the use of samples, so all the sounds had to be dynamically generated. I built a mixer system on top of Tone.js so I could use familiar concepts such as buses and effect loops.
For ambient music, 2 things are critical: there has to be very little tension in the music, and at the same time, there has to be constant motion. I tried to achieve this in different ways. In terms of composition, the system picks a key, then either a major or minor triad from that key. These 3 notes get sent to 3 harmony channels (so I don’t need to deal with polyphonic synths, it’s 3 mono synths) where their pan and levels get automated with various lfos. I use a spread voicing so the triad covers 2 octaves and doesn’t clutter a narrow range.
This already gives a lot of animation, but hearing the same 3 notes for 5 mn gets very boring. So the next trick is to add a bass note underneath, and change that bass note while the triad stays the same. Changing the bass note creates minor 7 or major 7 chords, which are perfect for ambient since they hold no tension by themselves.
Finally I routed all the channels except the bass one to to buses for reverb, delay, pitch shifters, using circular patching to create complex feedback loops (like it is done with modular synths).
Lastly, I generated events from the visuals when spawning side shoots or spore cells. These trigger extra melody notes picked randomly between the 3rd, 7th or 9th of the current chord.
Closing words
It took several months of fine tuning things to get it to that final release, but it was definitely worth it ! The initial concept had most of the code already, but adding details was the fun part (and the tedious one as well).
I was overwhelmed by the reception of this project, and I want to thank all the collectors for their support.
Please feel free to ask on Twitter (X) if you want more details about any specific part of the project. If you’re an artist, keep doing what you’re doing, and enjoy the process !