Skip to content

perf(particles): compute particle bounds lazily - #1607

Merged
obiot merged 4 commits into
masterfrom
particle-lazy-bounds
Aug 28, 2026
Merged

perf(particles): compute particle bounds lazily#1607
obiot merged 4 commits into
masterfrom
particle-lazy-bounds

Conversation

@obiot

@obiot obiot commented Aug 28, 2026

Copy link
Copy Markdown
Member

Particles recomputed their bounding box twice per frame, from a stale transform, and threw both results away. Fixing that is worth about a third of the particle update loop.

The bug

Renderable refreshes bounds from a callback fired on every pos assignment:

this.pos = new ObservableVector3d(x, y, 0, () => {
    this.updateBounds();
    this.isDirty = true;
});

That is the right trade for a scene object. For a particle it is the wrong one three times over — Particle.update does:

this.pos.x += this.vel.x * skew;    // fire 1 -> full updateBounds()
this.pos.y += this.vel.y * skew;    // fire 2 -> full updateBounds()
...currentTransform.setTransform(...)          // matrix rebuilt AFTER both
if (this.accurateBounds) this.updateBounds();  // fire 3
  1. it runs twice, once per component
  2. both runs are stale — they fire before currentTransform is rebuilt, so they derive bounds from the previous frame's matrix
  3. accurateBounds: true makes it three

So the setting meant to be cheap did the work twice, and the setting meant to be accurate did it three times. Neither did it once.

The fix

A particle swaps its own position callback for one that just marks the bounds stale, and getBounds() recomputes if needed. A particle nothing looks at costs nothing, and the bounds a reader gets are current rather than a frame behind.

Contained entirely to particle.ts. No Renderable change, so #817 stays untouched — the one setCallback line needs a local cast for exactly that reason, with a comment pointing at the ticket.

Measurements

WebGL, burst emitter, every particle on screen and verified actually drawn (the harness counts drawImage calls — an early version of it drove emitter.update() directly, which skips the visibility pass, leaving every particle culled and reporting draw cost as ~0 regardless of count).

before after ceiling @16.7ms
20 000 particles 20.1 ms 14.9 ms ~16,500 -> ~22,000
5 000 @ 4x CPU throttle 19.8 ms 14.5 ms ~4,200 -> ~5,700

Roughly a third off the update loop, and the same relative gain throttled — which is the case that counts. The absolute numbers come from a Mac Studio and are optimistic for real players; at 4x throttle the practical ceiling was ~1,400 particles before this, which games do reach.

accurateBounds is deprecated

It existed to trade hitbox accuracy for speed. There is no longer a trade: bounds are always current and cost one refresh per read. Still accepted, now inert, documented as such.

Two things for whoever touches this next

updateBounds() keeps its eager contract deliberately. Container.updateBounds aggregates child bounds through its return value under enableChildBoundsUpdate (container.js:715), so deferring there would have fed it stale data. That is why the callback, not the method, is the lever — and there is a test for it.

The dirty flag is cleared at the TOP of updateBounds(). getBounds() calls updateBounds(), which calls getBounds(); clearing late recurses until the stack blows. The mutation run confirms it — that ordering change takes all 10 tests down with a stack overflow.

And the flag is not a #private field. updateBounds() is reached from the base constructor chain via Polygon.setVertices, before a subclass's field initializers exist, and writing an undeclared private field throws.

Tests

12 new in tests/particle-bounds.spec.js, covering both halves — that the laziness is real, and that nothing reading bounds can tell the difference. Includes the enableChildBoundsUpdate aggregation path, pooled-particle callback reinstallation, culling correctness, and the construction-ordering case.

Three mutations, each breaking its own tests: restoring the eager callback (5 failures), getBounds() no longer refreshing (3), and not clearing the flag first (10, stack overflow).

Full suite 6341 passing / 263 files, root lint 0 errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N

A `Renderable` refreshes its bounds from a callback fired on every `pos`
assignment. That is the right trade for a scene object and the wrong one for a
particle: `Particle.update` writes `pos.x` and `pos.y` separately, so the
callback fired TWICE per particle per frame — and both runs happened before
`currentTransform` was rebuilt, deriving bounds from the previous frame's
matrix and then discarding the result. `accurateBounds: true`, which exists to
buy an accurate hitbox, added a third pass on top. So the cheap setting did the
work twice and the accurate one did it three times; neither did it once.

A particle now swaps its own position callback for one that marks the bounds
stale, and recomputes on read. A particle nothing looks at costs nothing, and
the bounds a reader gets are current rather than a frame behind.

Measured on WebGL with a burst emitter, every particle on screen and verified
actually drawn:

    20,000 particles   20.1ms -> 14.9ms   ceiling ~16,500 -> ~22,000
    4x CPU throttle    19.8ms -> 14.5ms   ceiling  ~4,200 ->  ~5,700

About a third off the update loop, and the same relative gain on throttled
hardware — which is the case that matters, since the absolute numbers above
come from a Mac Studio and are optimistic for anyone's players.

`accurateBounds` is deprecated as a result: it existed to trade hitbox accuracy
for speed, and there is no longer a trade to make. Still accepted, now inert.

Two things worth knowing for anyone touching this again. `updateBounds()` keeps
its eager contract because `Container.updateBounds` aggregates child bounds
through its RETURN value under `enableChildBoundsUpdate` — deferring there
would feed it stale data, which is why the callback rather than the method is
the lever. And the dirty flag is cleared at the TOP of `updateBounds()`:
`getBounds()` calls it, and it calls `getBounds()`, so clearing late recurses
until the stack blows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI lite review requested due to automatic review settings August 28, 2026 02:34

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI review requested due to automatic review settings August 28, 2026 06:09

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

None of those three entries were API changes. Two were consequences of what
the release ADDED — a scene that already set one of the six modes now renders
it — and the third records that 3D mesh rendering is UNCHANGED, which is the
opposite of a change.

The two substantive caveats, the per-draw capture and composite cost and the
drawMesh fallback, move onto the Added entry they belong to. `### Changed` is
for user-facing API changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
Copilot AI review requested due to automatic review settings August 28, 2026 06:15

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 28, 2026 06:40

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit 441a392 into master Aug 28, 2026
8 of 9 checks passed
@obiot
obiot deleted the particle-lazy-bounds branch August 28, 2026 06:52
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