Skip to content

[p5.js 2.0+ Bug Report]: Dense 2D paths (beginShape/vertex/endShape) run 3–4× slower than on 1.x #8973

Description

@okra-sf

Most appropriate sub-area of p5.js?

  • Core/Environment/Rendering

p5.js version

2.3.0 (reproduces on current main / 2.3.1-rc.1; the gap is larger on 2.0.1)

Web browser and version

Chrome 138 — the overhead is JS-side, so it reproduces on any browser

Operating system

Reproduced on both Windows 11 (x64) and macOS 26.5 (Apple Silicon)

Steps to reproduce this

On dense 2D paths — many vertex() calls per frame — 2.x currently spends 3-4x the frame time of 1.9.2 for identical geometry.
Similar in spirit to #8013 (transforms) and #8316 (noise()), 1.x->2.x gaps that have since been fixed — this one is about the shape pipeline.

Steps:

  1. Create a sketch on p5 1.9.2 with the snippet below; open the console and note the logged draw time.
  2. Switch the sketch's p5 to 2.3.0 (or any 2.x) and run it again.
  3. Compare: the logged draw time is ~3-5x higher on 2.x (our numbers under the snippet).

Snippet (minimal repro):

// compare logged ms on a p5 1.x vs 2.x sketch
function setup() { createCanvas(600, 600); }
function draw() {
  const t0 = performance.now();
  background(240);
  noStroke();
  for (let i = 0; i < 300; i++) {
    fill(50 + (i % 4) * 50, 100, 150);
    const cx = 40 + (i % 20) * 28, cy = 40 + ((i / 20) | 0) * 36;
    beginShape();
    for (let a = 0; a < TWO_PI; a += TWO_PI / 30) {
      vertex(cx + 14 * cos(a), cy + 14 * sin(a));
    }
    endShape(CLOSE);
  }
  if (frameCount % 30 === 0) {
    console.log((performance.now() - t0).toFixed(1) + ' ms');
  }
}

Observed output (Chrome, CPU raster) — Windows x64: ~1.2 ms on 1.9.2, ~5.7 ms on 2.3.0, ~9.8 ms on 2.0.1; macOS Apple Silicon: ~1.8 ms on 1.9.2, ~4.1 ms on 2.3.0, ~5.4 ms on 2.0.1.

Full benchmark — attached, runs on any machine

For a fuller side-by-side, save perfbench.html (below) and open it in a browser — no build or server needed (works from file://). The default run benches 1.9.2 / 2.0.1 / latest on identical geometry and prints a ratio table; add any version in the input box, or use ?versions=... / ?frames=N / ?p5url=....

perfbench.html — full benchmark, single self-contained file
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HB p5 perfbench</title>
    <!-- Standalone p5 performance benchmark representative of HB usage
         (hyperbolic geometry generative art, 2D canvas renderer):
           tree-fill   — filled geodesic regions + arc links, like an HB
                         {7,3} tree render (fills sampled 12 pts/edge)
           tree-lines  — hairline arc/link pass only (lines-only mode)
           kq-paint    — KQ tiling polygon paint: fill pass + separate
                         stroke pass over many small tiles

         Default (no params): COMPARE mode — benches each version in
         ?versions=1.9.2,2.0.1,latest sequentially via iframes and shows
         a combined table (works from file://; results travel by
         postMessage). With ?p5v=<ver> or ?p5url=<url>: single-version
         mode (used by perfbench-run.mjs and as the iframe embed).
         Other params: ?frames=N -->
    <script>
        const Q = new URLSearchParams(location.search);
        const SINGLE = Q.has("p5v") || Q.has("p5url");
        const VER = Q.get("p5v") || "1.9.2";
        if (SINGLE) {
            const src = Q.get("p5url") || ("https://cdn.jsdelivr.net/npm/p5@" + VER + "/lib/p5.min.js");
            document.write('<script src="' + src + '"><\/script>');
        }
    </script>
    <style>
        body {
            margin: 0;
            background: #14141a;
            color: #d8d8e0;
            font: 13px Arial, Helvetica, sans-serif;
            display: flex;
            gap: 16px;
            padding: 16px;
        }

        #panel {
            min-width: 380px;
        }

        table {
            border-collapse: collapse;
            margin-top: 10px;
        }

        td,
        th {
            border: 1px solid #2c2c38;
            padding: 4px 10px;
            text-align: right;
            white-space: nowrap;
        }

        th:first-child,
        td:first-child {
            text-align: left;
        }

        h1 {
            font-size: 15px;
            color: #fff;
        }

        .note {
            color: #8a8a98;
            max-width: 360px;
            line-height: 1.4;
        }

        .best {
            color: #4ade80;
        }

        .worst {
            color: #f87171;
        }

        canvas,
        iframe {
            border: 1px solid #2c2c38;
        }

        iframe {
            width: 940px;
            height: 940px;
        }
    </style>
</head>

<body>
    <div id="panel">
        <h1 id="title">HB p5 perfbench</h1>
        <p class="note">Median draw ms per frame — deterministic geometry,
            identical across versions. Scenes mirror real HB workloads:
            geodesic-region fills, arc links, KQ two-pass polygon paint.</p>
        <table id="results"></table>
        <p class="note" id="status">running…</p>
    </div>
    <div id="stage"></div>

    <script>
        const FRAMES = parseInt(Q.get("frames") || "30", 10);
        const WARMUP = 5;
        const SIZE = 900;
        const R = 430; // disk radius in px, like the app's H.radius
        const SCENE_NAMES = ["tree-fill", "tree-lines", "kq-paint"];

        // ======================= COMPARE MODE =======================
        if (!SINGLE) {
            document.getElementById("title").textContent = "HB p5 perfbench — compare";
            const table = document.getElementById("results");
            table.innerHTML = "<tr id='head'><th>scene</th></tr>" +
                SCENE_NAMES.map((s) => `<tr id="row-${s}"><td>${s}</td></tr>`).join("");

            const controls = document.createElement("p");
            controls.innerHTML =
                `<input id="addver" size="10" placeholder="e.g. 2.2.3"> ` +
                `<button id="addbtn">add run</button>`;
            document.getElementById("panel").appendChild(controls);

            const collected = {};
            const queue = (Q.get("versions") || "1.9.2,2.0.1,latest").split(",");
            let current = null;
            let baseVer = null;

            function addColumn(v) {
                const th = document.createElement("th");
                th.id = "col-" + v;
                th.textContent = v;
                document.getElementById("head").appendChild(th);
                for (const s of SCENE_NAMES) {
                    const td = document.createElement("td");
                    td.id = `cell-${s}-${v}`;
                    td.textContent = "…";
                    document.getElementById("row-" + s).appendChild(td);
                }
            }

            function runNext() {
                if (current || !queue.length) {
                    if (!current) {
                        document.getElementById("status").textContent =
                            `done — ${FRAMES} frames/scene per version. Cells: median ms (×ratio vs ${baseVer}).`;
                        window.result = collected;
                    }
                    return;
                }
                current = queue.shift();
                baseVer = baseVer || current;
                if (!document.getElementById("col-" + current)) addColumn(current);
                document.getElementById("status").textContent = `benching ${current}…`;
                const stage = document.getElementById("stage");
                stage.innerHTML = "";
                const f = document.createElement("iframe");
                const p5q = current.startsWith("http")
                    ? "p5url=" + encodeURIComponent(current) : "p5v=" + current;
                f.src = location.pathname + `?${p5q}&frames=${FRAMES}&embed=1`;
                stage.appendChild(f);
            }

            window.addEventListener("message", (e) => {
                if (!e.data || !e.data.perfbench || !current) return;
                const v = current;
                const r = e.data.perfbench;
                collected[v] = r;
                const label = r.detected && r.detected !== v ? `${v} (${r.detected})` : v;
                document.getElementById("col-" + v).textContent = label;
                const base = collected[baseVer];
                for (const s of r.scenes) {
                    const b = base?.scenes.find((x) => x.scene === s.scene);
                    const ratio = b ? s.medianMs / b.medianMs : 1;
                    const cell = document.getElementById(`cell-${s.scene}-${v}`);
                    cell.textContent = `${s.medianMs.toFixed(1)}${ratio.toFixed(2)})`;
                    cell.className = ratio <= 1.05 ? "best" : ratio >= 2 ? "worst" : "";
                }
                current = null;
                runNext();
            });

            document.getElementById("addbtn").onclick = () => {
                const v = document.getElementById("addver").value.trim();
                if (!v) return;
                document.getElementById("addver").value = "";
                queue.push(v);
                runNext();
            };

            runNext();
        }

        // ======================= SINGLE / EMBED MODE =======================
        if (SINGLE) {

            // deterministic PRNG so every version draws identical geometry
            function lcg(seed) {
                let s = seed >>> 0;
                return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
            }

            // --- inline hyperbolic helpers (mirror hbjs geodesic sampling) ---
            // circle through disk points a,b orthogonal to the unit circle
            function arcParams(ax, ay, bx, by) {
                const det = 4 * (ax * by - ay * bx);
                if (Math.abs(det) < 1e-9) return null; // diameter → straight line
                const ka = ax * ax + ay * ay + 1;
                const kb = bx * bx + by * by + 1;
                const cx = (2 * by * ka - 2 * ay * kb) / det;
                const cy = (2 * ax * kb - 2 * bx * ka) / det;
                const r2 = cx * cx + cy * cy - 1;
                if (r2 <= 0) return null;
                return { cx, cy, r: Math.sqrt(r2) };
            }

            // sample the geodesic a→b as n segments (the app's regionPath
            // uses 12 samples/edge); flat [x,y,...] in disk coords, omitting b
            function geodesicPts(ax, ay, bx, by, n) {
                const g = arcParams(ax, ay, bx, by);
                if (!g) return [ax, ay];
                const thA = Math.atan2(ay - g.cy, ax - g.cx);
                const thB = Math.atan2(by - g.cy, bx - g.cx);
                let d = thB - thA;
                if (d > Math.PI) d -= 2 * Math.PI;
                if (d < -Math.PI) d += 2 * Math.PI;
                const out = [];
                for (let k = 0; k < n; k++) {
                    const th = thA + (d * k) / n;
                    out.push(g.cx + g.r * Math.cos(th), g.cy + g.r * Math.sin(th));
                }
                return out;
            }

            // deterministic HB-like layout: rings of nodes toward the rim
            function treeLayout() {
                const rnd = lcg(424242);
                const rings = [0.32, 0.56, 0.73, 0.85, 0.92];
                const counts = [7, 21, 63, 147, 189]; // ~{7,3} growth, capped
                const nodes = [];
                for (let k = 0; k < rings.length; k++) {
                    for (let i = 0; i < counts[k]; i++) {
                        const a = (i / counts[k]) * 2 * Math.PI + 0.13 * k + 0.2 * rnd();
                        const rr = rings[k] * (1 + 0.03 * (rnd() - 0.5));
                        const x = rr * Math.cos(a), y = rr * Math.sin(a);
                        const pr = k === 0 ? 0 : rings[k - 1];
                        const px = pr * Math.cos(a + 0.1 * (rnd() - 0.5));
                        const py = pr * Math.sin(a + 0.1 * (rnd() - 0.5));
                        const s = 0.10 * (1 - rr * 0.85);
                        const verts = [];
                        for (let v = 0; v < 3; v++) {
                            const va = a + (v / 3) * 2 * Math.PI + 0.4 * rnd();
                            verts.push([x + s * Math.cos(va), y + s * Math.sin(va)]);
                        }
                        nodes.push({ x, y, px, py, verts, ring: k });
                    }
                }
                return nodes;
            }

            // KQ-like tiling: rings of 4–7-gon tiles, adjacency-ish colors
            function kqLayout() {
                const rnd = lcg(133713);
                const tiles = [];
                const rings = [0.18, 0.38, 0.56, 0.7, 0.81, 0.89];
                const counts = [8, 20, 40, 72, 120, 168];
                for (let k = 0; k < rings.length; k++) {
                    for (let i = 0; i < counts[k]; i++) {
                        const a = (i / counts[k]) * 2 * Math.PI + 0.21 * k;
                        const rr = rings[k];
                        const cx = rr * Math.cos(a), cy = rr * Math.sin(a);
                        const ns = 4 + Math.floor(rnd() * 4);
                        const s = 0.085 * (1 - rr * 0.8);
                        const verts = [];
                        for (let v = 0; v < ns; v++) {
                            const va = a + (v / ns) * 2 * Math.PI;
                            verts.push([cx + s * Math.cos(va), cy + s * Math.sin(va)]);
                        }
                        tiles.push({ verts, color: (i + k) % 4 });
                    }
                }
                return tiles;
            }

            const PAL = ["#fb8547", "#3aa3bc", "#d94f70", "#c757c9"];
            const SAMPLES = 12; // per edge, as in hbjs regionPath

            function fillRegion(p, verts, col, stroke) {
                if (col) p.fill(col); else p.noFill();
                if (stroke) p.stroke(stroke); else p.noStroke();
                p.beginShape();
                for (let i = 0; i < verts.length; i++) {
                    const [ax, ay] = verts[i];
                    const [bx, by] = verts[(i + 1) % verts.length];
                    const pts = geodesicPts(ax, ay, bx, by, SAMPLES);
                    for (let j = 0; j < pts.length; j += 2) p.vertex(pts[j] * R, pts[j + 1] * R);
                }
                p.endShape(p.CLOSE);
            }

            function arcEdge(p, ax, ay, bx, by) {
                const g = arcParams(ax, ay, bx, by);
                if (!g) {
                    p.line(ax * R, ay * R, bx * R, by * R);
                    return;
                }
                const thA = Math.atan2(ay - g.cy, ax - g.cx);
                const thB = Math.atan2(by - g.cy, bx - g.cx);
                let d = thB - thA;
                if (d > Math.PI) d -= 2 * Math.PI;
                if (d < -Math.PI) d += 2 * Math.PI;
                const s = d > 0 ? thA : thB, e = d > 0 ? thB : thA;
                p.arc(g.cx * R, g.cy * R, 2 * g.r * R, 2 * g.r * R, s, e);
            }

            const TREE = treeLayout();
            const KQ = kqLayout();

            function diskBackground(p) {
                p.push();
                p.fill("#440057");
                p.noStroke();
                p.rect(-SIZE / 2, -SIZE / 2, SIZE, SIZE);
                p.fill("#240047");
                p.ellipse(0, 0, R * 2, R * 2);
                p.pop();
            }

            const SCENES = {
                // full HB render: fills flush to link arcs, links, label
                "tree-fill": (p) => {
                    diskBackground(p);
                    p.push();
                    p.scale(1, -1);
                    for (const n of TREE) fillRegion(p, n.verts, PAL[n.ring % PAL.length], null);
                    p.noFill();
                    p.stroke("#e0e0ff");
                    p.strokeWeight(1);
                    for (const n of TREE) arcEdge(p, n.px, n.py, n.x, n.y);
                    p.pop();
                    p.fill(255);
                    p.noStroke();
                    p.textSize(20);
                    p.text("#52", R - 60, R - 10);
                },
                // lines-only mode: hairline links + region outlines, no fills
                "tree-lines": (p) => {
                    diskBackground(p);
                    p.push();
                    p.scale(1, -1);
                    p.noFill();
                    p.stroke(20);
                    p.strokeWeight(1);
                    for (const n of TREE) {
                        arcEdge(p, n.px, n.py, n.x, n.y);
                        for (let i = 0; i < 3; i++) {
                            const [ax, ay] = n.verts[i];
                            const [bx, by] = n.verts[(i + 1) % 3];
                            arcEdge(p, ax, ay, bx, by);
                        }
                    }
                    p.pop();
                },
                // KQ polygon paint: fill pass, then stroked-edge pass
                "kq-paint": (p) => {
                    diskBackground(p);
                    p.push();
                    p.scale(1, -1);
                    for (const t of KQ) fillRegion(p, t.verts, PAL[t.color], null);
                    for (const t of KQ) fillRegion(p, t.verts, null, "#101018");
                    p.pop();
                },
            };

            // inside a compare-mode iframe: canvas only, no second rail
            if (Q.get("embed") === "1") document.getElementById("panel").style.display = "none";
            document.getElementById("title").textContent =
                "HB p5 perfbench — " + (Q.get("p5url") ? "custom" : VER);
            const table = document.getElementById("results");
            table.innerHTML =
                "<tr><th>scene</th><th>median ms</th><th>min</th><th>max</th><th>fps</th></tr>";
            const results = [];

            new p5((p) => {
                p.setup = () => {
                    const c = p.createCanvas(SIZE, SIZE);
                    c.id("myCanvas");
                    c.parent("stage");
                    p.pixelDensity(1);
                    p.angleMode(p.RADIANS);
                    p.textFont("Arial, Helvetica, sans-serif");
                    p.noLoop();

                    for (const scene of SCENE_NAMES) {
                        const draw = SCENES[scene];
                        const times = [];
                        for (let f = 0; f < WARMUP + FRAMES; f++) {
                            const t0 = performance.now();
                            p.push();
                            p.translate(SIZE / 2, SIZE / 2);
                            draw(p);
                            p.pop();
                            const dt = performance.now() - t0;
                            if (f >= WARMUP) times.push(dt);
                        }
                        times.sort((a, b) => a - b);
                        const median = times[Math.floor(times.length / 2)];
                        const row = {
                            scene,
                            medianMs: median,
                            minMs: times[0],
                            maxMs: times[times.length - 1],
                            fps: 1000 / median,
                        };
                        results.push(row);
                        const tr = document.createElement("tr");
                        tr.innerHTML = `<td>${scene}</td><td>${median.toFixed(1)}</td>` +
                            `<td>${row.minMs.toFixed(1)}</td><td>${row.maxMs.toFixed(1)}</td>` +
                            `<td>${row.fps.toFixed(0)}</td>`;
                        table.appendChild(tr);
                    }
                    document.getElementById("status").textContent =
                        `done — ${FRAMES} frames/scene, ${WARMUP} warmup, canvas ${SIZE}px`;
                    let detected = "";
                    try { detected = (window.p5 && p5.VERSION) || ""; } catch (e) { }
                    window.result = { version: VER, detected, frames: FRAMES, scenes: results };
                    if (window.parent !== window) {
                        window.parent.postMessage({ perfbench: window.result }, "*");
                    }
                };
            }, "stage");
        }
    </script>
</body>

</html>

What we measured

Median ms/frame, Chromium with CPU raster pinned, identical geometry per version:

scene 1.9.2 2.3.0 2.x main
~430 filled polygons (40-80 verts each) 3.3 10.1 (x3.1) 11.2
hairline arc pass 3.6 5.6 (x1.6) 5.9
two-pass polygon paint (fill + stroke) 7.2 27.6 (x3.8) 32.5
  • macOS 26.5 (Apple Silicon, Chrome) shows the same ratios on the same scenes: fills 1.2 -> 3.6 ms (x3.0), polygon paint 2.5 -> 10.6 ms (x4.2).
  • Current main measures ~20% above released 2.3.0 on these scenes: arc()/line()/rect()/... now construct a fresh p5.Shape — including its own PrimitiveShapeCreators Map — per call, which looks like a side effect of the [p5.js 2.0 Bug Report]: Not all primitive shape drawing goes through internal p5.Shape #8277 unification.

Impact — an example

Our case: generative-art editions where the sketch renders on each viewer's machine, so frame budget is the product. Pieces that hold 60 fps on 1.x land around 15-25 fps on 2.x on the same hardware. The same profile applies to any vertex-heavy sketch — plots, maps, particle trails. We profiled where the time goes and believe most of the gap is recoverable without API changes — see fix direction below.

What CPU profiles show

  • The overhead is JS-side geometry emission, not canvas raster.
  • Each vertex() allocates a Vertex via Object.entries() — one entries array per vertex.
  • ...then a LineSegment through a string-keyed creator-map lookup and generic capacity/merge logic — three constructor levels per vertex.
  • The Path2D converter dispatches once per single-vertex segment.
  • Arcs build both fill and stroke Path2Ds even when the current state will draw only one.

Fix Direction

We appreciate the 2.x shape system is a deliberate architectural upgrade, and that 2.1/2.2 already recovered part of this — the following is offered in that spirit.

  • We prototyped fixes for the above locally to confirm the diagnosis — a bounded change (+126/-72 across 3 files: the shape module and the 2D renderer), internal only, no API or rendering-behavior differences — and measured 1.4-2.3x faster on these scenes (within 1.1-1.7x of 1.9.2), with the full unit + visual suites passing and a downstream golden-image suite rendering bit-identical to unpatched 2.3.0.
  • Happy to open a PR with that work if maintainers are interested

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions