Saltar al contenido

Delta time: el primer objeto girando de verdad

Por qué sumar una cantidad fija por fotograma está mal, cómo se mide el tiempo con la clase Timer de r184, y qué hacer con los saltos de tiempo al volver a una pestaña.

Por segundo, no por fotograma

malla.rotation.y += 0.01 significa «un centésimo de radián por fotograma». Como el número de fotogramas por segundo lo decide el navegador, esa expresión no describe ninguna velocidad concreta: en un monitor de sesenta hercios da unos treinta y cuatro grados por segundo, en uno de ciento cuarenta y cuatro casi ochenta y tres, y en un portátil que ha bajado a treinta, diecisiete.

La corrección es expresar la velocidad en unidades por segundo y multiplicarla por el tiempo transcurrido desde el fotograma anterior:

JS
const VELOCIDAD_ANGULAR = Math.PI / 4;    // 45 grados por segundo, un dato con sentidomalla.rotation.y += VELOCIDAD_ANGULAR * dt;

La diferencia no es solo de corrección técnica. La segunda versión es legible: cualquiera que lea el código sabe qué va a ocurrir sin ejecutarlo, y ajustar la velocidad es cambiar un número que significa algo. La primera versión exige probar.

La misma regla vale para todo lo que cambie con el tiempo: velocidades lineales en unidades por segundo, aceleraciones en unidades por segundo al cuadrado, tasas de aparición en elementos por segundo, desvanecimientos en fracción por segundo.

Medir el tiempo en la revisión actual

Three.js tuvo durante años una clase Clock con un método getDelta. Desde la revisión r183 esa clase está marcada como obsoleta, y en r184 lo sigue estando: su constructor imprime en consola Clock: This module has been deprecated. Please use THREE.Timer instead. cada vez que se instancia. No es una obsolescencia sobre el papel, es un aviso en tiempo de ejecución que vas a ver en tu propia consola.

La sustituta es Timer, que vive en el núcleo —src/core/Timer.js— y se exporta desde el paquete three como todo lo demás. No hay que ir a buscarla a los addons. Este track usa Timer en todas las lecciones a partir de aquí, y esta es la única que se detiene a explicar por qué.

Timer tiene una API con una diferencia de diseño importante: separa actualizar de consultar. Se llama update una vez por fotograma, pasándole la marca de tiempo que entrega el bucle, y a partir de ahí getDelta y getElapsed se pueden llamar tantas veces como haga falta devolviendo siempre el mismo valor durante ese fotograma.

JS
import * as THREE from 'three';const renderer = new THREE.WebGLRenderer({ antialias: true });renderer.setSize(window.innerWidth, window.innerHeight);renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));document.body.appendChild(renderer.domElement);const scene = new THREE.Scene();scene.background = new THREE.Color(0x11111b);const camera = new THREE.PerspectiveCamera(  50, window.innerWidth / window.innerHeight, 0.1, 100);camera.position.set(0, 1.2, 4);camera.lookAt(0, 0, 0);scene.add(new THREE.AmbientLight(0xffffff, 0.5));const luz = new THREE.DirectionalLight(0xffffff, 3);luz.position.set(3, 4, 2);scene.add(luz);const cubo = new THREE.Mesh(  new THREE.BoxGeometry(1.2, 1.2, 1.2),  new THREE.MeshStandardMaterial({ color: 0xf9e2af, roughness: 0.35 }));scene.add(cubo);// El reloj, conectado al documento para no acumular tiempo con la pestana oculta.const reloj = new THREE.Timer();reloj.connect(document);const VELOCIDAD_Y = THREE.MathUtils.degToRad(45);const VELOCIDAD_X = THREE.MathUtils.degToRad(20);renderer.setAnimationLoop((tiempo) => {  reloj.update(tiempo);  const dt = Math.min(reloj.getDelta(), 0.1);   // recorte de seguridad  const total = reloj.getElapsed();  cubo.rotation.y += VELOCIDAD_Y * dt;  cubo.rotation.x += VELOCIDAD_X * dt;  cubo.position.y = Math.sin(total * 1.5) * 0.35;  renderer.render(scene, camera);});

