Movimiento Intermedio 16 min de lectura

Steering Behaviors III: wander y steering controller

Aprende a deambular/vagar. Agrupa y mezcla fuerzas en un solo controller: La columna vertebral del steering.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

Hasta aquí todos los behaviors necesitaban un target o un threat. ¿Qué pasa cuando el agente no tiene a dónde ir? Un guardia en patrulla, un peatón cualquiera, un pez en una pecera, un NPC esperando.

La respuesta clásica es wander: un movimiento que se parece a tener intención. No la tiene, pero el espectador no nota la diferencia.

Y al final de este tutorial está la pieza que sostiene todo lo demás: el Steering Controller. Hasta ahora calcular seek + flee era una suma a mano. Pero un agente real combina 3, 5, 10 behaviors. Si los enchufas a pelo, terminas con un Update() de 200 líneas. Vamos a refactorizarlo en un patrón composite limpio que escala.

En este tutorial vas a ver:

  • Wander de Reynolds (el clásico, con círculo de proyección).
  • Wander con Perlin/value-noise (la alternativa minimalista).
  • Tabla comparativa para elegir cuál.
  • El Steering Controller — clase abstracta + behaviors concretos + weighted blending.
  • Por qué el orden y los pesos son lo que diferencia un agente bueno de uno mediocre.

1. Demo

Wander de Reynolds El círculo punteado al frente del agente es el 'wander circle'. El punto verde salta dentro de él aplicando jitter al ángulo. El agente lo persigue.
El círculo punteado es el "wander circle". El punto verde salta dentro de él aplicando jitter al ángulo. El agente persigue ese punto y traza una caminata orgánica.

2. Wander — variante (a): Reynolds clásico

2.1 La intuición

Imagina que el agente lleva un target invisible flotando justo delante de él, atado a una correa elástica. Ese target rebota de un lado a otro de forma suave (no salta de izquierda a derecha de golpe — se desliza). El agente persigue ese target con un seek normal, y como el target nunca se está quieto, el agente describe una caminata orgánica.

La elegancia de Reynolds está en cómo modela ese target con tres parámetros simples:

  • circleDist — qué tan lejos del agente está el target.
  • circleRadius — cuánto puede oscilar lateralmente.
  • jitter — qué tan rápido cambia de posición.

2.2 Wander Reynolds paso a paso

  1. Calcula el heading del agente (la dirección actual de la velocity).

    heading = normalize(agent.velocity) (si está parado, usa una dirección por defecto)

  2. Proyecta el centro del wander circle delante del agente.

    circleCenter = agent.position + heading * circleDist

  3. Suma jitter al ángulo persistente del wander.

    wanderAngle += randUniform(-1, 1) * jitter * dt

    El truco clave es que wanderAngle se acumula entre frames. Si lo regeneras en cada frame, el target salta como loco. Lo que quieres es una caminata aleatoria suave del ángulo.

  4. Calcula el target sobre el círculo, partiendo del heading actual.

    finalAngle = atan2(heading.y, heading.x) + wanderAngle
    target     = circleCenter + (cos(finalAngle), sin(finalAngle)) * circleRadius
    
  5. Aplica seek clásico hacia ese target.

    force = seek(agent, target)

2.3 Por qué el círculo está delante del agente

Si pusieras el círculo centrado en el agente, el target podría estar atrás y el agente daría vueltas sobre sí mismo. Al ponerlo delante, garantizas que el target siempre apunta hacia dónde el agente “estaría yendo si siguiera recto”, y el jitter solo lo desvía un poco. Eso es lo que produce el efecto natural.

circleDist controla qué tan decidido se ve el agente: si lo subes mucho, el agente se vuelve recto y aburrido (target muy lejos, jitter casi imperceptible). Si lo bajas, se vuelve errático (target muy cerca, cualquier jitter lo desvía drásticamente).

circleRadius controla qué tan inquieto se ve: si lo subes, los giros son más bruscos.

jitter controla qué tan frenético: a 0 el agente va recto eterno, a un valor alto se vuelve epiléptico.

3. Wander — variante (b): Perlin / value-noise sobre el ángulo

3.1 La idea

Si todo lo que quieres es “que el agente camine de forma natural”, el círculo es overkill. Hay una versión más minimalista:

  • Mantén un ángulo persistente agent.angle.
  • En cada frame, suma noise(t * freq) * amp * dt a ese ángulo.
  • Mueve al agente en (cos(angle), sin(angle)) * maxSpeed.

