p5.js Expert Article 6

Performance Optimisation

Profile your sketch, identify GPU vs CPU bottlenecks, reduce draw calls, use workers, and hit 60fps with thousands of elements.

⏱ 25 min read performance profiling optimization workers draw calls GPU

Measure before optimising

The browser’s DevTools are your first tool. In Chrome:

  1. Open DevTools (F12)
  2. Go to Performance tab
  3. Click Record, run your sketch for a few seconds, stop
  4. Examine the flame chart — long bars in JavaScript are CPU bottlenecks; GPU issues show up as long frame render times

Also check the Rendering tab: enable “Frame Rendering Stats” to see FPS and GPU memory.

// Quick in-sketch FPS meter
function draw() {
  // ... your sketch ...

  // Overlay FPS
  fill(255, 0);  // white text
  fill(255);
  noStroke();
  textSize(12);
  textAlign(LEFT, TOP);
  text(`${floor(frameRate())} fps`, 5, 5);
}

Common bottlenecks

1. Too many draw calls

Every circle(), rect(), line() is a draw call. Thousands of separate calls add CPU overhead.

Solution: batch with beginShape()

// ❌ 500 separate draw calls
for (let p of particles) {
  circle(p.x, p.y, p.r * 2);
}

// ✅ One draw call with beginShape(POINTS)
strokeWeight(8);
stroke(200, 100, 255);
beginShape(POINTS);
for (let p of particles) {
  vertex(p.x, p.y);
}
endShape();

2. loadPixels() in every frame

Calling loadPixels() + updatePixels() syncs the CPU and GPU — expensive.

Solution: move pixel processing to a shader.

3. Unnecessary object creation

Creating objects inside draw() forces the garbage collector to work every frame.

// ❌ Creates a new p5.Vector every frame
function draw() {
  let gravity = createVector(0, 0.1);  // new allocation every frame
}

// ✅ Allocate once
let gravity;
function setup() {
  gravity = createVector(0, 0.1);
}

4. String concatenation in draw()

// ❌ Creates intermediate strings every frame
text('FPS: ' + floor(frameRate()) + ' | Count: ' + particles.length, 10, 20);

// ✅ Template literal — marginally faster, but pre-format rarely-changing text
let statsText = '';
if (frameCount % 30 === 0) {
  statsText = `FPS: ${floor(frameRate())} | Count: ${particles.length}`;
}
text(statsText, 10, 20);

Web Workers for heavy computation

Move CPU-intensive work (pathfinding, simulation, data processing) off the main thread:

// main.js
const worker = new Worker('worker.js');

worker.onmessage = function(e) {
  // Receive computed results
  particles = e.data.particles;
};

// Every N frames, send data to worker
if (frameCount % 5 === 0) {
  worker.postMessage({ particles, width, height });
}
// worker.js
self.onmessage = function(e) {
  let { particles, width, height } = e.data;

  // Compute physics update (no p5 APIs available here — pure JS)
  let updated = particles.map(p => ({
    ...p,
    x: p.x + p.vx,
    y: p.y + p.vy,
    vx: (p.x < 0 || p.x > width)  ? -p.vx : p.vx,
    vy: (p.y < 0 || p.y > height) ? -p.vy : p.vy
  }));

  self.postMessage({ particles: updated });
};

Workers run in parallel — physics runs at full speed without blocking the render loop.

Spatial partitioning

Avoid O(N²) checks. A simple grid reduces nearest-neighbour queries to O(N):

class Grid {
  constructor(cellSize) {
    this.s = cellSize;
    this.g = {};
  }

  clear() { this.g = {}; }

  insert(x, y, data) {
    let k = `${Math.floor(x / this.s)},${Math.floor(y / this.s)}`;
    (this.g[k] = this.g[k] || []).push(data);
  }

  query(x, y, radius) {
    let r = [], cells = Math.ceil(radius / this.s);
    let gx = Math.floor(x / this.s), gy = Math.floor(y / this.s);
    for (let dx = -cells; dx <= cells; dx++)
      for (let dy = -cells; dy <= cells; dy++)
        (this.g[`${gx + dx},${gy + dy}`] || []).forEach(d => r.push(d));
    return r;
  }
}

WEBGL-specific optimisations

In WEBGL mode:

  • Texture atlases — pack multiple images into one texture, switch between them using UV offsets
  • Avoid fill() / stroke() changes between shapes — state changes are expensive
  • Pre-compile shaders in setup(); creating shaders in draw() is very slow
  • Reuse geometry — use model() with a cached 3D model instead of redrawing with beginShape()
  • pixelDensity(1) — on retina displays, default is 2 (4× the pixels to fill)
function setup() {
  pixelDensity(1);  // significant GPU load reduction on retina
  createCanvas(800, 600, WEBGL);
}

frameRate() target and delta time

Target lower frame rates for heavy sketches:

function setup() {
  createCanvas(800, 600);
  frameRate(30);  // target 30fps
}

Use delta time for physics that should be frame-rate independent:

let lastTime = 0;

function draw() {
  let now = millis();
  let dt  = (now - lastTime) / 1000;  // seconds since last frame
  lastTime = now;

  // Scale movement by dt — same speed at any frame rate
  for (let p of particles) {
    p.x += p.vx * dt * 60;  // * 60 so "velocity" is still in units/frame at 60fps
    p.y += p.vy * dt * 60;
  }
}

Key takeaways

  • Profile first — use browser DevTools before guessing where the bottleneck is
  • Batch draw calls with beginShape(POINTS/LINES/TRIANGLES) instead of individual shape calls
  • Avoid allocating objects inside draw() — pre-allocate and reuse
  • Web Workers move heavy computation off the main thread without blocking rendering
  • Spatial partitioning (grid, quadtree) reduces O(N²) checks to O(N)
  • pixelDensity(1) is a free 4× GPU load reduction on retina displays
  • Delta time makes physics frame-rate independent