Volver al Blog
Videojuegos21 de julio de 20268 min de lectura

Sensores y percepción: cómo ve y oye un NPC

Cómo dotar de sentidos a la IA de un videojuego: conos de visión, ráfagas de sonido y una memoria que recuerda lo que el NPC ha percibido.

IM
Ignacio MelendezDesarrollador Full-Stack y de Videojuegos
Sensores y percepción: cómo ve y oye un NPC

Un enemigo que gira la cabeza, ve al jugador asomarse por una esquina y grita "¡ahí está!" parece que entiende el mundo. No entiende nada. Debajo hay un puñado de comprobaciones geométricas corriendo cada frame: un ángulo, una distancia, un rayo que choca contra una pared. La sensación de que el NPC percibe es, casi siempre, la suma de trucos baratos bien orquestados.

Este artículo trata de esos trucos. Cómo se construye un cono de visión que no dispare falsos positivos, cómo un guardia "oye" un disparo sin simular acústica de verdad, y cómo hacer que recuerde lo que ha visto en lugar de olvidarlo en cuanto el jugador se agacha detrás de una caja.

1. La IA no ve, simula que ve

Lo primero que conviene interiorizar es que la percepción de un NPC es un sistema de consultas, no una simulación física. Las consolas y ordenadores no lanzan fotones ni se modela la retina del NPC. En cambio, preguntan cosas concretas: ¿está el objetivo dentro del ángulo de visión?, ¿hay algo tapándolo?, ¿a qué distancia está? Cada pregunta es barata por separado, y el orden en que se hacen importa para el rendimiento.

La percepción también tiene que ser tolerante. Un jugador que asoma medio pixel por una esquina no debería activar la alerta máxima al instante, igual que un guardia real no detecta a alguien en la penumbra de inmediato. Por eso, se puede separar en dos conceptos que a menudo se mezclan:

  • Detección: responde a ¿puedo percibir este estímulo ahora mismo?
  • Conocimiento: haciendo referencia a,¿qué sé sobre el objetivo, aunque ahora mismo no lo vea?

El primero es instantáneo y sin estado. El segundo es memoria, y es lo que da la impresión de inteligencia.

Una regla útil: la detección responde a "¿qué percibo este frame?" y la memoria responde a "¿qué creo que está pasando?". Mezclarlas en una sola variable booleana canSeePlayer es la causa número uno de IAs que parpadean entre alerta y calma.

2. El cono de visión

El "Cono de Visión" es un concepto que mezcla dos comprobaciones: un límite de distancia y un límite de ángulo. La distancia es trivial (un sqrMagnitude comparado contra el radio al cuadrado, evitando la raíz cuadrada). El ángulo es donde la gente se complica de más.

La forma intuitiva es usar Vector3.Angle, que devuelve grados. Funciona, pero calcula un arcocoseno por dentro. La forma barata es comparar productos escalares (dot products): si ser normaliza la dirección hacia el objetivo y se compara con la dirección de mirada del NPC, el dot product da como resultado el coseno del ángulo entre ambos. Precalculando el coseno del medio ángulo de visión una vez, y a partir de ahí solo es necesario comparar números.

1public class VisionCone : MonoBehaviour
2{
3    [SerializeField] private float viewRadius = 12f;
4    [SerializeField, Range(0, 360)] private float viewAngle = 90f;
5
6    private float _cosHalfAngle;
7
8    private void Awake()
9    {
10        // Precalcular el coseno del medio ángulo una sola vez.
11        _cosHalfAngle = Mathf.Cos(viewAngle * 0.5f * Mathf.Deg2Rad);
12    }
13
14    public bool IsInViewCone(Vector3 target)
15    {
16        Vector3 toTarget = target - transform.position;
17
18        // 1) Filtro de distancia sin raíz cuadrada.
19        if (toTarget.sqrMagnitude > viewRadius * viewRadius)
20            return false;
21
22        // 2) Filtro de ángulo con dot product.
23        Vector3 dir = toTarget.normalized;
24        float dot = Vector3.Dot(transform.forward, dir);
25        return dot >= _cosHalfAngle;
26    }
27}

El orden es importante: primero se descarta por distancia, que es lo más barato, y solo si el objetivo está en rango se calcula el ángulo. Este patrón de "filtrado por coste creciente" se repite en todo el sistema de percepción. Cada comprobación cara solo se ejecuta si las baratas ya han pasado.

