wandres.dev
PRIMER PROYECTO · Escena, cámara, renderer

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.

⏱ 17 min

El primer bucle de animación que escribe cualquiera suma una cantidad fija a una rotación en cada fotograma. Funciona, se ve bien en la máquina donde se escribió, y está mal: la velocidad depende de la frecuencia de refresco de la pantalla. Corregirlo es multiplicar por el tiempo transcurrido, y hacerlo bien exige un par de decisiones más que casi nadie toma, empezando por qué reloj usar en la revisión actual de la biblioteca.

🎯 Al terminar esta lección sabrás
  • Expresar cualquier movimiento en unidades por segundo en lugar de por fotograma.
  • Usar la clase Timer y explicar qué defectos de Clock resuelve.
  • Manejar los saltos de tiempo al recuperar una pestaña oculta.
  • Aplicar un suavizado independiente de la tasa de fotogramas a valores escalares.

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:

const VELOCIDAD_ANGULAR = Math.PI / 4;    // 45 grados por segundo, un dato con sentido
malla.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.

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 defecto de Clock que motivó su sustitución: getDelta destruye lo que devuelve

Merece la pena entender por qué Clock quedó obsoleto, porque el defecto es de diseño y aparece en cualquier API que uno escriba con esa forma. Clock.getDelta no es una consulta: es una operación que modifica el estado interno, tomando la hora actual y guardándola como referencia para la siguiente llamada. La consecuencia es que solo puede llamarse una vez por fotograma. Si la llama tu bucle principal y también, por su cuenta, un sistema de partículas y un mezclador de animación, el primero recibe el tiempo real y los otros dos reciben valores próximos a cero: las partículas se congelan, la animación no avanza, y no hay ningún error ni ninguna pista de por dónde buscar. Como cada uno de esos sistemas funciona perfectamente si se prueba aislado, el fallo solo aparece al integrarlos, que es el peor momento posible. La solución habitual era medir el delta una vez y pasarlo por parámetro a todo, disciplina que funciona hasta que alguien añade un módulo nuevo que se crea su propio reloj. Timer resuelve el problema de raíz separando update de las consultas, y añade además la conexión con la visibilidad del documento. La lección general, que vale bastante más allá de esta clase: una función cuyo nombre empieza por obtener no debería cambiar nada, y cuando lo hace, el bug que produce no se manifiesta donde está el error sino en quien la llama en segundo lugar.

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.

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.

⚔️ Reto práctico

Coge el ejemplo del cubo y añade un segundo cubo que use el incremento fijo por fotograma en lugar de delta. Después limita artificialmente la tasa a treinta fotogramas por segundo dibujando solo uno de cada dos, y observa cómo uno mantiene su velocidad y el otro se queda a la mitad. Es la demostración más corta que existe de por qué esta lección importa.