Beyond Random: Adding Character to Your Circle Packing Algorithms
Circle packing is a staple of generative art, but its "vibe" can change dramatically with just a few tweaks. Below, I’ll share some tips on how to move beyond pure randomness and give your packing patterns a distinct personality.
Same Algorithm, Different Vibes
Take a look at these two images. Both use the same underlying circle packing logic, yet they feel completely different. Why? Because the way we "seed" our circles matters just as much as how we pack them.
What is Circle Packing?
Circle packing is a classic example of a mathematical packing problem.
Packing problems : Wikipedia
In the world of creative coding, we usually favor "Place and Grow" over strict mathematical packing. The logic is beautifully simple:
- Place: Pick a random spot. If it's empty, drop a tiny circle.
- Grow: Slowly increase the radius of every circle.
- Stop: If a circle hits a neighbor or a boundary, it stops growing.
- Repeat: Do this thousands of times.
Daniel Shiffman has a fantastic breakdown of this on The Coding Train.
Interestingly, using only "Place" without the "Grow" step creates its own unique aesthetic.
And artists like Takawo Shunsuke(@takawo) and Jeff++(@ippsketch) have pushed this concept into "Non-Circular Packing" territories.
画面内の領域に重ならないようにグラフィックを配置していく.https://t.co/7Fz4pqjehH#p5js #creativecoding #dailycoding pic.twitter.com/MS95b0v8BP
— takawo shunsuke (@takawo) May 16, 2021
👉 The article of the original idea "Non Circular Packing" by Jeff++(@ippsketch).
Here is the example p5.js code of the circle packing with only 'Place' without 'Grow'.
const w = 640;
const h = 640;
const num = 2000; // trial number
function setup() {
createCanvas(w, h);
// circle packing
const circles = getRandomCircles(num, w * 0.4, h * 0.4);
// drawing
translate(w * 0.5, h * 0.5);
background(100);
circles.forEach((c) => circle(c.x, c.y, c.z));
}
function getRandomCircles(_num, _w, _h) {
let circles = [];
for (let i = 0; i < _num; i++) {
let x = random(-1, 1) * _w;
let y = random(-1, 1) * _h;
let z = random(10, 50); // z axis as radius of the circle
if (circles.every((c) => dist(x, y, c.x, c.y) > (z + c.z) * 0.5)) {
circles.push(createVector(x, y, z));
}
}
return circles;
}
'Only place without grow' method will create many open gaps if you set a small trial number.
You can consider these gaps as negative space as a design element.
The Power of Constraints: Packing on a Grid
What happens if we stop picking "completely" random coordinates? By snapping the center of each circle to a grid, we introduce a structured, architectural feel to the chaos.
// Completely random let x = random(-1, 1) * _w; let y = random(-1, 1) * _h;
// Snap to grid logic const gridDiv = 15; let x = floor(random(-gridDiv, gridDiv)) / gridDiv * _w; let y = floor(random(-gridDiv, gridDiv)) / gridDiv * _h;
Compare the organic scatter of a pure random distribution versus the rhythmic alignment of grid-based packing. Even a high-density grid creates a subtle "order" that the eye can sense.
You'll get a different impression if you change circle size or grid gap.
Creative Seeding: Diagonals, Circles, and Swirls
We can take this even further by placing circles along specific paths, such as diagonals, circumferences, or even Perlin noise flow fields.
let rnd = random(-1, 1); let x = rnd * _w; let y = rnd * _h;
let rnd = random(TWO_PI); let x = cos(rnd) * _w; let y = sin(rnd) * _h;
for (let i = 0; i < _num; i++) {
let rnd = 3 * TWO_PI * i / _num;
let x = cos(rnd) * _w * i / _num;
let y = sin(rnd) * _h * i / _num;
Flow Fields and Paths
This example demonstrates circle packing applied to a flow field (also known as a vector or noise field).
By calculating the noise field and the circle positions simultaneously, the packing follows the invisible "current" of the noise, creating a sense of organic movement.
It's a drawing of the flow field.
The combination looks nice also.
The p5.js Implementation
This code is released under the GPL license. To see other works based on my code is my pleasure. And my honor.
const w = 570;
const h = 570;
function setup() {
createCanvas(w, h);
translate(w * 0.5, h * 0.5);
background(100);
// draw flow field and circle pack
noFill();
stroke(250);
strokeWeight(3);
const circles = getNoiseField(100, w * 0.4, h * 0.4, min(w, h) * 0.15, 5);
// draw the circles
fill(250);
stroke(0);
strokeWeight(1);
circles.forEach((c) => {
circle(c.x, c.y, c.z);
if (c.z > 10) {
circle(c.x, c.y, c.z * 0.5);
}
});
}
function getNoiseField(_num, _w, _h, _iDiv, _step) {
const circles = [];
for (let iX = -_w; iX < _w; iX += _iDiv) {
for (let iY = -_h; iY < _h; iY += _iDiv) {
let x = iX;
let y = iY;
beginShape();
for (let i = 0; i < _num; i++) {
let nVal = noise(x * 0.001, y * 0.001) * 2;
x += _step * cos(nVal * TWO_PI);
y += _step * sin(nVal * TWO_PI);
vertex(x, y);
let z = random(_step, _step * 5);
if (circles.every((c) => dist(x, y, c.x, c.y) > (z + c.z) * 0.5)) {
circles.push(createVector(x, y, z));
}
}
endShape();
}
}
return circles;
}
/*
Copyright (C) 2021- deconbatch
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>
*/
In the getNoiseField function above, the code handles both the flow lines and the packing logic at once. It’s a bit of a "quick and dirty" hack—I’d love to see how you refactor this into something more elegant! 😌















