wandres.dev
CONTROLES · Orbit, fly y los propios

OrbitControls y el damping: por qué hay que llamar a update()

Qué hace exactamente OrbitControls en cada frame, por qué activar la inercia convierte update() en obligatorio, y cómo renderizar solo cuando la cámara se mueve.

⏱ 18 min

OrbitControls es el primer addon que instala todo el mundo y el que más veces se usa sin entender. La documentación dice que si activas enableDamping tienes que llamar a update() en el bucle, y casi nadie sabe por qué: parece una arbitrariedad de la librería. No lo es. La razón está en cuatro líneas de su método update(), y entenderlas explica de paso cómo hacer que la escena solo se redibuje cuando algo se mueve.

🎯 Al terminar esta lección sabrás
  • Describir el modelo interno de OrbitControls: coordenadas esféricas, delta pendiente y objetivo.
  • Explicar por qué sin damping el control funciona sin bucle y con damping no.
  • Implementar render bajo demanda usando el valor de retorno de update() y el evento change.
  • Corregir la dependencia del damping respecto a la frecuencia de refresco.

El modelo interno

OrbitControls extiende la clase base Controls y no mueve la cámara directamente: mantiene un estado en coordenadas esféricas alrededor de un punto objetivo y lo aplica cuando le dices que actualice.

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const controls = new OrbitControls( camera, renderer.domElement );
controls.target.set( 0, 1, 0 );
controls.update();   // obligatorio tras mover target o la cámara a mano

Las piezas del estado son cuatro. target es el punto alrededor del que orbita, y es una propiedad pública que puedes mover cuando quieras. El estado esférico interno guarda radio, ángulo polar y ángulo acimutal, deducidos en cada update() a partir de la posición actual de la cámara respecto a target. Y hay dos acumuladores de cambio pendiente: uno esférico, para la rotación, y un desplazamiento, para el paneo. Los manejadores de puntero no tocan la cámara: solo suman a esos acumuladores y llaman a update().

El constructor conecta los eventos si le pasas un elemento DOM. Si le pasas null, tienes que llamar a connect( element ) tú. En r184 ese método exige el argumento: llamarlo sin él emite un aviso de obsolescencia y no hace nada, y en r185 desaparece el aviso junto con el comportamiento.

Al final, siempre, dispose(). Un OrbitControls que no se desconecta deja escuchadores de puntero, de rueda y de teclado colgando del documento, y en una aplicación con navegación cliente eso es una fuga garantizada.

Por qué el damping cambia las reglas

Este es el núcleo de la lección. El método update() de r184 hace, con la inercia desactivada:

// enableDamping === false
_spherical.theta += _sphericalDelta.theta;
_spherical.phi += _sphericalDelta.phi;
target.add( _panOffset );
// ...aplica el resultado a la cámara...
_sphericalDelta.set( 0, 0, 0 );
_panOffset.set( 0, 0, 0 );

El delta pendiente se aplica entero y se vacía. Y como el manejador de pointermove llama a update() él mismo justo después de acumular, resulta que sin damping el control funciona perfectamente aunque tú no llames a update() nunca: cada movimiento del ratón produce su propia actualización completa. Por eso mucha gente monta su primera escena, arrastra el ratón, ve que funciona, y concluye que la advertencia de la documentación es opcional.

Con la inercia activada, el mismo bloque es otro:

// enableDamping === true
_spherical.theta += _sphericalDelta.theta * dampingFactor;
_spherical.phi += _sphericalDelta.phi * dampingFactor;
target.addScaledVector( _panOffset, dampingFactor );
// ...aplica el resultado a la cámara...
_sphericalDelta.theta *= ( 1 - dampingFactor );
_sphericalDelta.phi *= ( 1 - dampingFactor );
_panOffset.multiplyScalar( 1 - dampingFactor );

Ahora cada llamada consume solo una fracción del delta —el cinco por ciento con el dampingFactor por defecto— y deja el noventa y cinco restante para la siguiente. Esa es exactamente la sensación de peso: la cámara sigue moviéndose después de soltar, porque queda delta pendiente. Y ahí está la razón de la obligación: el delta pendiente solo se consume cuando alguien llama a update(). Si tu única llamada es la que hace el manejador de puntero, en cuanto sueltes el ratón nadie vuelve a llamar, el noventa y cinco por ciento restante se queda ahí congelado, y la cámara se para en seco. El síntoma es característico y desconcertante: el control “funciona” mientras arrastras y da un pequeño tirón al soltar.

const controls = new OrbitControls( camera, renderer.domElement );
controls.enableDamping = true;
controls.dampingFactor = 0.05;

renderer.setAnimationLoop( () => {
  controls.update();          // sin esta línea, la inercia no existe
  renderer.render( scene, camera );
} );

Lo mismo vale para autoRotate: la rotación automática se aplica dentro de update(), así que sin bucle no gira nada.

Renderizar solo cuando algo se mueve

update() devuelve un booleano: true si la cámara se ha movido lo suficiente para que merezca la pena redibujar, false si no. El umbral es interno y compara desplazamiento y rotación contra un epsilon. Ese valor de retorno es la base del render bajo demanda, que en una escena estática es la diferencia entre gastar batería continuamente y no gastarla.

let hayQueDibujar = true;

controls.addEventListener( 'change', () => { hayQueDibujar = true; } );

renderer.setAnimationLoop( () => {
  if ( controls.enableDamping ) {
    hayQueDibujar = controls.update() || hayQueDibujar;
  }
  if ( hayQueDibujar ) {
    renderer.render( scene, camera );
    hayQueDibujar = false;
  }
} );

Si no usas damping, puedes ir más lejos y prescindir del bucle por completo, porque el evento change se dispara exactamente cuando la cámara cambia:

controls.enableDamping = false;
controls.addEventListener( 'change', () => renderer.render( scene, camera ) );
renderer.render( scene, camera );   // primer dibujado

Ese patrón deja el hilo principal a cero cuando el usuario no interactúa. En un visor de producto embebido en una página con más contenido, es la diferencia entre un ventilador encendido y una página que no se nota. El precio es que pierdes la inercia, y hay que decidir cuál de las dos cosas vale más en tu caso.

Los otros dos eventos son start y end, que marcan el principio y el final de una interacción. Son el sitio correcto para bajar la calidad mientras el usuario arrastra: reducir el ratio de píxeles, apagar el post-procesado, congelar las animaciones caras, y restaurarlo al soltar.

controls.addEventListener( 'start', () => renderer.setPixelRatio( 1 ) );
controls.addEventListener( 'end', () => renderer.setPixelRatio( Math.min( devicePixelRatio, 2 ) ) );

El damping no es independiente del framerate

Aquí hay un detalle incómodo que conviene saber antes de que te lo diga un usuario con un monitor de 240 hercios. dampingFactor se aplica por llamada a update(), no por unidad de tiempo. En un portátil a 60 fotogramas por segundo, después de un segundo queda (1 − 0.05)^60, alrededor del cuatro por ciento del delta original. En un monitor a 120, queda (1 − 0.05)^120, algo menos del dos por mil. La misma configuración produce una inercia perceptiblemente más corta y más seca en la máquina rápida.

autoRotate sí tiene salida: update() acepta un delta de tiempo en segundos y, si se lo pasas, calcula la rotación automática por tiempo en lugar de por frame.

const reloj = new THREE.Timer();
reloj.connect( document );

renderer.setAnimationLoop( ( tiempo ) => {
  reloj.update( tiempo );
  controls.update( reloj.getDelta() );   // autoRotate ya no depende del framerate
  renderer.render( scene, camera );
} );

Pero ese argumento no afecta al damping, que sigue usando dampingFactor tal cual. Si necesitas una inercia consistente, la corrección es la fórmula estándar del decaimiento exponencial: convertir un factor definido a una frecuencia de referencia en el factor equivalente para el delta real.

const reloj = new THREE.Timer();
reloj.connect( document );
const FACTOR_BASE = 0.05;   // el que quieres a 60 fps

renderer.setAnimationLoop( ( tiempo ) => {
  reloj.update( tiempo );
  const delta = Math.min( reloj.getDelta(), 0.1 );   // acota los frames sueltos muy lentos
  controls.dampingFactor = 1 - Math.pow( 1 - FACTOR_BASE, delta * 60 );
  controls.update( delta );
  renderer.render( scene, camera );
} );

El acotado del delta no es opcional. Cuando el usuario cambia de pestaña y vuelve, el primer delta puede valer varios segundos, y sin límite el exponente dispara el factor a uno: la cámara salta al destino de golpe. Es un bug clásico que solo aparece en producción.

El damping no es un efecto: es un filtro paso bajo, y por eso arregla el ruido del ratón

Lo que hace enableDamping es, formalmente, un filtro exponencial de primer orden sobre la señal de entrada del puntero, y verlo así cambia cuándo decides usarlo. La entrada de un ratón o de un dedo no es una curva suave: es una sucesión de eventos discretos con jitter, con intervalos irregulares, y con una resolución que en pantallas táctiles es de varios píxeles. Aplicada directamente a la cámara, esa señal produce una imagen que tiembla, y el temblor es mucho más visible en 3D que en 2D porque afecta a la escena entera y no a un cursor. El damping filtra ese ruido, y esa es la razón de que la sensación de “calidad” que aporta sea desproporcionada respecto a lo que cuesta. Pero como todo filtro paso bajo, introduce latencia, y la latencia en una interacción directa se percibe como falta de respuesta. Ahí está el compromiso real, y no es estético: con dampingFactor alto —de 0,2 hacia arriba— filtras poco y respondes rápido, que es lo correcto cuando el usuario está apuntando a algo concreto y necesita precisión; con dampingFactor bajo —0,03 o menos— filtras mucho y respondes tarde, que es lo correcto para una cámara contemplativa o para una presentación. El error es elegirlo por cómo se siente arrastrando en tu escritorio y no por lo que el usuario va a hacer con la escena. Y hay un corolario que casi nadie aplica: puedes cambiar dampingFactor en caliente. Subirlo en el evento start y bajarlo en end te da un control preciso mientras el usuario arrastra y una parada elegante cuando suelta, que es exactamente lo que hacen los visores comerciales que se sienten mejor que el tuyo.

⚔️ Diagnostica la inercia
  1. Activa enableDamping sin llamar a update() en el bucle y describe con precisión el síntoma al soltar el ratón.
  2. Implementa el render bajo demanda con el valor de retorno de update() y comprueba en el panel de rendimiento que el hilo principal queda a cero en reposo.
  3. Baja el ratio de píxeles en start y restáuralo en end, y mide la diferencia de fotogramas por segundo durante el arrastre.
  4. Fuerza el bucle a 30 fotogramas por segundo y compara la duración de la inercia con la de 60.
  5. Aplica la corrección exponencial de dampingFactor y verifica que la duración deja de depender del framerate.