diff --git a/Assets/_Content/Scenes/TitleScreen.unity b/Assets/_Content/Scenes/TitleScreen.unity
index a368ee10..3ad53f2f 100644
--- a/Assets/_Content/Scenes/TitleScreen.unity
+++ b/Assets/_Content/Scenes/TitleScreen.unity
@@ -241,6 +241,108 @@ RectTransform:
m_CorrespondingSourceObject: {fileID: 5856627074613423090, guid: 10b589291a51b4a4f858c6c79ab6772a, type: 3}
m_PrefabInstance: {fileID: 23563224}
m_PrefabAsset: {fileID: 0}
+--- !u!1 &431578943
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 431578944}
+ - component: {fileID: 431578947}
+ - component: {fileID: 431578946}
+ - component: {fileID: 431578945}
+ m_Layer: 0
+ m_Name: Canvas_FPS
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!224 &431578944
+RectTransform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 431578943}
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 0, y: 0, z: 0}
+ m_ConstrainProportionsScale: 0
+ m_Children:
+ - {fileID: 1195733056}
+ m_Father: {fileID: 1741102573}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+ m_AnchorMin: {x: 0, y: 0}
+ m_AnchorMax: {x: 0, y: 0}
+ m_AnchoredPosition: {x: 0, y: 0}
+ m_SizeDelta: {x: 0, y: 0}
+ m_Pivot: {x: 0, y: 0}
+--- !u!114 &431578945
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 431578943}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: dc42784cf147c0c48a680349fa168899, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.GraphicRaycaster
+ m_IgnoreReversedGraphics: 1
+ m_BlockingObjects: 0
+ m_BlockingMask:
+ serializedVersion: 2
+ m_Bits: 4294967295
+--- !u!114 &431578946
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 431578943}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 0cd44c1031e13a943bb63640046fad76, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.CanvasScaler
+ m_UiScaleMode: 0
+ m_ReferencePixelsPerUnit: 100
+ m_ScaleFactor: 1
+ m_ReferenceResolution: {x: 800, y: 600}
+ m_ScreenMatchMode: 0
+ m_MatchWidthOrHeight: 0
+ m_PhysicalUnit: 3
+ m_FallbackScreenDPI: 96
+ m_DefaultSpriteDPI: 96
+ m_DynamicPixelsPerUnit: 1
+ m_PresetInfoIsWorld: 0
+--- !u!223 &431578947
+Canvas:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 431578943}
+ m_Enabled: 1
+ serializedVersion: 3
+ m_RenderMode: 0
+ m_Camera: {fileID: 0}
+ m_PlaneDistance: 100
+ m_PixelPerfect: 0
+ m_ReceivesEvents: 1
+ m_OverrideSorting: 0
+ m_OverridePixelPerfect: 0
+ m_SortingBucketNormalizedSize: 0
+ m_VertexColorAlwaysGammaSpace: 0
+ m_AdditionalShaderChannelsFlag: 0
+ m_UpdateRectTransformForStandalone: 0
+ m_SortingLayerID: 0
+ m_SortingOrder: 0
+ m_TargetDisplay: 0
--- !u!1001 &522028756
PrefabInstance:
m_ObjectHideFlags: 0
@@ -1211,6 +1313,135 @@ Transform:
- {fileID: 1411514848}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!1 &1195136603
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1195136605}
+ - component: {fileID: 1195136604}
+ m_Layer: 0
+ m_Name: PerfManager
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!114 &1195136604
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1195136603}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 508ae74360f7e5249b100f7873b182f1, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Assembly-CSharp::PerformanceOverlay
+ displayText: {fileID: 1195733054}
+ sampleSize: 60
+ updateInterval: 0.1
+ pipelineLatencyFrames: 2
+ goodFPS: 60
+ warningFPS: 30
+--- !u!4 &1195136605
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1195136603}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!1 &1195733053
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1195733056}
+ - component: {fileID: 1195733055}
+ - component: {fileID: 1195733054}
+ m_Layer: 0
+ m_Name: PerfText
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!114 &1195733054
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1195733053}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Text
+ m_Material: {fileID: 0}
+ m_Color: {r: 1, g: 1, b: 1, a: 1}
+ m_RaycastTarget: 1
+ m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
+ m_Maskable: 1
+ m_OnCullStateChanged:
+ m_PersistentCalls:
+ m_Calls: []
+ m_FontData:
+ m_Font: {fileID: 12800000, guid: 8c54d6a69a6e0604b9408d95d425ffc0, type: 3}
+ m_FontSize: 25
+ m_FontStyle: 0
+ m_BestFit: 0
+ m_MinSize: 8
+ m_MaxSize: 102
+ m_Alignment: 4
+ m_AlignByGeometry: 0
+ m_RichText: 1
+ m_HorizontalOverflow: 0
+ m_VerticalOverflow: 0
+ m_LineSpacing: 1
+ m_Text: New Text
+--- !u!222 &1195733055
+CanvasRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1195733053}
+ m_CullTransparentMesh: 1
+--- !u!224 &1195733056
+RectTransform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1195733053}
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 431578944}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+ m_AnchorMin: {x: 0, y: 1}
+ m_AnchorMax: {x: 0, y: 1}
+ m_AnchoredPosition: {x: 80, y: -15}
+ m_SizeDelta: {x: 160, y: 30}
+ m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &1291952569
GameObject:
m_ObjectHideFlags: 0
@@ -2002,6 +2233,7 @@ Transform:
m_Children:
- {fileID: 630642127}
- {fileID: 1505757215}
+ - {fileID: 431578944}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1757053106 stripped
@@ -2956,3 +3188,4 @@ SceneRoots:
- {fileID: 1530196629}
- {fileID: 1741102573}
- {fileID: 1034573946}
+ - {fileID: 1195136605}
diff --git a/Assets/_Content/Scripts/Core/Utils/PerformanceOverlay.cs b/Assets/_Content/Scripts/Core/Utils/PerformanceOverlay.cs
new file mode 100644
index 00000000..4caa2bf1
--- /dev/null
+++ b/Assets/_Content/Scripts/Core/Utils/PerformanceOverlay.cs
@@ -0,0 +1,117 @@
+using UnityEngine;
+using UnityEngine.UI;
+using System.Collections.Generic;
+
+///
+/// PerformanceOverlay — displays FPS, frame time, and estimated input lag.
+/// Singleton: only one instance will ever exist. Safe to place in every scene —
+/// duplicates are destroyed automatically. Persists across scene loads.
+/// Requires: a Canvas with a Text (or TMP_Text) component assigned in the Inspector.
+///
+public class PerformanceOverlay : MonoBehaviour
+{
+ // ── singleton ────────────────────────────────────────────────────────────
+ public static PerformanceOverlay Instance { get; private set; }
+
+ void Awake()
+ {
+ if (Instance != null && Instance != this)
+ {
+ // A valid instance already exists — destroy this duplicate.
+ Destroy(gameObject);
+ return;
+ }
+
+ Instance = this;
+ DontDestroyOnLoad(gameObject);
+ }
+
+ [Header("UI Reference")]
+ [Tooltip("Assign a UI Text or TextMeshPro Text component here.")]
+ public Text displayText; // swap to TMP_Text if using TextMeshPro
+
+ [Header("FPS Settings")]
+ [Tooltip("How many frames to average over for a smooth FPS reading.")]
+ public int sampleSize = 60;
+
+ [Tooltip("How often (seconds) the display refreshes.")]
+ public float updateInterval = 0.1f;
+
+ [Header("Input Lag Settings")]
+ [Tooltip("Frames of render pipeline latency added on top of frame time.\n" +
+ "Typical values: 0–1 for Forward, 1–2 for Deferred / URP with buffering.")]
+ public int pipelineLatencyFrames = 1;
+
+ [Header("Thresholds (for colour coding)")]
+ public int goodFPS = 60;
+ public int warningFPS = 30;
+
+ // ── internals ────────────────────────────────────────────────────────────
+ private readonly Queue _frameTimes = new Queue();
+ private float _accumulatedTime;
+ private float _timeSinceLastUpdate;
+
+ // Colour tags (works with both Text and TMP richtext)
+ private const string GREEN = "";
+ private const string YELLOW = "";
+ private const string RED = "";
+ private const string END = "";
+
+ // ── lifecycle ────────────────────────────────────────────────────────────
+ void Update()
+ {
+ float dt = Time.unscaledDeltaTime; // unscaled so pausing doesn't skew readings
+
+ // Rolling window
+ _frameTimes.Enqueue(dt);
+ _accumulatedTime += dt;
+ if (_frameTimes.Count > sampleSize)
+ _accumulatedTime -= _frameTimes.Dequeue();
+
+ _timeSinceLastUpdate += dt;
+ if (_timeSinceLastUpdate >= updateInterval)
+ {
+ _timeSinceLastUpdate = 0f;
+ RefreshDisplay();
+ }
+ }
+
+ // ── display logic ────────────────────────────────────────────────────────
+ void RefreshDisplay()
+ {
+ if (displayText == null) return;
+
+ // ── FPS ──────────────────────────────────────────────────────────────
+ float avgFrameTime = _accumulatedTime / _frameTimes.Count; // seconds
+ float fps = 1f / avgFrameTime;
+ float frameTimeMs = avgFrameTime * 1000f;
+
+ // ── Input Lag estimate ───────────────────────────────────────────────
+ // Input lag ≈ time from physical input to pixel on screen.
+ // Best estimate without hardware: frame time + pipeline latency frames.
+ //
+ // inputLag ≈ frameTime + (pipelineLatencyFrames × frameTime)
+ // = frameTime × (1 + pipelineLatencyFrames)
+ //
+ // Add 8 ms as a rough USB / Bluetooth HID polling floor.
+ float hidPollMs = 8f; // ~1 frame @ 125 Hz polling; 1 ms @ 1000 Hz
+ float inputLagMs = frameTimeMs * (1 + pipelineLatencyFrames) + hidPollMs;
+
+ // ── colour coding ────────────────────────────────────────────────────
+ string fpsColour = fps >= goodFPS ? GREEN
+ : fps >= warningFPS ? YELLOW
+ : RED;
+
+ // ── format ───────────────────────────────────────────────────────────
+ displayText.text =
+ $"{fpsColour}{fps,5:F0} FPS{END}\n" +
+ $"Frame {frameTimeMs,6:F2} ms\n" +
+ $"Lag ≈ {inputLagMs,6:F2} ms";
+ }
+
+ // ── public helpers ───────────────────────────────────────────────────────
+
+ /// Toggle the overlay on / off (e.g. bind to F3).
+ public void Toggle() => displayText.gameObject.SetActive(
+ !displayText.gameObject.activeSelf);
+}
diff --git a/Assets/_Content/Scripts/Core/Utils/PerformanceOverlay.cs.meta b/Assets/_Content/Scripts/Core/Utils/PerformanceOverlay.cs.meta
new file mode 100644
index 00000000..449c60d4
--- /dev/null
+++ b/Assets/_Content/Scripts/Core/Utils/PerformanceOverlay.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 508ae74360f7e5249b100f7873b182f1
\ No newline at end of file