# Target Tracks Perception System

> Source: <https://gist.github.com/adammyhre/db6bc4942b2c76addb65c82aa64e86db>
> Published: 2026-06-07 10:07:30+00:00

| // Adapted from Game AI Pro - Crytek’s Target Tracks Perception System | |
| using System.Collections.Generic; | |
| using UnityEngine; | |
| namespace Perception { | |
| public class PerceptionAgent : MonoBehaviour { | |
| #region Fields | |
| [Header("Sight")] | |
| [SerializeField] float viewRange = 15f; | |
| [SerializeField] float primaryFov = 90f; | |
| [SerializeField] float peripheralFov = 160f; | |
| [SerializeField] LayerMask obstructionMask = ~0; | |
| [Header("Awareness")] | |
| [SerializeField] float alertThreshold = 25f; | |
| [SerializeField] float highAlertEnter = 50f; | |
| [SerializeField] float highAlertExit = 35f; | |
| [SerializeField] float turnSpeed = 4f; | |
| readonly Dictionary<Transform, TargetTrack> tracks = new(); | |
| readonly Dictionary<Transform, HashSet<StimType>> active = new(); | |
| static readonly StimType[] allTypes = { StimType.VisualPrimary, StimType.VisualPeripheral, StimType.AudioMovement, StimType.AudioLoud }; | |
| bool latchedHighAlert; | |
| Renderer rend; | |
| Material mat; | |
| static readonly Color idle = new(0.2f, 0.8f, 0.3f), | |
| suspicious = new(1f, 0.85f, 0.1f), | |
| alert = new(1f, 0.2f, 0.15f); | |
| #endregion | |
| void LateUpdate() { | |
| ScanSight(); | |
| TickTracks(); | |
| React(); | |
| active.Clear(); | |
| } | |
| void React() { | |
| TargetTrack best = null; | |
| Transform bestTarget = null; | |
| foreach (var pair in tracks) { | |
| if (best == null || pair.Value.Score > best.Score) { | |
| best = pair.Value; | |
| bestTarget = pair.Key; | |
| } | |
| } | |
| var score = best != null ? best.Score : 0f; | |
| var isPerceiving = bestTarget && active.TryGetValue(bestTarget, out var set) && set.Count > 0; | |
| if (best != null && score >= alertThreshold && isPerceiving) { | |
| var dir = best.LastKnownPosition - transform.position; | |
| dir.y = 0f; | |
| if (dir.sqrMagnitude > 0.01f) transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), turnSpeed * Time.deltaTime); | |
| } | |
| if (score >= highAlertEnter) latchedHighAlert = true; | |
| else if (score < highAlertExit || score <= 0f) latchedHighAlert = false; | |
| if (mat) mat.color = latchedHighAlert ? alert : score >= alertThreshold ? suspicious : idle; | |
| } | |
| void TickTracks() { | |
| var remove = new List<Transform>(); | |
| foreach (var pair in tracks) { | |
| foreach (var t in allTypes) | |
| pair.Value.Tick(t, active.TryGetValue(pair.Key, out var set) && set.Contains(t), Time.deltaTime); | |
| if (pair.Value.Score <= 0f) remove.Add(pair.Key); | |
| } | |
| foreach (var t in remove) tracks.Remove(t); | |
| } | |
| void ScanSight() { | |
| var player = GameObject.FindGameObjectWithTag("Player"); | |
| if (!player) return; | |
| var target = player.transform; | |
| var eye = transform.position + Vector3.up; | |
| var to = target.position + Vector3.up - eye; | |
| var dist = to.magnitude; | |
| if (dist > viewRange) return; | |
| var angle = Vector3.Angle(transform.forward, to); | |
| StimType? type = null; | |
| if (angle <= primaryFov * 0.5f) type = StimType.VisualPrimary; | |
| else if (angle <= peripheralFov * 0.5f) type = StimType.VisualPeripheral; | |
| if (type == null || !HasLineOfSight(eye, to.normalized, dist, target)) return; | |
| PerceptionHub.Emit(new Stim(type.Value, target, target.position, viewRange), transform); | |
| } | |
| bool HasLineOfSight(Vector3 origin, Vector3 direction, float distance, Transform target) { | |
| if (!Physics.Raycast(origin, direction, out var hit, distance, obstructionMask)) return true; | |
| return hit.transform == target || hit.transform.IsChildOf(target); | |
| } | |
| public void Receive(Stim stim, Transform observer = null) { | |
| if (stim.Type == StimType.VisualPrimary || stim.Type == StimType.VisualPeripheral) { | |
| if (observer != transform) return; | |
| } | |
| if (!stim.Source || Vector3.Distance(transform.position, stim.Position) > RangeFor(stim)) return; | |
| if (!tracks.TryGetValue(stim.Source, out var track)) { | |
| track = new TargetTrack { Target = stim.Source }; | |
| tracks[stim.Source] = track; | |
| } | |
| var config = ConfigFor(stim.Type); | |
| track.Feed(stim.Type, config.peak, config.attack, config.release, stim.Position); | |
| if (!active.TryGetValue(stim.Source, out var set)) { | |
| set = new HashSet<StimType>(); | |
| active[stim.Source] = set; | |
| } | |
| set.Add(stim.Type); | |
| } | |
| float RangeFor(Stim s) => s.Type == StimType.AudioMovement || s.Type == StimType.AudioLoud ? s.Radius : viewRange; | |
| (float peak, float attack, float release) ConfigFor(StimType t) { | |
| return t switch { | |
| StimType.VisualPrimary => (100f, 2f, 40f), | |
| StimType.VisualPeripheral => (40f, 4f, 40f), | |
| StimType.AudioMovement => (25f, 1f, 8f), | |
| _ => (80f, 0.5f, 18f) | |
| }; | |
| } | |
| void Awake() { | |
| rend = GetComponent<Renderer>(); | |
| if (rend) mat = rend.material; | |
| } | |
| void OnEnable() => PerceptionHub.Register(this); | |
| void OnDisable() => PerceptionHub.Unregister(this); | |
| } | |
| } |

