A Razor UI component for an in-game performance HUD. It displays smoothed FPS, frame/ms, GPU time, memory, draw and light counts, owner hide toggles, and controls to start/stop logging and toggle profiling features; it also drives a persistent host object so the HUD updates and PerfLog continues across code reloads.
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Diagnostics;
@using System;
@using System.Linq;
@using NZombies;
@inherits PanelComponent
@*
PERF PANEL — the live readout, every diagnostic as a button, and the frame log.
⛔ THE READOUT EXISTS BECAUSE THE COST MOVES WITH THE CAMERA. The measurement that started this
was "about 51 without a config, about 31 with one, and it depends which way I'm looking" — a
console report cannot be correlated with a view direction, because by the time you read it you
have already turned.
⛔ AND THAT IS WHY THE MENU COLLAPSES. Clicking buttons needs the cursor; turning to watch the
number needs it gone. So the menu takes the cursor and the readout never does, and Collapse
hands it back — a panel that held the mouse the whole time would make its own readout useless.
⚠️ THE LOG SAMPLES EVEN WHEN THE PANEL IS HIDDEN. Recording only while a UI is on screen would
mean the file stops exactly when someone hides the overlay to look at something.
*@
<root class="perfhud @(Visible ? "" : "hidden")">
@* ⚠️ `live` IS WHAT MAKES IT CLICKABLE, and only while the menu is open. The stylesheet puts
`pointer-events: all` behind this class so the collapsed readout lets clicks through to the
game — a panel parked in the corner that permanently ate the mouse would swallow shots. *@
<div class="box @(MenuOpen ? "live" : "")">
<div class="fps @Grade">@Fps.ToString( "0" ) <span class="unit">fps</span>
<div class="spacer"></div>
<div class="tog" onclick=@(() => ToggleMenu())>@(MenuOpen ? "▲" : "▼")</div>
</div>
<div class="line">
<span class="k">frame</span><span class="v">@Frame.ToString( "0.0" ) ms</span>
<span class="k">worst</span><span class="v">@Worst.ToString( "0.0" ) ms</span>
</div>
<div class="line">
<span class="k">gpu</span><span class="v">@GpuText</span>
<span class="k">bound</span><span class="v @BoundClass">@Bound</span>
</div>
<div class="line">
<span class="k">ram</span><span class="v">@Ram</span>
<span class="k">vram</span><span class="v">@Vram</span>
</div>
<div class="line dim">
<span class="k">draws</span><span class="v">@Draws</span>
<span class="k">lights</span><span class="v">@LightsText</span>
</div>
@if ( PerfLog.Active )
{
<div class="line rec">
<span class="k">REC</span>
<span class="v wide">@PerfLog.Rows rows → @ShortPath</span>
</div>
}
@if ( MenuOpen )
{
<div class="menu">
<div class="head">MEASURE</div>
<div class="btns">
<div class="btn" onclick=@(() => PerfProbe.Report())>Report → console</div>
<div class="btn" onclick=@(() => PerfCensus.Report())>Census → console</div>
<div class="btn" onclick=@(() => PerfProbe.Gpu())>GPU passes → console</div>
</div>
<div class="head">TOGGLES <span class="hint">click, then watch the fps</span></div>
<div class="btns">
<div class="btn @(GpuProfilerStats.Enabled ? "on" : "")"
onclick=@(() => GpuToggle())>GPU profiler</div>
<div class="btn @(ShadowsOn ? "on" : "")"
onclick=@(() => ShadowToggle())>Shadows</div>
<div class="btn" onclick=@(() => ShowAll())>Show everything</div>
</div>
<div class="head">HIDE BY OWNER <span class="hint">the A/B that proves it</span></div>
<div class="btns">
@foreach ( var row in Owners )
{
var name = row.Owner;
<div class="btn owner @(PerfCensus.RenderingOn( name ) ? "on" : "off")"
onclick=@(() => HideOwner( name ))>
@name <span class="cnt">@row.DrawCalls</span>
</div>
}
</div>
<div class="head">LOG <span class="hint">every frame, to disk, for analysis</span></div>
<div class="btns">
<div class="btn @(PerfLog.Active ? "on" : "")" onclick=@(() => LogToggle())>
@(PerfLog.Active ? "Stop log" : "Start log")
</div>
<div class="btn" onclick=@(() => PerfLog.ListCmd())>List logs → console</div>
</div>
<div class="btns">
@foreach ( var m in Marks )
{
var label = m;
<div class="btn mark" onclick=@(() => PerfLog.Mark( label ))>⚑ @label</div>
}
</div>
<div class="foot">
<div class="btn" onclick=@(() => Collapse())>Collapse — release cursor</div>
<div class="btn" onclick=@(() => Off())>Close</div>
</div>
</div>
}
</div>
</root>
@code {
/// <summary>Is the readout on screen. Driven by `nz_perf_hud`.</summary>
public static bool Visible { get; set; }
/// <summary>Is the button menu expanded. It takes the cursor; the readout never does.</summary>
public static bool MenuOpen { get; set; }
/// <summary>
/// Labels offered as one-click log markers.
///
/// ⚠️ A FIXED LIST, NOT A TEXT BOX. Typing a label needs the keyboard, which fights the game's
/// own input, and these are the states actually being compared — anything else goes through
/// `nz_perf_mark` in the console.
/// </summary>
static readonly string[] Marks = { "baseline", "no shadows", "hidden", "restored" };
// ── the smoothing window ─────────────────────────────────────────────────
//
// ⚠️ A FIXED WINDOW IN SECONDS, not a frame count. At 30 fps a 60-frame window is two seconds
// of history and at 200 fps a third of one, so a frame-count window would smooth completely
// differently on the two machines being compared.
const float Window = 0.5f;
float _since;
int _frames;
double _sumFrame;
double _worstFrame;
double _sumGpu;
float Fps { get; set; }
float Frame { get; set; }
float Worst { get; set; }
float Gpu { get; set; }
string Ram { get; set; } = "-";
string Vram { get; set; } = "-";
// ⚠️ THE CENSUS IS RE-TAKEN RARELY, NOT PER FRAME. It walks every component in the scene, so
// running it at frame rate would make the profiler its own bottleneck.
int Draws { get; set; }
string LightsText { get; set; } = "-";
float _sinceCensus = 99f;
/// <summary>Owner rows for the hide buttons, refreshed with the census.</summary>
static List<PerfCensus.Row> Owners { get; set; } = new();
string GpuText => Gpu <= 0.01f ? "off" : $"{Gpu:0.0} ms";
string ShortPath => PerfLog.Path?.Replace( "perf/", "" ) ?? "";
static bool ShadowsOn
{
get
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return true;
var l = scene.GetAllComponents<Light>().FirstOrDefault( x => x.IsValid() );
return l is null || l.Shadows;
}
}
/// <summary>Which side is the wall. Same thresholds as `nz_perf`, deliberately.</summary>
string Bound => Gpu <= 0.01f
? "?"
: Gpu > Frame * 0.85f ? "GPU"
: Gpu < Frame * 0.5f ? "CPU"
: "even";
string BoundClass => Bound is "GPU" or "CPU" ? "warn" : "";
/// <summary>Colour band for the fps number, so a glance is enough.</summary>
string Grade => Fps >= 55f ? "good" : Fps >= 30f ? "ok" : "bad";
// ⛔ THE PANEL DOES NOT DRIVE THE LOG. PerfLog creates its own PerfLogDriver component, because
// a static in a .cs file cannot name a generated razor class — and because the log must keep
// recording with this panel closed. Sampling here as well would double every row.
protected override void OnUpdate()
{
if ( !Visible ) return;
var s = PerfProbe.Read();
_frames++;
_sumFrame += s.FrameMs;
_sumGpu += s.GpuMs;
if ( s.FrameMs > _worstFrame ) _worstFrame = s.FrameMs;
_since += Time.Delta;
if ( _since < Window ) return;
// ⚠️ FPS FROM THE MEAN FRAME TIME, not the mean of per-frame fps. Averaging fps values
// weights the cheap frames — the standard mistake that makes a stuttering game look fine.
var meanFrame = _sumFrame / MathF.Max( 1, _frames );
Frame = (float)meanFrame;
Fps = meanFrame > 0.0001 ? (float)(1000.0 / meanFrame) : 0f;
Gpu = (float)(_sumGpu / MathF.Max( 1, _frames ));
Worst = (float)_worstFrame;
Ram = $"{s.RamBytes / 1048576.0:0} MB";
Vram = s.VramBytes == 0 ? "-" : $"{s.VramBytes / 1048576.0:0} MB";
_since = 0f;
_frames = 0;
_sumFrame = 0;
_sumGpu = 0;
_worstFrame = 0;
_sinceCensus += Window;
if ( _sinceCensus >= 5f )
{
_sinceCensus = 0f;
var rows = PerfCensus.Take();
Owners = rows.Where( r => r.DrawCalls > 0 ).ToList();
Draws = rows.Sum( r => r.DrawCalls );
LightsText = $"{rows.Sum( r => r.Lights )} ({rows.Sum( r => r.Shadowing )} shadow)";
}
}
// ── buttons ──────────────────────────────────────────────────────────────
//
// ⚠️ EVERY BUTTON CALLS THE SAME METHOD THE CONSOLE COMMAND CALLS. A button with its own copy
// of the logic is a second implementation that drifts — and the console route has to keep
// working, because it is the only one usable over MCP.
void GpuToggle() => PerfProbe.Gpu( GpuProfilerStats.Enabled ? 0 : 1 );
void ShadowToggle()
{
var off = ShadowsOn;
PerfCensus.Shadows( off ? 0 : 1 );
if ( PerfLog.Active ) PerfLog.Mark( off ? "no shadows" : "shadows on" );
}
void ShowAll()
{
PerfCensus.ShowAll();
if ( PerfLog.Active ) PerfLog.Mark( "restored" );
}
void HideOwner( string owner )
{
var wasOn = PerfCensus.RenderingOn( owner );
PerfCensus.Hide( owner, wasOn ? 0 : 1 );
if ( PerfLog.Active ) PerfLog.Mark( wasOn ? $"hid {owner}" : $"shown {owner}" );
}
void LogToggle()
{
if ( PerfLog.Active ) PerfLog.Stop();
else PerfLog.Start( NZGame.Mode == GameMode.Creative ? "creative" : "survival" );
}
void ToggleMenu()
{
MenuOpen = !MenuOpen;
Mouse.Visibility = MenuOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;
}
static void Collapse()
{
MenuOpen = false;
Mouse.Visibility = MouseVisibility.Hidden;
}
static void Off()
{
Visible = false;
MenuOpen = false;
Mouse.Visibility = MouseVisibility.Hidden;
}
/// <summary>
/// `nz_perf_hud [0|1]` — show the panel.
///
/// ⚠️ IT OPENS WITH THE MENU EXPANDED, so the buttons are reachable from one command. Collapse
/// hands the cursor back when you want to turn and watch.
/// </summary>
[ConCmd( "nz_perf_hud" )]
public static void HudCmd( int on = -1 )
{
EnsureHost();
Visible = on < 0 ? !Visible : on != 0;
MenuOpen = Visible;
Mouse.Visibility = MenuOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;
Log.Info( $"[nz-perf] hud {(Visible ? "on" : "off")}"
+ (Visible ? " — ▼ collapses the menu and releases the cursor" : "") );
}
static GameObject _host;
/// <summary>
/// Make sure something is drawing this.
///
/// ⛔ SAME SELF-BUILDING HOST AS TradeTableTuner, for the same reason: every HUD here is a scene
/// object with a ScreenPanel, the scene file must not be rewritten from script, and without
/// this the razor compiles and never renders.
///
/// ⚠️ REBUILT WHENEVER THE OBJECT IS GONE — a GameObject created from code does not survive a
/// hotload, and a perf readout that silently stops appearing after a code edit is worse than
/// none. It is also what keeps PerfLog sampling, since the log rides this component's update.
///
/// ⚠️ ZIndex 90 — above the tuners (80), because it has to stay readable while one is open.
/// </summary>
static void EnsureHost()
{
if ( _host.IsValid() ) return;
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
_host = scene.CreateObject();
_host.Name = "Perf HUD";
_host.Flags |= GameObjectFlags.NotSaved;
var screen = _host.Components.Create<ScreenPanel>();
screen.ZIndex = 90;
_host.Components.Create<PerfHud>();
Log.Info( "[nz-perf] created the hud's screen panel" );
}
// ⚠️ EVERY DISPLAYED VALUE IS IN THE HASH. Without them the tree never rebuilds and the numbers
// freeze at their first reading, which reads as the tool being broken rather than stale.
protected override int BuildHash()
=> HashCode.Combine(
HashCode.Combine( Visible, MenuOpen, Fps, Frame, Worst, Gpu ),
HashCode.Combine( Ram, Vram, Draws, LightsText ),
HashCode.Combine( PerfLog.Active, PerfLog.Rows, Owners.Count,
GpuProfilerStats.Enabled, ShadowsOn ) );
}