diff --git a/README.md b/README.md index 529f0b9..ae8566d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ I originally planned on releasing individual mods, but considering my workflow o | Map Module | In-game map overlay and detachable popout window for a second monitor. Themes, custom colors, opacity controls, map rotation, track & industry labels, and optional MapEnhancer integration. | | Physics Optimizer | Cuts CPU spent on train physics (LOD fast-path + auto-freeze), with debug car tinting. Console: `/rpf` | | Mesh LOD | Adds level-of-detail to rolling stock: distant cars progressively shed detail and finally collapse to a cheap proxy box, cutting triangle count on large saves. | +| Base Game Performance | Smooths Unity's incremental garbage collector and Nature Renderer grass streaming to reduce camera-motion hitches without lowering visual quality. | | Profiler | Unified in-game performance overlay: a frame-time graph plus live readouts that adapt to whichever optimization modules are enabled. Console: `/rpf overlay` | All modules are disabled by default; enable them per-module from the S³ settings page. A game restart is required for enable/disable to take effect. More modules will follow; S³ is designed to grow. @@ -159,6 +160,20 @@ All three LOD reductions side by side, with the transition distances set artific --- +## Base Game Performance + +Direct per-frame profiling found that the repeatable base-game stalls were garbage-collection +frames rather than car culling, scenery loading, world streaming, or camera ground queries. +This module reduces Unity's incremental GC slice from the game's 3 ms default to a configurable +1 ms starting point. The work is spread across more frames, reducing individual stalls without +changing resolution, shadows, draw distance, scenery, rolling-stock detail, or simulation. + +It can also cap Nature Renderer grass-cell streaming (instance budget, queued nearby loads, +and staggered cell expiry) so abrupt camera looks hitch less. Density and draw distance stay +the same. The original runtime values are restored immediately when the module is disabled. + +--- + ## Profiler A unified in-game performance overlay. It always shows a render + physics frame-time graph (render, FixedUpdate, Tick, and PosCars times) and a timing report, and it grows extra sections for whichever optimization modules are enabled: diff --git a/src/Main.cs b/src/Main.cs index 127fa84..798003e 100644 --- a/src/Main.cs +++ b/src/Main.cs @@ -32,6 +32,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.BaseGamePerf.BaseGamePerfModule()); _registry.Register(new Modules.Profiler.ProfilerModule()); _registry.Register(new Modules.Popout.PopoutModule()); diff --git a/src/Modules/BaseGamePerf/BaseGamePerfModule.cs b/src/Modules/BaseGamePerf/BaseGamePerfModule.cs new file mode 100644 index 0000000..fc8655e --- /dev/null +++ b/src/Modules/BaseGamePerf/BaseGamePerfModule.cs @@ -0,0 +1,82 @@ +using S3.Core; +using UnityEngine; +using UnityEngine.Scripting; + +namespace S3.Modules.BaseGamePerf; + +public sealed class BaseGamePerfModule : IModule +{ + const string SettingsFile = "S3.basegame.json"; + + static bool _hasOriginal; + static ulong _originalSliceNanoseconds; + + public static BaseGamePerfSettings Settings { get; private set; } = new(); + + public BaseGamePerfModule() => + Settings = SettingsStore.Load(SettingsFile); + + public string Id => "basegame"; + public string DisplayName => "Base Game Performance"; + public string Description => + "Evidence-backed vanilla-game hitch controls. Smooths incremental GC and " + + "Nature Renderer grass-cell streaming without reducing density, draw distance, " + + "resolution, or simulation quality."; + + public bool Enabled + { + get => Settings.enabled; + set => Settings.enabled = value; + } + + public void OnEnable() + { + if (!_hasOriginal) + { + _originalSliceNanoseconds = GarbageCollector.incrementalTimeSliceNanoseconds; + _hasOriginal = true; + } + try { NatureStreamingOptimizer.Enable(); } + catch (System.Exception ex) + { + Log.Warn( + "[basegame] Nature streaming smoothing unavailable: " + + ex.GetBaseException().Message); + } + ApplyRuntimeSettings(); + } + + public void OnDisable() + { + NatureStreamingOptimizer.Disable(); + if (_hasOriginal) + { + GarbageCollector.incrementalTimeSliceNanoseconds = _originalSliceNanoseconds; + Log.Info( + $"[basegame] Restored incremental GC slice to " + + $"{_originalSliceNanoseconds / 1_000_000.0:0.###}ms."); + } + } + + public static void ApplyRuntimeSettings() + { + if (!GarbageCollector.isIncremental) + { + Log.Warn("[basegame] Unity incremental GC is disabled; smoothing was not applied."); + return; + } + + ulong desired = Settings.gcSmoothingEnabled + ? (ulong)(Mathf.Clamp(Settings.incrementalSliceMs, 0.25f, 5f) * 1_000_000f) + : _originalSliceNanoseconds; + GarbageCollector.incrementalTimeSliceNanoseconds = desired; + Log.Info( + $"[basegame] Incremental GC slice={desired / 1_000_000.0:0.###}ms " + + $"(smoothing={Settings.gcSmoothingEnabled})."); + NatureStreamingOptimizer.ApplySettings(); + } + + public void SaveSettings() => Persist(); + public static void Persist() => SettingsStore.Save(SettingsFile, Settings); + public void DrawSettings() => BaseGamePerfSettingsUI.Draw(); +} diff --git a/src/Modules/BaseGamePerf/BaseGamePerfSettings.cs b/src/Modules/BaseGamePerf/BaseGamePerfSettings.cs new file mode 100644 index 0000000..d455a24 --- /dev/null +++ b/src/Modules/BaseGamePerf/BaseGamePerfSettings.cs @@ -0,0 +1,16 @@ +using System; + +namespace S3.Modules.BaseGamePerf; + +[Serializable] +public sealed class BaseGamePerfSettings +{ + public bool enabled = false; + public bool gcSmoothingEnabled = true; + public float incrementalSliceMs = 1f; + public bool natureStreamingSmoothingEnabled = true; + public int grassInstanceBudgetPerFrame = 256; + public bool queueNearbyGrassLoads = true; + public int grassUnloadSpreadFrames = 120; + public bool distanceCullNatureTerrains = false; +} diff --git a/src/Modules/BaseGamePerf/BaseGamePerfSettingsUI.cs b/src/Modules/BaseGamePerf/BaseGamePerfSettingsUI.cs new file mode 100644 index 0000000..9b65a20 --- /dev/null +++ b/src/Modules/BaseGamePerf/BaseGamePerfSettingsUI.cs @@ -0,0 +1,112 @@ +using UnityEngine; + +namespace S3.Modules.BaseGamePerf; + +static class BaseGamePerfSettingsUI +{ + public static void Draw() + { + BaseGamePerfSettings s = BaseGamePerfModule.Settings; + bool changed = false; + + GUILayout.BeginVertical(); + GUILayout.Label("Base Game Performance — conservative vanilla hitch controls"); + GUILayout.Space(4f); + GUILayout.Label( + " Smooths Unity's incremental garbage collection. This does not alter\n" + + " resolution, shadows, draw distance, scenery, or rolling-stock detail.", + GUI.skin.label); + GUILayout.Space(8f); + + bool enabled = GUILayout.Toggle( + s.gcSmoothingEnabled, + " Shorter incremental GC slices"); + if (enabled != s.gcSmoothingEnabled) + { + s.gcSmoothingEnabled = enabled; + changed = true; + } + + GUILayout.BeginHorizontal(); + GUILayout.Label($"Slice budget: {s.incrementalSliceMs:0.00}ms", GUILayout.Width(150f)); + float slice = GUILayout.HorizontalSlider( + s.incrementalSliceMs, 0.25f, 3f, GUILayout.Width(200f)); + GUILayout.EndHorizontal(); + if (Mathf.Abs(slice - s.incrementalSliceMs) > 0.01f) + { + s.incrementalSliceMs = slice; + changed = true; + } + + GUILayout.Label( + " Lower values reduce individual GC stalls but spread collection work\n" + + " across more frames. 1.00ms is the measured starting point.", + GUI.skin.label); + GUILayout.Space(10f); + + bool nature = GUILayout.Toggle( + s.natureStreamingSmoothingEnabled, + " Smooth Nature Renderer grass streaming"); + if (nature != s.natureStreamingSmoothingEnabled) + { + s.natureStreamingSmoothingEnabled = nature; + changed = true; + } + + GUILayout.BeginHorizontal(); + GUILayout.Label( + $"Grass load budget: {s.grassInstanceBudgetPerFrame} instances/terrain", + GUILayout.Width(250f)); + int grassBudget = Mathf.RoundToInt(GUILayout.HorizontalSlider( + s.grassInstanceBudgetPerFrame, 64f, 1024f, GUILayout.Width(200f))); + GUILayout.EndHorizontal(); + if (grassBudget != s.grassInstanceBudgetPerFrame) + { + s.grassInstanceBudgetPerFrame = grassBudget; + changed = true; + } + + bool queueNearby = GUILayout.Toggle( + s.queueNearbyGrassLoads, + " Queue nearby grass cells instead of force-loading them"); + if (queueNearby != s.queueNearbyGrassLoads) + { + s.queueNearbyGrassLoads = queueNearby; + changed = true; + } + + GUILayout.BeginHorizontal(); + GUILayout.Label( + $"Grass unload spread: {s.grassUnloadSpreadFrames} frames", + GUILayout.Width(250f)); + int unloadSpread = Mathf.RoundToInt(GUILayout.HorizontalSlider( + s.grassUnloadSpreadFrames, 0f, 300f, GUILayout.Width(200f))); + GUILayout.EndHorizontal(); + if (unloadSpread != s.grassUnloadSpreadFrames) + { + s.grassUnloadSpreadFrames = unloadSpread; + changed = true; + } + + bool distanceCull = GUILayout.Toggle( + s.distanceCullNatureTerrains, + " Unload Nature Renderer data for distant terrain tiles (experimental)"); + if (distanceCull != s.distanceCullNatureTerrains) + { + s.distanceCullNatureTerrains = distanceCull; + changed = true; + } + + GUILayout.Label( + " The default 256/120 settings preserve density and draw distance while\n" + + " spreading cell uploads and expiry across frames. Distant-terrain\n" + + " lifecycle is off by default until portal-camera visuals are validated.", + GUI.skin.label); + GUILayout.EndVertical(); + + if (!changed) return; + if (Main.Registry.IsActive("basegame")) + BaseGamePerfModule.ApplyRuntimeSettings(); + BaseGamePerfModule.Persist(); + } +} diff --git a/src/Modules/BaseGamePerf/NatureStreamingOptimizer.cs b/src/Modules/BaseGamePerf/NatureStreamingOptimizer.cs new file mode 100644 index 0000000..d282886 --- /dev/null +++ b/src/Modules/BaseGamePerf/NatureStreamingOptimizer.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using S3.Core; +using UnityEngine; +using VisualDesignCafe.Rendering.Instancing; +using VisualDesignCafe.Rendering.Nature; + +namespace S3.Modules.BaseGamePerf; + +/// +/// Conservative smoothing for Nature Renderer's terrain-detail streaming. +/// It changes scheduling and residency only; density, render distance, +/// materials, shadows, and terrain content are untouched. +/// +static class NatureStreamingOptimizer +{ + sealed class RendererState + { + public WeakReference Renderer = null!; + public bool OnlyInitializeWithinRenderingDistance; + } + + static readonly Dictionary OriginalRenderers = new(); + static readonly FieldInfo? RenderingDistanceLimitField = + AccessTools.Field(typeof(NatureRenderer), "_renderingDistanceLimit"); + static readonly FieldInfo? StreamingBudgetField = + AccessTools.Field(typeof(TerrainGrassStreamer), "_globalStreamingBudget"); + static readonly FieldInfo? ForceNearbyField = + AccessTools.Field(typeof(TerrainGrassStreamer), "_globalNearbyCellLoading"); + static readonly FieldInfo? TimersField = + AccessTools.Field(typeof(TerrainGrassStreamer), "_inRangeOfAnyCamera"); + + static Harmony? _harmony; + static int _originalStreamingBudget = 1024; + static bool _originalForceNearby = true; + + public static void Enable() + { + if (_harmony != null) return; + CaptureGrassGlobals(); + _harmony = new Harmony("S3.basegame.nature"); + _harmony.CreateClassProcessor( + typeof(GrassBudgetSetterPatch)).Patch(); + _harmony.CreateClassProcessor( + typeof(GrassNearbySetterPatch)).Patch(); + _harmony.CreateClassProcessor( + typeof(NatureRendererOnEnablePatch)).Patch(); + _harmony.CreateClassProcessor( + typeof(NatureRendererRangePatch)).Patch(); + _harmony.CreateClassProcessor( + typeof(TerrainGrassExpiryPatch)).Patch(); + ApplySettings(); + } + + public static void Disable() + { + _harmony?.UnpatchAll("S3.basegame.nature"); + _harmony = null; + RestoreRendererSettings(); + try + { + TerrainGrassStreamer.SetStreamingBudget(_originalStreamingBudget); + TerrainGrassStreamer.SetForceLoadNearbyCells(_originalForceNearby); + } + catch { } + } + + public static void ApplySettings() + { + BaseGamePerfSettings settings = BaseGamePerfModule.Settings; + if (!settings.natureStreamingSmoothingEnabled) + { + RestoreRendererSettings(); + TerrainGrassStreamer.SetStreamingBudget(_originalStreamingBudget); + TerrainGrassStreamer.SetForceLoadNearbyCells(_originalForceNearby); + return; + } + + TerrainGrassStreamer.SetStreamingBudget( + Mathf.Clamp(settings.grassInstanceBudgetPerFrame, 64, 4096)); + TerrainGrassStreamer.SetForceLoadNearbyCells( + !settings.queueNearbyGrassLoads); + + NatureRenderer[] renderers = + Resources.FindObjectsOfTypeAll(); + for (int i = 0; i < renderers.Length; i++) + TrackAndApply(renderers[i]); + + Log.Info( + $"[basegame] Nature streaming: grassBudget=" + + $"{settings.grassInstanceBudgetPerFrame} instances/frame, " + + $"queueNearby={settings.queueNearbyGrassLoads}, " + + $"unloadSpread={settings.grassUnloadSpreadFrames} frames, " + + $"distanceLifecycle={settings.distanceCullNatureTerrains}, " + + $"terrains={OriginalRenderers.Count}."); + } + + static void CaptureGrassGlobals() + { + try + { + if (StreamingBudgetField?.GetValue(null) is int budget) + _originalStreamingBudget = budget; + if (ForceNearbyField?.GetValue(null) is bool force) + _originalForceNearby = force; + } + catch { } + } + + static void TrackAndApply(NatureRenderer? renderer) + { + if (renderer == null || !renderer.gameObject.scene.IsValid()) return; + int id = renderer.GetInstanceID(); + if (!OriginalRenderers.ContainsKey(id)) + { + OriginalRenderers[id] = new RendererState + { + Renderer = new WeakReference(renderer), + OnlyInitializeWithinRenderingDistance = + renderer.OnlyInitializeWithinRenderingDistance, + }; + } + renderer.OnlyInitializeWithinRenderingDistance = + BaseGamePerfModule.Settings.distanceCullNatureTerrains; + } + + static void RestoreRendererSettings() + { + foreach (RendererState state in OriginalRenderers.Values) + { + if (!state.Renderer.TryGetTarget(out NatureRenderer? renderer) || + renderer == null) + continue; + renderer.OnlyInitializeWithinRenderingDistance = + state.OnlyInitializeWithinRenderingDistance; + } + OriginalRenderers.Clear(); + } + + static bool CorrectRangeCheck( + NatureRenderer renderer, + double threshold) + { + Terrain? terrain = renderer.Terrain; + TerrainData? data = renderer.TerrainData; + if (terrain == null || data == null) return false; + + Bounds bounds = data.bounds; + bounds.center += terrain.GetPosition(); + float configuredLimit = 5000f; + try + { + if (RenderingDistanceLimitField?.GetValue(renderer) is float value) + configuredLimit = value; + } + catch { } + + foreach (CameraRenderer cameraRenderer in RendererPool.GetCameras()) + { + Camera? camera = cameraRenderer?.Camera; + if (camera == null || + camera.cameraType == CameraType.Preview || + camera.cameraType == CameraType.SceneView) + continue; + + Vector3 position = camera.transform.position; + Vector3 center = bounds.center; + Vector3 extents = bounds.extents; + double dx = Math.Max( + 0.0, Math.Abs(position.x - center.x) - extents.x); + double dy = Math.Max( + 0.0, Math.Abs(position.y - center.y) - extents.y); + double dz = Math.Max( + 0.0, Math.Abs(position.z - center.z) - extents.z); + double distance = Math.Sqrt(dx * dx + dy * dy + dz * dz); + double limit = Math.Min(configuredLimit, camera.farClipPlane); + if (distance < limit + threshold) return true; + } + return false; + } + + [HarmonyPatch( + typeof(TerrainGrassStreamer), + nameof(TerrainGrassStreamer.SetStreamingBudget))] + static class GrassBudgetSetterPatch + { + static void Prefix(ref int __0) + { + BaseGamePerfSettings settings = BaseGamePerfModule.Settings; + if (!settings.natureStreamingSmoothingEnabled) return; + __0 = Mathf.Min( + __0, Mathf.Clamp(settings.grassInstanceBudgetPerFrame, 64, 4096)); + } + } + + [HarmonyPatch( + typeof(TerrainGrassStreamer), + nameof(TerrainGrassStreamer.SetForceLoadNearbyCells))] + static class GrassNearbySetterPatch + { + static void Prefix(ref bool __0) + { + BaseGamePerfSettings settings = BaseGamePerfModule.Settings; + if (settings.natureStreamingSmoothingEnabled && + settings.queueNearbyGrassLoads) + __0 = false; + } + } + + [HarmonyPatch(typeof(NatureRenderer), "OnEnable")] + static class NatureRendererOnEnablePatch + { + static void Postfix(NatureRenderer __instance) + { + if (BaseGamePerfModule.Settings.natureStreamingSmoothingEnabled) + TrackAndApply(__instance); + } + } + + [HarmonyPatch(typeof(NatureRenderer), "IsInRangeOfAnyCamera")] + static class NatureRendererRangePatch + { + static bool Prefix( + NatureRenderer __instance, + double __0, + ref bool __result) + { + BaseGamePerfSettings settings = BaseGamePerfModule.Settings; + if (!settings.natureStreamingSmoothingEnabled || + !settings.distanceCullNatureTerrains) + return true; + __result = CorrectRangeCheck(__instance, __0); + return false; + } + } + + [HarmonyPatch(typeof(TerrainGrassStreamer), "OnCellOutOfRange")] + static class TerrainGrassExpiryPatch + { + static void Postfix(TerrainGrassStreamer __instance, int __1) + { + BaseGamePerfSettings settings = BaseGamePerfModule.Settings; + int spread = Mathf.Clamp(settings.grassUnloadSpreadFrames, 0, 600); + if (!settings.natureStreamingSmoothingEnabled || + spread <= 0 || + TimersField?.GetValue(__instance) is not int[] timers || + __1 < 0 || + __1 >= timers.Length) + return; + + int hash = unchecked( + __instance.GetHashCode() * 397 ^ __1 * 7919) & int.MaxValue; + timers[__1] += hash % (spread + 1); + } + } +} diff --git a/src/S3.csproj b/src/S3.csproj index e1ecb8e..d98c858 100644 --- a/src/S3.csproj +++ b/src/S3.csproj @@ -29,6 +29,18 @@ $(GameManaged)\Map.Runtime.dll false + + $(GameManaged)\Definition.dll + false + + + $(GameManaged)\VisualDesignCafe.Rendering.Nature.dll + false + + + $(GameManaged)\VisualDesignCafe.Rendering.Instancing.dll + false + @@ -49,6 +61,10 @@ $(GameManaged)\UnityEngine.PhysicsModule.dll false + + $(GameManaged)\UnityEngine.TerrainModule.dll + false + $(GameManaged)\UnityEngine.JSONSerializeModule.dll false @@ -65,6 +81,10 @@ $(GameManaged)\UnityEngine.InputLegacyModule.dll false + + $(GameManaged)\Unity.InputSystem.dll + false + $(GameManaged)\Unity.TextMeshPro.dll false @@ -73,6 +93,14 @@ $(GameManaged)\Unity.RenderPipelines.Universal.Runtime.dll false + + $(GameManaged)\UnityEngine.ImageConversionModule.dll + false + + + $(GameManaged)\UnityEngine.TextRenderingModule.dll + false + @@ -85,6 +113,10 @@ $(UmmDir)\0Harmony.dll false + + $(GameManaged)\Newtonsoft.Json.dll + false +