BaseGamePerf: GC smoothing and Nature Renderer grass streaming caps

Spreads incremental GC across more frames and caps grass-cell work so
abrupt camera looks hitch less. Density, draw distance, and simulation
stay the same. Original values restore when the module is disabled.
This commit is contained in:
Seton Carmichael 2026-09-11 15:19:22 -04:00
parent 7acf6b6841
commit e8aeb2d6a3
7 changed files with 516 additions and 0 deletions

View file

@ -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. | | 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` | | 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. | | 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` | | 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. 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 ## 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: 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:

View file

@ -32,6 +32,7 @@ public static class Main
// constructor. Order here is display order in the settings panel. // constructor. Order here is display order in the settings panel.
_registry.Register(new Modules.PhysicsOptimizer.PhysicsOptimizerModule()); _registry.Register(new Modules.PhysicsOptimizer.PhysicsOptimizerModule());
_registry.Register(new Modules.MeshLod.MeshLodModule()); _registry.Register(new Modules.MeshLod.MeshLodModule());
_registry.Register(new Modules.BaseGamePerf.BaseGamePerfModule());
_registry.Register(new Modules.Profiler.ProfilerModule()); _registry.Register(new Modules.Profiler.ProfilerModule());
_registry.Register(new Modules.Popout.PopoutModule()); _registry.Register(new Modules.Popout.PopoutModule());

View file

@ -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<BaseGamePerfSettings>(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();
}

View file

@ -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;
}

View file

@ -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("<b>Base Game Performance</b> — 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();
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
static class NatureStreamingOptimizer
{
sealed class RendererState
{
public WeakReference<NatureRenderer> Renderer = null!;
public bool OnlyInitializeWithinRenderingDistance;
}
static readonly Dictionary<int, RendererState> 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<NatureRenderer>();
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<NatureRenderer>(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);
}
}
}

View file

@ -29,6 +29,18 @@
<HintPath>$(GameManaged)\Map.Runtime.dll</HintPath> <HintPath>$(GameManaged)\Map.Runtime.dll</HintPath>
<Private>false</Private> <Private>false</Private>
</Reference> </Reference>
<Reference Include="Definition">
<HintPath>$(GameManaged)\Definition.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="VisualDesignCafe.Rendering.Nature">
<HintPath>$(GameManaged)\VisualDesignCafe.Rendering.Nature.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="VisualDesignCafe.Rendering.Instancing">
<HintPath>$(GameManaged)\VisualDesignCafe.Rendering.Instancing.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup> </ItemGroup>
<!-- Unity --> <!-- Unity -->
@ -49,6 +61,10 @@
<HintPath>$(GameManaged)\UnityEngine.PhysicsModule.dll</HintPath> <HintPath>$(GameManaged)\UnityEngine.PhysicsModule.dll</HintPath>
<Private>false</Private> <Private>false</Private>
</Reference> </Reference>
<Reference Include="UnityEngine.TerrainModule">
<HintPath>$(GameManaged)\UnityEngine.TerrainModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.JSONSerializeModule"> <Reference Include="UnityEngine.JSONSerializeModule">
<HintPath>$(GameManaged)\UnityEngine.JSONSerializeModule.dll</HintPath> <HintPath>$(GameManaged)\UnityEngine.JSONSerializeModule.dll</HintPath>
<Private>false</Private> <Private>false</Private>
@ -65,6 +81,10 @@
<HintPath>$(GameManaged)\UnityEngine.InputLegacyModule.dll</HintPath> <HintPath>$(GameManaged)\UnityEngine.InputLegacyModule.dll</HintPath>
<Private>false</Private> <Private>false</Private>
</Reference> </Reference>
<Reference Include="Unity.InputSystem">
<HintPath>$(GameManaged)\Unity.InputSystem.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Unity.TextMeshPro"> <Reference Include="Unity.TextMeshPro">
<HintPath>$(GameManaged)\Unity.TextMeshPro.dll</HintPath> <HintPath>$(GameManaged)\Unity.TextMeshPro.dll</HintPath>
<Private>false</Private> <Private>false</Private>
@ -73,6 +93,14 @@
<HintPath>$(GameManaged)\Unity.RenderPipelines.Universal.Runtime.dll</HintPath> <HintPath>$(GameManaged)\Unity.RenderPipelines.Universal.Runtime.dll</HintPath>
<Private>false</Private> <Private>false</Private>
</Reference> </Reference>
<Reference Include="UnityEngine.ImageConversionModule">
<HintPath>$(GameManaged)\UnityEngine.ImageConversionModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.TextRenderingModule">
<HintPath>$(GameManaged)\UnityEngine.TextRenderingModule.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup> </ItemGroup>
<!-- UMM + Harmony --> <!-- UMM + Harmony -->
@ -85,6 +113,10 @@
<HintPath>$(UmmDir)\0Harmony.dll</HintPath> <HintPath>$(UmmDir)\0Harmony.dll</HintPath>
<Private>false</Private> <Private>false</Private>
</Reference> </Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(GameManaged)\Newtonsoft.Json.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup> </ItemGroup>
</Project> </Project>