Skip to content

Render on demand instead of every frame - #441

Draft
sophiedeziel wants to merge 3 commits into
developfrom
fix-issue-354
Draft

Render on demand instead of every frame#441
sophiedeziel wants to merge 3 commits into
developfrom
fix-issue-354

Conversation

@sophiedeziel

@sophiedeziel sophiedeziel commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Closes #354

What

SceneManager used to re-arm a requestAnimationFrame loop from its constructor and redraw the scene on every display frame, whether or not anything changed. On the 3.5 MB 3DBenchy that cost ~0.9 ms CPU per frame, 804k triangles and 34 draw calls — roughly 54 ms/second of main-thread time and ~48M triangles/second to redraw a still image for as long as the tab stayed open.

This replaces the perpetual loop with on-demand rendering:

  • A public requestRender() schedules a single frame on the next animation frame and coalesces any burst of changes into one draw. An idle scene now draws zero frames.
  • Camera movement requests frames through the OrbitControls change event, so dragging still renders at full rate. The listener is re-bound when the orthographic swap replaces the controls instance.
  • Every code path that changes the picture in place now requests a frame: background / extrusion / travel / bounding-box colors, build volume, start/end layer, single-layer mode, extrusion/travel visibility, ambient/directional light, brightness, resize(), clear(), and renderProgressive() while a stream is being read.
  • Changes that rebuild geometry (line width/height, extrusion width, tubes/lines swap, top-layer and last-segment highlights, gradient toggle) keep drawing synchronously through the existing render() path, unchanged.
  • The camera is aimed at its target with a single controls.update() at construction. enableDamping and autoRotate are both off in this project, so nothing needs a per-frame update() and nothing self-animates.
  • renderAnimated() / renderFrameLoop keep their own legitimate frame loop while geometry streams in; it still terminates when the last path is drawn.

Behavior notes

  • requestRender() is the new supported way to present externally-made scene changes. The public animate() stays for compatibility as a @deprecated alias: it delegates to requestRender(), so one call schedules (at most) one coalesced frame and it no longer re-arms a continuous loop.
  • onFrameRendered — and the dev-mode stats FPS panel it feeds — now counts renders per second: it reads 0 while the scene is idle instead of the display refresh rate. Documented on both hooks.

Tests

  • New on-demand rendering suite: constructor schedules exactly one initial frame, idle draws nothing, controls change triggers a frame, bursts coalesce into a single draw, every visual setter requests a frame, the orthographic swap keeps camera movement rendering, renderProgressive/clear present their results, and a geometry rebuild with no job presents the emptied scene.
  • Mock-based tests cover coalescing, going idle after one frame, and dispose() cancelling a pending frame.
  • Full suite: 825 tests passing, 100% statements/branches/functions/lines coverage, typecheck and lint clean.

Measurements

Demo app with 3DBenchy.gcode (3.7 MB), fresh rollup builds of both branches, Chrome 152 on Apple Silicon. DevTools performance traces over a strictly idle 10 s window after the load fully settled, with the dev GUI panels hidden.

Metric (10 s idle) develop @ 91a063a PR @ 43598fc
renderer frames 596–600 (~60 fps) 0
main-thread busy 908 ms (9.1%) 190 ms (1.9%)
— of which FireAnimationFrame 797 ms 147 ms
triangles per rendered frame 753,448 753,448
draw calls per rendered frame 38–48 38
total frames, load → steady state 1,249+ 69–71

Caveats, in the interest of honesty:

  • The residual 190 ms on the PR build is the demo's devMode lil-gui rAF polling, which is identical in both versions — the renderer itself contributes 0 frames.
  • Draw calls varied 38↔48 across develop loads at a constant triangle count because streamed progressive loading batches meshes nondeterministically. Triangles per frame are bit-identical, so what is drawn is unchanged — only how often.

Sanity checks: a 30-event orbit drag rendered exactly 30 coalesced frames and then returned to 0 idle; a forced requestRender() advanced the frame counter by exactly 1; before/after screenshots are visually identical.

Out of scope

The draw-call consolidation noted under "Related" in #354 (progressive rendering fragmenting the scene into ~61 chunk groups) is deliberately not part of this PR.

Assisted by Claude Code - Fable 5

- Replace the perpetual requestAnimationFrame loop in SceneManager with a
  public requestRender() that coalesces a burst of changes into a single
  frame; an idle scene now draws zero frames
- Request a frame from the OrbitControls 'change' event, so dragging still
  renders at full rate; the listener is re-bound when the orthographic
  swap replaces the controls
- Request a frame from every in-place visual mutation: background, extrusion,
  travel and bounding-box colors, build volume, layer range, single-layer
  mode, visibility toggles, lights/brightness, resize, clear and progressive
  streaming; geometry rebuilds keep drawing through the existing sync render
- Aim the camera with a single controls.update() at construction; damping and
  auto-rotate are off, so no per-frame update is needed
- Document that onFrameRendered (and the dev-mode stats FPS panel) now counts
  renders per second, reading 0 while idle

Closes #354
@sophiedeziel sophiedeziel added enhancement Improvement to an existing feature performance Performance improvements or optimizations labels Sep 2, 2026
- Register the orthographic swap's replacement controls in the disposables,
  swapping out the hand-disposed old entry, so dispose() tears down the live
  controls (and their change listener) instead of leaving them undead
- Guard requestRender() with a disposed flag set by dispose(), so a leftover
  listener or late setter can never schedule a frame on the disposed renderer
- Cover both with tests: the swap replaces the disposables entry, and change
  events after dispose draw nothing
@sophiedeziel
sophiedeziel marked this pull request as draft September 2, 2026 05:40
@sophiedeziel sophiedeziel added the marinating Left to simmer — needs more thought before acting label Sep 2, 2026
- Renaming the public animate() away outright was a breaking API change;
  external callers of the old loop entry point keep working
- It delegates to requestRender(): one call schedules (at most) one
  coalesced frame, and it no longer re-arms itself
- Tagged @deprecated pointing at requestRender(), pinned back in the
  public API surface test, and covered by an alias test
@sophiedeziel sophiedeziel added the 3.1+ Targeted for the 3.1 release or later label Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3.1+ Targeted for the 3.1 release or later enhancement Improvement to an existing feature marinating Left to simmer — needs more thought before acting performance Performance improvements or optimizations

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render on demand instead of every frame

1 participant