Saltar al contenido

Interpolación entre pasos y sincronizar con la escena

Cómo se elimina el micro-tirón que deja el acumulador, cómo se interpolan posición y rotación correctamente, y cómo se copian miles de transformaciones a Three.js sin desperdiciar trabajo.

De dónde sale el temblor

Con el acumulador, cada frame termina con un sobrante entre cero y un paso. Supón 60 pasos por segundo y un monitor de 144 Hz. Los frames aportan 6,94 ms cada uno y el paso son 16,67 ms. La secuencia de sobrantes es 6,94 / 13,89 / 4,16 / 11,11 / 1,39 / … y el número de pasos por frame va 0, 0, 1, 0, 1, 0, 0, 1…

Si dibujas siempre el último estado simulado, dos frames consecutivos muestran exactamente la misma posición, el tercero da un salto del tamaño de un paso completo, y el patrón se repite de forma irregular. El ojo detecta esa irregularidad como vibración, aunque cada posición individual sea correcta. El fenómeno es el mismo que produce el juddering al proyectar cine de 24 fotogramas en una pantalla de 60 Hz.

La solución es no dibujar el último estado sino un punto intermedio entre el penúltimo y el último, con el peso que indica el sobrante:

JS
const alfa = acumulador / PASO;   // entre 0 y 1

Cuando alfa vale cero, el sobrante es nulo y hay que dibujar exactamente el último estado. Cuando vale 0,9 estamos casi en el siguiente paso, así que dibujamos casi el último estado y un poco del anterior… no: al revés. La interpolación va del estado previo hacia el actual, y alfa es cuánto hemos avanzado dentro del intervalo que va del previo al actual. Con alfa = 0 mostramos el previo; con alfa = 1, el actual.

Guardar el estado previo

El estado previo se guarda justo antes de cada step(), no una vez por frame. Si un frame ejecuta dos pasos, hay que guardar antes del primero y antes del segundo; lo que quieres al final es el estado inmediatamente anterior al último paso.

La estructura que mejor funciona es un registro por objeto con los datos de física y de render juntos:

JS
class Cuerpo {	constructor( cuerpoFisico, malla ) {		this.fisico = cuerpoFisico;		this.malla = malla;		this.posPrevia = new THREE.Vector3();		this.rotPrevia = new THREE.Quaternion();		this.posActual = new THREE.Vector3();		this.rotActual = new THREE.Quaternion();		this.leerDelMotor( this.posActual, this.rotActual );		this.posPrevia.copy( this.posActual );		this.rotPrevia.copy( this.rotActual );	}	leerDelMotor( pos, rot ) {		const t = this.fisico.translation();		const r = this.fisico.rotation();		pos.set( t.x, t.y, t.z );		rot.set( r.x, r.y, r.z, r.w );	}	guardarPrevio() {		this.posPrevia.copy( this.posActual );		this.rotPrevia.copy( this.rotActual );	}	capturarActual() {		this.leerDelMotor( this.posActual, this.rotActual );	}	sincronizar( alfa ) {		this.malla.position.lerpVectors( this.posPrevia, this.posActual, alfa );		this.malla.quaternion.slerpQuaternions( this.rotPrevia, this.rotActual, alfa );	}}

Tres métodos de Three.js hacen el trabajo pesado y conviene conocerlos por su nombre exacto: lerpVectors( a, b, t ) interpola entre dos vectores dejando el resultado en this, y slerpQuaternions( a, b, t ) hace lo propio con cuaterniones. Los dos evitan crear objetos temporales, que es justo lo que quieres en un bucle de miles de elementos.

El bucle queda así:

JS
while ( acumulador >= PASO && pasos < MAX_PASOS ) {	for ( const c of cuerpos ) c.guardarPrevio();	mundo.step();	for ( const c of cuerpos ) c.capturarActual();	acumulador -= PASO;	pasos ++;}const alfa = acumulador / PASO;for ( const c of cuerpos ) c.sincronizar( alfa );

slerp y por qué lerp no vale para rotaciones

Un cuaternión unitario representa una rotación, y el conjunto de cuaterniones unitarios es la superficie de una esfera en cuatro dimensiones. Interpolar linealmente entre dos puntos de una esfera te saca de ella: el resultado tiene módulo menor que uno y ya no representa una rotación pura, sino una rotación con escala. Aplicado a un objeto, eso lo encoge y lo deforma.

Además, aunque renormalices, la interpolación lineal no tiene velocidad angular constante: acelera en el centro del recorrido y frena en los extremos. Con ángulos pequeños es imperceptible; con rotaciones de más de treinta grados se nota como un tirón.

slerp —interpolación esférica lineal— recorre el arco de círculo máximo entre los dos cuaterniones a velocidad angular constante. Es lo correcto y es lo que implementa Quaternion.slerp. Three.js además maneja el caso del doble recubrimiento: cada rotación se representa por dos cuaterniones, q y -q, y si los dos extremos están en hemisferios opuestos, slerp invierte el signo de uno para tomar el camino corto en lugar de dar la vuelta larga.

Para ángulos muy pequeños —que es lo habitual entre dos pasos consecutivos— existe slerpQuaternions con la misma firma y una implementación que evita la división por seno cuando el ángulo tiende a cero.

No copiar lo que no se ha movido

Una escena con dos mil cuerpos donde solo veinte están en movimiento no necesita dos mil interpolaciones. Rapier ya sabe cuáles están activos:

JS
mundo.forEachActiveRigidBody( ( cuerpo ) => {	const registro = porHandle.get( cuerpo.handle );	registro.capturarActual();} );

forEachActiveRigidBody recorre solo los cuerpos que no están dormidos, apoyándose en el gestor de islas del motor. El mapa porHandle asocia el identificador entero del cuerpo con tu registro; construirlo al crear cada cuerpo cuesta nada y evita búsquedas lineales.

La otra optimización es del lado de Three.js. Cada vez que cambias position o quaternion, el objeto queda marcado y el renderer recalcula su matriz local y su matriz de mundo durante render(). Para objetos que no se mueven, eso es trabajo puro desperdiciado:

JS
// Cuerpos fijos: calcula la matriz una vez y desactiva la actualizacion.sueloMalla.position.set( 0, 0, 0 );sueloMalla.updateMatrix();sueloMalla.matrixAutoUpdate = false;

Con matrixAutoUpdate = false, Three.js deja de recomponer la matriz local en cada frame. Si después necesitas mover el objeto, tendrás que llamar a updateMatrix() tú. Para el escenario estático de una escena grande, la diferencia es medible.

Un caso especial que merece su propio párrafo: si tus cuerpos comparten geometría y material, un InstancedMesh reduce miles de draw calls a uno.

JS
const dummy = new THREE.Object3D();function sincronizarInstancias( alfa ) {	for ( let i = 0; i < cuerpos.length; i ++ ) {		const c = cuerpos[ i ];		dummy.position.lerpVectors( c.posPrevia, c.posActual, alfa );		dummy.quaternion.slerpQuaternions( c.rotPrevia, c.rotActual, alfa );		dummy.updateMatrix();		instancias.setMatrixAt( i, dummy.matrix );	}	instancias.instanceMatrix.needsUpdate = true;}

El objeto dummy es un Object3D fuera de la escena que se usa solo para componer matrices. Es el patrón estándar y evita crear una Matrix4 por instancia y por frame.