Saltar al contenido

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.

El catálogo, ordenado por coste

JS
// Primitivas: coste constante, deteccion analitica.RAPIER.ColliderDesc.ball( radio );RAPIER.ColliderDesc.cuboid( hx, hy, hz );                 // SEMIextensionesRAPIER.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 planoRAPIER.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:

JS
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:

JS
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.

JS
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 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:

JS
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

JS
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.

JS
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.

JS
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”.