Un cono plano (2D) suele bastar para juegos con gravedad y suelos claros. Esto permite ignorar el componente vertical del vector antes de normalizar, por lo que es un cálculo menos costoso. Reservar el cono 3D completo para enemigos voladores o niveles con verticalidad real ahorra falsos negativos raros con rampas y escaleras.

3. Línea de visión: el raycast

Estar dentro del cono no significa ver. Entre el NPC y el objetivo puede haber una pared, una columna o un contenedor. Para eso se lanza un rayo desde los "ojos" del NPC hacia el objetivo y comprobamos qué golpea primero. Si lo primero que golpea es el objetivo, hay línea de visión. Si golpea geometría antes, está oculto.

La clave aquí es filtrar bien qué puede bloquear el rayo. El raycast debe ignorar al propio NPC y considerar solo obstáculos (y opcionalmente al jugador). En Unity esto se resuelve con una LayerMask bien configurada; en Unreal, con collision channels y una lista de actores ignorados. En ambos casos el objetivo es el mismo: evitar que el rayo choque contra el collider del propio enemigo o contra triggers irrelevantes.

1[SerializeField] private Transform eyes;      // punto de origen del rayo
2[SerializeField] private LayerMask obstacleMask;
3
4public bool HasLineOfSight(Vector3 target)
5{
6    Vector3 origin = eyes.position;
7    Vector3 dir = target - origin;
8    float distance = dir.magnitude;
9
10    // Si el rayo no golpea ningún obstáculo, la vista está despejada.
11    return !Physics.Raycast(origin, dir.normalized, distance, obstacleMask);
12}

Un raycast por enemigo por frame es asumible. El problema aparece con decenas o cientos de NPCs. Ahí conviene escalonar las comprobaciones: no todos los enemigos necesitan revisar la línea de visión cada frame. Repartirlos en el tiempo (por ejemplo, cada NPC comprueba cada 3 o 4 frames, desfasados entre sí) reduce el pico de coste sin que el jugador note el retraso de unos milisegundos.

Apuntar el rayo al pivote del jugador (los pies) es un error clásico: si asoma la cabeza por encima de una cobertura, el pivote sigue oculto y el enemigo no lo ve, aunque debería. Conviene lanzar varios rayos a puntos representativos (cabeza, torso, pies) o al menos a un punto a la altura del pecho. Aunque también hay casos en los que con un solo rayo al centro del personaje es suficiente (juegos de plataformas, donde las coberturas no sean relevantes o no haya coberturas de diferentes alturas...).

4. Oír el mundo: ráfagas de sonido

La vista es direccional; el oído no. Un NPC no necesita mirar hacia un disparo para oírlo. En lugar de simular propagación de ondas, se puede usar un modelo de eventos: cuando algo hace ruido (un paso, un disparo, una puerta), emite un "evento de sonido" con una posición, una intensidad y un radio. Cualquier NPC dentro de ese radio recibe el evento.

Lo elegante de este modelo es que desacopla al emisor del receptor. El jugador que dispara no sabe nada de los enemigos; solo publica un evento. Los NPCs se suscriben y deciden qué hacer. Un ScriptableObject como canal de eventos funciona muy bien para esto en Unity; en Unreal, un UObject con un delegado multicast (o el propio sistema AIPerception con AISense_Hearing) cumple el mismo papel.

1// --- Emisor ---
2public struct SoundEvent
3{
4    public Vector3 position;
5    public float radius;      // hasta dónde llega
6    public float intensity;   // 0..1, cuánto "grita"
7}
8
9public class Weapon : MonoBehaviour
10{
11    [SerializeField] private SoundChannel soundChannel;
12
13    private void Fire()
14    {
15        // ... lógica de disparo ...
16        soundChannel.Raise(new SoundEvent
17        {
18            position = transform.position,
19            radius = 20f,
20            intensity = 1f
21        });
22    }
23}
24
25// --- Receptor ---
26public class Hearing : MonoBehaviour
27{
28    [SerializeField] private SoundChannel soundChannel;
29
30    private void OnEnable()  => soundChannel.OnSound += HandleSound;
31    private void OnDisable() => soundChannel.OnSound -= HandleSound;
32
33    private void HandleSound(SoundEvent e)
34    {
35        float sqrDist = (e.position - transform.position).sqrMagnitude;
36        if (sqrDist > e.radius * e.radius) return;
37
38        // La intensidad percibida decae con la distancia.
39        float dist = Mathf.Sqrt(sqrDist);
40        float perceived = e.intensity * (1f - dist / e.radius);
41
42        // Solo reacciona si supera el umbral de atención.
43        if (perceived > 0.15f)
44            InvestigatePosition(e.position, perceived);
45    }
46}

