Skip to content

Optimize the 2D shape hot path - #9025

Merged
davepagurek merged 5 commits into
processing:mainfrom
okra-sf:perf/shape-hot-path
Jul 30, 2026
Merged

Optimize the 2D shape hot path#9025
davepagurek merged 5 commits into
processing:mainfrom
okra-sf:perf/shape-hot-path

Conversation

@okra-sf

@okra-sf okra-sf commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Resolves #8973

Changes:

Dense 2D paths currently create and visit one LineSegment per vertex. This PR keeps the existing shape architecture, but:

  • batches consecutive path vertices into one LineSegment; closing vertices remain separate, preserving CLOSE behavior;
  • reuses a scratch Shape and a static creator table for 2D primitives instead of rebuilding that setup on every call; and
  • skips fill or stroke Path2D construction for arcs when that path will not be drawn.

Performance against current main at 5491e3c (median ms/frame, Chromium; lower is better):

workload main this PR speedup
filled regions 14.3 8.5 1.7×
hairline arcs 7.4 5.3 1.4×
fill + stroke polygons 39.9 18.5 2.2×
#8973 snippet 6.6 4.2 1.6×

The benchmark report includes the runner, raw repeat runs, and the ablations discussed in #8973. One tradeoff from the ablation: batching by itself costs about 12% on the stroke-only scene. The other changes more than offset it, so the complete branch is 1.4× faster there.

There are no public API changes. New unit tests cover batching boundaries, including CLOSE and non-PATH modes. Locally:

  • 2,028 tests pass. One host-font-dependent typography screenshot fails identically on this branch and unpatched main; 12 tests skip and 213 are todo.
  • 73 2D shape visual cases and 180 WebGL cases pass.
  • Across seven downstream golden-image cases, the branch adds 0 differing pixels relative to unpatched 2.x.

PR Checklist

AI disclosure: I used Claude and Codex for analysis, testing, and review. I reviewed and take responsibility for all changes.

okra-sf added 3 commits July 28, 2026 15:38
Per-vertex costs in the 2.x shape system dominate 2D drawing of dense
paths (3-4x slower than 1.x on fill-heavy scenes). Keep the shape
architecture intact but remove per-vertex overhead:

- Shape.vertex(): fast path that appends consecutive line vertices
  into a single multi-vertex LineSegment on PATH contours, skipping
  the creator-map lookup and generic capacity/merge logic. Closing
  vertices still get their own segment so isClosing semantics are
  unchanged. Batching also amortizes the draw-side per-segment
  visitor dispatch, which measurement shows is the dominant cost on
  dense paths: with every other optimization applied but batching
  disabled, fill-heavy scenes still run 47-71% slower than with it.
- PrimitiveToPath2DConverter/PrimitiveToVerticesConverter iterate a
  segment's vertices, so batched segments render identically.
- Vertex constructor: Object.assign() instead of Object.entries(),
  avoiding a per-vertex entries array allocation.
- Converters take hasFill/hasStroke so invisible paths are skipped.

Adds unit tests covering the batched fast path: accumulation into one
segment, isClosing isolation, and non-PATH contours taking the
general path.
The 2D primitive methods (rect, ellipse, arc, line, ...) built a fresh
p5.Shape per call. Reuse a per-renderer scratch Shape reset between
calls instead, and pass hasFill/hasStroke through to the converter so
arc() does not build Path2D geometry that will never be painted (a
stroke-only arc previously constructed and discarded its fill path,
and vice versa).
Per review on processing#8973: creator construction and lookup should stay fast
since reusable p5.Shape objects are planned. Store creators in a
module-level nested plain object keyed by vertex kind then shape kind,
so a lookup is two property accesses with no key string to build
(previously `vertex-${kind}` spliced per lookup into a Map key), and
nothing is constructed per Shape (previously a Map plus a dozen
closures per instance). Shape kinds are primitive constants, which
work as computed keys; Symbols would too, if constants become Symbols
later. PrimitiveShapeCreators was not exported and only Shape's
default path constructed it, so the class is removed outright.

