Interaction

Camera controls

When you use OrbitControls, you must configure all of the following:

  • Damping on: controls.enableDamping = true, with controls.update() called every frame.
  • Clamped zoom: set minDistance and maxDistance. 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 maxPolarAngle slightly 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-motion and switch off entirely when it is set.

The frame loop

  • Per-frame updates (motion, springs, controls.update()) must run in a rAF loop that mutates Object3D transforms directly. The UI framework re-renders only when Config changes; do not trigger framework state updates from the frame loop (React setState, 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.

Esc
Search all docs · to navigate · to open