Last active
June 28, 2026 05:33
-
-
Save adammyhre/db6bc4942b2c76addb65c82aa64e86db to your computer and use it in GitHub Desktop.
Target Tracks Perception System
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | |
| ); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment