Percepción: visión, audio y memoria para NPCs
Antes de decidir, el NPC necesita enterarse del mundo. Cono de visión con confianza, audio con atenuación, memoria con decay. La base de los stealth games.
Publicado: · Por Juanjo "Banyo" López
0. La pieza que falta
FSM, Behavior Trees, Utility AI y GOAP son sistemas de decisión. Todos asumen que el NPC ya sabe lo que pasa: que el jugador está a 12 metros, que hay una explosión cerca, que hay un aliado herido en la próxima sala.
¿Cómo se entera de eso? Ese es el rol de la percepción. Un NPC sin percepción es un cheater (lee variables internas del jugador) o un robot (no reacciona). La percepción decente es lo que separa un guardia de F.E.A.R. de uno que se queda mirando una pared cuando le pasás por al lado.
El guardia patrulla 4 waypoints. Cuando te ve dentro de su cono o escucha un sonido cercano, recuerda tu última posición conocida (cruz amarilla). Si su alerta sube a "searching" o "engaged", abandona la patrulla y va al lugar recordado. La memoria decae con el tiempo — si te escondés, en pocos segundos vuelve a la patrulla normal.
Figura 1 — Guardia que patrulla 4 waypoints. Mové al jugador con WASD/flechas, click para disparar (sonido fuerte). Mirá el cono de visión, los anillos de audio, y la cruz amarilla de la última posición conocida. La paleta del guardia cambia según su nivel de alerta.
Tres modalidades clásicas que cubre cualquier juego con stealth o IA táctica:
- Visión: cono frontal con range y FOV. Decide si “puede ver” un target.
- Audio: eventos sonoros que se propagan con atenuación. Decide si “escuchó” algo.
- Memoria: persistencia temporal de lo que percibió. Decide si “recuerda” algo.
1. Visión: cono con confianza graduada
Un cono ingenuo es un check binario: “¿está dentro del FOV y dentro del range, sin oclusión? entonces lo veo.” Pero los NPCs reales no funcionan así. Vos tampoco — alguien en la esquina de tu campo visual a 50 metros lo “registrás”, pero no podrías describir qué está haciendo.
Por eso modelamos confianza graduada: la confianza de la detección depende del ángulo (mejor en el centro) y de la distancia (mejor cerca).
El cono se compone de focus (zona central, alta confianza) y periferia (zona externa, decae al borde). Mové el cursor: dentro del foco la confianza se mantiene cerca de 1 hasta que la distancia la baja; en la periferia decae con el ángulo. Eso es lo que en producción se usa para decidir si "ver" cuenta como detección certera o solo como sospecha.
Figura 2 — Cono con dos zonas: focus (zona oscura, alta confianza) y periferia (zona clara, baja). Mové el cursor: la confianza cae con el ángulo y la distancia. En producción esto se mapea a “engaged” (conf > 0.7), “suspicious” (0.3 - 0.7), “ignorado” (< 0.3).
function evaluateVision(cone, target, obstacles) -> { visible, confidence, distance }:
distance = dist(cone.origin, target)
if distance > cone.range: return { visible: false, confidence: 0 }
angle = atan2(target.y - origin.y, target.x - origin.x)
dAng = normalize(angle - cone.facing) // [-π, π]
halfFov = cone.fov / 2
if abs(dAng) > halfFov: return { visible: false, confidence: 0 }
if anyWallBetween(origin, target, obstacles):
return { visible: false, confidence: 0 }
// confianza angular: 1 dentro del foco, decae al borde
if abs(dAng) <= focusAngle / 2:
angularConf = 1
else:
t = (abs(dAng) - focusAngle/2) / (halfFov - focusAngle/2)
angularConf = 1 - t
distanceFactor = 1 - distance / range
confidence = angularConf * (0.3 + 0.7 * distanceFactor)
return { visible: confidence > 0.05, confidence }
Tres factores combinados: dentro del cono, sin oclusión, producto angular × distancia. La oclusión usa el Bresenham/raycast que ya viste.
1.1 Variantes
- Cono fijo + cono periferia: dos conos concéntricos con FOVs distintos. El central da detección rápida y certera; el ancho da “vi algo de reojo” — dispara estado “suspicious” pero no “engaged”.
- Sphere check (omnidireccional): para “olfato” (zombies en Resident Evil) o sentidos sobrenaturales.
- Detección por silueta: si el target está agachado o con un disfraz, modificá la confidence con un multiplicador.
- Iluminación: en la oscuridad la confianza cae con un factor extra. Splinter Cell hace esto exhaustivamente.
2. Audio: eventos con atenuación
Vista es continua (mientras estés en el cono, te ven). Audio es discreto: un evento se emite, se propaga, y los listeners reciben una amplitud según distancia y obstáculos.
Click para emitir un sonido. Linear es lo que usa la mayoría de gamedev por simplicidad y previsibilidad. Inverse-square es físicamente correcto (intensidad cae con 1/r²) pero los NPCs lejanos casi no perciben — tenés que tunear el range. Las paredes muffle el sonido (atenuación extra) cuando la línea origen→listener las cruza.
Figura 3 — Click para emitir un sonido. Las paredes muffle (atenúan) la señal cuando cruzan la línea origen→listener. NPCs iluminados son los que “escuchan” arriba de su umbral.
class AudioEvent:
origin: Vec2
intensity: float in [0, 1]
range: float
emittedAt: ms
duration: ms
function audibleAt(event, listener, obstacles, model='inverse-square') -> float:
age = now - event.emittedAt
if age > event.duration or age < 0: return 0
distance = dist(event.origin, listener)
if distance > event.range: return 0
if model == 'inverse-square':
t = distance / event.range
amplitude = event.intensity / (1 + t * t * 4)
else: // linear
amplitude = event.intensity * (1 - distance / event.range)
// envelope temporal
lifeT = age / event.duration
amplitude *= 1 - lifeT * lifeT
// muffle por paredes
for wall in obstacles:
if segmentIntersects(event.origin, listener, wall.a, wall.b):
amplitude *= 1 - wall.muffle
return amplitude
2.1 Linear vs inverse-square
Linear: amplitude decae proporcional a 1 - d/r. Más previsible para gameplay, fácil de tunear, lo que usa la mayoría de gamedev.
Inverse-square (físicamente correcto): amplitude ∝ 1 / (1 + (d/r)²). Decae rápido al principio, lento después — refleja cómo se comporta el sonido real. Pero los NPCs lejanos casi no perciben, lo que requiere ajustar los ranges aggressive.
2.2 Categorías de sonido
Distintos sonidos tienen distinto impacto:
| Sonido | Intensidad | Range | Trigger |
|---|---|---|---|
| Pisada caminando | 0.2 | 60-80m | Cada 350ms al moverse |
| Pisada corriendo | 0.5 | 120m | Cada 200ms al correr |
| Disparo silenciado | 0.4 | 40m | Una vez por shot |
| Disparo normal | 1.0 | 200m | Una vez por shot |
| Explosión | 1.5 | 500m | Una vez |
| Conversación | 0.3 | 50m | Continuo mientras hablen |
Cada evento se traduce a un AudioEvent que el sistema de percepción evalúa.
3. Memoria: persistencia con decay
Sin memoria, el NPC olvida al jugador instantáneamente al perder visual. El juego se vuelve binario: te ven o no. Con memoria, hay grises: “estoy buscando porque te vi hace 3 segundos por allá”.
Mové el cursor por el canvas — es el "jugador". Cada NPC graba tu posición cuando entrás a su radio de visión (círculo punteado). Después, su memoria decae con un timer distinto: 2s, 5s, 10s. La cruz amarilla es la última posición conocida; su transparencia muestra la confianza restante. El NPC que ya olvidó vuelve a su estado base (gris).
Figura 4 — Tres NPCs con duraciones de memoria distintas (2s, 5s, 10s). Mové el cursor; cada NPC graba su última posición conocida cuando entrás a su radio. Después la confidence decae con el tiempo.
class TargetMemory:
record: { position, observedAt, initialConfidence }?
decayDuration: seconds
function observe(position, confidence, now):
record = { position, observedAt: now, initialConfidence: confidence }
function currentConfidence(now) -> float:
if no record: return 0
age = (now - record.observedAt) / 1000
if age >= decayDuration: return 0
return record.initialConfidence * (1 - age / decayDuration)
function lastKnown(now) -> Vec2?:
if currentConfidence(now) <= 0: return null
return record.position
3.1 Variantes de decay
- Lineal: confidence cae uniforme. Fácil de tunear.
- Exponencial:
conf * exp(-k * age). Se acerca a 0 pero nunca llega — útil para “fading” sutil. - Cuadrática:
conf * (1 - t²). Caída lenta al principio, rápida al final. Sensación realista.
3.2 Múltiples records
Una memoria sofisticada guarda varios records (no solo el último). Cuando ves al jugador en X y después en Y, mantenés ambos con confianzas distintas. Útil para que el NPC busque en múltiples lugares antes de rendirse.
class TargetMemory_Rich:
records: List<{ position, observedAt, confidence }>
function observe(position, conf, now):
// mergear si hay un record cercano y reciente
for r in records:
if dist(r.position, position) < threshold and (now - r.observedAt) < 2000:
r.confidence = max(r.confidence, conf)
r.observedAt = now
return
records.add({ position, confidence: conf, observedAt: now })
function topK(k, now) -> List<Position>:
return records sorted by confidence desc, take k
3.3 Memoria compartida (squad-level)
NPCs de un mismo escuadrón pueden compartir memoria. Si uno te ve, los aliados cerca lo “escuchan” por radio y graban tu lastKnown. Esto es lo que hacía F.E.A.R. para coordinar — la “inteligencia de squad” emergía de memoria compartida + GOAP individual.
4. Estado de alerta como agregado
Las tres modalidades se combinan en un nivel de alerta que el sistema de decisión consume:
function decideAlert(visConf, audioAmp, memConf) -> AlertLevel:
total = max(visConf, audioAmp * 0.7, memConf * 0.6)
if total >= 0.6: return 'engaged'
if total >= 0.3: return 'searching'
if total >= 0.1: return 'suspicious'
return 'idle'
Notá los multiplicadores: visión cuenta full, audio al 70% (más incierto), memoria al 60% (más antigua). El BT/GOAP lee este nivel y elige comportamiento:
- idle: patrullar normal.
- suspicious: parar, mirar alrededor, decir “¿quién anda ahí?”.
- searching: ir a la última posición conocida, agacharse, alertar al squad.
- engaged: combate activo, tirar granadas, flanquear.
Es la pieza que hace que el comportamiento se sienta progresivo y no binario.
5. Implementación en Unity / C#
public class Perception : MonoBehaviour {
[Header("Vision")]
public float visionRange = 15f;
public float visionFov = 90f; // grados
public float focusFov = 30f; // grados
public LayerMask obstacleLayers;
[Header("Audio")]
public float hearingThreshold = 0.1f;
[Header("Memory")]
public float memoryDuration = 4f;
Transform target;
float lastSeenAt = -1f;
Vector3 lastSeenPos;
float lastSeenConfidence;
public AlertLevel CurrentAlert { get; private set; }
void Update() {
float visConf = EvaluateVision();
float audioAmp = EvaluateAudio();
float memConf = EvaluateMemory();
CurrentAlert = DecideAlert(visConf, audioAmp, memConf);
}
float EvaluateVision() {
var to = target.position - transform.position;
if (to.magnitude > visionRange) return 0;
float angle = Vector3.Angle(transform.forward, to);
if (angle > visionFov / 2) return 0;
if (Physics.Raycast(transform.position, to.normalized, to.magnitude, obstacleLayers)) return 0;
float angularConf = angle <= focusFov / 2 ? 1f :
Mathf.Clamp01(1f - (angle - focusFov / 2) / (visionFov / 2 - focusFov / 2));
float distConf = 1f - to.magnitude / visionRange;
float conf = angularConf * (0.3f + 0.7f * distConf);
if (conf > 0.05f) {
lastSeenAt = Time.time;
lastSeenPos = target.position;
lastSeenConfidence = conf;
}
return conf;
}
float EvaluateMemory() {
if (lastSeenAt < 0) return 0;
float age = Time.time - lastSeenAt;
if (age >= memoryDuration) return 0;
return lastSeenConfidence * (1f - age / memoryDuration);
}
AlertLevel DecideAlert(float v, float a, float m) {
float total = Mathf.Max(v, a * 0.7f, m * 0.6f);
if (total >= 0.6f) return AlertLevel.Engaged;
if (total >= 0.3f) return AlertLevel.Searching;
if (total >= 0.1f) return AlertLevel.Suspicious;
return AlertLevel.Idle;
}
}
6. Errores comunes
- Cono binario — sin confianza graduada, los NPCs cambian de comportamiento abruptamente cuando el jugador cruza el borde del FOV. Se ve robótico.
- No considerar oclusión — el cono atraviesa paredes, el guardia “te ve” desde el otro lado. Inmersión rota.
- Audio sin atenuación temporal — un disparo se sigue escuchando para siempre porque no expira. Memoria del audio crece sin parar.
- Memoria sin decay — el NPC recuerda dónde te vio hace 3 horas y sigue yendo allá. Comportamiento persistente que rompe sigilo.
- Memoria que olvida demasiado rápido — 1s de duración hace que perder visual te haga invisible inmediato. El feel de stealth desaparece.
- No actualizar memoria al ver — solo grabás cuando ENTRA al cono pero no cuando sigue dentro. Si el target se queda quieto, la memoria decae aunque lo siga viendo.
7. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Tu NPC con cono binario se ve robótico — cambia abruptamente entre 'idle' y 'engaged'. ¿Mejor approach?
Tu juego de stealth: el NPC recuerda al jugador dónde lo vio durante 60 segundos. ¿Problema?
Audio linear vs inverse-square: ¿cuál usar para gameplay típico?
Tu guardia ve al jugador agachado tras un muro a 3m de distancia. ¿Qué falló?
8. Siguientes pasos
Con percepción cierra la Tanda 9 y el set de fundamentos del MVP del sitio. Tenés ya las piezas:
- Movimiento: steering, flocking, pathfinding (BFS, Dijkstra, A*, navmesh, flow fields).
- Decisiones: FSM, HFSM, BTs, AI architecture, command pattern, Utility AI, GOAP.
- Percepción: line-of-sight, FOV, percepción multimodal.
- Combate aplicado: TD, snake, lemmings, escort, multi-objetivo, influence maps.
- Procedural: Perlin, WFC, BSP, cellular automata, voronoi.
- Optimización: grids/bucketing, quadtree/octree, BVH, LOD para IA.
La caja de herramientas para construir IAs que se sienten vivas. Lo que hacés con ellas ya depende del juego que estés haciendo.