feat(hud): F3 debug overlay — FPS, frame time, worst frame, state

A top-left readout for performance work: smoothed FPS and frame time, the worst
frame in a rolling two-second window, the active view, player speed, a frame counter
and the player's position in metres from the project origin.

- **F3** toggles it, through the console variable  so a packaged build
  can be checked without a rebuild ().
- **On automatically** for  and , so the performance capture never
  depends on pressing a key first; **off in the built demo**, because a player should
  not see an FPS counter over Hollywood Boulevard.
- The first two seconds are a **warm-up window** excluded from the worst-frame figure.
  Without it the readout showed 'worst 400 ms' for a healthy run: the startup shader
  and streaming hitches dominated the rolling maximum.

Measured cost on the self-test route at 1440p: 2.64 -> 2.77 ms average, about 5 %,
still 6x the floor. The release-check number stays the overlay-off run; the overlay-on
series is committed beside it as Docs/perf/0.2.0-walk-frametimes-overlay.csv.

Docs: controls (F3 row, overlay section, switch table), release checks (cost row),
architecture building block view (HUD and input list).
This commit is contained in:
zwitschi committed 2026-11-09 12:40:48 +01:00
1 parent 12272d6b96
commit 73bce53a2b
12 files changed
+904 -8

No files matched your search

Binary file not shown.
Binary file not shown.
@@ -15,6 +15,7 @@
#include "Engine/World.h"
#include "TimerManager.h"
#include "Containers/Ticker.h"
#include "HAL/IConsoleManager.h"
#include "Kismet/GameplayStatics.h"
#include "Misc/CommandLine.h"
#include "Misc/Parse.h"
@@ -132,6 +133,8 @@ AGigLineCharacter::AGigLineCharacter()
TEXT("/Game/Input/IA_Jump.IA_Jump"));
static ConstructorHelpers::FObjectFinder<UInputAction> ResetF(
TEXT("/Game/Input/IA_Reset.IA_Reset"));
static ConstructorHelpers::FObjectFinder<UInputAction> DebugF(
TEXT("/Game/Input/IA_ToggleDebug.IA_ToggleDebug"));
static ConstructorHelpers::FObjectFinder<UInputAction> CamF(
TEXT("/Game/Input/IA_ToggleCamera.IA_ToggleCamera"));
static ConstructorHelpers::FObjectFinder<UInputAction> PauseF(
@@ -146,6 +149,7 @@ AGigLineCharacter::AGigLineCharacter()
JumpAction = JumpF.Object;
ResetAction = ResetF.Object;
ToggleCameraAction = CamF.Object;
ToggleDebugAction = DebugF.Object;
PauseAction = PauseF.Object;
if (!MappingContext || !MoveAction || !MoveBackAction || !StrafeAction || !StrafeLeftAction
@@ -257,6 +261,28 @@ void AGigLineCharacter::BeginPlay()
// with `-thirdperson` so a capture can show the walk clip mid-stride: an idle
// shot cannot prove the animation set, and a still cannot show a stride.
bWalkForever = FParse::Param(FCommandLine::Get(), TEXT("walk"));
// `-perf` starts the overlay on: the performance capture and any manual frame
// check should not depend on someone pressing F3 first. The built demo has
// neither switch, so the counter stays hidden for players.
if (FParse::Param(FCommandLine::Get(), TEXT("perf")) || FParse::Param(FCommandLine::Get(), TEXT("qaselftest")))
{
if (IConsoleVariable* Var = IConsoleManager::Get().FindConsoleVariable(TEXT("gigline.Debug")))
{
Var->Set(1, ECVF_SetByConsole);
}
}
}
void AGigLineCharacter::ToggleDebugOverlay()
{
static IConsoleVariable* Var = IConsoleManager::Get().FindConsoleVariable(TEXT("gigline.Debug"));
if (Var)
{
const bool bNowOn = Var->GetInt() == 0;
Var->Set(bNowOn ? 1 : 0, ECVF_SetByConsole);
UE_LOG(LogTemp, Log, TEXT("GigLine: debug overlay %s"), bNowOn ? TEXT("on") : TEXT("off"));
}
}
void AGigLineCharacter::SetReviewWalk(bool bEnable)
@@ -658,6 +684,10 @@ void AGigLineCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputCo
{
Input->BindAction(ToggleCameraAction, ETriggerEvent::Started, this, &AGigLineCharacter::ToggleCamera);
}
if (ToggleDebugAction)
{
Input->BindAction(ToggleDebugAction, ETriggerEvent::Started, this, &AGigLineCharacter::ToggleDebugOverlay);
}
if (JumpAction)
{
Input->BindAction(JumpAction, ETriggerEvent::Started, this, &AGigLineCharacter::StartJump);
@@ -42,6 +42,9 @@ protected:
void TogglePause();
public:
/** True while the third-person rig is the active view (for the debug overlay). */
bool IsThirdPerson() const { return bThirdPerson; }
/** Review harness: hold or release forward input from the screenshot actor. */
void SetReviewWalk(bool bEnable);
@@ -172,6 +175,13 @@ protected:
/** Applies the `-walk` forward input when the flag is set and the pawn is grounded. */
void WalkForever();
/** Debug-overlay action (F3). */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> ToggleDebugAction;
/** Flips the gigline.Debug console variable that the HUD reads. */
void ToggleDebugOverlay();
/** Toggle-camera action (V). */
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> ToggleCameraAction;
@@ -2,11 +2,61 @@
#include "Engine/Canvas.h"
#include "Engine/Engine.h"
#include "GameFramework/PlayerController.h"
#include "GigLineCharacter.h"
#include "HAL/IConsoleManager.h"
#include "Kismet/GameplayStatics.h"
// Console variable so a packaged build can be checked without a rebuild:
// gigline.Debug 1
// The HUD reads it every frame; the F3 binding toggles it.
static TAutoConsoleVariable<int32> CVarGigLineDebug(
TEXT("gigline.Debug"),
0,
TEXT("Draw the performance and state overlay (0=off, 1=on). F3 toggles it."),
ECVF_Cheat);
void AGigLineHUD::UpdateFrameStats(float DeltaSeconds)
{
if (DeltaSeconds <= 0.0f)
{
return;
}
++FramesDrawn;
const float Ms = DeltaSeconds * 1000.0f;
if (WarmupSeconds > 0.0f)
{
WarmupSeconds -= DeltaSeconds;
SmoothedFrameMs = FMath::Lerp(SmoothedFrameMs, Ms, 0.1f);
WorstFrameMs = Ms;
WorstWindowSeconds = 0.0f;
return;
}
// A 0.9/0.1 EMA settles in a few frames and still reacts to a stall.
SmoothedFrameMs = FMath::Lerp(SmoothedFrameMs, Ms, 0.1f);
WorstFrameMs = FMath::Max(WorstFrameMs, Ms);
// Rolling worst-frame window: a spike stays visible for two seconds, so a
// hitch is not missed between glances (and not averaged away).
WorstWindowSeconds += DeltaSeconds;
if (WorstWindowSeconds >= 2.0f)
{
WorstWindowSeconds = 0.0f;
WorstFrameMs = Ms;
}
}
void AGigLineHUD::DrawHUD()
{
Super::DrawHUD();
// Console variable is authoritative; the UPROPERTY is the per-instance default
// for tests that set it before BeginPlay.
if (IConsoleVariable* Var = IConsoleManager::Get().FindConsoleVariable(TEXT("gigline.Debug")))
{
bShowDebug = Var->GetInt() != 0;
}
UpdateFrameStats(GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.0f);
if (!Canvas || AttributionLine.IsEmpty())
{
return;
@@ -37,4 +87,35 @@ void AGigLineHUD::DrawHUD()
DrawText(ControlsLine, Shadow, CX + 1.0f, CY + 1.0f, nullptr, TextScale * 0.82f);
DrawText(ControlsLine, Ink, CX, CY, nullptr, TextScale * 0.82f);
}
if (!bShowDebug)
{
return;
}
// Performance and state readout, top-left. Everything here is cheap enough to
// leave on during a capture: four formatted lines and no additional passes.
const APawn* Pawn = UGameplayStatics::GetPlayerPawn(this, 0);
const AGigLineCharacter* Character = Cast<AGigLineCharacter>(Pawn);
const float Fps = SmoothedFrameMs > 0.0f ? 1000.0f / SmoothedFrameMs : 0.0f;
const FVector Where = Pawn ? Pawn->GetActorLocation() / 100.0f : FVector::ZeroVector;
const float Speed = Pawn ? Pawn->GetVelocity().Size2D() / 100.0f : 0.0f;
const FString Header = FString::Printf(TEXT("%.0f FPS %.2f ms worst %.2f ms"), Fps, SmoothedFrameMs, WorstFrameMs);
const FString State = FString::Printf(TEXT("%s %.2f m/s frames %lld"),
Character && Character->IsThirdPerson() ? TEXT("third person") : TEXT("first person"),
Speed, FramesDrawn);
const FString Place = FString::Printf(TEXT("x %.1f y %.1f z %.1f m"), Where.X, Where.Y, Where.Z);
const FString Hint = TEXT("F3 hide");
const FLinearColor DebugInk(0.85f, 1.0f, 0.55f, 0.95f);
const FLinearColor DebugShadow(0.0f, 0.0f, 0.0f, 0.6f);
const float DX = MarginPx;
float DY = MarginPx;
for (const FString& Line : { Header, State, Place, Hint })
{
DrawText(Line, DebugShadow, DX + 1.0f, DY + 1.0f, nullptr, TextScale * 0.9f);
DrawText(Line, DebugInk, DX, DY, nullptr, TextScale * 0.9f);
DY += 22.0f * TextScale * 0.9f;
}
}
+34
View File
@@ -40,6 +40,19 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution")
FString LicenceLine = TEXT("Data \u00A9 OpenStreetMap contributors, ODbL 1.0 \u2014 openstreetmap.org/copyright");
/**
* Performance and state readout, top-left, off by default.
*
* Toggle in game with **F3**, or start with it on by passing `-perf` (the
* performance capture does). The built demo ships with it hidden so a player
* does not see an FPS counter over Hollywood Boulevard.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Debug")
bool bShowDebug = false;
/** Smoothed FPS and a rolling frame-time window, refreshed every frame. */
void UpdateFrameStats(float DeltaSeconds);
/** Screen-space margin in pixels. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution")
float MarginPx = 18.0f;
@@ -47,4 +60,25 @@ public:
/** Text scale relative to the default canvas font. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution")
float TextScale = 1.45f;
private:
/** Exponential moving average of frame time, in milliseconds. */
float SmoothedFrameMs = 16.7f;
/** Worst frame in the rolling window, so a hitch is visible without a log. */
float WorstFrameMs = 0.0f;
/** Seconds until the rolling worst-frame window resets. */
float WorstWindowSeconds = 0.0f;
/** Frames drawn, so the overlay can show that the counter is live. */
int64 FramesDrawn = 0;
/**
* Seconds left in the warm-up window. The first frames of a session include
* shader and streaming hitches of several hundred milliseconds; without this
* the "worst" figure read 400 ms for the whole first two seconds and made a
* healthy run look broken.
*/
float WarmupSeconds = 2.0f;
};