setAnimationLoop(callback) calls callback(time) before each display refresh, uses the headset's frame loop in WebXR (WebXR), and stops on null. Move things by elapsed time, as Import Maps did, or a 120 Hz screen spins twice as fast. For deltas, THREE.Timer replaced the deprecated Clock in r183; timer.connect(document) avoids a huge delta when a hidden tab returns. With a timer and the shelf:
let frames = 0;
renderer.setAnimationLoop(() => {
timer.update();
shelf.rotation.y += 0.5 * timer.getDelta();
renderer.render(scene, camera);
if (++frames < 120) return;
renderer.setAnimationLoop(null);
const angle = shelf.rotation.y.toFixed(2);
console.log(frames, 'frames,', timer.getElapsed().toFixed(2), 's, angle', angle);
});Output
120 frames, 2.04 s, angle 1.02
A scene that changes only on input can skip the loop and call render() from its event handlers.
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; }
canvas { display: block; max-width: 100%; }
.ui { padding: 6px 8px; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div class="ui"><button id="toggle">Stop</button> <span id="out"></span></div>
<script type="module">
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 280);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(35, 600 / 280, 0.1, 50);
camera.position.set(0, 1.2, 4.2);
camera.lookAt(0, 0, 0);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1.2));
const sun = new THREE.DirectionalLight('#ffffff', 2.5);
sun.position.set(2, 3, 4);
scene.add(sun);
const shelf = new THREE.Group();
const plank = new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.06, 0.4), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
plank.position.y = -0.52;
shelf.add(plank);
['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'].forEach((color, i) => {
const book = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.98, 0.12), new THREE.MeshStandardMaterial({ color }));
book.position.x = (i - 2.5) * 0.5;
shelf.add(book);
});
scene.add(shelf);
// THREE.Timer (r183+) measures deltas; connect(document) ignores time spent in a hidden tab
const timer = new THREE.Timer();
timer.connect(document);
let frames = 0, running = true;
const out = document.getElementById('out');
function loop() {
timer.update();
shelf.rotation.y += 0.5 * timer.getDelta(); // 0.5 rad per second at any refresh rate
renderer.render(scene, camera);
if (++frames % 20 === 0) {
out.textContent = `${frames} frames, ${timer.getElapsed().toFixed(2)} s, angle ${shelf.rotation.y.toFixed(2)} rad`;
}
}
renderer.setAnimationLoop(loop);
document.getElementById('toggle').addEventListener('click', (e) => {
running = !running;
renderer.setAnimationLoop(running ? loop : null); // null stops the loop
if (running) timer.reset(); // no jump after a pause
e.target.textContent = running ? 'Stop' : 'Start';
});
</script>