Skip to content

feat(stats): Add 1% low FPS tracking - #2661

Open
githubawn wants to merge 15 commits into
TheSuperHackers:mainfrom
githubawn:feature/fps-1percent-low-hud
Open

feat(stats): Add 1% low FPS tracking#2661
githubawn wants to merge 15 commits into
TheSuperHackers:mainfrom
githubawn:feature/fps-1percent-low-hud

Conversation

@githubawn

@githubawn githubawn commented Apr 29, 2026

Copy link
Copy Markdown

This PR adds a 1% low FPS metric to the existing FPS counter HUD, displayed in parentheses next to the average FPS. The 1% low is a standard performance metric used to surface frame time spikes that the average FPS hides. Inspired by #1942.

Add 1% low FPS display to HUD counter
Add m_renderFpsLowString and supporting UI members
Add RenderFpsLowColor configuration to InGameUI INI
Increase history to 4,096 frames for bitwise indexing
Implement rolling 1.0s window for average FPS
Implement rolling 3.0s window for 1% lows

The following screenshot from AOD Cobalt Rush shows the 1% low FPS overlay compared to CapFrameX (an external benchmarking tool, centered right), demonstrating the value of surfacing this metric separately from the average.

lowfps

This change was generated with AI assistance. All generated code has been reviewed, tested, and verified for correctness. The implementation went through multiple iterations, including fundamental changes to the underlying approach, as well as passes to apply simplifications, fix inconsistencies, and optimize performance. Both Generals and GeneralsMD implementations are included in this PR with identical code.

@greptile-apps

greptile-apps Bot commented Apr 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a 1% low FPS metric to the in-game HUD by replacing the old 30-frame simple average with a compact ring-buffer of up to 4 096 quantized frame durations, supporting both a rolling 1-second average and a rolling 3-second 1%-low calculation.

  • New QuantizedUnsignedShort class — stores each frame time as a 16-bit tick count (62 500 ticks/s), keeping memory use to 8 KB per display object while spanning frame rates from 0.95 fps to 62 500 fps.
  • calculateLow1PercentFPS — walks the ring buffer for the last 3 s of samples, uses std::nth_element with std::greater to isolate the slowest ≈1 % of frames, and returns their harmonic-mean FPS; the result is throttled to at most one recalculation per second in getLow1PercentFPS.
  • HUD rendering — adds m_renderFpsLowString/m_renderFpsLowColor parallel to the existing strings, inserts a half-gap between elements, and exposes RenderFpsLowColor as a new INI field.

Confidence Score: 5/5

Safe to merge — the new ring-buffer and nth_element 1%-low calculation are algorithmically correct, existing null-guard and string-lifecycle patterns are followed, and no regressions to the Dynamic LOD path were introduced.

The algorithmic changes are sound: the window-based average replaces a fixed 30-frame mean without changing the Dynamic LOD consumer, the 1% low uses a correct harmonic mean over the bottom ~1% of frame durations, and overflow/underflow in the 16-bit tick representation is clamped at both ends. The only observation is a style note about the implicit-conversion comparator in nth_element, which compiles correctly.

Files Needing Attention: Both W3DDisplay.h files (Generals and GeneralsMD) share the QuantizedUnsignedShort definition and are worth a second glance if comparison-operator behavior ever changes.

Important Files Changed

Filename Overview
Generals/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DDisplay.h Introduces QuantizedUnsignedShort class and TICKS_PER_SECOND constant, adds 4096-entry ring buffer members and new method declarations; class relies on implicit UnsignedShort conversion for comparators
Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Replaces static-local 30-frame average with instance ring buffer; addFpsSample, calculateAverageFPS, calculateLow1PercentFPS, and getLow1PercentFPS are algorithmically correct
Generals/Code/GameEngine/Source/GameClient/InGameUI.cpp Adds m_renderFpsLowString lifecycle (alloc/free/font), updateRenderFpsString low-FPS update, and drawRenderFps layout changes; follows existing null-guard and refresh patterns cleanly
GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DDisplay.h Identical to Generals counterpart; same QuantizedUnsignedShort class and ring-buffer members added
GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Identical implementation to Generals counterpart; all FPS metric methods are consistent
Core/GameEngine/Include/GameClient/Display.h Adds pure virtual getLow1PercentFPS() to the Display interface; all known implementors updated in this PR
Generals/Code/Tools/GUIEdit/Include/GUIEditDisplay.h Adds stub getLow1PercentFPS() override returning 0 to satisfy the new pure virtual; correct
GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp Mirrors Generals InGameUI.cpp changes; FPS low string lifecycle and drawing are consistent

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["draw() - every frame"] --> B["updatePerformanceMetrics()"]
    B --> C["getPerformanceCounter()"]
    C --> D["addFpsSample(elapsedSeconds)"]
    D --> E["QuantizedUnsignedShort::fromSeconds\n(clamp to [1, 65535] ticks)"]
    E --> F["m_durationHistory[m_historyOffset] = sample\nm_historyOffset = (offset+1) & 4095"]
    F --> G["calculateAverageFPS(1.0s window)\n→ m_averageFPS"]
    G --> H["Dynamic LOD / FPS display"]

    subgraph UI["UI Update (throttled by m_renderFpsRefreshMs)"]
        I["drawRenderFps()"] --> J["refreshRenderFpsResources()"]
        J --> K["updateRenderFpsString()"]
        K --> L["getLow1PercentFPS()\n(throttled: recalc at most 1/sec)"]
        L --> M["calculateLow1PercentFPS(3.0s window)\nnth_element → worst 1% durations\nharmonic mean → m_low1PercentFPS"]
        M --> N["draw: avgFPS ▼lowFPS ▲limit"]
    end

    H --> I
Loading

Reviews (14): Last reviewed commit: "if nitpick" | Re-trigger Greptile

Comment thread Generals/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DDisplay.h Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
@githubawn
githubawn requested a review from Skyaero42 April 30, 2026 23:53
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DDisplay.h Outdated
@xezon

xezon commented May 5, 2026

Copy link
Copy Markdown

I do not like the visuals of the new value. Can this look better?

@xezon xezon added Enhancement Is new feature or request GUI For graphical user interface Minor Severity: Minor < Major < Critical < Blocker Gen Relates to Generals ZH Relates to Zero Hour labels May 7, 2026
@xezon

xezon commented May 7, 2026

Copy link
Copy Markdown

Suggestion:

image

Triangle down code is: \x25BC
Triangle up code is: \x25B2

Uncapped FPS:

image

X is ascii X

X because 0 makes no sense as a value for no cap.

@xezon

xezon commented May 7, 2026

Copy link
Copy Markdown

Maybe make the 1% low value a bit brighter. It can be difficult to read in game.

Comment thread GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp Outdated
Comment thread GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DDisplay.h Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DDisplay.h Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
Comment thread GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DDisplay.h Outdated
Comment thread GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp

@xezon xezon left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are compile errors.

Comment thread GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated
@xezon
xezon requested a review from Skyaero42 June 14, 2026 09:39
@githubawn
githubawn marked this pull request as draft June 27, 2026 18:45
githubawn added 10 commits July 30, 2026 23:45
Move FPS history state into W3DDisplay members.
Implement accurate time-based windowing for frame metrics.
Use ceiling logic for improved 1% low accuracy.
Optimize percentile calculation using efficient selection algorithm.
Rename and centralize performance update call sites.
Increase history buffer for stable high-FPS monitoring.
Update average FPS math to use time-weighted mean.
Move sortBuffer to class members for consistency.
…ootprint.

