perf(runtime): Idle state machines stop redoing work that can't change anything (#14508) 30c30acec9 * perf(runtime): skip transition searches on settled state machine layers Every layer of every state machine searched all of its transitions at least twice a frame, even when nothing its conditions read had changed. In an idle 400-row component list with view model conditions, that search was ~40% of the frame. A layer whose search finds nothing now settles: later searches return at once until its state changes or something its conditions read does. Only states whose transitions use view model conditions alone can settle (classified once per state in StateMachine::onAddedClean). State machine inputs, random states, and component, artboard, focus and scripted conditions keep the full search. Settled layers wake on any dirtied state machine data bind, on data context changes (new DataBindContainer::dataContextChanged), and on main view model swaps; none settles while a dirtied bind is still unapplied. Editor builds never settle, since transitions change there after import. Memory: StateMachineLayerInstance stays 80 B (its flags are now bit-fields) and StateMachineInstance 288 B (two bools in existing padding). LayerState grows 104 -> 112 B per state definition. Under TESTING every skipped search is re-run and aborts if it would have transitioned: the full suite checks 1.7M skips with no mismatch. ArtboardFrame benches: a 400-tile list scene -33% idle (-22% with the RiveRenderer), bidirectional_binding_idle -85%, the rest neutral. * perf(runtime): freeze settled layers in states that key nothing A settled layer still advanced and applied its current state every frame. For entry, exit and any states, and for animation states whose animation keys nothing (including the empty animation that stands in for a missing one), that work only moves a clock and applies no values. In an idle 400-row component list it was ~30% of the frame. Such a layer is now frozen: once settled, fully mixed and no longer keeping itself going, its advance returns what it would have without advancing, mixing, applying or searching. The one reader of that clock is exit time, so a state with any enabled exit-time transition never freezes (LayerState::transitionsIgnoreTime, classified in onAddedClean). Whether a state keys anything is checked on the live instance instead: an artboard's last layer only resolves its animations once the next layer is read, which can be after the artboard initializes. No size change: the new LayerState flag sits in existing padding. Idle-frame benches vs settled layers alone: a 400-tile list scene -31% steady-state advance, -22% advance + draw (-47% vs master); bidirectional_binding_idle -24%; the rest neutral. * perf(runtime): add state machine bind clones once their target is set StateMachineInstance added each cloned data bind before pointing it at its per-instance bindable. addDataBind then saw no target, decided a toSource bind couldn't push, and put it on the polling list, so every listener bind in every state machine was re-read on each updateDataBinds pass. The state machine never applies target to source in those passes (it always passes false; listener actions push their value explicitly), so the polling did nothing but keep updateDataBinds from returning early. Adding the clone after its target is set puts such binds on the push path: they are processed only when the listener's bindable actually changes. * perf(runtime): skip the per-frame work of quiet list rows Every row of an ArtboardComponentList advanced its state machine, searched its transitions, updated its data binds, advanced its artboard and reset it several times a frame, even when none of that did anything. With 400 settled, frozen tile rows that bookkeeping was ~95% of the frame. A row whose per-frame work would do nothing is now marked quiet and skipped by every pass (advance, settle, updateDataBinds, reset, and the update pass) until something wakes it. Quiet means: its state machine's layers are all frozen with nothing pending (inputs, binds, reports, focus and semantic queues, draw order), and its artboard has no dirt, bind work, layout or host transform change, and only advancing components that report being idle (AdvancingComponent::quietState). Rows with scripts, nested artboards, joysticks, resettables, text inputs, scroll constraints or converters that advance never go quiet, and neither do rows of lists with duplicate items. Unrealized rows count as quiet: they have no work until realized. Rows are woken through Artboard::wakeIfQuietRow, from the artboard's dirt, data binds, data context, layout and host transform, and from its state machine (markNeedsAdvance, focus and semantic queues, reports, unsettling, reset) and every listener's performChanges, which keyboard and text listeners otherwise use without marking anything. Rows are reset before their instances change (updateList, setRowsForItem, addArtboardAt, clear). The list keeps one bit per row, so passes step over quiet rows 64 at a time. Item view models of number-bound lists are still advanced for every row: skipping that needs a changed flag on view model instances. Memory: Artboard gains a row index in existing padding (size unchanged); each list gains two bit vectors. Under TESTING every skipped row pass still runs after the pass and aborts if the row stopped being quiet without waking or did any work; the suite checks 22,847 of them. Idle-frame benches vs frozen layers: a 400-tile list scene -92% steady-state advance (68 -> 5.6 us), -60% advance + draw; component_list_virtualized_long -16%, its scroll -4%; the rest neutral. * fix(runtime): don't wake a quiet row from a state machine's destructor A state machine's destructor unbinds its data context, which unsettles its layers and so woke its artboard's quiet list row, reading the artboard. When an ArtboardComponentList cleared its pools it destroyed the pooled artboards before the pooled state machines, so that read hit freed memory (caught by ASan in "Artboard lists with child artboard origin offset"). A host can also destroy an artboard before its state machine. The state machine now stops waking its row once its destructor starts, and the list destroys pooled state machines before pooled artboards, as it already does for its realized rows. m_bindsPending, m_anySettled and the new m_destroying are bit-fields sharing the byte after m_needsAdvance, so StateMachineInstance stays 288 B. Co-authored-by: hernan <hernan@rive.app>

