Skip to content

Manim's scene graph: Mobject, VMobject, and the cubic-Bézier basis ​

How Manim Community models a drawable: a one-directional tree of Mobject nodes, each carrying a flat points array that a VMobject interprets as cubic Bézier curves — and why "cubic vs quadratic" is the single most load-bearing geometry decision in the codebase. This is the object-model companion to the Manim Community deep-dive; shared terms live in ../concepts.md.


How it works ​

Mobject: a node with points and children ​

Mobject (mobject.py:72) — "Mathematical Object: base class for objects that can be displayed on screen" — is the universal node. Its __init__ (mobject.py:107) sets a handful of fields and calls three template hooks:

python
# mobject.py:107 — the base Mobject constructor
def __init__(self, color=WHITE, name=None, dim=3, target=None, z_index=0):
    ...
    self.submobjects: list[Mobject] = []      # children (no parent back-ref)
    self.updaters: list[_Updater] = []
    self.color = ManimColor.parse(color)
    self.reset_points()                       # → self.points = np.zeros((0, self.dim))
    self.generate_points()                    # empty hook; subclasses build geometry
    self.init_colors()                        # empty hook; subclasses set rgba arrays

reset_points (mobject.py:456) initializes points to an empty (0, dim) array; generate_points (mobject.py:468) and init_colors (mobject.py:461) are deliberately empty — a subclass like Circle or Square overrides generate_points to fill the array. Two fields make the whole model: points (the geometry) and submobjects (the children). This is retained mode: the objects persist across frames and animations mutate them in place.

The family tree is one-directional ​

add (mobject.py:475) only appends to the child list — self.submobjects = list_update(self.submobjects, unique_mobjects) (mobject.py:560) — and stores no parent pointer. Consequently the transitive closure (the "family") is computed, not stored, by get_family (mobject.py:2518):

python
# mobject.py:2548 — family = self + recursively flattened children, de-duplicated
sub_families = [x.get_family() for x in self.submobjects]
all_mobjects = [self] + list(it.chain(*sub_families))
return remove_list_redundancies(all_mobjects)

family_members_with_points (mobject.py:2552) filters that to nodes with get_num_points() > 0 — the set the renderer actually draws and the set Transform zips over. The free function extract_mobject_family_members (family.py:12) does the same across a list of roots, optionally z_index-sorted. Because there are no back-references, a node can be added to at most one displayed tree at a time, and duplicate-add is a no-op with a warning (mobject.py:554).

__init_subclass__ wires per-type animation overrides ​

Every Mobject subclass runs __init_subclass__ (mobject.py:100), which resets cls.animation_overrides = {}, calls _add_intrinsic_animation_overrides(), and snapshots cls._original__init__. That is the machinery behind mob.animate.shift(...) and @override_animate: animation_override_for (mobject.py:186) looks up a type-specific animation, so a subclass can replace how it is transformed without touching the Animation classes.


VMobject and vector geometry ​

A drawable shape is a VMobject (vectorized_mobject.py:81) — a Mobject whose points array encodes Bézier curves. On the default Cairo backend it is cubic: n_points_per_cubic_curve = 4 (vectorized_mobject.py:129, an __init__ default), so points is a flat run of [anchor, handle, handle, anchor, anchor, handle, handle, anchor, ...]. set_anchors_and_handles (vectorized_mobject.py:841) shows the interleaved storage directly:

python
# vectorized_mobject.py:842 — four control arrays interleaved into one points array
nppcc = self.n_points_per_cubic_curve  # 4
arrays = [anchors1, handles1, handles2, anchors2]
for index, array in enumerate(arrays):
    self.points[index::nppcc] = array         # points[0::4]=A0, [1::4]=H1, [2::4]=H2, [3::4]=A1

and gen_cubic_bezier_tuples_from_points (vectorized_mobject.py:1305) recovers the curves by "take every nppcc element" — points[i:i+4] for i in range(0, len, 4). Curves are built by add_cubic_bezier_curve_to (vectorized_mobject.py:927) (append [handle1, handle2, anchor]), add_line_to, and set_points_as_corners (vectorized_mobject.py:1110), which places handles on the segment so the cubic degenerates to a straight line:

python
# vectorized_mobject.py:1152 — corners: handles interpolated along each edge
self.set_anchors_and_handles(
    *(interpolate(points[:-1], points[1:], t) for t in self._bezier_t_values))

Fill and stroke are separate RGBA arrays ​

Color is not one attribute but several parallel arrays. set_fill (vectorized_mobject.py:282) writes fill_rgbas (vectorized_mobject.py:327); set_stroke (vectorized_mobject.py:332) writes stroke_rgbas orbackground_stroke_rgbas (vectorized_mobject.py:344) depending on the background flag. interpolate_color (vectorized_mobject.py:1894) lerps all of them independently — fill_rgbas, stroke_rgbas, background_stroke_rgbas, stroke_width, background_stroke_width, sheen_direction, sheen_factor — which is why a morph can cross-fade fill and stroke on different schedules. Cairo consumes them as stroke(background) → fill → stroke (display_vectorized, camera.py:677).

