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.
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 manoLas 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 dibujadoEse 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 fpsrenderer.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.