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
targetva a estar. - Evade — huir prediciendo dónde el
threatva 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
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)hacesseek(target.position + target.velocity * T). Tes 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
-
Calculo la
distanceentre el agente y eltarget.dist = length(target.position - agent.position) -
Calculo el tiempo de predicción.
T = dist / maxSpeed(capado a un máximo, por ejemplo 1 segundo) -
Calculo la
positionfutura deltarget.predicted = target.position + target.velocity * T -
Aplico
seekclásico hacia esapositionpredicha.desired = normalize(predicted - agent.position) * maxSpeedsteer = limit(desired - agent.velocity, maxForce) -
Aplico la
forcey actualizo como siempre.velocity += steer * dt(clamp amaxSpeed)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
targetes mucho más rápido que tú, bajaT(tu predicción se vuelve poco confiable). - Promediar la velocity: en vez de
target.velocityactual, 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.5te 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.
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
fleepuro. 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 del agente. La línea del feeler se pinta de rojo cuando hay colisión proyectada.
Paso a paso
-
Si la
velocityes casi cero, no hay nada que esquivar (no hay heading definido). -
Calcula el
heading = normalize(velocity). -
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) -
Si
along < 0oalong > lookAhead→ el obstáculo está detrás o demasiado lejos, ignóralo. -
Si
perp > obstacle.radius + agent.radius→ el feeler pasa de largo, no hay colisión. -
Si quedan candidatos, toma el más cercano (menor
along). -
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 -
Combina con la
forcede 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 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?
| Aspecto | Obstacle Avoidance (a) | Unaligned Collision Avoidance (b) |
|---|---|---|
| Qué evita | Obstáculos estáticos (rocas, edificios) | Agentes con velocity propia |
| Mecanismo | Feeler hacia adelante + proyección | Tiempo de mínima distancia entre trayectorias |
| Coste por frame | O(agentes × obstáculos) | O(agentes²), mejorable con grid espacial |
| Falla cuando… | El obstáculo es muy alargado o cóncavo | Los agentes giran bruscamente (la predicción asume línea recta) |
| Combina con | Cualquier behavior de target (seek, pursuit, follow path) | Flocking, queue, follow leader |
| Cuándo elegirlo | Mapa con geometría fija | Multitudes, ejércitos, NPCs en zonas pobladas |
| Truco común | Mú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:
pursuityevadeviven en el_physics_processde unCharacterBody3DoRigidBody3D. Para obstacle avoidance, Godot ya traeNavigationAgent3D.avoidance_enabledque usa RVO internamente — más sofisticado que la versión de Reynolds, pero menos didáctico. - Unreal: el
CharacterMovementComponentno expone esto directamente; lo implementas en tuAIControlleraplicandoSetForceCustom. ElRVOAvoidanceplugin 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.
Subes T_max a 5 segundos en pursuit. ¿Qué problema esperarías?
Tienes un enemigo básico que debe sentirse torpe. ¿Qué eliges?
Tu agente ignora obstacle avoidance. Compruebas que el feeler detecta colisión. ¿Causa más probable?
Dos agentes vienen de frente, mismo maxSpeed. Sin avoidance se chocan. Activas unaligned collision avoidance y siguen chocando. ¿Qué falla?
¿Cuál es la diferencia esencial entre obstacle avoidance (a) y unaligned collision avoidance (b)?