Tutorial · Creative Coding · Beginner–Intermediate
📅 March 2026⏱ 40 min read🎓 Beginner–Intermediate⚙️ p5.js / GLSL

Generative Art with p5.js and GLSL Shaders

p5.js makes creative coding accessible with its draw() loop and helper functions, while GLSL fragment shaders unlock GPU-accelerated visuals impossible in JavaScript alone. This tutorial covers four techniques — from basic p5 sketches through shader integration, flow fields, Truchet tiles, and canvas export — and explains the math behind each.

1. p5.js Fundamentals

p5.js provides a setup() function called once at startup and a draw() loop called every frame. The built-in noise(x, y, z) returns Perlin noise in [0, 1]. Here's a minimal sketch that draws flowing circles:

// Load via CDN:  https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js
let t = 0;

function setup() {
  createCanvas(800, 800);
  background(10);
  noFill();
  strokeWeight(1.2);
}

function draw() {
  background(10, 10, 10, 8);  // semi-transparent → trails
  for (let i = 0; i < 200; i++) {
    const x = noise(i * 0.1, t) * width;
    const y = noise(i * 0.1, t + 100) * height;
    const r = noise(i * 0.1, t + 200) * 50 + 5;
    stroke(lerpColor(
      color(167, 139, 250),
      color(250, 200, 100),
      noise(i * 0.05, t + 300)
    ));
    circle(x, y, r * 2);
  }
  t += 0.003;
}

Key globals: width/height (canvas size), mouseX/mouseY (real-time pointer), frameCount (integer frame counter), random(min, max) (uniform), noise(x[, y[, z]]) (smooth Perlin).

2. GLSL Fragment Shaders in p5.js

Switch to createCanvas(w, h, WEBGL) to enable p5's WebGL mode, then use createShader(vert, frag). The shader runs on the GPU for every pixel — perfect for procedural textures, ray marching, and effects impossible in Canvas2D.

const fragSrc = `
precision mediump float;
uniform vec2  u_res;
uniform float u_time;

// 2D rotation matrix
mat2 rot(float a) { float c=cos(a),s=sin(a); return mat2(c,-s,s,c); }

void main() {
  vec2 uv = (gl_FragCoord.xy - u_res * 0.5) / u_res.y;
  uv *= rot(u_time * 0.2);

  float d = length(uv);
  float rings = sin(d * 30.0 - u_time * 3.0) * 0.5 + 0.5;
  vec3  col   = mix(vec3(0.1, 0.0, 0.3), vec3(0.9, 0.6, 1.0), rings);

  gl_FragColor = vec4(col, 1.0);
}`;

const vertSrc = `
attribute vec3 aPosition;
void main() {
  gl_Position = vec4(aPosition * 2.0, 1.0);
}`;  // multiply by 2 to fill the -1..1 clip space

let sh;
function setup() {
  createCanvas(800, 800, WEBGL);
  sh = createShader(vertSrc, fragSrc);
}

function draw() {
  shader(sh);
  sh.setUniform('u_res',  [width, height]);
  sh.setUniform('u_time', millis() / 1000);
  rect(-width/2, -height/2, width, height);  // draws a full-screen quad
}
Coordinate warning: In p5 WEBGL mode, (0,0) is the centre of the canvas and Y increases downward. In the shader gl_FragCoord.xy origin is bottom-left. Subtract u_res * 0.5 and divide by u_res.y to get normalised (–0.5…0.5) UV space.

3. Flow Fields with Perlin Noise

A flow field assigns a direction vector to every point in 2D space. Particles released into the field follow those directions, tracing organic curves. The direction is computed from the gradient of a Perlin noise function.

const COLS = 80, ROWS = 80;
const SCALE = 10;        // pixels per field cell
const PARTICLES = 3000;

let field, particles;

function setup() {
  createCanvas(COLS*SCALE, ROWS*SCALE);
  field     = new Array(COLS * ROWS);
  particles = Array.from({length: PARTICLES}, () => ({
    x: random(width), y: random(height),
    vx: 0, vy: 0
  }));
  background(15);
}

