Skip to content

DiagnosticTextRenderer: Make numbers monospace (big perf win) - #21860

Merged
Gillibald merged 4 commits into
AvaloniaUI:mainfrom
qwertychouskie:patch-1
Oct 5, 2026
Merged

Gillibald merged 4 commits into
AvaloniaUI:mainfrom
qwertychouskie:patch-1

Conversation

@qwertychouskie

@qwertychouskie qwertychouskie commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

What does the pull request do?

Makes the FPS overlay cheap to draw and stops its numbers from jumping around.

The overlay formatted and measured a new string every frame, and because digits have different widths, the text shifted as values changed. On a Rockchip PX30 (Cortex-A35, Mali-G31, Linux DRM, NativeAOT, RenderDemo "Spring Animation" page) the first step of this PR, monospaced digits, took the frame rate from the 30-40 range to the 90-100 range.

Changes:

  • DiagnosticTextRenderer lays out digits in equal-width cells (the widest digit) and centers each glyph in its cell. The line height now comes from the font metrics instead of scanning glyph bounds.
  • DiagnosticTextRenderer gains Shape (text shaped once into a single glyph run) and DrawDigits (zero-padded number drawn in the digit grid without formatting a string).
  • FpsCounter shapes its labels once and draws the frame number, FPS and visual counts with DrawDigits. The memory text is rebuilt only when the pool sizes change, the background width is computed once, and the layer brush is a static field. A frame no longer formats or measures any text.
  • FrameTimeGraph labels get the digit cells through DrawAsciiText.

What is the updated/expected behavior with this PR?

Open the FPS overlay: numbers stay in place and digits no longer overlap the following text or leave the background rect.

Checklist

Breaking changes

None for public API. The overlay counters now have a fixed width and clamp instead of growing: frame number 8 digits, FPS 3, visited/rendered visuals 5 (was 4, minimum).

Obsoletions / Deprecations

None

Fixed issues

None filed

Assisted-by: Gemini 3.1 Pro

🤖 Generated with Claude Code

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0067789-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@cla-avalonia

cla-avalonia commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator
  • All contributors have signed the CLA.

@qwertychouskie

Copy link
Copy Markdown
Contributor Author

@cla-avalonia agree

@Gillibald

Copy link
Copy Markdown
Contributor

Splitting this into multiple pieces with the static part shaped once is most likely the biggest performance gain:

var fpsLine = string.Create(CultureInfo.InvariantCulture, $"Frame #{_totalFrames:00000000} FPS: {_fps:000} {aux}");

The changing part is all digits, and these can be laid out with a fixed column width. So in the end, you can calculate the column width and height(FontMetrics.LineSpacing) once.

@MrJul MrJul added enhancement area-perf backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Jul 27, 2026
@qwertychouskie

qwertychouskie commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Splitting this into multiple pieces with the static part shaped once is most likely the biggest performance gain:

var fpsLine = string.Create(CultureInfo.InvariantCulture, $"Frame #{_totalFrames:00000000} FPS: {_fps:000} {aux}");

The changing part is all digits, and these can be laid out with a fixed column width. So in the end, you can calculate the column width and height(FontMetrics.LineSpacing) once.

But even in this version, the fpsLine is still getting Created every frame, and MeasureAsciiText also gets called each frame. (I have a local change that only calculates MeasureAsciiText once, but it didn't make a clearly-measurable difference, so I didn't include it in this PR). I have to think there's some caching deeper in e.g. Skia that having the numbers be monospaced helps with, because as far as I can tell, none of the seemingly-obvious stuff (e.g. re-calculating text size) makes any measurable performance impact, while avoiding the numbers jumping around makes a huge impact for seemingly "no reason" since the glyphs are all the same cached glyphs and are just having their positions (specifically, their offsets) change vs not change.

(Unless I'm misunderstanding your message?)

@Gillibald

Copy link
Copy Markdown
Contributor

The whole concept of measuring the FPS text each tick is just wrong. Reserve space for the maximum number of digits and only redraw the digits that are changing each tick. Everything is fixed with that approach. So you never need to work with strings.

@qwertychouskie

qwertychouskie commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor Author

I re-ran the numbers (CPU and GPU clocks are fixed now, which makes it slightly easier to get a proper feel for averages)

Baseline: avg ~50 FPS
Only caching MeasureAsciiText: avg ~50 FPS
Only the DiagnosticTextRenderer change (current state of this PR): avg ~100 FPS
Both changes: avg ~100 FPS

Since the MeasureAsciiText caching seems to make 0 measurable difference, I originally left it out of this PR, but I can still push if you want.

Or were you referring to a different sort of change? Apologies if I'm misunderstanding something; this is my first time working with Avalonia, and C# for that matter. (My role in the new Driver Station project has been focused on getting a stripped-down Linux build running on the Driver Hub to run the DS software, but at this point I've already exhausted basically every OS-level optimization possible and the DS app is still significantly low framerate even when just e.g. scrolling the settings tab with the touchscreen, so I decided to start investigating potential performance fixes in Avalonia itself to hopefully get performance ultimately up-to-par with what teams will expect based on the old Android-based DS that was generally quite smooth.)

@Gillibald

Copy link
Copy Markdown
Contributor

Your implementation uses the wrong cell width for centering. In DrawAsciiText the cell is maxNumberCharGlyphWidth, but the centering is computed against maxCharGlyphWidth, which is the widest printable ASCII glyph (@, W, or %), not the widest digit.

So every digit is drawn well to the right of its cell while the advance stays at the digit width. Digits overlap the following glyph, and the last one hangs outside the background rect that MeasureAsciiText sized. FrameTimeGraph's Min/Avg/Max labels go through the same path and get the same corruption. The fix is to center against the digit cell, after which maxCharGlyphWidth is unused and can go.

All this is font-dependent, and ideally we embed a font that only contains the needed glyphs so we control what font is used.

The height computation of the renderer can be simplified. Compute it directly in the constructor from glyphTypeface.Metrics.LineSpacing and skip touching the runs at all.

The one optimization I mentioned before is still valid but is not required to be able to merge this PR. Splitting the line into a static part shaped once plus a fixed digit grid.

…m metrics

- Digits were centered against the widest printable ASCII glyph instead of the digit cell, so each digit was drawn right of its cell, overlapped the next glyph and the last one left the background rect. Center against the digit cell and drop the unused max glyph width.
- Compute the line height once from the font metrics (LineSpacing scaled to the em size) instead of scanning glyph run bounds, which already resolve to that value.
- Add tests for fixed digit width, cell centering, stable trailing text and the metrics-based height.
- Shape the static labels once into single glyph runs and draw the frame number, FPS and visual counts as zero-padded digits in fixed cells. A frame no longer formats a string or measures text.
- Rebuild the memory text only when the pool sizes change, and compute the background rect width once.
- RenderFps takes the raw memory and visual counts instead of a preformatted string. Counters now have a fixed width (frame 8 digits, FPS 3, visual counts 5) and clamp instead of growing.
- Hoist the translucent layer brush into a static field.
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

NuGet packages

The packages for this pull request have been published (workflow run):

@Gillibald
Gillibald enabled auto-merge October 5, 2026 08:56
@Gillibald
Gillibald added this pull request to the merge queue Oct 5, 2026
Merged via the queue into AvaloniaUI:main with commit f4591be Oct 5, 2026
11 checks passed
@qwertychouskie
qwertychouskie deleted the patch-1 branch October 5, 2026 14:53
grokys pushed a commit to Evan260/Avalonia that referenced this pull request Oct 7, 2026
…iaUI#21860)

* DiagnosticTextRenderer: Make numbers monospace

* Center diagnostic digits in their own cell and derive line height from metrics

- Digits were centered against the widest printable ASCII glyph instead of the digit cell, so each digit was drawn right of its cell, overlapped the next glyph and the last one left the background rect. Center against the digit cell and drop the unused max glyph width.
- Compute the line height once from the font metrics (LineSpacing scaled to the em size) instead of scanning glyph run bounds, which already resolve to that value.
- Add tests for fixed digit width, cell centering, stable trailing text and the metrics-based height.

* Draw the FPS line from shaped labels and a fixed digit grid

- Shape the static labels once into single glyph runs and draw the frame number, FPS and visual counts as zero-padded digits in fixed cells. A frame no longer formats a string or measures text.
- Rebuild the memory text only when the pool sizes change, and compute the background rect width once.
- RenderFps takes the raw memory and visual counts instead of a preformatted string. Counters now have a fixed width (frame 8 digits, FPS 3, visual counts 5) and clamp instead of growing.
- Hoist the translucent layer brush into a static field.

---------

Co-authored-by: Benedikt Stebner <Gillibald@users.noreply.github.com>
MrJul pushed a commit that referenced this pull request Oct 9, 2026
* DiagnosticTextRenderer: Make numbers monospace

* Center diagnostic digits in their own cell and derive line height from metrics

- Digits were centered against the widest printable ASCII glyph instead of the digit cell, so each digit was drawn right of its cell, overlapped the next glyph and the last one left the background rect. Center against the digit cell and drop the unused max glyph width.
- Compute the line height once from the font metrics (LineSpacing scaled to the em size) instead of scanning glyph run bounds, which already resolve to that value.
- Add tests for fixed digit width, cell centering, stable trailing text and the metrics-based height.

* Draw the FPS line from shaped labels and a fixed digit grid

- Shape the static labels once into single glyph runs and draw the frame number, FPS and visual counts as zero-padded digits in fixed cells. A frame no longer formats a string or measures text.
- Rebuild the memory text only when the pool sizes change, and compute the background rect width once.
- RenderFps takes the raw memory and visual counts instead of a preformatted string. Counters now have a fixed width (frame 8 digits, FPS 3, visual counts 5) and clamp instead of growing.
- Hoist the translucent layer brush into a static field.

---------

Co-authored-by: Benedikt Stebner <Gillibald@users.noreply.github.com>
@MrJul MrJul added backported-12.1.x and removed backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants