inicio / blog / jugabilidad
Publicado: 2 de diciembre de 2025 10 min de lectura jugabilidad
Por qué tu character controller parece que patina en el suelo

Por qué tu character controller parece que patina en el suelo

Cuando los jugadores dicen que un character controller patina, describen cuatro fallos distintos: frenado, detección de suelo, pendientes e interpolación.

Qué significa realmente «que patina»

El año pasado hice una prueba de juego con seis personas sobre un prototipo en tercera persona. Los seis dijeron lo mismo: el character controller parece que patina. Nadie supo dar más detalle, y me pasé los dos primeros días buscando donde no era, ajustando el suavizado de cámara y los tiempos de blend de animación.

El problema apareció cuando revisé fotograma a fotograma una captura a 240 FPS. Después de que el jugador soltara el stick, el personaje seguía avanzando 11 fotogramas más, unos 180 ms a 60 Hz. En una rampa la velocidad horizontal se mantenía, pero la normal del suelo iba cambiando y empujaba el cuerpo hacia delante. En un escalón de 20 cm la capsule se enganchaba y se quedaba clavada un instante.

Así que «patinar» no es un solo fallo. La curva de deceleración, la comprobación de suelo, el tratamiento de pendientes y la sincronización entre física y render estaban rotos por separado, y los cuatro salían a la superficie como la misma queja. Este artículo repasa cómo los fui arreglando uno a uno.

¿Rigidbody o movimiento cinemático?

La primera versión era la pareja clásica de Rigidbody y AddForce. Sobre el papel suena bien: el motor simula y tú aplicas fuerza. En la práctica, poner a cero la fricción del physics material hace que el personaje se deslice por cualquier pendiente, y activarla te mata la velocidad en cuanto rozas una pared. No hay un valor intermedio bueno, porque un único coeficiente sirve a la vez para suelos y paredes.

Acabé con movimiento cinemático. El Rigidbody sigue ahí pero con isKinematic activado; integro la velocidad yo mismo y barro con un capsule cast en cada paso de física. También probé el CharacterController de Unity: no expone nada más allá de step offset y slope limit, no puedes ver su comportamiento de depenetración y toma sus propias decisiones cuando bajas una rampa.

La regla quedó sencilla. Las consultas de colisión son cosa del motor de física; las decisiones de velocidad son cosa mía. La clase CharacterMotor ronda las 300 líneas y todos los números del movimiento viven ahí, en un único sitio. El precio es escribir a mano el knockback y el transporte sobre plataformas móviles. El tiempo que ganas mientras ajustas la sensación es muy superior a eso.

Lo más visible que pierdes es la interacción con cuerpos dinámicos. El personaje ya no empuja cajas por sí solo, porque el motor deja de verlo como una masa. Cuando un barrido golpea un Rigidbody dinámico le aplico un impulso escalado por la velocidad relativa y la relación de masas; esas quince líneas devolvieron la parte del comportamiento perdido que el jugador realmente nota.

