Skip to content

Instantly share code, notes, and snippets.

@adammyhre
Last active June 28, 2026 05:33
Show Gist options
  • Select an option

  • Save adammyhre/db6bc4942b2c76addb65c82aa64e86db to your computer and use it in GitHub Desktop.

Select an option

Save adammyhre/db6bc4942b2c76addb65c82aa64e86db to your computer and use it in GitHub Desktop.
Target Tracks Perception System
// 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;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment