Render on demand instead of every frame - #441
Draft
sophiedeziel wants to merge 3 commits into
Draft
Conversation
- 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
- 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
marked this pull request as draft
September 2, 2026 05:40
- 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #354
What
SceneManagerused to re-arm arequestAnimationFrameloop 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:
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.changeevent, so dragging still renders at full rate. The listener is re-bound when the orthographic swap replaces the controls instance.resize(),clear(), andrenderProgressive()while a stream is being read.render()path, unchanged.controls.update()at construction.enableDampingandautoRotateare both off in this project, so nothing needs a per-frameupdate()and nothing self-animates.renderAnimated()/renderFrameLoopkeep 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 publicanimate()stays for compatibility as a@deprecatedalias: it delegates torequestRender(), 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
on-demand renderingsuite: constructor schedules exactly one initial frame, idle draws nothing, controlschangetriggers a frame, bursts coalesce into a single draw, every visual setter requests a frame, the orthographic swap keeps camera movement rendering,renderProgressive/clearpresent their results, and a geometry rebuild with no job presents the emptied scene.dispose()cancelling a pending frame.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.
Caveats, in the interest of honesty:
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