Guide
Performance
The engine does work only for what changed, and allocates nothing per frame in steady state. This chapter says what a frame does, what each kind of change costs, and how to measure your own pages.
The frame
- Input, at the end of PreUpdate: DOM events run before your game's
Update. - Your
UpdateandLateUpdatewrite state and DOM. Changes only mark things dirty. - Flush, after
LateUpdate, in this order:- animations and transitions advance;
- reactivity runs:
[Tick]values, effects, template updates; - style for dirty elements only;
- layout for dirty boxes only;
- paint, in place, of what changed;
- upload of the changed GPU records, and one draw per page.
Reading layout from code (GetBoundingClientRect, ElementFromPoint) brings style and layout up to date first, as in a browser. An idle page costs about a microsecond.
What a change costs
| Change | Cost |
|---|---|
translate, rotate, scale, opacity through typed setters, style: directives, CSS animations and transitions | Cheapest. No style, layout or paint: numbers in a table the shader reads |
Text through {expr} or Text.SetData | No allocation; that line is laid out; changed glyphs are painted |
| Colors, backgrounds, borders that keep their size | The element is restyled; colors are rewritten in place, or the box is repainted |
| A class on one element | Restyle of it and of what its selectors reach |
left, top, width, height, margin, font-size | Layout of the box and of what depends on it |
A theme class on body | Every element is restyled: fine once, not every frame |
An element inserted or removed, or display switched | Most expensive. The view's layout is rebuilt and everything is painted |
String setters (SetProperty, SetAttribute("style")) | CSS is parsed and memory allocated: not for per-frame values |
{@html}, string concatenation in templates, LINQ in [Derived] | Allocate: use interpolation and loops |
Animated font-size | New glyph sizes are rasterized: animate scale, or use -devcore-text-rendering: sdf |
Habits that keep a UI fast
- Move things with
style:translate, notleftandtop. - Show and hide frequent popups (tooltips, toasts, drag ghosts) with
visibility, and keep them in the page. - Open and close whole screens with
display: none; it is one rebuild, not one per frame. - Use
{#virtual}for lists of thousands of rows. - Give containers a fixed size where you can: they act as layout boundaries.
- Use
font-variant-numeric: tabular-numsfor timers, so changing digits do not move the text around them. - Look elements up once with
Q<T>when the page loads, and keep them.
Numbers
Milliseconds of UI time per frame in a Windows player (Mono), with UI Toolkit showing the same content beside it. Zero bytes are allocated per frame in every DevCore scenario.
| Scenario | DevCore | UI Toolkit |
|---|---|---|
| Empty page | 0.19 | 0.19 |
| Idle shop page, 400 cards | 0.21 | 0.40 |
| Minimap: 1,000 markers moved | 1.92 | 2.65 |
| Timers: 200 texts changing | 5.35 | 8.27 |
| Health bars: 300 widths (layout) | 3.66 | 5.65 |
| Theme switch, 1,600 elements | 4.45 | 50.78 |
| Long list, 10,000 virtual rows | 0.55 | 0.78 |
| Transitions: 200 tiles, color and scale | 0.52 | 1.58 |
| Building a page (warm) | 62 | 75 |
| Managed memory kept (MB) | 7.2 to 9.8 | 0.6 to 3.5 |
IL2CPP is often about twice as fast as Mono for this code. A table of 1,000 cells with text changing takes about 16 ms per frame in the same player, so keep large tables still.
One view or several
Measured with a game UI of 1,450 elements (HUD, minimap, menu, inventory) split into one, two and six views at 1080p:
- Ordinary frames cost the same however the UI is split: 0.9 ms during gameplay, 2.4 ms with the inventory changing.
- Each further view adds about 18 MB of native memory, about 20 ms of build time and one draw call.
- An element inserted or removed rebuilds its whole view. A tooltip appearing over a 350-element inventory costs 7 to 8.5 ms; showing a kept tooltip by
visibilitycosts 4.6 ms and hiding it is free. - Splitting isolates one screen's rebuild from another: a HUD toast under an open inventory costs 8.4 ms in one view and 3.8 ms with the HUD in its own.
Measuring
- Add the UI Stats Overlay component to see the engine's numbers on screen.
- From code:
UIView.FrameStats,UiSystem.Stats. - The Unity Profiler shows the markers
DevCore.Input,.Reactivity,.Animation,.Style,.Layout,.Paint,.Mesh,.Drawand.Content. - The inspector's Performance and Layers tabs show the last frame, and why a draw call was needed.
Measure in a player, or in the editor with Release code optimization: debug code is several times slower. Sub-millisecond numbers vary between runs, so run twice before calling something a regression. The mouse over the window adds hit-testing cost.