wandres.dev
FÍSICA · Rapier y el mundo simulado

Integrar un motor de física: Rapier y el mundo

Por qué la física vive fuera de Three.js, cómo se carga un motor compilado a WebAssembly, cómo se crea el mundo y qué parámetros globales deciden la calidad de la simulación.

⏱ 18 min

Three.js no simula física y nunca ha pretendido hacerlo. Es una biblioteca de renderizado: sabe dónde dibujar las cosas, no por qué se mueven. La física es un problema completamente distinto —integración numérica, detección de colisiones, resolución de restricciones— y se resuelve con una biblioteca aparte que no sabe nada de escenas ni de materiales. Toda la integración se reduce a una idea: hay dos mundos paralelos, uno de simulación y otro de render, y tu trabajo es copiar transformaciones del primero al segundo.

🎯 Al terminar esta lección sabrás
  • Cargar Rapier compilado a WebAssembly y esperar su inicialización correctamente.
  • Crear un mundo con su gravedad y ajustar lengthUnit a la escala de tu escena.
  • Distinguir los parámetros que afectan a la calidad de los que afectan al coste.
  • Montar el esqueleto de una escena con un cuerpo cayendo sobre el suelo.

Por qué WebAssembly cambió la ecuación

Durante años la opción por defecto en el navegador era cannon-es o ammo.js. El primero está escrito en JavaScript puro y es cómodo pero lento en cuanto la escena crece. El segundo es Bullet compilado con Emscripten y es rápido, pero arrastra una API traducida desde C++ que exige gestionar memoria a mano y que produce fugas si te descuidas.

Rapier ocupa el punto intermedio interesante: está escrito en Rust, se compila a WebAssembly, y sus enlaces de JavaScript exponen una API pensada para JavaScript en lugar de una traducción literal de la de C++. La consecuencia práctica es que el rendimiento está en el orden del código nativo y el código de integración se lee como código normal.

El paquete se distribuye en dos formas y elegir mal cuesta una tarde:

# Requiere que tu bundler entienda importaciones de .wasm.
npm install @dimforge/rapier3d

# Empaqueta el wasm en base64 dentro del JS. Funciona en cualquier sitio.
npm install @dimforge/rapier3d-compat

La versión -compat incrusta el binario codificado en base64 dentro del fichero JavaScript. Es más grande y tarda un poco más en arrancar porque hay que decodificar, pero funciona sin configurar nada en Vite, en Astro, en un script type="module" suelto o desde un CDN. Salvo que tengas una razón concreta para lo contrario, empieza por -compat.

La inicialización es asíncrona en las dos, y ahí está el primer tropiezo clásico:

import RAPIER from '@dimforge/rapier3d-compat';

// Sin esto, cualquier uso de RAPIER lanza un error opaco de wasm.
await RAPIER.init();

const gravedad = { x: 0, y: - 9.81, z: 0 };
const mundo = new RAPIER.World( gravedad );

RAPIER.init() compila e instancia el módulo WebAssembly. Hasta que su promesa resuelve, ninguna clase del namespace está disponible. Si tu aplicación arranca la escena en un useEffect, en un onMount o en un módulo de nivel superior, asegúrate de que el await ocurre antes de cualquier new RAPIER.algo.

ℹ️
El paquete no lleva render

Rapier no dibuja nada, pero sí sabe describirse. mundo.debugRender() devuelve dos arrays planos, vertices y colors, con los segmentos que representan todos los colliders. Alimentando esos arrays a un LineSegments de Three.js tienes un visualizador de colisiones en veinte líneas, y es lo primero que deberías montar: la mitad de los bugs de física son colliders que no están donde crees.

El mundo y sus parámetros

World recibe la gravedad y guarda dentro un conjunto de parámetros de integración que deciden calidad y coste.

const mundo = new RAPIER.World( { x: 0, y: - 9.81, z: 0 } );

mundo.timestep = 1 / 60;              // paso fijo de simulacion, en segundos
mundo.lengthUnit = 1;                 // cuantas unidades de tu mundo son un metro
mundo.numSolverIterations = 4;        // rigidez de las restricciones
mundo.numInternalPgsIterations = 1;   // estabilidad extra, mas barata
mundo.maxCcdSubsteps = 1;             // deteccion continua de colisiones

timestep es el intervalo que avanza cada step(). Su valor por defecto de un sesentavo de segundo es el correcto para casi todo, y no debe variar durante la simulación: cambiar el paso entre frames introduce inestabilidades en el solver, y ese es precisamente el motivo de todo el aparato del bucle de tiempo fijo que verás en su propia lección.

lengthUnit es el parámetro que más gente ignora y más problemas causa. Rapier está calibrado internamente para un mundo en metros donde los objetos típicos miden alrededor de uno. Varios umbrales —el error lineal permitido, la distancia de predicción de contactos, el umbral por debajo del cual un cuerpo se duerme— se escalan por este valor. Si tu escena está en centímetros y un personaje mide 170 unidades, pon lengthUnit = 100 y todos esos umbrales se ajustan solos. Si no lo haces, verás objetos que se hunden ligeramente unos en otros, contactos que tiemblan y cuerpos que se duermen en pleno vuelo.

numSolverIterations controla cuántas veces se recorre el conjunto de restricciones para resolver contactos y juntas. Más iteraciones significa apilamientos más rígidos y juntas menos elásticas, a coste lineal. Cuatro es el defecto; subir a ocho es razonable para torres de cajas; subir a treinta es casi siempre señal de que el problema es otro.

numInternalPgsIterations añade iteraciones internas dentro de cada iteración del solver. Es más barato que subir el número principal y mejora la estabilidad, aunque con menos efecto. Es la primera palanca que probar cuando una pila tiembla.

maxCcdSubsteps afecta a la detección continua de colisiones, que es lo que evita que una bala atraviese una pared por ir demasiado rápido entre un paso y el siguiente. Ponerlo a cero desactiva CCD por completo; subirlo permite que un cuerpo con CCD activo continúe su trayectoria tras el primer impacto en el mismo paso. La CCD se activa por cuerpo, no globalmente, así que este parámetro solo importa si tienes cuerpos rápidos marcados.

El esqueleto completo

Un ejemplo mínimo que funciona, con los dos mundos visibles:

import * as THREE from 'three';
import RAPIER from '@dimforge/rapier3d-compat';

await RAPIER.init();

// --- Mundo de render ---
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 60, innerWidth / innerHeight, 0.1, 100 );
camera.position.set( 6, 5, 8 );
camera.lookAt( 0, 1, 0 );

const renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setSize( innerWidth, innerHeight );
renderer.setPixelRatio( Math.min( devicePixelRatio, 2 ) );
document.body.appendChild( renderer.domElement );

scene.add( new THREE.HemisphereLight( 0xffffff, 0x334455, 2 ) );

// --- Mundo de simulacion ---
const mundo = new RAPIER.World( { x: 0, y: - 9.81, z: 0 } );

// Suelo: un collider sin cuerpo asociado es implicitamente fijo.
mundo.createCollider( RAPIER.ColliderDesc.cuboid( 10, 0.1, 10 ) );

const sueloMalla = new THREE.Mesh(
	new THREE.BoxGeometry( 20, 0.2, 20 ),
	new THREE.MeshStandardMaterial( { color: 0x445566 } )
);
scene.add( sueloMalla );

// Caja que cae.
const cuerpo = mundo.createRigidBody(
	RAPIER.RigidBodyDesc.dynamic().setTranslation( 0, 6, 0 )
);
mundo.createCollider( RAPIER.ColliderDesc.cuboid( 0.5, 0.5, 0.5 ), cuerpo );

const cajaMalla = new THREE.Mesh(
	new THREE.BoxGeometry( 1, 1, 1 ),
	new THREE.MeshStandardMaterial( { color: 0xffaa33 } )
);
scene.add( cajaMalla );

