Movimiento Avanzado 17 min de lectura

Steering Behaviors IV: follow leader, queue y path following

Sigue a un líder, formar filas, recorrer caminos. La capa táctica del steering.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

Hasta aquí los behaviors han sido individuales: un agente, un target o un threat, y reglas locales. Funcionan, pero no escalan a situaciones tácticas: un escuadrón siguiendo a su capitán, NPCs entrando en una tienda en orden, una patrulla por un circuito.

En este tutorial vivimos esos tres casos:

  • Follow Leader — un grupo se acomoda detrás de un líder móvil, sin pisarse, sin meterse en su camino.
  • Queue — agentes que llegan al mismo punto forman una fila natural, sin necesidad de un sistema de turnos central.
  • Path Following — el agente recorre un camino. Variante (a) waypoints discretos, variante (b) path tube de Reynolds.

Todos los behaviors aquí son forces que enchufas en el SteeringController de Steering Behaviors III. El composite es lo que hace posible combinarlos sin que el código se rompa.

1. Demo

Follow leader con zona prohibida Mueve el cursor: los followers se acomodan detrás. Si entran al cono frontal del leader, son empujados a un costado.
Mueve el cursor: los followers se acomodan detrás. La zona roja punteada delante del leader es la "no-go zone" — si entran ahí, son empujados a un costado.

2. Follow Leader

2.1 La intuición

Quieres que un grupo siga a su capitán. La solución ingenua: que cada follower haga seek(leader.position). Resultado: todos se apilan literalmente sobre el leader, lo empujan y lo bloquean. Ridículo.

Reynolds resolvió esto con tres ideas combinadas:

  1. El target NO es el leader — es un punto detrás del leader (en -leader.heading * behindDist).
  2. Hay una zona prohibida delante del leader — si un follower entra, recibe una force de evade que lo aparta a un costado.
  3. Los followers se separan entre sí — separation behavior, igual que en flocking.

2.2 Follow Leader paso a paso

  1. Obtener el heading del leader.

    leader.heading = normalize(leader.velocity) (si está parado, conserva el último heading válido)

  2. Calcular el target detrás del leader.

    behindPoint = leader.position - leader.heading * behindDist

  3. Calcular la posición prohibida frente al leader.

    aheadPoint = leader.position + leader.heading * aheadDist

  4. Para cada follower:

    • Si distance(follower, aheadPoint) < aheadRadius → aplicar evade(aheadPoint) con peso alto. Está estorbando.
    • Aplicar arrive(behindPoint) — al estar detrás, frena suavemente al llegar.
    • Aplicar separation contra los demás followers, peso medio.
  5. Pasar el resultado al SteeringController para que lo combine con peso normal.

2.3 Por qué arrive y no seek

Si los followers usan seek(behindPoint), llegan al punto a velocidad máxima, se pasan, regresan y oscilan — exactamente el problema de Steering Behaviors I. Arrive les hace desacelerar al llegar, así se asientan detrás del leader y mantienen la formación cuando este se detiene.

2.4 Variantes que valen la pena

  • Formaciones — en vez de un único behindPoint, asignar a cada follower un offset relativo al leader (formación V, columna, escuadrón). Cada slot es un target diferente.
  • Slots dinámicos — los followers compiten por los slots libres más cercanos. Útil si los followers entran y salen del grupo.
  • Predicción del leader — usar pursuit en vez de seek/arrive puro hacia el behindPoint. Hace que el grupo “anticipe” giros del leader.

3. Queue

3.1 El problema

Diez agentes corren al mismo target. Si todos hacen seek(goal), llegan a la vez y se encajonan en un grumo. Lo quieres es que formen una fila natural.

3.2 La idea

No hay un sistema central que asigne turnos. Cada agente decide localmente:

  • Sigo a mi target (seek o arrive normal).
  • Si tengo otro agente delante de mí (en un cono frontal corto), bajo mi velocidad deseada.
  • Aplico separation para no chocar lateralmente.

La combinación produce el orden emergente.

Cada agente quiere llegar al goal. Si detecta a otro adelante (cono frontal), reduce su velocidad. Junto con separation, esto forma una fila natural.

Cada agente reaparece a la izquierda cuando llega al goal. Mira cómo se forma la fila sola.

3.3 Detección del cono frontal

Para saber si hay alguien delante, proyectamos cada vecino sobre la dirección del agente:

