Interaction
Camera controls
When you use OrbitControls, you must configure all of the following:
- Damping on:
controls.enableDamping = true, withcontrols.update()called every frame. - Clamped zoom: set
minDistanceandmaxDistance. 1.2×R to 8×R (R being the model's bounding radius) is the recommended range — it keeps the camera from entering the model or drifting out to nothing. - Clamped pitch: set
maxPolarAngleslightly under π (0.95π works), so the camera never tips under the model. - Opening shot: about 3.2×R out, tilted slightly down — the whole model visible at the default angle, and reading as a solid.
Wheel events
If you take over zoom yourself, you must attach a native non-passive listener:
element.addEventListener('wheel', handler, { passive: false })
and call preventDefault() in the handler. Framework-level wheel bindings are usually passive and cannot stop the page's default scroll (React's onWheel, Vue's @wheel without a passive opt-out), so zooming inside the 3D area scrolls the whole page with it. The wheel handling built into OrbitControls already satisfies this.
Ambient motion
- You should add an idle float: a Lissajous sway on frequencies that are not integer multiples of each other, so there is no perceptible loop point. Keep the amplitude slight.
- You should drive drag through an under-damped spring rather than mapping pointer travel 1:1 onto rotation. Aim for the behaviour, not a number: releasing produces one visible overshoot and settles in roughly 0.3s. (Stiffness 70 / damping 5.5 lands there in one particular per-frame integrator — the values only mean something alongside the formula you use, so tune to the behaviour.)
- Ambient-motion transforms must live on a persistent parent node (the rig) that geometry rebuilds never touch (see Rebuild on change), so motion stays continuous while parameters change.
- Every animation must honour
prefers-reduced-motionand switch off entirely when it is set.
The frame loop
- Per-frame updates (motion, springs,
controls.update()) must run in a rAF loop that mutatesObject3Dtransforms directly. The UI framework re-renders only when Config changes; do not trigger framework state updates from the frame loop (ReactsetState, Vue reactive assignment, Svelte store writes — all of them re-render 60 times a second and drop frames). - Note that rAF pauses while the tab is hidden (
document.visibilityState === 'hidden'), so every animated value freezes at its last frame. Anything that reads those values — an automated check, a debugging session — has to bring the tab to the foreground first, or it reads stale numbers and concludes the motion is broken.
Text selection
The 3D stage container must set user-select: none, so drag-to-rotate does not select page text.