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.
El bucle de tiempo fijo deja un residuo: entre el último paso simulado y el instante que estás dibujando hay hasta un paso completo de desfase, y ese desfase varía de frame a frame. Dibujar el último estado sin más produce un temblor sutil que se nota especialmente en objetos que se mueven despacio y en la cámara. La corrección es interpolar, y hacerla bien exige guardar dos estados, usar slerp para las rotaciones y no copiar cosas que no se han movido.
- Calcular el factor de interpolación y aplicarlo entre el estado previo y el actual.
- Interpolar rotaciones con
slerpy explicar por quélerpno vale. - Copiar transformaciones a Three.js evitando actualizaciones de matriz redundantes.
- Recorrer solo los cuerpos activos y saltarse los dormidos.
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:
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.
Esto introduce un retardo constante de hasta un paso: siempre estás dibujando el pasado. Existe la alternativa de extrapolar —proyectar el estado actual hacia adelante usando la velocidad— que elimina el retardo a cambio de predecir mal cuando algo choca, produciendo interpenetraciones visibles que hay que corregir de golpe al frame siguiente. Para casi todo, un retardo de 16 ms es imperceptible y la interpolación es la elección correcta. La extrapolación se reserva para el objeto que controla el jugador, donde la latencia sí se nota.
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:
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í:
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:
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:
// 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.
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.
Hay una consecuencia de todo esto que produce bugs muy difíciles de rastrear y que conviene tener presente desde el principio: después de interpolar, la posición del objeto de Three.js ya no es la posición del cuerpo físico. Son dos números distintos, y difieren en hasta un paso de simulación de movimiento. Mientras solo dibujes, da igual. El problema aparece en cuanto algo lee la transformación de la malla y espera que sea verdad. Los casos concretos son estos y todos han costado tardes a alguien. Si haces un raycast de Three.js contra las mallas interpoladas para colocar un objeto, y luego creas ahí un cuerpo físico, el cuerpo aparece ligeramente desplazado respecto a lo que el usuario vio. Si adjuntas una luz o una cámara como hija de una malla interpolada y además consultas la posición del cuerpo para otra cosa, tienes dos fuentes de verdad que se contradicen. Si un sistema de partículas emite desde malla.position mientras la lógica de colisión usa cuerpo.translation(), las partículas salen de un sitio ligeramente distinto de donde ocurre el impacto. Y el más traicionero: si un objeto se destruye por una colisión y quieres reventarlo en pedazos usando la transformación de la malla, los pedazos aparecen desalineados respecto al cuerpo que acaba de chocar. La regla que evita toda esta familia de bugs es una sola frase: la transformación de la malla es solo para dibujar; para cualquier otra cosa, pregunta al motor. Y si vas a tener que preguntar mucho, plantéate exponer en tu registro un método que devuelva la posición autoritativa, para que en el código de la aplicación nunca se lea malla.position directamente. Es una restricción incómoda que se convierte en costumbre en una tarde y ahorra semanas.
- Monta un péndulo lento con el bucle de tiempo fijo sin interpolación y obsérvalo a 144 Hz.
- Añade la interpolación con
lerpVectorsyslerpQuaternionsy compara. - Sustituye
slerpQuaternionspor interpolación lineal de las cuatro componentes y busca la deformación. - Añade dos mil cuerpos, deja que se duerman, y compara el coste de recorrerlos todos frente a
forEachActiveRigidBody. - Convierte los dos mil cuerpos a un
InstancedMeshy mide la caída en draw calls.