feat(editor): stroke position (#14437) fa1cd145d3 * feat(stroke): expose inside/outside stroke position in the editor The Rive renderer learned inside and outside strokes in #14117, through RenderPaint::strokePosition, but nothing in the scene graph set it. This wires it from a new core property to a Position dropdown in the stroke options popout. Core: Stroke.position, a uint8 (key 470) whose values match rive::StrokePosition -- inside 0, center 1, outside 2. It defaults to center and is only exported when it differs, so existing files are unchanged and older runtimes skip it and draw centered. Runtime: Stroke pushes the position alongside cap and join in initRenderPaint, applyTo and update, and positionChanged marks paint dirt. An out of range value draws centered, since the renderer asserts on positions it doesn't know. rive_native: a StrokePosition enum and RenderPaint.strokePosition, serialized as one byte right after blendMode under a new PaintDirt bit. Being a leading field it rides the first chunk of a large gradient, like join and cap. renderer_binding reads it back and ignores unknown values. Editor: the dropdown sits under the cap/join toggles, behind ENABLE_STROKE_POSITION (on in dev, UAT and early access, off in prod). UI only: a stroke authored with a position keeps it with the flag off. Only the Rive renderer draws inside/outside so far; the other backends draw centered. Shift+X remembers the position across a fill round trip. Silvers: a strokePosition op (43) records the value when a paint leaves center, so every existing silver is byte for byte unchanged. The C++ replayer rejects an unknown value, and the golden comparison checks it. Viewer: reads strokePosition, and also applyLayerMask (42), which #14369 added to the C++ writer but not the Dart reader. The instanced mesh ops (#14381) had landed after the enum's closing semicolon, so the reader didn't compile; that is fixed too, and the op parity test passes again. Renderer behavior this exposes, all intended: inside/outside close open paths, so the closing segment is stroked too; joins only exist on the outer side of a turn, so inside strokes show them at concave corners and outside strokes at convex ones; and which side is inside follows the stroke path's non-zero rule, so same-direction holes and opposite-wound contours resolve the way that rule says. * test(renderer): pin stroke position on open, concave and overlapping paths The stroke_position GMs only draw a circle: closed, convex and without corners, so none of the cases that decide how inside and outside strokes look were covered. These three GMs pin the intended behavior. stroke_position_shapes: an open zigzag, a square with one concave notch, and an even-odd square with a hole, for each join (rows) and each position (columns). Open paths come out closed, and joins show only on the outer side of a turn -- concave corners for inside, convex for outside. stroke_position_shapes_feathered: the same matrix with a feather, which takes the renderer's other branch (clip by the path, then draw the thickened stroke). stroke_position_winding: what the editor sends, a non-zero stroke path. A same-direction hole that an even-odd Fill shows, and two squares wound in opposite directions, resolved the way non-zero says. Each cell draws the stroke, the path's fill region in translucent purple over it, and the path itself as a thin red line. Goldens come from this PR's CI run via scripts/rebaseline_pr_goldens.py, since every device renders its own. * test(renderer): goldens for the stroke position GMs, skipped on canvas2d Adds the golden images for stroke_position_shapes, _shapes_feathered and stroke_position_winding from this PR's CI candidates, pulled with scripts/rebaseline_lfs_gms_and_goldens.py. Every device job reported 0 failing, so no existing golden changed; they were only missing these. browserstack_gms gets the pixel6/gl images, as check_browserstack_gm_images.py asks. canvas2d already skips stroke_position and stroke_position_clipped because it has no stroke position support, and draws every column centered. The new GMs join that skip list, so canvas2d and svg get no goldens for them, the same as the existing ones. galaxy_s22/glmsaa is still waiting on a device and comes in a follow-up. * feat(stroke): make stroke position animatable and bindable Stroke.position gets "animates" and "bindable" in its def, regenerated into rive_core and rml. The C++ runtime and editor_native output is unchanged: keyframes (KeyFrameUint) and the number/enum data bind contexts already set it through CoreRegistry::setUint, and positionChanged already pushes it onto the paint. Keys hold. Uints only tween with "interpolates", which an enum has no use for, so a partial mix in a blend state or transition switches the value rather than landing on a neighbor. In animate mode the Position dropdown shows its key button, and the timeline row reads "Position". Binding gets a "Stroke Position" system enum (Inside, Center, Outside), the way text and layout enums have one, so a view model can bind by name instead of a number or a hand-built enum in the right order. It is appended to SystemEnum so every existing enumType keeps its index, and takes reserved ids [83..86, 0]. The editor maps it to the property with the enumToUint converter; the runtime needs nothing, since it never reads enumType. Tests: a keyframe and a registry write (the path binds take) both reach the render paint, and a half mix holds instead of interpolating. In the editor, setting position in animate mode keys it and drives the paint without touching the design value, the paint follows the design value again back in design mode, the dropdown offers a key button, and the system enum's names, maps and reserved ids line up with StrokePosition. * test(renderer): galaxy_s22/glmsaa goldens for the stroke position GMs The last device from this PR's CI: its job was still waiting on a device when the other goldens went in. It reported 0 failing, so only the three new GMs were missing. * fix(stroke): read a negative position as center Stroke.position is a plain int in Dart, so a bound number can arrive negative, and strokePositionFromCore only checked the upper bound: StrokePosition.values[-1] threw from positionChanged instead of drawing centered like any other unknown value. It checks both bounds now. The C++ side reads a uint8, so it never sees a negative value. * fix(stroke): size outside strokes by their full reach paintBoundsOutset estimated every stroke as reaching half its thickness past the path, and shapePaintOutset deliberately skipped stroke position because Stroke::applyTo never set it. It does now. The renderer draws an outside stroke as a centered stroke twice as thick, clipped to the outside, so it reaches the full thickness: twice the estimate, and four times the thickness with a miter join. That outset sizes the offscreen surfaces for layer masks and cache-as-bitmap and drives culling, so an outside stroke in a masked or cached layer had its outer edge cut off, in the runtime and, through the Dart mirror, on the editor stage. An outside stroke now counts its full thickness before the miter and cap factors, on both sides. An inside stroke reaches nothing past the path, but keeps the centered half as a safe over-estimate. The renderer's own call (calculateBoundsOutset) only ever passes centered strokes, so it is unchanged. Both paint_outset tests get the same outside-position row, and each side checks that shapePaintOutset reads a real stroke's position; the C++ one fails without the pass-through. fix(runtime): draw COLRv1 gradients in color emoji instead of solid black (#14552) 55f76f1773 * fix(runtime): draw COLRv1 gradients in color emoji instead of solid black * fix(runtime): keep elliptical radial emoji gradients, refresh foreground stops, clamp stop offsets fix(rive_native): windows hang on exit from GPU teardown in static destructors (#14535) 1f180ed07b * fix(rive_native): windows hang on exit from GPU teardown in static destructors The process ore context and session consumers map were file statics, so their destructors ran at DLL unload under the loader lock, where ~ContextD3D11 releasing the device hangs or crashes in the driver. The hang keeps Rive.exe alive and its single instance mutex blocks relaunch; the crash is RIVE_NATIVE-SN. Both now outlive the process, like DeferredRenderThread and g_contextSession, and a static_assert on each keeps them that way. * fix(rive_native): release GPU and audio statics before exit on windows instead of leaking them Co-authored-by: Guido Rosso <guido@rive.app> Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com> Co-authored-by: Luigi Rosso <luigi.rosso@gmail.com> 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" } ]