Repository: ManimCommunity/manim

Issue:
Fix performance issues based on the new implementation of `point_from_proportion`
## Enhancement proposal
Since #1274 the method `point_from_proportion` has linear run time in the number of points on the curve. This change most noticeably affects the run time of `get_arc_length`, which in turn uses a linear amount of calls to `point_from_proportion`. Linked to this are a few other performance problems, which I want to fix.


## Additional comments
I noticed this problem when trying to render a `StreamLines` object, which took a few seconds PER LINE (and contained roughly 1000 lines...). Basically, `StreamLines` with `color_by_arc_length=True` isn't usable at the moment.



Discussion:

Resolving patch:
diff --git a/manim/mobject/types/opengl_vectorized_mobject.py b/manim/mobject/types/opengl_vectorized_mobject.py
--- a/manim/mobject/types/opengl_vectorized_mobject.py
+++ b/manim/mobject/types/opengl_vectorized_mobject.py
@@ -1,6 +1,7 @@
 import itertools as it
 import operator as op
 from functools import reduce, wraps
+from typing import Optional
 
 import moderngl
 import numpy as np
@@ -551,7 +552,7 @@ def get_nth_curve_function(self, n):
         return bezier(self.get_nth_curve_points(n))
 
     def get_nth_curve_function_with_length(
-        self, n: int, n_sample_points: int = 10
+        self, n: int, sample_points: Optional[int] = None
     ) -> typing.Tuple[typing.Callable[[float], np.ndarray], float]:
         """Returns the expression of the nth curve along with its (approximate) length.
 
@@ -559,7 +560,7 @@ def get_nth_curve_function_with_length(
         ----------
         n
             The index of the desired curve.
-        n_sample_points
+        sample_points
             The number of points to sample to find the length.
 
         Returns
@@ -570,9 +571,12 @@ def get_nth_curve_function_with_length(
             The length of the nth curve.
         """
 
+        if sample_points is None:
+            sample_points = 10
+
         curve = self.get_nth_curve_function(n)
 
-        points = np.array([curve(a) for a in np.linspace(0, 1, n_sample_points)])
+        points = np.array([curve(a) for a in np.linspace(0, 1, sample_points)])
         diffs = points[1:] - points[:-1]
         norms = np.apply_along_axis(get_norm, 1, diffs)
 
