UI/PerfHud.razor

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.

File AccessNetworking
@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 ) );
}