using UnityEngine; namespace RailroaderPhysicsOverhaul; public class PhysicsOverlayGUI : MonoBehaviour { public static PhysicsOverlayGUI Instance { get; private set; } public bool Visible = true; public float Opacity = 1.0f; Rect _windowRect = new(10f, 10f, 420f, 10f); GUIStyle _blueLabel; GUIStyle _greenLabel; GUIStyle _yellowLabel; GUIStyle _orangeLabel; GUIStyle _dimLabel; GUIStyle _fps60Label; GUIStyle _fps30Label; GUIStyle _onStyle; // bold green, no button background GUIStyle _offStyle; // bold red, no button background string _lodStatsStr = ""; string _freezeStatsStr = ""; string _debugStatsStr = ""; int _lodStatsFrame; GUIStyle _windowStyle; Texture2D _solidTex; // 1×1 white — lets GUI.backgroundColor.a control opacity 0–100% // Graph rendered as a texture — avoids all GL coordinate-space issues. // Unity textures are Y-up (row 0 = bottom pixel), IMGUI is Y-down, // but GUI.DrawTexture just stretches the texture into the target rect, // so we flip Y in our write formula and the result renders correctly. Texture2D _graphTex; Color32[] _graphPixels; const int TexW = 300; // matches RingSize exactly — 1 pixel column per sample const int TexH = 90; static readonly Color32 ColBg = new(16, 16, 16, 220); static readonly Color32 Col60 = new(210, 210, 210, 255); // 60fps reference line static readonly Color32 Col30 = new(210, 210, 210, 255); // 30fps reference line static readonly Color32 ColRender = new(50, 140, 215, 255); // render frame time (blue) static readonly Color32 ColFrame = new(70, 200, 70, 255); // FixedUpdate total (green) static readonly Color32 ColTick = new(210, 185, 50, 255); // Tick() only (yellow) static readonly Color32 ColPosCars = new(220, 110, 30, 255); // PositionCars (orange) static readonly int WindowId = "RPFOverlay".GetHashCode(); const float RefFps60Ms = 16.7f; const float RefFps30Ms = 33.3f; void Awake() { Instance = this; _graphTex = new Texture2D(TexW, TexH, TextureFormat.RGBA32, false) { filterMode = FilterMode.Point, hideFlags = HideFlags.HideAndDontSave }; _graphPixels = new Color32[TexW * TexH]; _solidTex = new Texture2D(1, 1, TextureFormat.RGBA32, false) { hideFlags = HideFlags.HideAndDontSave }; _solidTex.SetPixel(0, 0, Color.white); _solidTex.Apply(); } void LateUpdate() { // Record render frame time each rendered frame (not each physics tick). // Time.unscaledDeltaTime = wall-clock ms since last render frame. PhysicsTimer.RecordRenderFrame(Time.unscaledDeltaTime * 1000f); } void OnDestroy() { if (_graphTex != null) Destroy(_graphTex); if (_solidTex != null) Destroy(_solidTex); Instance = null; } void OnGUI() { if (!Visible) return; EnsureStyles(); // Tint the solid-white window background to dark gray at the chosen opacity. // Using a custom style (solid texture) means Opacity=1 → fully opaque, not // capped by whatever alpha the default IMGUI skin has baked in. Color prevBg = GUI.backgroundColor; GUI.backgroundColor = new Color(0.063f, 0.063f, 0.063f, Opacity); _windowRect = GUILayout.Window(WindowId, _windowRect, DrawWindow, "Physics Profiler", _windowStyle, GUILayout.MinWidth(420f)); GUI.backgroundColor = prevBg; } void DrawWindow(int _) { // The window background was already drawn with the opacity tint — restore // backgroundColor so buttons and labels inside use their normal skin colors. GUI.backgroundColor = Color.white; GUILayout.Label(PhysicsTimer.GetReport()); // Reserve space for graph in window-local coords. Rect localRect = GUILayoutUtility.GetRect( GUIContent.none, GUIStyle.none, GUILayout.Height(TexH), GUILayout.ExpandWidth(true)); if (Event.current.type == EventType.Repaint) { UpdateGraphTexture(); // GUI.DrawTexture uses window-local coords — no coordinate conversion needed. GUI.DrawTexture(localRect, _graphTex, ScaleMode.StretchToFill); // 10% headroom above the 30fps line so it's never squashed against the top pixel row. float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f); // Reference-line labels: centered vertically on the line, anchored to right edge. float y60Gui = LocalGaugeY(localRect, maxMs, RefFps60Ms); GUI.Label(new Rect(localRect.xMax - 54f, y60Gui - 9f, 52f, 18f), "60 fps", _fps60Label); float y30Gui = LocalGaugeY(localRect, maxMs, RefFps30Ms); GUI.Label(new Rect(localRect.xMax - 54f, y30Gui - 9f, 52f, 18f), "30 fps", _fps30Label); // Current sample readout (top-left of graph). int prev = (PhysicsTimer.RingWrite - 1 + PhysicsTimer.RingSize) % PhysicsTimer.RingSize; GUI.Label(new Rect(localRect.x + 4f, localRect.y + 2f, 340f, 20f), $"render:{PhysicsTimer.RingRender[prev]:F2}ms phys:{PhysicsTimer.RingFrame[prev]:F2}ms tick:{PhysicsTimer.RingTick[prev]:F2}ms", _dimLabel); } // Legend row GUILayout.BeginHorizontal(); GUILayout.Label(" ■ Render", _blueLabel); GUILayout.Label(" ■ FixedUpdate", _greenLabel); GUILayout.Label(" ■ Tick()", _yellowLabel); if (PhysicsTimer.HasPosCarsData) GUILayout.Label(" ■ PosCars", _orangeLabel); GUILayout.Label($" [{PhysicsTimer.RingSize} frames]", _dimLabel); GUILayout.EndHorizontal(); // Physics Optimizer row: plain-text toggle (no button background), then slow-updating stats GUILayout.BeginHorizontal(); GUILayout.Label("Physics Optimizer", _dimLabel, GUILayout.Width(130f)); if (GUILayout.Button(ConsistLOD.Enabled ? "ON" : "OFF", ConsistLOD.Enabled ? _onStyle : _offStyle, GUILayout.Width(34f))) { ConsistLOD.Enabled = !ConsistLOD.Enabled; // Persist toggle state so it survives a game restart. Main.Settings.OptimizerEnabled = ConsistLOD.Enabled; Main.Settings.Save(Main.ModEntry); } // Stats refresh once per second — readable, not flickering if (Time.frameCount != _lodStatsFrame && Time.frameCount % 60 == 0) { _lodStatsFrame = Time.frameCount; if (ConsistLOD.Enabled) { int fast = ConsistLOD.LastFastPathCount; int full = ConsistLOD.LastFullPathCount; int total = fast + full; _lodStatsStr = $"fast:{fast}/{total} full:{full}/{total} dist:{ConsistLOD.DistanceThreshold:F0}m resync:1/{ConsistLOD.ResyncInterval}"; } else { _lodStatsStr = ""; } int atRest = ConsistFreezer.LastAtRestCars; int byDist = ConsistFreezer.LastDistanceCars; _freezeStatsStr = $"stopped:{atRest} dist/spd:{byDist}"; bool anyDbg = Main.Settings.DebugHighlightFrozen || Main.Settings.DebugHighlightFastPath || Main.Settings.DebugHighlightFullPath; _debugStatsStr = anyDbg ? $"dbg — frozen:{ConsistFreezer.FrozenCars.Count} fast:{ConsistLOD.FastPathCars.Count} full:{ConsistLOD.FullPathCars.Count} tinted:{CarDebugVisualizer.Instance?.TintedCount ?? 0}" : ""; } GUILayout.Label(_lodStatsStr, _dimLabel); GUILayout.EndHorizontal(); if (_debugStatsStr.Length > 0) GUILayout.Label(_debugStatsStr, _dimLabel); // Auto-freeze row — shows cars skipping the Verlet tick each physics step GUILayout.BeginHorizontal(); GUILayout.Label("Auto Freeze", _dimLabel, GUILayout.Width(130f)); if (GUILayout.Button(ConsistFreezer.AutoFreezeEnabled ? "ON" : "OFF", ConsistFreezer.AutoFreezeEnabled ? _onStyle : _offStyle, GUILayout.Width(34f))) { ConsistFreezer.AutoFreezeEnabled = !ConsistFreezer.AutoFreezeEnabled; Main.Settings.AutoFreezeEnabled = ConsistFreezer.AutoFreezeEnabled; Main.Settings.Save(Main.ModEntry); } GUILayout.Label(_freezeStatsStr, _dimLabel); GUILayout.EndHorizontal(); GUI.DragWindow(new Rect(0f, 0f, _windowRect.width, 20f)); } // Y in IMGUI window-local space: top of rect = maxMs, bottom = 0ms. static float LocalGaugeY(Rect r, float maxMs, float ms) => r.yMax - Mathf.Clamp01(ms / maxMs) * r.height; void UpdateGraphTexture() { float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f); int write = PhysicsTimer.RingWrite; int n = PhysicsTimer.RingSize; float[] render = PhysicsTimer.RingRender; float[] frame = PhysicsTimer.RingFrame; float[] tick = PhysicsTimer.RingTick; float[] posCars = PhysicsTimer.RingPosCars; // Background fill. for (int i = 0; i < _graphPixels.Length; i++) _graphPixels[i] = ColBg; // Stacked filled areas — drawn back-to-front so later layers paint over earlier ones. // // Layer 1 (bottom): render frame time. DrawFilledArea(render, null, n, write, maxMs, ColRender); // Layer 2: FixedUpdate total, stacked on top of render. DrawFilledArea(frame, render, n, write, maxMs, ColFrame); // Layer 3: Tick(), painted over the lower part of the FixedUpdate band (same baseline). DrawFilledArea(tick, render, n, write, maxMs, ColTick); // Layer 4: PosCars, painted over the lower part of the Tick band (same baseline). if (PhysicsTimer.HasPosCarsData) DrawFilledArea(posCars, render, n, write, maxMs, ColPosCars); // Reference lines on top of all data. DrawHLine(maxMs, RefFps60Ms, Col60); DrawHLine(maxMs, RefFps30Ms, Col30); _graphTex.SetPixels32(_graphPixels); _graphTex.Apply(false); } // Pixel row for a given ms value: row 0 = bottom (0ms), row TexH-1 = top (maxMs). static int MsToRow(float ms, float maxMs) => Mathf.Clamp((int)(ms / maxMs * TexH), 0, TexH - 1); void DrawHLine(float maxMs, float ms, Color32 color) { int row = MsToRow(ms, maxMs); int offset = row * TexW; for (int x = 0; x < TexW; x++) _graphPixels[offset + x] = color; } // Fills a solid area from [baselines[i]] to [baselines[i] + values[i]] for each sample column. // baselines == null means 0 (fill from the bottom of the chart). void DrawFilledArea(float[] values, float[] baselines, int n, int write, float maxMs, Color32 color) { for (int x = 0; x < TexW; x++) { int si = (write + (int)((float)x / TexW * n)) % n; float bot = baselines != null ? baselines[si] : 0f; int rowBot = MsToRow(bot, maxMs); int rowTop = MsToRow(bot + values[si], maxMs); int lo = Mathf.Min(rowBot, rowTop); int hi = Mathf.Max(rowBot, rowTop); for (int r = lo; r <= hi; r++) _graphPixels[r * TexW + x] = color; } } void EnsureStyles() { if (_blueLabel != null) return; _blueLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.2f, 0.55f, 0.85f) } }; _greenLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.4f, 1f, 0.4f) } }; _yellowLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(1f, 0.85f, 0.2f) } }; _orangeLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.9f, 0.45f, 0.1f) } }; _dimLabel = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.55f, 0.55f, 0.55f) } }; _fps60Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } }; _fps30Label = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.82f, 0.82f, 0.82f) } }; _onStyle = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(0.4f, 1f, 0.4f) }, hover = { textColor = new Color(0.7f, 1f, 0.7f) } }; _offStyle = new GUIStyle(GUI.skin.label) { normal = { textColor = new Color(1f, 0.35f, 0.3f) }, hover = { textColor = new Color(1f, 0.6f, 0.55f) } }; // Custom window style: solid white background texture so GUI.backgroundColor.a // gives true 0–100% opacity rather than being capped by the skin's baked-in alpha. _windowStyle = new GUIStyle(GUI.skin.window); _windowStyle.normal.background = _solidTex; _windowStyle.onNormal.background = _solidTex; _windowStyle.focused.background = _solidTex; _windowStyle.onFocused.background = _solidTex; } }