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
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
-
Calcula el
headingdel agente (la dirección actual de lavelocity).heading = normalize(agent.velocity)(si está parado, usa una dirección por defecto) -
Proyecta el centro del wander circle delante del agente.
circleCenter = agent.position + heading * circleDist -
Suma jitter al ángulo persistente del wander.
wanderAngle += randUniform(-1, 1) * jitter * dtEl truco clave es que
wanderAnglese acumula entre frames. Si lo regeneras en cada frame, el target salta como loco. Lo que quieres es una caminata aleatoria suave del ángulo. -
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 -
Aplica
seekclá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 * dta 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?
| Aspecto | Reynolds (a) — círculo + jitter | Perlin/noise (b) — ruido sobre ángulo |
|---|---|---|
| Concepto | Target invisible que rebota en un círculo al frente | Ángulo modulado por noise temporal |
| Parámetros | 3 (circleDist, circleRadius, jitter) | 2 (noiseFreq, noiseAmp) |
| Composable con seek/flee | Sí — devuelve una force, se mezcla con otras | Mecanismo aparte: define el desired directamente, NO calza igual de limpio |
| Visualizable / didáctico | Excelente — ves círculo, target y heading | Pobre — el “qué pasa” no se ve, solo el resultado |
| Determinismo (mismo seed = misma trayectoria) | Sí, con random seedeado | Sí, con noise seedeado |
| Coste por frame | O(1), 1 random + 1 trig | O(1), 1 lookup de noise |
| Sensación visual | Curvas con personalidad (puede sentirse “circular”) | Caminata flotante, neutral |
| Cuándo elegirlo | Cuando wander va mezclado con otros behaviors | Cuando 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
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ón | Mejor 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 runtime | Weighted 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 runtime —
controller.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
Resourcecon un métodocalculate(). El controller es unNodecon un array exportado de behaviors. - Unreal: extiende
UObjectparaUSteeringBehavior. El controller vive en elAAIControllercomoUPROPERTYeditable 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.
Subes circleDist a 200 en wander Reynolds. ¿Qué efecto ves?
En wander Reynolds, ¿por qué wanderAngle se acumula entre frames en vez de regenerarse?
Tienes seek + obstacle avoidance con weighted sum, pesos 1 y 1. El agente choca con paredes. ¿Mejor solución?
Un peatón decorativo en una escena de pueblo: ¿qué wander conviene más?
¿Qué garantiza el patrón Steering Controller (composite) que la suma a mano no?
Entre weighted sum y priority blending, ¿cuándo priority gana claramente?