The official Rive runtime parses the file and runs the scene, then hands
drawing to an abstract render interface. This project implements that entire
interface from scratch — a second, independent renderer for the same .riv.
Here is the exact contract, how we satisfied each piece, what we reuse unchanged, and
the binary & feather internals where ours meets the original.
Companion to RIV — Project Structure.
Rive's runtime never draws pixels itself. It builds a scene of Core objects and emits draw commands against a set of pure-virtual interfaces. Anyone who implements those interfaces gets a working renderer. Rive ships one (the GPU "Rive Renderer", PLS). We wrote a second.
rive::RiveRenderer over rive::gpu::RenderContextGLImpl
— the Pixel-Local-Storage GPU pipeline. The RenderContext is
the Factory. Wired by gl_backend.cpp; flushed per frame.
SimpleShapeFactory + GlStencilShapeRenderer + the
SimpleRender* objects: a hand-written GL pipeline (MSAA FBO,
stencil-then-cover, analytic erf feather, in-shader gradients).
Both are real rive::Factory / rive::Renderer
implementations. The same .riv is imported twice — once with each factory —
so the app can render it through either pipeline and compare them live.
Every interface below lives in backend/src/render/. Each card states
the Contract — what the runtime requires you to fill in — and
Ours — how we satisfied it. The recurring idea: keep
geometry and paint as plain CPU data at build time, then do all GPU work lazily at
draw time. We reimplement rendering only — never parsing, the Core model,
or animation.
Contract Hand back a ref-counted (rcp) render
resource for each make* the runtime issues while building a scene — a path,
an empty path, a paint, a linear/radial gradient shader, or a vertex/index buffer — and
decode in-band image bytes to a RenderImage. The runtime owns lifetimes
through the rcp; you choose the concrete representation. decodeImage
may return null on failure.
Ours The object factory the runtime calls during File::import
to materialise every render resource for the "ours" route. Each make* wraps the
matching SimpleRender* in an rcp via make_rcp, keeping
gradients as CPU stop data rather than baking them. decodeImage runs Rive's own
bundled Bitmap decoder, forces premultiplied RGBA8 (so GL_LINEAR is
correct at transparent edges), then uploads straight to a GL texture — a GL context is
already current during import.
SimpleShapeFactory::decodeImage — reuse Rive's decoder, then upload to GL
rive::rcp<rive::RenderImage> decodeImage(rive::Span<const uint8_t> span) override
{
// Decode PNG/JPEG/WebP with Rive's bundled Bitmap decoder, force PREMULTIPLIED
// RGBA8 (like the official runtime), then wrap a GL texture in SimpleRenderImage.
auto bmp = Bitmap::decode(span.data(), span.size());
if (!bmp) return nullptr;
if (bmp->pixelFormat() != Bitmap::PixelFormat::RGBAPremul)
bmp->pixelFormat(Bitmap::PixelFormat::RGBAPremul);
return rive::make_rcp<SimpleRenderImage>(bmp->bytes(), bmp->width(), bmp->height());
}
Contract Maintain a transform / clip / opacity state stack
(save / restore / transform /
modulateOpacity) and execute the draw commands the runtime issues against it —
drawPath(path, paint), clipPath(path), drawImage,
drawImageMesh — producing pixels however you like. The runtime hands you its
own RenderPath/RenderPaint pointers; you downcast to read them.
Ours The draw-command sink the runtime issues into. It owns a transform / opacity /
clip State stack (save/restore/transform/
modulateOpacity push and mutate the back entry). drawPath downcasts
the path & paint to our concretes, flattens the RawPath into device-space
contours under the current transform, then dispatches to a GlShapePipeline
(solid / gradient / feathered fills, tessellated strokes) that does the actual GPU
stencil-then-cover into an MSAA FBO. clipPath pushes a flattened device-space
clip layer (non-convex stencil intersection). It also carries side-channel aux modes
(id / depth / velocity / bounds / contour) that re-purpose the same draws for the NPR
post-process — something the official renderer can't feed.
The save/restore/transform/opacity State-stack that backs the adapter
void save() override { m_stack.push_back(m_stack.back()); }
void restore() override { if (m_stack.size() > 1) m_stack.pop_back(); }
void transform(const rive::Mat2D& t) override
{
m_stack.back().transform = m_stack.back().transform * t;
}
void modulateOpacity(float o) override { m_stack.back().opacity *= o; }
Contract Accumulate path geometry: the verb stream
(moveTo / lineTo / cubicTo / close), a
fill rule, and combiners that append other paths or a raw path under a transform
(addRenderPath, addRenderPathBackwards, addRawPath);
rewind clears it. The runtime never asks you to rasterize — only to hold the
geometry until it issues a drawPath.
Ours Geometry is stored purely on the CPU as a rive::RawPath + a
FillRule — there is no tessellation at build time. The verb overrides
(moveTo/lineTo/cubicTo/close) and the
add*Path combiners forward straight into the held RawPath, baking
the supplied Mat2D via RawPath::addPath(&transform) and
downcasting to recover the concrete when merging two paths. The renderer reads
rawPath()/fillRuleValue() and flattens to device-space polylines
only at draw time.
Verb & combiner overrides forwarding into the held RawPath
void moveTo(float x, float y) override { m_path.moveTo(x, y); }
void lineTo(float x, float y) override { m_path.lineTo(x, y); }
void addRenderPath(const rive::RenderPath* p, const rive::Mat2D& m) override
{
if (auto* s = dynamic_cast<const SimpleRenderPath*>(p))
m_path.addPath(s->rawPath(), &m); // bake the transform into our RawPath
}
Contract Capture the paint state the runtime sets: style
(fill / stroke), color, stroke thickness / join / cap, feather strength, blend mode, and an
optional gradient shader. invalidateStroke signals stroke geometry
should be rebuilt. Pure data — the interface never draws.
Ours A plain CPU value-object: every setter stashes its argument into a member
(style, color, thickness, join, cap, feather, blend mode, optional shader);
invalidateStroke is a no-op because no stroke geometry is cached here. Accessors
expose those to the renderer — colorValue() even folds a bound gradient down to a
representative average so a solid-color path always has something usable, while
shaderValue() downcasts back to SimpleRenderShader for full gradient
reconstruction.
Contract Be an opaque gradient handle. The runtime constructs you
via Factory::makeLinearGradient / makeRadialGradient (endpoints or
centre + radius, plus parallel color and stop-position arrays) and hands you back through
RenderPaint::shader. The base declares no virtuals — you decide how to
store and later sample the gradient.
Ours RenderShader is an opaque marker base in Rive — no virtuals to
override — so the gradient lives as data: a kind (linear/radial), start/end points, a radius,
and a sorted, clamped vector of (position, color) stops captured from the
factory's arrays. The renderer either feeds those stops to the GL gradient program in object
space (with the device→object inverse, so radial gradients stay elliptical under rotation), or
— for the CPU/ImGui path — evaluates a screen point against the stops by bracketing and
colorLerp.
Gradient kept as stop data, evaluated by bracketing + colorLerp
rive::ColorInt sampleAt(float position) const
{
if (position <= m_stops.front().position) return m_stops.front().color;
if (position >= m_stops.back().position) return m_stops.back().color;
for (size_t i = 1; i < m_stops.size(); ++i)
if (position <= m_stops[i].position) {
float t = (position - m_stops[i-1].position) /
(m_stops[i].position - m_stops[i-1].position);
return rive::colorLerp(m_stops[i-1].color, m_stops[i].color, t);
}
return m_stops.back().color;
}
Contract Back a typed buffer (vertex / index, sized in bytes from
the base ctor) for image meshes. The runtime writes into it through the map protocol:
onMap returns a writable pointer, onUnmap finalises the write
(upload to GPU if you need to). You keep the contents readable at draw time.
Ours Vertex / UV / index buffers for image meshes are just a heap
std::vector<uint8_t> sized to the requested byte count. The runtime fills it
through the map/unmap protocol — onMap hands back the raw pointer,
onUnmap is a no-op (no GPU upload), so the bytes simply live in CPU memory. At
draw time drawImageMesh reads bytes()/byteSize() to
expand the mesh into device-space triangles.
Contract Wrap a decoded bitmap as a drawable image and report its
width / height (base members). The runtime hands you premultiplied
RGBA8 from Factory::decodeImage; you own the GPU upload and the texture's
lifetime, and expose whatever handle your Renderer needs to bind it.
Ours An image is one GL texture. The ctor receives tightly-packed premultiplied
RGBA8, sets the base RenderImage width/height members, then
glGenTextures + glTexImage2D uploads it as GL_RGBA8 with
linear filtering and clamp-to-edge wrap. The destructor frees the texture;
textureId() exposes the handle for the renderer's textured-quad / mesh draws.
Premultiplied RGBA8 → a GL_RGBA8 texture (linear, clamp-to-edge)
SimpleRenderImage(const uint8_t* rgba, int w, int h)
{
m_Width = w; m_Height = h; // base rive::RenderImage members
glGenTextures(1, &m_tex);
glBindTexture(GL_TEXTURE_2D, m_tex);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA,
GL_UNSIGNED_BYTE, rgba);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
}
▸ Scope note. These seven are the high-level
render interfaces. For headless GPU-free tests, native_backend.cpp additionally
subclasses three lower-level rive::gpu classes — BufferRingNull,
RenderContextNullImpl, RenderTargetNull — to stand in for the render
context without a real GL device.
The honest split. Almost everything above the rasterizer is the official runtime, reused unchanged; we own the rendering, the binary export, and the experimental container. cyan = official (reused), violet = ours (from scratch).
| capability | official (rive-runtime) | ours (from scratch) |
|---|---|---|
| .riv parse / import | rive::File::import — header + object-stream decode into live Core objects | io/riv_binary_reader inspectRiv() — independent structural walk only |
| Core object model | Core / Component / Drawable / Path / Shape + generated reflection; CoreRegistry | — (100% reused via artboard->objects()) |
| Animation + state machines | LinearAnimationInstance, StateMachineInstance, Scene::advanceAndApply | — (100% reused) |
| Layout (Yoga) | vendored Yoga + src/layout flexbox solve | — |
| Text shaping | text_engine + HarfBuzz + SheenBidi (glyphs, BiDi, line-break) | — (consumes the resulting RenderPaths) |
| Render backend | PLS RenderContext + RiveRenderer (gl_backend.cpp → FramebufferRenderTargetGL) | render/ GlShapePipeline — MSAA FBO, stencil-then-cover, + NPR aux passes |
| Factory + render objects | the GL RenderContext is the Factory; RiveRenderPath/Paint/Image | SimpleShapeFactory + SimpleRender* (the seam in §02) |
| Feathered fills | feather.cpp + rive_renderer Gaussian-integral LUT; fillRule=clockwise gate | 3 modes: gaussian blur · atlas blur · analytic JFA-SDF + erf |
| Image decode | decoders/ Bitmap::decode → libpng / libjpeg / libwebp | — (only the premultiply + GL upload wrapper is ours) |
| .riv binary export | — (runtime is import + render only) | io/riv_binary_writer writeSplatRiv() — a full v7.0 encoder |
| experimental .rivx | — (no such format) | editor writeRivx/readRivx — splat fields, spline curves, NPR look |
Three places where our code calls directly into rive-runtime instead of reinventing it — the parser, the image decoder, and the binary primitives our writer mirrors.
Takes the raw .riv bytes + a
Factory, walks the RuntimeHeader + property table-of-contents + object
stream (via BinaryReader), and deserialises into the live Core model. Because the
resources come from the supplied Factory, the same parse populates either the GPU
objects or our SimpleRender* objects. Our load path (scene_loader.cpp)
just calls it.
// backend/src/scene/scene_loader.cpp — the canonical load
rive::ImportResult importResult = rive::ImportResult::malformed;
auto file = rive::File::import({bytes.data(), bytes.size()},
app.backend->factory(), // official OR SimpleShapeFactory
&importResult);
auto artboard = file->artboardDefault();
Our io/riv_binary_reader walks the same bytes for diagnostics, but
its own header says it plainly: "actual object decode still defers to rive::File::import".
We prove we understand the format; the runtime still owns the decode.
decoders/include/rive/decoders/bitmap_decoder.hpp
// Called from SimpleShapeFactory::decodeImage. // libpng / libjpeg / libwebp live in rive-runtime; // we add only premultiply + the GL upload. auto bmp = Bitmap::decode(span.data(), span.size()); bmp->pixelFormat(Bitmap::PixelFormat::RGBAPremul);
external/rive-runtime/src/core/binary_writer.cpp
void BinaryWriter::writeVarUint(uint64_t value) {
uint8_t buf[16]; int i = 0;
do { uint8_t b = value & 0x7f; value >>= 7;
if (value) b |= 0x80; // more bytes follow
buf[i++] = b; } while (value);
m_Stream->write(buf, i);
}
.riv binary round-tripWhat turns scene data into bytes, and what reads bytes back. Our writer is
the one subsystem with no official counterpart — and it is validated by feeding its output
straight back through the official File::import.
Value width comes from the key's ToC field type:
uint → varuint · double →
f32 little-endian · color → 4-byte 0xAARRGGBB ·
string → varuint length + bytes. Object order: Backboard →
Artboard → per shape: Shape → Ellipse/Rectangle → Fill(+fillRule) →
SolidColor/Gradient(+stops) → Feather → optional Stroke. parentId is an
artboard-local index, not a global one.
io/riv_binary_writer.cpp · writeSplatRiv()
struct ByteWriter {
std::vector<uint8_t> bytes;
void varuint(uint64_t v){ // LEB128
do { uint8_t b=v&0x7F; v>>=7;
if(v) b|=0x80; bytes.push_back(b);} while(v);
}
void f32(float f){ uint32_t u; memcpy(&u,&f,4); u32le(u);}
};
// emit one Shape: typeKey, (key,value) pairs, 0 terminator
w.varuint(TK_SHAPE);
w.varuint(PK_PARENT); w.varuint(0); // parent = Artboard
w.varuint(PK_X); w.f32(sp.x);
w.varuint(PK_Y); w.f32(sp.y);
w.varuint(0); // end of object
external/.../src/file.cpp · readRuntimeObject()
static Core* readRuntimeObject(BinaryReader& reader,
const RuntimeHeader& header) {
auto key = reader.readVarUintAs<int>();
auto object = CoreRegistry::makeCoreInstance(key);
while (true) {
auto propertyKey = reader.readVarUintAs<uint16_t>();
if (propertyKey == 0) break; // terminator
if (!object->deserialize(propertyKey, reader)) {
int id = CoreRegistry::propertyFieldId(propertyKey);
if (id == -1) id = header.propertyFieldId(propertyKey);
/* decode by CoreUint/String/Double/ColorType */
}
}
return object;
}
Our inspectRiv() reader is the structural
mirror of that loop — read a typeKey, then (propKey, value) pairs to the
0 terminator, resolving each value's width from the ToC (or a registry fallback) and
skipping it. Same grammar, used only to validate the bytes; the real object build is
File::import.
A feathered fill is a soft, Gaussian-blurred edge. The original convolves coverage
with a precomputed Gaussian-integral table; ours floods a signed-distance field and evaluates
the same integral (erf) per pixel. Both blur with the same width:
sigma = feather / 2.
render/gl_shaders.inl · kFeatherErfFS (mode 2)
float erf(float x){ // Abramowitz-Stegun 7.1.26
float s=sign(x); x=abs(x);
float t=1.0/(1.0+0.3275911*x);
float y=1.0-(((((1.061405429*t-1.453152027)*t
+1.421413741)*t-0.284496736)*t
+0.254829592)*t)*exp(-x*x);
return s*y;
}
void main(){
float sd = sgn*dist; // signed dist to JFA edge
float k = 3.0/(max(uFeatherRadius,0.25)*1.41421356);
// k = 3/(radius*sqrt2) ⇒ sigma = radius/3 = feather/2
float cov = 0.5*(erf((2.0*uHalfWidth - sd)*k) + erf(sd*k));
frag = vec4(0.0, 0.0, 0.0, clamp(cov, 0.0, 1.0));
}
external/.../renderer/src/rive_renderer.cpp:152
if (paint->getFeather() != 0 && !paint->getIsStroked())
{
if (path->getFillRule() != FillRule::clockwise &&
!m_context->frameDescriptor().clockwiseFillOverride)
{
// Don't draw feathered fills that aren't clockwise.
return;
}
// curvature-softened copy so 1-D feather along the normal is valid
clipAndPushDraw(gpu::PathDraw::Make(m_context, m_stack.back().matrix,
path->makeSoftenedCopyForFeathering(paint->getFeather(),
matrixMaxScale),
path->getFillRule(), paint, ...));
}
The official runtime
silently drops any feathered, non-stroked fill whose path fill rule is not
clockwise (FillRule::clockwise == 2). Our writer originally omitted
fillRule (defaulting to nonZero), so feathered exports painted
~0 pixels in the official backend. Emitting property key 40 = 2 on every
Fill — exactly what the Rive editor does — flips that to ~335k painted pixels on the
test fixture. The matching Feather (typeKey 533, key 749) carries the strength,
with sigma = strength / 2.
w.varuint(TK_FILL); w.varuint(PK_PARENT); w.varuint(shapeLocal); w.varuint(PK_FILLRULE); w.varuint(2); // 2 = clockwise — REQUIRED for feather w.varuint(0); // ... the Feather under that same Fill: w.varuint(TK_FEATHER); w.varuint(PK_PARENT); w.varuint(fillLocal); w.varuint(PK_FEATHER); w.f32(sp.feather); // strength; sigma = strength/2 w.varuint(0);