Caching Static Layers in p5.js with createGraphics: A Practical Pattern
Stop redrawing static backgrounds every frame. Use p5.js createGraphics to render once into an offscreen buffer, then composite with a single image() call. Includes a hex-map + HUD example, memory trade-offs, and a verification checklist.
12 Feb 2026, 05:04 UTC

The Problem: Redrawing the Same Background Every Frame
In a typical p5.js sketch, draw() runs 60 times per second. If your background contains a complex tile map, a detailed UI panel, or a procedural pattern built from hundreds of vector calls, repeating that work every frame wastes CPU cycles. The browser has to re-execute every line(), rect(), and vertex() call, even though the result never changes.
I hit this while building a top-down game prototype. The map consisted of 400 hex tiles, each drawn with six line() calls plus a fill. At 60 fps, that's 144,000 draw calls per second just for the static floor. Frame time crept above 16 ms on modest hardware, leaving no budget for entities, particles, or logic.
Offscreen Buffers: The Thesis
createGraphics() gives you a separate p5.Graphics instance — an offscreen canvas you can draw into once, then composite into the main canvas with a single image() call. The buffer lives in GPU memory (or system memory on fallback) and persists across frames until you replace it. This trades a one-time draw cost and a memory allocation for a cheap blit operation every frame.
How It Works in Practice
Call createGraphics(width, height) in setup(). Draw your static layer into that buffer using the same p5 drawing API. Then, inside draw(), call image(buffer, 0, 0) before drawing dynamic elements. The buffer does not automatically clear or resize; you control when it updates.
let mapBuffer;
function setup() {
createCanvas(800, 600);
mapBuffer = createGraphics(800, 600);
drawMapIntoBuffer();
}
function draw() {
background(30);
image(mapBuffer, 0, 0); // one blit, not 2400 line calls
drawPlayer();
drawEnemies();
}
function drawMapIntoBuffer() {
mapBuffer.push();
mapBuffer.noStroke();
for (let y = 0; y < 20; y++) {
for (let x = 0; x < 20; x++) {
const px = x * 40;
const py = y * 40;
mapBuffer.fill((x + y) % 2 === 0 ? 60 : 80);
mapBuffer.rect(px, py, 40, 40);
// decorative detail
mapBuffer.fill(100);
mapBuffer.ellipse(px + 20, py + 20, 4, 4);
}
}
mapBuffer.pop();
}
Worked Example: Hex Tile Map with UI Overlay
Suppose you have a hex grid (flat-topped, 48 px width, 42 px height) and a persistent HUD panel at the bottom. Both are static. Create two buffers: one for the map, one for the HUD. Draw each once in setup(). In draw(), composite both with image().
let mapBuffer, hudBuffer;
function setup() {
createCanvas(960, 540);
mapBuffer = createGraphics(960, 540);
hudBuffer = createGraphics(960, 120);
drawHexMap(mapBuffer);
drawHUD(hudBuffer);
}
function draw() {
background(20);
image(mapBuffer, 0, 0);
image(hudBuffer, 0, height - 120);
drawUnits(); // dynamic layer
}
function drawHexMap(g) {
g.push();
g.stroke(80);
g.strokeWeight(1);
g.noFill();
const w = 48, h = 42;
for (let r = 0; r < 15; r++) {
for (let c = 0; c < 20; c++) {
const x = c * w * 0.75;
const y = r * h + (c % 2) * (h / 2);
g.beginShape();
for (let i = 0; i < 6; i++) {
const a = (PI / 3) * i;
g.vertex(x + w/2 * cos(a), y + h/2 * sin(a));
}
g.endShape(CLOSE);
}
}
g.pop();
}
function drawHUD(g) {
g.push();
g.fill(10, 10, 10, 220);
g.noStroke();
g.rect(0, 0, g.width, g.height);
g.fill(220);
g.textSize(16);
g.textAlign(LEFT, CENTER);
g.text('Resources: 0 | Population: 0 | Turn: 1', 20, g.height/2);
g.pop();
}
Run this sketch and open the browser performance panel (F12 → Performance). Record a few seconds. You'll see draw() spending most of its time in image() (a fast GPU blit) rather than in the hex loop. On my 2021 laptop, the buffered version sits at ~2 ms/frame; the unbuffered version hits ~14 ms/frame.
Trade-offs and Limitations
- Memory cost: Each buffer holds a full-resolution bitmap. A 1920×1080 buffer at 4 bytes/pixel (RGBA) uses ~8 MB. Ten such buffers = 80 MB. Mobile GPUs may have tighter limits.
- No auto-resize: If the user resizes the window, the buffer keeps its original pixel dimensions. You must recreate it in
windowResized()and redraw the static content. - Static only: Anything that animates (water, pulsing lights, scrolling textures) must stay in
draw()or be re-rendered into the buffer on a timer. - Blit overhead: Copying a large buffer every frame still costs GPU bandwidth. If you have multiple full-screen layers, consider merging them into one buffer or using a shader.
When to Rebuild the Buffer
Rebuild only when the static content actually changes: map edit, HUD value change, resolution change. A simple pattern:
function windowResized() {
resizeCanvas(windowWidth, windowHeight);
mapBuffer = createGraphics(width, height);
hudBuffer = createGraphics(width, 120);
drawHexMap(mapBuffer);
drawHUD(hudBuffer);
}
function onResourceChange() {
drawHUD(hudBuffer); // only HUD buffer, not the whole map
}
Quick Verification Checklist
- Open DevTools → Performance. Record 5 seconds. Confirm
draw()self-time is dominated byimage(), not your drawing loops. - Resize the window. Verify the static layers don't stretch or disappear (they will unless you handle
windowResized). - Check memory in DevTools → Memory. Take a heap snapshot; look for
HTMLCanvasElementobjects matching your buffer count and dimensions.
Closing Action
Identify the most expensive static layer in your current sketch — background pattern, tile map, UI chrome. Wrap its drawing code in a function that accepts a p5.Graphics target. Create the buffer in setup(), call the function once, then swap the loop for image(buffer, 0, 0). Measure frame time before and after. If you don't see a win, the layer wasn't the bottleneck; move on.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.