From 4853015effcafe4512997bdfe3f20ae104ea053c Mon Sep 17 00:00:00 2001 From: seton Date: Thu, 25 Jun 2026 19:06:46 -0400 Subject: [PATCH] Profiler: new unified overlay module; absorb PhysicsOverlayGUI Add a standalone Profiler module (S3.profiler.json, disabled by default) that hosts the in-game frame-time overlay previously owned by Physics Optimizer. The overlay now adapts to whichever modules are enabled: - Always shows: render + physics frame-time graph, timing report. - Physics Optimizer section (if enabled): LOD fast-path and auto-freeze quick-toggles with live stats, debug car count line. - Mesh LOD section (if enabled): total tracked cars, loco/freight split, per-LOD-level counts refreshed once per second. PhysicsOptimizerModule retains only the Harmony patches and CarDebugVisualizer; ShowOverlay/OverlayOpacity removed from PhysicsSettings. MeshLodInjector gains GetLodStats() and GetLodLevel() for the overlay. BBox material shader search now tries URP/Lit before Standard. /rpf overlay toggle redirected to ProfilerOverlayGUI.Instance. --- src/Main.cs | 1 + src/Modules/MeshLod/MeshLodInjector.cs | 62 +++- .../PhysicsOptimizer/ConsoleCommands.cs | 4 +- .../PhysicsOptimizerModule.cs | 17 +- .../PhysicsOptimizer/PhysicsOverlayGUI.cs | 286 ----------------- .../PhysicsOptimizer/PhysicsSettings.cs | 4 - .../PhysicsOptimizer/PhysicsSettingsUI.cs | 29 -- src/Modules/Profiler/ProfilerModule.cs | 48 +++ src/Modules/Profiler/ProfilerOverlayGUI.cs | 290 ++++++++++++++++++ src/Modules/Profiler/ProfilerSettings.cs | 13 + src/Modules/Profiler/ProfilerSettingsUI.cs | 76 +++++ 11 files changed, 495 insertions(+), 335 deletions(-) delete mode 100644 src/Modules/PhysicsOptimizer/PhysicsOverlayGUI.cs create mode 100644 src/Modules/Profiler/ProfilerModule.cs create mode 100644 src/Modules/Profiler/ProfilerOverlayGUI.cs create mode 100644 src/Modules/Profiler/ProfilerSettings.cs create mode 100644 src/Modules/Profiler/ProfilerSettingsUI.cs diff --git a/src/Main.cs b/src/Main.cs index b7cc592..faef95c 100644 --- a/src/Main.cs +++ b/src/Main.cs @@ -30,6 +30,7 @@ public static class Main // constructor. Order here is display order in the settings panel. _registry.Register(new Modules.PhysicsOptimizer.PhysicsOptimizerModule()); _registry.Register(new Modules.MeshLod.MeshLodModule()); + _registry.Register(new Modules.Profiler.ProfilerModule()); _registry.Register(new Modules.Popout.PopoutModule()); _registry.EnableConfigured(); diff --git a/src/Modules/MeshLod/MeshLodInjector.cs b/src/Modules/MeshLod/MeshLodInjector.cs index 7238a44..fbf994d 100644 --- a/src/Modules/MeshLod/MeshLodInjector.cs +++ b/src/Modules/MeshLod/MeshLodInjector.cs @@ -48,6 +48,55 @@ static class MeshLodInjector return (total, locos, total - locos); } + // LOD level distribution — called at most once per second by the Profiler overlay. + public static (int total, int locos, int freight, int atLod0, int atLod1, int atLod2, int atLod3) GetLodStats() + { + int total = 0, locos = 0, freight = 0; + int atLod0 = 0, atLod1 = 0, atLod2 = 0, atLod3 = 0; + foreach (var wr in _trackedCars) + { + if (!wr.TryGetTarget(out Car? car) || car == null || car.BodyTransform == null) continue; + total++; + bool isLoco = car is BaseLocomotive; + if (isLoco) locos++; else freight++; + switch (GetLodLevel(car)) + { + case 0: atLod0++; break; + case 1: atLod1++; break; + case 2: atLod2++; break; + default: atLod3++; break; // 3 = proxy box, -1 = no group (treated same) + } + } + return (total, locos, freight, atLod0, atLod1, atLod2, atLod3); + } + + // Detect current LOD level by reading which renderer sets are active on the LODGroup. + // lods[0] = all renderers; lods[1] = lod1Keep subset; lods[2] = lod2Keep subset; + // lods[3] = bbox. Counting enabled renderers in lods[0] vs the subset sizes + // avoids storing extra per-car state. + static int GetLodLevel(Car car) + { + var lg = car.BodyTransform?.GetComponent(); + if (lg == null) return -1; + LOD[] lods = lg.GetLODs(); + if (lods.Length < 4) return -1; // not our LODGroup + + // LOD3: proxy box renderer is enabled + if (lods[3].renderers.Length > 0 && lods[3].renderers[0] is { } bbox && bbox.enabled) + return 3; + + int enabled = 0; + foreach (var r in lods[0].renderers) + if (r != null && r.enabled) enabled++; + + int total = lods[0].renderers.Length; + int lod2Keep = lods[2].renderers.Length; + + if (enabled == total) return 0; // all renderers on: full detail + if (enabled == 0) return 3; // culled entirely: treat as LOD3 + return enabled > lod2Keep ? 1 : 2; // more than structural count: LOD1, else LOD2 + } + // Called from the UI whenever any setting changes. // Triggers RefreshAll after RefreshDebounce seconds with no further changes, // so slider drags don't spam scene rebuilds. @@ -267,18 +316,23 @@ static class MeshLodInjector // ── Proxy box mesh ──────────────────────────────────────────────────────── - // One matte Standard material shared across all proxy boxes; tinted to the - // car body's albedo so it looks approximately correct under any lighting. + // One matte material shared across all proxy boxes; tinted to the car body's + // albedo so it looks approximately correct under any lighting / post-processing. + // Tries URP first since Railroader uses the Universal Render Pipeline. static Material GetOrCreateBBoxMat(Material? src) { if (_bboxMat != null) return _bboxMat; - var shader = Shader.Find("Standard") ?? Shader.Find("Legacy Shaders/Diffuse"); + var shader = Shader.Find("Universal Render Pipeline/Lit") + ?? Shader.Find("Universal Render Pipeline/Simple Lit") + ?? Shader.Find("Standard") + ?? Shader.Find("Legacy Shaders/Diffuse"); _bboxMat = shader != null ? new Material(shader) : new Material(src); _bboxMat.color = src != null && src.HasProperty("_Color") ? src.color : new Color(0.45f, 0.45f, 0.45f); if (_bboxMat.HasProperty("_Metallic")) _bboxMat.SetFloat("_Metallic", 0f); - if (_bboxMat.HasProperty("_Glossiness")) _bboxMat.SetFloat("_Glossiness", 0.1f); + if (_bboxMat.HasProperty("_Glossiness")) _bboxMat.SetFloat("_Glossiness", 0.1f); // built-in + if (_bboxMat.HasProperty("_Smoothness")) _bboxMat.SetFloat("_Smoothness", 0.1f); // URP return _bboxMat; } diff --git a/src/Modules/PhysicsOptimizer/ConsoleCommands.cs b/src/Modules/PhysicsOptimizer/ConsoleCommands.cs index c4e544f..68508c1 100644 --- a/src/Modules/PhysicsOptimizer/ConsoleCommands.cs +++ b/src/Modules/PhysicsOptimizer/ConsoleCommands.cs @@ -105,8 +105,8 @@ static class RpfCommands static string ToggleOverlay() { - var gui = PhysicsOverlayGUI.Instance; - if (gui == null) return "Overlay not initialized."; + var gui = Profiler.ProfilerOverlayGUI.Instance; + if (gui == null) return "Overlay not initialized (enable the Profiler module)."; gui.Visible = !gui.Visible; return $"Overlay {(gui.Visible ? "shown" : "hidden")}."; } diff --git a/src/Modules/PhysicsOptimizer/PhysicsOptimizerModule.cs b/src/Modules/PhysicsOptimizer/PhysicsOptimizerModule.cs index 8acda39..413b354 100644 --- a/src/Modules/PhysicsOptimizer/PhysicsOptimizerModule.cs +++ b/src/Modules/PhysicsOptimizer/PhysicsOptimizerModule.cs @@ -20,6 +20,7 @@ public sealed class PhysicsOptimizerModule : IModule public static PhysicsSettings Settings { get; private set; } = new(); private static Harmony? _harmony; + private static GameObject? _hostGo; // Attribute-annotated patch classes the module owns. PosCarsTimerPatch is NOT // here — it has no [HarmonyPatch] attribute and is applied dynamically by @@ -40,7 +41,7 @@ public sealed class PhysicsOptimizerModule : IModule public string DisplayName => "Physics Optimizer"; public string Description => "Cuts CPU time spent on train physics (LOD fast-path + auto-freeze for far/slow consists). " + - "Includes a profiler overlay and debug car tinting. Console: /rpf"; + "Includes debug car tinting. Enable the Profiler module for the overlay. Console: /rpf"; public bool Enabled { @@ -57,21 +58,17 @@ public sealed class PhysicsOptimizerModule : IModule _harmony.CreateClassProcessor(t).Patch(); PhysicsTimer.TryPatchPositionCars(_harmony, Main.ModEntry.Logger); - var go = new GameObject("S3.PhysicsOptimizer.Host"); - UnityEngine.Object.DontDestroyOnLoad(go); - PhysicsOverlayGUI overlay = go.AddComponent(); - overlay.Visible = Settings.ShowOverlay; - overlay.Opacity = Settings.OverlayOpacity; - go.AddComponent(); + _hostGo = new GameObject("[S3] PhysicsOptimizerHost"); + UnityEngine.Object.DontDestroyOnLoad(_hostGo); + _hostGo.AddComponent(); } public void OnDisable() { - // Reserved for live toggling. Today modules apply on next launch, so this - // is not called; when it is, unpatch via _harmony.UnpatchAll("S3.physics") - // and destroy the host GameObject. _harmony?.UnpatchAll("S3.physics"); _harmony = null; + if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo); + _hostGo = null; } public void SaveSettings() => Persist(); diff --git a/src/Modules/PhysicsOptimizer/PhysicsOverlayGUI.cs b/src/Modules/PhysicsOptimizer/PhysicsOverlayGUI.cs deleted file mode 100644 index 115736c..0000000 --- a/src/Modules/PhysicsOptimizer/PhysicsOverlayGUI.cs +++ /dev/null @@ -1,286 +0,0 @@ -using UnityEngine; - -namespace S3.Modules.PhysicsOptimizer; - -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 = "S3PhysicsOverlay".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. - PhysicsOptimizerModule.Settings.OptimizerEnabled = ConsistLOD.Enabled; - PhysicsOptimizerModule.Persist(); - } - - // 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 = PhysicsOptimizerModule.Settings.DebugHighlightFrozen || - PhysicsOptimizerModule.Settings.DebugHighlightFastPath || - PhysicsOptimizerModule.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; - PhysicsOptimizerModule.Settings.AutoFreezeEnabled = ConsistFreezer.AutoFreezeEnabled; - PhysicsOptimizerModule.Persist(); - } - 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; - } -} diff --git a/src/Modules/PhysicsOptimizer/PhysicsSettings.cs b/src/Modules/PhysicsOptimizer/PhysicsSettings.cs index ebbe903..a05bc86 100644 --- a/src/Modules/PhysicsOptimizer/PhysicsSettings.cs +++ b/src/Modules/PhysicsOptimizer/PhysicsSettings.cs @@ -20,10 +20,6 @@ public class PhysicsSettings public float AutoFreezeDistance = 200f; // meters public float AutoFreezeSpeedThreshold = 0.09f; // m/s (~0.2 mph) - // Profiler overlay - public bool ShowOverlay = true; - public float OverlayOpacity = 0.75f; - // Locomotive exclusions public bool ExcludeLocosFromLOD = true; public bool ExcludeLocosFromFreeze = true; diff --git a/src/Modules/PhysicsOptimizer/PhysicsSettingsUI.cs b/src/Modules/PhysicsOptimizer/PhysicsSettingsUI.cs index 68ff59c..38050c7 100644 --- a/src/Modules/PhysicsOptimizer/PhysicsSettingsUI.cs +++ b/src/Modules/PhysicsOptimizer/PhysicsSettingsUI.cs @@ -230,35 +230,6 @@ static class PhysicsSettingsUI GUILayout.Space(12f); - // ── Profiler Overlay ────────────────────────────────────────────────────── - GUILayout.Label("Profiler Overlay", GUILayout.ExpandWidth(false)); - GUILayout.Space(4f); - - bool newOverlay = GUILayout.Toggle(s.ShowOverlay, " Show in-game profiler overlay"); - if (newOverlay != s.ShowOverlay) - { - s.ShowOverlay = newOverlay; - if (PhysicsOverlayGUI.Instance != null) - PhysicsOverlayGUI.Instance.Visible = newOverlay; - changed = true; - } - - GUILayout.Space(4f); - - GUILayout.BeginHorizontal(); - GUILayout.Label($"Opacity: {s.OverlayOpacity * 100f:F0}%", GUILayout.Width(110f)); - float newOpacity = GUILayout.HorizontalSlider(s.OverlayOpacity, 0.1f, 1.0f, GUILayout.Width(200f)); - GUILayout.EndHorizontal(); - if (Mathf.Abs(newOpacity - s.OverlayOpacity) > 0.01f) - { - s.OverlayOpacity = newOpacity; - if (PhysicsOverlayGUI.Instance != null) - PhysicsOverlayGUI.Instance.Opacity = s.OverlayOpacity; - changed = true; - } - - GUILayout.Space(12f); - // ── Debug Visualization ─────────────────────────────────────────────────── GUILayout.Label("Debug Visualization", GUILayout.ExpandWidth(false)); GUILayout.Space(4f); diff --git a/src/Modules/Profiler/ProfilerModule.cs b/src/Modules/Profiler/ProfilerModule.cs new file mode 100644 index 0000000..151508b --- /dev/null +++ b/src/Modules/Profiler/ProfilerModule.cs @@ -0,0 +1,48 @@ +using S3.Core; +using UnityEngine; + +namespace S3.Modules.Profiler; + +public sealed class ProfilerModule : IModule +{ + private const string SettingsFile = "S3.profiler.json"; + + public static ProfilerSettings Settings { get; private set; } = new(); + + private static GameObject? _hostGo; + + public ProfilerModule() => Settings = SettingsStore.Load(SettingsFile); + + public string Id => "profiler"; + public string DisplayName => "Profiler"; + public string Description => + "In-game performance overlay: render and physics frame-time graph, " + + "Physics Optimizer quick-toggles, and Mesh LOD car counts. " + + "Sections appear only when the corresponding module is enabled."; + + public bool Enabled + { + get => Settings.enabled; + set => Settings.enabled = value; + } + + public void OnEnable() + { + _hostGo = new GameObject("[S3] ProfilerHost"); + UnityEngine.Object.DontDestroyOnLoad(_hostGo); + var overlay = _hostGo.AddComponent(); + overlay.Visible = Settings.visible; + overlay.Opacity = Settings.opacity; + } + + public void OnDisable() + { + if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo); + _hostGo = null; + } + + public void SaveSettings() => Persist(); + internal static void Persist() => SettingsStore.Save(SettingsFile, Settings); + + public void DrawSettings() => ProfilerSettingsUI.Draw(); +} diff --git a/src/Modules/Profiler/ProfilerOverlayGUI.cs b/src/Modules/Profiler/ProfilerOverlayGUI.cs new file mode 100644 index 0000000..ab17d4f --- /dev/null +++ b/src/Modules/Profiler/ProfilerOverlayGUI.cs @@ -0,0 +1,290 @@ +using S3.Modules.MeshLod; +using S3.Modules.PhysicsOptimizer; +using UnityEngine; + +namespace S3.Modules.Profiler; + +public class ProfilerOverlayGUI : MonoBehaviour +{ + public static ProfilerOverlayGUI? Instance { get; private set; } + public bool Visible = true; + public float Opacity = 0.85f; + + Rect _windowRect = new(10f, 10f, 420f, 10f); + + GUIStyle _blueLabel; + GUIStyle _greenLabel; + GUIStyle _yellowLabel; + GUIStyle _orangeLabel; + GUIStyle _dimLabel; + GUIStyle _fps60Label; + GUIStyle _fps30Label; + GUIStyle _onStyle; + GUIStyle _offStyle; + GUIStyle _windowStyle; + Texture2D _solidTex; + + Texture2D _graphTex; + Color32[] _graphPixels; + const int TexW = 300; + const int TexH = 90; + + static readonly Color32 ColBg = new(16, 16, 16, 220); + static readonly Color32 Col60 = new(210, 210, 210, 255); + static readonly Color32 Col30 = new(210, 210, 210, 255); + static readonly Color32 ColRender = new(50, 140, 215, 255); + static readonly Color32 ColFrame = new(70, 200, 70, 255); + static readonly Color32 ColTick = new(210, 185, 50, 255); + static readonly Color32 ColPosCars = new(220, 110, 30, 255); + + static readonly int WindowId = "S3ProfilerOverlay".GetHashCode(); + + const float RefFps60Ms = 16.7f; + const float RefFps30Ms = 33.3f; + + // Per-second stats cache + string _physLodStr = ""; + string _physFreezeStr = ""; + string _physDebugStr = ""; + string _meshLodStr = ""; + int _statsFrame; + + 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() => 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(); + Color prevBg = GUI.backgroundColor; + GUI.backgroundColor = new Color(0.063f, 0.063f, 0.063f, Opacity); + _windowRect = GUILayout.Window(WindowId, _windowRect, DrawWindow, + "Profiler", _windowStyle, GUILayout.MinWidth(420f)); + GUI.backgroundColor = prevBg; + } + + void DrawWindow(int _) + { + GUI.backgroundColor = Color.white; + + // ── Frame-time report ───────────────────────────────────────────────── + GUILayout.Label(PhysicsTimer.GetReport()); + + Rect graphRect = GUILayoutUtility.GetRect( + GUIContent.none, GUIStyle.none, + GUILayout.Height(TexH), GUILayout.ExpandWidth(true)); + + if (Event.current.type == EventType.Repaint) + { + UpdateGraphTexture(); + GUI.DrawTexture(graphRect, _graphTex, ScaleMode.StretchToFill); + + float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f); + GUI.Label(new Rect(graphRect.xMax - 54f, LocalGaugeY(graphRect, maxMs, RefFps60Ms) - 9f, 52f, 18f), "60 fps", _fps60Label); + GUI.Label(new Rect(graphRect.xMax - 54f, LocalGaugeY(graphRect, maxMs, RefFps30Ms) - 9f, 52f, 18f), "30 fps", _fps30Label); + + int prev = (PhysicsTimer.RingWrite - 1 + PhysicsTimer.RingSize) % PhysicsTimer.RingSize; + GUI.Label(new Rect(graphRect.x + 4f, graphRect.y + 2f, 340f, 20f), + $"render:{PhysicsTimer.RingRender[prev]:F2}ms phys:{PhysicsTimer.RingFrame[prev]:F2}ms tick:{PhysicsTimer.RingTick[prev]:F2}ms", + _dimLabel); + } + + 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(); + + // Refresh per-second stats cache + if (Time.frameCount != _statsFrame && Time.frameCount % 60 == 0) + { + _statsFrame = Time.frameCount; + RefreshStats(); + } + + // ── Physics Optimizer section ───────────────────────────────────────── + if (PhysicsOptimizerModule.Settings.enabled && ProfilerModule.Settings.showPhysicsSection) + { + GUILayout.Space(4f); + 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; + PhysicsOptimizerModule.Settings.OptimizerEnabled = ConsistLOD.Enabled; + PhysicsOptimizerModule.Persist(); + } + GUILayout.Label(_physLodStr, _dimLabel); + GUILayout.EndHorizontal(); + + 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; + PhysicsOptimizerModule.Settings.AutoFreezeEnabled = ConsistFreezer.AutoFreezeEnabled; + PhysicsOptimizerModule.Persist(); + } + GUILayout.Label(_physFreezeStr, _dimLabel); + GUILayout.EndHorizontal(); + + if (_physDebugStr.Length > 0) + GUILayout.Label(_physDebugStr, _dimLabel); + } + + // ── Mesh LOD section ────────────────────────────────────────────────── + if (MeshLodModule.Settings.enabled && ProfilerModule.Settings.showMeshLodSection) + { + GUILayout.Space(4f); + GUILayout.Label(_meshLodStr, _dimLabel); + } + + GUI.DragWindow(new Rect(0f, 0f, _windowRect.width, 20f)); + } + + void RefreshStats() + { + bool physOn = PhysicsOptimizerModule.Settings.enabled; + + if (physOn && ConsistLOD.Enabled) + { + int fast = ConsistLOD.LastFastPathCount; + int full = ConsistLOD.LastFullPathCount; + _physLodStr = $"fast:{fast}/{fast + full} full:{full}/{fast + full} dist:{ConsistLOD.DistanceThreshold:F0}m resync:1/{ConsistLOD.ResyncInterval}"; + } + else + { + _physLodStr = ""; + } + + if (physOn) + { + _physFreezeStr = $"stopped:{ConsistFreezer.LastAtRestCars} dist/spd:{ConsistFreezer.LastDistanceCars}"; + + bool anyDbg = PhysicsOptimizerModule.Settings.DebugHighlightFrozen || + PhysicsOptimizerModule.Settings.DebugHighlightFastPath || + PhysicsOptimizerModule.Settings.DebugHighlightFullPath; + _physDebugStr = anyDbg + ? $"dbg frozen:{ConsistFreezer.FrozenCars.Count} fast:{ConsistLOD.FastPathCars.Count} full:{ConsistLOD.FullPathCars.Count} tinted:{CarDebugVisualizer.Instance?.TintedCount ?? 0}" + : ""; + } + else + { + _physFreezeStr = _physDebugStr = ""; + } + + if (MeshLodModule.Settings.enabled) + { + var (tot, locos, freight, l0, l1, l2, l3) = MeshLodInjector.GetLodStats(); + _meshLodStr = tot == 0 + ? "Mesh LOD — no cars loaded yet" + : $"Mesh LOD — {tot} tracked ({locos}L / {freight}C) LOD0:{l0} LOD1:{l1} LOD2:{l2} LOD3:{l3}"; + } + else + { + _meshLodStr = ""; + } + } + + // ── Graph rendering ─────────────────────────────────────────────────────── + + void UpdateGraphTexture() + { + float maxMs = Mathf.Max(PhysicsTimer.GetRingMax(), RefFps30Ms * 1.1f); + int write = PhysicsTimer.RingWrite; + int n = PhysicsTimer.RingSize; + + for (int i = 0; i < _graphPixels.Length; i++) + _graphPixels[i] = ColBg; + + DrawFilledArea(PhysicsTimer.RingRender, null, n, write, maxMs, ColRender); + DrawFilledArea(PhysicsTimer.RingFrame, PhysicsTimer.RingRender, n, write, maxMs, ColFrame); + DrawFilledArea(PhysicsTimer.RingTick, PhysicsTimer.RingRender, n, write, maxMs, ColTick); + if (PhysicsTimer.HasPosCarsData) + DrawFilledArea(PhysicsTimer.RingPosCars, PhysicsTimer.RingRender, n, write, maxMs, ColPosCars); + + DrawHLine(maxMs, RefFps60Ms, Col60); + DrawHLine(maxMs, RefFps30Ms, Col30); + + _graphTex.SetPixels32(_graphPixels); + _graphTex.Apply(false); + } + + static float LocalGaugeY(Rect r, float maxMs, float ms) => + r.yMax - Mathf.Clamp01(ms / maxMs) * r.height; + + 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); + for (int x = 0; x < TexW; x++) + _graphPixels[row * TexW + x] = color; + } + + 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) }, + }; + _windowStyle = new GUIStyle(GUI.skin.window); + _windowStyle.normal.background = _solidTex; + _windowStyle.onNormal.background = _solidTex; + _windowStyle.focused.background = _solidTex; + _windowStyle.onFocused.background = _solidTex; + } +} diff --git a/src/Modules/Profiler/ProfilerSettings.cs b/src/Modules/Profiler/ProfilerSettings.cs new file mode 100644 index 0000000..6940a5e --- /dev/null +++ b/src/Modules/Profiler/ProfilerSettings.cs @@ -0,0 +1,13 @@ +using System; + +namespace S3.Modules.Profiler; + +[Serializable] +public class ProfilerSettings +{ + public bool enabled = false; + public bool visible = true; + public float opacity = 0.85f; + public bool showPhysicsSection = true; + public bool showMeshLodSection = true; +} diff --git a/src/Modules/Profiler/ProfilerSettingsUI.cs b/src/Modules/Profiler/ProfilerSettingsUI.cs new file mode 100644 index 0000000..f542f8f --- /dev/null +++ b/src/Modules/Profiler/ProfilerSettingsUI.cs @@ -0,0 +1,76 @@ +using S3.Modules.MeshLod; +using S3.Modules.PhysicsOptimizer; +using UnityEngine; + +namespace S3.Modules.Profiler; + +static class ProfilerSettingsUI +{ + public static void Draw() + { + ProfilerSettings s = ProfilerModule.Settings; + bool changed = false; + + GUILayout.BeginVertical(); + + GUILayout.Label("Profiler — in-game frame-time overlay with module readouts"); + GUILayout.Space(4f); + GUILayout.Label( + " Always shows the render and physics frame-time graph.\n" + + " Physics Optimizer and Mesh LOD sections appear when those modules are enabled.", + GUI.skin.label); + + // ── Overlay visibility ──────────────────────────────────────────────── + GUILayout.Space(10f); + GUILayout.Label("Display"); + GUILayout.Space(4f); + + bool newVisible = GUILayout.Toggle(s.visible, " Show overlay in-game"); + if (newVisible != s.visible) + { + s.visible = newVisible; + if (ProfilerOverlayGUI.Instance != null) + ProfilerOverlayGUI.Instance.Visible = newVisible; + changed = true; + } + + GUILayout.Space(4f); + + GUILayout.BeginHorizontal(); + GUILayout.Label($"Opacity: {s.opacity * 100f:F0}%", GUILayout.Width(110f)); + float newOpacity = GUILayout.HorizontalSlider(s.opacity, 0.1f, 1.0f, GUILayout.Width(200f)); + GUILayout.EndHorizontal(); + if (Mathf.Abs(newOpacity - s.opacity) > 0.01f) + { + s.opacity = newOpacity; + if (ProfilerOverlayGUI.Instance != null) + ProfilerOverlayGUI.Instance.Opacity = newOpacity; + changed = true; + } + + // ── Section visibility ──────────────────────────────────────────────── + GUILayout.Space(10f); + GUILayout.Label("Sections"); + GUILayout.Space(4f); + + bool physAvail = PhysicsOptimizerModule.Settings.enabled; + GUI.enabled = physAvail; + bool newPhys = GUILayout.Toggle(s.showPhysicsSection && physAvail, + " Physics Optimizer" + (physAvail ? "" : " (module not enabled)")); + GUI.enabled = true; + if (newPhys != s.showPhysicsSection && physAvail) + { s.showPhysicsSection = newPhys; changed = true; } + + bool meshAvail = MeshLodModule.Settings.enabled; + GUI.enabled = meshAvail; + bool newMesh = GUILayout.Toggle(s.showMeshLodSection && meshAvail, + " Mesh LOD" + (meshAvail ? "" : " (module not enabled)")); + GUI.enabled = true; + if (newMesh != s.showMeshLodSection && meshAvail) + { s.showMeshLodSection = newMesh; changed = true; } + + GUILayout.EndVertical(); + + if (changed) ProfilerModule.Persist(); + } +}