DEV Community

Cover image for Building Generative Art Step by Step — A Node Garden Example
usapopopooon
usapopopooon

Posted on

Building Generative Art Step by Step — A Node Garden Example

This node garden implementation is just one example. There are many different expressions and implementations out there, so please consider this as a reference.

First, See the Final Result

This is what we're building.

Introduction

When creating generative art, you usually have an image in your head of "I want it to move like this." But when you actually try to implement it, it looks complex and you don't know where to start.

In such cases, the approach of "breaking it down into elements and building up from the bottom" might work well.

In this article, I'll introduce this approach using a node garden — a classic generative art implementation — as an example.

Verbalize Your Image

First, put the vague image in your head into words.

For this node garden, I imagined it like this:

  • Points floating around
  • Lines connect when they get close
  • They influence each other and change movement
  • Something organic feeling

Even vague things like "something organic" should be written down for now.

Break Down Into Elements

Next, break down each description into concrete processes.

Image Process
Points floating Drawing points, movement with velocity
Connect when close Distance calculation, threshold check, line drawing
Influence each other / Organic feeling Force from charge, line stretch animation

The way you break things down will vary by person. I often separate "visible elements" from "movement rules."

Organize as Layer Structure

Organize the elements into a layer structure while considering dependencies.

For a simple node garden, Layer 3 might be enough.

Layer 4: Charge + Stretch Animation
    ↑
Layer 3: Line Connection & Opacity
    ↑
Layer 2: Point Movement & Screen Wrap
    ↑
Layer 1: Point Drawing
Enter fullscreen mode Exit fullscreen mode

This is a dependency structure where upper layers can't exist without lower layers.

For example, to implement "force from charge," you first need "distance calculation," and to calculate distance, you need "point positions"... and so on.

Build From the Bottom

Now let's actually write the code. The key point is to verify at each layer.


Layer 1: Point Drawing

First, just draw points.
If white points are randomly displayed on the screen, it's OK.


const CONFIG = {
  width: 340,
  height: 340,
  numNodes: 60,
  nodeRadius: 3,
};

class Node {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.radius = CONFIG.nodeRadius;
  }

  draw(ctx) {
    ctx.fillStyle = '#fff';
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
    ctx.fill();
  }
}

// Initialize
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const nodes = Array.from({ length: CONFIG.numNodes }, () =>
  new Node(
    Math.random() * CONFIG.width,
    Math.random() * CONFIG.height
  )
);

// Draw
ctx.clearRect(0, 0, CONFIG.width, CONFIG.height);
nodes.forEach(node => node.draw(ctx));
Enter fullscreen mode Exit fullscreen mode

Layer 2: Point Movement & Screen Wrap

Give points velocity and make them move.
If points are floating around, it's a success.


class Node {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.vx = Math.random() * 3 - 1.5;
    this.vy = Math.random() * 3 - 1.5;
    this.radius = CONFIG.nodeRadius;
  }

  update() {
    this.x += this.vx;
    this.y += this.vy;

    // Wrap to opposite side when reaching screen edge
    if (this.x > CONFIG.width) this.x = 0;
    else if (this.x < 0) this.x = CONFIG.width;
    if (this.y > CONFIG.height) this.y = 0;
    else if (this.y < 0) this.y = CONFIG.height;
  }

  draw(ctx) {
    ctx.fillStyle = '#fff';
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
    ctx.fill();
  }
}

// Animation loop
function loop() {
  ctx.clearRect(0, 0, CONFIG.width, CONFIG.height);
  nodes.forEach(node => {
    node.update();
    node.draw(ctx);
  });
  requestAnimationFrame(loop);
}
loop();
Enter fullscreen mode Exit fullscreen mode

Layer 3: Line Connection & Opacity

Calculate distance between points and draw lines if within threshold.
If nearby points are connected with lines and opacity changes with distance, it's a success.

Here's one trick: when nodes wrap at the screen edge, lines suddenly disappear which looks unnatural. To prevent this, we set the wrap boundary larger than the drawing area (margin: 100). Nodes go off-screen and lines naturally fade out before wrapping to the opposite side.

Up to Layer 2, it's just nodes so it's not noticeable, but from Layer 3 onward where lines connect, this technique becomes important.


const CONFIG = {
  // ...
  margin: 100,
  minDist: 100,
  lineWidth: 1.5,
};

class Node {
  // ...
  update() {
    this.x += this.vx;
    this.y += this.vy;

    // Wrap outside screen edge (so lines fade naturally)
    const m = CONFIG.margin;
    if (this.x > CONFIG.width + m) this.x = -m;
    else if (this.x < -m) this.x = CONFIG.width + m;
    if (this.y > CONFIG.height + m) this.y = -m;
    else if (this.y < -m) this.y = CONFIG.height + m;
  }