noise(x) es una función que devuelve un valor entre -1 y 1, suave (sus valores cercanos en x son cercanos en salida). Perlin noise sirve, value-noise interpolado también, incluso un seno más un seno desfasado funciona si no tienes ganas de implementar el ruido.

Wander con noise 1D: el ángulo se modula con un ruido suave. Más simple que Reynolds, sin círculo, pero menos parámetros expresivos.

El gráfico de abajo es el noise en tiempo real. Subir noiseFreq lo hace cambiar más rápido (giros más cortos). Subir noiseAmp los hace más bruscos.

3.2 Por qué es distinto del random puro

Si en cada frame haces angle += randUniform(-1, 1) * dt, el ángulo hace una caminata aleatoria que técnicamente sirve, pero estadísticamente diverge: a la larga el agente apunta a cualquier dirección con igual probabilidad y la trayectoria es ruidosa.

noise(t) hace que valores cercanos en el tiempo sean correlacionados: si en t=1.0 el noise vale 0.7, en t=1.01 valdrá algo cercano a 0.7. Eso produce trayectorias que parecen pensadas, con curvas suaves, sin epilepsia.

4. Tabla comparativa: ¿Reynolds o Perlin?

AspectoReynolds (a) — círculo + jitterPerlin/noise (b) — ruido sobre ángulo
ConceptoTarget invisible que rebota en un círculo al frenteÁngulo modulado por noise temporal
Parámetros3 (circleDist, circleRadius, jitter)2 (noiseFreq, noiseAmp)
Composable con seek/fleeSí — devuelve una force, se mezcla con otrasMecanismo aparte: define el desired directamente, NO calza igual de limpio
Visualizable / didácticoExcelente — ves círculo, target y headingPobre — el “qué pasa” no se ve, solo el resultado
Determinismo (mismo seed = misma trayectoria)Sí, con random seedeadoSí, con noise seedeado
Coste por frameO(1), 1 random + 1 trigO(1), 1 lookup de noise
Sensación visualCurvas con personalidad (puede sentirse “circular”)Caminata flotante, neutral
Cuándo elegirloCuando wander va mezclado con otros behaviorsCuando wander es lo único y quieres simplicidad

Recomendación práctica: Reynolds gana en el 80% de los casos porque encaja con el patrón “todo es una force y se suma”. Perlin gana cuando el agente solo deambula y no se va a mezclar con otros behaviors (peces, partículas ambientales, NPCs decorativos).

5. El Steering Controller — el patrón composite

Llegamos al pez gordo. Hasta ahora hemos calculado force = seek(...) + flee(...) a mano. Eso funciona para 2 behaviors. ¿Qué pasa cuando un agente tiene 8 behaviors activos condicionalmente — pursuit del jugador, flee de explosiones, wander cuando está aburrido, obstacle avoidance, separación de amigos, follow leader, queue al pasar por puertas estrechas, arrive al objetivo final?

Si lo dejas como suma a mano:

  • El método Update() crece sin control.
  • No puedes activar/desactivar behaviors en runtime sin meter ifs en cascada.
  • Probar valores se vuelve doloroso: hay que recompilar para cambiar pesos.
  • No puedes reusar entre tipos de agente — un guardia y un perseguidor copy-pastean código.

La solución: un controller composite. Cada behavior es una clase que devuelve una force. El controller las recolecta, las pondera, las suma, y aplica el resultado.

5.1 La clase abstracta

abstract class SteeringBehavior:
    weight: Float = 1.0
    enabled: Bool = true

    abstract function calculate(agent: Agent) -> Vec2

Cada behavior concreto extiende esto:

class Seek extends SteeringBehavior:
    target: Vec2

    function calculate(agent: Agent) -> Vec2:
        desired = normalize(target - agent.position) * agent.maxSpeed
        return desired - agent.velocity

class Wander extends SteeringBehavior:
    circleDist:   Float
    circleRadius: Float
    jitter:       Float
    wanderAngle:  Float = 0   # persistente entre frames

    function calculate(agent: Agent) -> Vec2:
        # ... lógica de wander Reynolds

5.2 El controller

class SteeringController:
    behaviors: SteeringBehavior[]

    function compute(agent: Agent) -> Vec2:
        total = Vec2(0, 0)
        for each behavior in behaviors
            if not behavior.enabled: continue
            force = behavior.calculate(agent)
            total += force * behavior.weight
        return limit(total, agent.maxForce)

