rive_gl_player · api & interfaces

RIV — API & Interfaces

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.

7 render interfaces implemented 2 conforming renderers · ours ∥ PLS parse · model · animation = reused round-trips through File::import

Companion to RIV — Project Structure.

01The contract — one interface, two implementations

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.

the rive render interface — the API contract

rive::Factoryrive::Renderer rive::RenderPathrive::RenderPaint rive::RenderShaderrive::RenderBuffer rive::RenderImage
▼ two independent implementations ▼

official — Rive Renderer (PLS)

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.

not cg_renderer — Rive's Apple CoreGraphics path, unused here (CMake include only, 0 source refs)

ours — GL stencil-then-cover

SimpleShapeFactory + GlStencilShapeRenderer + the SimpleRender* objects: a hand-written GL pipeline (MSAA FBO, stencil-then-cover, analytic erf feather, in-shader gradients).

backend/src/render/ · gl_shape_renderer · gl_stencil_renderer · simple_shape_factory

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.

02The seven interfaces — how we did each

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.

rive::Factory→SimpleShapeFactory

render/simple_shape_factory.hpp:350 · rive/factory.hpp

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.

makeRenderPath(RawPath&, FillRule)makeEmptyRenderPath() makeRenderPaint()makeLinearGradient(...) makeRadialGradient(...)makeRenderBuffer(type, flags, size) decodeImage(Span<const uint8_t>)

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());
}

rive::Renderer→GlStencilShapeRenderer

render/gl_stencil_renderer.hpp:26 · rive/renderer.hpp

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.

save() / restore()transform(Mat2D&) modulateOpacity(float)drawPath(path, paint) clipPath(path)drawImage(img, sampler, blend, opacity) drawImageMesh(img, verts, uvs, indices, ...)

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; }

rive::RenderPath→SimpleRenderPath

render/simple_shape_factory.hpp:231 · rive/renderer.hpp

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.

rewind()fillRule(FillRule) moveTo / lineTo / cubicTo / closeaddRawPath(RawPath&) addRenderPath(path, Mat2D&)addRenderPathBackwards(...)

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
}

rive::RenderPaint→SimpleRenderPaint

render/simple_shape_factory.hpp:185 · rive/renderer.hpp

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.

style(RenderPaintStyle)color(ColorInt) thickness(float)join(StrokeJoin)cap(StrokeCap) feather(float)blendMode(BlendMode) shader(rcp<RenderShader>)invalidateStroke()

rive::RenderShader→SimpleRenderShader

render/simple_shape_factory.hpp:51 · rive/renderer.hpp

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.

isRadial() / startPoint() / endPoint()radiusValue() gradientStops()sample(screenPos, Mat2D&) representativeColor()

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;
}

rive::RenderBuffer→SimpleRenderBuffer

render/simple_shape_factory.hpp:289 · rive/render_buffer.hpp

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.

onMap()onUnmap() + bytes() / byteSize()

rive::RenderImage→SimpleRenderImage

render/simple_shape_factory.hpp:315 · rive/renderer.hpp

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.

~SimpleRenderImage() (glDeleteTextures)+ textureId() sets base m_Width / m_Height

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.

03Ours vs original — who does what

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

capabilityofficial (rive-runtime)ours (from scratch)
.riv parse / importrive::File::import — header + object-stream decode into live Core objectsio/riv_binary_reader inspectRiv() — independent structural walk only
Core object modelCore / Component / Drawable / Path / Shape + generated reflection; CoreRegistry— (100% reused via artboard->objects())
Animation + state machinesLinearAnimationInstance, StateMachineInstance, Scene::advanceAndApply— (100% reused)
Layout (Yoga)vendored Yoga + src/layout flexbox solve—
Text shapingtext_engine + HarfBuzz + SheenBidi (glyphs, BiDi, line-break)— (consumes the resulting RenderPaths)
Render backendPLS RenderContext + RiveRenderer (gl_backend.cpp → FramebufferRenderTargetGL)render/ GlShapePipeline — MSAA FBO, stencil-then-cover, + NPR aux passes
Factory + render objectsthe GL RenderContext is the Factory; RiveRenderPath/Paint/ImageSimpleShapeFactory + SimpleRender* (the seam in §02)
Feathered fillsfeather.cpp + rive_renderer Gaussian-integral LUT; fillRule=clockwise gate3 modes: gaussian blur · atlas blur · analytic JFA-SDF + erf
Image decodedecoders/ 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

04What we reference from the original

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.

rive::File::import — the parser we call, never reimplement

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.

Bitmap::decode — the image codec (reused)

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);

rive::BinaryWriter — what our ByteWriter mirrors

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);
}

05The .riv binary round-trip

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

on-disk layout (all ints LEB128 varuint unless noted)
HEADER"RIVE" · major=7 · minor=0 · fileId
PROPERTY ToCvaruint keys → 0, then packed 2-bit field-type bitmap (4 keys / uint32)
OBJECT STREAM[ typeKey · (propKey, value)* · 0 ]* until EOF

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.

ours — what turns into binary

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

official — what reads from binary

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.

06The feather — analytic from scratch

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.

ours — JFA signed-distance + erf

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));
}

official — Gaussian-integral LUT + the gate

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 fillRule gate — why exported feathers used to render blank

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);