Skip to content

Manim Community Edition (Python)

The community-maintained fork of 3Blue1Brown's manim: a retained-mode, CPU-vector 2D animation engine that turns a Scene.construct method into an MP4 by rasterizing a cubic-Bézier scene graph with Cairo and muxing the frames through PyAV.

Last reviewed: July 11, 2026

FieldValue
LanguagePython (requires-python >=3.11, pyproject.toml:23)
LicenseMIT (Copyright (c) 2024, the Manim Community Developers, LICENSE.community)
RepositoryManimCommunity/manim (reviewed at 4d25c031, v0.20.1)
Documentationdocs.manim.community
CategoryRetained-mode 2D vector animation engine (imperative scene-graph → video)
First releasev0.1.0 — October 21, 2020, forked from 3b1b/manim (0.1.0-changelog.rst)
Default rendererCairo (CPU vector, config.renderer == RendererType.CAIRO); optional OpenGL/moderngl
Bézier basisCubic (Cairo VMobject, n_points_per_cubic_curve = 4); quadratic (OpenGLVMobject, = 3)
Video encoderPyAV (import av → libav/FFmpeg): libx264/yuv420p default; libvpx-vp9, qtrle for webm/transparent

NOTE

Every file:line on this page is against the local clone at 4d25c031 (v0.20.1). The manim project ships two renderers with two geometry models in one tree; unqualified statements below describe the default Cairo path, and the OpenGL divergences are called out explicitly. The sibling deep-dive manimgl covers 3Blue1Brown's upstream, which is quadratic-and-GPU throughout.


Overview

What it solves

manim is a library for programmatic explanatory-math animation: you subclass Scene, build mathematical objects (Mobjects) in a construct method, and call self.play(Animation(...)) to advance time. The README states the scope plainly:

"Manim is an animation engine for explanatory math videos. It's used to create precise animations programmatically, as demonstrated in the videos of 3Blue1Brown."README.md

The engine's job is the whole path from that Python description to an encoded video: a retained scene graph of vector objects, a time-sampled interpolation loop, a CPU vector rasterizer, a LaTeX/Pango typesetting front-end, and a content-hash cache that skips re-rendering unchanged play() calls.

Design philosophy

Manim CE began as a cleanup fork rather than a rewrite — the first changelog is explicit:

"This is the first release of manimce after forking from 3b1b/manim. As such, developers have focused on cleaning up and refactoring the codebase while still maintaining backwards compatibility wherever possible."0.1.0-changelog.rst

Three architectural commitments follow from that lineage and are load-bearing for anything that reimplements the engine:

  1. Everything on screen is a Mobject. The base class (mobject.py:72) is described as "Mathematical Object: base class for objects that can be displayed on screen" — a tree of submobjects with a points array. Geometry, text, and groups are all the same node type.
  2. Vector-first, cubic-Bézier geometry. A displayable shape is a VMobject: a flat points array grouped into 4-point cubic Bézier curves, rasterized by Cairo. Nothing is a bitmap until the final frame.
  3. A dual-backend metaclass, chosen at import. ConvertToOpenGL (opengl_compatibility.py:17) is a "Metaclass for swapping (V)Mobject with its OpenGL counterpart at runtime depending on config.renderer" — so the same user class name resolves to a Cairo (cubic) or an OpenGL (quadratic) base class depending on config.

How it works

A render is a straight-line pipeline. The CLI (manim render scene.py Scene, built on click/cloup, __main__.py:3) instantiates the Scene, runs construct, and each self.play(...) drives one iteration of the loop below.

python
# scene.py — the per-play() loop (CairoRenderer.play, cairo_renderer.py:64)
scene.compile_animation_data(*args, **kwargs)          # build Animation list, split moving/static
hash_current_animation = get_hash_from_play_call(      # content hash of this call
    scene, self.camera, scene.animations, scene.mobjects)
if self.file_writer.is_already_cached(hash_current_animation):
    self.skip_animations = True                        # cache hit → skip rendering
...
scene.play_internal()                                  # sample time, render, encode
self.num_plays += 1

play_internal (scene.py:1351) samples wall-clock time into frames, and for each frame sets every animation's progress and repaints:

python
# scene.py:1364 — deterministic frame sampling + interpolate + render
self.time_progression = self._get_animation_time_progression(self.animations, self.duration)
for t in self.time_progression:                        # times = np.arange(0, run_time, 1/frame_rate)
    self.update_to_time(t)                             # alpha = t/run_time; animation.interpolate(alpha)
    self.renderer.render(self, t, self.moving_mobjects)

update_to_time (scene.py:1687) computes alpha = t / animation.run_time and calls animation.interpolate(alpha), which reshapes alpha through the rate_func, lerps each submobject's points and colors, and mutates the scene graph in place. renderer.render then calls camera.capture_mobjects (camera.py:528), which walks each VMobject into a Cairo path and fills/strokes it, and SceneFileWriter encodes the resulting RGBA framebuffer into a partial movie file — one per play() — through PyAV. finish() concatenates the partials into the final MP4.


Object & scene model

Manim is a retained-mode scene graph (../concepts.md): the Scene keeps a persistent list of Mobjects, animations mutate those objects between frames, and the renderer re-reads the whole graph each frame. Mobject (mobject.py:72) is a node holding self.submobjects: list[Mobject] (mobject.py:120) plus a points NumPy array and a color; its __init__ calls reset_points() / generate_points() / init_colors() (mobject.py:125-127) as empty template hooks subclasses fill in.

The submobject list is one-directional: add appends via self.submobjects = list_update(self.submobjects, unique_mobjects) (mobject.py:560) with no parent back-reference, so the "family" of a node is recomputed on demand by get_family (mobject.py:2518), which chains children recursively and de-duplicates (extract_mobject_family_members, family.py:12). New subclasses are wired through __init_subclass__ (mobject.py:100), which resets animation_overrides and captures _original__init__ per class — the hook that lets mob.animate.shift(...) dispatch to a per-type override.

The renderer choice is baked into the type via the ConvertToOpenGL metaclass (opengl_compatibility.py:28): when config.renderer == RendererType.OPENGL, it rewrites a class's bases through a name map ("Mobject": OpenGLMobject, "VMobject": OpenGLVMobject) so the same class Square(...) inherits the quadratic-GPU or cubic-Cairo lineage. The full object model, the cubic points layout, and the fill/stroke split are the subject of scene-graph.md.


Animation & timing model

An Animation (animation.py:30) is "An animation. Animations have a fixed time span." — a function of a scalar alpha ∈ [0,1] that mutates its mobject. Its __init__ (animation.py:128) carries the four timing knobs: run_time, rate_func (default smooth), reverse_rate_function, and lag_ratio. Each frame, interpolate_mobject (animation.py:339) zips the object's family members and applies a staggered sub-progress per member via get_sub_alpha (animation.py:364):

python
# animation.py:384 — lag_ratio staggering of submobject i of n
full_length = (num_submobjects - 1) * lag_ratio + 1
value = alpha * full_length
lower = index * lag_ratio
return self.rate_func(value - lower)          # rate_func saturates outside [0,1]

The rate function reshapes time (easing); it is not clamped here because every exported rate function is wrapped by the unit_interval decorator (rate_functions.py:124), which returns 0 for t < 0 and 1 for t > 1.

WARNING

The default rate_func is smooth (rate_functions.py:156), a normalized logistic sigmoidmin(max((sigmoid(inflection·(t-0.5)) - error) / (1 - 2·error), 0), 1) with inflection = 10.0not the smootherstep polynomial 6t⁵ − 15t⁴ + 10t³. That polynomial is a separate function, smootherstep (rate_functions.py:174). The rate-functions.d probe reimplements smootherstep as a self-contained stand-in for the sigmoid; both are monotone [0,1]→[0,1] S-curves, but the exact default is the sigmoid.

Composition lives in composition.py. AnimationGroup (composition.py:30) lays its children on a timeline via build_animations_with_timings (composition.py:146) — start[1:] = cumsum(run_times[:-1] * lag_ratio), end = start + run_times — so lag_ratio = 0 plays all together, 1 is strict succession (Succession, composition.py:198), and fractional values overlap (LaggedStart, composition.py:297). Transform (transform.py:58) is the workhorse morph: begin (transform.py:200) calls self.mobject.align_data(self.target_copy) to make point counts match, then interpolate_submobject (transform.py:249) lerps points along a path_func and blends color. See ../concepts.md and the rate-functions.d / affine-transform.d probes.


Rendering backend & rasterization

The default backend is Cairo, a CPU vector rasterizer (../concepts.md). Camera imports it directly (import cairo, camera.py:15) and binds a Cairo surface straight onto the NumPy pixel buffer:

python
# camera.py:591 — a Cairo context over the RGBA framebuffer
surface = cairo.ImageSurface.create_for_data(pixel_array.data, cairo.FORMAT_ARGB32, pw, ph)
ctx = cairo.Context(surface)
ctx.set_matrix(cairo.Matrix(pw/fw, 0, 0, -(ph/fh), ...))   # world → pixel affine

