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

Type

No type

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions