DEV Community

Sk
Sk

Posted on • Originally published at open.substack.com

Evolutionary Algorithms, Rendered Live in Node.js

Reinforcement learning, evolutionary algorithms, and anything that lets computers see are some of the most visually satisfying problems to work on. Watching something learn in real time never gets old.

This article is really just an excuse to show off a tiny graphics renderer I built for Node.js: tessera.js.

And I honestly can’t think of a better demo than the hello world of evolutionary algorithms:
a bunch of random colors mutating and slowly evolving toward a target color.

evo algorithm

If you’re new to graphics programming, don’t worry, the unit of everything visual in the digital world is surprisingly simple. I cover that here:
Graphics 101: A Node.js Renderer


Finding the Target Color

Install tessera:

npm i tessera.js@latest
Enter fullscreen mode Exit fullscreen mode

Start the renderer:

import { loadRenderer, PixelBuffer, ShapeDrawer } from "tessera.js";

const { Renderer } = loadRenderer();
const renderer = new Renderer();

if (!renderer.initialize(1200, 800, "Evolution Algos Hello World")) {
  console.error("failed to init renderer");
  process.exit(1);
}

renderer.targetFPS = 60;
const canvas = new PixelBuffer(renderer, 1200, 800);
Enter fullscreen mode Exit fullscreen mode

Parameters and the Genome (the “species”)

// Target color we're evolving toward
const TARGET = { r: 120, g: 80, b: 150 };

/**
 * Genome = full DNA
 * Genotype = specific gene values
 * Phenotype = what we actually see
 *
 * Here: genome is just RGB.
 */
class Genome {
  constructor(r = null, g = null, b = null) {
    this.r = r ?? Math.floor(Math.random() * 256);
    this.g = g ?? Math.floor(Math.random() * 256);
    this.b = b ?? Math.floor(Math.random() * 256);
    this.fitness = 0;
  }
}

const MUTATION_RATE = 0.3;   // more chaos
const ELITE_COUNT = 5;      // more stability
const POP_SIZE = 150;       // slower but more thorough

let population = Array.from({ length: POP_SIZE }, () => new Genome());
let generation = 0;
let bestEver = null;
Enter fullscreen mode Exit fullscreen mode

The Core Evolution Algorithm

// Fitness: lower distance = better
function evaluateFitness(genome) {
  const dr = genome.r - TARGET.r;
  const dg = genome.g - TARGET.g;
  const db = genome.b - TARGET.b;
  genome.fitness = Math.sqrt(dr * dr + dg * dg + db * db);
}

// Tournament selection
function selectParent(pop) {
  const tournamentSize = 3;
  let best = pop[Math.floor(Math.random() * pop.length)];
  for (let i = 1; i < tournamentSize; i++) {
    const candidate = pop[Math.floor(Math.random() * pop.length)];
    if (candidate.fitness < best.fitness) best = candidate;
  }
  return best;
}

// Crossover: mix genes
function crossover(p1, p2) {
  const child = new Genome();
  child.r = Math.random() < 0.5 ? p1.r : p2.r;
  child.g = Math.random() < 0.5 ? p1.g : p2.g;
  child.b = Math.random() < 0.5 ? p1.b : p2.b;
  return child;
}

function randomGaussian() {
  return Math.sqrt(-2 * Math.log(Math.random())) *
         Math.cos(2 * Math.PI * Math.random());
}

// Mutation
function mutate(genome) {
  if (Math.random() < MUTATION_RATE)
    genome.r = Math.max(0, Math.min(255, genome.r + randomGaussian() * 60));
  if (Math.random() < MUTATION_RATE)
    genome.g = Math.max(0, Math.min(255, genome.g + randomGaussian() * 60));
  if (Math.random() < MUTATION_RATE)
    genome.b = Math.max(0, Math.min(255, genome.b + randomGaussian() * 60));
}
Enter fullscreen mode Exit fullscreen mode

Tie Everything Together

function evolve() {
  population.forEach(evaluateFitness);
  population.sort((a, b) => a.fitness - b.fitness);

  if (!bestEver || population[0].fitness < bestEver.fitness) {
    bestEver = { ...population[0] };
  }

  const newPop = [];

  // Elitism
  for (let i = 0; i < ELITE_COUNT; i++) {
    newPop.push(new Genome(
      population[i].r,
      population[i].g,
      population[i].b
    ));
  }

  while (newPop.length < POP_SIZE) {
    const p1 = selectParent(population);
    const p2 = selectParent(population);
    const child = crossover(p1, p2);
    mutate(child);
    newPop.push(child);
  }

  population = newPop;
  generation++;
}
Enter fullscreen mode Exit fullscreen mode

Render Everything

function render() {
  canvas.clear(20, 20, 25, 255);

  // Target
  ShapeDrawer.fillRect(canvas, 50, 50, 150, 150, TARGET.r, TARGET.g, TARGET.b, 255);

  // Best current
  const best = population[0];
  ShapeDrawer.fillRect(canvas, 250, 50, 150, 150, best.r, best.g, best.b, 255);

  // Best ever
  if (bestEver) {
    ShapeDrawer.fillRect(canvas, 450, 50, 150, 150, bestEver.r, bestEver.g, bestEver.b, 255);
  }

  // Population grid
  const gridX = 50, gridY = 250, cellSize = 20, cols = 10;
  population.forEach((g, i) => {
    const x = gridX + (i % cols) * (cellSize + 5);
    const y = gridY + Math.floor(i / cols) * (cellSize + 5);
    ShapeDrawer.fillRect(canvas, x, y, cellSize, cellSize, g.r, g.g, g.b, 255);
  });

  canvas.upload();
}
Enter fullscreen mode Exit fullscreen mode
renderer.onRender(() => {
  canvas.draw(0, 0);

  renderer.drawText("TARGET", { x: 50, y: 30 }, 16, { r: 1, g: 1, b: 1, a: 1 });
  renderer.drawText("BEST NOW", { x: 250, y: 30 }, 16, { r: 1, g: 1, b: 1, a: 1 });
  renderer.drawText("BEST EVER", { x: 450, y: 30 }, 16, { r: 1, g: 1, b: 1, a: 1 });

  const best = population[0];
  renderer.drawText(
    `Gen: ${generation} | Fitness: ${best.fitness.toFixed(2)} | RGB: (${best.r}, ${best.g}, ${best.b})`,
    { x: 50, y: 650 },
    16,
    { r: 1, g: 1, b: 1, a: 1 }
  );
});
Enter fullscreen mode Exit fullscreen mode

The Loop

let frameCount = 0;
const FRAME_DELAY = 60;

evolve();

function Loop() {
  frameCount++;
  renderer.input.GetInput();

  if (frameCount % FRAME_DELAY === 0) {
    evolve();
  }

  render();

  if (renderer.step()) {
    setImmediate(Loop);
  } else {
    renderer.shutdown();
  }
}

Loop();
Enter fullscreen mode Exit fullscreen mode

In ~100 lines (minus boilerplate), we’ve got a fully animated evolutionary algorithm running in Node.js.

If graphics programming is something you’re curious about, I’ve got more content coming.

Check out the repo:
https://github.com/sklyt/render
Star it and browse the examples if this kind of stuff is your thing.

More from me:

Building A Distributed Video Transcoding System with Node.js

Thanks for reading!

Find me here:

Top comments (0)