Build1 publisherNot yet confirmed elsewhere3 min readPublished
Your idle Three.js scene is not too heavy, it is redrawing 60 times a second for nothing
A developer's phone ran hot on a motionless game board. The cause was the unconditional requestAnimationFrame loop every tutorial ships, and a dirty flag fixes it without a rewrite.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- The author writes on dev.to that he has several small games on his site, including Ludo, tic-tac-toe, carrom and rock-paper-scissors, built with Three.js, the standard library for drawing 3D graphics in a web page.
- Testing the games on his iPhone, the author found the phone got genuinely hot to hold while he was sitting on the Ludo board waiting for his turn, with nothing on screen moving.
- The author's first instinct was that Three.js might be too heavy for these small games and that he should rip it out and draw everything with plain Canvas; he calls that instinct completely backwards.
- The render loop every Three.js tutorial hands you is: function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate();
- requestAnimationFrame (rAF) is a browser function meaning run this again right before you paint the next frame; browsers repaint the screen about 60 times a second, so anything handed to rAF runs about 60 times a second.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A developer writing on dev.to noticed his iPhone got hot to hold while he sat on a static Ludo board in one of his own Three.js games, waiting for his turn, with nothing on screen moving [5][6]. His first instinct, which he flags as the wrong one, was that Three.js is too heavy for small games and should be replaced with plain Canvas drawing [7]. That instinct costs you a rewrite and buys nothing, because the renderer was never the variable.
Here is the loop every Three.js tutorial hands you [8]:
```js function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); } animate(); ```
Two things are load bearing. `requestAnimationFrame` schedules a callback right before the browser paints the next frame, and browsers repaint roughly 60 times a second, so the callback runs roughly 60 times a second [9]. And `renderer.render` clears the framebuffer and redraws every object in the scene from scratch [10]. The GPU has no concept of your scene being "basically static": a motionless board at 60fps costs exactly what a board mid-animation costs, because it is doing identical work [11]. Sixty renders a second is 3,600 full-scene redraws per minute of you staring at a menu [16]; ten seconds of deciding a move is 600 of them [17].
Switching to Canvas2D does not help. According to the same post, a 60fps loop repainting a full-screen 2D canvas heats a phone just as happily [12]. The loop is the defect, not the library.
The guard is a dirty flag: a boolean you flip when something on screen actually changes, checked before you draw [13].
```js let needsRender = true; function invalidate() { needsRender = true; }
function animate() { requestAnimationFrame(animate); const moving = tweens.update() || particles.update(); if (needsRender || moving) { renderer.render(scene, camera); needsRender = false; } } animate(); ```
The rAF loop keeps ticking; only the expensive call is conditional. You call `invalidate()` from input handlers, animations and the resize listener, anywhere a pixel genuinely changes [14]. A board waiting on input then renders zero frames [1], and a turn-based game spends almost its entire life waiting [1]. When something is animating, every frame pays for a real render again, which is the behaviour you wanted all along [2]. If you are on @react-three/fiber, this is one prop: `frameloop="demand"` on the `<Canvas>`, plus `invalidate()` calls [15].
Two caveats on the evidence. The post demonstrates the difference with a side-by-side pair of simulated phones, one climbing hot and staying there, one sitting cold until tapped, and does not publish measured temperature or power numbers [3]. And the text breaks off mid-sentence while starting a second argument, that the frames you do draw each cost about twice something [4], so the per-frame half of the optimisation is not verifiable from what was published.
What to watch: grep your own projects for unconditional `renderer.render` inside a rAF callback, which is most of them if you started from a tutorial. Then check the coverage of your `invalidate()` calls, because the failure mode of render-on-demand is a frame that should have been drawn and was not: a stale board after a resize, or a hover state that never paints. That bug is visible and fixable. A hot phone in a user's hand is neither.