@@ -600,10 +604,15 @@ def get_curve_functions(
             yield self.get_nth_curve_function(n)
 
     def get_curve_functions_with_lengths(
-        self,
+        self, **kwargs
     ) -> typing.Iterable[typing.Tuple[typing.Callable[[float], np.ndarray], float]]:
         """Gets the functions and lengths of the curves for the mobject.
 
+        Parameters
+        ----------
+        **kwargs
+            The keyword arguments passed to :meth:`get_nth_curve_function_with_length`
+
         Returns
         -------
         typing.Iterable[typing.Tuple[typing.Callable[[float], np.ndarray], float]]
@@ -613,16 +622,18 @@ def get_curve_functions_with_lengths(
         num_curves = self.get_num_curves()
 
         for n in range(num_curves):
-            yield self.get_nth_curve_function_with_length(n)
+            yield self.get_nth_curve_function_with_length(n, **kwargs)
 
     def point_from_proportion(self, alpha):
-        curves_with_lengths = list(self.get_curve_functions_with_lengths())
+        if alpha == 1:
+            return self.get_points()[-1]
+
+        curves_and_lengths = tuple(self.get_curve_functions_with_lengths())
 
-        total_length = np.sum(length for _, length in curves_with_lengths)
-        target_length = alpha * total_length
+        target_length = alpha * np.sum(length for _, length in curves_and_lengths)
         current_length = 0
 
-        for curve, length in curves_with_lengths:
+        for curve, length in curves_and_lengths:
             if current_length + length >= target_length:
                 if length != 0:
                     residue = (target_length - current_length) / length
@@ -678,15 +689,26 @@ def get_points_without_null_curves(self, atol=1e-9):
         )
         return points[distinct_curves.repeat(nppc)]
 
-    def get_arc_length(self, n_sample_points=None):
-        if n_sample_points is None:
-            n_sample_points = 4 * self.get_num_curves() + 1
-        points = np.array(
-            [self.point_from_proportion(a) for a in np.linspace(0, 1, n_sample_points)]
+    def get_arc_length(self, sample_points_per_curve: Optional[int] = None) -> float:
+        """Return the approximated length of the whole curve.
+
+        Parameters
+        ----------
+        sample_points_per_curve
+            Number of sample points per curve used to approximate the length. More points result in a better approximation.
+
+        Returns
+        -------
+        float
+            The length of the :class:`OpenGLVMobject`.
+        """
+
+        return np.sum(
+            length
+            for _, length in self.get_curve_functions_with_lengths(
+                sample_points=sample_points_per_curve
+            )
         )
-        diffs = points[1:] - points[:-1]
-        norms = np.array([get_norm(d) for d in diffs])
-        return norms.sum()
 
     def get_area_vector(self):
         # Returns a vector whose length is the area bound by
diff --git a/manim/mobject/types/vectorized_mobject.py b/manim/mobject/types/vectorized_mobject.py
--- a/manim/mobject/types/vectorized_mobject.py
+++ b/manim/mobject/types/vectorized_mobject.py
@@ -13,7 +13,7 @@
 
 import itertools as it
 import sys
-from typing import Iterable
+from typing import Iterable, Optional
 
 import colour
 
@@ -896,7 +896,7 @@ def get_nth_curve_function(self, n: int) -> typing.Callable[[float], np.ndarray]
         return bezier(self.get_nth_curve_points(n))
 
     def get_nth_curve_function_with_length(
-        self, n: int, n_sample_points: int = 10
+        self, n: int, sample_points: Optional[int] = None
     ) -> typing.Tuple[typing.Callable[[float], np.ndarray], float]:
         """Returns the expression of the nth curve along with its (approximate) length.
 
@@ -904,7 +904,7 @@ def get_nth_curve_function_with_length(
         ----------
         n
             The index of the desired curve.
-        n_sample_points
+        sample_points
             The number of points to sample to find the length.
 
         Returns
@@ -915,9 +915,12 @@ def get_nth_curve_function_with_length(
             The length of the nth curve.
         """
 
+        if sample_points is None:
+            sample_points = 10
+
         curve = self.get_nth_curve_function(n)
 
-        points = np.array([curve(a) for a in np.linspace(0, 1, n_sample_points)])
+        points = np.array([curve(a) for a in np.linspace(0, 1, sample_points)])
         diffs = points[1:] - points[:-1]
         norms = np.apply_along_axis(get_norm, 1, diffs)
 
@@ -953,10 +956,15 @@ def get_curve_functions(
             yield self.get_nth_curve_function(n)
 
     def get_curve_functions_with_lengths(
-        self,
+        self, **kwargs
     ) -> typing.Iterable[typing.Tuple[typing.Callable[[float], np.ndarray], float]]:
         """Gets the functions and lengths of the curves for the mobject.
 
+        Parameters
+        ----------
+        **kwargs
+            The keyword arguments passed to :meth:`get_nth_curve_function_with_length`
+
         Returns
         -------
         typing.Iterable[typing.Tuple[typing.Callable[[float], np.ndarray], float]]
@@ -966,7 +974,7 @@ def get_curve_functions_with_lengths(
         num_curves = self.get_num_curves()
 
         for n in range(num_curves):
-            yield self.get_nth_curve_function_with_length(n)
+            yield self.get_nth_curve_function_with_length(n, **kwargs)
 
     def point_from_proportion(self, alpha: float) -> np.ndarray:
         """Get the bezier curve evaluated at a position P,
@@ -983,13 +991,15 @@ def point_from_proportion(self, alpha: float) -> np.ndarray:
             Point evaluated.
         """
 
-        curves_with_lengths = list(self.get_curve_functions_with_lengths())
+        if alpha == 1:
+            return self.get_points()[-1]
 
-        total_length = np.sum(length for _, length in curves_with_lengths)
-        target_length = alpha * total_length
+        curves_and_lengths = tuple(self.get_curve_functions_with_lengths())
+
+        target_length = alpha * np.sum(length for _, length in curves_and_lengths)
         current_length = 0
 
-        for curve, length in curves_with_lengths:
+        for curve, length in curves_and_lengths:
             if current_length + length >= target_length:
                 if length != 0:
                     residue = (target_length - current_length) / length
@@ -1060,15 +1070,26 @@ def get_points_defining_boundary(self):
         # Probably returns all anchors, but this is weird regarding  the name of the method.
         return np.array(list(it.chain(*[sm.get_anchors() for sm in self.get_family()])))
 
-    def get_arc_length(self, n_sample_points=None):
-        if n_sample_points is None:
-            n_sample_points = 4 * self.get_num_curves() + 1
-        points = np.array(
-            [self.point_from_proportion(a) for a in np.linspace(0, 1, n_sample_points)]
+    def get_arc_length(self, sample_points_per_curve: Optional[int] = None) -> float:
+        """Return the approximated length of the whole curve.
+
+        Parameters
+        ----------
+        sample_points_per_curve
+            Number of sample points per curve used to approximate the length. More points result in a better approximation.
+
+        Returns
+        -------
+        float
+            The length of the :class:`VMobject`.
+        """
+
+        return np.sum(
+            length
+            for _, length in self.get_curve_functions_with_lengths(
+                sample_points=sample_points_per_curve
+            )
         )
-        diffs = points[1:] - points[:-1]
-        norms = np.apply_along_axis(get_norm, 1, diffs)
-        return np.sum(norms)
 
     # Alignment
     def align_points(self, vmobject):


Test patch: