Skip to content

The Sparkles Animation Engine — a design direction

A milestoned architecture direction for a Manim-class mathematical-animation engine in idiomatic D, targeting both a deterministic native-video path and an interactive web/wasm path. It synthesises the survey — the Manim object model, the timing models of Motion Canvas/Remotion/Makie, the rendering backends, the typesetting and encoding pipelines — against the Sparkles baseline.

Last reviewed: July 11, 2026

IMPORTANT

This is design direction, not a commitment: no code is proposed for merge here. The milestones sketch a build order and name the prior art each step borrows; the concrete design lands later under docs/specs/ or docs/libs/<name>/.

Two framing principles

Everything below follows from two decisions the survey makes defensible:

  1. Cubic-canonical, capability-gated. Store and interchange geometry as cubic Béziers. Cairo, Blend2D, and HTML Canvas2D all consume cubics natively, SVG import from typesetting is cubic-native, and quadratic→ cubic elevation is exact while cubic→quadratic is lossy (the bezier-eval.d probe measures both: ~4e-16 vs ~0.86). So a cubic store costs zero conversion on the whole deterministic-video path and the default web path; only the GPU backend lowers to quadratics/triangles — and it was going to tessellate arbitrary fills anyway. This is the opposite of ManimGL's quadratic-canonical choice, which is right only when the GPU is the sole target.
  2. GC for topology, value types for the hot payload. The scene-graph tree is a reference-semantic, GC-managed final class (matches Manim's identity model, makes updater closures natural, and the baseline already runs a GC under wasm). The per-frame numeric payload — points and colours — lives in allocator-conscious Structure-of-Arrays value buffers so interpolation stays @nogc.

Core object model

A Mobject is a GC-managed tree node (id, parent back-ref, submobjects, a value-type Geometry payload, a Paint, a local affine transform, updaters, a dirty flag, and a lazy family() range). A VMobject's geometry is Structure-of-Arrays (the Manim-community-Cairo layout, not ManimGL's interleaved structured array): cubic subpaths plus parallel per-anchor colour/width arrays, so Transform's two passes (equalise point counts, then lerp points and colours) are independent and vectorisable. The GPU backend packs the interleaved [x,y,z,r,g,b,a,w] vertex buffer as a pack step, not the canonical store. A MobjectId indirection is kept as insurance: the payload can migrate to a component arena for an ECS-style @nogc hot loop without touching call sites.

libs/math grows from its lone Vector type: Matrix/Affine2/Affine3, Quaternion, CubicBezier/QuadraticBezier (eval/subdivide/flatten/elevate), curve alignment (equalizeCurveCounts), an ease module (rate functions), lerp, and a Color = Vector!(float,4,["r","g","b","a"]) with an sRGB↔linear pair (gamma — interpolate in linear, encode at the paint boundary). This layer stays C-library- and OS-free so it compiles to wasm32-wasip1.

Animation & timing model

Mirror Manim's Animation primitive (begin → interpolate(alpha) → finish, with run_time, a pure float→floatrate function, and lag_ratio staggering). Composition (AnimationGroup/Succession/LaggedStart) is a small value-type combinator algebra; laziness belongs in the sampling (the play loop pulls a range of (frame, t)), not the composition. Transform uses Manim's alignment on the SoA payload. The reactive layer — ValueTracker + addUpdater + alwaysRedraw — is where the GC-tree pays off (updaters are naturally closures); the convention is "compute into a preallocated payload" so per-frame updaters don't churn the GC.

The backend abstraction — the crux

Use the repo's Design-by-Introspection idiom (versions/traits.d): a required renderer surface plus an optional capability vocabulary that generic code static ifs over.

  • Required: beginFrame / fillPath / strokePath / endFrame / readback (cubic subpaths in device space; transform baked into the submitted path).
  • Optional (DbI): hasNativeCubicFill, hasNativeClip, hasNativeGlyphs, hasGpuWindingFill, hasImageBlit. Generic code: static if (hasNativeCubicFill!R) submit cubics directly; else static if (hasGpuWindingFill!R) submit the raw soup for stencil-and-cover; else flatten and CPU-triangulate.

The four backends and how the cubic-canonical choice interacts:

BackendKindBind viaCurve handling
CairoCPU vector, deterministic referenceImportC (easy C API)native cubic — no lowering
Blend2DCPU vector, JIT (faster)ImportC (easy)native cubic — no lowering
raylib/GLGPU + TakeScreenshotalready wiredflatten+triangulate or GPU winding
wasm→Canvas2D/WebGLweb previewscene in wasm, drawn in JSCanvas2D native cubic; WebGL lowers

Three of four are cubic-native, so cubic-canonical means zero conversion on the deterministic-video and default-web paths. Cairo is the reproducible oracle (the CPU-vs-GPU determinism gap is real; GL is the interactive path, reconciled by a perceptual-diff tolerance). Not chosen: Vello (no C API → hardest to bind) and Skia (C++, no stable C ABI → heaviest wrapper), though both are watch-list options if the GPU path becomes primary.