Rive's C++ runtime — the lowest-level Rive runtime. Loads .riv files, advances state machines and animations, and draws via the abstract Renderer interface. The built-in GPU renderer (RiveRenderer) has RenderContextImpl backends for Metal, Vulkan, D3D11, D3D12, and OpenGL/WebGL.
Rive's Apple, Android, Flutter, Unity, Unreal, and web runtimes all wrap this library.
Features:
.riv files.Artboard::advance.Renderer interface for hooking up an external vector renderer.xcode-select --install).apt install clang).premake5 on first run. On Windows, install Git for Windows and during setup pick “Use Git and optional Unix tools from the Command Prompt” so that sh.exe ends up on PATH.The build is driven by premake5 wrapped by a helper script:
build/build_rive.sh — macOS / Linux (bash) / Windows (MinGW).build/build_rive.ps1 — a PowerShell convenience wrapper for Windows; it shells out to the bash script, so a bash environment (Git for Windows / MinGW, see Prerequisites) must still be on PATH.The helper self-installs the pinned premake version on first run and dispatches to the right build system for your platform (gmake2 on macOS/Linux, MSBuild on Windows). It must be run from a directory that contains a premake5.lua — typically tests/, which builds the core library, the GPU renderer, and the player sample app.
git clone https://github.com/rive-app/rive-runtime.git cd rive-runtime/tests ../build/build_rive.sh release
git clone https://github.com/rive-app/rive-runtime.git cd rive-runtime\tests ..\build\build_rive.ps1 release
(On Windows, substitute build_rive.ps1 for build_rive.sh.)
build_rive.sh (no args) — debug build for the host.build_rive.sh release clean — clean then build release.build_rive.sh ninja release — use Ninja instead of make/MSBuild.build_rive.sh ios release — cross-compile for iOS.build_rive.sh android release — cross-compile for Android (defaults to arm64).build_rive.sh ninja release wasm — cross-compile for WebAssembly.build_rive.sh --toolset=msc release (Windows) — build with MSVC's cl.exe instead of clang-cl. The build supports both toolchains; the lua warning suppressions cover MSVC too.See the comment header at the top of build/build_rive.sh for the complete flag reference.
Artifacts land in out/<config>/ relative to the directory you built from (typically tests/). The config directory encodes any OS/arch flags you passed:
out/release, out/debug — host build.out/ios_release, out/android_arm64_release, out/wasm_release — cross-compile builds.| Artifact | Description |
|---|---|
librive.a / rive.lib | Core runtime library. |
librive_pls_renderer.a / rive_pls_renderer.lib | GPU renderer. |
player / player.exe | Sample app — loads and renders a .riv file. |
out/<config>/goldens, out/<config>/gms, out/<config>/bench | Test harness binaries (regression, golden-image, benchmarking). |
librive_decoders.a, librive_harfbuzz.a, … | Supporting libraries. |
The runtime's primary form of testing is golden testing — rendering known scenes and diffing the output against checked-in reference images via the goldens and gms test harness binaries (built into out/<config>/goldens and out/<config>/gms). See tests/ for how to run and rebaseline goldens.
Unit tests are secondary and use the Catch2 framework. From the repo root:
cd tests/unit_tests ./test.sh
Unit tests live in tests/unit_tests/runtime/ (core runtime) and tests/unit_tests/renderer/ (renderer). To add a test, create an xxx_test.cpp file in the appropriate directory — the harness picks it up automatically.
rive-runtime uses clang-format.
brew install clang-format.clang-format.exe alongside clang-cl.exe at C:\Program Files\Microsoft Visual Studio\2022\<edition>\VC\Tools\Llvm\x64\bin\. You can also install LLVM standalone from llvm.org or via winget install LLVM.LLVM.apt install clang-format).To run the tests under macOS's built-in leaks tool:
cd tests/unit_tests ./test.sh memory
This wraps the test binary with leaks --atExit, which ships with macOS — no install required. The memory flag is ignored on Linux and Windows.
To inspect generated assembly per cpp file, install the Disassembly Explorer VSCode extension. A disassemble task is provided in .vscode/tasks.json. The underlying gen assembly task invokes clang++ directly, so it needs clang++ on PATH — works out of the box on macOS (Xcode CLI tools) and most Linux distros, but not on a default Windows + VS install (which provides clang-cl.exe, not clang++.exe).
Reach the task from Tasks: Run Task, or bind a key in keybindings.json:
[ { "key": "cmd+d", "command": "workbench.action.tasks.runTask", "args": "disassemble" } ]