function updateField(t) {
  for (let y = 0; y < ROWS; y++)
    for (let x = 0; x < COLS; x++) {
      const angle = noise(x * 0.08, y * 0.08, t) * TWO_PI * 2;
      field[y * COLS + x] = angle;
    }
}

function draw() {
  updateField(frameCount * 0.003);
  stroke(167, 139, 250, 40);
  strokeWeight(1);

  for (const p of particles) {
    const col   = floor(p.x / SCALE);
    const row   = floor(p.y / SCALE);
    const angle = field[row * COLS + col] || 0;
    p.vx = p.vx * 0.9 + cos(angle) * 2;
    p.vy = p.vy * 0.9 + sin(angle) * 2;
    const nx = p.x + p.vx, ny = p.y + p.vy;
    line(p.x, p.y, nx, ny);
    p.x = (nx + width)  % width;
    p.y = (ny + height) % height;
  }
}

4. Truchet Tiles

Sébastien Truchet (1704) found that two simple tiles — a square with arcs from corner to midpoint — placed randomly produce surprisingly complex lattice patterns. Each cell is independently rotated 0°, 90°, 180°, or 270°:

const T = 60;  // tile size in pixels

function setup() {
  createCanvas(720, 720);
  noFill();
  strokeWeight(2);
  stroke(200);
  background(15);
  drawTruchet();
  noLoop();
}

function drawTruchet() {
  const cols = width  / T;
  const rows = height / T;
  for (let gy = 0; gy < rows; gy++) {
    for (let gx = 0; gx < cols; gx++) {
      push();
      translate(gx * T + T/2, gy * T + T/2);
      rotate(floor(random(4)) * HALF_PI);
      // Two quarter-circle arcs from midpoints of adjacent edges
      arc(-T/2,  0,  T,  T, -HALF_PI, 0);
      arc( T/2,  0,  T,  T,  HALF_PI, PI);
      pop();
    }
  }
}

// Regenerate on click
function mousePressed() {
  background(15);
  drawTruchet();
}

For Smith variation (less regular), use hash-based pseudo-random rotation based on grid coordinates — this gives deterministic yet varied patterns useful for terrain or dungeon generation.

5. Color Palettes by Formula

Íñigo Quílez's cosine palette formula generates smooth, cyclic color gradients with just four vec3 constants — perfect for both p5 and GLSL:

// In GLSL: cos palette — t is a float 0..1
// vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d)
// col = a + b * cos(2π(c*t + d))

// Rainbow palette: a=(0.5,0.5,0.5), b=(0.5,0.5,0.5), c=(1,1,1), d=(0,0.33,0.67)
function cosinePalette(t, a, b, c, d) {
  return {
    r: (a.r + b.r * Math.cos(TWO_PI * (c.r * t + d.r))) * 255,
    g: (a.g + b.g * Math.cos(TWO_PI * (c.g * t + d.g))) * 255,
    b: (a.b + b.b * Math.cos(TWO_PI * (c.b * t + d.b))) * 255
  };
}

// Usage in p5 draw():
const col = cosinePalette(i / PARTICLES,
  {r:.5, g:.5, b:.5}, {r:.5, g:.5, b:.5},
  {r:1,  g:1,  b:1},  {r:0,  g:.33, b:.67});
stroke(col.r, col.g, col.b, 120);

6. Saving Canvas as PNG

p5.js provides saveCanvas(), but for high-res exports up to 8192×8192 pixels on a normal screen, render off-screen first:

let exportMode = false;

function keyPressed() {
  if (key === 's' || key === 'S') {
    exportMode = true;
    const g = createGraphics(4096, 4096);  // 4K export
    drawMySketch(g);                          // use g.noise(), g.line() etc.
    save(g, 'generative-art.png');
    g.remove();
    exportMode = false;
  }
  if (key === 'r' || key === 'R') {
    noiseSeed(Math.random() * 99999);       // new random seed
    background(15);
  }
}
Anti-aliasing for WEBGL mode: p5 WEBGL renders at device pixel ratio × canvas size by default on Retina displays. Calling pixelDensity(2) in setup() ensures crisp output. For saves, use pixelDensity(4) and then reset it after saving to avoid GPU memory exhaustion.