Rendering · NPR · Temporal Imaging

A rendering & non‑photorealistic
research engine, built on Rive

A C++/OpenGL .riv player that grew into an experimentation surface: a from-scratch GPU vector renderer that mirrors the official Rive runtime, a data-driven engine of 122 painterly / simulation effects, a volumetric "spacetime" raymarcher, an oscilloscope beam tracer, six curve families, and an append-only document format. This document explains how each part works — and dwells on the algorithms that are genuinely hard.

2Backends
122NPR effects
~80GLSL shaders
11Aux buffers
6Curve families
RIVX1–6Doc format

00What this is

At its root the project is an offline player for Rive's .riv vector-animation files. But almost everything interesting sits downstream of the rendered frame: once the scene is in a texture, the engine treats it as raw material for stylization, simulation, vectorization, and temporal stacking. Two questions organize the whole codebase:

  • Can we reproduce what the official, closed-source tooling does? — leading to a hand-written GPU stencil renderer and a reverse-engineered .riv writer.
  • What can we build on top that the official runtime has no concept of? — leading to the NPR engine, the spacetime volume, the vector beam, spline curves, and an effects-aware document format.

The sections below follow that arc: the pipeline, the replication work, then each extension, with deep-dive cards for the research-level pieces.

01Two backends, side by side

A frame can be drawn two ways, and they run in parallel so they can be compared pixel-for-pixel and FPS-for-FPS.

Official — the Rive runtime + PLS renderer

We link, don't fork. rive.lib (runtime core) + rive_pls_renderer.lib (the Pixel-Local-Storage GPU path renderer) are driven through rive::Factory + rive::Renderer via makeBackend(BackendKind::gl). It is the production renderer — the full feature set: text, state machines, IK, clipping, blend modes, variable fonts — and far more complex than ours. We consume it as a library.

Ours — GlShapePipeline (~2.3k LOC)

A small custom GL renderer that exists only to do the things our experiments need that the official path can't easily expose: feathered fills, spline curves, and the auxiliary geometry buffers the NPR effects read. It is not "better" — it is optimal for that narrow surface, and a sandbox we fully control.

A live split-pane panel renders both into 360 px targets and reports a rolling-average FPS via glFinish timing; on typical scenes the two land within 0.5–2× of each other. The point of "ours" is not to win that race — it is to be a renderer we can instrument and extend.

02The frame pipeline

The scene is drawn once into sceneTex (already including any .rivx editor geometry). Everything after operates on textures, which is what makes the stack composable.

.riv / .rivx source scene render → sceneTex ours aux producer id·edge·vel·depth·sdf·etf·height… NPR effect chain vector beam spacetime volume aux feeds custom pipeline viewport picks tex priority: custom › beam › spacetime › NPR › raw · Raw/Final toggle forces sceneTex
The frame graph. One scene render fans out to the aux producer and the three stage families; a stackable custom pipeline composes them; the viewport selects the display texture by priority (a global Raw/Final toggle can bypass the whole stack).

Each stage is a texture-to-texture operation, so they nest: the custom pipeline lets you put effects after a beam, a beam after a spacetime volume, and so on. Aux buffers are recomputed per stage — exact for scene-aligned stages, approximate after a spacetime warp.

03Replicating the official renderer

"Ours" is a stencil-then-cover vector renderer — the same family the official Rive GPU renderer uses. A path is flattened to device-space polylines, its winding is accumulated into the stencil buffer, then a bounding quad is drawn gated to the stencil test. Everything is composited in a 4× MSAA framebuffer and resolved at 1×.

The core mechanics

  • Stencil-then-cover. The winding pass accumulates signed edge crossings into stencil bits 0–6 via GL_INCR_WRAP/GL_DECR_WRAP on front/back triangle fans; the cover quad tests even-odd (EQUAL 0x01) or non-zero (NOTEQUAL 0x00).
  • Non-convex clipping packs an intersection bit (bit 7) alongside the 7 winding bits, so an arbitrary multi-layer clip mask is built once and AND-tested in the same cover pass — no redundant multipass compositing.
  • Scale-aware flattening. Cubics subdivide to keep each chord ≤ 3 px on-screen; the step count comes from the control-polygon arc-length estimate, clamped to [1, 256]. Quadratics are degree-elevated to cubic first; near-duplicate points (< 0.05 px) are culled to avoid degenerate stroke normals.
  • Strokes are the Minkowski sum of the path with a disc of radius thickness/2: a band quad per segment plus an 8-triangle radial fan per vertex, unioned via stencil so overlaps never double-blend.
  • Gradients are evaluated per-fragment in object space (inverse 2×2 + translation), so radial gradients stay elliptical under non-uniform transforms.
  • Blend modes (multiply/screen/darken/lighten) are approximated by snapshotting the destination into a background texture and seeding the MSAA buffer with it, then using non-standard blend equations.

The hard one — analytic feather via jump-flood SDF + erf

Feathering a hard-edged fill is the part the official renderer keeps proprietary, and the part that's genuinely difficult. Blurring the binary coverage mask works for small radii but loses crispness and develops radius-dependent artifacts. We instead compute, per fragment, the exact Gaussian integral of coverage to the nearest edge.

