The engine¶
The engine is the Rust crate in rust/engine/, one of the
crates of the Rust workspace, rust/.
maturin compiles it into the package as manimgx._engine, a Python extension module. It
does three things: it draws frames on the GPU, encodes them into an MP4, and typesets text.
Python decides what each frame shows; the engine makes the pixels. It also records a film as
a take, the same work written down, for manimgx's player to play: one player, the same in a
window of its own on this machine's screen (manimgx preview) and on a page's canvas, where
the engine itself, compiled to WebAssembly, is the player.
Why a compiled engine?¶
Drawing a frame means computing millions of pixels, and a GPU does that through native APIs (Metal, Vulkan) that Python cannot reach directly. Typst is a Rust library, and x264 a C library. A compiled extension module holds all of them in the Python process: no subprocess, and no files passed between programs.
The crate's dependencies do the heavy lifting:
- wgpu: one GPU API over Metal, Vulkan and the others.
- PyO3: the Python module, its classes and its functions.
- wasm-bindgen: the player's bindings to JavaScript, and wgpu's to the browser's WebGPU and Web Audio.
- Typst (
typst,typst-kit,typst-layout): the typesetter, as a library. - mitex: LaTeX converted to Typst.
- x264: the H.264 encoder, a C library,
built from its source with the engine (
rust/x264/). - FFmpeg's audio decoders, with libopus for
Opus: C libraries, built from their sources with the engine (
rust/ffmpeg/). - winit: the window
manimgx previewplays in; cpal: its sound, on macOS and Windows. - rustybuzz, the shaper Typst sets text with, and unicode-bidi: the player's face's text, in lines.
Files¶
| File | What it holds |
|---|---|
src/lib.rs |
The crate: its builds (features), and the content key |
src/render.rs |
The GPU's player (Player, Python's too): the store of shapes, a frame's passes, the GPU |
src/lavapipe.rs |
Mesa's lavapipe, which a Linux wheel bundles, loaded where no GPU driver is |
src/python.rs |
The Python module: the Player and Recorder classes and the functions Python calls |
src/take.rs |
A take's messages, written and read |
src/pack.rs |
A take's arrays, each coded against the one it replaces |
src/project.rs |
The projector: takes as they come, any frame of them drawn on demand, in bounded memory |
src/player.rs |
manimgx's player, on any screen: the projector on a clock, its face and sound, its viewer's input |
src/chrome.rs |
The player's face (its controls, its text), drawn by the engine itself |
src/text.rs |
The face's text: lines shaped as Typst shapes them, in manimgx's fonts |
src/speaker.rs |
The player's sound, following its clock: a window's (cpal), a page's (Web Audio) |
src/window.rs |
The player in a window on this machine's screen (winit) |
src/web.rs |
The player on a page's canvas (WebAssembly) |
src/export.rs |
A film's video: frames converted and handed to the encoder |
src/vector.rs, src/vector.wgsl |
A 2D view, drawn exactly |
src/blend.wgsl |
The raster pipeline: 3D views, point clouds and meshes |
src/paint.wgsl |
Paint, as both pipelines evaluate it |
src/nv12.wgsl |
A frame's changed macroblocks, converted for the encoder |
src/encode.rs |
H.264 through x264 |
src/mp4.rs |
The MP4 file |
src/audio.rs |
Sound read from its files (FFmpeg), and resampled |
src/aac.rs |
A film's sound, as AAC |
src/typeset.rs |
Typst as a library, and mitex |
build.rs |
The player for a page, built beside Pyodide's module and carried inside it |
../mitex-spec-gen/ |
mitex's Typst package (fetched), and the LaTeX command spec made from it |
../x264/ |
x264, built from its source (fetched), behind a C shim and a few safe calls |
../nasm/ |
NASM, built from its source (fetched), for x264's x86-64 assembly |
../ffmpeg/ |
FFmpeg's audio decoders and libopus, built from their sources (fetched), behind a C shim and one safe call |
../fetch/ |
The sources the other crates build from, fetched by their build scripts and pinned by the hash of their files |
Each file begins with a comment that states its design.
The boundary with Python¶
src/manimgx/_engine.pyi
is the engine's interface as Python sees it, typed: the Player and Recorder classes,
the typesetting functions, digest (the content key that names what is uploaded), note
and TypstError. It is
written by hand, so a change to the engine's interface changes it too; ty checks the Python
code against it.
- One module for every Python from 3.13 on. The crate builds against Python's stable
ABI (PyO3's
abi3-py313). - Bytes, not objects. What crosses is packed arrays: control points as float64,
records of 320 bytes, a view of float32s. Python packs them with numpy
(
rendering/feed.py); the engine reads them as they are. A shape crosses as its object defines it, and the engine derives what drawing needs: it flattens a path's curves, and sums a mesh's faces into its vertices' normals. - Upload once, then per frame.
add_path,add_points,add_mesh,add_rowsandadd_texturestore a shape, a set of color rows or an image under a key;grow_pathextends a path, andevictforgets what no frame shows any more. A frame is then a view and its records:renderdraws one and reads it back as RGBA, andpushsends one to the video thatbegin_exportstarted andend_exportfinishes. - A take is the same calls, written down.
Recordertakes the uploads aPlayertakes, thenframefor each frame, and writes them as messages: a take, which the projector reads back (see The player and The browser). A film giventakerecords into one. - The GIL is released while the engine draws and encodes, so other Python threads run.
- One GPU per process, brought up on a thread of its own once the process knows it will
draw (
start_gpu): a drawing command starts it before the rest of the command line loads, and a film with a video or frames as it begins. It comes up meanwhile, and the first frame waits for it; a process that never draws (manimgx --version, a take, a film that only counts its frames) never brings it up. A process that ends before the GPU is up waits for it, as Python exits: exit unloads the drivers, and must not unload one while it starts.
Drawing¶
wgpu takes the high-performance adapter: Metal on macOS, Direct3D 12 on Windows (WARP, on the CPU, where there is no GPU), Vulkan on Linux. The engine needs one that can blend float32 render targets, since a 2D view adds up its coverage in one.
On Linux, where no adapter will do (no GPU driver: a server, a container), the engine draws
with Mesa's lavapipe, Vulkan on the CPU, which the Linux wheels bundle beside it
(manimgx/lavapipe/libvulkan_lvp.so, built from Mesa's source with LLVM linked in by
scripts/release/build_lavapipe.sh).
src/lavapipe.rs loads it as Vulkan's loader loads a driver, and gives wgpu an instance of
it: neither the system's loader nor the environment is involved, and a machine with a GPU
never loads it. An engine built from source has none of its own; there, the system's Mesa
draws.
A 2D view, exactly¶
No tessellation, no stencil, no multisampling: each pixel's coverage is the area of the object inside it, computed from the control points.
- A compute pass flattens every curve of every path into as many segments as its size on screen needs (Wang's bound, to 1/32 of a pixel), every frame, at the size it is drawn.
- A raster pass adds each segment's exact area into the object's rectangle of a float atlas: a fill by its winding, a stroke as pieces along the curve's exact normals, with its joints and caps.
- A compute pass composites each 16×16 tile of the view over the objects that reach it, in draw order. It keeps each pixel as two regions split by a line, so edges that two shapes share do not show what lies under them.
A 2D view's point clouds and meshes are drawn by the raster pipeline, into layers that the composite lays in their place in the order.
See vector.rs and vector.wgsl.
The raster pipeline¶
The raster pipeline draws every object of a 3D view, and a 2D view's point clouds and meshes, with real depth. A vertex is placed by the blend itself: clip = C₁·S₁ + C₂·S₂, where Cₖ = camera · Mₖ is composed on the CPU once per object (the second term only while a shape morphs). The kinds differ only in how their vertices become triangles:
- A path's fill is a fan counted into the stencil, then covered, unless the shape is convex, fully shown and still; its stroke is a ribbon in screen space.
- A point is a disk that faces the camera.
- A mesh is its triangles, optionally textured.
Where 3D surfaces can be seen through, each fragment is appended to a list per pixel, and the lists are composited in depth order: a pixel shows every surface on its ray, the nearer over the farther, whatever order they were drawn in.
A camera's picture, as a ZoomedScene shows it, is a view drawn
first into a texture, which the frame then samples by its key.
See blend.wgsl, and the Player in lib.rs.
Paint¶
paint.wgsl is shared by both pipelines. Colors mix in OKLab, weighted by opacity, as
Python's colors mix. A gradient is evenly spaced stops. A tween's paint is two paints and
how far the second is mixed in: the engine mixes them, so a tween uploads nothing.
Encoding¶
The problem: encoding a video frame by frame repeats work where nothing changed, and most of an animation's frame does not change: a moving square touches a few blocks of the picture.
The engine's answer: the records say what changed, so only that is converted, read back and analyzed.
- For each frame, the engine finds the 16×16 macroblocks that can differ from the previous frame: the old and new footprints of every record that changed. A new camera, objects added or removed, or anything that cannot be bounded makes it the whole frame.
- It converts only those macroblocks to NV12 on the GPU (
nv12.wgsl), and reads only those back. - x264, on a thread of its own, is told which macroblocks are unchanged and skips its
analysis there (
encode.rs). mp4.rswrites each picture as a sample of an MP4; a held frame is one sample that lasts longer. The header is written first, so a player can start before the file has arrived.
Film.export reports how it went: the seconds taken, the part of
them in x264, the share of macroblocks converted, and the file's size.
x264 is built with the engine, from its source (r3223, 8-bit, without its command line), so
nothing is installed and the engine links it statically. Its build
script writes the configuration x264's configure would for the target, then compiles its
C and its assembly with the cc crate: NEON and SVE with the C compiler on AArch64; on
x86-64, SSE to AVX-512 with NASM, which x264's x86 code is written for. NASM comes from its
source too (rust/nasm/): compiled into x264's build script, which runs it as a process of
itself. Elsewhere, x264's C alone; assembly makes x264 two to five times faster. Rust sees
x264 through x264/src/shim.c, which sets x264's parameters and moves pictures in and samples
out, so x264's structs never cross into Rust. The build takes a few seconds, and makes the
same bytes as a configure-built x264.
Typesetting¶
typeset.rs runs Typst as a library. typeset takes Typst source and returns its layout,
read straight off Typst's frames: every glyph with its outline's key, its placement, its
paint and the bytes of the source it draws; every shape as cubic curves; every labelled
group as the items inside it. A glyph's outline comes separately, by key
(glyph_outlines), so it crosses once.
Fonts come from the folders Python passes, manimgx's own among them: the font packages'
(fonts/manimgx-fonts/
and
fonts/manimgx-fonts-cjk/,
published apart and required at exact versions), then from Typst's embedded fonts. The
system's fonts are read only for a family a text names: their scan is cached on disk, and
they are memory-mapped, not read whole (a CJK font can be 60 MB).
LaTeX reaches Typst through mitex (mitex_math, mitex_text). mitex converts by a
command spec: which LaTeX commands it knows, and how many arguments each takes. mitex's
own spec crate reads the spec from a git submodule or makes it with a typst on the PATH;
rust/Cargo.toml replaces that crate with
mitex-spec-gen,
which fetches mitex's Typst package (with its own lib.typ, which imports only the scope the
converted LaTeX calls) and holds the spec (spec.rkyv) made from it. The engine serves the
package to Typst itself, as @preview/mitex:0.2.7, so
the converted code finds its scope with no files on disk, and the converter and the Typst
code it converts for come from the same files. cargo test -p mitex-spec-gen, in rust/,
checks that the committed spec is still what the package makes, and that the engine serves
every file of it; after an upgrade of mitex, MITEX_SPEC=write cargo test -p mitex-spec-gen
writes the new spec. No workflow runs it.
The player¶
manimgx's player plays takes: manimgx preview and
Window in a window of its own on this machine's screen, the npm package on
a page's canvas. It is one player, player.rs, the same code on both screens: a screen gives
it a surface, hands on its viewer's input, draws it when it is due and does what it asks. The
window's screen is winit's (window.rs); a page's is the engine compiled to WebAssembly
(web.rs), with the page's element handing on its events
(browser/src/player.ts).
- Any frame, at once. The player keeps the take as sent (
project.rs), and draws the frame its clock is at, at the size it is shown, in the screen's own pixels. A newer take, the scene run again, replaces the one shown once it has the moment shown, or ends: an edit never loses the place. A take ends with a message of its own (END), which says whether its scene failed: a failed take replaces the shown one only if it got to the moment shown. A save cuts a run it makes out of date (the take raisesCut), and the scene runs again at once. A frame whose see-through layers overflow the lists is drawn again once its count is back: the player never waits for the GPU. - One clock. It runs from an instant at a rate, waits at the last frame made while the take
is being recorded, and stops at the end; a frame is drawn for the moment it is seen (a
refresh of the screen ahead). The sound follows it (
speaker.rs), continuous while the clock runs, again from the clock after a jump or 30 ms of drift: in a window, written for the instant it is heard (cpal; on Linux the window is silent, as the system's audio would be linked, ALSA); in a page, a Web Audio source started at the context's moment that is heard when the clock is there (its output timestamp ties the context's clock to the page's). - Its face is the engine's own drawing. The controls, the time, the plays (its chapters,
named by the lines that played them), the captions, the error and the keys are shapes and
text, records of a 2D view over the film, drawn exactly (
chrome.rs): no toolkit, and one face on both screens. Its text is set in lines, in manimgx's fonts (text.rs), shaped by rustybuzz, the shaper Typst sets text with: a test sets the face's lines both ways and finds the same glyphs in the same places. Typst itself, a typesetter of documents, would be 28 MB of WebAssembly in a page; the lines take 0.7 MB. The player composites the film, at its place, and the face in one pass. A control is where the layout put it, so a click, a tap and a key do the same thing. - Input in the web's words. A key comes as its
KeyboardEvent.keyvalue (winit names keys as the web does), a pointer as its place in pixels, a wheel's turn in points, the web's way round. Each input says whether the player took it, so that a page lets the rest go by: a vertical scroll over a film scrolls the page, a sideways one moves the playhead. - Notes, both ways. The director's notes (JSON) give it the plays, sections and captions, and between takes the file's scenes and the scene's error. The player asks for another of the file's scenes: the window writes the ask as a line of JSON on its standard output, the page posts it to its director.
The window is a process of its own (python -m manimgx.rendering.window, whose main
thread runs the window's event loop, _engine.window), and the take reaches it through a pipe, on its
standard input: so the scene keeps its process — its main thread, its signals — as
manimgx render gives them, a script or a notebook can open one, and a scene that crashes
leaves the window up. A Window is a take: Scene.render(take=window). Closing it cuts the
film sent to it (Cut); it closes when its director closes it, or goes. On a page, the player
runs on the page's own thread, as the window's runs on its own: its events, its clock, its
drawing and its sound on one thread, so it says at once whether it took an input.
Measured on an M4 Pro: a save is on screen 43-79 ms later; the window draws the heaviest example film (the Hopf fibration, 2560×1440) in 0.5-1.7 ms a frame (the median), using 5-17% of a core at 60 Hz in 150-320 MB. In Chrome, a page's thread spends 0.7 ms a frame on it, face included (the median; 1.2 ms at the 95th percentile, at 1920×1080).
The browser¶
In the browser, Python runs in Pyodide, which has no GPU, and the engine is two WebAssembly builds of the same crate:
- The Python module, without the GPU (features
pythonandtypeset): typesetting, the content keys, sound read, andRecorder, but noPlayer. Pyodide's toolchain compiles it forwasm32-unknown-emscripten, and it ships as manimgx's wheel for the browser (pyemscripten_2026_0_wasm32, PEP 783). There, every film is recorded as a take. - The player (feature
web, forwasm32-unknown-unknown):player.rson a page's canvas, drawing with WebGPU and sounding with Web Audio (web.rs). The page's element hands it the take as it comes (feed), its viewer's input (key,pointer,press,release,cancel,wheel) and its canvas's size, and draws it when it is due (wake,draw). It keeps the take as sent, coded, and makes a frame's shapes from it when the frame is drawn; the GPU's player keeps what the frames drawn lately drew, up to a budget. Its face's fonts come from Pyodide, the ones the window's face is set in:_engine.face_fontsgives manimgx's font files and Typst's monospace font.browser/holds the page's side: the element and the director's worker (Pyodide).
A take is the protocol between Python and the player: manimgx preview sends one through a
pipe to its window, and in the browser the director hands one from Pyodide to the page. What
its uploads hold goes as arrays (pack.rs), each under a key of its
kind and content, sent once, and coded against the array it replaces: the same role's in what
the same record slot drew the frame before, which the writer reads off the frames' records. A
film whose shapes change a little every frame is sent as their changes: the example films'
takes are 14 times smaller for it (50 GB in all before, 3.6 GB after), and a 3D film of 30
seconds is tens of megabytes, where it was gigabytes. A mesh's points, uvs and normals go as
float32, the precision the GPU draws them at; everything else goes exactly as Python gave it. The frames the browser draws match the native engine's: of the
integration corpus's 841 scenes, 534 are identical in every frame, and the others differ by
rounding, one level in a few pixels.
Others' sources¶
The repository holds only manimgx's code. What the engine is built from that others wrote —
x264 and NASM, FFmpeg's audio decoders and libopus, mitex's Typst package — its crates' build
scripts fetch from where each project publishes it (fetch::tree, in rust/fetch/), each
pinned by the SHA-256 of its files: their paths and bytes, not an archive's, which hosts
regenerate, so any copy of the same files passes. A tree is fetched once into the crate's
build folder and found again by that hash. The first build needs the network, as cargo does
for crates. MANIMGX_SOURCES names a folder for the archives (an absolute path): a build reads
an archive there, and downloads it there first if it isn't. A folder that holds them builds
offline; one shared by your checkouts downloads each archive once; and a build into an empty
one gathers what it was built from. A build script fetches the same archives on every target,
whatever it compiles of them, so one build gathers what every build reads.
That is how a release ships the wheels' complete source, which x264's GPL and FFmpeg's LGPL
ask for: manimgx-X.Y.Z-source.tar.xz, on the GitHub release (just build-source makes it).
It is the source distribution with vendor/, every crate rust/Cargo.lock names
(cargo vendor), sources/, the archives, and a .cargo/config.toml that reads them,
offline. The wheel the recipe builds from it gathers the archives into sources/, and shows
that nothing else is needed. Unpacked, it builds a wheel with the tools alone (Rust, a C
compiler, and maturin, which uv installs), reading nothing else from the network:
Each is built by its crate's build script with the cc crate, the C compiler alone, the same
way on every target: no project's own build system runs. x264's and NASM's scripts write the
configuration their configure would for the target. FFmpeg's configure resolved the
components manimgx decodes with once, and rust/ffmpeg/build.rs keeps what it resolved (the
names it turns on, the files it compiles; the command is in the script's comment): its
configuration is written from that, in portable C (no assembly, no threads: one file decodes
at a time), the same on every target, the browser's included. Opus goes to libopus, the
reference decoder, which passes all of RFC 8251's test vectors (FFmpeg's own fails four).
rust/ffmpeg/src/shim.c decodes a file from memory into float samples, at most two channels
(wider sound is downmixed to stereo, scaled so it can't clip), starting and ending where the
file declares.
Building¶
uv sync, and everyuv run, rebuild the engine when one of its sources changes, with maturin's release build: see Project management.just build-wheelbuilds this machine's wheel as a release does, with cibuildwheel, and tests it on every supported Python (see Project management);just build-wheel pyodidebuilds the browser's, with Pyodide's toolchain.build.rsbuilds the player for a page with Pyodide's module (a cargo of its own, inrust/target/web-player/), and the module carries it:_engine.web()gives its JavaScript and WebAssembly (engine.js,engine_bg.wasm), which the director hands to the page. It needs Rust's WebAssembly target (rustup target add wasm32-unknown-unknown); without it the module builds, with a warning. A native module carries none: natively, a film plays in a window.- Cargo run by hand in
rust/(cargo clippy,cargo test -p mitex-spec-gen,cargo test -p fetch) uses thedevprofile, which is optimized too (opt-level = 2). rust/engine/Cargo.tomldenies Clippy's correctness lints and warns on its suspicious and performance ones. No recipe, check or workflow runs Clippy: runcargo clippyinrust/.
The engine is tested through Python, by the suite and the integration corpus (see
Testing); its Rust unit tests (cargo test --lib, in rust/) check that a take
reads back as it was written, that its arrays' codings decode exactly, and how the projector
takes takes in: a newer one replacing the shown one at the frame shown, a failed one dropped
or ended, the stream read whole however it is cut. They also check the player: its keys by the
web's names, its chapters, a take's sound read as it was written, and the face's lines, set as
Typst sets them. The player in a page is tried by hand, in a browser. cargo test -p fetch
checks MANIMGX_SOURCES: an empty folder gathers an archive, whole, and a full one builds
with the archive gone from its URL.