Movimiento Intermedio 15 min de lectura

Steering Behaviors II: pursuit, evade, collision avoidance

Predicción y reacción: calcula la posicion futura de un objetivo para atraparlo o esquivarlo.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

En Steering Behaviors I viste seek, flee y arrive: el agente reacciona a un target (o un threat) que ya está ahí. Funciona, pero falla en cuanto el target se mueve rápido: el perseguidor siempre llega tarde, el que huye corre hacia donde la amenaza ya estuvo.

Este tutorial cubre los tres behaviors que arreglan ese desfase:

  • Pursuit — perseguir prediciendo dónde el target va a estar.
  • Evade — huir prediciendo dónde el threat va a estar.
  • Collision Avoidance — esquivar lo que se interpone en tu camino, ya sea un obstáculo estático o un agente cruzándose.

La idea común es la misma: proyectar al futuro y aplicar la force ahora.

1. Demo

Pursuit con predicción Mueve el cursor con velocidad: el agente apunta a donde vas a estar, no a donde estás.
Mueve el cursor con velocidad: el agente apunta a tu predicción, no a tu posición actual. Apaga "predicción" para comparar.

2. Pursuit: perseguir el futuro

2.1 El problema de seek contra un blanco móvil

Seek siempre apunta a la position actual del target. Si el target se queda quieto, perfecto. Si se mueve, el perseguidor traza una curva tipo “perro persiguiendo correa”: siempre va detrás, nunca adelanta.

Mira la diferencia lado a lado: el target traza un círculo regular y los dos perseguidores intentan alcanzarlo.

El leader (rojo) hace un círculo. Izquierda: el agente persigue dónde está. Derecha: persigue dónde estará. Compara las trazas.

El de la izquierda (seek puro) hace una curva más cerrada, siempre atrás. El de la derecha (pursuit) corta la curva: se anticipa.

2.2 La idea: target predicho

Pursuit cambia una sola línea respecto a seek:

  • En vez de seek(target.position) haces seek(target.position + target.velocity * T).
  • T es cuánto tiempo en el futuro quiero apuntar.

¿Cuánto vale T? Lo más común y robusto: proporcional a la distancia al target, dividida por tu propia maxSpeed. Así, si está cerca, predices poco. Si está lejos, predices mucho.

T = distance(target.position, agent.position) / agent.maxSpeed
predictedTarget = target.position + target.velocity * T
desired = normalize(predictedTarget - agent.position) * maxSpeed

2.3 Pursuit paso a paso

  1. Calculo la distance entre el agente y el target.

    dist = length(target.position - agent.position)

  2. Calculo el tiempo de predicción.

    T = dist / maxSpeed (capado a un máximo, por ejemplo 1 segundo)

  3. Calculo la position futura del target.

    predicted = target.position + target.velocity * T

  4. Aplico seek clásico hacia esa position predicha.

    desired = normalize(predicted - agent.position) * maxSpeed

    steer = limit(desired - agent.velocity, maxForce)

  5. Aplico la force y actualizo como siempre.

    velocity += steer * dt (clamp a maxSpeed)

    position += velocity * dt

2.4 Pursuit avanzado: ajustar T por reactividad del target

Si el target es muy reactivo (cambia de dirección a menudo), una predicción larga te lleva al lado equivocado. Trucos comunes:

  • Capar T según la velocidad relativa: si el target es mucho más rápido que tú, baja T (tu predicción se vuelve poco confiable).
  • Promediar la velocity: en vez de target.velocity actual, usa una media móvil de los últimos N frames. Suaviza falsos positivos.
  • Apuntar a la línea entre actual y predicho según un peso α: aim = lerp(target.position, predicted, α). Con α = 0.5 te ahorras predicciones extremas.

3. Evade: huir del futuro

3.1 La idea

Evade es a pursuit lo que flee es a seek: cambia el signo. En vez de calcular desired hacia el predicted, lo calculas desde el predicted.

T = distance(threat.position, agent.position) / agent.maxSpeed
predictedThreat = threat.position + threat.velocity * T
desired = normalize(agent.position - predictedThreat) * maxSpeed

