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

Colliders y la malla de colisión

El catálogo de formas de colisión, por qué la geometría que dibujas casi nunca sirve para colisionar, cómo se construye una malla de colisión desde un glTF, y qué controlan fricción y restitución.

⏱ 19 min

Un cuerpo rígido no tiene forma. Es un punto con masa, inercia y una orientación; todo lo que sabe de geometría se lo dan los colliders que le cuelgues. Y ahí aparece la decisión que más determina el rendimiento y la sensación de una escena con física: qué forma le das al collider. La respuesta correcta casi nunca es “la misma que la malla que dibujo”, y entender por qué es lo que separa una simulación de 60 frames de una que se arrastra.

🎯 Al terminar esta lección sabrás
  • Elegir la forma de collider adecuada según coste y comportamiento.
  • Construir un trimesh y un convexHull desde una geometría de Three.js.
  • Explicar por qué un trimesh no puede ser un cuerpo dinámico bien comportado.
  • Configurar fricción, restitución, sensores y grupos de colisión.

El catálogo, ordenado por coste

// Primitivas: coste constante, deteccion analitica.
RAPIER.ColliderDesc.ball( radio );
RAPIER.ColliderDesc.cuboid( hx, hy, hz );                 // SEMIextensiones
RAPIER.ColliderDesc.capsule( mediaAltura, radio );
RAPIER.ColliderDesc.cylinder( mediaAltura, radio );
RAPIER.ColliderDesc.cone( mediaAltura, radio );
RAPIER.ColliderDesc.roundCuboid( hx, hy, hz, borde );

// Compuestas y de malla.
RAPIER.ColliderDesc.convexHull( vertices );               // Float32Array plano
RAPIER.ColliderDesc.trimesh( vertices, indices );
RAPIER.ColliderDesc.heightfield( nfilas, ncols, alturas, escala );

La jerarquía de coste es clara y vale la pena tenerla presente. Una esfera contra una esfera es una resta de vectores y una comparación: no hay nada más barato. Una cápsula es casi igual de barata y es la forma preferida para personajes porque no se engancha en las esquinas: al ser un segmento con radio, sube escalones y desliza por paredes sin quedarse trabada, cosa que una caja hace fatal. Una caja es barata y estable, y apilar cajas funciona bien. Un cilindro o un cono ya requieren algoritmos generales de convexos y cuestan varias veces más.

Un casco convexo es el envoltorio convexo de una nube de puntos. El coste de colisión depende del número de caras, pero sigue siendo un problema de convexos, que está muy bien resuelto. Es la mejor relación entre fidelidad y coste para objetos irregulares que no tienen huecos.

Un trimesh es la malla literal: triángulos sueltos, sin volumen. Es la forma más cara y la más problemática, y merece su propia sección.

El error de usar la malla que dibujas

La geometría de render está optimizada para verse bien: densidad uniforme de triángulos, bordes suavizados, subdivisiones para que la normal interpolada quede limpia. Nada de eso ayuda a colisionar. Una silla de veinte mil triángulos y una caja de doce se comportan casi idénticamente cuando el jugador choca con ellas, y una cuesta mil veces más.

La forma correcta de trabajar es tener dos geometrías: la visual, que el artista cuida, y la de colisión, que es un conjunto de primitivas o una malla simplificada. En un pipeline serio esa segunda malla viene del artista con una convención de nombres —sufijos como _col o _ucx— y el cargador la separa:

const colisionadores = [];
const visuales = [];

gltf.scene.traverse( ( o ) => {

	if ( ! o.isMesh ) return;

	if ( o.name.endsWith( '_col' ) ) {

		colisionadores.push( o );
		o.visible = false;          // no se dibuja, solo colisiona

	} else {

		visuales.push( o );

	}

} );

Cuando no tienes esa malla, hay tres salidas por orden de preferencia.

Aproximar con primitivas a mano. Un edificio son seis cajas. Un árbol es un cilindro. Un coche son dos cajas. Es trabajo manual, es aburrido, y es la opción que mejor rinde y mejor se comporta.

Casco convexo desde la geometría. Para objetos irregulares pero macizos:

function cascoDesdeMalla( malla ) {

	const g = malla.geometry;
	const pos = g.getAttribute( 'position' );

	// convexHull espera un Float32Array plano de coordenadas.
	const puntos = new Float32Array( pos.count * 3 );

	for ( let i = 0; i < pos.count; i ++ ) {

		puntos[ i * 3 + 0 ] = pos.getX( i );
		puntos[ i * 3 + 1 ] = pos.getY( i );
		puntos[ i * 3 + 2 ] = pos.getZ( i );

	}

	return RAPIER.ColliderDesc.convexHull( puntos );

}

Devuelve null si la nube de puntos es degenerada —todos coplanares, por ejemplo— así que comprueba el resultado.

Trimesh, solo para geometría estática.

function trimeshDesdeMalla( malla ) {

	const g = malla.geometry.index !== null
		? malla.geometry
		: malla.geometry.toNonIndexed();

	const pos = g.getAttribute( 'position' );
	const vertices = new Float32Array( pos.array );

	const indices = g.index
		? new Uint32Array( g.index.array )
		: new Uint32Array( Array.from( { length: pos.count }, ( _, i ) => i ) );

	return RAPIER.ColliderDesc.trimesh( vertices, indices );

}
🛑
Un trimesh dinámico no funciona y no es un bug

Un trimesh es una superficie, no un sólido: no tiene interior. El motor puede decirte que un punto ha cruzado un triángulo, pero no puede decirte “estás dentro”, porque no hay dentro. Las consecuencias son concretas: el cálculo de masa e inercia no está definido, la profundidad de penetración no se puede recuperar de forma fiable, y dos trimesh no generan contacto entre sí. Por eso un trimesh se usa solo en cuerpos fijos: terreno, edificios, escenario. Si necesitas que un objeto cóncavo sea dinámico, la solución es descomponerlo en varios colliders convexos colgando del mismo cuerpo.

Un cuerpo puede tener varios colliders, y ese es el mecanismo para formas compuestas. Cada uno se posiciona respecto al cuerpo con setTranslation y setRotation en el descriptor:

const cuerpo = mundo.createRigidBody( RAPIER.RigidBodyDesc.dynamic() );

// Una mesa: tablero mas cuatro patas, todo en el mismo cuerpo.
mundo.createCollider(
	RAPIER.ColliderDesc.cuboid( 1, 0.05, 0.6 ).setTranslation( 0, 0.75, 0 ),
	cuerpo
);

for ( const [ x, z ] of [ [ 0.9, 0.5 ], [ - 0.9, 0.5 ], [ 0.9, - 0.5 ], [ - 0.9, - 0.5 ] ] ) {

	mundo.createCollider(
		RAPIER.ColliderDesc.cuboid( 0.05, 0.375, 0.05 ).setTranslation( x, 0.375, z ),
		cuerpo
	);

}

Las masas e inercias de los cinco colliders se combinan automáticamente, así que la mesa tiene el centro de masas donde debe y vuelca de forma creíble.

Fricción, restitución y su combinación

const col = RAPIER.ColliderDesc.cuboid( 0.5, 0.5, 0.5 )
	.setFriction( 0.7 )
	.setRestitution( 0.3 )
	.setDensity( 1.5 )
	.setFrictionCombineRule( RAPIER.CoefficientCombineRule.Min )
	.setRestitutionCombineRule( RAPIER.CoefficientCombineRule.Max );

Fricción es el coeficiente de rozamiento. Cero es hielo, uno es goma sobre asfalto, valores por encima de uno son físicamente posibles y útiles para que las cosas no resbalen.

Restitución es la elasticidad del rebote. Cero es plastilina, uno es rebote perfecto sin pérdida. Valores cercanos a uno son numéricamente peligrosos: con la integración discreta pueden ganar energía y hacer que un objeto rebote cada vez más alto.

Lo importante es que el valor efectivo de un contacto sale de combinar los dos colliders implicados, y la regla de combinación se elige. Por defecto es la media. Si quieres que una superficie de hielo sea resbaladiza contra cualquier cosa, ponle fricción cero y regla Min: así impone su valor sobre cualquier objeto que lo toque, en lugar de negociar la media. Si quieres una cama elástica que rebote todo, restitución alta y regla Max.

Sensores y grupos

Un sensor detecta solapamientos pero no genera fuerza. Es el disparador de zonas de juego, checkpoints, áreas de daño.

const zona = RAPIER.ColliderDesc.cuboid( 2, 2, 2 )
	.setTranslation( 0, 2, - 10 )
	.setSensor( true );

const colZona = mundo.createCollider( zona );

// Consulta cada paso.
mundo.intersectionPairsWith( colZona, ( otro ) => {

	console.log( 'algo dentro de la zona' );

} );

Los grupos de colisión filtran qué choca con qué sin coste de resolución. Rapier los codifica en un entero de 32 bits: los 16 bits altos son los grupos a los que pertenece el collider, los 16 bajos son los grupos con los que puede interactuar.

const GRUPO_JUGADOR   = 0x0001;
const GRUPO_ENEMIGO   = 0x0002;
const GRUPO_ESCENARIO = 0x0004;

// El jugador pertenece a JUGADOR e interactua con ENEMIGO y ESCENARIO.
const filtroJugador = ( GRUPO_JUGADOR << 16 ) | ( GRUPO_ENEMIGO | GRUPO_ESCENARIO );

const col = RAPIER.ColliderDesc.capsule( 0.5, 0.3 ).setCollisionGroups( filtroJugador );

Dos colliders interactúan si cada uno tiene en su máscara de interacción algún grupo del otro. El filtro ocurre en la fase amplia, antes de calcular nada, así que es la forma más barata de decir “las balas del jugador no tocan al jugador”.

La malla de colisión es un contrato con el jugador, no una aproximación de la visual

La forma habitual de pensar en el collider es como una versión degradada de la malla visual: lo mejor sería usar la geometría real, pero como es cara, usamos una aproximación. Esa forma de pensar produce colliders malos. En los estudios que hacen esto bien, la geometría de colisión no aproxima nada: describe cómo debe sentirse el objeto, y a veces se aleja deliberadamente de la forma visual. Tres ejemplos que lo dejan claro. Un personaje se representa con una cápsula, no con su silueta, y no es por ahorro: es porque una cápsula sube escalones sin engancharse, desliza por paredes en lugar de trabarse en las esquinas, y gira sin cambiar su huella. Una silueta fiel produciría un personaje que se queda clavado en cada marco de puerta, que es peor jugabilidad aunque sea más “correcto”. Segundo: las escaleras. Casi nadie pone colliders escalón por escalón; se pone una rampa lisa con la misma pendiente, porque subir escalones reales hace que la cámara bote y que los objetos que ruedan se atasquen. La colisión miente sobre la geometría a propósito, y el resultado se siente mejor. Tercero: los objetos recogibles suelen tener colliders más grandes que su malla, para que el jugador no falle al intentar cogerlos, y los proyectiles suelen tenerlos más pequeños, para que el jugador se sienta hábil esquivándolos. Ninguna de esas decisiones sale de mirar la malla visual. Salen de decidir qué experiencia quieres y traducirla a una forma. Por eso el flujo de trabajo correcto no es “simplifica la malla hasta que quepa en el presupuesto” sino “empieza por primitivas y sube en complejidad solo cuando el comportamiento lo exija”. Casi siempre te quedas en las primitivas, y casi siempre se siente mejor que la malla exacta.

⚔️ Cinco colliders para el mismo objeto
  1. Carga un modelo glTF de un mueble y móntale un collider de caja a ojo.
  2. Genera un convexHull desde su geometría y compara comportamiento y coste de step().
  3. Genera un trimesh y compruébalo primero como cuerpo fijo, después intenta hacerlo dinámico.
  4. Descompón el mueble en cuatro cajas colgando del mismo cuerpo y comprueba que el centro de masas es creíble.
  5. Añade un sensor delante del mueble y detecta cuándo entra el jugador con intersectionPairsWith.