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>
23 files changed
tree: 6f54705ddc370cb1a2e186ba1421518cf7bca8b4
  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"
    }
]