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>
25 files changed
tree: 552ec3ed8b9873cb5b1bbcaa0183de5555c79ffa
  1. .github/
  2. .vscode/
  3. build/
  4. cg_renderer/
  5. decoders/
  6. dependencies/
  7. dev/
  8. include/
  9. renderer/
  10. rivinfo/
  11. scripting/
  12. skia/
  13. src/
  14. tests/
  15. utils/
  16. .dockerignore
  17. .gitattributes
  18. .gitignore
  19. .lua-format
  20. .rive_head
  21. .version
  22. Dockerfile
  23. Doxyfile
  24. dummy.cpp
  25. LICENSE
  26. premake5_v2.lua
  27. README.md
  28. runtime.natvis
README.md

Build Status Discord badge Twitter handle

rive-runtime

Rive hero image

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:

  • Loading artboards and their contents from .riv files.
  • Querying state machines from artboards.
  • Mutating the artboard hierarchy (the same mechanism used by state machines) and efficiently solving those changes via Artboard::advance.
  • A state-of-the-art vector renderer for Metal, Vulkan, D3D12, D3D11, and OpenGL/WebGL.
  • An abstract Renderer interface for hooking up an external vector renderer.

Prerequisites

  • A C++17 toolchain:
    • macOS: clang from Xcode Command Line Tools (xcode-select --install).
    • Linux: clang from your distro (e.g. apt install clang).
    • Windows: Visual Studio 2022 with the C++ Clang Compiler for Windows and MSBuild support for LLVM (clang-cl) toolset individual components.
  • git — the build script clones a pinned 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.
  • Platform SDK for the renderer you're targeting:
    • macOS / iOS: Xcode.
    • Windows: Windows SDK (for D3D).
    • Linux: a Vulkan or OpenGL development environment.
    • Vulkan: the Vulkan SDK.

Build

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.

macOS / Linux

git clone https://github.com/rive-app/rive-runtime.git
cd rive-runtime/tests
../build/build_rive.sh release

Windows

git clone https://github.com/rive-app/rive-runtime.git
cd rive-runtime\tests
..\build\build_rive.ps1 release

Common variants

(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.

Build outputs

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.
ArtifactDescription
librive.a / rive.libCore runtime library.
librive_pls_renderer.a / rive_pls_renderer.libGPU renderer.
player / player.exeSample app — loads and renders a .riv file.
out/<config>/goldens, out/<config>/gms, out/<config>/benchTest harness binaries (regression, golden-image, benchmarking).
librive_decoders.a, librive_harfbuzz.a, …Supporting libraries.

Testing

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.

Code formatting

rive-runtime uses clang-format.

  • macOS: brew install clang-format.
  • Windows: already installed if you have the prerequisites — VS 2022's C++ Clang Compiler component ships 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.
  • Linux: install via your distro's package manager (e.g. apt install clang-format).

Memory checks (macOS only)

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.

Disassembly explorer (macOS / Linux)

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"
    }
]