  distanceTo(other) {
    const dx = this.x - other.x;
    const dy = this.y - other.y;
    return { dx, dy, dist: Math.sqrt(dx * dx + dy * dy) };
  }
}

function loop() {
  ctx.clearRect(0, 0, CONFIG.width, CONFIG.height);
  ctx.lineWidth = CONFIG.lineWidth;

  for (let i = 0; i < nodes.length; i++) {
    const node1 = nodes[i];
    node1.update();
    node1.draw(ctx);

    for (let j = i + 1; j < nodes.length; j++) {
      const node2 = nodes[j];
      const { dist } = node1.distanceTo(node2);

      if (dist < CONFIG.minDist) {
        // Closer distance = more opaque
        const opacity = 1 - dist / CONFIG.minDist;
        ctx.beginPath();
        ctx.strokeStyle = `rgba(255, 255, 255, ${opacity})`;
        ctx.moveTo(node1.x, node1.y);
        ctx.lineTo(node2.x, node2.y);
        ctx.stroke();
      }
    }
  }

  requestAnimationFrame(loop);
}
Enter fullscreen mode Exit fullscreen mode

Layer 4: Charge + Stretch Animation

Give each node a random "charge" so pairs attract or repel each other. Also, instead of lines appearing suddenly, add an animation where they extend from both ends toward the center.

By multiplying charges, we express attraction and repulsion.

interaction = charge1 × charge2
Enter fullscreen mode Exit fullscreen mode
  • Same sign (+ × + or − × −) → Positive → Repel
  • Opposite sign (+ × − or − × +) → Negative → Attract
Charge (Node) 1 Charge (Node) 2 Result Movement
+ + + Repel
+ Repel
+ Attract
+ Attract

Nodes that were moving similarly now have personality, and movement becomes more diverse.


const CONFIG = {
  // ...
  margin: 100,
  springAmount: 0.0005,
  maxSpeed: 1.5,
  lineGrowSpeed: 0.05,
};

class Node {
  constructor(x, y) {
    // ...
    // Random charge from -1 to 1
    this.charge = Math.random() * 2 - 1;
  }

  update() {
    // Speed limit
    const speed = Math.sqrt(this.vx * this.vx + this.vy * this.vy);
    if (speed > CONFIG.maxSpeed) {
      this.vx = (this.vx / speed) * CONFIG.maxSpeed;
      this.vy = (this.vy / speed) * CONFIG.maxSpeed;
    }

    this.x += this.vx;
    this.y += this.vy;

    // Wrap outside screen edge (so lines fade naturally)
    const m = CONFIG.margin;
    if (this.x > CONFIG.width + m) this.x = -m;
    else if (this.x < -m) this.x = CONFIG.width + m;
    if (this.y > CONFIG.height + m) this.y = -m;
    else if (this.y < -m) this.y = CONFIG.height + m;
  }

  applyForce(ax, ay) {
    this.vx += ax;
    this.vy += ay;
  }
}

// Manage connection state
const connections = new Map();

function getConnectionKey(i, j) {
  return i < j ? `${i}-${j}` : `${j}-${i}`;
}

function drawConnection(node1, node2, opacity, progress) {
  const midX = (node1.x + node2.x) / 2;
  const midY = (node1.y + node2.y) / 2;

  // Extend toward midpoint based on progress
  const x1 = node1.x + (midX - node1.x) * progress;
  const y1 = node1.y + (midY - node1.y) * progress;
  const x2 = node2.x + (midX - node2.x) * progress;
  const y2 = node2.y + (midY - node2.y) * progress;

  ctx.beginPath();
  ctx.strokeStyle = `rgba(255, 255, 255, ${opacity})`;
  ctx.moveTo(node1.x, node1.y);
  ctx.lineTo(x1, y1);
  ctx.moveTo(node2.x, node2.y);
  ctx.lineTo(x2, y2);
  ctx.stroke();
}

// Inside loop
if (dist < CONFIG.minDist) {
  const key = getConnectionKey(i, j);
  const currentProgress = connections.get(key) || 0;
  const newProgress = Math.min(1, currentProgress + CONFIG.lineGrowSpeed);
  connections.set(key, newProgress);

  const opacity = 1 - dist / CONFIG.minDist;
  drawConnection(node1, node2, opacity, newProgress);

  // Multiply charges (same sign → repel, opposite sign → attract)
  const interaction = node1.charge * node2.charge;
  const ax = dx * CONFIG.springAmount * interaction;
  const ay = dy * CONFIG.springAmount * interaction;
  node1.applyForce(ax, ay);
  node2.applyForce(-ax, -ay);
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

What's important in implementing art expressions is this process of taking the image in your head and:

  1. Verbalizing it
  2. Breaking it into elements
  3. Organizing as layers
  4. Building from the bottom

At each layer, you can verify "it's working, it's correct," so it's easy to see where problems occur. You can also decide mid-way "I don't need this element" and remove it.

Top comments (0)