For motion decided at run time, write the property each frame. THREE.MathUtils.damp(current, target, lambda, delta) eases exponentially toward a target and, using the frame's delta, lands in the same place at any frame rate. The common shortcut, a fixed lerp fraction per frame, does not:
import * as THREE from 'three';
const { damp, lerp } = THREE.MathUtils;
for (const fps of [30, 144]) { // slide a book 0.35 m back in 0.5 s
let smooth = 0.35, naive = 0.35;
for (let frame = 0; frame < fps / 2; frame++) {
smooth = damp(smooth, 0, 8, 1 / fps); // rate 8 per second, scaled by delta
naive = lerp(naive, 0, 0.1); // 10% per frame, whatever the frame rate
}
console.log(`${fps} fps: damp ${smooth.toFixed(4)} m, per-frame lerp ${naive.toFixed(4)} m`);
}Output
30 fps: damp 0.0064 m, per-frame lerp 0.0721 m 144 fps: damp 0.0064 m, per-frame lerp 0.0002 m
For timed tweens with easing and sequencing, the add-ons bundle tween.js 10,147 (three/addons/libs/tween.module.js, 23.1.1; npm 2,036 @tweenjs/tween.js, MIT), and GSAP 88,281 (gsap.com (https://gsap.com 88,281 ), 3.15.0) is free for commercial use with all plugins, barring no-code animation builders that compete with Webflow 2,768 .
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 12px system-ui, sans-serif; color: #263238; }
canvas { display: block; max-width: 100%; }
.ui { padding: 4px 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="go">Slide again</button> Each book slides 0.35 m back toward the shelf in 0.5 s of simulated time.</div>
<script type="module">
import * as THREE from 'three';
const { damp, lerp } = THREE.MathUtils;
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, 2.2, 2.2);
camera.lookAt(0, 0, 0.15);
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 plank = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.04, 1), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
plank.position.set(0, -0.02, 0.2);
scene.add(plank);
// Labels painted onto the plank's front edge
const canvas = Object.assign(document.createElement('canvas'), { width: 1200, height: 120 });
const g = canvas.getContext('2d');
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
const labels = new THREE.Mesh(new THREE.PlaneGeometry(2.4, 0.24), new THREE.MeshBasicMaterial({ map: tex }));
labels.rotation.x = -Math.PI / 2;
labels.position.set(0, 0.001, 0.56);
scene.add(labels);
const runners = [
['damp @ 30 fps', 30, (v, dt) => damp(v, 0, 8, dt), '#1f5f8b'],
['damp @ 144 fps', 144, (v, dt) => damp(v, 0, 8, dt), '#2a9d8f'],
['lerp 10% @ 30 fps', 30, (v) => lerp(v, 0, 0.1), '#e09a10'],
['lerp 10% @ 144 fps', 144, (v) => lerp(v, 0, 0.1), '#b5452f'],
].map(([name, fps, step, color], i) => {
const book = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.5, 0.3), new THREE.MeshStandardMaterial({ color }));
book.position.set((i - 1.5) * 0.55, 0.25, 0.35);
scene.add(book);
return { name, fps, step, book, z: 0.35, frames: 0 };
});
function drawLabels() {
g.fillStyle = '#fff3e0'; g.fillRect(0, 0, 1200, 120);
g.fillStyle = '#263238'; g.font = 'bold 26px sans-serif'; g.textAlign = 'center';
runners.forEach((r, i) => {
g.fillText(r.name, 150 + i * 300, 45);
g.fillText(`${r.z.toFixed(3)} m`, 150 + i * 300, 90);
});
tex.needsUpdate = true;
}
let clock = 0;
function restart() { clock = 0; runners.forEach((r) => { r.z = 0.35; r.frames = 0; }); }
document.getElementById('go').onclick = restart;
const timer = new THREE.Timer();
renderer.setAnimationLoop(() => {
timer.update();
clock = Math.min(clock + timer.getDelta() * 0.5, 0.5); // play at half speed, stop at 0.5 s
// Step each simulation at its own frame rate until it has caught up with the clock
for (const r of runners) {
while (r.frames < Math.floor(clock * r.fps)) { r.z = r.step(r.z, 1 / r.fps); r.frames++; }
r.book.position.z = r.z;
}
drawLabels();
renderer.render(scene, camera);
});
</script>