Skip to content

Speed up Cairo renderer by 2.2x on animation-heavy scenes#4695

Open
HamdiBarkous wants to merge 14 commits into
ManimCommunity:mainfrom
HamdiBarkous:performance-optimizations
Open

Speed up Cairo renderer by 2.2x on animation-heavy scenes#4695
HamdiBarkous wants to merge 14 commits into
ManimCommunity:mainfrom
HamdiBarkous:performance-optimizations

Conversation

@HamdiBarkous

Copy link
Copy Markdown

Summary

Two small, behavior-preserving optimizations to the Cairo renderer hot
path, plus a real-world benchmark to measure them.

Optimizations

1. Vectorized path building in set_cairo_context_path
Replace Python generators (gen_subpaths_from_points_2d,
gen_cubic_bezier_tuples_from_points) and per-curve tuple
unpacking with numpy-based subpath splitting and direct flat-array
indexing. Same Cairo API calls, same output, just less Python
overhead per bezier segment.

2. Eliminate redundant numpy copies

  • Camera.reset() / set_frame_to_background(): replace
    set_pixel_array() -> convert_pixel_array() -> np.array() (copy) -> slice assignment (second copy) with a single np.copyto().
  • CairoRenderer.get_frame(): replace np.array() with .copy()
    to skip dtype inference on an already-typed array.

Both changes are purely Python-side and leave rendering output
pixel-identical.

Benchmark