CharacterMotor.cs
public sealed class CharacterMotor : MonoBehaviour { private const int MaxBounces = 4; private const float Skin = 0.02f; private const float WallFriction = 0.85f; private const float MaxSlopeCos = 0.5736f; // cos(55 degrees) private Vector3 CollideAndSlide(Vector3 motion, Vector3 origin, int depth) { if (depth >= MaxBounces || motion.sqrMagnitude < 1e-6f) return Vector3.zero; Vector3 dir = motion.normalized; GetCapsulePoints(origin, out Vector3 p0, out Vector3 p1); if (!Physics.CapsuleCast(p0, p1, _radius, dir, out RaycastHit hit, motion.magnitude + Skin, _collisionMask)) return motion; // Stop just short of the surface, then slide with whatever is left. Vector3 travelled = dir * Mathf.Max(0f, hit.distance - Skin); Vector3 leftover = Vector3.ProjectOnPlane(motion - travelled, hit.normal); // Walls bleed speed; walkable ground keeps it, so slopes do not slow you down. if (hit.normal.y < MaxSlopeCos) leftover *= WallFriction; return travelled + CollideAndSlide(leftover, origin + travelled, depth + 1); } }
csharpCharacterMotor.cs

Detección de suelo y pendientes con capsule casts

Un solo ray hacia abajo se rompe en los bordes. Cuando el centro del personaje pasa el borde de la plataforma por 3-4 cm el ray falla, el cuerpo cuenta como aéreo durante un fotograma y vuelve a estar en el suelo en el siguiente. En su lugar uso un CapsuleCast con el mismo radio que el cuerpo: 0,15 m hacia abajo, con un margen de skin de 0,02 m.

La prueba de caminabilidad lee la componente vertical de la normal del impacto: hit.normal.y >= 0.5736f, el coseno de 55 grados. Para cortar el parpadeo justo en el límite añadí histéresis: se entra en estado de suelo a 55 grados y se sigue considerando suelo hasta los 58. Esa única comparación acabó con la oscilación entre suelo y aire en los bordes de rampa más inclinados.

En pendientes el trabajo de verdad es proyectar la velocidad sobre el plano correcto. Si aplicas el input horizontal en crudo, aceleras cuesta abajo y te arrastras cuesta arriba. Aplanar el vector de input con ProjectOnPlane contra la normal del suelo hace que la velocidad de caminar sea independiente de la inclinación. Además pego el cuerpo al suelo hasta 0,3 m en rampas descendentes; sin eso, cada bajada se convierte en un pequeño salto.

Un único cast devuelve un único impacto, y donde se juntan dos superficies la normal que recibes cambia de fotograma a fotograma. Pasar a CapsuleCastNonAlloc con un buffer de ocho resultados y quedarme con la normal caminable más inclinada eliminó esa inestabilidad. Poner las consultas en su propia layer mask también ayudó: excluir triggers y volúmenes de daño redujo el coste de los cast en torno a un tercio.

Step offset y deslizamiento a lo largo de las paredes

Subir un escalón cuesta tres barridos: hacia arriba la altura del escalón (0,35 m), hacia delante lo que marque el movimiento y de nuevo hacia abajo. Si los tres están libres y el barrido descendente aterriza sobre una normal caminable, muevo el cuerpo allí; si no, cancelo y trato la superficie como una pared. Esos tres cast extra suponen unos 0,06 ms de los 0,28 ms por paso que medí para el motor.

El deslizamiento por paredes es el propio bucle de collide-and-slide. Al detectar un impacto proyecto el movimiento restante sobre el plano de contacto y vuelvo a barrer. Una sola proyección no basta en las esquinas interiores, así que permito como máximo cuatro iteraciones y descarto el movimiento sobrante si la cuarta sigue chocando. En la build donde las iteraciones no tenían límite, el frame time se disparaba a 4 ms en esquinas estrechas.

Por muy cuidadoso que sea el barrido, el cuerpo acaba colándose dentro de la geometría, sobre todo cuando lo empuja una plataforma móvil. Al final de cada paso de física mido el solape con Physics.ComputePenetration y empujo hacia fuera, con un tope de 0,2 m por paso. Sin ese tope el personaje se teletransportó una vez al otro lado de una pared: el solape medido superaba los 3 m porque un mesh collider de la escena tenía escala negativa.

El barrido de escalón lleva dos protecciones. La primera es que solo se ejecuta con el personaje en el suelo; cuando lo dejé activo en el aire, el personaje se auto-escalaba por cualquier pared contra la que corriera. La segunda es que el barrido hacia arriba cancela todo el intento de escalón si toca un techo; si no, el cuerpo se mete un instante dentro del techo en pasillos bajos.

Aceleración, frenado y control aéreo

El frenado es la causa más directa de la sensación de patinaje. En lugar de arrastrar la velocidad hacia un objetivo con Lerp, mantengo tasas separadas: 45 m/s² de aceleración en suelo, 60 m/s² de frenado y 12 m/s² en el aire. Con una velocidad de caminar de 6,2 m/s eso son unos 100 ms hasta la parada total desde que se suelta el stick, casi la mitad de los 180 ms de los que se quejaban los testers.

Que el frenado sea mayor que la aceleración es deliberado. Si los haces simétricos el personaje se siente poco reactivo; si subes mucho el frenado, se siente robótico. Una relación entre 1,3 y 1,5 me ha funcionado de forma consistente en todos mis proyectos. Probé a exponerlo como un AnimationCurve para diseño; no dio nada mejor que dos números y complicó la depuración.

En el aire cambian las reglas. Conservo el momento, no aplico frenado, sumo el input con la tasa de aceleración baja y limito la velocidad horizontal al máximo de suelo. El jugador puede corregir la dirección en el aire, pero no ganar velocidad saltando. En la build donde olvidé ese límite, los testers alcanzaron 1,4 veces la velocidad prevista con una combinación de salto y sprint y se salieron directamente de los límites del nivel.

Quedan dos ajustes en el lado del input. Corto la zona muerta analógica en 0,15 y remapeo el rango restante otra vez de cero a uno; si no, caminar despacio es imposible. También separé la orientación de la dirección de movimiento: el cuerpo gira a 720 grados por segundo mientras el vector de velocidad cambia al instante, porque poner la rotación por delante de la velocidad hacía que los cambios bruscos de dirección se sintieran con retardo.

Timestep fijo, interpolación y un orden de trabajo

El motor corre a un paso fijo de 50 Hz mientras la pantalla refresca a 144 Hz. Si escribes la posición directamente en el paso de física, algunos fotogramas de render muestran dos veces la misma pose. El micro-tembleque resultante vuelve como «da tirones», y la mayoría da por hecho que es una caída de frames y se va a buscarlo al perfil de GPU.

La solución es guardar la pose de física anterior y la actual e interpolar entre ambas dentro de Update según la fracción de tiempo sobrante. La raíz visual, es decir la mesh y el objetivo de cámara, va sobre la pose interpolada mientras la capsule de colisión se queda en la pose de física. Enganchar la cámara a esa misma pose interpolada en LateUpdate también importa: una lectura con un fotograma de retraso hace que el personaje parezca flotar por delante de la cámara.

La segunda ventaja del paso fijo es la repetibilidad. Como el movimiento siempre pasa por el mismo número de pasos, el mismo input produce el mismo recorrido aunque el frame time oscile; sin ello, la distancia de salto variaba unos 20 cm entre 60 Hz y 144 Hz. Cuando llega un fotograma largo limito el tiempo acumulado a cuatro pasos; si no, la simulación no se recupera nunca tras un tirón de carga.

Si estás oyendo la misma queja, trabaja en este orden: mide y arregla primero el tiempo de parada, luego añade el pegado al suelo, después el barrido de escalón y deja la interpolación para el final. Dibuja en pantalla los capsule cast y la normal del suelo en cada etapa; esa visualización me costó medio día de trabajo y me enseñó cada fallo de un vistazo durante las tres semanas siguientes. «Parece que patina» no es una frase vaga: es la suma de cuatro números que todavía no has medido.

← Todas las entradas