zinc:lottie

Lottie (Bodymovin JSON) animations rendered natively, without a JavaScript engine: the file is parsed once in C++ into a compact scene, and each frame is evaluated, flattened and drawn through the shared software rasterizer (gfx.path polygons, anti-aliased). Plugin: plugins/lottie/ (module zinc:lottie).

import { Lottie } from 'zinc:lottie';
// UI node (zinc:ui canvas), played on the UI engine clock
<Lottie src="spinner.json" loop autoplay className="w-24 h-24" />
import * as gfx from 'zinc:gfx';
import * as lottie from 'zinc:lottie';

const p = new lottie.Player(lottie.load('spinner.json'));  // asset name (embedded), else a file path
p.loop = true; p.play();
gfx.onFrame((dt) => { p.update(dt); gfx.clear(0); p.draw(0, 0, 200, 200); });
  • functions: load(name), parse(json) → handle (-1 on error), width, height, frames, fps, duration (s), layers, draw(a, frame, x, y, w, h) (frame 0 = first; the composition is fitted into the box, aspect ratio kept, centered and clipped), free, drawMs() (time spent in Player.draw since the last call).
  • Player(anim): play, pause, stop, seek(frame), segment(from, to), update(dt), draw(x, y, w, h), shown(); fields frame, speed (negative plays backwards), loop, playing, alpha, subframe (default false: whole frames at the file’s rate, see performance), from/to. cache(w, h, bg, maxBytes) switches to render-to-image mode (below).
  • <Lottie> props: src, loop, autoplay, speed, class, subframe, cacheBg (render-to-image mode on that background colour), player (callback receiving the Player, for play/pause from the app).

Examples: examples/ui/lottie-gallery (12 animations in a grid with a fps readout, click a card to pause it), src/view.ts there (one file, full window: LOTTIE=file.json [LOTTIE_FRAME=n]), src/bench.ts there (numbers below).

Supported features

Lottie featurestatus
composition size, frame rate, ip/opyes
layers: shape (4), solid (1), null (3), precomp (0)yes; precomps with st (offset), sr (stretch), tm (time remap), clipped to their w/h
image (2), text (5), audio, camera, 3D layersno (skipped; 3D layers are drawn flat, rotation = r or rz)
layer in/out points, hidden (hd)yes: layers outside their range are skipped before any evaluation
parentingyes (any depth; parent opacity is not inherited, as in Lottie)
transform: anchor, position (also split x/y), scale, rotation, opacityyes; skew (sk/sa) ignored
keyframes: linear, hold, cubic-bezier easingyes (first-dimension easing for multi-dimensional values); old exports (e end values, no a flag) too
spatial bezier (to/ti) on positionsyes, parametrized by arc length
expressionsno (the static/keyframed value is used)
shapes: group, path, rect (rounded), ellipse, star, polygonyes; star/polygon roundness (os/is) ignored; direction (d = 3) honoured
fill (color, opacity)yes; even-odd fill rule drawn as nonzero
stroke: width, color, opacity, caps (butt/round/square), joins (miter + limit/round/bevel)yes; dashes (d) not supported (drawn solid)
gradient fill/strokelinear gradients along x or y on axis-aligned rects are drawn as real two-colour gradients (colours sampled at the rect edges); every other gradient is drawn flat with its average colour and opacity
trim paths (start, end, offset; simultaneous and individual modes)yes
repeater (copies, offset, transform, start/end opacity, composite order)yes (integer offsets); one repeater per group
masksadditive masks clip to their bounding box (exact for axis-aligned rectangles); subtract, intersect, inverted, feather: ignored
track mattesno: the matte source layer is hidden, the matted layer drawn unmasked
merge paths, round corners, offset path, pucker/bloat, twist, zig-zag, layer effects, blend modesno (ignored)

Verified on macOS by screenshots of the gallery at 0.5 s, 1.5 s and of single files, compared with lottie-web 5.12 (SVG renderer) in Chrome at the same frames: all 12 gallery files match in shape, position, colour and timing (differences: flat gradient on the ellipse of shapes.json, hairlines slightly fainter than Chrome’s retina rendering). Found on the way: lottie-web evaluates layer properties in composition time (a layer’s st and sr only shift/stretch a precomp’s inner time); Zinc does the same.