| using System.Collections.Generic; | |
| using UnityEngine; | |
| using UnityEngine.Rendering; | |
| namespace Perception { | |
| public class PerceptionStimVisualizer : MonoBehaviour { | |
| struct Pulse { | |
| public StimType Type; | |
| public Transform Source; | |
| public Vector3 Position; | |
| public float Radius; | |
| public Transform Observer; | |
| public float StartTime; | |
| public float EndTime; | |
| public int RingIndex; | |
| public int BeamIndex; | |
| public int BracketIndex; | |
| public bool Alive(float time) => time < EndTime; | |
| public void Refresh(Vector3 pos, float linger) { | |
| Position = pos; | |
| EndTime = Time.time + linger; | |
| } | |
| } | |
| [Header("Timing")] | |
| [SerializeField] float audioLinger = 0.4f; | |
| [SerializeField] float visualLinger = 0.3f; | |
| [SerializeField] float expandDuration = 0.5f; | |
| [Header("Ring")] | |
| [SerializeField] int ringSegments = 48; | |
| [SerializeField] float ringWidth = 0.08f; | |
| [SerializeField] float loudRingWidth = 0.14f; | |
| [Header("Sight Beam")] | |
| [SerializeField] float beamWidth = 0.06f; | |
| [SerializeField] float bracketSize = 0.55f; | |
| [SerializeField] float bracketHeight = 1.8f; | |
| readonly List<Pulse> pulses = new(); | |
| readonly List<LineRenderer> rings = new(); | |
| readonly List<LineRenderer> beams = new(); | |
| readonly List<LineRenderer> brackets = new(); | |
| static readonly Color primaryColor = new(0.35f, 1f, 0.55f, 1f); | |
| static readonly Color peripheralColor = new(1f, 0.9f, 0.3f, 1f); | |
| static readonly Color walkColor = new(0.35f, 0.85f, 1f, 1f); | |
| static readonly Color sprintColor = new(1f, 0.55f, 0.2f, 1f); | |
| void OnEnable() => PerceptionHub.StimEmitted += OnStim; | |
| void OnDisable() => PerceptionHub.StimEmitted -= OnStim; | |
| void Awake() { | |
| EnsurePool(rings, 16, "Ring"); | |
| EnsurePool(beams, 8, "Beam"); | |
| EnsurePool(brackets, 8, "Bracket"); | |
| } | |
| void OnStim(Stim stim, Transform observer) { | |
| var linger = IsAudio(stim.Type) ? audioLinger : visualLinger; | |
| for (var i = 0; i < pulses.Count; i++) { | |
| var p = pulses[i]; | |
| if (p.Type != stim.Type) continue; | |
| if (IsAudio(stim.Type) && p.Source == stim.Source) { | |
| p.Refresh(stim.Position, linger); | |
| pulses[i] = p; | |
| return; | |
| } | |
| if (!IsAudio(stim.Type) && p.Observer == observer) { | |
| p.Refresh(stim.Position, linger); | |
| pulses[i] = p; | |
| return; | |
| } | |
| } | |
| pulses.Add(new Pulse { | |
| Type = stim.Type, | |
| Source = stim.Source, | |
| Position = stim.Position, | |
| Radius = stim.Radius, | |
| Observer = observer, | |
| StartTime = Time.time, | |
| EndTime = Time.time + linger, | |
| RingIndex = -1, | |
| BeamIndex = -1, | |
| BracketIndex = -1 | |
| }); | |
| } | |
| void LateUpdate() { | |
| var time = Time.time; | |
| HidePool(rings); | |
| HidePool(beams); | |
| HidePool(brackets); | |
| for (var i = pulses.Count - 1; i >= 0; i--) { | |
| if (!pulses[i].Alive(time)) { | |
| pulses.RemoveAt(i); | |
| continue; | |
| } | |
| pulses[i] = DrawPulse(pulses[i], time); | |
| } | |
| } | |
| Pulse DrawPulse(Pulse pulse, float time) { | |
| var linger = IsAudio(pulse.Type) ? audioLinger : visualLinger; | |
| var fade = Mathf.Clamp01((pulse.EndTime - time) / linger); | |
| var expand = Mathf.Clamp01((time - pulse.StartTime) / expandDuration); | |
| var color = ColorFor(pulse.Type); | |
| color.a *= fade; | |
| var ground = new Vector3(pulse.Position.x, pulse.Position.y + 0.05f, pulse.Position.z); | |
| if (IsAudio(pulse.Type)) { | |
| pulse.RingIndex = DrawRing(pulse.RingIndex, ground, pulse.Radius * expand, color, pulse.Type == StimType.AudioLoud ? loudRingWidth : ringWidth); | |
| if (pulse.Type == StimType.AudioLoud && expand > 0.15f) { | |
| var echo = ColorFor(pulse.Type); | |
| echo.a = fade * 0.4f; | |
| DrawRing(-1, ground, pulse.Radius * expand * 0.5f, echo, ringWidth * 0.55f); | |
| } | |
| return pulse; | |
| } | |
| if (pulse.Observer) { | |
| var eye = pulse.Observer.position + Vector3.up; | |
| var target = pulse.Position + Vector3.up; | |
| pulse.BeamIndex = DrawBeam(pulse.BeamIndex, eye, target, color, beamWidth); | |
| } | |
| var bracketCenter = pulse.Position + Vector3.up * 0.5f; | |
| pulse.BracketIndex = DrawBracket(pulse.BracketIndex, bracketCenter, bracketSize, bracketHeight, color); | |
| DrawRing(-1, ground, 0.5f + expand * 0.5f, color, ringWidth * 0.65f); | |
| return pulse; | |
| } | |
| int DrawRing(int index, Vector3 center, float radius, Color color, float width) { | |
| if (radius <= 0.01f) return index; | |
| var lr = Acquire(rings, ref index); | |
| lr.enabled = true; | |
| ApplyLine(lr, color, width); | |
| lr.positionCount = ringSegments + 1; | |
| lr.loop = true; | |
| for (var i = 0; i <= ringSegments; i++) { | |
| var a = i / (float)ringSegments * Mathf.PI * 2f; | |
| lr.SetPosition(i, center + new Vector3(Mathf.Cos(a) * radius, 0f, Mathf.Sin(a) * radius)); | |
| } | |
| return index; | |
| } | |
| int DrawBeam(int index, Vector3 from, Vector3 to, Color color, float width) { | |
| var lr = Acquire(beams, ref index); | |
| lr.enabled = true; | |
| ApplyLine(lr, color, width); | |
| lr.loop = false; | |
| lr.positionCount = 2; | |
| lr.SetPosition(0, from); | |
| lr.SetPosition(1, to); | |
| return index; | |
| } | |
| int DrawBracket(int index, Vector3 center, float size, float height, Color color) { | |
| var lr = Acquire(brackets, ref index); | |
| lr.enabled = true; | |
| ApplyLine(lr, color, ringWidth); | |
| lr.loop = false; | |
| lr.positionCount = 8; | |
| var h = height * 0.5f; | |
| var s = size * 0.5f; | |
| lr.SetPosition(0, center + new Vector3(-s, -h, 0f)); | |
| lr.SetPosition(1, center + new Vector3(-s, h, 0f)); | |
| lr.SetPosition(2, center + new Vector3(-s * 0.4f, h, 0f)); | |
| lr.SetPosition(3, center + new Vector3(0f, h + s * 0.35f, 0f)); | |
| lr.SetPosition(4, center + new Vector3(s * 0.4f, h, 0f)); | |
| lr.SetPosition(5, center + new Vector3(s, h, 0f)); | |
| lr.SetPosition(6, center + new Vector3(s, -h, 0f)); | |
| lr.SetPosition(7, center + new Vector3(-s, -h, 0f)); | |
| return index; | |
| } | |
| static bool IsAudio(StimType t) => t == StimType.AudioMovement || t == StimType.AudioLoud; | |
| static Color ColorFor(StimType t) { | |
| if (t == StimType.VisualPrimary) return primaryColor; | |
| if (t == StimType.VisualPeripheral) return peripheralColor; | |
| if (t == StimType.AudioLoud) return sprintColor; | |
| return walkColor; | |
| } | |
| void EnsurePool(List<LineRenderer> pool, int count, string label) { | |
| for (var i = pool.Count; i < count; i++) { | |
| var go = new GameObject($"{label}_{i}"); | |
| go.transform.SetParent(transform, false); | |
| var lr = go.AddComponent<LineRenderer>(); | |
| lr.useWorldSpace = true; | |
| lr.shadowCastingMode = ShadowCastingMode.Off; | |
| lr.receiveShadows = false; | |
| lr.material = new Material(Shader.Find("Sprites/Default")); | |
| lr.enabled = false; | |
| pool.Add(lr); | |
| } | |
| } | |
| static void HidePool(List<LineRenderer> pool) { | |
| foreach (var lr in pool) lr.enabled = false; | |
| } | |
| static LineRenderer Acquire(List<LineRenderer> pool, ref int index) { | |
| if (index < 0 || index >= pool.Count || pool[index].enabled) { | |
| index = -1; | |
| for (var i = 0; i < pool.Count; i++) { | |
| if (!pool[i].enabled) { index = i; break; } | |
| } | |
| if (index < 0) index = 0; | |
| } | |
| return pool[index]; | |
| } | |
| static void ApplyLine(LineRenderer lr, Color color, float width) { | |
| lr.startColor = lr.endColor = color; | |
| lr.startWidth = lr.endWidth = width; | |
| } | |
| } | |
| } |

| using AdvancedController; // see https://www.youtube.com/watch?v=jSauntZrQro | |
| using UnityEngine; | |
| namespace Perception { | |
| public class StimEmitter : MonoBehaviour { | |
| [SerializeField] float walkSpeed = 1.5f; | |
| [SerializeField] float sprintSpeed = 4f; | |
| [SerializeField] float walkRadius = 8f; | |
| [SerializeField] float sprintRadius = 18f; | |
| PlayerController player; | |
| Transform body; | |
| void Awake() { | |
| player = GetComponent<PlayerController>(); | |
| body = transform; | |
| } | |
| void Update() { | |
| if (!player || !body) return; | |
| var vel = player.GetMovementVelocity(); | |
| var speed = vel.magnitude; | |
| if (speed < walkSpeed) return; | |
| var loud = speed >= sprintSpeed; | |
| PerceptionHub.Emit( | |
| new Stim( | |
| loud ? StimType.AudioLoud : StimType.AudioMovement, | |
| body, | |
| body.position, | |
| loud ? sprintRadius : walkRadius) | |
| ); | |
| } | |
| } | |
| } |

| using System; | |
| using System.Collections.Generic; | |
| using UnityEngine; | |
| namespace Perception { | |
| public enum StimType { VisualPrimary, VisualPeripheral, AudioMovement, AudioLoud } | |
| public static class PerceptionHub { | |
| static readonly List<PerceptionAgent> agents = new(); | |
| public static event Action<Stim, Transform> StimEmitted; | |
| public static void Register(PerceptionAgent agent) => agents.Add(agent); | |
| public static void Unregister(PerceptionAgent agent) => agents.Remove(agent); | |
| public static void Emit(Stim stim, Transform observer = null) { | |
| StimEmitted?.Invoke(stim, observer); | |
| foreach (var a in agents) a.Receive(stim, observer); | |
| } | |
| } | |
| public class TargetTrack { | |
| public Transform Target; | |
| public Vector3 LastKnownPosition; | |
| readonly Dictionary<StimType, Envelope> envelopes = new(); | |
| public float Score { | |
| get { | |
| var max = 0f; | |
| foreach (var e in envelopes.Values) | |
| if (e.Value > max) max = e.Value; | |
| return max; | |
| } | |
| } | |
| public void Feed(StimType type, float peak, float attack, float release, Vector3 pos) { | |
| LastKnownPosition = pos; | |
| if (!envelopes.TryGetValue(type, out var env)) { | |
| env = new Envelope(); | |
| envelopes[type] = env; | |
| } | |
| env.Configure(peak, attack, release); | |
| } | |
| public void Tick(StimType type, bool stimulated, float dt) { | |
| if (envelopes.TryGetValue(type, out var env)) env.Tick(stimulated, dt); | |
| } | |
| } | |
| public class Envelope { | |
| float value, peak, attackRate, releaseRate; | |
| public float Value => value; | |
| public void Configure(float p, float attack, float release) { | |
| peak = p; | |
| attackRate = p / Mathf.Max(attack, 0.01f); | |
| releaseRate = p / Mathf.Max(release, 0.01f); | |
| } | |
| public void Tick(bool stimulated, float dt) { | |
| if (stimulated) value = Mathf.Min(peak, value + attackRate * dt); | |
| else value = Mathf.Max(0f, value - releaseRate * dt); | |
| } | |
| } | |
| public struct Stim { | |
| public StimType Type { get; } | |
| public Transform Source { get; } | |
| public Vector3 Position { get; } | |
| public float Radius { get; } | |
| public Stim(StimType type, Transform source, Vector3 position, float radius) { | |
| Type = type; | |
| Source = source; | |
| Position = position; | |
| Radius = radius; | |
| } | |
| } | |
| } |