Point-count alignment before interpolation ​

Mobject.interpolate (mobject.py:3080) is a straight lerp — self.points = path_func(mobject1.points, mobject2.points, alpha) followed by interpolate_color (mobject.py:3149) — and it requires equal point counts. VMobject.align_points (vectorized_mobject.py:1747) makes that true: it first align_rgbas (stretch the color arrays to equal length, vectorized_mobject.py:1875), then subdivides curves and inserts null subpaths so both objects have matching subpath structure. Transform.begin (transform.py:200) calls this via align_data before the first frame; pointwise_become_partial (vectorized_mobject.py:1918) is the related operation that carves a [a,b] sub-span out of a spline (used by Create/ShowPartial), splitting inner cubics with partial_bezier_points and integer_interpolate (vectorized_mobject.py:1972). See ../concepts.md and the affine-transform.d probe for the coordinate-space math.


Bézier basis: cubic vs quadratic ​

This is the crux. Manim ships two VMobject lineages with different Bézier degrees, selected by the ConvertToOpenGL metaclass (opengl_compatibility.py:17) — "Metaclass for swapping (V)Mobject with its OpenGL counterpart at runtime depending on config.renderer":

BackendClassPoints/curveBasisFill
Cairo (default)VMobjectn_points_per_cubic_curve = 4 (:129)cubicCairo fill_preserve (winding)
OpenGLOpenGLVMobjectn_points_per_curve = 3 (:112)quadraticGPU earcut triangulation

When config.renderer == RendererType.OPENGL, the metaclass rewrites base classes through {"Mobject": OpenGLMobject, "VMobject": OpenGLVMobject} (opengl_compatibility.py:31), so class Dot(VMobject) silently inherits the quadratic lineage with its quadratic_bezier_fill / quadratic_bezier_stroke shaders (opengl_vectorized_mobject.py:84).

The conversion is asymmetric ​

A quadratic elevates to a cubic exactly; a cubic lowers to quadratics lossily. Manim's own code encodes both directions.

Quadratic → cubic (exact). The cubic VMobject accepts a quadratic and degree-elevates it, with an unusually candid comment:

python
# vectorized_mobject.py:978 — add_quadratic_bezier_curve_to elevates to a cubic
# 2. Place the 2 middle control points 2/3 along the line segments
#    from the end points to the quadratic curve's middle control point.
# I think that's beautiful.
self.add_cubic_bezier_curve_to(
    2 / 3 * handle + 1 / 3 * self.get_last_point(),
    2 / 3 * handle + 1 / 3 * anchor,
    anchor,
)

That 2/3-rule is the same exact elevation the SVG importer uses (handle_commands for n_points_per_curve == 4, svg_mobject.py:586: add_quad → add_cubic(start, (start+2cp)/3, (2cp+end)/3, end)), and it is exactly what the bezier-eval.d probe reproduces — printing a max sample deviation of 0 within floating-point epsilon, confirming quadratics are a strict subset of cubics.

Cubic → quadratic (lossy). The OpenGL lineage cannot store a cubic, so OpenGLVMobject.add_cubic_bezier_curve_to (opengl_vectorized_mobject.py:496) calls get_quadratic_approximation_of_cubic(...) and stores two quadratics per cubic (opengl_vectorized_mobject.py:499). The SVG importer does the same on the quadratic path (svg_mobject.py:602). A single quadratic cannot follow a cubic's inflection; the bezier-eval.d probe fits the best single quadratic to an inflected cubic and prints the residual error — the per-curve cost the quadratic backends pay, split across two quads to keep it small. See ../concepts.md.

NOTE

The upstream authors have long wanted to unify on the cubic layout — the module's TODO opens with "Change cubic curve groups to have 4 points instead of 3" (vectorized_mobject.py:73). It has not happened: the Cairo path is cubic and the OpenGL path is quadratic in the reviewed tree.


Coordinate space ​

Every geometric operation is an affine map on the flat points array. Mobject methods like shift, scale, rotate, and apply_matrix transform points in world space; the camera then applies one more affine — the world-to-pixel matrix — when rasterizing (ctx.set_matrix(cairo.Matrix(pw/fw, 0, 0, -(ph/fh), ...)), camera.py:591). Because composing affine maps is matrix multiplication (right-to-left function composition), a chain of transforms collapses to a single matrix; the affine-transform.d probe proves the composed matrix and the sequential application agree to floating-point epsilon, and that translate·scale ≠ scale·translate.


Sources ​

Related: the Manim Community deep-dive · ../concepts.md · text-pipeline.md · caching.md · manimgl. Probes: bezier-eval.d · affine-transform.d.