How it is fast

  • Parse once: JSON → a temporary DOM → flat arrays (layers, shape items, transforms) with every animatable property either a static value or a keyframe table in one float pool. The DOM is freed after loading.
  • Evaluate only what is visible: layers outside their in/out range, hidden layers, matte sources and nulls are skipped before any evaluation.
  • Per-layer command cache: each top-level layer keeps the commands it emitted with their key (frame, box). A layer with no keyframes anywhere in its content, transform or parent chain is evaluated and flattened once; an animated layer only when its frame or the box changes. Redrawing the same frame (paused player, a 30 fps file on a 60 Hz loop with subframe = false) is a replay of stored commands (a few µs); the frame diff then finds nothing changed and the rasterizer does no work — an idle Lottie costs nothing to rasterize.
  • Device-space flattening: beziers are transformed first, then flattened with a 0.25 px tolerance, so a small icon gets few segments and a full-screen one stays smooth.
  • Cheap strokes: a stroke is one outline per polyline (left side, cap, right side, cap), 2 edges per vertex, with proper miter/round/bevel joins; sharp inner turns fall back to the rasterizer’s quad + disc stroker.
  • Rasterizer-friendly output: axis-aligned rects and solid layers become rounded-rect commands (row fills), and large paths with many edges are cut into 16-row bands (exact, no seams) so the scanline filler only walks the edges of each band.
  • Render-to-image mode (Player.cache(w, h, bg, maxBytes), <Lottie cacheBg={...}>): for small looping icons, each frame is rasterized once into a runtime image atlas (w × h·frames, opaque on bg) and then drawn as a 1:1 row copy. Memory: w·h·4 bytes per frame (a 48 px, 54-frame icon: 500 KiB); refused above maxBytes.

Performance (macOS, M1 Pro, release build, zinc run examples/ui/lottie-gallery/src/bench.ts)

Every frame of each file rendered off screen at 256×256 (gfx.beginImage), ms per frame: vector = evaluation + flattening + command emission, raster = rasterization, replay = redrawing an unchanged frame.

fileframesvectorrastertotalreplay
spinner (trim, round caps)900.0060.700.700.002
shapes (rounded rect, star, polygon, gradients)600.0040.520.520.001
orbit (parenting, precomp, repeater, solid)600.0100.810.820.004
TwitterHeart (18 layers, trims)1160.0030.030.03< 0.001
Watermelon5630.0070.420.420.001
PinJump930.0030.120.120.001
LottieLogo1 (48 layers, trims)1790.0100.090.100.002
IconTransitions1580.0050.280.290.001
9squares (18 large stroked circles, two-level parenting)1760.0591.411.460.012
HamburgerArrow1800.0010.030.03< 0.001
Switch (large fills)1500.0041.701.700.001
skottie-trimpath-modes6010.0050.410.410.001
LottieLogo1 at 512×5121790.0120.260.280.002
TwitterHeart at 64×641160.0020.0030.005< 0.001
skottie_sample_search at 48×48540.0020.0190.021< 0.001
same icon, render-to-image mode (atlas copy)54< 0.001

Evaluation is in the µs range; the rasterizer dominates and scales with the covered area (big fills and long strokes), not with the number of layers. Measured with the machine under load (other builds running); numbers are CPU time on one core.

Whole programs (release, ZINC_FIXED_DT=1/60, 600 frames, /usr/bin/time, user CPU per frame):

programCPU/frame
lottie-gallery (860×400, 12 animations at 112 px, UI, fps readout, measured before the kit restyle (96 px cards); the damaged rectangle covers most of the window every frame)2.4 ms (60 fps, ~15 % of a 60 Hz frame)
view.ts 9squares full window (400×400 composition), playing1.3 ms
same, paused0.30 ms, vs 0.23 ms for the same program with nothing loaded: an idle animation costs ~0.07 ms (command replay and frame diff), nothing is rasterized

Support matrix

targetstatus
macossupported, verified (gallery, viewer, bench, conformance test)
wasmbuilds; gallery verified in Chrome
rpi1builds and runs the conformance test under QEMU (arm1176), identical to the sim
linux, rmppportable C++, same code as macOS (no platform code); not built here
simmetadata only (size, frames, fps, layers via JSON.parse); drawing is a no-op like the rest of the sim’s gfx; Player logic is shared Zinc code
esp32builds (ESP-IDF 6.0, firmware 303 KiB) and runs the conformance test under Espressif’s QEMU, including draw calls of a small animation; output identical to the sim. The esp32 HAL has no display yet, so nothing is shown. The plugin sets a 4 Ki-float point pool and relaxes two GCC warnings of runtime/gfx.cpp/raster.cpp (misleading indentation) that -Werror would otherwise reject: gfx had never been built for esp32 before.
ps2builds (ps2dev docker, 1.7 MiB ELF); not run (no emulator here)

Command budgets: the frame holds at most ZRT_MAX_DRAW_CMDS commands and ZRT_POINT_POOL floats of polygon points; the plugin raises the point pool to 256 Ki floats on hosts (128 Ki on rpi1/wasm) and lowers it to 4 Ki on esp32 (with 256 commands there), so only small animations fit on esp32. Extra commands are dropped, not crashed on.

Follow-ups

  • Dashes, even-odd fill, mattes and non-rectangular masks need rasterizer support (a coverage mask or a per-command fill rule); gradients on arbitrary paths need a gradient paint mode for polygons.
  • The scanline filler walks every edge of a command for each sub-scanline; an active-edge table in raster.cpp would help big, complex paths more than the banding done here.
  • Per-group caching inside animated layers (static groups are re-flattened when a sibling animates).

Edit this page on GitHub ↗