display_vectorized (camera.py:677) draws each object as stroke(background) → fill → stroke, and set_cairo_context_path (camera.py:698) feeds Manim's cubic tuples to Cairo's cubic path API one curve at a time: ctx.curve_to(*p1[:2], *p2[:2], *p3[:2]) (camera.py:727). Fill is ctx.fill_preserve() (camera.py:781) — Cairo does its own scan-conversion and winding, so Manim never triangulates for the Cairo path. Anti-aliasing is Cairo's, not Manim's. The framebuffer is 4-channel RGBA (image_mode = "RGBA", n_channels = 4, camera.py:87), and renderer.get_frame returns np.array(self.camera.pixel_array) (cairo_renderer.py:181).

The optional OpenGL backend (OpenGLRenderer, moderngl) is a GPU vector renderer: its OpenGLVMobject is quadratic (n_points_per_curve = 3, opengl_vectorized_mobject.py:112) with quadratic_bezier_fill/quadratic_bezier_stroke shaders, and fill is GPU-triangulated by earclip_triangulation (space_ops.py:718, mapbox_earcut, space_ops.py:10). That earcut path is only reached from the OpenGL renderer (vectorized_mobject_rendering.py:162); the Cairo path never calls it. The two rasterization models — and why cubic-vs-quadratic is load-bearing — are detailed in scene-graph.md and the bezier-eval.d / frame-capture.d probes.


Typesetting & text

Text is also vector geometry — every glyph becomes VMobject outlines. Three front-ends feed one SVG-to-VMobject importer:

All three land in SVGMobject (svg_mobject.py, svgelements parser at svg_mobject.py:11), whose handle_commands (svg_mobject.py:561) walks each path segment and elevates lines and quadratics into the cubic points array (or lowers cubics to quads under OpenGL). The full LaTeX/Pango/Typst mechanics and glyph-outline extraction are in text-pipeline.md.


Output & encoding

Encoding is PyAV (import av, scene_file_writer.py:17) — the libav/FFmpeg Python bindings — with a distinctive one-partial-movie-file-per-play() scheme that is the physical substrate of the cache. open_partial_movie_stream (scene_file_writer.py:540) opens a container per call and picks the codec/pixel-format:

OutputCodecPixel formatSource
default .mp4libx264yuv420pscene_file_writer.py:552
.webmlibvpx-vp9yuv420pscene_file_writer.py:560
.webm transparentlibvpx-vp9yuva420pscene_file_writer.py:562
transparent .movqtrleargbscene_file_writer.py:566

Frames are pushed as av.VideoFrame.from_ndarray(frame, format="rgba") (scene_file_writer.py:449) and muxed on a writer thread. In finish(), combine_files writes an FFmpeg concat list and re-opens it with av.open(str(file_list), format="concat") (scene_file_writer.py:652) to produce the final video, stamped Rendered with Manim Community v{__version__}. Subtitles use srt (srt.compose, scene_file_writer.py:898); PNG/GIF are alternate finish() branches. See ../concepts.md and frame-capture.d.


Interactivity, preview & authoring

Manim is fundamentally a batch renderer, not a live tool. The default flow renders to disk and then opens the file: --preview/-p triggers open_media_file (file_ops.py:220) after the render completes. There is no live Cairo window.

Live interaction is OpenGL-only. Scene.interactive_embed (scene.py:1412) asserts isinstance(self.renderer, OpenGLRenderer) (scene.py:1415) and drops into an IPython embedded shell over the moderngl window; the Cairo backend has no equivalent. Authoring surfaces: a %%manim Jupyter magic (ipython_magic.py), Scene.next_section (scene.py:340) for chaptered output (section.py), and a checkhealth CLI subcommand that verifies the LaTeX/FFmpeg toolchain. The CLI subcommand set is cfg, checkhealth, init, plugins, render (manim/cli/).


Extensibility & API surface

The user-facing API is subclassing + method chaining: subclass Scene, override construct, and compose Mobject subclasses whose methods return self. Extension points:

  • configManimConfig (_config/utils.py:145) is a MutableMapping described as "a single source of truth for all of the library's customizable behavior", layering default.cfg (configparser) < CLI < programmatic assignment; tempconfig (_config/__init__.py:46) scopes overrides. RendererType (constants.py:258) is the cairo/opengl switch.
  • Animation overridesMobject.__init_subclass__ + animation_override_for let a Mobject subclass supply a custom animation for mob.animate.
  • Plugins — a manim plugins subcommand and a manim.plugins package discover third-party Mobject/Scene extensions.
  • Custom Mobjects — override generate_points to fill the points array; the base hooks are deliberately empty (mobject.py:468).

Dependencies (from pyproject.toml) name the whole backend: pycairo, av, manimpango, svgelements, skia-pathops (boolean path ops, boolean_ops.py:8), mapbox-earcut, numpy>=2.1, scipy, networkx, pillow, pygments, and moderngl/moderngl-window (all required, though moderngl is only exercised by the OpenGL renderer). Only typst, gui (dearpygui), and jupyterlab are [project.optional-dependencies] extras.