Fast Indexing: Replaced slow modulo division with fast bitwise index masking.
Loop Optimization: Eliminated per-iteration modulo checks from history search loops.
Safe Sampling: Clamped minimum frame time to prevent division by zero errors.
Removed Branching: Pre-primed timer in display init to remove redundant per-frame branches.
Cleaned Capping: Used RenderFpsPreset constant instead of magic zero value for uncapped.
Improved Windows: Widened average window and boosted low-percent telemetry update rates.
…gnedShort m_durationHistory), shrinking memory from 49 KB to 8 KB

Remove redundant m_fpsHistory and m_sortBuffer member variables from W3DDisplay class
Use fast integer additions and comparisons instead of float operations inside calculation loops
Wrap ring buffer indices using a branchless bitwise AND mask (& 4095) instead of modulo and check branches
Implement lazy evaluation of the 1% low metrics in the getter, caching calculations to run at most once per second
Allocate the sorting buffer locally on the stack instead of storing it as a class member
Use std::fill to initialize the duration history to a baseline of 30 FPS at startup
Remove duplicate semicolons, outdated comments, and format spacing
fix divergence between generals and generalsmd
@githubawn
githubawn force-pushed the feature/fps-1percent-low-hud branch from adf3d8f to 4f7079b Compare July 30, 2026 21:46
@githubawn
githubawn marked this pull request as ready for review July 30, 2026 21:48
@githubawn
githubawn dismissed Skyaero42’s stale review July 31, 2026 15:19

Implemented requested changes.

Comment thread GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp Outdated

@xezon xezon left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks very optimized. A bunch of small comments.

if (now - m_lastLow1PercentUpdateMs >= 1000)
{
m_low1PercentFPS = calculateLow1PercentFPS(3.0f);
m_lastLow1PercentUpdateMs = now;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way this timer is currently implemented means that there will be time creep depending on the calling delays after 1000 ms. So it will never be at a fixed 1000 ms interval, but ever so slightly more than 1000 ms every time and over time it will creep behind the interval. Not a big deal, but maybe there is a way to implement it in a more stable manner.

class RTS3DInterfaceScene;
class TextureClass;

constexpr const Real TICKS_PER_SECOND = 62500.0f;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to see a bit of documentation that explainst the 16 microseconds precision and what the class does.

class QuantizedUnsignedShort
{
public:
QuantizedUnsignedShort() : m_value(2083) {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better clarify this refers to 30 FPS. Maybe write as TICKS_PER_SECOND / 30. It is not intuitive.

class QuantizedUnsignedShort
{
public:
QuantizedUnsignedShort() : m_value(2083) {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason for defaulting to 30? Does it matter?

Real m_low1PercentFPS; ///<1% low fps.
Real m_currentFPS; ///<current fps value.

enum { FPS_HISTORY_SIZE = 4096 }; // degrades gracefully beyond this size

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style: ///<

}
}

Real W3DDisplay::calculateAverageFPS(Real windowSeconds)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe call this timeWindowSeconds to make the meaning a bit clearer. Or spanSeconds. window is ok but took me a moment to understand what this means.

}
}

if (sampleCount == 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If m_historyCount starts at 1, then this condition is not needed.

durationUnitsSum += sortBuffer[i];
}

return (durationUnitsSum > 0) ? ((Real)bottomSampleCount * TICKS_PER_SECOND / (Real)durationUnitsSum) : m_currentFPS;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be 100% above 0 right? If yes then condition can be removed.

Real toFPS() const { return TICKS_PER_SECOND / (Real)m_value; }
Real toSeconds() const { return (Real)m_value / TICKS_PER_SECOND; }

operator UnsignedShort() const { return m_value; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe remove this operator and call getValue() explicitly. Makes for less sneaky code.

return m_currentFPS;
}

const Int bottomSampleCount = std::max((sampleCount + 50) / 100, 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or call it low1PercentSampleCount

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement Is new feature or request Gen Relates to Generals GUI For graphical user interface Minor Severity: Minor < Major < Critical < Blocker ZH Relates to Zero Hour

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants