CalmUI
Performance

Measure UI Performance

Record a View in Studio or Unreal before deciding what to optimize.

Record your View

  1. Open Tests in Studio and choose Profiler.
  2. Select Studio Preview or Unreal. Use a connected Unreal target to measure the running game.
  3. Open the View and reproduce the interaction you want to measure.
  4. Click Record 5s.
  5. Compare another recording after your change, using the same target, resolution, and interaction.

A value shown as n/a was not supplied by that target. It is not a zero and is not an estimated measurement. Studio Preview and Unreal measure different renderers, so do not compare their numbers as if they were the same test.

The Profiler's Reload cost section and footer pill show how often an update falls back to a full document reload instead of patching in place, with the exact reason when it just happened. See Reload cost.

Measure the first cold opening separately from a repeated View switch. Studio reuses unchanged compiled component scripts, and Preview avoids reloading or laying out an unchanged document. A cold compiler result and an interaction-time layout result answer different performance questions. Repeated generated stylesheets reuse only their immutable parsed rule trees; selector matching, computed styles, and media-query state are still isolated per document.

For a critical first menu, load the package before opening the View. The Unreal runtime prewarms reusable compiled presentation state during the load; initial Store writes and action listeners can also complete before the entrance. Still measure the first visible frame in the packaged game because host assets, scene composition, and the target GPU remain outside that preparation.

For a real Unreal interaction, use a Development build and reproduce it through the normal mouse, keyboard, or gamepad input path. The Unreal log records the input queue, dispatch, layout, paint-build, submit, first-frame-submit, and animation-settled timings for the input sequence. Correlate the reported CalmUI.InputFrame.<sequence> event in Unreal Insights to include render-thread, GPU, and presentation timing; the game-thread submit timestamp by itself is not proof that the frame reached the display.

A View declared with KeepAlive may reuse its prepared presentation when the same package and rendered HTML/CSS are still valid. A View declared with Destroy is intentionally a cold open, so record and budget it separately.

Retained screen budget

A hidden KeepAlive screen keeps its component View, script context, laid-out document and cached paint frame so that reopening it is instant. That is not free: measured on a project where 25 of 34 screens are KeepAlive, each screen a player had opened held roughly 12 MB for the rest of the session, about 307 MB across 21 screens, including thousands of paint commands and layout elements that are never drawn.

Project Settings -> CalmUI -> Screens -> Retained keep-alive screen budget caps how many hidden screens stay resident. The default is 8. Past the cap, the least recently shown screen is evicted: it is not broken, only cold, and reopens exactly like a Destroy screen: it loses the script-local state KeepAlive would have kept, so raise the budget if a screen further back in the player's history must remember its selected tab or scroll position. 0 disables eviction and restores unbounded retention.

Retention never costs frame time on screens the player is not looking at. A store patch is applied only to the screens that actually bind one of the changed fields, so a hidden screen that reads none of them does no work at all: the per-frame cost of a store flush is a function of what changed, not of how many screens have ever been opened. ScriptCounters::StorePatchesSkipped counts the patches a context ignored for exactly this reason.

Script event counters and memory diagnostics

Native hosts use ICalmUiScriptContext::EventCounters() for frame scheduling and per-frame event statistics. It reads the same timer, watch and job counters as Counters() without walking the JavaScript heap. Its QuickJsMemoryBytes field contains the last diagnostic sample, initially zero.

Use Counters() when an explicit memory diagnostic is needed. It may traverse the entire shared runtime heap. Ordinary job processing does not collect memory statistics. Custom script-context implementations must implement EventCounters(); rebuild native consumers against the matching SDK headers and libraries.

In Unreal, periodic heap logging is disabled by default. Set CalmUi.Debug.ScriptHeap 1 to opt in to one measurement per minute, and set it back to 0 when finished. The measurement walks the shared heap and may cause a diagnostic hitch; it does not change memory limits, allocation or garbage collection behavior.

Rebuild only what changed, once per frame

A View's template reads its script state through a render observer: every reactive state() or computed() a binding touched is remembered per component instance, exactly like a render effect. When one of them changes, that instance's ICalmUiScriptContext::InstanceStateVersion() and the context's RenderStateVersion() move; nothing else does. So:

  • ApplyStorePatch() returns true only when the patch reached state a template renders, and ComponentView::NeedsSettle() says whether the View needs a compose at all. A host marks only those screens dirty after a Store flush instead of recomposing every visible screen.
  • A handler, timer, watch or promise job that writes nothing a template reads (a hover-intent timer being armed, an action dispatched to the game) causes no rebuild at all.
  • With ComponentView::SetDeferredEventSettle(true), event handlers still run immediately and in order, and their emissions still reach parent handlers immediately, but the rebuild waits for the host's next settle point, so the pointerleave + pointerenter of one crossed tile, or any number of events in one frame, cost one rebuild.
  • A rebuild reads each script binding at most once, and not at all for an instance whose state version did not move. A component instance that takes no slot content from its parent and whose props and subtree state are unchanged replays its previous markup and bookkeeping instead of executing its template (ComponentRuntimeCounters::ComponentOutputsReused). The package stylesheet is built once per package, and ComponentViewResult::HtmlRevision / CssRevision let a host skip comparing unchanged markup.

NeedsSettle() is also true while a timer or watch emission waits to reach its listeners. State that is not reactive (a plain object or array changed in place, a module-level variable) is not observed; keep rendered data in state()/computed(). On the Calmaria Trade screen (118 component instances, 71 KB HTML, 465 KB CSS; Release, CalmUiIntegrationTests --trade-profile) an unchanged recompose went from 8.3 ms to 1.5 ms, a hover frame from 26 ms (two full rebuilds) to about 2 ms, and a published 32-row grid from 70 ms to about 22 ms. Set CALMUI_COMPONENT_OUTPUT_CACHE=0 to execute every template again for A/B measurement.

Animated layout

A width or height keyframe (or any script timer that drives one, such as a drawer opening) is a layout-affecting animation, not a paint-only one: every frame it changes, Core re-runs layout for the affected box and Unreal applies the new size in the same render callback (SettleAnimatedLayout), so a mounting drawer never paints once at its natural size before catching up to the animation.

Most of that cost is contained rather than eliminated. When the only change since the last successful layout is a size override on an out-of-flow node (the common case for a drawer, popover, or toast), the layout backend re-renders and re-places just that node's subtree through a dedicated LiteHTML entry point instead of the whole document; measured on a HUD-scale reference scene (286 addressable elements, 2844 paint commands, 1920x1080) this brought the median animated frame from 8.14 ms to 1.68 ms. Any change outside that shape (a document reload, a viewport or safe-area change, a scroll, or an override with no out-of-flow ancestor) still takes the full render.

A box in normal flow whose own geometry cannot depend on its content is the same kind of boundary: a block or flex box with a definite width (a length or a percentage), a definite length height and overflow other than visible, that is not baseline-aligned, and that is itself positioned when it contains out-of-flow descendants (a scrolled item grid, a tray of staged items). A text, attribute, restyle or mount change inside it re-lays out that box alone, in place, from the containing block its last render used; if the box comes out of that with different geometry the full render runs instead. Every step is held to a fresh layout of the same markup in CalmUiIntegrationTests. Set CALMUI_LAYOUT_CONTAINED=0 to disable this root for A/B measurement.

The first frame a text style is laid out also rasterizes that style's glyphs for digits, signs and Polish letters (TextSystem::kDynamicGlyphCharacters, contextual digit forms included), while the glyph atlas still has a free page, so a counter showing a new digit later draws without rasterizing. CALMUI_GLYPH_PREWARM=0 disables it. Set CALMUI_LAYOUT_SUBTREE=0 to force the full-document path on every frame and compare against the fast path; use CALMUI_LAYOUT_MEMO=0 to additionally disable LiteHTML's own scalar-measurement memoization. Both are process environment variables read once at startup, meant for A/B measurement, not for shipping configuration.

Store updates that only change text

A store field bound into rendered text (a HUD counter, a wave timer) makes the host reload the document with new HTML. When that HTML differs from the loaded document only in text-node contents, the element sequence, every tag and every attribute byte-identical, which is what the component compiler emits for a value-only change, Core patches the loaded document in place instead of re-parsing and restyling it. The changed nodes are then marked dirty the same way an animated size override is: text inside an out-of-flow island re-renders and re-places that island alone (DocumentRenderCount stays 0), text in normal flow still takes one full document render but no parse and no style pass.

Anything else falls back to the ordinary full load: a structure or CSS change, and also a text change that adds or removes a word, because that changes how LiteHTML splits the run into text nodes. LayoutResult::IncrementalPatchCount counts the patches a document has applied, so a host can log whether its updates are taking the cheap path. Measured on a HUD-scale reference scene (412 elements, 1226 paint commands, 1920x1080), a text-only update went from 4.84 ms reload + 4.01 ms layout to 0.05 ms reload + 2.41 ms layout. Set CALMUI_DOC_PATCH=0 to force every reload back through the full parse/style/render path for A/B measurement.

Store updates that change attribute values

The same store tick usually moves attribute values too: a progress bar re-renders as style="width:57%" where it was style="width:42%", a meter follows aria-valuenow, a state chip swaps a class. Core patches those in place as well. Attributes are matched by name: a tag may change attribute values, gain an attribute or lose one (the compiler omits a bound :disabled, :aria-hidden or empty :class whose value is false, so a toggle looks like an attribute appearing or vanishing) as long as the tag name and the identity attributes are byte-identical; the new values are written into the loaded elements, removed attributes are erased from them, and the document stylesheets are re-applied to the changed element's subtree only. Its parent's subtree is restyled only when a selector can observe the change on a sibling: the changed class or attribute appears in a compound directly left of + or ~, or inside :nth-child(An+B of S); a class toggled on one grid slot no longer restyles every other slot. CALMUI_RESTYLE_SIBLINGS=1 restores the parent-subtree restyle for every selector-visible change (A/B). Values the HTML parser rewrites (an invalid UTF-8 byte or a control character becomes U+FFFD in the tree) are compared through the same rewrite, so one mis-encoded Store string no longer disables patching for the whole document. Element metadata (class lists, selector attributes, accessible and range values) is collected again, so interactive styling and the Unreal element cache re-key off the new values.

These attributes are never patched and always take the full load: id, data-cui-node, data-cui-scope, data-cui-virtual-index and data-testid (node identity, style scope and the virtualization window key off them), src, srcset and href (a resource load and an intrinsic size, not a restyle), and any value containing -- (a custom property moves the retained animation property set). A document whose markup carries its own <style>, <link>, <script> or <base>, or whose CSS uses :has() or CSS counters, never takes the attribute path at all. LayoutResult::AttributePatchCount counts the patched elements additively next to IncrementalPatchCount, and CALMUI_DOC_PATCH=0 disables both patches. Measured on a HUD-scale reference scene (150 elements, 48 progress bars, 1920x1080), a progress-fill update went from 2.06 ms reload + 0.59 ms layout to 0.27 ms reload + 0.44 ms layout.

Store updates that mount or unmount a subtree

A ui:if drawer opening changes the element sequence itself, which neither patch above accepts. Core recognises the case where the only structural difference is one contiguous range of complete elements inserted under, or removed from, a single parent, and splices that range into the loaded document: the new markup is parsed as a fragment through the same parser a full load uses, the parent's subtree is restyled, and the parent's render subtree is rebuilt through the same render-item construction a full load runs, for that one parent, without a parse and without a whole-document cascade. The two markup versions are aligned by element identity: the tag name plus the compiler's data-cui-node and the other identity attributes above (for markup without data-cui-node, the ordered attribute names too), so the same update may carry the mount together with the text and attribute patches of everything around it, including a bound :disabled or :aria-* appearing or disappearing elsewhere. Compiler output is indented, so every element boundary is a whitespace text node and the text in front of a mounted subtree is not the text in front of its parent's close tag; that boundary text travels with the splice rather than refusing it. The change is then handed to the dirty-subtree relayout, so a mount inside an out-of-flow island issues no document render at all and a mount in normal flow issues exactly one, and neither re-parses the document. One update may move more than one subtree: two drawers whose close timers land in the same tick, or a toast mounting while a drawer unmounts, even when the token counts happen to match: each range is spliced on its own, over markup that applies exactly that range, so every step keeps the same verification. Anything else takes the ordinary full load: a reordering (the same identities arriving somewhere else, which must keep their elements rather than be rebuilt from markup), a changed node identity, inserted markup carrying its own <style>, <script> or a table, and a stylesheet using :empty. LayoutResult::StructuralPatchCount counts these updates additively next to IncrementalPatchCount and AttributePatchCount, and CALMUI_DOC_PATCH=0 disables them with the other patches. Measured on a Calmaria-scale reference scene (307 elements, 33 KB HTML, 249 KB CSS over ~4 300 rules, a 17-element drawer with flex rows, progress fills and icons), mounting the drawer went from 22.5 ms to 5.4 ms and unmounting it from 20.7 ms to 3.2 ms. On the same scene in compiler-shaped markup (a doctype, html/head/body, a scoped <main>, newline-indented children and inline animation-delay styles: 241 elements, 22.6 KB HTML, 219 KB CSS), a mount went from 11.6 ms to 4.1 ms and an unmount from 11.1 ms to 2.8 ms.

LayoutResult::LastPatchRefusal names the guard that refused the most recent update, so a host can see why it took the full load instead of guessing: structure: elements outside the range are not the same elements, attribute: identity changed [data-cui-node on <div id=hud data-testid=hud-root>] (attribute refusals name the element), text: token count differs, or document: not a markup-only reload [css text changed], which names the condition that disqualified the reload. It is empty while the last update was patched in place, and is diagnostic only. Studio surfaces the same reason as the Profiler's full-reload banner and footer pill; see Reload cost for the per-reason authoring fix.

The cost of a full load

A load that cannot be patched (the first load of a screen, a CSS reload, or any update the rules above refuse) used to test every selector of every stylesheet against every element, which is linear in rule count times element count and is the dominant cost of a design-token stylesheet. Core indexes each loaded stylesheet once by the rightmost compound of its selectors and offers every element only the selectors whose rightmost compound that element carries: its tag, its classes, its id, and the attribute names and exact attribute values it holds, plus the universal and pseudo-only rules that are candidates everywhere. The offered list is a strict superset of what can match and keeps the sheet's own order, so the cascade, specificity and generated ::before/::after boxes are identical. On the Calmaria-scale scene above a fresh load went from 29.1 ms to 20.6 ms; small documents and small stylesheets are unchanged. Set CALMUI_STYLE_INDEX=0 to apply the stylesheets selector by selector again and compare. The patch maps a later update needs are built by that same load, so the first patched update after a screen opens costs what every later one costs (0.43 ms against 0.37 ms on the same scene) instead of several milliseconds more.

To read what a given frame actually cost, enable Verbose logging for LogCalmUI in Unreal and look for:

  • R4 layout prepared (animated): ... layout=<ms> prepare=<ms> litehtml=<ms> render=<ms> draw=<ms> collect=<ms> renders=<n>: one line per layout pass. layout/prepare are Unreal-side timings around the call; litehtml is Core's own LayoutMilliseconds for that pass, broken down into render (the litehtml box pass), draw (pre-draw preparation plus PaintFrame recording) and collect (the element/interaction/ownership rebuild) when the staged LayoutResult carries those fields; renders is DocumentRenderCount.
  • R4 prepare phases: nodes=<n> commands=<n> resolves=<n> frame=<ms> styles=<ms> split=<ms> total=<ms>: the Unreal-side frame-copy, interactive style resolve, and paint-command split costs around that layout pass. resolves is the count of interactive-style selector resolutions the per-node style-resolve cache performed; on an animated-size relayout that changes no pseudo-state this should read 0.

Compare a recording with the kill switches on and off, and compare resolves before and after a change that might have widened what the per-node style cache invalidates.

Measured examples

The figures below are individual recordings from one project (Calmaria), kept here with the exact build, scene, viewport, date, and measurement method so they can be reproduced or superseded rather than taken on faith. They are not a benchmark suite and do not generalize to another project's scenes or hardware.

Studio save-to-preview with a warm script cache

4.3 s → 140 ms rebuild after a save, for a View with eight scripted imports and nothing changed since the last build. Compiled component scripts are cached under the system temporary directory, keyed by script text, source path, configuration, the generated Store/Action contract and the pinned JavaScript toolchain, and reused across builds instead of recompiled; an isolated cold build of Calmaria JoinGame also fell from about 2.87 s to 1.57 s, with byte-identical cold and warm packages. Recorded 2026-09-12.

Static HUD idle cost

0.6 ms game thread. Calmaria GameplayHud, Development editor build, Unreal Editor PIE, viewport 1280x722, recorded 2026-09-14. Measured with calmui dev inspect --json against the idle HUD (no drawer open, no counter changing).

Animated drawer relayout

1.15 ms median frame cost, 0 document renders. Same project, build, and session as above (2026-09-14). Measured with calmui dev inspect --json while the drawer's width/height keyframe animation ran; see Animated layout for why a layout-affecting animation still avoids a document render when it stays inside the dirty-subtree fast path.

Store-driven counter patches

0 document renders per HUD counter tick. Same project, build, and session as above (2026-09-14). Measured against the R4AnimatedDrawer fixture and the IncrementalPatchTests counters (LayoutResult::IncrementalPatchCount); see Store updates that only change text.

Stylesheet warm-up

155 ms → 0.27 ms first-resolve warm-up on a 175 KB stylesheet. Calmaria, recorded 2026-09-14. 155 ms is the cost of the first selector resolve against that stylesheet before the per-stylesheet selector index is built; 0.27 ms is the cost of the same resolve once the index exists. Measured with calmui dev inspect --json; see The cost of a full load.

Structural drawer mount

22.5 ms → 5.4 ms to mount a 17-element drawer (flex rows, progress fills and icons) into a 307-element HUD (33 KB HTML, 249 KB CSS over ~4,300 rules); unmounting the same drawer went from 20.7 ms to 3.2 ms. Native, measured with the CalmUiLayoutTests "Structural drawer mount benchmark" on a Calmaria-scale reference scene; see Store updates that mount or unmount a subtree.

UI GPU cost with per-primitive scissoring

17.7 ms → 4.2 ms. Calmaria MainMenu, Development editor build, Unreal Editor PIE (viewport not claimed as 1080p), recorded 2026-09-12. Measured with Unreal's csvprofile around the UI GPU pass, comparing per-primitive scissoring against no scissoring on the same scene.

Changes to investigate

  • Prefer color, opacity, or a transform when changing a button's appearance.
  • Keep repeated items keyed by their real IDs.
  • Use fixed-height lists for large collections when their restrictions fit your design.
  • Keep component scripts short; move gameplay work to Unreal.
  • Inspect texture memory when testing large images or many icon variants.
  • Bound icon-sized Unreal host images with maximumDecodedExtent so the runtime can choose an appropriate cooked mip and retain a smaller RGBA resource.
  • Avoid stacking large full-screen backdrop blurs. Each blur needs additional render-target work on the real renderer.

Record the release candidate

Use the same resolution, package, Store data, input route, and camera scene for the before-and-after recording. Test the busiest overlay or modal, including its opening and closing animation. Then repeat the interaction in a packaged Development or Shipping game on the target machine.

A faster Studio recording does not establish Unreal performance. A faster local Unreal recording does not establish performance on another platform.

On this page