heading = normalize(agent.velocity)
for each other in agents:
    offset = other.position - agent.position
    along  = dot(offset, heading)
    if along < 0 or along > coneLength: continue   # detrás o muy lejos
    perp = length(offset - heading * along)
    if perp > coneWidth: continue                  # no está en el carril
    # Hay alguien delante: tomar el más cercano (menor along)

Si encuentras a alguien, escalas la velocidad deseada según along / coneLength (más cerca = más freno).

3.4 Por qué este patrón es robusto

  • Sin coordinación central — no hace falta un manager que reparta turnos.
  • Resilient — si quitas o agregas un agente, la fila se reacomoda sola.
  • Escalable — con grid espacial, el cono cuesta O(vecinos) por agente, no O(N).
  • Combinable — el “brake si hay alguien delante” es una force más en el controller.

4. Path Following

4.1 ¿Por qué existe?

Hay momentos donde el agente debe seguir una ruta predefinida: una patrulla, un circuito de carrera, una ruta de NPC en una ciudad, un escolta. No estás persiguiendo un target móvil — estás recorriendo geometría fija.

Hay dos formas clásicas de modelar esto. Las dos sirven; resuelven matices distintos.

4.2 Variante (a): Waypoints discretos

El path es una lista de puntos [w0, w1, w2, ...]. El agente:

  1. Apunta al waypoint actual.
  2. Aplica seek (en intermedios) o arrive (en el último, si no hace loop).
  3. Cuando entra en arriveRadius del waypoint actual, avanza al siguiente índice.
Arrastra los waypoints. Click en zona vacía añade uno nuevo. El círculo punteado es el arriveRadius del waypoint actual: al entrar, salta al siguiente.

Arrastra los waypoints. Click en el canvas vacío añade uno nuevo. El círculo punteado es el arriveRadius del waypoint activo: cuando el agente entra, salta al siguiente.

Pros y contras

  • Pro: trivial de implementar, fácil de visualizar, fácil de tunear.
  • Pro: encaja bien con grafos de navegación (un waypoint = un nodo del grafo).
  • Contra: el agente toma curvas con esquinas duras en cada waypoint. Si pasas a 60 km/h, hace zigzag.
  • Contra: si arriveRadius es muy pequeño, el agente da vueltas alrededor del waypoint sin “tocarlo”. Si es muy grande, corta esquinas demasiado pronto.

4.3 Variante (b): Path tube de Reynolds

La idea: el path no son puntos, es una polilínea con grosor. Mientras el agente esté dentro del tubo, está libre — no hay force correctora. Solo cuando se sale, el sistema lo empuja de vuelta.

El truco del 0.5 — predicción:

  1. Calcular la posición futura: predicted = position + velocity * predictAhead.
  2. Encontrar el segmento del path más cercano a predicted.
  3. Proyectar predicted sobre ese segmento → punto proj.
  4. Si distance(predicted, proj) > tubeRadius → aplicar seek(proj) para regresar al tubo.
  5. Si distance(predicted, proj) ≤ tubeRadius → no aplicar nada (o un seek suave hacia el waypoint siguiente para mantener avance).
El tube verde es la zona libre. El agente proyecta su posición futura (línea punteada). Si esa predicción cae fuera del tube, se corrige; si cae dentro, sigue libre.

La línea verde es el path; el área verde claro es el tubo. La línea punteada es la predicción. Cuando esa predicción cae fuera del tubo (línea roja), el agente recibe una corrección.

Pros y contras

  • Pro: curvas suaves. El agente puede tomar un atajo tangente entre segmentos sin zigzag.
  • Pro: se puede combinar con otros behaviors sin que peleen — el path solo corrige cuando es necesario.
  • Contra: más matemática (proyección sobre segmentos).
  • Contra: si los segmentos son muy desiguales, “el más cercano” puede saltar de uno a otro de forma confusa.

4.4 Tabla comparativa: ¿waypoints o tube?

AspectoWaypoints (a)Path tube (b) — Reynolds
Modelo del pathLista de puntos discretosPolilínea con grosor (tubo)
Trayectoria visibleEsquinas duras en cada waypointCurvas suaves, atajos tangentes
Coste por frameO(1) — solo el waypoint actualO(segmentos) — proyectar en cada uno
Tuning críticoarriveRadius (overshoot vs zigzag)tubeRadius (rigidez vs deriva) y predictAhead
Encaja con grafos de navegaciónDirecto (1 waypoint = 1 nodo)Requiere convertir el grafo a polilínea
Combina con otros behaviorsAceptable, pero pelea con avoidanceExcelente — solo corrige cuando hace falta
Cuándo elegirloPatrullas con paradas, paths cortos, mobile (CPU justa)Carreras, vuelos, persecuciones largas, sensación arcade

Patrón pragmático: en producción se ven los dos. Waypoints para NPCs de baja prioridad y patrullas; tube para vehículos, vuelo, y todo lo que necesite “feel” arcade.

4.5 Loops, idas y vueltas, y reverso

Tres modos típicos:

  • One-shot — del waypoint 0 al N-1, frenar al final con arrive. Una patrulla a un puesto.
  • Loop — al llegar al N-1, volver al 0. Circuitos, rondas.
  • Ping-pong — al llegar al N-1, invertir dirección hacia 0; al llegar a 0, volver a invertir. Patrullas de ida y vuelta.

Implementación: una variable direction ∈ {+1, -1} y una rama en el “avanzar índice” para flipearla en los extremos.

5. Pseudocódigo completo

function followLeader(follower: Agent, leader: Agent, params: FollowParams) -> Vec2
    heading = leader.headingOrLastValid()
    behindPoint = leader.position - heading * params.behindDist
    aheadPoint  = leader.position + heading * params.aheadDist

    force = Vec2(0, 0)
    # 1) Si el follower está delante del leader, evade
    if distance(follower.position, aheadPoint) < params.aheadRadius
        force += evade(follower, virtualThreatAt(aheadPoint, leader.velocity)) * 2.0

    # 2) Arrive al punto detrás
    force += arrive(follower, behindPoint, params.slowRadius)

    # 3) Separación contra otros followers
    force += separation(follower, otherFollowers, params.sepRadius) * 0.6

    return limit(force, follower.maxForce)

function queueBrake(agent: Agent, others: Agent[], params: QueueParams) -> Float
    if length(agent.velocity) < epsilon: return 1.0  # sin freno
    heading = normalize(agent.velocity)
    closestAlong = +inf
    for each other in others
        if other == agent: continue
        offset = other.position - agent.position
        along  = dot(offset, heading)
        if along < 0 or along > params.coneLength: continue
        perp = distance(offset, heading * along)
        if perp > params.coneWidth: continue
        if along < closestAlong: closestAlong = along
    if closestAlong == +inf: return 1.0
    return clamp(closestAlong / params.coneLength, 0, 1)

function pathFollowWaypoints(agent: Agent, path: PathState) -> Vec2
    target = path.waypoints[path.currentIdx]
    if distance(agent.position, target) < path.arriveRadius
        path.advance()  # avanza índice según mode (loop, one-shot, ping-pong)
    isFinal = (path.mode == "one-shot" and path.currentIdx == last)
    return isFinal
        ? arrive(agent, target, path.slowRadius)
        : seek(agent, target)

function pathFollowTube(agent: Agent, path: Path, params: TubeParams) -> Vec2
    predicted = agent.position + agent.velocity * params.predictAhead
    bestSeg, proj, dist = findClosestSegment(predicted, path.segments)
    if dist > params.tubeRadius
        # fuera del tubo: seek al punto proyectado
        return seek(agent, proj)
    else
        # dentro: mantener avance hacia el siguiente waypoint del segmento
        nextWp = path.waypoints[bestSeg + 1]
        return seek(agent, nextWp)

6. Implementación en Unity / C#

using System.Collections.Generic;
using UnityEngine;

public class FollowLeader : SteeringBehavior {
    public Transform leader;
    public List<SteeringAgent> otherFollowers;
    public float behindDist = 2f;
    public float aheadDist = 1.5f;
    public float aheadRadius = 1.5f;
    public float slowRadius = 1f;
    public float sepRadius = 1.2f;

    Vector3 lastHeading = Vector3.forward;
    Vector3 lastLeaderPos;

    public override Vector3 Calculate(SteeringAgent agent) {
        if (leader == null) return Vector3.zero;
        var leaderVel = (leader.position - lastLeaderPos) / Mathf.Max(Time.deltaTime, 0.001f);
        lastLeaderPos = leader.position;
        if (leaderVel.sqrMagnitude > 0.01f) lastHeading = leaderVel.normalized;

        var behindPoint = leader.position - lastHeading * behindDist;
        var aheadPoint  = leader.position + lastHeading * aheadDist;

        var force = Vector3.zero;

        // 1) Evade si está en zona prohibida
        if (Vector3.Distance(agent.transform.position, aheadPoint) < aheadRadius) {
            var away = (agent.transform.position - aheadPoint).normalized * agent.maxSpeed;
            force += (away - agent.velocity) * 2f;
        }

        // 2) Arrive al behindPoint
        var to = behindPoint - agent.transform.position;
        var dist = to.magnitude;
        var speed = agent.maxSpeed * Mathf.Min(1f, dist / slowRadius);
        var desired = to.normalized * speed;
        force += desired - agent.velocity;

        // 3) Separation
        foreach (var o in otherFollowers) {
            if (o == agent) continue;
            var off = agent.transform.position - o.transform.position;
            var d = off.magnitude;
            if (d > 0.01f && d < sepRadius) {
                var pushSpeed = agent.maxSpeed * (1f - d / sepRadius);
                force += (off.normalized * pushSpeed - agent.velocity) * 0.6f;
            }
        }

        return force;
    }
}

public class PathFollowWaypoints : SteeringBehavior {
    public Vector3[] waypoints;
    public float arriveRadius = 0.8f;
    public float slowRadius = 1.5f;
    public bool loop = true;
    int currentIdx;

    public override Vector3 Calculate(SteeringAgent agent) {
        if (waypoints == null || waypoints.Length == 0) return Vector3.zero;
        var target = waypoints[currentIdx];
        var dist = Vector3.Distance(agent.transform.position, target);
        var isFinal = !loop && currentIdx == waypoints.Length - 1;

        if (dist < arriveRadius && !isFinal) {
            currentIdx = (currentIdx + 1) % waypoints.Length;
            target = waypoints[currentIdx];
        }

        var to = target - agent.transform.position;
        var speed = isFinal ? agent.maxSpeed * Mathf.Min(1f, dist / slowRadius) : agent.maxSpeed;
        var desired = to.normalized * speed;
        return desired - agent.velocity;
    }
}

public class PathFollowTube : SteeringBehavior {
    public Vector3[] waypoints;
    public float tubeRadius = 1f;
    public float predictAhead = 0.5f;

    public override Vector3 Calculate(SteeringAgent agent) {
        if (waypoints == null || waypoints.Length < 2) return Vector3.zero;
        var predicted = agent.transform.position + agent.velocity * predictAhead;

        Vector3 bestProj = waypoints[0];
        float bestDist = float.MaxValue;
        int bestSeg = 0;
        for (int i = 0; i < waypoints.Length - 1; i++) {
            var (proj, dist) = ProjectOnSegment(predicted, waypoints[i], waypoints[i + 1]);
            if (dist < bestDist) { bestProj = proj; bestDist = dist; bestSeg = i; }
        }

        Vector3 aim;
        if (bestDist > tubeRadius) {
            aim = bestProj;
        } else {
            aim = waypoints[Mathf.Min(bestSeg + 1, waypoints.Length - 1)];
        }
        var desired = (aim - agent.transform.position).normalized * agent.maxSpeed;
        return desired - agent.velocity;
    }

    static (Vector3, float) ProjectOnSegment(Vector3 p, Vector3 a, Vector3 b) {
        var ab = b - a;
        var ap = p - a;
        var lenSq = Mathf.Max(ab.sqrMagnitude, 0.0001f);
        var t = Mathf.Clamp01(Vector3.Dot(ap, ab) / lenSq);
        var proj = a + ab * t;
        return (proj, Vector3.Distance(p, proj));
    }
}

7. En otros engines

  • Godot: para path following, Godot trae Path3D + PathFollow3D que ya hacen el trabajo de proyección. Para follow leader y queue, las clases composite se transcriben directo a Resource.
  • Unreal: USplineComponent cubre paths con curvas. Follow leader vive en el AAIController o como UBehaviorTree task.
  • JavaScript / Canvas: la lib del sitio implementa los tres patrones en ~/lib/viz/sim/.

8. Quiz

Pon a prueba lo que entendiste

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

  1. Implementas follow leader con seek directo al leader. Los followers se apilan sobre él. ¿Mejor primer fix?

  2. En queue, dos agentes están exactamente en línea uno detrás del otro pero ambos siguen avanzando a maxSpeed. ¿Causa probable?

  3. Patrullas en una ciudad, NPCs lentos, CPU justa en mobile. ¿Path following recomendado?

  4. En path tube, el agente vibra entre dentro y fuera del tubo. ¿Causa más probable?

  5. ¿Por qué follow leader necesita ZONA PROHIBIDA delante del leader si los followers ya apuntan al behindPoint?

  6. Necesitas un agente que recorra un camino circular y al final empiece de nuevo, sin frenar al cerrar el loop. ¿Modo apropiado?