Hay que tener en cuenta que el sonido no da certeza, da una dirección aproximada. El NPC no sabe qué produjo el ruido, solo dónde. Por eso la reacción correcta a un sonido no es "atacar", sino "investigar": moverse hacia el punto de origen y aumentar la vigilancia. Es la vista, después, la que confirma o descarta la amenaza. Esta división entre "oír para sospechar" y "ver para confirmar" es lo que hace que el sigilo se sienta justo.

5. Memoria: recordar lo percibido

Aquí es donde una IA deja de parecer un sensor y empieza a parecer un personaje. Sin memoria, en cuanto el jugador rompe la línea de visión el NPC lo olvida y vuelve a patrullar como si nada. Con memoria, el guardia recuerda dónde vio al jugador por última vez, va hacia allí y busca un rato antes de rendirse.

El modelo mínimo es un "último punto conocido" (last known position) más un nivel de confianza que decae con el tiempo. Mientras el NPC percibe al objetivo, la confianza sube y el punto conocido se actualiza. Cuando lo pierde de vista, la confianza empieza a bajar poco a poco. Solo cuando llega a cero el NPC "olvida de verdad" y vuelve a su rutina.

1public class PerceptionMemory
2{
3    public Vector3 LastKnownPosition { get; private set; }
4    public float Confidence { get; private set; } // 0..1
5
6    private const float DecayPerSecond = 0.2f;
7
8    // Llamado cada frame en que percibe algo del objetivo.
9    public void Reinforce(Vector3 position, float strength)
10    {
11        LastKnownPosition = position;
12        Confidence = Mathf.Min(1f, Confidence + strength);
13    }
14
15    // Llamado cada frame en que NO percibe nada.
16    public void Decay(float deltaTime)
17    {
18        Confidence = Mathf.Max(0f, Confidence - DecayPerSecond * deltaTime);
19    }
20
21    public bool HasTarget => Confidence > 0f;
22}

El decaimiento gradual es lo que da el comportamiento humano. Un guardia que acaba de perder de vista al objetivo (confianza alta) va directo y decidido a su última posición conocida. Un guardia que hace rato que no ve al jugador (confianza baja) busca de forma más laxa y se rinde antes. Ajustar la tasa de decaimiento por tipo de enemigo (un perro guardián olvida rápido, un dron de vigilancia casi no olvida) da variedad sin escribir IAs distintas.

La percepción sin memoria es un interruptor. La percepción con memoria es un personaje. La diferencia entre ambos son cuatro líneas de código y una variable que decae.

6. Estados de alerta

La confianza es un número continuo, pero el comportamiento del NPC suele expresarse en estados discretos: desconocido, sospecha, alerta. Mapear el número a estados con umbrales evita que el enemigo cambie de comportamiento cada frame, y facilita enganchar animaciones, música y decisiones a cada nivel.

La trampa está en usar un solo umbral para subir y bajar. Si el corte entre "sospecha" y "alerta" está en 0.5, un objetivo que ronda ese valor hará que el NPC parpadee entre ambos estados. La solución es la histéresis: exigir más para entrar en un estado que para salir de él. Se entra en alerta a 0.7, pero no se sale de ella hasta bajar de 0.4. Ese margen muerto elimina el parpadeo.

1public enum AlertState { Unaware, Suspicious, Alerted }
2
3public AlertState Evaluate(AlertState current, float confidence)
4{
5    switch (current)
6    {
7        case AlertState.Unaware:
8            if (confidence > 0.3f) return AlertState.Suspicious;
9            break;
10
11        case AlertState.Suspicious:
12            if (confidence > 0.7f) return AlertState.Alerted;   // subir cuesta
13            if (confidence < 0.1f) return AlertState.Unaware;
14            break;
15
16        case AlertState.Alerted:
17            if (confidence < 0.4f) return AlertState.Suspicious; // bajar cuesta menos
18            break;
19    }
20    return current;
21}

