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:
- 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.dprobe measures both: ~4e-16vs ~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. - 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;elseflatten and CPU-triangulate.
The four backends and how the cubic-canonical choice interacts:
| Backend | Kind | Bind via | Curve handling |
|---|---|---|---|
| Cairo | CPU vector, deterministic reference | ImportC (easy C API) | native cubic — no lowering |
| Blend2D | CPU vector, JIT (faster) | ImportC (easy) | native cubic — no lowering |
| raylib/GL | GPU + TakeScreenshot | already wired | flatten+triangulate or GPU winding |
| wasm→Canvas2D/WebGL | web preview | scene in wasm, drawn in JS | Canvas2D 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 outlines → VMobject. 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.
| M | Goal | Reuses / borrows from | Key risk | Prove-it |
|---|---|---|---|---|
| M1 | libs/math: matrix/affine/quaternion, cubic Bézier (eval/subdivide/flatten/align), ease, gamma Color | the probes; MetaPost Hobby curves | keep it wasm/betterC-clean | unittests: split reproduces the curve; sRGB↔linear round-trips; all @safe pure @nogc |
| M2 | Object model: Mobject GC tree + VMobject SoA (cubic + parallel colour) | Manim-community cubic layout | GC-tree vs arena; SoA churn | build a scene graph; dump family() + point counts |
| M3 | First backend: Cairo via ImportC + frame capture (the reference) | libs/ghostty recipe | ImportC/Cairo header quirks; unique c.c stem | a static scene → byte-stable PNG |
| M4 | Animation + play loop + Transform alignment | Manim timing | alignment correctness | Transform(square → circle) frame sequence |
| M5 | Encoding: ffmpeg subprocess + per-play() content-hash cache | ManimGL pipe; Manim-community cache | cache-key determinism | one scene → cached MP4; re-run skips unchanged plays |
| M6 | Typesetting: HarfBuzz+FreeType text + LaTeX→dvisvgm math | Manim text pipeline | SVG-path fidelity; TeX hermeticity | animate a rendered equation + a shaped text line |
| M7 | Second backend: raylib/GL + readback, diffed vs Cairo oracle | apps/terminal raylib surface | GPU↔CPU pixel divergence | same scene on GL, perceptually diffed |
| M8 | wasm/web preview: scene/geometry/easing → wasm32-wasip1, drawn on Canvas2D | buildDWasmModule; Motion Canvas | single-module, no-C-libs (esp. text) | a docs widget playing a scene in-browser |
| M9 | Reactive: ValueTracker + updaters | Makie observables; Manim updaters | per-frame updater churn | a graph tracking a live ValueTracker |
| M10 | (stretch) Blend2D backend; libav-in-process; MicroTeX C-wrapper | ImportC recipe | Blend2D determinism vs Cairo | drop-in backend/encoder swap, diffed vs reference |
Open questions & risks
- Cubic vs quadratic lock-in — cubic optimises three of four backends but makes GL lower every frame. Mitigation:
SubPathexposes acurves()range, not a raw stride, so the basis is swappable; reconsider before making GL primary. - GC tree vs arena — the per-frame family walk can pressure the GC. Mitigation: the payload is already value-type SoA;
MobjectIdlets hot data move to an arena later. Reversible by construction. @nogcvs GC updaters — forcing@nogchurts authoring; free allocation risks churn. Mitigation: the preallocated-payload convention; measure with the repo's bench tooling.- 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.
- 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.
- GPU winding-fill vs CPU triangulation — winding handles arbitrary paths but needs a stencil pass; earcut needs simple polygons (hence
skia-pathopsboolean pre-processing). Mitigation: start with CPU flatten+triangulate for M7; add GPU winding as a capability later. - 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.