// --- Bucle ---
renderer.setAnimationLoop( () => {

	mundo.step();

	const t = cuerpo.translation();
	const r = cuerpo.rotation();

	cajaMalla.position.set( t.x, t.y, t.z );
	cajaMalla.quaternion.set( r.x, r.y, r.z, r.w );

	renderer.render( scene, camera );

} );

Tres detalles que se repiten en cualquier integración:

ColliderDesc.cuboid recibe semiextensiones, no dimensiones completas. Un cuboid( 0.5, 0.5, 0.5 ) es un cubo de lado uno, que es lo que corresponde a un BoxGeometry( 1, 1, 1 ). Confundir esto produce cuerpos que flotan o que se hunden exactamente la mitad de su tamaño, y es el error de novato más universal.

translation() y rotation() devuelven objetos planos, no vectores de Three.js. { x, y, z } para la posición y { x, y, z, w } para el cuaternión. Copiar componente a componente con set() es lo correcto y evita crear objetos nuevos cada frame.

Un collider sin cuerpo padre es fijo. mundo.createCollider( desc ) sin segundo argumento crea un obstáculo inmóvil. Es la forma más barata de hacer suelos y paredes, porque no consume un cuerpo rígido ni entra en la lista de cuerpos activos.

Ese bucle es correcto como demostración y es incorrecto como base para un proyecto real: llama a step() una vez por frame de render, así que la simulación va más rápida en un monitor de 144 Hz que en uno de 60. Es exactamente el problema que resuelve el bucle de tiempo fijo.

El motor de física es una máquina de estados que odia las sorpresas

Hay una intuición que hay que corregir pronto porque lo contamina todo: el motor de física no es una función pura que va de estado a estado. Es una máquina con memoria interna considerable y esa memoria es la que produce el comportamiento estable que ves. El solver guarda de un paso al siguiente los impulsos que aplicó a cada contacto, y los usa como estimación inicial del siguiente paso. Se llama warm starting y es la razón por la que una pila de cajas se queda quieta en lugar de temblar eternamente: en el primer paso el solver hace cuatro iteraciones y llega a una solución aproximada; en el segundo parte de la solución anterior y converge mucho más. Después de veinte pasos, la pila está resuelta con una precisión que cuatro iteraciones desde cero jamás alcanzarían. También guarda variedades de contacto persistentes, un grafo de islas de cuerpos conectados y el estado de sueño de cada uno. Todo eso tiene una consecuencia práctica muy concreta: cualquier cosa que rompa la continuidad entre pasos degrada la simulación de forma invisible. Cambiar el timestep entre frames invalida los impulsos guardados porque están escalados por el paso. Teletransportar un cuerpo con setTranslation destruye sus contactos y obliga a redescubrirlos, lo que se nota como un salto o una penetración que tarda varios pasos en corregirse. Recrear un collider cada frame en lugar de moverlo hace que nunca haya continuidad y el objeto vibre para siempre. La regla que se deriva de esto es la que separa una física que se siente bien de una que se siente barata: habla con el motor en su idioma, que son fuerzas, impulsos y velocidades, no posiciones. Cada vez que te veas escribiendo directamente una posición sobre un cuerpo dinámico, es señal de que quieres un cuerpo cinemático o de que quieres aplicar un impulso.

⚔️ Monta los dos mundos
  1. Levanta el ejemplo completo y comprueba que la caja cae y se detiene sobre el suelo.
  2. Añade el visualizador con mundo.debugRender() sobre un LineSegments y comprueba que los colliders coinciden con las mallas.
  3. Cambia las semiextensiones del collider a 1, 1, 1 sin tocar la geometría y observa exactamente cómo se manifiesta el error.
  4. Escala toda la escena por cien, ajusta lengthUnit y compara el comportamiento con y sin ese ajuste.
  5. Sube numSolverIterations a 16 con una pila de diez cajas y mide cuánto sube el tiempo de step().