Math typesetting strategy

Per the typesetting survey: non-math text via HarfBuzz + FreeType through ImportC (the best C-ABI fit — stable C APIs, no C++ wrapper) → glyph outlinesVMobject. Math primary:LaTeX → dvisvgm → SVG → VMobject, exactly like Manim, with the TeX distribution Nix- pinned (which neutralises the usual subprocess-nondeterminism objection) and per-equation content-hash caching. TeX-free fallback: MicroTeX behind an extern "C" wrapper (it is C++, so not pure ImportC — a bounded but real lift). Web math: Typst compiled to wasm (it already ships one) or KaTeX for a docs preview — noting Typst's math syntax differs from LaTeX.

Video / encoding

Per the encoding survey: ffmpeg subprocess with a raw-RGBA pipe first (ManimGL-style — zero ABI, deterministic when Nix-pinned), moving to libav via ImportC in-process later (the Manim-community PyAV pattern) when the pipe becomes a bottleneck. Adopt Manim community's per-play()content-hash caching + partial-file concat — the biggest iteration-speed win, correct only because the Cairo render and the pinned encoder are deterministic. Skip GStreamer.

Milestone roadmap

Design-direction only; each milestone names the prior art it borrows and a "prove-it" artifact.

MGoalReuses / borrows fromKey riskProve-it
M1libs/math: matrix/affine/quaternion, cubic Bézier (eval/subdivide/flatten/align), ease, gamma Colorthe probes; MetaPost Hobby curveskeep it wasm/betterC-cleanunittests: split reproduces the curve; sRGB↔linear round-trips; all @safe pure @nogc
M2Object model: Mobject GC tree + VMobject SoA (cubic + parallel colour)Manim-community cubic layoutGC-tree vs arena; SoA churnbuild a scene graph; dump family() + point counts
M3First backend: Cairo via ImportC + frame capture (the reference)libs/ghostty recipeImportC/Cairo header quirks; unique c.c stema static scene → byte-stable PNG
M4Animation + play loop + Transform alignmentManim timingalignment correctnessTransform(square → circle) frame sequence
M5Encoding: ffmpeg subprocess + per-play() content-hash cacheManimGL pipe; Manim-community cachecache-key determinismone scene → cached MP4; re-run skips unchanged plays
M6Typesetting: HarfBuzz+FreeType text + LaTeX→dvisvgm mathManim text pipelineSVG-path fidelity; TeX hermeticityanimate a rendered equation + a shaped text line
M7Second backend: raylib/GL + readback, diffed vs Cairo oracleapps/terminal raylib surfaceGPU↔CPU pixel divergencesame scene on GL, perceptually diffed
M8wasm/web preview: scene/geometry/easing → wasm32-wasip1, drawn on Canvas2DbuildDWasmModule; Motion Canvassingle-module, no-C-libs (esp. text)a docs widget playing a scene in-browser
M9Reactive: ValueTracker + updatersMakie observables; Manim updatersper-frame updater churna graph tracking a live ValueTracker
M10(stretch) Blend2D backend; libav-in-process; MicroTeX C-wrapperImportC recipeBlend2D determinism vs Cairodrop-in backend/encoder swap, diffed vs reference

Open questions & risks

  1. Cubic vs quadratic lock-in — cubic optimises three of four backends but makes GL lower every frame. Mitigation: SubPath exposes a curves() range, not a raw stride, so the basis is swappable; reconsider before making GL primary.
  2. GC tree vs arena — the per-frame family walk can pressure the GC. Mitigation: the payload is already value-type SoA; MobjectId lets hot data move to an arena later. Reversible by construction.
  3. @nogc vs GC updaters — forcing @nogc hurts authoring; free allocation risks churn. Mitigation: the preallocated-payload convention; measure with the repo's bench tooling.
  4. Determinism across backends — Cairo is the byte-stable oracle; GL will not be bit-identical. Mitigation: perceptual-diff tolerance; Nix-pin every rasteriser, encoder, and TeX distribution on the reference path.
  5. wasm's no-C-libs constraint for text — HarfBuzz/FreeType cannot run in the wasm module. Mitigation: precompute glyph outlines natively and ship them as data, or shape/draw text on the JS side (Canvas/KaTeX/Typst-wasm); keep only geometry/easing in wasm.
  6. GPU winding-fill vs CPU triangulation — winding handles arbitrary paths but needs a stencil pass; earcut needs simple polygons (hence skia-pathops boolean pre-processing). Mitigation: start with CPU flatten+triangulate for M7; add GPU winding as a capability later.
  7. MicroTeX is C++ — ImportC cannot bind it directly; it needs an extern "C" wrapper TU. Keep Nix-pinned LaTeX→dvisvgm as primary.

Sources

This proposal synthesises the catalog. Its architectural claims are grounded in the surveyed deep-dives and building-block pages linked throughout, the concepts vocabulary, and the baseline audit of the sparkles repository; the field-wide capability picture and the per-capability delta are in the comparison.