Determinism, caching & performance

Two orthogonal determinism mechanisms make caching correct.

Deterministic frame sampling. get_time_progression (scene.py:1087) samples times = np.arange(0, run_time, 1 / config["frame_rate"]) — a fixed grid independent of wall-clock speed (../concepts.md), so a given animation always produces the same frames at the same t.

Per-play() content-hash caching. Before rendering, CairoRenderer.play computes get_hash_from_play_call (hashing.py:333) over the camera, the animations, and the current mobjects. The docstring defines the key exactly:

"A string concatenation of the respective hashes of camera_object, animations_list and current_mobjects_list, separated by _."hashing.py:358

Each component is a zlib.crc32 of a custom JSON serialization (hashing.py:366); run-dependent fields are stripped via KEYS_TO_FILTER_OUT = {"original_id", "background", "pixel_array", "pixel_array_to_cairo_context"} (hashing.py:29) — flagged in-source as elements "not suitable for hashing (too long or run-dependent)" (hashing.py:27). The hash is the partial-movie filename (f"{hash_animation}{movie_file_extension}", scene_file_writer.py:270), so is_already_cached (scene_file_writer.py:606) is a single path.exists(), and eviction is LRU by st_atime capped at max_files_cached (default 100, scene_file_writer.py:864). The full mechanism, including the circular-reference _Memoizer and the determinism it depends on, is in caching.md.

Performance profile. The Cairo path is single-threaded per frame; the encoder runs on a background writer thread. The static/moving split (begin_animations, scene.py:1334) paints non-moving objects once into a static image, so only moving_mobjects are re-rasterized per frame.


Strengths

  • Uniform object model. Everything is a Mobject with a points array; geometry, text, and groups compose through one tree and one interpolate.
  • Cubic-Bézier vector geometry feeds Cairo directly (ctx.curve_to) with no approximation — the on-screen curve is exactly the stored curve.
  • Correct, cheap caching. Deterministic sampling + a content hash that is the filename makes cache hits a path.exists() check; unchanged play() calls cost nothing.
  • Rich typesetting. LaTeX, Pango markup, and Typst all reduce to vector outlines, so math and text animate identically to shapes.
  • Batteries-included encoding. PyAV covers MP4/WebM/GIF/PNG and transparent output without shelling out to a separate ffmpeg binary.

Weaknesses

  • Two geometry models in one tree. Cubic (Cairo) vs quadratic (OpenGL) means a curve is stored differently per backend, and the cubic→quadratic lowering is lossy (opengl_vectorized_mobject.py:499).
  • CPU-bound default renderer. Cairo rasterizes one frame at a time on one thread; complex scenes are slow, and the OpenGL alternative is less mature.
  • Heavy external toolchain. LaTeX text needs a full TeX install + dvisvgm as subprocesses; a missing binary fails at render time.
  • No live editing on the default backend. Interactivity requires the OpenGL renderer; Cairo is render-then-open.
  • Fork drift. API-compatible with 3b1b/manim only "wherever possible"; ManimGL and Manim CE have diverged substantially (see manimgl).

Key design decisions and trade-offs

DecisionRationaleTrade-off
Retained scene graph of MobjectsOne uniform node type; animations mutate persistent objectsWhole graph re-read per frame; no immediate-mode escape hatch
Cubic Bézier basis on the Cairo defaultMaps 1:1 to Cairo's curve_to; exact on-screen curvesDiverges from the quadratic OpenGL/ManimGL basis; lossy interchange
Dual backend via ConvertToOpenGL metaclassSame user code targets CPU-vector or GPU-vector by configTwo geometry models, two rasterizers, two fill strategies to maintain
Cairo (CPU) as default rasterizerDeterministic, dependency-light, high-quality AA out of the boxSingle-threaded per frame; GPU speed only via the less-mature OpenGL path
Text = vector outlines (LaTeX/Pango/Typst → SVG)Math and text animate exactly like shapes; resolution-independentNeeds external TeX/dvisvgm subprocesses; slow first compile per string
PyAV per-play() partial movie filesEach call is an independent, cacheable, concatenatable clipMany small files; a concat pass and cache eviction to manage
Content-hash cache keyed by the filenameCache hit is a path.exists(); deterministic sampling makes it soundHashing must strip run-dependent state; a missed field silently over-caches

Sources

Primary (local clone ManimCommunity/manim@4d25c031, v0.20.1):

Official docs: docs.manim.community · reference repository.

Deep sub-topics: scene graph & geometry · text pipeline · caching & determinism. Shared vocabulary: ../concepts.md. Sibling engine: manimgl. Runnable probes: bezier-eval.d · rate-functions.d · affine-transform.d · frame-capture.d.