đź”­ Breaking Down lunarean's Heat Death

Let's take a look at this masterpiece and try to figure out how it was done

Hello everyone!

Today I’m trying something different. I’ve been saying we should pay more attention to the coding side of gen art, and I found the perfect collection to take the first step.

Lunarean released Heat Death as part of fxhash interactive minting experience at The Ever-Evolving World of Art, Art Basel Hong-Kong, 2022. The code was left unmodified, which means it is open to the public.

Heat Death by lunearean

Heat Death by lunearean

Access the code here → https://gateway.fxhash2.xyz/ipfs/QmbXCxAvSDdhcyHEEhrdcD6cpduuQqNZHpntkD55e7hf2i/heatdeath.js

Interesting facts:

  • There are less than 180 lines of code. This was accomplished by simplifying multiple operations in one line as much as possible. E.g., using ? operator.

  • Five main functions:

    • setup: takes care of the background, canvas size, and coloring options.

    • Draw: uses the custom functions to draw the planets in a loop.

    • Calculate circles: creates the planets (circles), sizes, and positioning.

    • Draw points (take circles as input): forms the planets and the different effects using points.

    • Calculate theta: this controls the lightning directions.

  • 880 outputs.

  • Five features: Palette, Mode, Margin, Full House, Tight.

    • Palettes: Dust, Plum, Picnic, Home, Calm.

    • Mode: Journey, Compass, Nest. This controls the lightning effect.

    • Margin: true/false,

    • Full House: true/false. Full house means it will continue generating planets until they don’t fit anymore.

    • Tight: true/false. No space between planets, so they are touching each other when true.

I broke the code into small sections and tried to explain what each part does. I asked lunarean some questions to clear some doubts about his logic and decisions.

p5.js documentation was linked for most functions to make it easier to follow.

Palette Selection

const PALETTES = [

{ name: "Dust", colors: ["#555555"] },

{ name: "Plum", colors: ["#513B56"] },

{ name: "Picnic", colors: ["#FAFAFA", "#0078BF", "#FF665E", "#FFCB47"] },

{ name: "Home", colors: ["#755B7B", "#77A0A9", "#F2545B", "#555555"] },

{ name: "Home", colors: ["#755B7B", "#77A0A9", "#F2545B", "#555555"] },

{ name: "Calm", colors: ["#447DC2", "#3B668C", "#E08D79", "#204344"] },

{ name: "Calm", colors: ["#447DC2", "#3B668C", "#E08D79", "#204344"] },

];

const palette = PALETTES[Math.floor(fxrand() * PALETTES.length)];

  • The Home and Calm palettes were included twice, so they have twice the chance of showing up.

Heat Death #377

Heat Death #377 - Picnic palette, Journey mode, Full House, tight and no margins.

Random seed initialization

const seed = Math.floor(fxrand() * 1e9);

randomSeed(seed);

noiseSeed(seed);

  • This is a clean way of initializing randomSeed, noiseSeed, and fxrand. You can use the p5.js random function and maintain deterministic outputs this way.

  • 1e9 is just a simple way of using a big integer for the operation.

General Setup

const size = min(windowWidth, windowHeight);

createCanvas(size, size);

colorMode(HSB, 360, 100, 100, 1);

pixelDensity(2);

background(random(6, 10));

blendMode(SCREEN);

loop();

noStroke();

  • Using unique color interpretations mechanism - pixelDensity set to 2, blindMode(SCREEN), and colorMode set to HSB.

Parameters for the custom functions

// params for calcCircles()

numAttempts = isFullHouse ? 10000 : 25 * exp(sqrt(random()) * log(5));

minR = 0.03 * exp(log(3) * sqrt(random()));

maxR = minR * exp(log(3) * sqrt(random()));

sdBounds = 0.2 * exp(random(log(2)));

circleSpacing = isTight ? 1 : exp(sq(random()) * log(2));

overlapProb = 0.05 * sqrt(random());

margin = isMargin ? random(0.02, 0.12) : -1;

// params for drawPoint()

vignette = random(0.1, 0.5);

thetaSd = lerp(0.6, 0.9, sq(random())) + (random() < 0.05 ? 0.3 : 0);

meanBeams = sqrt(random());

scatterProb = 0.1 * sqrt(random());

umbraProb = 0.25 * sqrt(sqrt(random()));

saturationMod = random(0.5, 0.8);

brightnessMod = random(0.5, 0.8);

// params for calcTheta()

journeyNoise = random() < 0.8 ? 3 : 0;

journeyTheta = (int(random(12)) * TWO_PI) / 12;

compassSides = random() < 0.8 ? 4 : 6;

circles = calcCircles();

Heat Death #123

Heat Death #123 - Plum palette, Nest mode, tight and no margins

calcCircles Function