The trick is getting a signed distance field cheaply. A naïve transform is O(N) per pixel; instead we use the Jump Flood Algorithm (Rong & Tan 2006), which propagates nearest-seed coordinates in O(log N) ping-pong passes with a halving step. Crucially the cost scales with shape size, not screen size — every feather pass is scissored to a padded bounding box, and the scratch is explicitly cleared after each shape to maintain a zero-outside invariant (the subtle bug: JFA's ±1-texel seed kernel reads stale white from a prior shape otherwise).

coverage white fill seed 0.5-isoline sub-pixel crossings JFA ping-pong step max/2 → 1 · O(log N) signed dist sd, dir erf composite soft coverage erf(sd·k)
Per-fragment exact feather. A jump-flood distance field feeds a Gaussian erf integral. For fills the band half-width is infinite; for strokes two erf terms combine the two edges so thin strokes correctly dim.
Fill coverage (infinite band)cov = 0.5 · (1 + erf(sd · k)) with k = 3 / (R·√2), σ = R/3, R = 1.5·featherPx
Stroke coverage (finite half-width hw)cov = 0.5 · ( erf((2·hw − sd)·k) + erf(sd·k) )

The erf itself is the Abramowitz–Stegun 7.1.26 rational approximation evaluated inline — a 512-entry CPU reference table exists in the feather lab for validation (sub-0.5% delta).

The reverse-engineered .riv writer

The official runtime is open — but it only shows how to read a .riv. The editor that writes them is closed, with no published binary spec. writeSplatRiv() reverse-engineers the format well enough to emit valid RIVE v7.0 containers: a 'RIVE' magic, varuint version, a property table-of-contents with a 2-bit field-type bitmap, then a stream of core objects (Backboard, Artboard, then per-shape Shape + Ellipse/Rectangle + Fill + SolidColor / Linear&RadialGradient + GradientStop + Feather + Stroke), parented by artboard-local indices and emitted in reverse order so late shapes draw on top.

The fillRule=clockwise discovery. Feathered fills silently drop in the official renderer unless the fill carries fillRule = clockwise (property key 40 = 2). The editor always emitted clockwise, masking the bug for years; our writer originally defaulted to non-zero and produced blank feathers. Setting property 40 = 2 — and winding-correcting cubic paths to CW — was the fix.

Research-level Anisotropic Gaussian → feathered-ellipse fit least-squares on a convolution profile▸
Problem

Rive's feather is isotropic (one scalar), but the source splats are anisotropic 2D Gaussians (σx ≠ σy). Scaling the feather by max(σx, σy) obliterates the short axis (a 100×4 splat blurs its 4 px axis by ~65 px).

Approach

Model the rendered profile along each principal axis as amp · C(u), where C is the exact 1-D centre slice of an ellipse convolved with a Gaussian (not erf-of-SDF, which over-estimates curved boundaries by up to 2.14×). Evaluate it by midpoint integration at 25 samples per axis spanning 4σ, then fit four free variables — Rx, Ry, σfeather, amp — by greedy coordinate descent in log-space (multiplicative 1.22× steps, halving on a plateau), with amp solved in closed form per probe via Σ(C·T)/Σ(C²).

Why it's hard

The anisotropic→isotropic map has no closed form; it couples ellipse chord-mass theory with numerical integration of an error surface in (Rx, Ry, σ). Tailored to match Rive's internal feather semantics rather than any textbook filter.

Research-level Disk-convolved-Gaussian coverage LUT Bessel-weighted, RMS < 0.0007▸
Problem

The official feather is the 2-D Gaussian convolution of the shape indicator. For a disk of radius R there is no closed form, and erf-of-SDF deviates significantly on curved edges — so the preview wouldn't match the runtime.

Approach

Precompute a 224×160 LUT over u′=(r−R)/σ ∈ [−8,+6] and v=R/σ ∈ [0,16]. Each entry is the 1-D integral ∫₀ᴿ ρ·exp(−(ρ−r)²/2)·I₀e(rρ) dρ using the exponentially-scaled Bessel I₀ (to keep exponents bounded), midpoint rule with n = min(2048, ceil(16R)). Bilinear-sampled at runtime; v > 16 falls back to erf.

Why it's hard

Reverse-engineered by grid search against official output and validated to RMS < 0.0007. A Bessel-weighted convolution is not a standard GPU primitive — it encodes the exact disk-blur profile the proprietary renderer uses.

04The NPR effects engine

The stylization engine cleanly separates what an effect is (pure C++ data) from how it runs (a generic GL executor). An effect is a list of passes; the executor compiles shaders on demand and wires up the texture flow.

struct Effect   { id; label; params[]; passes[]; }
struct Pass     { shaderId; inputs[]; constUniforms[]; output; scratchSlot; prim; vertexRows; }
enum Src       { Prev, Original, Noise, Self, Aux, History, State }   // where a pass reads
enum Dst       { Scratch, Result, State }                            // where it writes
enum Prim      { FullscreenTri, LineStrip }                          // fragment vs. ribbon geometry
struct ChainEntry { effectIndex; enabled; params[]; }              // a live instance
ChainEntry[] idx+params Src resolve Prev · Original · Noise Self · History Aux (geometry) State (sim) shader kNprFragTable[id] Tri (3 verts) · or ribbon strip Result ping-pong Scratch ×4 State ping-pong RGBA16F · sims outTex State feeds next substep (6–8 / frame for sims)
The generic executor. For each pass it resolves inputs, compiles/binds the shader, draws a fullscreen triangle (or a ribbon strip), and routes output to a Result/Scratch/State target. State is a persistent RGBA16F ping-pong that lets simulations carry across frames.

Two details make the engine expressive:

  • Persistent State — a 16-bit-float ping-pong buffer turns the post-processor into a substrate for multi-frame PDE and particle simulations. Ownership is per-effect; taking over mid-chain forces a re-seed so a new sim never reads another's garbage.
  • The line-primitive ribbon pass — a particle chain stored in the State texture is drawn as geometry via vertex-texture-fetch (gl_VertexID indexes particles), emitting two verts per Catmull-Rom sample as a variable-width GL_TRIANGLE_STRIP. That's how the "one-line drawing" becomes a real, antialiased stroke instead of hardware lines.

The registry holds 122 effects across ~80 shaders, grouped for the UI into painterly (Kuwahara, oil, watercolor, painterly strokes), ink & line (ballpoint, Ciallo, sumi-e, crosshatch, FDoG, engraving), halftone & stipple, abstraction (toon, bilateral, comic), light (bloom, neon, iridescent, hologram, prism, matcap, tendrils), and temporal & sim — the research-level batch below.

05Auxiliary buffers & the silhouette vectorizer

Content-aware effects need to know about the geometry, not just the pixels. The aux producer re-renders the scene through the custom pipeline to manufacture eleven buffers, each lazily computed (a per-frame Done flag) and cached/deduped by name across the effect chain. A uHasAux executor signal lets every consumer fall back to a luma approximation when no producer is present (e.g. on the official backend).

BufferChannelsMade from / consumed by
idhashed RGBper-shape flat colour; occupancy for sdf/depth
edgeRid-discontinuity ∪ luma Sobel; seeds sdf/etf/height
velocityrg (0.5=still)per-draw transform delta across frames
depthRnormalized paint order (background→front)
sdfr=dist, gb=dir, a=occjump-flood distance field; 2D lighting
curvaturer=bend, g=proxdivergence of the sdf direction field
ager=activityEWMA of luma change (recently-moved glows)
etfrg=tangent, b=coherencesmoothed structure tensor; hatching/flow effects
heightr=height, gb=normal, a=|∇|denoised reference normal; matcap/STM/prism
structurerg=grad dir, b=magoff the etf tensor; tendrils, edge-stopping
curvepts256×1, rg=xyvectorized silhouette polyline

The vectorizer — turning a silhouette into a parametric curve

The curvepts buffer is the hard one: it converts the rendered scene into an ordered, resampled polyline that effects can trace. It's a CPU pass (the only GPU→CPU readback, throttled to every 20 frames) running a five-stage classic computer-vision pipeline, run at both polarities (bright subject and dark subject) with the larger valid bounding box winning.

binarize field ≥ τ largest interior component (no border) Moore trace 8-nbr CW + Jacob stop Douglas–Peucker closed-loop simplify resample N=256 by arc-len
Silhouette → polyline. Stack-based flood fill prefers the largest non-border-touching component (so a full-frame background never wins); Moore boundary tracing with Jacob's stopping criterion handles thin spurs; closed-loop Douglas–Peucker avoids the start/end collapse; arc-length resampling yields a fixed, evenly-spaced 256-point loop.

06Research-level algorithms

Each card states the problem, the technique as actually implemented (with the governing math), and the lineage. These are the pieces where the complexity lives — coherent line drawing, structure-tensor filtering, and a shelf of GPU PDE / agent simulations running entirely in fragment shaders on a 16-bit-float state buffer.

Line art & structure

Research-level Edge Tangent Flow + FDoG Kang · Lee · Chui, SIGGRAPH 2007▸
Problem

Raw gradients are noisy and sign-ambiguous; a line extractor built on them speckles. Real structure has a direction — lines should be crisp across the flow and continuous along it.

ETF — the flow field

Build the structure tensor from Sobel gradients of luma + 0.75·edge, store [gx², gy², gxgy], Gaussian-smooth it over a 9×9 window, then eigen-decompose. The minor eigenvector (perpendicular to the gradient) is the edge tangent; coherence = (λ₁−λ₂)/(λ₁+λ₂) gauges how much the neighbourhood agrees, gated by trace magnitude to kill flat-region noise. Encoded as rg=tangent·0.5+0.5, b=coherence.

FDoG — the line extractor

Filter a Difference-of-Gaussians across the flow (σs=1.6·σc, ρ≈0.99), then integrate the response along the streamline maintaining tangent sign-continuity, and threshold with Kang's tanh rule: e = ½(1+tanh(60·H)); ink = 1 − smoothstep(τ±0.04, e).

Complexity

O(W·H·81) for the tensor smooth; O(W·H·36) per FDoG pass (the along-flow streamline tracing dominates). The same ETF flow drives hatching, LIC smear, and the fluid swirl force.

Research-level Anisotropic Kuwahara Kyprianidis et al. 2008▸
Problem

Classic Kuwahara (4 axis-aligned quadrants, pick the lowest-variance mean) preserves edges but is isotropic — it can't follow structure, so it smears across diagonal features.

Approach

Compute the local structure tensor; form an elliptical kernel with axes a = R(1+A), b = R(1−A) from the anisotropy A = (λ₁−λ₂)/(λ₁+λ₂), rotated to the edge tangent. Sample 8 angular sectors (Gaussian-weighted), compute mean + variance per sector, output the mean of the lowest-variance sector. All loops are unrolled (no dynamic branching).

Why it's hard

Per-pixel eigen-analysis + elliptical rotation + sector statistics, ~128 taps per pixel, every frame. The kernel stretches along edges and shrinks across them, which is what produces the painterly-but-sharp look.

Core technique Jump Flood distance transform Rong & Tan 2006▸
Problem

Exact distance to the nearest contour at every pixel, fast enough for interactive use. Scan-based transforms are O(N); we need sub-millisecond.

Approach

Seed edge pixels with their own coordinates; then for step k = max(W,H)/2, /4, … , 1, each pixel checks 8 neighbours at offset ±k and adopts the nearest valid seed. After ⌈log₂ max(W,H)⌉ passes every pixel knows its nearest seed to ≤ 0.5 px. Finalize to r=distance, gb=direction, a=occupancy.

Where it shows up

Three places: the feather SDF (§03), the sdf aux buffer, and — remarkably — inside the love level-set shader as a per-frame interior fill (next card).

Research-level Geodesic active-contour "love" Caselles 1997 + Sussman reinit + in-shader JFA▸
Problem

Evolve a contour that flows onto the artwork, sticks to structure, and fills its interior every frame so it doesn't leave phantom bands as it moves — all on a fragment-shader state buffer.

Approach

Store the level-set φ in State.r, an edge-stopping field g = 1/(1+λ|∇I|²) in State.g, and a jump-flood distance in State.b. Evolve the geodesic active-contour PDE, then a Sussman reinitialization term to keep |∇φ|≈1:

φₜ = μ·g·κ·|∇φ| + ν·g·|∇φ| + β·(∇g·∇φ) (curvature smoothing · balloon · edge advection) φ −= dt·α·s·(|∇φ| − 1) (reinit, s = φ/√(φ²+1))

The same pass runs a 4-scale in-shader JFA (64/16/4/1 px jumps, 8 neighbours each) seeded at sign-flips and grown +2 px/frame, so the distance field recovers as the contour moves. The colour pass renders nested bands tiled off that distance, with a bright accent at the zero level-set.

Why it's hard

Three research threads in one ~130-line shader: geodesic active contours, a live-moving distance transform (most JFA implementations are static), and level-set reinitialization for numerical stability.

Research-level One-line Lagrangian snake tone-weighted relaxation + Catmull-Rom ribbon▸
Problem

A single connected polyline of K = W·rows particles must relax onto the artwork — tracing contours, packing tighter in dark regions — and render as a smooth variable-width ribbon.

Approach

Particles live in the State texture (.xy clip pos, .zw velocity). Forces: a spring with a tone-weighted rest length (L shrinks where the image is dark, so the line packs), a magnitude-clamped image-gradient pull toward edges, neighbour separation, and Laplacian smoothing. Seeded as an Archimedean spiral (r=√(1−i) for even area coverage). Rendering fetches 4 surrounding particles per vertex, evaluates Catmull-Rom, and emits a 2-vertex-wide triangle strip — sidestepping hardware line limits.

Why it's hard

The tone-weighted spring is the load-bearing mechanic — it makes one continuous line describe a subject non-uniformly. (An experiment to additionally pull it toward the SDF contour de-noised but collapsed the composition: the noise was structurally necessary.)

State-buffer simulations

Each runs 1–8 fragment-shader substeps per frame on the persistent RGBA16F state, seeding from the artwork and biasing toward its geometry (edges, tangent flow, curvature) so the simulation describes the picture rather than ignoring it.

SimulationState channelsGoverning ideasteps/frame
Gray-Scott RDr,g = U,VTuring reaction-diffusion; luma modulates feed/kill6
Flow-advected RDr,g = U,Vsame, but back-traced along the etf flow + seeded at curvature corners6
BZ chemistryr,g = a,bBrusselator / Oregonator spirals & target waves6
FitzHugh-Nagumorgbaexcitable medium, spiral-defect turbulence4
Ginzburg-Landaur,g = Re,Imcomplex field, 9-tap Laplacian, spiral chaos2
SmoothLifer = densitycontinuous-space Life via disk/ring integrals + sigmoid1
Multiscale Turingr = morphogenMcCabe — smallest-variation scale sets the step1
Stable fluidsrg=vel, ba=flow-mapStam advection + vorticity confinement + etf buoyancy6
Falling sandrgb=col, a=massconservative gather + diagonal angle-of-repose8
Hydraulic erosionH,W,S,H₀virtual-pipe shallow water + capacity-driven sediment6
PhysarumT,θ,EWMAEulerian slime mould — sense/steer/deposit with feedback8
Research-level Semi-Lagrangian stable fluids + vorticity confinement Stam 1999 · Fedkiw 2001▸
Problem

A stable fluid solver normally needs an iterative Poisson pressure projection — impossible in a single fragment pass — yet we want large timesteps without checkerboard blow-up, and the artwork to flow like liquid paint.

Approach

Pack velocity in State.rg and a flow-map (the UV each pixel reads the artwork from) in State.ba. Advect via backward characteristics: v = velAt(uv − v·dt) — self-consistent, no iteration. Reinject the small-scale swirl lost to coarse advection with vorticity confinement (curl gradient ascent), and use the etf tangent as a zero-mean circulation force. The colour pass samples the artwork through the back-advected flow-map, so the picture "flows" by texture indirection.

Why it's hard

It composes three ideas — semi-Lagrangian advection, post-hoc vortex amplification, and structure-tensor-driven buoyancy — into incompressible-looking motion without ever solving div(v)=0.

Research-level Conservative flux gather (sand & erosion) discrete-conservative single-pass CA▸
Problem

Redistribute a conserved quantity (mass, sediment) so out-flux exactly equals in-flux — in one pass, where a receiving cell can't "know" its total incoming until all senders are computed.

Approach

Use a single symmetric, bounded flow function flow(src,dst) = clamp(min(srcAmount, 1−dstCapacity)) · rate. Because both (A→B) and (B→A) call the same function, A's out-flux to B provably equals B's in-flux from A; the gather pass nets me − Σout + Σin. Sand adds an angle-of-repose conditional (grains slump diagonally only if straight-down is full); erosion adds capacity-driven erode/deposit (erode when flow speed exceeds capacity, deposit otherwise), conserving H+S.

Why it's hard

Maintaining discrete conservation without a global solve or iteration is a careful discretization — the bounded symmetric flux is what prevents oscillation and matter creation.

Research-level Eulerian physarum with positive-feedback deposit Jones 2010, gridded▸
Problem

Classic slime-mould models track thousands of discrete agents. A dense-grid (Eulerian) version avoids per-agent cost but, without autocatalysis, the trail field just diffuses into uniform background — filaments don't self-sustain.

Approach

Each pixel stores trail T and heading θ. Per substep: sense T at 3 points ahead (front, ±sensor angle), steer toward the strongest (Jones rule), bias the heading toward the etf tangent, transport semi-Lagrangian, diffuse weakly (~12%). The key is a positive-feedback deposit: fb = 0.25 + 0.75·smoothstep(0.2,1.4,T) so cells with existing trail deposit more, and ridge = clamp(sF − max(sL,sR)) only rewards directed confidence — emulating agent autocatalysis as a field equation.

Why it's hard

Reproducing the directed filament topology of a particle system without particles, and tuning diffusion low enough that networks stay crisp instead of collapsing into glow.

07The spacetime volume

The spacetime stage stacks recent frames into a 3D texture ring buffer (R×R×N, with GL_TEXTURE_WRAP_R for a seamless time axis) and raymarches it. A parallel R8 volume tracks paint-order depth so foreground strokes can be emphasized. At 512²×128 the ring is ~80 MiB and the orbit marches 192 steps per pixel.

z = time head pointer → N slices (ring, wrapped) eye (orbit) render modes 0 · orbit timespace 1 · slit-scan 2 · Real STM heightfield iso · contour sheets sources time stack bilateral luma slices bilateral highlight 2.5D extrusion (aux+scalar) HDR / grade Reinhard · ACES filmic contrast · vibrance · sat
Time as a third dimension. The same ring is interpreted three ways — z as time, as luminance (bilateral), or as height/paint-order — via a single layer-remap branch, then raymarched with step-count-independent opacity 1−exp(−σΔt), motion, age-fade, and depth weighting.

The volume captures one slice per frame (with motion stored in alpha) or rebuilds all N layers for the bilateral and extrusion sources. Four modes share the marcher:

  • Orbit — a camera flies through the time box; front-to-back accumulation with early-out.
  • Slit-scan — screen-aligned, no camera: each pixel reads a per-pixel time layer driven by depth or age, so foreground reads "now" and laggy regions read older frames (the strange-time look).
  • Real STM — a volumetric heightfield of the current frame with five gather modes (fill, soft shell, growable metaball spheres, iso-bands, point cloud), morphological dilation, and 14 scientific palettes.
  • Isolines — keep only voxels near a luminance level, rendering topographic contour sheets that sweep through time.
Research-level Ring-buffer layer remap across time / luma / height one branch, three dimensions▸
Problem

The z-axis means different things per mode (time, luminance, or paint-order height), and time modes have a moving head pointer that must wrap seamlessly mid-ray.

Approach

A boolean bilAxis splits the cases. Axis-aligned modes map directly: layer = zWorld·(N−1). Time modes preserve causality: layer = (head−1) − zWorld·(N−1), so the front of the box is the newest capture and the back wraps around the ring via (layer+0.5)/N with hardware GL_REPEAT — a true circular sampler at no per-voxel cost.

Why it's hard

Mapping a 3-D ray coordinate onto a 1-D circular buffer while switching the meaning of that axis — and keeping the wrap seamless — is the non-obvious bit that makes all four modes share one marcher.

08The vector beam

The beam stage renders the scene the way an oscilloscope would: it chains all the scene's contours into one continuous XY path and traces an electron beam onto a persistent phosphor buffer. The signature is the analytic beam integral — each segment is shaded by the exact intensity of a Gaussian spot sweeping along it, not a heuristic glow.

Per-segment energy (separable erf × Gaussian)along(x) = erf(x/√2σ) − erf((x−len)/√2σ) across(y) = exp(−y² / 2σ²) energy = along · across · intensity · 2σ / max(len, σ) → brightness ∝ 1/len

Because energy is inversely proportional to segment length, slow strokes burn bright and the fast jumps between disconnected shapes leave the authentic faint connector lines — all of it falling straight out of the physics, with no special-casing. The quad is inflated by 4σ to capture the Gaussian tails, and the erf is a fast 4th-order polynomial approximation.

chain contours greedy nearest-endpoint analytic segment erf × Gaussian → energy phosphor (RGBA16F) decay ×persist · ping-pong tone-ramp + halo P31 · amber · cyan · white
CRT in a shader. Contours chain into one path; each segment deposits exact Gaussian energy additively into a 16-bit-float phosphor buffer that decays by a persistence factor each frame; the composite tone-ramps energy through a tint with halo bloom. Float storage is what keeps faint traces glowing for many frames after the beam passes.

09The curve system

Official Rive has no spline primitive of any kind, so the editor grows its own. Six families are evaluable as dense polylines, all C²-continuous where it matters, and each is unit-tested for continuity via finite differences.

FamilyEvaluatorNote
Bézier (any degree)de Casteljaurepeated lerp — convex, numerically stable to degree 9
B-splinede Boorclamped uniform (interpolates endpoints) or periodic
Yuksel · Circularcircumcirclequadratic-Lagrange on unwrapped angle; parabola fallback when collinear
Yuksel · Ellipticalconjugate diametersE(s) = m + e₁·sin φ + e₂·cos φ
Yuksel · Hybridasymmetry blendw·ellipse + (1−w)·circle, w from apex projection
Yuksel · Parabolicquadratic Lagrangethe C² fallback base
Cubic-pencubic Bézier chainRive's native model — per-vertex handles (Mirrored/Asymmetric/Detached)

The Yuksel interpolating splines (Cem Yuksel, ACM TOG 2020) pass through every control point with C² continuity and no degree elevation — each segment blends two overlapping local interpolants with trigonometric cos²/sin² weights whose value and first two derivatives vanish at the joins:

Yuksel blendC(t) = cos²(πt/2)·Lⱼ(½ + t/2) + sin²(πt/2)·Lⱼ₊₁(t/2)

Only the cubic-pen exports as exact cubics to native .riv; every other family flattens losslessly to a polyline on save but stays fully editable in .rivx.

10The .rivx format

Since .riv can't hold splines or our runtime look, the editor has its own append-only container. Each version adds a section; older readers stop cleanly at the boundary.

VersionAdds
RIVX1shapes + curves (every kind, with control points)
RIVX2per-object paint style (fill kind, gradient stops, stroke, feather)
RIVX3cubic-pen handles (in/out offsets + coupling type)
RIVX4fillOn / stroke-only flag
RIVX5the runtime look: NPR chain (by effect string-id) + enable flags + SpaceTimeParams & VectorBeamParams as sizeof-tagged POD blobs
RIVX6the multi-stage custom pipeline: stage stack + per-slot chains + per-kind param vectors

Keying the chain by string-id (not registry index) keeps files robust to reordering; the POD blobs carry a sizeof tag so a struct that gains a field is skipped gracefully on old files. The loader is hardened against corrupt and truncated input — short reads are rejected via gcount() (not eof(), which is set together with the fail bit on a partial read), an over-cap length is consumed to stay aligned, and the v6 pipeline applies all-or-nothing: a corrupt count, size mismatch, or out-of-range stage slot drops the whole stack rather than restoring something the frame loop would index out of bounds. A GL-free self-test (--rivx-fx-test) round-trips a populated config plus seven crafted corruption cases.

The runtime GL objects are never serialized — on load the parallel vectors are resized to null pointers and the frame loop rebuilds each stage lazily (every Impl destructor is =default with no glDelete*, so this is safe with or without a current context).

11Results & capabilities

Official vs. ours

Official (rive-runtime + PLS)Ours (GlShapePipeline)
Scopefull .riv: text, state machines, IK, fontsfills, strokes, gradients, feather, clipping
Feathered fillsyes (proprietary)yes — analytic JFA + erf, any radius
Spline curvesnone6 families incl. Yuksel C²
Aux geometry buffersnone11 (id…curvepts)
Code sizelarge library we consume~2.3k LOC we maintain
Relative perfbaseline0.5–2× on typical scenes

What we added on top

Effects

122 effects / ~80 shaders: painterly, ink & line (incl. coherent FDoG), halftone, abstraction, light, and 11 State-buffer simulations — all geometry-aware via the aux buffers and the uHasAux fallback.

Spacetime

A 3D ring-buffer raymarcher with four render modes and four volume sources, full HDR tonemap + colour grade, and a volumetric Real-STM heightfield with metaball growth.

Beam

An oscilloscope tracer with a physically-exact analytic beam integral and float-precision phosphor persistence — slow strokes bright, fast jumps faint, for free.

Authoring

A shape/curve editor, a reverse-engineered native .riv writer (with the fillRule discovery), six spline families, and an effects-aware .rivx document format.

Honest limits

  • Blend modes are approximated by background seeding, not a true framebuffer-fetch feedback loop.
  • Aux effects fall back to screen-space luma on the official backend (no geometry re-render available).
  • Only one State-owning simulation per chain (two back-to-back re-seed each other).
  • The silhouette vectorizer's figure/ground split grabs the frame on full-background-rect scenes.
  • Spacetime trades resolution against history (512²×128 typical); bilateral slicing has a luma bandwidth.
  • The greedy contour chain is O(n²) — fine for typical scenes, suboptimal for tangled paths.