MeshLOD: polish settings fields, bbox material, tracked car count

- Rename all MeshLodSettings public fields to camelCase (consistency
  with every other settings class in the project).
- Replace the proxy-box renderer's borrowed car material with a shared
  matte Standard material tinted to the car body albedo; cheaper at
  LOD3 distances and responds correctly to scene lighting at night.
- Expose GetTrackedCounts() and display loco/freight breakdown next to
  the Refresh Now button in the settings panel.
This commit is contained in:
Seton Carmichael 2026-06-25 18:57:50 -04:00
parent fcfdc6fba0
commit 1b4ab97be5
3 changed files with 80 additions and 45 deletions

View file

@ -12,6 +12,7 @@ static class MeshLodInjector
{ {
static MeshLodSettings _settings = new(); static MeshLodSettings _settings = new();
static readonly List<WeakReference<Car>> _trackedCars = new(); static readonly List<WeakReference<Car>> _trackedCars = new();
static Material? _bboxMat;
// Debounced auto-refresh: set by MarkDirty, consumed by CheckAutoRefresh (called from Update). // Debounced auto-refresh: set by MarkDirty, consumed by CheckAutoRefresh (called from Update).
static float _pendingRefreshAt = -1f; static float _pendingRefreshAt = -1f;
@ -22,9 +23,9 @@ static class MeshLodInjector
public static void Init(MeshLodSettings settings) public static void Init(MeshLodSettings settings)
{ {
_settings = settings; _settings = settings;
Log.Info($"MeshLOD settings: frac1={settings.Lod1RendererFraction:F2} frac2={settings.Lod2RendererFraction:F2} " + Log.Info($"MeshLOD settings: frac1={settings.lod1RendererFraction:F2} frac2={settings.lod2RendererFraction:F2} " +
$"d1={settings.Lod1Distance}m d2={settings.Lod2Distance}m d3={settings.Lod3Distance}m " + $"d1={settings.lod1Distance}m d2={settings.lod2Distance}m d3={settings.lod3Distance}m " +
$"locos={settings.ApplyToLocomotives} freight={settings.ApplyToFreightCars} " + $"locos={settings.applyToLocomotives} freight={settings.applyToFreightCars} " +
$"lodBias={QualitySettings.lodBias:F2}"); $"lodBias={QualitySettings.lodBias:F2}");
} }
@ -32,6 +33,19 @@ static class MeshLodInjector
{ {
_trackedCars.Clear(); _trackedCars.Clear();
_pendingRefreshAt = -1f; _pendingRefreshAt = -1f;
if (_bboxMat != null) { UnityEngine.Object.Destroy(_bboxMat); _bboxMat = null; }
}
public static (int total, int locos, int freight) GetTrackedCounts()
{
int total = 0, locos = 0;
foreach (var wr in _trackedCars)
{
if (!wr.TryGetTarget(out Car? car) || car == null) continue;
total++;
if (car is BaseLocomotive) locos++;
}
return (total, locos, total - locos);
} }
// Called from the UI whenever any setting changes. // Called from the UI whenever any setting changes.
@ -57,8 +71,8 @@ static class MeshLodInjector
if (car.BodyTransform.GetComponent<LODGroup>() != null) return; if (car.BodyTransform.GetComponent<LODGroup>() != null) return;
bool isLoco = car is BaseLocomotive; bool isLoco = car is BaseLocomotive;
if ( isLoco && !_settings.ApplyToLocomotives) return; if ( isLoco && !_settings.applyToLocomotives) return;
if (!isLoco && !_settings.ApplyToFreightCars) return; if (!isLoco && !_settings.applyToFreightCars) return;
MeshRenderer[] renderers = CollectRenderers(car.BodyTransform); MeshRenderer[] renderers = CollectRenderers(car.BodyTransform);
if (renderers.Length == 0) return; if (renderers.Length == 0) return;
@ -84,8 +98,8 @@ static class MeshLodInjector
RemoveLods(car); RemoveLods(car);
bool isLoco = car is BaseLocomotive; bool isLoco = car is BaseLocomotive;
if ( isLoco && !_settings.ApplyToLocomotives) { skipped++; continue; } if ( isLoco && !_settings.applyToLocomotives) { skipped++; continue; }
if (!isLoco && !_settings.ApplyToFreightCars) { skipped++; continue; } if (!isLoco && !_settings.applyToFreightCars) { skipped++; continue; }
MeshRenderer[] renderers = CollectRenderers(car.BodyTransform); MeshRenderer[] renderers = CollectRenderers(car.BodyTransform);
if (renderers.Length == 0) { skipped++; continue; } if (renderers.Length == 0) { skipped++; continue; }
@ -135,10 +149,10 @@ static class MeshLodInjector
.OrderByDescending(r => BoundsVolume(r.bounds)) .OrderByDescending(r => BoundsVolume(r.bounds))
.ToArray(); .ToArray();
// LOD1: minor cull — keep the top Lod1RendererFraction of renderers // LOD1: minor cull — keep the top lod1RendererFraction of renderers
int lod1Keep = Mathf.Max(1, Mathf.RoundToInt(sorted.Length * _settings.Lod1RendererFraction)); int lod1Keep = Mathf.Max(1, Mathf.RoundToInt(sorted.Length * _settings.lod1RendererFraction));
// LOD2: major cull — keep only the top Lod2RendererFraction (must be ≤ lod1Keep) // LOD2: major cull — keep only the top lod2RendererFraction (must be ≤ lod1Keep)
int lod2Keep = Mathf.Clamp(Mathf.RoundToInt(sorted.Length * _settings.Lod2RendererFraction), 1, lod1Keep); int lod2Keep = Mathf.Clamp(Mathf.RoundToInt(sorted.Length * _settings.lod2RendererFraction), 1, lod1Keep);
Renderer[] lod0Rend = allRenderers.Cast<Renderer>().ToArray(); Renderer[] lod0Rend = allRenderers.Cast<Renderer>().ToArray();
Renderer[] lod1Rend = sorted.Take(lod1Keep).Cast<Renderer>().ToArray(); Renderer[] lod1Rend = sorted.Take(lod1Keep).Cast<Renderer>().ToArray();
@ -147,7 +161,7 @@ static class MeshLodInjector
// LOD3: 12-triangle proxy box derived from the largest renderer's mesh-local AABB. // LOD3: 12-triangle proxy box derived from the largest renderer's mesh-local AABB.
Bounds localBounds = ComputeMeshLocalBounds(sorted[0], bodyRoot); Bounds localBounds = ComputeMeshLocalBounds(sorted[0], bodyRoot);
MeshRenderer bboxMr = CreateBoundingBoxRenderer(bodyRoot, localBounds, MeshRenderer bboxMr = CreateBoundingBoxRenderer(bodyRoot, localBounds,
sorted[0].sharedMaterial, GetOrCreateBBoxMat(sorted[0].sharedMaterial),
sorted[0].gameObject.layer); sorted[0].gameObject.layer);
Renderer[] lod3Rend = new[] { (Renderer)bboxMr }; Renderer[] lod3Rend = new[] { (Renderer)bboxMr };
@ -161,9 +175,9 @@ static class MeshLodInjector
float lodBias = Mathf.Max(QualitySettings.lodBias, 0.01f); float lodBias = Mathf.Max(QualitySettings.lodBias, 0.01f);
float k = sphereDiameter * lodBias / invFovFactor; float k = sphereDiameter * lodBias / invFovFactor;
float lod1H = Mathf.Clamp(k / _settings.Lod1Distance, 0.001f, 0.999f); float lod1H = Mathf.Clamp(k / _settings.lod1Distance, 0.001f, 0.999f);
float lod2H = Mathf.Clamp(k / _settings.Lod2Distance, 0.001f, lod1H * 0.9f); float lod2H = Mathf.Clamp(k / _settings.lod2Distance, 0.001f, lod1H * 0.9f);
float lod3H = Mathf.Clamp(k / _settings.Lod3Distance, 0.001f, lod2H * 0.9f); float lod3H = Mathf.Clamp(k / _settings.lod3Distance, 0.001f, lod2H * 0.9f);
var lodGroup = bodyRoot.gameObject.AddComponent<LODGroup>(); var lodGroup = bodyRoot.gameObject.AddComponent<LODGroup>();
lodGroup.SetLODs(new LOD[] lodGroup.SetLODs(new LOD[]
@ -253,6 +267,21 @@ static class MeshLodInjector
// ── Proxy box mesh ──────────────────────────────────────────────────────── // ── 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.
static Material GetOrCreateBBoxMat(Material? src)
{
if (_bboxMat != null) return _bboxMat;
var shader = 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);
return _bboxMat;
}
static MeshRenderer CreateBoundingBoxRenderer(Transform parent, Bounds localBounds, static MeshRenderer CreateBoundingBoxRenderer(Transform parent, Bounds localBounds,
Material mat, int layer) Material mat, int layer)
{ {

View file

@ -8,15 +8,15 @@ public class MeshLodSettings
public bool enabled = false; public bool enabled = false;
// Fraction of renderers kept at each progressive cull level (sorted by bounds volume desc). // Fraction of renderers kept at each progressive cull level (sorted by bounds volume desc).
public float Lod1RendererFraction = 0.50f; // minor cull: remove tiny details public float lod1RendererFraction = 0.50f; // minor cull: remove tiny details
public float Lod2RendererFraction = 0.15f; // major cull: structural geometry only public float lod2RendererFraction = 0.15f; // major cull: structural geometry only
// Distance in metres for each LOD transition. Converted to Unity's // Distance in metres for each LOD transition. Converted to Unity's
// screenRelativeTransitionHeight at apply time, corrected for lodBias. // screenRelativeTransitionHeight at apply time, corrected for lodBias.
public float Lod1Distance = 80f; // LOD0 → LOD1 (full detail → minor cull) public float lod1Distance = 80f; // LOD0 -> LOD1 (full detail -> minor cull)
public float Lod2Distance = 200f; // LOD1 → LOD2 (minor cull → structural) public float lod2Distance = 200f; // LOD1 -> LOD2 (minor cull -> structural)
public float Lod3Distance = 600f; // LOD2 → LOD3 (structural → proxy box) public float lod3Distance = 600f; // LOD2 -> LOD3 (structural -> proxy box)
public bool ApplyToLocomotives = true; public bool applyToLocomotives = true;
public bool ApplyToFreightCars = true; public bool applyToFreightCars = true;
} }

View file

@ -23,11 +23,11 @@ static class MeshLodSettingsUI
GUILayout.Label("<b>Apply to</b>"); GUILayout.Label("<b>Apply to</b>");
GUILayout.Space(4f); GUILayout.Space(4f);
bool newLocos = GUILayout.Toggle(s.ApplyToLocomotives, " Locomotives"); bool newLocos = GUILayout.Toggle(s.applyToLocomotives, " Locomotives");
if (newLocos != s.ApplyToLocomotives) { s.ApplyToLocomotives = newLocos; changed = true; } if (newLocos != s.applyToLocomotives) { s.applyToLocomotives = newLocos; changed = true; }
bool newCars = GUILayout.Toggle(s.ApplyToFreightCars, " Freight / passenger cars"); bool newCars = GUILayout.Toggle(s.applyToFreightCars, " Freight / passenger cars");
if (newCars != s.ApplyToFreightCars) { s.ApplyToFreightCars = newCars; changed = true; } if (newCars != s.applyToFreightCars) { s.applyToFreightCars = newCars; changed = true; }
// ── Renderer fractions ──────────────────────────────────────────────── // ── Renderer fractions ────────────────────────────────────────────────
GUILayout.Space(10f); GUILayout.Space(10f);
@ -36,35 +36,35 @@ static class MeshLodSettingsUI
int ex60 = 60; // example car with 60 renderers int ex60 = 60; // example car with 60 renderers
GUILayout.Label( GUILayout.Label(
$" LOD1 (minor cull): {s.Lod1RendererFraction * 100f:F0}% kept " + $" LOD1 (minor cull): {s.lod1RendererFraction * 100f:F0}% kept " +
$"— {Mathf.Max(1, Mathf.RoundToInt(ex60 * s.Lod1RendererFraction))}/{ex60} for a 60-renderer car", $"— {Mathf.Max(1, Mathf.RoundToInt(ex60 * s.lod1RendererFraction))}/{ex60} for a 60-renderer car",
GUI.skin.label); GUI.skin.label);
GUILayout.BeginHorizontal(); GUILayout.BeginHorizontal();
GUILayout.Space(4f); GUILayout.Space(4f);
float newF1 = GUILayout.HorizontalSlider(s.Lod1RendererFraction, 0.15f, 0.80f, GUILayout.Width(220f)); float newF1 = GUILayout.HorizontalSlider(s.lod1RendererFraction, 0.15f, 0.80f, GUILayout.Width(220f));
GUILayout.EndHorizontal(); GUILayout.EndHorizontal();
if (Mathf.Abs(newF1 - s.Lod1RendererFraction) > 0.005f) if (Mathf.Abs(newF1 - s.lod1RendererFraction) > 0.005f)
{ {
s.Lod1RendererFraction = Mathf.Round(newF1 * 20f) / 20f; // snap to 5% steps s.lod1RendererFraction = Mathf.Round(newF1 * 20f) / 20f; // snap to 5% steps
if (s.Lod1RendererFraction <= s.Lod2RendererFraction) if (s.lod1RendererFraction <= s.lod2RendererFraction)
s.Lod2RendererFraction = Mathf.Max(0.05f, s.Lod1RendererFraction - 0.10f); s.lod2RendererFraction = Mathf.Max(0.05f, s.lod1RendererFraction - 0.10f);
changed = true; changed = true;
} }
GUILayout.Space(4f); GUILayout.Space(4f);
GUILayout.Label( GUILayout.Label(
$" LOD2 (structural): {s.Lod2RendererFraction * 100f:F0}% kept " + $" LOD2 (structural): {s.lod2RendererFraction * 100f:F0}% kept " +
$"— {Mathf.Max(1, Mathf.RoundToInt(ex60 * s.Lod2RendererFraction))}/{ex60} for a 60-renderer car", $"— {Mathf.Max(1, Mathf.RoundToInt(ex60 * s.lod2RendererFraction))}/{ex60} for a 60-renderer car",
GUI.skin.label); GUI.skin.label);
GUILayout.BeginHorizontal(); GUILayout.BeginHorizontal();
GUILayout.Space(4f); GUILayout.Space(4f);
float newF2 = GUILayout.HorizontalSlider(s.Lod2RendererFraction, 0.05f, 0.40f, GUILayout.Width(220f)); float newF2 = GUILayout.HorizontalSlider(s.lod2RendererFraction, 0.05f, 0.40f, GUILayout.Width(220f));
GUILayout.EndHorizontal(); GUILayout.EndHorizontal();
if (Mathf.Abs(newF2 - s.Lod2RendererFraction) > 0.005f) if (Mathf.Abs(newF2 - s.lod2RendererFraction) > 0.005f)
{ {
s.Lod2RendererFraction = Mathf.Round(newF2 * 20f) / 20f; s.lod2RendererFraction = Mathf.Round(newF2 * 20f) / 20f;
if (s.Lod2RendererFraction >= s.Lod1RendererFraction) if (s.lod2RendererFraction >= s.lod1RendererFraction)
s.Lod1RendererFraction = Mathf.Min(0.80f, s.Lod2RendererFraction + 0.10f); s.lod1RendererFraction = Mathf.Min(0.80f, s.lod2RendererFraction + 0.10f);
changed = true; changed = true;
} }
@ -78,23 +78,29 @@ static class MeshLodSettingsUI
GUI.skin.label); GUI.skin.label);
GUILayout.Space(4f); GUILayout.Space(4f);
DrawDistanceSlider(ref s.Lod1Distance, "LOD0→LOD1 (minor cull)", 30f, 500f, ref changed); DrawDistanceSlider(ref s.lod1Distance, "LOD0→LOD1 (minor cull)", 30f, 500f, ref changed);
GUILayout.Space(2f); GUILayout.Space(2f);
DrawDistanceSlider(ref s.Lod2Distance, "LOD1→LOD2 (structural)", s.Lod1Distance + 20f, 1000f, ref changed); DrawDistanceSlider(ref s.lod2Distance, "LOD1→LOD2 (structural)", s.lod1Distance + 20f, 1000f, ref changed);
if (s.Lod2Distance <= s.Lod1Distance) { s.Lod2Distance = s.Lod1Distance + 50f; changed = true; } if (s.lod2Distance <= s.lod1Distance) { s.lod2Distance = s.lod1Distance + 50f; changed = true; }
GUILayout.Space(2f); GUILayout.Space(2f);
DrawDistanceSlider(ref s.Lod3Distance, "LOD2→LOD3 (proxy box) ", s.Lod2Distance + 20f, 1500f, ref changed); DrawDistanceSlider(ref s.lod3Distance, "LOD2→LOD3 (proxy box) ", s.lod2Distance + 20f, 1500f, ref changed);
if (s.Lod3Distance <= s.Lod2Distance) { s.Lod3Distance = s.Lod2Distance + 100f; changed = true; } if (s.lod3Distance <= s.lod2Distance) { s.lod3Distance = s.lod2Distance + 100f; changed = true; }
// ── Manual refresh ──────────────────────────────────────────────────── // ── Manual refresh ────────────────────────────────────────────────────
GUILayout.Space(10f); GUILayout.Space(10f);
GUILayout.Label(" Settings auto-refresh loaded cars ~0.5 s after the last change.", GUI.skin.label); GUILayout.Label(" Settings auto-refresh loaded cars ~0.5 s after the last change.", GUI.skin.label);
GUILayout.Space(4f); GUILayout.Space(4f);
GUILayout.BeginHorizontal();
if (GUILayout.Button("Refresh Now", GUILayout.Width(120f))) if (GUILayout.Button("Refresh Now", GUILayout.Width(120f)))
{ {
MeshLodModule.Persist(); MeshLodModule.Persist();
MeshLodInjector.RefreshAll(); MeshLodInjector.RefreshAll();
} }
var (total, locos, freight) = MeshLodInjector.GetTrackedCounts();
GUILayout.Label(
$" {total} tracked ({locos} loco{(locos == 1 ? "" : "s")}, {freight} car{(freight == 1 ? "" : "s")})",
GUI.skin.label);
GUILayout.EndHorizontal();
GUILayout.EndVertical(); GUILayout.EndVertical();