Cada estado dispara un comportamiento distinto: en desconocido el NPC patrulla, en sospecha investiga el último punto conocido y mira alrededor, en alerta persigue y ataca. Al enganchar la máquina de estados a la confianza (y esta a los sensores y la memoria), toda la cadena queda conectada: veo u oigo algo, sube la confianza, cambio de estado, cambio de comportamiento.

7. Uniéndolo todo

Las piezas por separado son simples. La percepción convincente nace de orquestarlas en el orden correcto, cada frame. Un PerceptionSystem central recoge los estímulos de los sensores, actualiza la memoria y expone un estado de alerta que el resto de la IA (el árbol de comportamiento, la máquina de estados) puede consultar.

1public class PerceptionSystem : MonoBehaviour
2{
3    [SerializeField] private VisionCone vision;
4    [SerializeField] private Transform target;   // el jugador
5
6    private readonly PerceptionMemory _memory = new();
7    private AlertState _state = AlertState.Unaware;
8
9    public AlertState State => _state;
10    public Vector3 LastKnownPosition => _memory.LastKnownPosition;
11
12    private void Update()
13    {
14        bool sensed = false;
15
16        // Vista: cono + línea de visión (filtros de coste creciente).
17        if (vision.IsInViewCone(target.position) &&
18            vision.HasLineOfSight(target.position))
19        {
20            _memory.Reinforce(target.position, strength: 1.5f * Time.deltaTime);
21            sensed = true;
22        }
23
24        // El oído reinicia la memoria vía eventos (ver seccion 4),
25        // no hace falta consultarlo aqui cada frame.
26
27        if (!sensed)
28            _memory.Decay(Time.deltaTime);
29
30        _state = Evaluate(_state, _memory.Confidence);
31    }
32}

A partir de aquí, el resto de la IA no toca sensores ni raycasts: solo lee State y LastKnownPosition. Esa separación es lo que mantiene el sistema manejable a medida que crece. Puedes añadir un sensor nuevo (olfato, sensor de tacto, detección de cadáveres) sin tocar el árbol de comportamiento, porque todos alimentan la misma memoria y el mismo estado de alerta.

Conclusiones

La percepción de un NPC no es magia ni una simulación cara. Es un cono, un rayo, un radio de sonido y un número que decae, conectados con criterio. Ninguna pieza es difícil por separado; lo que convence al jugador es orquestarlas en el orden correcto y dejar que se retroalimenten.

  • La IA no ve, consulta. La percepción es un conjunto de preguntas baratas (ángulo, distancia, oclusión), no una simulación física.
  • Filtra por coste creciente. Descarta primero con lo barato (distancia con sqrMagnitude), y reserva lo caro (el raycast) para cuando el resto ya ha pasado.
  • Separa detección de conocimiento. Un canSeePlayer booleano es la causa número uno de IAs que parpadean; la memoria es lo que da la impresión de inteligencia.
  • Oír es sospechar, ver es confirmar. El sonido da dirección, no certeza: la reacción correcta a un ruido es investigar, no atacar.
  • La confianza que decae es el personaje. Un último punto conocido más un número que baja con el tiempo convierte un interruptor en un guardia que duda, busca y se rinde.
  • Usa histéresis en los estados. Exigir más para entrar en alerta que para salir elimina el parpadeo entre comportamientos.
  • Centraliza y desacopla. Un PerceptionSystem que expone State y LastKnownPosition deja añadir sensores nuevos sin tocar el árbol de comportamiento.

Cuando esas cuatro piezas trabajan juntas, el jugador jura que el enemigo lo ha visto, lo ha oído y lo recuerda. Y en cierto sentido, es verdad.

Un buen sistema de percepción es invisible: el jugador nunca piensa en conos ni en raycasts, solo siente que el enemigo lo está buscando.

Artículos Relacionados

Ver todos los artículos
Pathfinding en Videojuegos: A* y sus Variantes

Pathfinding en Videojuegos: A* y sus Variantes

A* es el algoritmo de pathfinding más conocido, pero los juegos modernos van mucho más allá: NavMesh, JPS, HPA*. Este artículo explora por qué Dijkstra no escala, cómo funciona A* de verdad y cuándo usar cada variante.

Coyote time y buffering de inputs: el platformer que perdona

Coyote time y buffering de inputs: el platformer que perdona

Los mejores platformers se sienten justos porque hacen trampa a tu favor. Un análisis de coyote time, jump buffering y otros trucos de input que perdonan al jugador, con implementación en Unity/C#.