Ese es el primer proyecto completo del track: tríada, bucle, reloj y movimiento con velocidades expresadas en grados por segundo. Cambia el monitor, cambia la carga del sistema, cambia el dispositivo, y el cubo gira exactamente igual.

Fíjate en el uso mixto de las dos medidas de tiempo. Las rotaciones acumulan con delta, porque son un estado que avanza. La altura, en cambio, se calcula a partir del tiempo total con un seno, porque es una función del tiempo y no un estado. La segunda forma es preferible siempre que sea posible: no acumula error de redondeo, es reproducible, y permite retroceder o saltar en el tiempo sin que nada se rompa.

El salto de tiempo al volver

Cuando una pestaña deja de ser visible, el navegador suspende los fotogramas. Al volver, el primer delta que se calcula abarca todo el tiempo que la pestaña estuvo oculta: pueden ser diez minutos, seiscientos segundos.

Multiplicar cualquier velocidad por seiscientos produce un desastre inmediato. Un objeto que se movía a dos unidades por segundo aparece a mil doscientas unidades de distancia; una simulación física explota; un contador de progreso salta al final; una interpolación se pasa de largo.

Hay dos defensas y conviene poner las dos.

Conectar el reloj al documento. reloj.connect(document) hace que Timer use la interfaz de visibilidad de página para no contabilizar el tiempo en el que la pestaña estuvo oculta. Es la solución limpia y cubre el caso más frecuente.

Recortar el delta. Un tope de cien milisegundos, como en el ejemplo, protege del resto de casos: una pausa del depurador, un fotograma muy lento por una compilación de shader, una carga de archivo síncrona. El efecto de recortar es que en esos instantes la simulación va a cámara lenta durante un fotograma, cosa que nadie percibe, en lugar de dar un salto, cosa que todo el mundo percibe.

El tope conviene ponerlo bajo y no alto. Con cien milisegundos, cualquier fotograma que tarde más se comporta como si hubiera tardado exactamente cien, y eso es preferible a propagar un valor anómalo por toda la lógica.

Suavizados que no dependen de la tasa

El último idioma que hay que incorporar desde el primer proyecto es el suavizado exponencial. Aparece en cuanto quieres que algo siga a otra cosa con inercia: una cámara que persigue, un valor que converge, un color que cambia.

La versión ingenua interpola una fracción fija por fotograma, y tiene exactamente el mismo defecto que sumar una cantidad fija: depende de cuántas veces por segundo se ejecute. La versión correcta usa una exponencial del tiempo transcurrido, y Three.js la incluye para valores escalares.

JS
import * as THREE from 'three';let alturaActual = 0;let alturaObjetivo = 3;export function actualizarAltura(dt) {  // lambda alto converge rapido; 5 es un valor comodo para una camara.  alturaActual = THREE.MathUtils.damp(alturaActual, alturaObjetivo, 5, dt);  return alturaActual;}// Comprobacion de que la formula es independiente de la tasa de fotogramas.let a = 0;let b = 0;a = THREE.MathUtils.damp(a, 10, 5, 0.016);a = THREE.MathUtils.damp(a, 10, 5, 0.016);b = THREE.MathUtils.damp(b, 10, 5, 0.032);console.log('dos pasos frente a uno doble:', Math.abs(a - b).toFixed(12));

Para orientaciones, la misma idea se aplica con interpolación esférica y el factor exponencial, tal como se vio en la lección de slerp. Para vectores no hay función equivalente en la biblioteca, pero la fórmula es idéntica: calcular el factor con la exponencial y pasárselo a lerp.

Una última observación sobre los límites de este enfoque. Multiplicar por delta funciona para movimientos lineales y para suavizados; deja de funcionar para simulaciones con acumulación, como la física con colisiones, donde un paso de tiempo variable produce resultados distintos en cada ejecución y hace imposible reproducir un fallo. Esas simulaciones necesitan un paso fijo con acumulador, que es un patrón distinto y que este track aborda cuando llega la física.