Skip to content

Batch custom shape draw calls (fills, and borders of additive shapes) - #1044

Open
fa7ad wants to merge 3 commits into
projectM-visualizer:masterfrom
fa7ad:batch-custom-shape-instances
Open

fa7ad wants to merge 3 commits into
projectM-visualizer:masterfrom
fa7ad:batch-custom-shape-instances

Conversation

@fa7ad

@fa7ad fa7ad commented Oct 10, 2026

Copy link
Copy Markdown

Hi, a heads-up before anything else: these changes were written by Claude (LLM/AI), not by me, and I don't know the projectM codebase at all. I've tested them visually and checked the frame rate, but I can't vouch for them beyond that. If you don't take AI-generated contributions, or would rather not review code from someone who can't fully explain it, please feel free to close this outright. No hard feelings.

Background

I'm building a small native macOS frontend for libprojectM 4.x for personal use and experimentation here. These patches came out of chasing slow presets there.

Problem

CustomShape::Draw() makes at least one draw call per instance, and shapes can have up to 1024 instances. On macOS, where OpenGL runs on top of Metal and each draw call is expensive, that cost dominates:

  • Fills: one draw (plus a buffer upload) per instance.
  • Borders: each border flushes the fill, then draws once per outline pass (4 passes with thickOutline), with a buffer upload each time. A 1024-instance thick-outlined shape ends up at about 5,000 draws per frame.

Changes

  1. Fills (835a16e): consecutive instances that share a blend mode and texturing go into one indexed triangle list and are drawn with one call. Borders still flush the batch first, so the draw order doesn't change.
  2. Borders of additive instances (3ced1dd): additive blending gives the same result in any order, so these borders, including the 4 thick-outline offsets, go into a line list drawn right after their fills. Non-additive borders keep the exact per-instance order.

Results (Apple Silicon, macOS):

  • A fill-heavy preset went from about 15 to about 58 fps.
  • suksma - 3125 bad attempts2*.milk (1024 instances, thick additive borders) went from about 5 to 60 fps.
  • I saw no visual difference, but I only compared by eye.

Things I'm unsure about

  • Order changes for additive borders: the result should be identical up to floating-point rounding, and with an 8-bit target, clamping at each step gives the same result.
  • The outline batch uses GL_LINES with GL_LINE_SMOOTH, like the existing per-instance GL_LINE_LOOP. I haven't checked the GLES builds.
  • I have no idea whether this fits how you'd want the renderer structured.

Thanks for projectM!

@kblaschke

Copy link
Copy Markdown
Member

Oh, the shape draw call count, yes, this issue was something we'd have to get into.

From a first glance, the change looks okay to me. If draw order, blending and texturing are properly honoured, it shouldn't result in any visual differences.

There is an issue with the code though: It uses C++17 features (std::optional), which we cannot use because libprojectM is built with C++14 to improve compatibility. This has to be replaced with a C++14-compatible implementation before this can be properly tested.

@kblaschke kblaschke left a comment

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.

Code must compile with C++14, but uses C++17 features.

@fa7ad

fa7ad commented Oct 16, 2026

Copy link
Copy Markdown
Author

Thanks for taking a look, I'll push an update targeting C++14 when I get some time (probably over the weekend) :)

fa7ad and others added 3 commits October 16, 2026 12:27
CustomShape::Draw drew every instance with its own shader/uniform setup, buffer
upload and glDrawElements. A shape can have up to 1024 instances, and drivers
with a high per-draw cost (macOS's OpenGL-over-Metal layer, which starts a new
render pass per draw) spend most of the frame on it: a preset with 1300
instances ran at 15 fps on an M-series Mac.

Collect the fills of consecutive instances that share a blend mode and
texturing into one indexed triangle list and draw them together. Borders are
still drawn per instance, after flushing the batch, so the draw order (and the
output) is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A border forced a flush of the fill batch, then one draw per outline pass
(four with thickOutline), each with its own buffer upload, so a 1024-instance
shape with borders still made ~5000 draw calls per frame. Additive blending is
order-independent, so borders of additive instances are now collected as a
line list and drawn right after their fills. Non-additive borders keep the
exact per-instance order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fa7ad
fa7ad force-pushed the batch-custom-shape-instances branch from 194cf2b to 869913d Compare October 16, 2026 10:57

This branch has not been deployed

No deployments
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.

2 participants