Lo demás es idéntico al pipeline de I: steer = limit(desired - velocity, maxForce), velocity += steer * dt, position += velocity * dt.

Mueve el cursor con velocidad: el agente huye de donde vas a estar, no de donde estás. Apaga "predicción" para ver flee puro.

Activa el toggle de predicción y mueve el cursor en zigzag rápido. Sin predicción el agente intenta huir de donde estuviste; con predicción huye de donde vas a estar. La diferencia se siente en milisegundos.

3.2 Cuándo NO usar evade

Evade con predicción es agresivo: el agente reacciona a tu movimiento futuro. En enemigos básicos esto los hace demasiado listos (frustrante para el jugador).

Reglas prácticas:

  • NPCs débiles que deben caer: usa flee puro. Que se sientan torpes.
  • Enemigos elite, jefes finales, esquivadores profesionales: usa evade. Que se sienta que te leen.
  • Jugadores controlables: nunca apliques predicción a su input — se siente desfasado.

3.3 Zona segura (revisión)

Evade hereda el problema de flee: nunca para. Aplica la safeRadius del tutorial I igual que con flee (cuando estás fuera del radio, desired = (0, 0) y el agente frena). Sin eso, tu enemigo huye al borde del mapa y se queda vibrando contra una pared.

4. Collision Avoidance: esquivar lo que estorba

Aquí hay dos sabores. Suelen confundirse y resuelven problemas distintos.

4.1 Obstacle Avoidance — variante (a): esquivar lo estático

Para qué sirve: rocas, edificios, cajas, paredes finitas. Cosas que no se mueven.

Cómo funciona: el agente proyecta un feeler (sensor) en la dirección de su velocity, de longitud lookAhead. Si un obstáculo cae dentro de la “caja” del feeler, calculas una force perpendicular al heading que aleja al agente del obstáculo.

Arrastra los obstáculos para ponerlos en el camino. El feeler (línea hacia adelante) detecta colisiones inminentes y aplica una fuerza lateral.

Arrastra los obstáculos para ponerlos en el camino del agente. La línea del feeler se pinta de rojo cuando hay colisión proyectada.

Paso a paso

  1. Si la velocity es casi cero, no hay nada que esquivar (no hay heading definido).

  2. Calcula el heading = normalize(velocity).

  3. Para cada obstáculo, proyéctalo sobre la línea del feeler:

    toObstacle = obstacle.position - agent.position
    along = dot(toObstacle, heading)   # distancia proyectada hacia adelante
    perp  = distance(obstacle.position, agent.position + heading * along)
    
  4. Si along < 0 o along > lookAhead → el obstáculo está detrás o demasiado lejos, ignóralo.

  5. Si perp > obstacle.radius + agent.radius → el feeler pasa de largo, no hay colisión.

  6. Si quedan candidatos, toma el más cercano (menor along).

  7. Calcula la fuerza lateral:

    side    = perpendicular(heading)        # vector a la izquierda
    sign    = side_of(obstacle, heading)    # +1 o -1 según de qué lado está
    urgency = 1 - along / lookAhead         # más fuerza cuanto más cerca la colisión
    avoid   = side * sign * maxSpeed * urgency
    
  8. Combina con la force de seek o pursuit, dándole prioridad al avoidance (peso 2x o más). Sin esa prioridad, el agente sigue empujando contra la pared.

4.2 Unaligned Collision Avoidance — variante (b): esquivar agentes móviles

Para qué sirve: otros agentes que también tienen velocity. Multitudes, tropas, NPCs cruzándose.

Cómo funciona: entre cada par de agentes, calculas el tiempo de mínima distancia asumiendo que ambos siguen rectos. Si en ese momento van a estar demasiado cerca, aplicas una force lateral ahora para que no choquen después.

Cada agente predice el punto de mínima distancia con cada vecino. Si el cruce es demasiado cerca, aplica una fuerza lateral.

Cada agente quiere llegar al lado opuesto. Sin avoidance se aplastan en el centro. Con avoidance bailan alrededor unos de otros.

Las matemáticas en una línea

Para dos agentes A y B con posiciones pA, pB y velocidades vA, vB:

relPos = pB - pA
relVel = vB - vA
tMin   = -dot(relPos, relVel) / dot(relVel, relVel)

tMin es el momento futuro de mayor cercanía. Si tMin < 0, ya pasó el momento (se están alejando, ignora). Si tMin > T_max, está demasiado lejos en el futuro (ignora).

La distancia mínima es:

futurePos = relPos + relVel * tMin
minDist   = length(futurePos)

Si minDist < avoidRadius, calcula una force que empuje al agente perpendicular a la trayectoria, en la dirección opuesta a futurePos.

4.3 Tabla comparativa: ¿cuál variante usar?

AspectoObstacle Avoidance (a)Unaligned Collision Avoidance (b)
Qué evitaObstáculos estáticos (rocas, edificios)Agentes con velocity propia
MecanismoFeeler hacia adelante + proyecciónTiempo de mínima distancia entre trayectorias
Coste por frameO(agentes × obstáculos)O(agentes²), mejorable con grid espacial
Falla cuando…El obstáculo es muy alargado o cóncavoLos agentes giran bruscamente (la predicción asume línea recta)
Combina conCualquier behavior de target (seek, pursuit, follow path)Flocking, queue, follow leader
Cuándo elegirloMapa con geometría fijaMultitudes, ejércitos, NPCs en zonas pobladas
Truco comúnMúltiples feelers (3 en abanico: centro, izq, der)Reducir frecuencia de chequeo (cada 2-3 frames) si hay muchos agentes

Lo normal es combinar ambos: primero el unaligned avoidance contra otros agentes, luego el obstacle avoidance contra el mapa. La fuerza final es la suma ponderada.

5. Pseudocódigo

function pursuit(agent: Agent, target: Agent) -> Vec2
    dist = distance(target.position, agent.position)
    T    = min(dist / agent.maxSpeed, T_max)
    predicted = target.position + target.velocity * T
    return seek(agent, predicted)

function evade(agent: Agent, threat: Agent, safeRadius: Float) -> Vec2
    dist = distance(threat.position, agent.position)
    if dist >= safeRadius
        # ya estoy a salvo: el deseo es velocidad cero
        desired = Vec2(0, 0)
    else
        T = min(dist / agent.maxSpeed, T_max)
        predicted = threat.position + threat.velocity * T
        scale     = agent.maxSpeed * (1 - dist / safeRadius)
        desired   = normalize(agent.position - predicted) * scale
    return limit(desired - agent.velocity, agent.maxForce)

function obstacleAvoid(agent: Agent, obstacles: Obstacle[], lookAhead: Float) -> Vec2
    if length(agent.velocity) < epsilon
        return Vec2(0, 0)
    heading = normalize(agent.velocity)
    closest = none
    closestAlong = +inf
    for each obs in obstacles
        toObs = obs.position - agent.position
        along = dot(toObs, heading)
        if along < 0 or along > lookAhead: continue
        perp  = distance(obs.position, agent.position + heading * along)
        if perp < obs.radius + agent.radius and along < closestAlong
            closest = obs
            closestAlong = along
    if closest == none
        return Vec2(0, 0)
    side    = perpendicular(heading)
    sign    = sign_of_side(closest.position, agent.position, heading)
    urgency = 1 - closestAlong / lookAhead
    return side * sign * agent.maxSpeed * urgency

function unalignedAvoid(agent: Agent, others: Agent[], avoidRadius: Float) -> Vec2
    push = Vec2(0, 0)
    for each other in others
        if other == agent: continue
        relPos = other.position - agent.position
        relVel = other.velocity - agent.velocity
        if lengthSq(relVel) < epsilon: continue
        tMin = -dot(relPos, relVel) / lengthSq(relVel)
        if tMin < 0 or tMin > T_max: continue
        futurePos = relPos + relVel * tMin
        minDist   = length(futurePos)
        if minDist > avoidRadius: continue
        urgency = 1 - minDist / avoidRadius
        push -= normalize(futurePos) * agent.maxSpeed * urgency
    return limit(push, agent.maxForce)

Patrón: cada función devuelve una force. El controller las suma (con pesos) y aplica el resultado al agente. Esto es tema central de Steering Behaviors III.

6. Implementación en Unity / C#

using UnityEngine;

public class SteeringAgentII : MonoBehaviour {
    public float maxSpeed = 5f;
    public float maxForce = 12f;
    public float lookAhead = 3f;
    public float tMax = 1.0f;
    public Transform leader;
    public ObstacleSO[] obstacles;

    Vector3 velocity;

    void Update() {
        var force = Vector3.zero;
        if (leader != null) {
            var leaderVel = (leader.position - lastLeaderPos) / Mathf.Max(Time.deltaTime, 0.001f);
            lastLeaderPos = leader.position;
            force += Pursuit(leader.position, leaderVel);
        }
        force += ObstacleAvoid() * 2f; // prioridad sobre el seek
        force = Vector3.ClampMagnitude(force, maxForce);

        velocity = Vector3.ClampMagnitude(velocity + force * Time.deltaTime, maxSpeed);
        transform.position += velocity * Time.deltaTime;
        if (velocity.sqrMagnitude > 0.01f) transform.forward = velocity.normalized;
    }

    Vector3 lastLeaderPos;

    Vector3 Pursuit(Vector3 targetPos, Vector3 targetVel) {
        var dist = Vector3.Distance(targetPos, transform.position);
        var T = Mathf.Min(dist / maxSpeed, tMax);
        var predicted = targetPos + targetVel * T;
        var desired = (predicted - transform.position).normalized * maxSpeed;
        return desired - velocity;
    }

    Vector3 ObstacleAvoid() {
        if (velocity.sqrMagnitude < 0.01f) return Vector3.zero;
        var heading = velocity.normalized;
        ObstacleSO closest = null;
        float closestAlong = float.MaxValue;
        foreach (var o in obstacles) {
            var to = o.position - transform.position;
            var along = Vector3.Dot(to, heading);
            if (along < 0f || along > lookAhead) continue;
            var perp = (o.position - (transform.position + heading * along)).magnitude;
            if (perp > o.radius) continue;
            if (along < closestAlong) { closest = o; closestAlong = along; }
        }
        if (closest == null) return Vector3.zero;
        var side = new Vector3(-heading.z, 0f, heading.x); // perpendicular en plano XZ
        var toObs = closest.position - transform.position;
        var sign = Vector3.Dot(side, toObs) > 0f ? -1f : 1f;
        var urgency = 1f - closestAlong / lookAhead;
        return side * sign * maxSpeed * urgency;
    }
}

[System.Serializable]
public class ObstacleSO {
    public Vector3 position;
    public float radius;
}

7. En otros engines

  • Godot: pursuit y evade viven en el _physics_process de un CharacterBody3D o RigidBody3D. Para obstacle avoidance, Godot ya trae NavigationAgent3D.avoidance_enabled que usa RVO internamente — más sofisticado que la versión de Reynolds, pero menos didáctico.
  • Unreal: el CharacterMovementComponent no expone esto directamente; lo implementas en tu AIController aplicando SetForceCustom. El RVOAvoidance plugin maneja el caso de agentes móviles.
  • JavaScript / Canvas: idéntico al pseudocódigo. La demo de arriba lo prueba.

8. Quiz

Pon a prueba lo que entendiste

Responde una por una. La explicación aparece al elegir, correcta o no.

  1. Subes T_max a 5 segundos en pursuit. ¿Qué problema esperarías?

  2. Tienes un enemigo básico que debe sentirse torpe. ¿Qué eliges?

  3. Tu agente ignora obstacle avoidance. Compruebas que el feeler detecta colisión. ¿Causa más probable?

  4. Dos agentes vienen de frente, mismo maxSpeed. Sin avoidance se chocan. Activas unaligned collision avoidance y siguen chocando. ¿Qué falla?

  5. ¿Cuál es la diferencia esencial entre obstacle avoidance (a) y unaligned collision avoidance (b)?