Utility for performance diagnostics. Walks the active scene, groups render-related components by root GameObject name (owner), counts objects, renderers, meshes, colliders, lights and shadow-casters, computes a weighted score, and exposes console commands to report the census, take leak baselines, hide/show renderers, and toggle shadows.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace NZombies;
/// <summary>
/// WHAT IS IN THE WORLD, AND WHO PUT IT THERE.
///
/// ⛔ THIS IS THE HALF THAT ANSWERS THE QUESTION. `nz_perf` says whether the frame is CPU- or
/// GPU-bound; this says which part of the gamemode is responsible. The measurement that started it
/// — canyon at ~51 fps with no config and ~31 with one — is a difference in what got BUILT, so the
/// only useful report is per-owner counts of the things a GPU is charged for.
///
/// ⛔ IT COUNTS, IT DOES NOT ESTIMATE. Every number is a real component queried from the scene. The
/// managers each keep a private `_built` list, so rather than exposing fourteen of them this groups
/// by the ROOT OBJECT'S NAME — every manager names what it creates deliberately ("Soul Box #0",
/// "Perk Machine — Juggernog", "Teleporter #2"), which makes the name a usable owner key.
///
/// ⚠️ ANYTHING UNRECOGNISED LANDS IN ITS OWN ROW rather than being dropped. A census that quietly
/// omits the row containing the cost is worse than no census.
/// </summary>
public static class PerfCensus
{
/// <summary>One owner's contribution.</summary>
public sealed class Row
{
public string Owner;
public int Objects;
public int Static; // ModelRenderer
public int Skinned; // SkinnedModelRenderer - re-posed every frame
public int Meshes; // MeshComponent - imported world brushes
public int Colliders;
public int Lights;
public int Shadowing;
public int DrawCalls; // sum of Model.MeshCount over the renderers
/// <summary>
/// Everything a GPU is charged for, weighted, for sorting.
///
/// ⚠️ THE WEIGHTS ARE AN ORDERING, NOT A COST MODEL. They exist so the dearest owner lands
/// at the top of the table; they are not milliseconds and must never be quoted as such.
/// A shadow-casting light is the heaviest thing here because it costs a pass over
/// everything it touches.
/// </summary>
public int Weight => DrawCalls + Skinned * 4 + Lights * 8 + Shadowing * 24;
}
/// <summary>
/// Reduce an object's name to an owner.
///
/// ⚠️ STRIPS THE INSTANCE, KEEPS THE KIND. "Soul Box #3" and "Perk Machine — Speed Cola" become
/// one row each as "Soul Box" and "Perk Machine" — otherwise a map with six machines reports six
/// rows of one and the total that matters is never on screen.
/// </summary>
static string OwnerOf( GameObject go )
{
var root = go;
while ( root.Parent.IsValid() ) root = root.Parent;
var name = root.Name ?? "(unnamed)";
var cut = name.IndexOf( '—' ); // em dash: "Perk Machine — Juggernog"
if ( cut > 0 ) name = name[..cut];
name = Regex.Replace( name, @"[#_]?\d+\s*$", "" ).Trim();
return string.IsNullOrWhiteSpace( name ) ? "(unnamed)" : name;
}
static Row For( Dictionary<string, Row> rows, GameObject go )
{
var key = OwnerOf( go );
if ( !rows.TryGetValue( key, out var r ) )
rows[key] = r = new Row { Owner = key };
return r;
}
/// <summary>Walk the scene and total everything up, by owner.</summary>
public static List<Row> Take()
{
var scene = Game.ActiveScene;
var rows = new Dictionary<string, Row>();
if ( !scene.IsValid() ) return new List<Row>();
var seen = new HashSet<Guid>();
// ⚠️ SkinnedModelRenderer DERIVES FROM ModelRenderer, so one query returns both and the
// type test is what separates them. Two queries would double-count every skinned one.
foreach ( var r in scene.GetAllComponents<ModelRenderer>() )
{
if ( !r.IsValid() ) continue;
var row = For( rows, r.GameObject );
if ( seen.Add( r.GameObject.Id ) ) row.Objects++;
if ( r is SkinnedModelRenderer ) row.Skinned++;
else row.Static++;
// ⚠️ MeshCount IS A DRAW-CALL PROXY, NOT A TRIANGLE COUNT. A model draws roughly once
// per mesh, and draw calls are what a low-end GPU actually runs out of first.
// Triangles are not exposed on Model in a form this can read.
row.DrawCalls += r.Model?.MeshCount ?? 0;
}
foreach ( var m in scene.GetAllComponents<MeshComponent>() )
{
if ( !m.IsValid() ) continue;
var row = For( rows, m.GameObject );
if ( seen.Add( m.GameObject.Id ) ) row.Objects++;
row.Meshes++;
row.DrawCalls++;
}
foreach ( var c in scene.GetAllComponents<Collider>() )
{
if ( !c.IsValid() ) continue;
For( rows, c.GameObject ).Colliders++;
}
// ⛔ LIGHTS BY CONCRETE TYPE, AND SHADOW-CASTERS SEPARATELY. A shadowing light costs a whole
// extra pass over everything it touches, which is exactly the cost that changes when the
// camera turns — the symptom that started this.
CountLights( rows, scene.GetAllComponents<PointLight>() );
CountLights( rows, scene.GetAllComponents<SpotLight>() );
CountLights( rows, scene.GetAllComponents<DirectionalLight>() );
return rows.Values.OrderByDescending( r => r.Weight ).ToList();
}
static void CountLights<T>( Dictionary<string, Row> rows, IEnumerable<T> lights ) where T : Light
{
foreach ( var l in lights )
{
if ( !l.IsValid() ) continue;
var row = For( rows, l.GameObject );
row.Lights++;
if ( l.Shadows ) row.Shadowing++;
}
}
/// <summary>`nz_perf_census` — who put what in the world, dearest first.</summary>
[ConCmd( "nz_perf_census" )]
public static void Report()
{
var rows = Take();
if ( rows.Count == 0 ) { Log.Info( "[nz-perf] no scene" ); return; }
Log.Info( "[nz-perf] owner obj draw static skin mesh coll light shad" );
Log.Info( "[nz-perf] --------------------------------------------------------------------------" );
foreach ( var r in rows )
Log.Info( $"[nz-perf] {Trim( r.Owner, 26 ),-26} {r.Objects,4} {r.DrawCalls,6}"
+ $" {r.Static,6} {r.Skinned,5} {r.Meshes,5} {r.Colliders,5} {r.Lights,5} {r.Shadowing,4}" );
Log.Info( "[nz-perf] --------------------------------------------------------------------------" );
Log.Info( $"[nz-perf] {"TOTAL",-26} {rows.Sum( r => r.Objects ),4} {rows.Sum( r => r.DrawCalls ),6}"
+ $" {rows.Sum( r => r.Static ),6} {rows.Sum( r => r.Skinned ),5}"
+ $" {rows.Sum( r => r.Meshes ),5} {rows.Sum( r => r.Colliders ),5}"
+ $" {rows.Sum( r => r.Lights ),5} {rows.Sum( r => r.Shadowing ),4}" );
// ⚠️ THE READING IS PART OF THE REPORT. A table with no interpretation is a table nobody
// acts on, and on a low-end GPU the two cheapest wins are almost always shadow-casting
// lights and skinned renderers that never actually animate.
var shad = rows.Sum( r => r.Shadowing );
var skin = rows.Sum( r => r.Skinned );
if ( shad > 8 )
Log.Info( $"[nz-perf] ! {shad} shadow-casting lights — this is the cost that moves when you "
+ "turn the camera. nz_perf_shadows 0, then nz_perf, measures it." );
if ( skin > 0 )
Log.Info( $"[nz-perf] ! {skin} skinned renderer(s) — re-posed every frame whether or not "
+ "they animate." );
}
static string Trim( string s, int n ) => s.Length <= n ? s : s[..(n - 1)] + "…";
// ── leak hunting ─────────────────────────────────────────────────────────
/// <summary>Object counts per owner when the baseline was taken.</summary>
static Dictionary<string, int> _baseline;
static float _baselineAt;
/// <summary>
/// `nz_leak` — take a baseline, play, run it again: what GREW, and how fast.
/// </summary>
///
/// ⛔ THE CENSUS ANSWERS "WHAT IS IN THE WORLD"; THIS ANSWERS "WHAT IS STILL ARRIVING", AND
/// ONLY THE SECOND ONE FINDS A LEAK. A table of 900 objects is not evidence of anything on its
/// own — a map legitimately builds hundreds. The same table twenty minutes later, with one row
/// three hundred higher and every other row flat, names the culprit outright.
///
/// ⛔ AND IT IS THE ONLY WAY TO ANSWER IT FROM HERE. Reported as *"the longer the game is on
/// the more lag it gets, something is not being cleaned up properly"*. Every per-event creator
/// in the project was read looking for one without a lifetime — corpses expire at 6s, pickups
/// at 30 or 120, bullet holes are capped at 30 and evict oldest-first, physical bullets carry
/// `TemporaryEffect`, and both static registries (`ZombieAI.All`, `Barricade.All`) remove on
/// `OnDisabled`. Nothing static was wrong, which means the thing that grows is something a
/// reading cannot see, and a guess shipped as a fix would be worse than no fix.
///
/// ⚠️ OWNER ROWS, NOT TYPES, so the answer is already a name somebody can act on — the row key
/// is the GameObject's own name, which every manager in this project sets deliberately.
///
/// ⚠️ THE RATE IS PER MINUTE because that is the shape of the complaint. A row that gains four
/// objects a minute is invisible in a two-minute test and is 240 objects by round 30.
[ConCmd( "nz_leak" )]
public static void Leak( int reset = 0 )
{
var rows = Take();
if ( rows.Count == 0 ) { Log.Info( "[nz-leak] no scene" ); return; }
var now = rows.ToDictionary( r => r.Owner, r => r.Objects );
var total = now.Values.Sum();
if ( _baseline is null || reset != 0 )
{
_baseline = now;
_baselineAt = Time.Now;
Log.Info( $"[nz-leak] baseline: {total} object(s) across {now.Count} row(s)."
+ " Play for a few rounds, then run nz_leak again." );
return;
}
var mins = MathF.Max( 0.01f, (Time.Now - _baselineAt) / 60f );
// ⚠️ THE UNION OF BOTH SIDES. A row that did not exist at baseline is the most interesting
// kind of growth there is, and keying off either side alone drops exactly that case.
var owners = now.Keys.Union( _baseline.Keys )
.Select( o => (Owner: o,
Delta: now.GetValueOrDefault( o ) - _baseline.GetValueOrDefault( o )) )
.Where( r => r.Delta != 0 )
.OrderByDescending( r => r.Delta )
.ToList();
var was = _baseline.Values.Sum();
Log.Info( $"[nz-leak] {mins:0.0} minute(s) since baseline — {was} object(s) then,"
+ $" {total} now ({(total - was >= 0 ? "+" : "")}{total - was})" );
if ( owners.Count == 0 ) { Log.Info( "[nz-leak] nothing moved" ); return; }
Log.Info( "[nz-leak] owner then now delta /min" );
Log.Info( "[nz-leak] ------------------------------------------------------" );
foreach ( var r in owners )
Log.Info( $"[nz-leak] {Trim( r.Owner, 26 ),-26} {_baseline.GetValueOrDefault( r.Owner ),6}"
+ $" {now.GetValueOrDefault( r.Owner ),6} {r.Delta,7} {r.Delta / mins,6:0.0}" );
// ⚠️ THE READING, as `nz_perf_census` does. A row still climbing at the END of a round is
// the one to chase: everything in this game is spawned in waves and cleared between them,
// so a leak and a busy round look identical until the round is over.
Log.Info( "[nz-leak] ------------------------------------------------------" );
Log.Info( "[nz-leak] ! run this again between rounds, with nothing alive — anything still"
+ " above its baseline then is not being cleaned up. nz_leak 1 re-baselines." );
}
/// <summary>
/// `nz_perf_hide <owner>` — stop drawing one census row, so its cost can be MEASURED.
///
/// ⛔ THE WEIGHT COLUMN IS AN ORDERING, THIS IS THE EVIDENCE. `Weight` guesses which owner is
/// dearest from component counts; this switches one off and lets the fps say. Given the whole
/// question is "config on costs 20 fps, which part", a per-owner A/B is the only answer that
/// cannot be wrong.
///
/// ⚠️ RENDERERS ONLY — colliders, logic and lights are left alone. Disabling the GameObject
/// would also remove collision and stop the component's own work, which would measure three
/// things at once and attribute all of it to drawing.
///
/// ⚠️ NOT SAVED, AND NOT SURVIVING A REBUILD. Reload the map or rebuild the config to get
/// everything back; `nz_perf_hide` with no argument lists what is currently hidden.
/// </summary>
[ConCmd( "nz_perf_hide" )]
public static void Hide( string owner = "", int on = -1 )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Info( "[nz-perf] no scene" ); return; }
if ( string.IsNullOrWhiteSpace( owner ) )
{
Log.Info( "[nz-perf] nz_perf_hide <owner> [0|1] — owners come from nz_perf_census:" );
foreach ( var r in Take() )
Log.Info( $"[nz-perf] {r.Owner}" );
return;
}
// ⚠️ A PREFIX MATCH, CASE-INSENSITIVE. The owner keys carry spaces and an em dash, and
// nobody is typing "Perk Machine" exactly into a console.
bool Matches( GameObject go )
=> OwnerOf( go ).StartsWith( owner, StringComparison.OrdinalIgnoreCase );
var renderers = scene.GetAllComponents<ModelRenderer>()
.Where( r => r.IsValid() && Matches( r.GameObject ) )
.Cast<Component>()
.Concat( scene.GetAllComponents<MeshComponent>()
.Where( m => m.IsValid() && Matches( m.GameObject ) )
.Cast<Component>() )
.ToList();
if ( renderers.Count == 0 )
{
Log.Warning( $"[nz-perf] nothing matches '{owner}' — nz_perf_hide with no argument lists them" );
return;
}
// ⚠️ READ FROM THE FIRST ONE rather than a remembered static, so the toggle cannot get out
// of step with the world after a rebuild or a hotload.
var want = on >= 0 ? on == 0 : renderers[0].Enabled;
foreach ( var c in renderers ) c.Enabled = !want;
Log.Info( $"[nz-perf] '{owner}' rendering {(want ? "OFF" : "ON")}"
+ $" — {renderers.Count} renderer(s). Watch nz_perf_hud, then toggle back." );
}
/// <summary>
/// Are an owner's renderers currently drawing?
///
/// ⚠️ READ FROM THE WORLD, NOT FROM A REMEMBERED SET. A static list of hidden owners would go
/// stale the moment anything rebuilt — which every manager does on a config change — and the
/// menu would then show a toggle that disagrees with what is on screen.
/// </summary>
public static bool RenderingOn( string owner )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return true;
var first = scene.GetAllComponents<ModelRenderer>()
.FirstOrDefault( r => r.IsValid()
&& OwnerOf( r.GameObject ).StartsWith( owner, StringComparison.OrdinalIgnoreCase ) );
if ( first is not null ) return first.Enabled;
var mesh = scene.GetAllComponents<MeshComponent>()
.FirstOrDefault( m => m.IsValid()
&& OwnerOf( m.GameObject ).StartsWith( owner, StringComparison.OrdinalIgnoreCase ) );
return mesh is null || mesh.Enabled;
}
/// <summary>`nz_perf_show_all` — draw everything again, after a run of A/B tests.</summary>
[ConCmd( "nz_perf_show_all" )]
public static void ShowAll()
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Info( "[nz-perf] no scene" ); return; }
int n = 0;
foreach ( var r in scene.GetAllComponents<ModelRenderer>() )
if ( r.IsValid() && !r.Enabled ) { r.Enabled = true; n++; }
foreach ( var m in scene.GetAllComponents<MeshComponent>() )
if ( m.IsValid() && !m.Enabled ) { m.Enabled = true; n++; }
Log.Info( $"[nz-perf] re-enabled {n} renderer(s)"
+ (n == 0 ? " — nothing was hidden" : "") );
}
/// <summary>
/// `nz_perf_shadows [0|1]` — turn every light's shadows off and on.
///
/// ⛔ AN A/B TEST, NOT A SETTING. It changes the live scene and saves nothing. The point is to
/// answer "is it the shadows?" in one keystroke instead of reasoning about it — the same
/// principle as the tuner sliders. Reload the map to get back to authored state.
/// </summary>
[ConCmd( "nz_perf_shadows" )]
public static void Shadows( int on = -1 )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Info( "[nz-perf] no scene" ); return; }
var lights = scene.GetAllComponents<Light>().Where( l => l.IsValid() ).ToList();
if ( lights.Count == 0 ) { Log.Info( "[nz-perf] no lights" ); return; }
// ⚠️ READ FROM THE FIRST ONE rather than a remembered static, so the toggle cannot get out
// of step with the world after a map change or a hotload — the reasoning nz_dev_freeze
// already documents.
var want = on >= 0 ? on != 0 : !lights[0].Shadows;
foreach ( var l in lights ) l.Shadows = want;
Log.Info( $"[nz-perf] shadows {(want ? "ON" : "OFF")} on {lights.Count} light(s)"
+ " — run nz_perf now, then toggle back and run it again" );
}
}