function calcCircles() {

const circles = [];

for (let i = 0; i < numAttempts; i++) {

// try to place some near center

const sd = map(i, 0, 100, 0.1, 0.25);

const x = i < 100 ? 0.5 + sd * randomGaussian() : random();

const y = i < 100 ? 0.5 + sd * randomGaussian() : random();

// perturb bounds so we don't end up with many circles the same size

let r = maxR * exp(sdBounds * randomGaussian());

// no overlap with center or edge

r = min(r, dist(x, y, 0.5, 0.5), x - margin, 1 - margin - x, y - margin, 1 - margin - y);

circles.map((c) => {

const skip = r > 0.1 && random() < overlapProb;

r = skip ? r / 2 : min(r, dist(x, y, c.x, c.y) - c.r * circleSpacing);

});

// only keep circle if it's big enough

if (r > minR * exp(sdBounds * randomGaussian())) {

const theta = calcTheta(x, y);

const sc = color(random(palette.colors));

const pointsLeft = ceil(25000000 * sq(r));

circles.push({ x, y, r, theta, sc, pointsLeft });

}

}

return shuffle(circles).sort((a, b) => brightness(a.sc) - brightness(b.sc));

}

  • This method calculates each circle (planet) and pushes them to an array. For each circle, it also calculates a theta variable (see calcTheta below).

  • To calculate the circle positions, lunarean used circle packing with his own variations.

  • By reading the comments, we can see how the algorithm tries to position planets in the center and avoids overlaps and small planets.

  • pointsLeft stores how many points should be drawn for that planet and is proportional to the area.

drawPoint Function

function drawPoint({ x, y, r, theta, sc }) {

const numBeams = round(meanBeams * exp(randomGaussian()));

// choose random chord

let theta1 = theta + thetaSd * randomGaussian();

let theta2 = theta + thetaSd * randomGaussian();

// specks and light beams

if (random() < 0.05 * numBeams) {

theta1 = theta2 = 1000 * noise(x, y, int(random(numBeams)));

// interior beam

if (random() < 0.1) {

theta1 *= 2;

}

}

// choose random point on the random chord

const w = random();

const isScatter = random() < scatterProb;

const scatterR = r * (isScatter ? 1 + 0.2 * exp(randomGaussian()) : 1);

let px = x + scatterR * lerp(cos(theta1), cos(theta2), w);

let py = y + scatterR * lerp(sin(theta1), sin(theta2), w);

// smoke warp. add x and y to avoid lining up between circles

let sa = lerp(noise(4 * px + 1000 * x, 4 * py + 1000 * y, 1), noise(40 * px, 40 * py, 2), 0.1);

sa = 3 * max(sa - 0.53, 0);

const ss = 150 * exp(noise(30 * px, 30 * py, 3) - 0.5);

const [px_, py_] = [px, py];

px += sa * (noise(ss * px_, ss * py_, 4) - 0.5);

py += sa * (noise(ss * px_, ss * py_, 5) - 0.5);

// jump in direction away from light source

if (random() < umbraProb) {

const umbraR = random(random(0.15));

const umbraTheta = calcTheta(px, py) + PI;

px += umbraR * cos(umbraTheta);

py += umbraR * sin(umbraTheta);

}

px += 0.0001 * randomGaussian();

py += 0.0001 * randomGaussian();

// build color

const edgeDist = min(px, 1 - px, py, 1 - py);

const vignetteMod = map(constrain(edgeDist, 0, 0.2), 0, 0.2, vignette, 1);

const s = saturationMod * saturation(sc);

let b = brightnessMod * vignetteMod * min(30, brightness(sc));

// blemishes

b *= 1 + 0.8 * (noise(30 * px, 30 * py, 3) - 0.5);

fill(color(hue(sc), s, b));

ellipse(px * width, py * height, 2 * 0.00025 * width, 2 * 0.00025 * height);

}

  • drawPoint takes a planet as input and “paints” an ellipse.

  • The smoky texture is based on domain warping.

“The shading on the planets is something I came up with randomly - and is actually intentionally inaccurate - like not how a sphere would be lit in real life — lunarean.”

calcTheta Function

// direction of light source

function calcTheta(x, y) {

if (isJourney) {

return journeyTheta + journeyNoise * noise(x, y);

} else if (isCompass) {

return (int(1000 * noise(1000 * x, 1000 * y)) * TWO_PI) / compassSides;

}

return atan2(0.5 - y, 0.5 - x);

}

  • Theta considers the mode (Journey, Compass, or Nest to produce a specific type of lightning source. This function is called for each circle.

Heat Death #831

Heat Death #831 - Picnic palette, Compass mode, tight with margins

Draw Function

function draw() {

for (let i = 0; i < 12000 && circles.length > 0; i++) {

drawPoint(circles[0]);

if (--circles[0].pointsLeft <= 0) {

circles.shift();

}

}

if (circles.length === 0) {

noLoop();

fxpreview();

}

}

  • The draw function is straightforward as it loops through the circle’s array calling drawPoint for each element. After each circle is processed, it removes the element from the array.

  • Instead of drawing each planet (or iterating in the planet’s array alone), the algorithm draws 12000 points at once, which could be part of one planet or spread across multiple. This way, it is much smoother than drawing each planet individually.

  • Once there aren’t elements in the array, the noLoop() method is called, thus stopping the drawing loop and executing the fxpreview() to produce the output cover.

Kaloh’s Newsletter is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.

I hope this was as useful to read as it was for me to write and learn. I believe there are some fundamental techniques we can all learn from lunarean’s setup. This is also a great way to understand how complex this collection (and many gen art projects) can be. In this case, lunarean did a terrific job keeping the code simple and well structured while using best practices to accomplish optimal performance on many fronts.

Thanks to lunarean for the fantastic project, for sharing his knowledge, and for making this open source!

Until next time,

- Kaloh

Loading...
highlight
Collect this post to permanently own it.
Kaloh's Newsletter logo
Subscribe to Kaloh's Newsletter and never miss a post.