Adds benchmarks/bench_lissajous.py, a heavy real-world workload
(adapted from Abhijith Muthyala's Lissajous project) with a grid
of circles + updaters tracing Lissajous curves. Stresses the per-frame
render path far more than static gallery scenes.

Results

Measured with bench_lissajous.py on the same machine, 1920x1080 @ 60fps:

Scene Baseline Optimized Speedup
RadiusOne 66,674 ms 49,758 ms 1.34x
RadiusHalf 1,883,353 ms (31m) 799,578 ms (13m) 2.36x
RadiusThreeFourths 319,071 ms 171,342 ms 1.86x
TOTAL 37m 49s 17m 00s 2.22x

A ~20-minute wall-clock reduction on a single scene render.

Test plan

  • Existing test suite passes (graphical reference frames unchanged)
  • python benchmarks/bench_lissajous.py runs to completion
  • Rendered output visually identical to baseline

HamdiBarkous and others added 5 commits April 9, 2026 02:05
…flat array indexing

Replace Python generators and tuple unpacking with numpy-based subpath
splitting and direct flat-array indexing for bezier point lookups.
Same Cairo calls, same output, ~2-7x faster path building.

- Replace gen_subpaths_from_points_2d generator with vectorized numpy
  boundary detection using np.arange + boolean masking
- Replace gen_cubic_bezier_tuples_from_points generator with direct
  integer-range iteration over pre-flattened xy array
- Eliminate per-curve numpy slice creation (*p[:2] splat)
- Cache method references (ctx.curve_to → local) to avoid attribute
  lookup per call

Benchmarks (1920x1080 @ 60fps):
- set_path: 2-7x faster across scene types
- Overall: up to 1.5x faster on shape/text-heavy scenes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- camera.reset(): Replace set_pixel_array() → convert_pixel_array() →
  np.array() (copy) → slice assignment (second copy) with a single
  np.copyto() call. Removes one full-frame copy per frame.
- set_frame_to_background(): Same optimization for static frame restore.
- renderer.get_frame(): Replace np.array() with .copy() — avoids
  dtype inference overhead on an already-typed array.

Benchmarks (1920x1080 @ 60fps):
- camera_reset: 3-10x faster (e.g. 390ms → 120ms on AnimatedTransforms)
- Overall: ~2x faster across scene types when combined with set_path opt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds benchmarks/bench_lissajous.py — a heavy real-world animation
workload with grid-of-circles updaters tracing Lissajous curves.
Stresses the per-frame render path far more than static gallery
scenes, making rendering optimizations visible end-to-end.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
self.background is typed as PixelArray | None (from __init__ param)
but is guaranteed non-None after init_background() runs during
construction. Add an assert to satisfy mypy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@behackl

behackl commented Apr 15, 2026

Copy link
Copy Markdown
Member

This looks quite promising -- but the claim of pixel-identical ouput appears to be wrong, given our pipelines. We can accept some variation in the tests, but we would need to understand (and verify individually) why the output has changed and that it still is practically correct.

HamdiBarkous and others added 2 commits April 15, 2026 12:03
When a VMobject had exactly nppcc points (one cubic curve, e.g. Line),
np.arange(nppcc, n_pts, nppcc) returned an empty array and the function
exited before drawing. The original code handled this via split_indices
of [0, n_pts], yielding one subpath of all 4 points.

Handle the empty-boundary case explicitly as a single subpath.

Verified pixel-identical vs main across 12 scenes (Line, Dot, Square,
Circle, Arrow, Text, MathTex, Polyline, DashedLine, OpenPath, mixed,
animated).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@HamdiBarkous

Copy link
Copy Markdown
Author

@behackl Thanks for catching this. Fixed in the latest commit. Verified pixel-identical against main across 12 scenes (Line, Dot, Square, Circle, Arrow, Text, MathTex, Polyline, DashedLine, open polyline, mixed, animated). Can you please confirm?

@behackl

behackl commented Apr 15, 2026

Copy link
Copy Markdown
Member

Looking much better, indeed -- I'll make sure to review this in more detail as soon as I can; very impressive!

Our CI says that the documentation build and specifically the rendering of the VMobjectDemo example currently fails, this is the relevant snippet from the stack trace:

File "<string>", line 29, in <module>
[146](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--146)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/scene/scene.py", line 270, in render
[147](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--147)	        self.renderer.scene_finished(self)
[148](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--148)	        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
[149](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--149)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/renderer/cairo_renderer.py", line 278, in scene_finished
[150](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--150)	        self.update_frame(scene)
[151](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--151)	        ~~~~~~~~~~~~~~~~~^^^^^^^
[152](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--152)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/renderer/cairo_renderer.py", line 159, in update_frame
[153](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--153)	        self.camera.capture_mobjects(mobjects, **kwargs)
[154](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--154)	        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
[155](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--155)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/camera/camera.py", line 566, in capture_mobjects
[156](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--156)	        self.display_funcs[group_type](list(group), self.pixel_array)
[157](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--157)	        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[158](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--158)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/camera/camera.py", line 665, in display_multiple_vectorized_mobjects
[159](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--159)	        self.display_multiple_non_background_colored_vmobjects(
[160](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--160)	        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
[161](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--161)	            batch,
[162](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--162)	            ^^^^^^
[163](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--163)	            pixel_array,
[164](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--164)	            ^^^^^^^^^^^^
[165](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--165)	        )
[166](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--166)	        ^
[167](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--167)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/camera/camera.py", line 685, in display_multiple_non_background_colored_vmobjects
[168](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--168)	        self.display_vectorized(vmobject, ctx)
[169](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--169)	        ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
[170](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--170)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/camera/camera.py", line 702, in display_vectorized
[171](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--171)	        self.set_cairo_context_path(ctx, vmobject)
[172](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--172)	        ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
[173](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--173)	      File "/home/docs/checkouts/readthedocs.org/user_builds/manimce/envs/4695/lib/python3.13/site-packages/manim/camera/camera.py", line 747, in set_cairo_context_path
[174](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--174)	        ends = points[boundary_indices - 1, :2]  # end of previous curve
[175](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--175)	               ~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
[176](https://app.readthedocs.org/projects/manimce/builds/32273262/#315817839--176)	    TypeError: list indices must be integers or slices, not tuple

Could you take a look at this?

@chopan050

Copy link
Copy Markdown
Member

The docbuild issue happens because of this piece of code in the VMobjectDemo example in https://docs.manim.community/en/stable/guides/deep_dive.html:

        my_vmobject = VMobject(color=GREEN)
        my_vmobject.points = [
            np.array([-2, -1, 0]),  # start of first curve
            np.array([-3, 1, 0]),
            np.array([0, 3, 0]),
            np.array([1, 3, 0]),  # end of first curve
            np.array([1, 3, 0]),  # start of second curve
            np.array([0, 1, 0]),
            np.array([4, 3, 0]),
            np.array([4, -2, 0]),  # end of second curve
        ]

When doing this, my_vmobject.points is not a 2D NumPy array of points itself, but rather a list of 1D NumPy arrays representing individual points. This causes the error TypeError: list indices must be integers or slices, not tuple: you're trying to multi-index a list which is not supported: doing arr[1, 2] is equivalent to doing arr[(1, 2)], i.e. you're indexing arr with the tuple (1, 2), which explains why the error says you're trying to index a list with a tuple.

You can fix this by changing the list above to a 2D NumPy array, but IMO a cleaner solution is to use the existing VMobject.set_points() method:

        my_vmobject = VMobject(color=GREEN).set_points(
            [
                [-2, -1, 0],  # start of first curve
                [-3, 1, 0],
                [0, 3, 0],
                [1, 3, 0],  # end of first curve
                [1, 3, 0],  # start of second curve
                [0, 1, 0],
                [4, 3, 0],
                [4, -2, 0],  # end of second curve
            ]
        )

The vectorized path builder uses numpy fancy indexing (e.g.
points[boundary_indices - 1, :2]), which fails when vmobject.points
is a plain Python list. The documented VMobjectDemo example sets
points this way, which broke the docs build.

np.asarray the points array once on entry; it's a no-op when the
input is already an ndarray.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@HamdiBarkous

Copy link
Copy Markdown
Author

The docs' VMobjectDemo sets vmobject.points to a Python list, which broke my numpy fancy indexing. Fixed by normalizing to ndarray on entry (no-op when it already is one, which is the default).

@HamdiBarkous

Copy link
Copy Markdown
Author

@behackl Any updates on this PR?

@chopan050 chopan050 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the optimization! I left two requests, if you could please take a look at them:

Comment thread manim/camera/camera.py Outdated
):
np.copyto(self.pixel_array, self.background)
else:
self.pixel_array = self.background.copy()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Camera.reset() / set_frame_to_background(): replace
    set_pixel_array() -> convert_pixel_array() -> np.array() (copy) -> slice assignment (second copy) with a single np.copyto().

Skipping unnecessary copies is great. However, rather than circumventing set_pixel_array() to skip these copies, it would be best to fix the issues in set_pixel_array() to make it faster.

The main issue is that it always calls Camera.convert_pixel_array(), which only does something meaningful when convert_from_floats == True, but always makes a copy of the passed array. Instead, you can call that method conditionally, only when convert_from_floats == True, rather than always. Moreover, if you rewrite the other two calls to Camera.convert_pixel_array() so that it's only called when you do actually want to convert from floats, then you can drop the convert_from_floats parameter from the Camera.convert_pixel_array() method itself. You can rewrite the method like this:

    def convert_pixel_array(self, pixel_array: PixelArray | list | tuple) -> PixelArray:
        """docstring..."""
        return np.apply_along_axis(
            lambda f: (f * self.rgb_max_val).astype(self.pixel_array_dtype),
            2,
            retval,
        )

then rewrite Camera.set_pixel_array() as follows:

    def set_pixel_array(
        self, pixel_array: PixelArray | list | tuple, convert_from_floats: bool = False
    ) -> None:
        """Sets the pixel array of the camera to the passed pixel array.

        Parameters
        ----------
        pixel_array
            The pixel array to convert and then set as the camera's pixel array.
        convert_from_floats
            Whether or not to convert float values to proper RGB values, by default False
        """
        converted_array: PixelArray = (
            self.convert_pixel_array(pixel_array)
            if convert_from_floats
            else np.asarray(pixel_array)
        )
        if (
            hasattr(self, "pixel_array")
            and self.pixel_array.shape == converted_array.shape
        ):
            np.copyto(self.pixel_array, converted_array)  # Set in place
        else:
            self.pixel_array = converted_array.copy()

and then leave Camera.reset() and Camera.set_frame_to_background() as they were originally.

Comment thread manim/camera/camera.py Outdated
Comment on lines +451 to +454
if hasattr(self, "pixel_array") and self.pixel_array.shape == background.shape:
np.copyto(self.pixel_array, background)
else:
self.pixel_array = background.copy()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the suggestion above.

Comment thread manim/camera/camera.py Outdated
Comment on lines +736 to +738
# Find subpath split points using vectorized comparison.
# A split occurs where consecutive anchors (at nppcc boundaries)
# are NOT close — i.e., there's a gap between subpaths.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calculating subpath split indices is great and is what ManimGL does, actually.

Because VMobject has methods for getting subpaths from its points, could you please move this logic of calculating subpath split indices inside VMobject methods? I did something similar in an older and unmerged PR, #3759, which you can take a look at.

Comment thread manim/camera/camera.py Outdated
Comment on lines +726 to +728
# vmobject.points may be a Python list (see VMobjectDemo in the docs);
# the vectorized path-building below needs an ndarray.
points = np.asarray(points)

@chopan050 chopan050 Jun 30, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vmobject.points should actually never be a list. It should always be a NumPy array. The VMobjectDemo example in the docs set the points as a list, which is wrong. Rather, that example should be fixed so that the points are a NumPy array. One way to ensure that is with the fix I proposed in a comment I made earlier.

HamdiBarkous and others added 6 commits July 7, 2026 00:52
Rework set_pixel_array/convert_pixel_array so the per-frame reset path
does a single in-place copy, then route Camera.reset() and
set_frame_to_background() back through set_pixel_array() rather than
inlining np.copyto. convert_pixel_array() no longer takes
convert_from_floats and only performs the float->RGB conversion; callers
convert only when needed. Addresses review feedback on ManimCommunity#4695.
Add VMobject.get_subpath_split_indices_from_points(), a vectorized
computation of the point-index ranges delimiting each subpath, and have
Camera.set_cairo_context_path() call it instead of computing the split
indices inline. Keeps the vectorized comparison (the exact vectorized
form of consider_points_equals_2d), so rendered output is unchanged.
Mirrors the API of the unmerged ManimCommunity#3759. Addresses review feedback on ManimCommunity#4695.
The deep-dive VMobjectDemo example assigned a Python list to
VMobject.points, violating the invariant that points is always a NumPy
array (and breaking the docs build with the vectorized path builder).
Use VMobject.set_points() instead, then drop the defensive np.asarray()
workaround in set_cairo_context_path since points is now always an
ndarray. Addresses review feedback on ManimCommunity#4695.
Add strict=True to zip() in the new subpath test (B905) and collapse
two signatures/calls that fit on one line per ruff format.

@chopan050 chopan050 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just took a look at Abhijith Muthyala's repository where you mentioned that you took the files for the Lissajous curves benchmark from.

The README.md says at the end:

LICENSE

This repository is mainly intended to update and keep track of my personal projects made with Manim. It is made public in the hope that someone might find it useful while learning Manim. All the code is only meant as a reference and not for reuse without permission.

Do you have explicit permission from Abhitijh to include the contents of his projects almost unmodified? If not, I'm afraid that this file cannot be included in the PR...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants