<!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>
Most appropriate sub-area of p5.js?
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:
Snippet (minimal repro):
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 fromfile://). 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
What we measured
Median ms/frame, Chromium with CPU raster pinned, identical geometry per version:
mainmeasures ~20% above released 2.3.0 on these scenes:arc()/line()/rect()/... now construct a freshp5.Shape— including its ownPrimitiveShapeCreatorsMap — 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
vertex()allocates aVertexviaObject.entries()— one entries array per vertex.LineSegmentthrough a string-keyed creator-map lookup and generic capacity/merge logic — three constructor levels per vertex.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.