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
}
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);
}
}
pixelDensity(2) in setup() ensures
crisp output. For saves, use pixelDensity(4) and then
reset it after saving to avoid GPU memory exhaustion.