feat(stats): Add 1% low FPS tracking - #2661
Conversation
|
| 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
Reviews (14): Last reviewed commit: "if nitpick" | Re-trigger Greptile
|
I do not like the visuals of the new value. Can this look better? |
|
Maybe make the 1% low value a bit brighter. It can be difficult to read in game. |
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
adf3d8f to
4f7079b
Compare
xezon
left a comment
There was a problem hiding this comment.
Looks very optimized. A bunch of small comments.
| if (now - m_lastLow1PercentUpdateMs >= 1000) | ||
| { | ||
| m_low1PercentFPS = calculateLow1PercentFPS(3.0f); | ||
| m_lastLow1PercentUpdateMs = now; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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) {} |
There was a problem hiding this comment.
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) {} |
There was a problem hiding this comment.
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 |
| } | ||
| } | ||
|
|
||
| Real W3DDisplay::calculateAverageFPS(Real windowSeconds) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; } |
There was a problem hiding this comment.
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); |
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.
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.