Y el Update() del agente queda así de limpio:

function update(agent: Agent, dt: Float):
    force = agent.controller.compute(agent)
    agent.velocity = limit(agent.velocity + force * dt, agent.maxSpeed)
    agent.position += agent.velocity * dt

5.3 Demo: tres behaviors mezclados

El controller suma 3 behaviors con pesos. Mueve el cursor (threat) y juega con los sliders: ves cómo cada peso cambia la mezcla y el comportamiento resultante.

Ese controller tiene tres behaviors enchufados: seek a un waypoint orbital, flee del cursor, wander. Mueve los sliders de peso y mira:

  • Con seek=1, flee=0, wander=0: el agente persigue el waypoint sin desviarse.
  • Con seek=0, flee=2, wander=0: el agente solo huye del cursor.
  • Con seek=1, flee=2, wander=0.5: balance — persigue, pero esquiva al cursor y nunca va perfectamente recto.

Las flechas de colores son las forces individuales. La flecha violeta gruesa es la suma final.

5.4 Weighted sum vs Priority blending

La fórmula total = sum(force_i * weight_i) es weighted sum — todos los behaviors contribuyen siempre. Funciona bien hasta que tienes contradicciones fuertes: si seek empuja al norte con fuerza 100 y flee empuja al sur con fuerza 100, el agente se queda paralizado.

La alternativa es priority blending: los behaviors se evalúan en orden, cada uno consume parte del presupuesto de maxForce, y los siguientes solo ven lo que queda.

function computePriority(agent: Agent) -> Vec2:
    total = Vec2(0, 0)
    budget = agent.maxForce
    for each behavior in behaviors_in_priority_order
        if not behavior.enabled: continue
        force = behavior.calculate(agent) * behavior.weight
        # cuánto de esta fuerza cabe en el presupuesto
        mag = length(force)
        if mag > budget:
            force = normalize(force) * budget
        total += force
        budget -= length(force)
        if budget <= 0: break
    return total

Cuándo elegir cuál:

SituaciónMejor enfoque
Pocos behaviors, todos compatibles (seek + wander)Weighted sum
Behaviors que pueden contradecirse fuerte (seek + obstacle avoidance)Priority blending — avoidance gana siempre
Necesitas tunear con sliders en runtimeWeighted sum (más predecible)
Necesitas garantizar que el avoidance “siempre tenga la última palabra”Priority blending

Patrón pragmático: usa weighted sum como base; pon obstacle avoidance y unaligned avoidance fuera del sum, aplicándolas al resultado con prioridad. Es el equilibrio que más se ve en producción.

5.5 Beneficios concretos del composite

  • Activar/desactivar behaviors en runtimecontroller.behaviors[2].enabled = false.
  • Distintos tipos de agente — un guardia tiene un controller, un perseguidor otro, sin tocar la lógica del agente.
  • Tunear pesos en el inspector / debug UI sin recompilar.
  • Tests unitarios — cada behavior se prueba aislado.
  • Debug visual — el controller puede exponer las forces individuales para dibujarlas (justo lo que hace la demo).

6. Pseudocódigo completo

function wanderReynolds(agent: Agent, params: WanderParams) -> Vec2
    if length(agent.velocity) < epsilon
        heading = Vec2(1, 0)
    else
        heading = normalize(agent.velocity)
    circleCenter = agent.position + heading * params.circleDist
    params.wanderAngle += randUniform(-1, 1) * params.jitter * dt
    finalAngle = atan2(heading.y, heading.x) + params.wanderAngle
    target = circleCenter + Vec2(cos(finalAngle), sin(finalAngle)) * params.circleRadius
    desired = normalize(target - agent.position) * agent.maxSpeed
    return limit(desired - agent.velocity, agent.maxForce)

function wanderPerlin(agent: Agent, params: PerlinParams) -> Vec2
    n = noise1D(t * params.noiseFreq)   # -1..1
    agent.angle += n * params.noiseAmp * dt
    desired = Vec2(cos(agent.angle), sin(agent.angle)) * agent.maxSpeed
    return limit(desired - agent.velocity, agent.maxForce)

class SteeringController:
    behaviors: SteeringBehavior[]
    mode: "weighted" | "priority"

    function compute(agent: Agent) -> Vec2
        if mode == "weighted":
            total = Vec2(0, 0)
            for each b in behaviors
                if b.enabled
                    total += b.calculate(agent) * b.weight
            return limit(total, agent.maxForce)
        else:  # priority
            total = Vec2(0, 0)
            budget = agent.maxForce
            for each b in behaviors  # asume ya ordenado por prioridad
                if not b.enabled: continue
                f = b.calculate(agent) * b.weight
                mag = length(f)
                if mag > budget: f = normalize(f) * budget
                total += f
                budget -= length(f)
                if budget <= 0: break
            return total

7. Implementación en Unity / C#

using System.Collections.Generic;
using UnityEngine;

public abstract class SteeringBehavior {
    public float weight = 1f;
    public bool enabled = true;
    public abstract Vector3 Calculate(SteeringAgent agent);
}

public class Wander : SteeringBehavior {
    public float circleDist = 2f;
    public float circleRadius = 1f;
    public float jitter = 2.5f;
    float wanderAngle;

    public override Vector3 Calculate(SteeringAgent a) {
        var heading = a.velocity.sqrMagnitude < 0.01f ? a.transform.forward : a.velocity.normalized;
        var center = a.transform.position + heading * circleDist;
        wanderAngle += Random.Range(-1f, 1f) * jitter * Time.deltaTime;
        var baseAng = Mathf.Atan2(heading.z, heading.x);
        var finalAng = baseAng + wanderAngle;
        var target = center + new Vector3(Mathf.Cos(finalAng), 0, Mathf.Sin(finalAng)) * circleRadius;
        var desired = (target - a.transform.position).normalized * a.maxSpeed;
        return desired - a.velocity;
    }
}

public class Seek : SteeringBehavior {
    public Transform target;
    public override Vector3 Calculate(SteeringAgent a) {
        if (target == null) return Vector3.zero;
        var desired = (target.position - a.transform.position).normalized * a.maxSpeed;
        return desired - a.velocity;
    }
}

public class SteeringController {
    public List<SteeringBehavior> behaviors = new();
    public bool priorityMode = false;

    public Vector3 Compute(SteeringAgent a) {
        if (!priorityMode) {
            var total = Vector3.zero;
            foreach (var b in behaviors) if (b.enabled) total += b.Calculate(a) * b.weight;
            return Vector3.ClampMagnitude(total, a.maxForce);
        } else {
            var total = Vector3.zero;
            var budget = a.maxForce;
            foreach (var b in behaviors) {
                if (!b.enabled) continue;
                var f = b.Calculate(a) * b.weight;
                var mag = f.magnitude;
                if (mag > budget) f = f.normalized * budget;
                total += f;
                budget -= f.magnitude;
                if (budget <= 0f) break;
            }
            return total;
        }
    }
}

public class SteeringAgent : MonoBehaviour {
    public float maxSpeed = 5f;
    public float maxForce = 12f;
    public Vector3 velocity;
    public SteeringController controller = new();

    void Update() {
        var force = controller.Compute(this);
        velocity = Vector3.ClampMagnitude(velocity + force * Time.deltaTime, maxSpeed);
        transform.position += velocity * Time.deltaTime;
        if (velocity.sqrMagnitude > 0.01f) transform.forward = velocity.normalized;
    }
}

Uso típico:

var agent = GetComponent<SteeringAgent>();
agent.controller.behaviors.Add(new Wander { weight = 0.5f });
agent.controller.behaviors.Add(new Seek { target = playerTransform, weight = 1.0f });

8. En otros engines

  • Godot: el patrón composite es directo en GDScript. Cada behavior es un Resource con un método calculate(). El controller es un Node con un array exportado de behaviors.
  • Unreal: extiende UObject para USteeringBehavior. El controller vive en el AAIController como UPROPERTY editable en el editor.
  • JavaScript / TypeScript: clases ES6 puras como en el pseudocódigo. Es lo que hace la lib del sitio internamente.

9. Quiz

Pon a prueba lo que entendiste

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

  1. Subes circleDist a 200 en wander Reynolds. ¿Qué efecto ves?

  2. En wander Reynolds, ¿por qué wanderAngle se acumula entre frames en vez de regenerarse?

  3. Tienes seek + obstacle avoidance con weighted sum, pesos 1 y 1. El agente choca con paredes. ¿Mejor solución?

  4. Un peatón decorativo en una escena de pueblo: ¿qué wander conviene más?

  5. ¿Qué garantiza el patrón Steering Controller (composite) que la suma a mano no?

  6. Entre weighted sum y priority blending, ¿cuándo priority gana claramente?