Benched neutral on 2D scenes (the vertex() fast path already skips
lookups on the hot path); the win is construction cost and simplicity.
@okra-sf

okra-sf commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

@VANSH3104, tagging you as a co-reviewer since this builds on the shape-pipeline work.

@p5-bot

p5-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

@davepagurek davepagurek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this! The changes generally look good. The main question I had is whether we could avoid special casing vertex calls and still get the batching benefits.

class LineSegment extends Segment {
#vertexCapacity = 1;

// Consecutive line vertices on a path batch into a single LineSegment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was the capacity of 1 preventing it from creating a polyline before? addToShape already has code for merging a new shape with a previous one, could we have been relying on that rather than special-casing calls to vertex?

it stays at 1 so the generic path never merges into a LineSegment

What's an example of some user code inside beginShape/endShape that would trigger that if we made this Infinity?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any consecutive vertex() calls would trigger it, for example:

beginShape();
vertex(0, 0);
vertex(20, 0);
vertex(20, 20);
endShape(CLOSE);

With Infinity, the third vertex merges into the existing LineSegment; the closing segment still has to remain separate. I tried that generic path, but it made the issue snippet about 51% slower, so direct batching worth keeping.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that imply that there was a large overhead just in making a new LineSegment before then merging it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(If so, doesn't need to block this PR, but it's maybe worth making another issue for that to clean up the special case introduced here and make the default case faster)

Comment thread src/core/p5.Renderer2D.js Outdated
const visitor = new PrimitiveToPath2DConverter({
strokeWeight: this.states.strokeWeight
strokeWeight: this.states.strokeWeight,
hasFill: !!this.states.fillColor || this._clipping,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remind me how clipping works here? is there a scenario where you can clip to just the fill or just to the stroke that we need to handle?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clipping only uses the base path, so there isn’t a fill-only or stroke-only case here. I’ve updated this to skip building those paths while clipping.

Comment thread src/core/p5.Renderer2D.js Outdated
// line, ...). Each call fully consumes the shape via drawShape() before
// returning, so a single reusable instance per renderer avoids
// rebuilding a Shape (and its vertex-property closures) per call.
_primitiveShape() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This optimization changes the lifetime semantics of the Shape passed to drawShape(). Previously each primitive call created a fresh Shape, whereas now the same scratch Shape is reset and reused. This breaks consumers that retain the Shape beyond the duration of drawShape(). Would it be worth keeping the existing lifecycle and instead seeing what state can be cached statically rather than on the instance, so we preserve the previous semantics while still getting most of the performance benefits?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually it looks like the new caching is on Renderer, so the main cost of doing a new shape is this loop:

    for (const key in this.#vertexProperties) {
      if (key !== 'position' && key !== 'textureCoordinates') {
        this[key] = function(value) {
          this.#vertexProperties[key] = value;
        };
      }
    }

Is that a bottleneck? if not then we could be good already

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes its not a bottleneck

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I rechecked, and fresh Shapes aren’t a bottleneck here. I’ve restored the original lifetime and removed the scratch reuse.

okra-sf and others added 2 commits July 29, 2026 17:57
Renderer hooks may retain the Shape passed to drawShape(), so preserve the existing per-call lifetime.

Clipping uses only the converter base path, so skip building specialized fill and stroke paths there.

@davepagurek davepagurek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your work on this! Let me know what you think about the possibility of cleaning up the special-case added here, we can file another issue/PR for it if that seems possible.

@davepagurek
davepagurek merged commit 9324d91 into processing:main Jul 30, 2026
2 of 10 checks passed
davepagurek added a commit that referenced this pull request Jul 30, 2026
@davepagurek

Copy link
Copy Markdown
Contributor

@all-contributors please add @okra-sf for code

@allcontributors

Copy link
Copy Markdown
Contributor

@davepagurek

I've put up a pull request to add @okra-sf! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

4 participants