Repository: ManimCommunity/manim
Pull request: Porting and drastical rewrite to reach compatibility with `3b1b/manim` again
Porting all the new features from `3b1b/manim` drastical rewrite of the render system.

Currently no explanation because it is highly experimental. Further discussion about this can be found on the discord server.

Problem statement:
Rounding Corners doesn't work for OpenGL as well as Depth Testing
```py
class Test(ThreeDScene):
    colors = [RED, GREEN, YELLOW, WHITE, BLUE, ORANGE]

    def create_cube(self):
        cube = Cube(1)
        for i, face in enumerate(cube):
            face.set_fill(self.colors[i])
            face.set_stroke(BLACK, 2)
            face.round_corners(0.2)
        return cube

    def construct(self):
        self.move_camera(phi=75 * DEGREES, theta=-45 * DEGREES)
        cubes = []
        for x in range(3):
            for y in range(3):
                for z in range(3):
                    cube = self.create_cube()
                    cube.shift((x - 1) * RIGHT + (y - 1) * UP + (z - 1) * OUT)
                    cubes.append(cube)

        self.play(
            AnimationGroup(
                *(GrowFromPoint(cube, cube.get_center()) for cube in cubes),
                lag_ratio=0.5,
                run_time=2
            )
        )
        self.wait(
```

Hints from issue discussion:
Shader wrapper has depth testing disabled by default and is not updatet by passing anything to the mobject or using `apply_depth_test`

Commit message: first working render

Patch to review:
diff --git a/manim/_config/utils.py b/manim/_config/utils.py
index cdbd62fce4..98229ac4c6 100644
--- a/manim/_config/utils.py
+++ b/manim/_config/utils.py
@@ -15,6 +15,8 @@
 import configparser
 import copy
 import errno
+import importlib
+import inspect
 import logging
 import os
 import re
diff --git a/manim/constants.py b/manim/constants.py
index d81d85902c..efbb86e1d4 100644
--- a/manim/constants.py
+++ b/manim/constants.py
@@ -194,6 +194,9 @@
 DEGREES: float = TAU / 360
 """The exchange rate between radians and degrees."""
 
+RADIANS: float = 1.0
+"""Just a default to select for camera."""
+
 # Video qualities
 QUALITIES: dict[str, dict[str, str | int | None]] = {
     "fourk_quality": {
diff --git a/manim/event_handler/__init__.py b/manim/event_handler/__init__.py
new file mode 100644
index 0000000000..1a9a247106
--- /dev/null
+++ b/manim/event_handler/__init__.py
@@ -0,0 +1,5 @@
+from manim.event_handler.event_dispatcher import EventDispatcher
+
+# This is supposed to be a Singleton
+# i.e., during runtime there should be only one object of Event Dispatcher
+EVENT_DISPATCHER = EventDispatcher()
diff --git a/manim/event_handler/event_dispatcher.py b/manim/event_handler/event_dispatcher.py
new file mode 100644
index 0000000000..050379b5e1
--- /dev/null
+++ b/manim/event_handler/event_dispatcher.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+import numpy as np
+
+from manim.event_handler.event_listener import EventListener
+from manim.event_handler.event_type import EventType
+
+
+class EventDispatcher:
+    def __init__(self):
+        self.event_listeners: dict[EventType, list[EventListener]] = {
+            event_type: [] for event_type in EventType
+        }
+        self.mouse_point = np.array((0.0, 0.0, 0.0))
+        self.mouse_drag_point = np.array((0.0, 0.0, 0.0))
+        self.pressed_keys: set[int] = set()
+        self.draggable_object_listeners: list[EventListener] = []
+
+    def add_listener(self, event_listener: EventListener):
+        assert isinstance(event_listener, EventListener)
+        self.event_listeners[event_listener.event_type].append(event_listener)
+        return self
+
+    def remove_listener(self, event_listener: EventListener):
+        assert isinstance(event_listener, EventListener)
+        try:
+            while event_listener in self.event_listeners[event_listener.event_type]:
+                self.event_listeners[event_listener.event_type].remove(event_listener)
+        except Exception:
+            # raise ValueError("Handler is not handling this event, so cannot remove it.")
+            pass
+        return self
+
+    def dispatch(self, event_type: EventType, **event_data):
+        if event_type == EventType.MouseMotionEvent:
+            self.mouse_point = event_data["point"]
+        elif event_type == EventType.MouseDragEvent:
+            self.mouse_drag_point = event_data["point"]
+        elif event_type == EventType.KeyPressEvent:
+            self.pressed_keys.add(event_data["symbol"])  # Modifiers?
+        elif event_type == EventType.KeyReleaseEvent:
+            self.pressed_keys.difference_update({event_data["symbol"]})  # Modifiers?
+        elif event_type == EventType.MousePressEvent:
+            self.draggable_object_listeners = [
+                listener
+                for listener in self.event_listeners[EventType.MouseDragEvent]
+                if listener.mobject.is_point_touching(self.mouse_point)
+            ]
+        elif event_type == EventType.MouseReleaseEvent:
+            self.draggable_object_listeners = []
+
+        propagate_event = None
+
+        if event_type == EventType.MouseDragEvent:
+            for listener in self.draggable_object_listeners:
+                assert isinstance(listener, EventListener)
+                propagate_event = listener.callback(listener.mobject, event_data)
+                if propagate_event is not None and propagate_event is False:
+                    return propagate_event
+
+        elif event_type.value.startswith("mouse"):
+            for listener in self.event_listeners[event_type]:
+                if listener.mobject.is_point_touching(self.mouse_point):
+                    propagate_event = listener.callback(listener.mobject, event_data)
+                    if propagate_event is not None and propagate_event is False:
+                        return propagate_event
+
+        elif event_type.value.startswith("key"):
+            for listener in self.event_listeners[event_type]:
+                propagate_event = listener.callback(listener.mobject, event_data)
+                if propagate_event is not None and propagate_event is False:
+                    return propagate_event
+
+        return propagate_event
+
+    def get_listeners_count(self) -> int:
+        return sum([len(value) for key, value in self.event_listeners.items()])
+
+    def get_mouse_point(self) -> np.ndarray:
+        return self.mouse_point
+
+    def get_mouse_drag_point(self) -> np.ndarray:
+        return self.mouse_drag_point
+
+    def is_key_pressed(self, symbol: int) -> bool:
+        return symbol in self.pressed_keys
+
+    __iadd__ = add_listener
+    __isub__ = remove_listener
+    __call__ = dispatch
+    __len__ = get_listeners_count
diff --git a/manim/event_handler/event_listener.py b/manim/event_handler/event_listener.py
new file mode 100644
index 0000000000..9c923d4d57
--- /dev/null
+++ b/manim/event_handler/event_listener.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from typing import Callable
+
+    import manim.mobject.opengl.opengl_mobject as glmob
+    from manim.event_handler.event_type import EventType
+
+
+class EventListener:
+    def __init__(
+        self,
+        mobject: glmob.OpenGLMobject,
+        event_type: EventType,
+        event_callback: Callable[[glmob.OpenGLMobject, dict[str, str]], None],
+    ):
+        self.mobject = mobject
+        self.event_type = event_type
+        self.callback = event_callback
+
+    def __eq__(self, o: object) -> bool:
+        return_val = False
+        if isinstance(o, EventListener):
+            try:
+                return_val = (
+                    self.callback == o.callback
+                    and self.mobject == o.mobject
+                    and self.event_type == o.event_type
+                )
+            except Exception:
+                pass
+        return return_val
diff --git a/manim/event_handler/event_type.py b/manim/event_handler/event_type.py
new file mode 100644
index 0000000000..b9a9eb2da3
--- /dev/null
+++ b/manim/event_handler/event_type.py
@@ -0,0 +1,11 @@
+from enum import Enum
+
+
+class EventType(Enum):
+    MouseMotionEvent = "mouse_motion_event"
+    MousePressEvent = "mouse_press_event"
+    MouseReleaseEvent = "mouse_release_event"
+    MouseDragEvent = "mouse_drag_event"
+    MouseScrollEvent = "mouse_scroll_event"
+    KeyPressEvent = "key_press_event"
+    KeyReleaseEvent = "key_release_event"
diff --git a/manim/mobject/opengl/opengl_mobject.py b/manim/mobject/opengl/opengl_mobject.py
index 25159ab23c..724f6ceffd 100644
--- a/manim/mobject/opengl/opengl_mobject.py
+++ b/manim/mobject/opengl/opengl_mobject.py
@@ -2,22 +2,30 @@
 
 import copy
 import itertools as it
+import numbers
+import os
+import pickle
 import random
 import sys
 from functools import partialmethod, wraps
 from math import ceil
-from typing import Iterable, Sequence
+from typing import TYPE_CHECKING
 
 import moderngl
 import numpy as np
 from colour import Color
+from typing_extensions import TypedDict
 
 from manim import config, logger
 from manim.constants import *
+from manim.event_handler import EVENT_DISPATCHER
+from manim.event_handler.event_listener import EventListener
+from manim.event_handler.event_type import EventType
+from manim.renderer.shader_wrapper import ShaderWrapper, get_colormap_code
 from manim.utils.bezier import integer_interpolate, interpolate
 from manim.utils.color import *
-from manim.utils.color import Colors
-from manim.utils.config_ops import _Data, _Uniforms
+from manim.utils.color import Colors, get_colormap_list
+from manim.utils.deprecation import deprecated
 
 # from ..utils.iterables import batch_by_property
 from manim.utils.iterables import (
@@ -34,20 +42,23 @@
 from manim.utils.simple_functions import get_parameters
 from manim.utils.space_ops import (
     angle_between_vectors,
+    angle_of_vector,
+    get_norm,
     normalize,
     rotation_matrix_transpose,
 )
 
+if TYPE_CHECKING:
+    from typing import Callable, Iterable, Sequence, Tuple, Union
 
-def affects_shader_info_id(func):
-    @wraps(func)
-    def wrapper(self):
-        for mob in self.get_family():
-            func(mob)
-            mob.refresh_shader_wrapper_id()
-        return self
+    from typing_extensions import TypeAlias
 
-    return wrapper
+    TimeBasedUpdater: TypeAlias = Callable[[OpenGLMobject, float], OpenGLMobject | None]
+    NonTimeUpdater: TypeAlias = Callable[[OpenGLMobject], OpenGLMobject | None]
+    Updater: TypeAlias = Union[TimeBasedUpdater, NonTimeUpdater]
+    PointUpdateFunction: TypeAlias = Callable[[np.ndarray], np.ndarray]
+
+UNIFORM_DTYPE = np.float64
 
 
 class OpenGLMobject:
@@ -66,96 +77,60 @@ class OpenGLMobject:
 
     """
 
-    shader_dtype = [
+    dim: int = 3
+    shader_folder: str = ""
+    render_primitive: int = moderngl.TRIANGLE_STRIP
+    shader_dtype: Sequence[tuple[str, type, tuple[int]]] = [
         ("point", np.float32, (3,)),
     ]
-    shader_folder = ""
-
-    # _Data and _Uniforms are set as class variables to tell manim how to handle setting/getting these attributes later.
-    points = _Data()
-    bounding_box = _Data()
-    rgbas = _Data()
-
-    is_fixed_in_frame = _Uniforms()
-    is_fixed_orientation = _Uniforms()
-    fixed_orientation_center = _Uniforms()  # for fixed orientation reference
-    gloss = _Uniforms()
-    shadow = _Uniforms()
 
     def __init__(
         self,
         color=WHITE,
-        opacity=1,
-        dim=3,  # TODO, get rid of this
-        # Lighting parameters
-        # Positive gloss up to 1 makes it reflect the light.
-        gloss=0.0,
-        # Positive shadow up to 1 makes a side opposite the light darker
-        shadow=0.0,
-        # For shaders
-        render_primitive=moderngl.TRIANGLES,
-        texture_paths=None,
-        depth_test=False,
-        # If true, the mobject will not get rotated according to camera position
-        is_fixed_in_frame=False,
-        is_fixed_orientation=False,
-        # Must match in attributes of vert shader
-        # Event listener
-        listen_to_events=False,
-        model_matrix=None,
-        should_render=True,
+        opacity: float = 1.0,
+        reflectiveness: float = 0.0,
+        shadow: float = 0.0,
+        gloss: float = 0.0,
+        texture_paths: dict[str, str] | None = None,
+        is_fixed_in_frame: bool = False,
+        depth_test: bool = False,
         name: str | None = None,
         **kwargs,
     ):
-        self.name = self.__class__.__name__ if name is None else name
-        # getattr in case data/uniforms are already defined in parent classes.
-        self.data = getattr(self, "data", {})
-        self.uniforms = getattr(self, "uniforms", {})
-
+        self.color = color
         self.opacity = opacity
-        self.dim = dim  # TODO, get rid of this
-        # Lighting parameters
-        # Positive gloss up to 1 makes it reflect the light.
-        self.gloss = gloss
-        # Positive shadow up to 1 makes a side opposite the light darker
+        self.reflectiveness = reflectiveness
         self.shadow = shadow
-        # For shaders
-        self.render_primitive = render_primitive
+        self.gloss = gloss
         self.texture_paths = texture_paths
+        self.is_fixed_in_frame = is_fixed_in_frame
         self.depth_test = depth_test
-        # If true, the mobject will not get rotated according to camera position
-        self.is_fixed_in_frame = float(is_fixed_in_frame)
-        self.is_fixed_orientation = float(is_fixed_orientation)
-        self.fixed_orientation_center = (0, 0, 0)
-        # Must match in attributes of vert shader
-        # Event listener
-        self.listen_to_events = listen_to_events
-
-        self._submobjects = []
-        self.parents = []
-        self.parent = None
-        self.family = [self]
-        self.locked_data_keys = set()
-        self.needs_new_bounding_box = True
-        if model_matrix is None:
-            self.model_matrix = np.eye(4)
-        else:
-            self.model_matrix = model_matrix
+        self.name = self.__class__.__name__ if name is None else name
+
+        # internal_state
+        self.submobjects: list[OpenGLMobject] = []
+        self.parents: list[OpenGLMobject] = []
+        self.family: list[OpenGLMobject] = [self]
+        self.locked_data_keys: set[str] = set()
+        self.needs_new_bounding_box: bool = True
+        self._is_animating: bool = False
+        self.saved_state: OpenGLMobject | None = None
+        self.target: OpenGLMobject | None = None
+
+        self.data: dict[str, np.ndarray] = {}
+        self.uniforms: dict[str, float | np.ndarray] = {}
 
         self.init_data()
+        self.init_uniforms()
         self.init_updaters()
-        # self.init_event_listners()
+        self.init_event_listeners()
         self.init_points()
-        self.color = Color(color) if color else None
         self.init_colors()
-
-        self.shader_indices = None
+        self.init_shader_data()
 
         if self.depth_test:
             self.apply_depth_test()
 
-        self.should_render = should_render
-
     @classmethod
     def __init_subclass__(cls, **kwargs):
         super().__init_subclass__(**kwargs)
@@ -167,17 +142,15 @@ def __str__(self):
     def __repr__(self):
         return str(self.name)
 
-    def __sub__(self, other):
-        return NotImplemented
-
-    def __isub__(self, other):
-        return NotImplemented
-
-    def __add__(self, mobject):
-        return NotImplemented
+    def __add__(self, other: OpenGLMobject) -> OpenGLMobject:
+        if not isinstance(other, OpenGLMobject):
+            raise TypeError(f"Only Mobjects can be added to Mobjects not {type(other)}")
+        return self.get_group_class()(self, other)
 
-    def __iadd__(self, mobject):
-        return NotImplemented
+    def __mul__(self, other: int) -> OpenGLMobject:
+        if not isinstance(other, int):
+            raise TypeError(f"Only int can be multiplied to Mobjects not {type(other)}")
+        return self.replicate(other)
 
     @classmethod
     def set_default(cls, **kwargs):
@@ -227,12 +200,51 @@ def construct(self):
         else:
             cls.__init__ = cls._original__init__
 
+    @property
+    def points(self):
+        return self.data["points"]
+
+    @points.setter
+    def points(self, value):
+        self.data["points"] = value
+
+    @property
+    def bounding_box(self):
+        return self.data["bounding_box"]
+
+    @bounding_box.setter
+    def bounding_box(self, value):
+        self.data["bounding_box"] = value
+
+    @property
+    def rgbas(self):
+        return self.data["rgbas"]
+
+    @rgbas.setter
+    def rgbas(self, value):
+        self.data["rgbas"] = value
+
     def init_data(self):
         """Initializes the ``points``, ``bounding_box`` and ``rgbas`` attributes and groups them into self.data.
         Subclasses can inherit and overwrite this method to extend `self.data`."""
-        self.points = np.zeros((0, 3))
-        self.bounding_box = np.zeros((3, 3))
-        self.rgbas = np.zeros((1, 4))
+        self.data = {
+            "points": np.zeros((0, 3)),
+            "bounding_box": np.zeros((3, 3)),
+            "rgbas": np.zeros((1, 4)),
+        }
+
+    def init_uniforms(self):
+        """Initializes the uniforms.
+
+        Gets called upon creation"""
+        self.uniforms = {
+            "is_fixed_in_frame": np.array(
+                float(self.is_fixed_in_frame), dtype=UNIFORM_DTYPE
+            ),
+            "gloss": np.array(self.gloss, dtype=UNIFORM_DTYPE),
+            "shadow": np.array(self.shadow, dtype=UNIFORM_DTYPE),
+            "reflectiveness": np.array(self.reflectiveness, dtype=UNIFORM_DTYPE),
+        }
 
     def init_colors(self):
         """Initializes the colors.
@@ -248,52 +260,20 @@ def init_points(self):
         # Typically implemented in subclass, unless purposefully left blank
         pass
 
-    def set(self, **kwargs) -> OpenGLMobject:
-        """Sets attributes.
-
-        Mainly to be used along with :attr:`animate` to
-        animate setting attributes.
-
-        Examples
-        --------
-        ::
-
-            >>> mob = OpenGLMobject()
-            >>> mob.set(foo=0)
-            OpenGLMobject
-            >>> mob.foo
-            0
-
-        Parameters
-        ----------
-        **kwargs
-            The attributes and corresponding values to set.
-
-        Returns
-        -------
-        :class:`OpenGLMobject`
-            ``self``
-
-
-        """
-
-        for attr, value in kwargs.items():
-            setattr(self, attr, value)
-
-        return self
-
     def set_data(self, data):
         for key in data:
-            self.data[key] = data[key].copy()
+            self.data[key] = data[key]
         return self
 
     def set_uniforms(self, uniforms):
-        for key in uniforms:
-            self.uniforms[key] = uniforms[key]  # Copy?
+        for key, value in uniforms.items():
+            if isinstance(value, np.ndarray):
+                value = value.copy()
+            self.uniforms[key] = value
         return self
 
     @property
-    def animate(self):
+    def animate(self) -> _AnimationBuilder:
         """Used to animate the application of a method.
 
         .. warning::
@@ -380,6 +360,101 @@ def construct(self):
         """
         return _AnimationBuilder(self)
 
+    def resize_points(self, new_length, resize_func=resize_array):
+        if new_length != len(self.points):
+            self.points = resize_func(self.points, new_length)
+        self.refresh_bounding_box()
+        return self
+
+    def set_points(self, points):
+        if len(points) == len(self.points):
+            self.points[:] = points
+        elif isinstance(points, np.ndarray):
+            self.points = points.copy()
+        else:
+            self.points = np.array(points)
+        self.refresh_bounding_box()
+        return self
+
+    def append_points(self, new_points):
+        self.points = np.vstack([self.points, new_points])
+        self.refresh_bounding_box()
+        return self
+
+    def reverse_points(self):
+        for mob in self.get_family():
+            for key in mob.data:
+                mob.data[key] = mob.data[key][::-1]
+        return self
+
+    def apply_points_function(
+        self,
+        func: PointUpdateFunction,
+        about_point=None,
+        about_edge=ORIGIN,
+        works_on_bounding_box=False,
+    ):
+        if about_point is None and about_edge is not None:
+            about_point = self.get_bounding_box_point(about_edge)
+
+        for mob in self.get_family():
+            arrs = []
+            if mob.has_points():
+                arrs.append(mob.points)
+            if works_on_bounding_box:
+                arrs.append(mob.get_bounding_box())
+
+            for arr in arrs:
+                if about_point is None:
+                    arr[:] = func(arr)
+                else:
+                    arr[:] = func(arr - about_point) + about_point
+
+        if not works_on_bounding_box:
+            self.refresh_bounding_box(recurse_down=True)
+        else:
+            for parent in self.parents:
+                parent.refresh_bounding_box()
+        return self
+
+    # ce only
+    def get_array_attrs(self):
+        """This method is used to determine which attributes of the :class:`~.OpenGLMobject` are arrays.
+        These can be used to apply functions to all of them at once.
+        """
+        return ["points"]
+
+    # ce only
+    def apply_over_attr_arrays(self, func):
+        """This method is used to apply a function to all attributes of the :class:`~.OpenGLMobject` that are arrays."""
+        for attr in self.get_array_attrs():
+            setattr(self, attr, func(getattr(self, attr)))
+        return self
+
+    # ce only
+    def get_midpoint(self) -> np.ndarray:
+        """Get coordinates of the middle of the path that forms the  :class:`~.OpenGLMobject`.
+
+        Examples
+        --------
+
+        .. manim:: AngleMidPoint
+            :save_last_frame:
+
+            class AngleMidPoint(Scene):
+                def construct(self):
+                    line1 = Line(ORIGIN, 2*RIGHT)
+                    line2 = Line(ORIGIN, 2*RIGHT).rotate_about_origin(80*DEGREES)
+
+                    a = Angle(line1, line2, radius=1.5, other_angle=False)
+                    d = Dot(a.get_midpoint()).set_color(RED)
+
+                    self.add(line1, line2, a, d)
+                    self.wait()
+
+        """
+        return self.point_from_proportion(0.5)
+
     @property
     def width(self):
         """The width of the mobject.
@@ -476,91 +551,6 @@ def depth(self):
     def depth(self, value):
         self.rescale_to_fit(value, 2, stretch=False)
 
-    def resize_points(self, new_length, resize_func=resize_array):
-        if new_length != len(self.points):
-            self.points = resize_func(self.points, new_length)
-        self.refresh_bounding_box()
-        return self
-
-    def set_points(self, points):
-        if len(points) == len(self.points):
-            self.points[:] = points
-        elif isinstance(points, np.ndarray):
-            self.points = points.copy()
-        else:
-            self.points = np.array(points)
-        self.refresh_bounding_box()
-        return self
-
-    def apply_over_attr_arrays(self, func):
-        for attr in self.get_array_attrs():
-            setattr(self, attr, func(getattr(self, attr)))
-        return self
-
-    def append_points(self, new_points):
-        self.points = np.vstack([self.points, new_points])
-        self.refresh_bounding_box()
-        return self
-
-    def reverse_points(self):
-        for mob in self.get_family():
-            for key in mob.data:
-                mob.data[key] = mob.data[key][::-1]
-        return self
-
-    def get_midpoint(self) -> np.ndarray:
-        """Get coordinates of the middle of the path that forms the  :class:`~.OpenGLMobject`.
-
-        Examples
-        --------
-
-        .. manim:: AngleMidPoint
-            :save_last_frame:
-
-            class AngleMidPoint(Scene):
-                def construct(self):
-                    line1 = Line(ORIGIN, 2*RIGHT)
-                    line2 = Line(ORIGIN, 2*RIGHT).rotate_about_origin(80*DEGREES)
-
-                    a = Angle(line1, line2, radius=1.5, other_angle=False)
-                    d = Dot(a.get_midpoint()).set_color(RED)
-
-                    self.add(line1, line2, a, d)
-                    self.wait()
-
-        """
-        return self.point_from_proportion(0.5)
-
-    def apply_points_function(
-        self,
-        func,
-        about_point=None,
-        about_edge=ORIGIN,
-        works_on_bounding_box=False,
-    ):
-        if about_point is None and about_edge is not None:
-            about_point = self.get_bounding_box_point(about_edge)
-
-        for mob in self.get_family():
-            arrs = []
-            if mob.has_points():
-                arrs.append(mob.points)
-            if works_on_bounding_box:
-                arrs.append(mob.get_bounding_box())
-
-            for arr in arrs:
-                if about_point is None:
-                    arr[:] = func(arr)
-                else:
-                    arr[:] = func(arr - about_point) + about_point
-
-        if not works_on_bounding_box:
-            self.refresh_bounding_box(recurse_down=True)
-        else:
-            for parent in self.parents:
-                parent.refresh_bounding_box()
-        return self
-
     # Others related to points
 
     def match_points(self, mobject):
@@ -581,6 +571,7 @@ def construct(self):
                     self.wait(0.5)
         """
         self.set_points(mobject.points)
+        return self
 
     def clear_points(self):
         self.points = np.empty((0, 3))
@@ -631,11 +622,28 @@ def refresh_bounding_box(self, recurse_down=False, recurse_up=True):
                 parent.refresh_bounding_box()
         return self
 
-    def is_point_touching(self, point, buff=MED_SMALL_BUFF):
+    def are_points_touching(self, points, buff: float = 0) -> np.ndarray:
         bb = self.get_bounding_box()
         mins = bb[0] - buff
         maxs = bb[2] + buff
-        return (point >= mins).all() and (point <= maxs).all()
+        return ((points >= mins) * (points <= maxs)).all(1)
+
+    def is_point_touching(self, point, buff=MED_SMALL_BUFF):
+        return self.are_points_touching(np.array(point, ndmin=2), buff)[0]
+
+    def is_touching(self, mobject: OpenGLMobject, buff: float = 1e-2) -> bool:
+        bb1 = self.get_bounding_box()
+        bb2 = mobject.get_bounding_box()
+        return not any(
+            (
+                (
+                    bb2[2] < bb1[0] - buff
+                ).any(),  # E.g. Right of mobject is left of self's left
+                (
+                    bb2[0] > bb1[2] + buff
+                ).any(),  # E.g. Left of mobject is right of self's right
+            )
+        )
 
     # Family matters
 
@@ -648,13 +656,13 @@ def __getitem__(self, value):
     def __iter__(self):
         return iter(self.split())
 
-    def __len__(self):
+    def __len__(self) -> int:
         return len(self.split())
 
-    def split(self):
+    def split(self) -> list[OpenGLMobject]:
         return self.submobjects
 
-    def assemble_family(self):
+    def assemble_family(self) -> OpenGLMobject:
         sub_families = (sm.get_family() for sm in self.submobjects)
         self.family = [self, *uniq_chain(*sub_families)]
         self.refresh_has_updater_status()
@@ -663,17 +671,39 @@ def assemble_family(self):
             parent.assemble_family()
         return self
 
-    def get_family(self, recurse=True):
+    def get_family(self, recurse=True) -> list[OpenGLMobject]:
         if recurse and hasattr(self, "family"):
             return self.family
         else:
             return [self]
 
-    def family_members_with_points(self):
+    def family_members_with_points(self) -> list[OpenGLMobject]:
         return [m for m in self.get_family() if m.has_points()]
 
+    def get_ancestors(self, extended: bool = False) -> list[OpenGLMobject]:
+        """
+        Returns parents, grandparents, etc.
+        Order of result should be from higher members of the hierarchy down.
+
+        If extended is set to true, it includes the ancestors of all family members,
+        e.g. any other parents of a submobject
+        """
+        ancestors = []
+        to_process = list(self.get_family(recurse=extended))
+        excluded = set(to_process)
+        while to_process:
+            for p in to_process.pop().parents:
+                if p not in excluded:
+                    ancestors.append(p)
+                    to_process.append(p)
+        # Ensure mobjects highest in the hierarchy show up first
+        ancestors.reverse()
+        # Remove list redundancies while preserving order
+        return list(dict.fromkeys(ancestors))
+
     def add(
-        self, *mobjects: OpenGLMobject, update_parent: bool = False
+        self,
+        *mobjects: OpenGLMobject,
     ) -> OpenGLMobject:
         """Add mobjects as submobjects.
 
@@ -733,17 +763,6 @@ def add(
             ValueError: OpenGLMobject cannot contain self
 
         """
-        if update_parent:
-            assert len(mobjects) == 1, "Can't set multiple parents."
-            mobjects[0].parent = self
-
-        if self in mobjects:
-            raise ValueError("OpenGLMobject cannot contain self")
-        if any(mobjects.count(elem) > 1 for elem in mobjects):
-            logger.warning(
-                "Attempted adding some Mobject as a child more than once, "
-                "this is not possible. Repetitions are ignored.",
-            )
         for mobject in mobjects:
             if not isinstance(mobject, OpenGLMobject):
                 raise TypeError("All submobjects must be of type OpenGLMobject")
@@ -754,44 +773,8 @@ def add(
         self.assemble_family()
         return self
 
-    def insert(self, index: int, mobject: OpenGLMobject, update_parent: bool = False):
-        """Inserts a mobject at a specific position into self.submobjects
-
-        Effectively just calls  ``self.submobjects.insert(index, mobject)``,
-        where ``self.submobjects`` is a list.
-
-        Highly adapted from ``OpenGLMobject.add``.
-
-        Parameters
-        ----------
-        index
-            The index at which
-        mobject
-            The mobject to be inserted.
-        update_parent
-            Whether or not to set ``mobject.parent`` to ``self``.
-        """
-
-        if update_parent:
-            mobject.parent = self
-
-        if mobject is self:
-            raise ValueError("OpenGLMobject cannot contain self")
-
-        if not isinstance(mobject, OpenGLMobject):
-            raise TypeError("All submobjects must be of type OpenGLMobject")
-
-        if mobject not in self.submobjects:
-            self.submobjects.insert(index, mobject)
-
-        if self not in mobject.parents:
-            mobject.parents.append(self)
-
-        self.assemble_family()
-        return self
-
     def remove(
-        self, *mobjects: OpenGLMobject, update_parent: bool = False
+        self, *mobjects: OpenGLMobject, reassemble: bool = True
     ) -> OpenGLMobject:
         """Remove :attr:`submobjects`.
 
@@ -814,16 +797,13 @@ def remove(
         :meth:`add`
 
         """
-        if update_parent:
-            assert len(mobjects) == 1, "Can't remove multiple parents."
-            mobjects[0].parent = None
-
         for mobject in mobjects:
             if mobject in self.submobjects:
                 self.submobjects.remove(mobject)
             if self in mobject.parents:
                 mobject.parents.remove(self)
-        self.assemble_family()
+        if reassemble:
+            self.assemble_family()
         return self
 
     def add_to_back(self, *mobjects: OpenGLMobject) -> OpenGLMobject:
@@ -871,7 +851,7 @@ def add_to_back(self, *mobjects: OpenGLMobject) -> OpenGLMobject:
         :meth:`add`
 
         """
-        self.submobjects = list_update(mobjects, self.submobjects)
+        self.set_submobjects(list_update(mobjects, self.submobjects))
         return self
 
     def replace_submobject(self, index, new_submob):
@@ -882,32 +862,39 @@ def replace_submobject(self, index, new_submob):
         self.assemble_family()
         return self
 
-    def invert(self, recursive=False):
-        """Inverts the list of :attr:`submobjects`.
+    def insert_submobject(self, index: int, mobject: OpenGLMobject):
+        """Inserts a mobject at a specific position into self.submobjects
+
+        Effectively just calls  ``self.submobjects.insert(index, mobject)``,
+        where ``self.submobjects`` is a list.
+
+        Highly adapted from ``OpenGLMobject.add``.
 
         Parameters
         ----------
-        recursive
-            If ``True``, all submobject lists of this mobject's family are inverted.
+        index
+            The index at which
+        mobject
+            The mobject to be inserted.
+        update_parent
+            Whether or not to set ``mobject.parent`` to ``self``.
+        """
+        if mobject is self:
+            raise ValueError("OpenGLMobject cannot contain self")
 
-        Examples
-        --------
+        if not isinstance(mobject, OpenGLMobject):
+            raise TypeError("All submobjects must be of type OpenGLMobject")
 
-        .. manim:: InvertSumobjectsExample
+        if mobject not in self.submobjects:
+            self.submobjects.insert(index, mobject)
 
-            class InvertSumobjectsExample(Scene):
-                def construct(self):
-                    s = VGroup(*[Dot().shift(i*0.1*RIGHT) for i in range(-20,20)])
-                    s2 = s.copy()
-                    s2.invert()
-                    s2.shift(DOWN)
-                    self.play(Write(s), Write(s2))
-        """
-        if recursive:
-            for submob in self.submobjects:
-                submob.invert(recursive=True)
-        list.reverse(self.submobjects)
         self.assemble_family()
+        return self
+
+    def set_submobjects(self, submobject_list: list[OpenGLMobject]):
+        self.remove(*self.submobjects, reassemble=False)
+        self.add(*submobject_list)
+        return self
 
     # Submobject organization
 
@@ -935,7 +922,8 @@ def construct(self):
             self.center()
         return self
 
-    def arrange_in_grid(
+    # !TODO this differs a lot from 3b1b/manim
+    def arrange_in_grid_legacy(
         self,
         rows: int | None = None,
         cols: int | None = None,
@@ -1062,7 +1050,7 @@ def init_size(num, alignments, sizes):
             # This is favored over rows>cols since in general
             # the sceene is wider than high.
         if rows is None:
-            rows = ceil(len(mobs) / cols)
+            rows = ceil(len(mobs) / cols)  # type: ignore
         if cols is None:
             cols = ceil(len(mobs) / rows)
         if rows * cols < len(mobs):
@@ -1117,7 +1105,7 @@ def init_alignments(alignments, num, mapping, name, dir):
             raise ValueError(
                 'flow_order must be one of the following values: "dr", "rd", "ld" "dl", "ru", "ur", "lu", "ul".',
             )
-        flow_order = mapper[flow_order]
+        flow_order = mapper[flow_order]  # type: ignore
 
         # Reverse row_alignments and row_heights. Necessary since the
         # grid filling is handled bottom up for simplicity reasons.
@@ -1136,7 +1124,11 @@ def reverse(maybe_list):
         # properties of 0.
 
         mobs.extend([placeholder] * (rows * cols - len(mobs)))
-        grid = [[mobs[flow_order(r, c)] for c in range(cols)] for r in range(rows)]
+
+        grid = [
+            [mobs[flow_order(r, c)] for c in range(cols)]  # type:ignore
+            for r in range(rows)
+        ]
 
         measured_heigths = [
             max(grid[r][c].height for c in range(cols)) for r in range(rows)
@@ -1163,7 +1155,7 @@ def init_sizes(sizes, num, measures, name):
             x = 0
             for c in range(cols):
                 if grid[r][c] is not placeholder:
-                    alignment = row_alignments[r] + col_alignments[c]
+                    alignment = row_alignments[r] + col_alignments[c]  # type:ignore
                     line = Line(
                         x * RIGHT + y * UP,
                         (x + widths[c]) * RIGHT + (y + heights[r]) * UP,
@@ -1179,20 +1171,76 @@ def init_sizes(sizes, num, measures, name):
         self.move_to(start_pos)
         return self
 
-    def get_grid(self, n_rows, n_cols, height=None, **kwargs):
-        """
-        Returns a new mobject containing multiple copies of this one
-        arranged in a grid
-        """
-        grid = self.duplicate(n_rows * n_cols)
-        grid.arrange_in_grid(n_rows, n_cols, **kwargs)
-        if height is not None:
-            grid.set_height(height)
-        return grid
+    def arrange_in_grid(
+        self,
+        n_rows: int | None = None,
+        n_cols: int | None = None,
+        buff: float | None = None,
+        h_buff: float | None = None,
+        v_buff: float | None = None,
+        buff_ratio: float | None = None,
+        h_buff_ratio: float = 0.5,
+        v_buff_ratio: float = 0.5,
+        aligned_edge: np.ndarray = ORIGIN,
+        fill_rows_first: bool = True,
+    ):
+        submobs = self.submobjects
+        if n_rows is None and n_cols is None:
+            n_rows = int(np.sqrt(len(submobs)))
+        if n_rows is None and n_cols is not None:
+            n_rows = len(submobs) // n_cols
+        if n_cols is None and n_rows is not None:
+            n_cols = len(submobs) // n_rows
+
+        if buff is not None:
+            h_buff = buff
+            v_buff = buff
+        else:
+            if buff_ratio is not None:
+                v_buff_ratio = buff_ratio
+                h_buff_ratio = buff_ratio
+            if h_buff is None:
+                h_buff = h_buff_ratio * self[0].get_width()
+            if v_buff is None:
+                v_buff = v_buff_ratio * self[0].get_height()
+
+        x_unit = h_buff + max([sm.get_width() for sm in submobs])
+        y_unit = v_buff + max([sm.get_height() for sm in submobs])
+
+        for index, sm in enumerate(submobs):
+            if fill_rows_first:
+                x, y = index % n_cols, index // n_cols  # type: ignore
+            else:
+                x, y = index // n_rows, index % n_rows  # type: ignore
+            sm.move_to(ORIGIN, aligned_edge)
+            sm.shift(x * x_unit * RIGHT + y * y_unit * DOWN)
+        self.center()
+        return self
 
-    def duplicate(self, n: int):
-        """Returns an :class:`~.OpenGLVGroup` containing ``n`` copies of the mobject."""
-        return self.get_group_class()(*[self.copy() for _ in range(n)])
+    def arrange_to_fit_dim(self, length: float, dim: int, about_edge=ORIGIN):
+        ref_point = self.get_bounding_box_point(about_edge)
+        n_submobs = len(self.submobjects)
+        if n_submobs <= 1:
+            return
+        total_length = sum(sm.length_over_dim(dim) for sm in self.submobjects)
+        buff = (length - total_length) / (n_submobs - 1)
+        vect = np.zeros(self.dim)
+        vect[dim] = 1
+        x = 0
+        for submob in self.submobjects:
+            submob.set_coord(x, dim, -vect)
+            x += submob.length_over_dim(dim) + buff
+        self.move_to(ref_point, about_edge)
+        return self
+
+    def arrange_to_fit_width(self, width: float, about_edge=ORIGIN):
+        return self.arrange_to_fit_dim(width, 0, about_edge)
+
+    def arrange_to_fit_height(self, height: float, about_edge=ORIGIN):
+        return self.arrange_to_fit_dim(height, 1, about_edge)
+
+    def arrange_to_fit_depth(self, depth: float, about_edge=ORIGIN):
+        return self.arrange_to_fit_dim(depth, 2, about_edge)
 
     def sort(self, point_to_num_func=lambda p: p[0], submob_func=None):
         """Sorts the list of :attr:`submobjects` by a function defined by ``submob_func``."""
@@ -1225,8 +1273,8 @@ def construct(self):
         self.assemble_family()
         return self
 
-    def invert(self, recursive=False):
-        """Inverts the list of :attr:`submobjects`.
+    def reverse_submobjects(self, recursive=False):
+        """Reverses the list of :attr:`submobjects`.
 
         Parameters
         ----------
@@ -1236,24 +1284,58 @@ def invert(self, recursive=False):
         Examples
         --------
 
-        .. manim:: InvertSumobjectsExample
+        .. manim:: ReverseSumobjectsExample
 
-            class InvertSumobjectsExample(Scene):
+            class ReverseSumobjectsExample(Scene):
                 def construct(self):
                     s = VGroup(*[Dot().shift(i*0.1*RIGHT) for i in range(-20,20)])
                     s2 = s.copy()
-                    s2.invert()
+                    s2.reverse_submobjects()
                     s2.shift(DOWN)
                     self.play(Write(s), Write(s2))
         """
         if recursive:
             for submob in self.submobjects:
-                submob.invert(recursive=True)
-        list.reverse(self.submobjects)
+                submob.reverse_submobjects(recursive=True)
+        self.submobjects.reverse()
+        self.assemble_family()
 
     # Copying
 
-    def copy(self, shallow: bool = False):
+    @staticmethod
+    def stash_mobject_pointers(func: Callable):
+        @wraps(func)
+        def wrapper(self, *args, **kwargs):
+            uncopied_attrs = ["parents", "target", "saved_state"]
+            stash = {}
+            for attr in uncopied_attrs:
+                if hasattr(self, attr):
+                    value = getattr(self, attr)
+                    stash[attr] = value
+                    null_value = [] if isinstance(value, list) else None
+                    setattr(self, attr, null_value)
+            result = func(self, *args, **kwargs)
+            self.__dict__.update(stash)
+            return result
+
+        return wrapper
+
+    @stash_mobject_pointers
+    def serialize(self) -> bytes:
+        return pickle.dumps(self)
+
+    def deserialize(self, data: bytes) -> OpenGLMobject:
+        self.become(pickle.loads(data))
+        return self
+
+    def deepcopy(self) -> OpenGLMobject:
+        try:
+            return pickle.loads(pickle.dumps(self))
+        except AttributeError:
+            return copy.deepcopy(self)
+
+    @stash_mobject_pointers
+    def copy(self, deep: bool = False) -> OpenGLMobject:
         """Create and return an identical copy of the :class:`OpenGLMobject` including all
         :attr:`submobjects`.
 
@@ -1271,70 +1353,58 @@ def copy(self, shallow: bool = False):
         ----
         The clone is initially not visible in the Scene, even if the original was.
         """
-        if not shallow:
+        if deep:
             return self.deepcopy()
 
-        # TODO, either justify reason for shallow copy, or
-        # remove this redundancy everywhere
-        # return self.deepcopy()
-
-        parents = self.parents
-        self.parents = []
-        copy_mobject = copy.copy(self)
-        self.parents = parents
-
-        copy_mobject.data = dict(self.data)
-        for key in self.data:
-            copy_mobject.data[key] = self.data[key].copy()
-
-        # TODO, are uniforms ever numpy arrays?
-        copy_mobject.uniforms = dict(self.uniforms)
-
-        copy_mobject.submobjects = []
-        copy_mobject.add(*(sm.copy() for sm in self.submobjects))
-        copy_mobject.match_updaters(self)
+        result = copy.copy(self)
+
+        # The line above is only a shallow copy, so the internal
+        # data which are numpyu arrays or other mobjects still
+        # need to be further copied.
+        result.data = {k: np.array(v) for k, v in self.data.items()}
+        result.uniforms = {k: np.array(v) for k, v in self.uniforms.items()}
+
+        # Instead of adding using result.add, which does some checks for updating
+        # updater statues and bounding box, just directly modify the family-related
+        # lists
+        result.submobjects = [sm.copy() for sm in self.submobjects]
+        for sm in result.submobjects:
+            sm.parents = [result]
+        result.family = [
+            result,
+            *it.chain(*(sm.get_family() for sm in result.submobjects)),
+        ]
 
-        copy_mobject.needs_new_bounding_box = self.needs_new_bounding_box
+        # Similarly, instead of calling match_updaters, since we know the status
+        # won't have changed, just directly match.
+        result.non_time_updaters = list(self.non_time_updaters)
+        result.time_based_updaters = list(self.time_based_updaters)
 
-        # Make sure any mobject or numpy array attributes are copied
         family = self.get_family()
         for attr, value in list(self.__dict__.items()):
             if (
                 isinstance(value, OpenGLMobject)
-                and value in family
                 and value is not self
+                and value in family
             ):
-                setattr(copy_mobject, attr, value.copy())
+                setattr(result, attr, result.family[self.family.index(value)])
             if isinstance(value, np.ndarray):
-                setattr(copy_mobject, attr, value.copy())
-            # if isinstance(value, ShaderWrapper):
-            #     setattr(copy_mobject, attr, value.copy())
-        return copy_mobject
-
-    def deepcopy(self):
-        parents = self.parents
-        self.parents = []
-        result = copy.deepcopy(self)
-        self.parents = parents
+                setattr(result, attr, value.copy())
+            if isinstance(value, ShaderWrapper):
+                setattr(result, attr, value.copy())
         return result
 
     def generate_target(self, use_deepcopy: bool = False):
-        self.target = None  # Prevent exponential explosion
-        if use_deepcopy:
-            self.target = self.deepcopy()
-        else:
-            self.target = self.copy()
+        target: OpenGLMobject = self.copy(deep=use_deepcopy)
+        target.saved_state = self.saved_state
+        self.target = target
         return self.target
 
     def save_state(self, use_deepcopy: bool = False):
         """Save the current state (position, color & size). Can be restored with :meth:`~.OpenGLMobject.restore`."""
-        if hasattr(self, "saved_state"):
-            # Prevent exponential growth of data
-            self.saved_state = None
-        if use_deepcopy:
-            self.saved_state = self.deepcopy()
-        else:
-            self.saved_state = self.copy()
+        saved_state: OpenGLMobject = self.copy(deep=use_deepcopy)
+        saved_state.target = self.target
+        self.saved_state = saved_state
         return self
 
     def restore(self):
@@ -1344,43 +1414,113 @@ def restore(self):
         self.become(self.saved_state)
         return self
 
+    def save_to_file(self, file_path: str):
+        with open(file_path, "wb") as fp:
+            fp.write(self.serialize())
+        logger.info(f"Saved mobject to {file_path}")
+        return self
+
+    @staticmethod
+    def load(file_path):
+        if not os.path.exists(file_path):
+            logger.error(f"No file found at {file_path}")
+            sys.exit(2)
+        with open(file_path, "rb") as fp:
+            mobject = pickle.load(fp)
+        return mobject
+
+    # Creating new Mobjects from this one
+
+    def replicate(self, n: int) -> OpenGLGroup:
+        """Returns an :class:`~.OpenGLVGroup` containing ``n`` copies of the mobject."""
+        group_class = self.get_group_class()
+        return group_class(*(self.copy() for _ in range(n)))
+
+    def get_grid_legacy(self, n_rows, n_cols, height=None, **kwargs):
+        """
+        Returns a new mobject containing multiple copies of this one
+        arranged in a grid
+        """
+        grid = self.duplicate(n_rows * n_cols)
+        grid.arrange_in_grid(n_rows, n_cols, **kwargs)
+        if height is not None:
+            grid.set_height(height)
+        return grid
+
+    def get_grid(
+        self,
+        n_rows: int,
+        n_cols: int,
+        height: float | None = None,
+        width: float | None = None,
+        group_by_rows: bool = False,
+        group_by_cols: bool = False,
+        **kwargs,
+    ) -> OpenGLGroup:
+        """
+        Returns a new mobject containing multiple copies of this one
+        arranged in a grid
+        """
+        total = n_rows * n_cols
+        grid = self.replicate(total)
+        if group_by_cols:
+            kwargs["fill_rows_first"] = False
+        grid.arrange_in_grid(n_rows, n_cols, **kwargs)
+        if height is not None:
+            grid.set_height(height)
+        if width is not None:
+            grid.set_height(width)
+
+        group_class = self.get_group_class()
+        if group_by_rows:
+            return group_class(*(grid[n : n + n_cols] for n in range(0, total, n_cols)))
+        elif group_by_cols:
+            return group_class(*(grid[n : n + n_rows] for n in range(0, total, n_rows)))
+        else:
+            return grid
+
     # Updating
 
-    def init_updaters(self):
-        self.time_based_updaters = []
-        self.non_time_updaters = []
-        self.has_updaters = False
-        self.updating_suspended = False
+    def init_updaters(self) -> None:
+        self.time_based_updaters: list[TimeBasedUpdater] = []
+        self.non_time_updaters: list[NonTimeUpdater] = []
+        self.has_updaters: bool = False
+        self.updating_suspended: bool = False
 
-    def update(self, dt=0, recurse=True):
+    def update(self, dt: float = 0, recurse: bool = True) -> OpenGLMobject:
         if not self.has_updaters or self.updating_suspended:
             return self
-        for updater in self.time_based_updaters:
-            updater(self, dt)
-        for updater in self.non_time_updaters:
-            updater(self)
+        for time_updater in self.time_based_updaters:
+            time_updater(self, dt)
+        for non_time_updater in self.non_time_updaters:
+            non_time_updater(self)
         if recurse:
             for submob in self.submobjects:
                 submob.update(dt, recurse)
         return self
 
-    def get_time_based_updaters(self):
+    def get_time_based_updaters(self) -> list[TimeBasedUpdater]:
         return self.time_based_updaters
 
-    def has_time_based_updater(self):
+    def has_time_based_updater(self) -> bool:
         return len(self.time_based_updaters) > 0
 
-    def get_updaters(self):
+    def get_updaters(self) -> list[Updater]:
         return self.time_based_updaters + self.non_time_updaters
 
-    def get_family_updaters(self):
+    def get_family_updaters(self) -> list[Updater]:
         return list(it.chain(*(sm.get_updaters() for sm in self.get_family())))
 
-    def add_updater(self, update_function, index=None, call_updater=False):
+    def add_updater(
+        self,
+        update_function: Updater,
+        index: int | None = None,
+        call_updater: bool = False,
+    ) -> OpenGLMobject:
         if "dt" in get_parameters(update_function):
-            updater_list = self.time_based_updaters
+            updater_list: list[Updater] = self.time_based_updaters  # type: ignore
         else:
-            updater_list = self.non_time_updaters
+            updater_list: list[Updater] = self.non_time_updaters  # type: ignore
 
         if index is None:
             updater_list.append(update_function)
@@ -1392,14 +1532,18 @@ def add_updater(self, update_function, index=None, call_updater=False):
             self.update()
         return self
 
-    def remove_updater(self, update_function):
-        for updater_list in [self.time_based_updaters, self.non_time_updaters]:
+    def remove_updater(self, update_function: Updater) -> OpenGLMobject:
+        updater_lists: list[list[Updater]] = [
+            self.time_based_updaters,  # type: ignore
+            self.non_time_updaters,  # type: ignore
+        ]
+        for updater_list in updater_lists:
             while update_function in updater_list:
                 updater_list.remove(update_function)
         self.refresh_has_updater_status()
         return self
 
-    def clear_updaters(self, recurse=True):
+    def clear_updaters(self, recurse: bool = True) -> OpenGLMobject:
         self.time_based_updaters = []
         self.non_time_updaters = []
         self.refresh_has_updater_status()
@@ -1408,20 +1552,22 @@ def clear_updaters(self, recurse=True):
                 submob.clear_updaters()
         return self
 
-    def match_updaters(self, mobject):
+    def match_updaters(self, mobject: OpenGLMobject) -> OpenGLMobject:
         self.clear_updaters()
         for updater in mobject.get_updaters():
             self.add_updater(updater)
         return self
 
-    def suspend_updating(self, recurse=True):
+    def suspend_updating(self, recurse: bool = True) -> OpenGLMobject:
         self.updating_suspended = True
         if recurse:
             for submob in self.submobjects:
                 submob.suspend_updating(recurse)
         return self
 
-    def resume_updating(self, recurse=True, call_updater=True):
+    def resume_updating(
+        self, recurse: bool = True, call_updater: bool = True
+    ) -> OpenGLMobject:
         self.updating_suspended = False
         if recurse:
             for submob in self.submobjects:
@@ -1432,13 +1578,25 @@ def resume_updating(self, recurse=True, call_updater=True):
             self.update(dt=0, recurse=recurse)
         return self
 
-    def refresh_has_updater_status(self):
+    def refresh_has_updater_status(self) -> OpenGLMobject:
         self.has_updaters = any(mob.get_updaters() for mob in self.get_family())
         return self
 
+    # Check if mark as static or not for camera
+
+    def is_changing(self) -> bool:
+        return self.has_updaters or self._is_animating
+
+    def set_animating_status(
+        self, is_animating: bool, recurse: bool = True
+    ) -> OpenGLMobject:
+        for mob in (*self.get_family(recurse), *self.get_ancestors(extended=True)):
+            mob._is_animating = is_animating
+        return self
+
     # Transforming operations
 
-    def shift(self, vector):
+    def shift(self, vector) -> OpenGLMobject:
         self.apply_points_function(
             lambda points: points + vector,
             about_edge=None,
@@ -1448,9 +1606,10 @@ def shift(self, vector):
 
     def scale(
         self,
-        scale_factor: float,
-        about_point: Sequence[float] | None = None,
-        about_edge: Sequence[float] = ORIGIN,
+        scale_factor: float | np.ndarray,
+        min_scale_factor: float = 1e-8,
+        about_point: Sequence[float] | np.ndarray | None = None,
+        about_edge: Sequence[float] | np.ndarray = ORIGIN,
         **kwargs,
     ) -> OpenGLMobject:
         r"""Scale the size by a factor.
@@ -1469,6 +1628,10 @@ def scale(
             The scaling factor :math:`\alpha`. If :math:`0 < |\alpha| < 1`, the mobject
             will shrink, and for :math:`|\alpha| > 1` it will grow. Furthermore,
             if :math:`\alpha < 0`, the mobject is also flipped.
+
+        min_scale_factor
+            The minimum scaling factor that is used such that the mobject is not scaled to zero.
+
         kwargs
             Additional keyword arguments passed to
             :meth:`apply_points_function_about_point`.
@@ -1499,16 +1662,33 @@ def construct(self):
         :meth:`move_to`
 
         """
+        if isinstance(scale_factor, numbers.Number):
+            scale_factor = max(scale_factor, min_scale_factor)
+        else:
+            scale_factor = np.array(scale_factor).clip(min=min_scale_factor)  # type: ignore
         self.apply_points_function(
             lambda points: scale_factor * points,
             about_point=about_point,
             about_edge=about_edge,
             works_on_bounding_box=True,
-            **kwargs,
         )
+        for mob in self.get_family():
+            mob._handle_scale_side_effects(scale_factor)
         return self
 
-    def stretch(self, factor, dim, **kwargs):
+    def _handle_scale_side_effects(self, scale_factor: float | np.ndarray) -> None:
+        """In case subclasses, such as DecimalNumber, need to make
+        any other changes when the size gets altered by scaling.
+        This method can be overridden in subclasses.
+
+        Parameters
+        ----------
+        scale_factor
+            The scaling factor :math:`\alpha`. If :math:`0 < |\alpha| < 1`
+        """
+        pass
+
+    def stretch(self, factor: float, dim: int, **kwargs) -> OpenGLMobject:
         def func(points):
             points[:, dim] *= factor
             return points
@@ -1516,16 +1696,16 @@ def func(points):
         self.apply_points_function(func, works_on_bounding_box=True, **kwargs)
         return self
 
-    def rotate_about_origin(self, angle, axis=OUT):
-        return self.rotate(angle, axis, about_point=ORIGIN)
+    def rotate_about_origin(self, angle: float, axis=OUT) -> OpenGLMobject:
+        return self.rotate(angle, axis, about_point=ORIGIN)  # type: ignore
 
     def rotate(
         self,
-        angle,
+        angle: float,
         axis=OUT,
         about_point: Sequence[float] | None = None,
         **kwargs,
-    ):
+    ) -> OpenGLMobject:
         """Rotates the :class:`~.OpenGLMobject` about a certain point."""
         rot_matrix_T = rotation_matrix_transpose(angle, axis)
         self.apply_points_function(
@@ -1535,7 +1715,7 @@ def rotate(
         )
         return self
 
-    def flip(self, axis=UP, **kwargs):
+    def flip(self, axis=UP, **kwargs) -> OpenGLMobject:
         """Flips/Mirrors an mobject about its center.
 
         Examples
@@ -1554,7 +1734,7 @@ def construct(self):
         """
         return self.rotate(TAU / 2, axis, **kwargs)
 
-    def apply_function(self, function, **kwargs):
+    def apply_function(self, function: PointUpdateFunction, **kwargs) -> OpenGLMobject:
         # Default to applying matrix about the origin, not mobjects center
         if len(kwargs) == 0:
             kwargs["about_point"] = ORIGIN
@@ -1563,16 +1743,20 @@ def apply_function(self, function, **kwargs):
         )
         return self
 
-    def apply_function_to_position(self, function):
+    def apply_function_to_position(
+        self, function: PointUpdateFunction
+    ) -> OpenGLMobject:
         self.move_to(function(self.get_center()))
         return self
 
-    def apply_function_to_submobject_positions(self, function):
+    def apply_function_to_submobject_positions(
+        self, function: PointUpdateFunction
+    ) -> OpenGLMobject:
         for submob in self.submobjects:
             submob.apply_function_to_position(function)
         return self
 
-    def apply_matrix(self, matrix, **kwargs):
+    def apply_matrix(self, matrix, **kwargs) -> OpenGLMobject:
         # Default to applying matrix about the origin, not mobjects center
         if ("about_point" not in kwargs) and ("about_edge" not in kwargs):
             kwargs["about_point"] = ORIGIN
@@ -1584,7 +1768,7 @@ def apply_matrix(self, matrix, **kwargs):
         )
         return self
 
-    def apply_complex_function(self, function, **kwargs):
+    def apply_complex_function(self, function, **kwargs) -> OpenGLMobject:
         """Applies a complex function to a :class:`OpenGLMobject`.
         The x and y coordinates correspond to the real and imaginary parts respectively.
 
@@ -1629,7 +1813,7 @@ def hierarchical_model_matrix(self):
             current_object = current_object.parent
         return np.linalg.multi_dot(list(reversed(model_matrices)))
 
-    def wag(self, direction=RIGHT, axis=DOWN, wag_factor=1.0):
+    def wag(self, direction=RIGHT, axis=DOWN, wag_factor=1.0) -> OpenGLMobject:
         for mob in self.family_members_with_points():
             alphas = np.dot(mob.points, np.transpose(axis))
             alphas -= min(alphas)
@@ -1646,19 +1830,21 @@ def wag(self, direction=RIGHT, axis=DOWN, wag_factor=1.0):
 
     # Positioning methods
 
-    def center(self):
+    def center(self) -> OpenGLMobject:
         """Moves the mobject to the center of the Scene."""
         self.shift(-self.get_center())
         return self
 
-    def align_on_border(self, direction, buff=DEFAULT_MOBJECT_TO_EDGE_BUFFER):
+    def align_on_border(
+        self, direction, buff=DEFAULT_MOBJECT_TO_EDGE_BUFFER
+    ) -> OpenGLMobject:
         """
         Direction just needs to be a vector pointing towards side or
         corner in the 2d plane.
         """
         target_point = np.sign(direction) * (
-            config["frame_x_radius"],
-            config["frame_y_radius"],
+            config.frame_x_radius,
+            config.frame_y_radius,
             0,
         )
         point_to_align = self.get_bounding_box_point(direction)
@@ -1667,10 +1853,12 @@ def align_on_border(self, direction, buff=DEFAULT_MOBJECT_TO_EDGE_BUFFER):
         self.shift(shift_val)
         return self
 
-    def to_corner(self, corner=LEFT + DOWN, buff=DEFAULT_MOBJECT_TO_EDGE_BUFFER):
+    def to_corner(
+        self, corner=LEFT + DOWN, buff=DEFAULT_MOBJECT_TO_EDGE_BUFFER
+    ) -> OpenGLMobject:
         return self.align_on_border(corner, buff)
 
-    def to_edge(self, edge=LEFT, buff=DEFAULT_MOBJECT_TO_EDGE_BUFFER):
+    def to_edge(self, edge=LEFT, buff=DEFAULT_MOBJECT_TO_EDGE_BUFFER) -> OpenGLMobject:
         return self.align_on_border(edge, buff)
 
     def next_to(
@@ -1682,7 +1870,7 @@ def next_to(
         submobject_to_align=None,
         index_of_submobject_to_align=None,
         coor_mask=np.array([1, 1, 1]),
-    ):
+    ) -> OpenGLMobject:
         """Move this :class:`~.OpenGLMobject` next to another's :class:`~.OpenGLMobject` or coordinate.
 
         Examples
@@ -1725,7 +1913,7 @@ def construct(self):
         return self
 
     def shift_onto_screen(self, **kwargs):
-        space_lengths = [config["frame_x_radius"], config["frame_y_radius"]]
+        space_lengths = [config.frame_x_radius, config.frame_y_radius]
         for vect in UP, DOWN, LEFT, RIGHT:
             dim = np.argmax(np.abs(vect))
             buff = kwargs.get("buff", DEFAULT_MOBJECT_TO_EDGE_BUFFER)
@@ -1831,6 +2019,36 @@ def set_depth(self, depth, stretch=False, **kwargs):
 
     scale_to_fit_depth = set_depth
 
+    def set_max_width(self, max_width: float, **kwargs):
+        if self.get_width() > max_width:
+            self.set_width(max_width, **kwargs)
+        return self
+
+    def set_max_height(self, max_height: float, **kwargs):
+        if self.get_height() > max_height:
+            self.set_height(max_height, **kwargs)
+        return self
+
+    def set_max_depth(self, max_depth: float, **kwargs):
+        if self.get_depth() > max_depth:
+            self.set_depth(max_depth, **kwargs)
+        return self
+
+    def set_min_width(self, min_width: float, **kwargs):
+        if self.get_width() < min_width:
+            self.set_width(min_width, **kwargs)
+        return self
+
+    def set_min_height(self, min_height: float, **kwargs):
+        if self.get_height() < min_height:
+            self.set_height(min_height, **kwargs)
+        return self
+
+    def set_min_depth(self, min_depth: float, **kwargs):
+        if self.get_depth() < min_depth:
+            self.set_depth(min_depth, **kwargs)
+        return self
+
     def set_coord(self, value, dim, direction=ORIGIN):
         curr = self.get_coord(dim, direction)
         shift_vect = np.zeros(self.dim)
@@ -1860,7 +2078,7 @@ def move_to(
         self,
         point_or_mobject,
         aligned_edge=ORIGIN,
-        coor_mask=np.array([1, 1, 1]),
+        coor_mask=np.array((1, 1, 1)),
     ):
         """Move center of the :class:`~.OpenGLMobject` to certain coordinate."""
         if isinstance(point_or_mobject, OpenGLMobject):
@@ -1899,12 +2117,13 @@ def surround(
         self.scale((length + buff) / length)
         return self
 
-    def put_start_and_end_on(self, start, end):
+    # ! TODO: Check implementation of 3b1b for this method
+    def put_start_and_end_on_legacy(self, start, end):
         curr_start, curr_end = self.get_start_and_end()
         curr_vect = curr_end - curr_start
         if np.all(curr_vect == 0):
             raise Exception("Cannot position endpoints of closed loop")
-        target_vect = np.array(end) - np.array(start)
+        target_vect = np.asarray(end) - np.asarray(start)
         axis = (
             normalize(np.cross(curr_vect, target_vect))
             if np.linalg.norm(np.cross(curr_vect, target_vect)) != 0
@@ -1922,9 +2141,32 @@ def put_start_and_end_on(self, start, end):
         self.shift(start - curr_start)
         return self
 
+    def put_start_and_end_on(self, start: np.ndarray, end: np.ndarray):
+        curr_start, curr_end = self.get_start_and_end()
+        curr_vect = curr_end - curr_start
+        if np.all(curr_vect == 0):
+            raise Exception("Cannot position endpoints of closed loop")
+        target_vect = end - start
+        self.scale(
+            get_norm(target_vect) / get_norm(curr_vect),
+            about_point=curr_start,
+        )
+        self.rotate(
+            angle_of_vector(target_vect) - angle_of_vector(curr_vect),
+        )
+        self.rotate(
+            np.arctan2(curr_vect[2], get_norm(curr_vect[:2]))
+            - np.arctan2(target_vect[2], get_norm(target_vect[:2])),
+            axis=np.array([-target_vect[1], target_vect[0], 0]),
+        )
+        self.shift(start - self.get_start())
+        return self
+
     # Color functions
 
-    def set_rgba_array(self, color=None, opacity=None, name="rgbas", recurse=True):
+    def set_rgba_array_legacy(
+        self, color=None, opacity=None, name="rgbas", recurse=True
+    ):
         if color is not None:
             rgbs = np.array([color_to_rgb(c) for c in listify(color)])
         if opacity is not None:
@@ -1954,7 +2196,9 @@ def set_rgba_array(self, color=None, opacity=None, name="rgbas", recurse=True):
                 mob.data[name] = rgbas.copy()
         return self
 
-    def set_rgba_array_direct(self, rgbas: np.ndarray, name="rgbas", recurse=True):
+    def set_rgba_array(
+        self, rgba_array: np.ndarray, name: str = "rgbas", recurse: bool = False
+    ):
         """Directly set rgba data from `rgbas` and optionally do the same recursively
         with submobjects. This can be used if the `rgbas` have already been generated
         with the correct shape and simply need to be set.
@@ -1969,7 +2213,58 @@ def set_rgba_array_direct(self, rgbas: np.ndarray, name="rgbas", recurse=True):
             set to true to recursively apply this method to submobjects
         """
         for mob in self.get_family(recurse):
-            mob.data[name] = rgbas.copy()
+            mob.data[name] = np.array(rgba_array)  # type: ignore
+        return self
+
+    def set_color_by_rgba_func(
+        self, func: Callable[[np.ndarray], np.ndarray], recurse: bool = True
+    ):
+        """
+        Func should take in a point in R3 and output an rgba value
+        """
+        for mob in self.get_family(recurse):
+            rgba_array = np.asarray([func(point) for point in mob.points])
+            mob.set_rgba_array(rgba_array)
+        return self
+
+    def set_color_by_rgb_func(
+        self,
+        func: Callable[[np.ndarray], np.ndarray],
+        opacity: float = 1,
+        recurse: bool = True,
+    ):
+        """
+        Func should take in a point in R3 and output an rgb value
+        """
+        for mob in self.get_family(recurse):
+            rgba_array = np.asarray([[*func(point), opacity] for point in mob.points])
+            mob.set_rgba_array(rgba_array)
+        return self
+
+    def set_rgba_array_by_color(
+        self,
+        color=None,
+        opacity: float | Iterable[float] | None = None,
+        name: str = "rgbas",
+        recurse: bool = True,
+    ):
+        max_len = 0
+        if color is not None:
+            rgbs = np.array([color_to_rgb(c) for c in listify(color)])
+            max_len = len(rgbs)
+        if opacity is not None:
+            opacities = np.array(listify(opacity))
+            max_len = max(max_len, len(opacities))
+
+        for mob in self.get_family(recurse):
+            if max_len > len(mob.data[name]):  # type: ignore
+                mob.data[name] = resize_array(mob.data[name], max_len)  # type: ignore
+            size = len(mob.data[name])  # type: ignore
+            if color is not None:
+                mob.data[name][:, :3] = resize_array(rgbs, size)  # type: ignore
+            if opacity is not None:
+                mob.data[name][:, 3] = resize_array(opacities, size)  # type: ignore
+        return self
 
     def set_color(self, color, opacity=None, recurse=True):
         self.set_rgba_array(color, opacity, recurse=False)
@@ -1995,10 +2290,13 @@ def get_color(self):
         return rgb_to_hex(self.rgbas[0, :3])
 
     def get_opacity(self):
-        return self.rgbas[0, 3]
+        return self.data["rgbas"][0, 3]
 
-    def set_color_by_gradient(self, *colors):
-        self.set_submobject_colors_by_gradient(*colors)
+    def set_color_by_gradient(self, *colors: Color):
+        if self.has_points():
+            self.set_color(colors)
+        else:
+            self.set_submobject_colors_by_gradient(*colors)
         return self
 
     def set_submobject_colors_by_gradient(self, *colors):
@@ -2018,20 +2316,28 @@ def set_submobject_colors_by_gradient(self, *colors):
     def fade(self, darkness=0.5, recurse=True):
         self.set_opacity(1.0 - darkness, recurse=recurse)
 
-    def get_gloss(self):
-        return self.gloss
+    def get_reflectiveness(self) -> np.ndarray:
+        return self.uniforms["reflectiveness"]
+
+    def set_reflectiveness(self, reflectiveness: float, recurse: bool = True):
+        for mob in self.get_family(recurse):
+            mob.uniforms["reflectiveness"] = np.asarray(reflectiveness)
+        return self
+
+    def get_shadow(self) -> np.ndarray:
+        return self.uniforms["shadow"]
 
-    def set_gloss(self, gloss, recurse=True):
+    def set_shadow(self, shadow: float, recurse: bool = True):
         for mob in self.get_family(recurse):
-            mob.gloss = gloss
+            mob.uniforms["shadow"] = np.asarray(shadow)
         return self
 
-    def get_shadow(self):
-        return self.shadow
+    def get_gloss(self) -> np.ndarray:
+        return self.uniforms["gloss"]
 
-    def set_shadow(self, shadow, recurse=True):
+    def set_gloss(self, gloss: float, recurse: bool = True):
         for mob in self.get_family(recurse):
-            mob.shadow = shadow
+            mob.uniforms["gloss"] = np.asarray(gloss)
         return self
 
     # Background rectangle
@@ -2073,7 +2379,7 @@ def add_background_rectangle(
         self.background_rectangle = BackgroundRectangle(
             self, color=color, fill_opacity=opacity, **kwargs
         )
-        self.add_to_back(self.background_rectangle)
+        self.add_to_back(self.background_rectangle)  # type: ignore
         return self
 
     def add_background_rectangle_to_submobjects(self, **kwargs):
@@ -2101,6 +2407,15 @@ def get_corner(self, direction) -> np.ndarray:
         """Get corner coordinates for certain direction."""
         return self.get_bounding_box_point(direction)
 
+    def get_all_corners(self):
+        bb = self.get_bounding_box()
+        return np.array(
+            [
+                [bb[indices[-i + 1]][i] for i in range(3)]
+                for indices in it.product([0, 2], repeat=3)
+            ]
+        )
+
     def get_center(self) -> np.ndarray:
         """Get center coordinates."""
         return self.get_bounding_box()[1]
@@ -2205,9 +2520,8 @@ def point_from_proportion(self, alpha):
         i, subalpha = integer_interpolate(0, len(points) - 1, alpha)
         return interpolate(points[i], points[i + 1], subalpha)
 
-    def pfp(self, alpha):
-        """Abbreviation for point_from_proportion"""
-        return self.point_from_proportion(alpha)
+    pfp = point_from_proportion
+    """Abbreviation for point_from_proportion"""
 
     def get_pieces(self, n_pieces):
         template = self.copy()
@@ -2247,25 +2561,25 @@ def match_depth(self, mobject: OpenGLMobject, **kwargs):
         """Match the depth with the depth of another :class:`~.OpenGLMobject`."""
         return self.match_dim_size(mobject, 2, **kwargs)
 
-    def match_coord(self, mobject: OpenGLMobject, dim, direction=ORIGIN):
+    def match_coord(self, mobject_or_point: OpenGLMobject, dim, direction=ORIGIN):
         """Match the coordinates with the coordinates of another :class:`~.OpenGLMobject`."""
-        return self.set_coord(
-            mobject.get_coord(dim, direction),
-            dim=dim,
-            direction=direction,
-        )
+        if isinstance(mobject_or_point, OpenGLMobject):
+            coord = mobject_or_point.get_coord(dim, direction)
+        else:
+            coord = mobject_or_point[dim]
+        return self.set_coord(coord, dim=dim, direction=direction)
 
-    def match_x(self, mobject, direction=ORIGIN):
+    def match_x(self, mobject_or_point, direction=ORIGIN):
         """Match x coord. to the x coord. of another :class:`~.OpenGLMobject`."""
-        return self.match_coord(mobject, 0, direction)
+        return self.match_coord(mobject_or_point, 0, direction)
 
-    def match_y(self, mobject, direction=ORIGIN):
+    def match_y(self, mobject_or_point, direction=ORIGIN):
         """Match y coord. to the x coord. of another :class:`~.OpenGLMobject`."""
-        return self.match_coord(mobject, 1, direction)
+        return self.match_coord(mobject_or_point, 1, direction)
 
-    def match_z(self, mobject, direction=ORIGIN):
+    def match_z(self, mobject_or_point, direction=ORIGIN):
         """Match z coord. to the x coord. of another :class:`~.OpenGLMobject`."""
-        return self.match_coord(mobject, 2, direction)
+        return self.match_coord(mobject_or_point, 2, direction)
 
     def align_to(
         self,
@@ -2305,9 +2619,9 @@ def align_data_and_family(self, mobject):
         self.align_family(mobject)
         self.align_data(mobject)
 
-    def align_data(self, mobject):
+    def align_data(self, mobject) -> None:
         # In case any data arrays get resized when aligned to shader data
-        # self.refresh_shader_data()
+        self.refresh_shader_data()
         for mob1, mob2 in zip(self.get_family(), mobject.get_family()):
             # Separate out how points are treated so that subclasses
             # can handle that case differently if they choose
@@ -2315,20 +2629,20 @@ def align_data(self, mobject):
             for key in mob1.data.keys() & mob2.data.keys():
                 if key == "points":
                     continue
-                arr1 = mob1.data[key]
+                arr1 = mob1.data[key]  # type: ignore
                 arr2 = mob2.data[key]
                 if len(arr2) > len(arr1):
-                    mob1.data[key] = resize_preserving_order(arr1, len(arr2))
+                    mob1.data[key] = resize_preserving_order(arr1, len(arr2))  # type: ignore
                 elif len(arr1) > len(arr2):
                     mob2.data[key] = resize_preserving_order(arr2, len(arr1))
 
-    def align_points(self, mobject):
+    def align_points(self, mobject) -> OpenGLMobject:
         max_len = max(self.get_num_points(), mobject.get_num_points())
         for mob in (self, mobject):
             mob.resize_points(max_len, resize_func=resize_preserving_order)
         return self
 
-    def align_family(self, mobject):
+    def align_family(self, mobject) -> OpenGLMobject:
         mob1 = self
         mob2 = mobject
         n1 = len(mob1)
@@ -2341,14 +2655,14 @@ def align_family(self, mobject):
             sm1.align_family(sm2)
         return self
 
-    def push_self_into_submobjects(self):
+    def push_self_into_submobjects(self) -> OpenGLMobject:
         copy = self.deepcopy()
         copy.submobjects = []
-        self.resize_points(0)
+        self.clear_points()
         self.add(copy)
         return self
 
-    def add_n_more_submobjects(self, n):
+    def add_n_more_submobjects(self, n) -> OpenGLMobject:
         if n == 0:
             return self
 
@@ -2377,7 +2691,9 @@ def add_n_more_submobjects(self, n):
 
     # Interpolate
 
-    def interpolate(self, mobject1, mobject2, alpha, path_func=straight_path()):
+    def interpolate(
+        self, mobject1, mobject2, alpha, path_func=straight_path
+    ) -> OpenGLMobject:
         """Turns this :class:`~.OpenGLMobject` into an interpolation between ``mobject1``
         and ``mobject2``.
 
@@ -2401,7 +2717,7 @@ def construct(self):
         for key in self.data:
             if key in self.locked_data_keys:
                 continue
-            if len(self.data[key]) == 0:
+            if len(self.data[key]) == 0:  # type: ignore
                 continue
             if key not in mobject1.data or key not in mobject2.data:
                 continue
@@ -2411,23 +2727,11 @@ def construct(self):
             else:
                 func = interpolate
 
-            self.data[key][:] = func(mobject1.data[key], mobject2.data[key], alpha)
-
+            self.data[key][:] = func(mobject1.data[key], mobject2.data[key], alpha)  # type: ignore
         for key in self.uniforms:
-            if key != "fixed_orientation_center":
-                self.uniforms[key] = interpolate(
-                    mobject1.uniforms[key],
-                    mobject2.uniforms[key],
-                    alpha,
-                )
-            else:
-                self.uniforms["fixed_orientation_center"] = tuple(
-                    interpolate(
-                        np.array(mobject1.uniforms["fixed_orientation_center"]),
-                        np.array(mobject2.uniforms["fixed_orientation_center"]),
-                        alpha,
-                    )
-                )
+            self.uniforms[key] = interpolate(  # type: ignore
+                mobject1.uniforms[key], mobject2.uniforms[key], alpha
+            )
         return self
 
     def pointwise_become_partial(self, mobject, a, b):
@@ -2439,9 +2743,45 @@ def pointwise_become_partial(self, mobject, a, b):
         """
         pass  # To implement in subclass
 
+    # Locking data
+
+    def lock_data(self, keys: Iterable[str]):
+        """
+        To speed up some animations, particularly transformations,
+        it can be handy to acknowledge which pieces of data
+        won't change during the animation so that calls to
+        interpolate can skip this, and so that it's not
+        read into the shader_wrapper objects needlessly
+        """
+        if self.has_updaters:
+            return
+        # Be sure shader data has most up to date information
+        self.refresh_shader_data()
+        self.locked_data_keys = set(keys)
+
+    def lock_matching_data(self, mobject1: OpenGLMobject, mobject2: OpenGLMobject):
+        for sm, sm1, sm2 in zip(
+            self.get_family(), mobject1.get_family(), mobject2.get_family()
+        ):
+            keys = sm.data.keys() & sm1.data.keys() & sm2.data.keys()
+            sm.lock_data(
+                list(
+                    filter(
+                        lambda key: np.all(sm1.data[key] == sm2.data[key]),  # type: ignore
+                        keys,
+                    )
+                )
+            )
+        return self
+
+    def unlock_data(self):
+        for mob in self.get_family():
+            mob.locked_data_keys = set()
+
     def become(
         self,
         mobject: OpenGLMobject,
+        match_updaters=False,
         match_height: bool = False,
         match_width: bool = False,
         match_depth: bool = False,
@@ -2482,7 +2822,7 @@ def construct(self):
                     circ.become(square)
                     self.wait(0.5)
         """
-
+        # Manim CE Weird stretching thing which also modifies the original mobject
         if stretch:
             mobject.stretch_to_fit_height(self.height)
             mobject.stretch_to_fit_width(self.width)
@@ -2498,74 +2838,62 @@ def construct(self):
         if match_center:
             mobject.move_to(self.get_center())
 
+        # Original 3b1b/manim behaviour
         self.align_family(mobject)
-        for sm1, sm2 in zip(self.get_family(), mobject.get_family()):
+        family1 = self.get_family()
+        family2 = mobject.get_family()
+        for sm1, sm2 in zip(family1, family2):
             sm1.set_data(sm2.data)
             sm1.set_uniforms(sm2.uniforms)
+            sm1.shader_folder = sm2.shader_folder
+            sm1.texture_paths = sm2.texture_paths
+            sm1.depth_test = sm2.depth_test
+            sm1.render_primitive = sm2.render_primitive
+        # Make sure named family members carry over
+        for attr, value in list(mobject.__dict__.items()):
+            if isinstance(value, OpenGLMobject) and value in family2:
+                setattr(self, attr, family1[family2.index(value)])
         self.refresh_bounding_box(recurse_down=True)
+        if match_updaters:
+            self.match_updaters(mobject)
         return self
 
-    # Locking data
-
-    def lock_data(self, keys):
-        """
-        To speed up some animations, particularly transformations,
-        it can be handy to acknowledge which pieces of data
-        won't change during the animation so that calls to
-        interpolate can skip this, and so that it's not
-        read into the shader_wrapper objects needlessly
-        """
-        if self.has_updaters:
-            return
-        # Be sure shader data has most up to date information
-        self.refresh_shader_data()
-        self.locked_data_keys = set(keys)
-
-    def lock_matching_data(self, mobject1, mobject2):
-        for sm, sm1, sm2 in zip(
-            self.get_family(),
-            mobject1.get_family(),
-            mobject2.get_family(),
-        ):
-            keys = sm.data.keys() & sm1.data.keys() & sm2.data.keys()
-            sm.lock_data(
-                list(
-                    filter(
-                        lambda key: np.all(sm1.data[key] == sm2.data[key]),
-                        keys,
-                    ),
-                ),
-            )
-        return self
+    # Operations touching shader uniforms
 
-    def unlock_data(self):
-        for mob in self.get_family():
-            mob.locked_data_keys = set()
+    @staticmethod
+    def affects_shader_info_id(func):
+        @wraps(func)
+        def wrapper(self):
+            for mob in self.get_family():
+                func(mob)
+                mob.refresh_shader_wrapper_id()
+            return self
 
-    # Operations touching shader uniforms
+        return wrapper
 
     @affects_shader_info_id
-    def fix_in_frame(self):
-        self.is_fixed_in_frame = 1.0
+    def fix_in_frame(self) -> OpenGLMobject:
+        self.uniforms["is_fixed_in_frame"] = np.asarray(1.0)
+        self.is_fixed_in_frame = True
         return self
 
     @affects_shader_info_id
-    def fix_orientation(self):
-        self.is_fixed_orientation = 1.0
+    def fix_orientation(self) -> OpenGLMobject:
+        self.uniforms["is_fixed_orientation"] = np.asarray(1.0)
+        self.is_fixed_orientation = True
         self.fixed_orientation_center = tuple(self.get_center())
-        self.depth_test = True
         return self
 
     @affects_shader_info_id
-    def unfix_from_frame(self):
-        self.is_fixed_in_frame = 0.0
+    def unfix_from_frame(self) -> OpenGLMobject:
+        self.uniforms["is_fixed_in_frame"] = np.asarray(0.0)
+        self.is_fixed_in_frame = False
         return self
 
     @affects_shader_info_id
     def unfix_orientation(self):
         self.is_fixed_orientation = 0.0
         self.fixed_orientation_center = (0, 0, 0)
-        self.depth_test = False
         return self
 
     @affects_shader_info_id
@@ -2625,26 +2953,27 @@ def set_color_by_xyz_func(
         return self
 
     # For shader data
+    def init_shader_data(self):
+        # TODO, only call this when needed?
+        self.shader_data = np.zeros(len(self.points), dtype=self.shader_dtype)
+        self.shader_indices = None
+        self.shader_wrapper = ShaderWrapper(
+            vert_data=self.shader_data,
+            shader_folder=self.shader_folder,
+            texture_paths=self.texture_paths,
+            depth_test=self.depth_test,
+            render_primitive=self.render_primitive,
+        )
 
     def refresh_shader_wrapper_id(self):
-        self.get_shader_wrapper().refresh_id()
+        self.shader_wrapper.refresh_id()
         return self
 
     def get_shader_wrapper(self):
-        from manim.renderer.shader_wrapper import ShaderWrapper
-
-        # if hasattr(self, "__shader_wrapper"):
-        # return self.__shader_wrapper
-
-        self.shader_wrapper = ShaderWrapper(
-            vert_data=self.get_shader_data(),
-            vert_indices=self.get_shader_vert_indices(),
-            uniforms=self.get_shader_uniforms(),
-            depth_test=self.depth_test,
-            texture_paths=self.texture_paths,
-            render_primitive=self.render_primitive,
-            shader_folder=self.__class__.shader_folder,
-        )
+        self.shader_wrapper.vert_data = self.get_shader_data()
+        self.shader_wrapper.vert_indices = self.get_shader_vert_indices()
+        self.shader_wrapper.uniforms = self.get_shader_uniforms()
+        self.shader_wrapper.depth_test = self.depth_test
         return self.shader_wrapper
 
     def get_shader_wrapper_list(self):
@@ -2676,12 +3005,12 @@ def check_data_alignment(self, array, data_key):
             )
         return self
 
-    def get_resized_shader_data_array(self, length):
+    def get_resized_shader_data_array(self, length: int) -> np.ndarray:
         # If possible, try to populate an existing array, rather
         # than recreating it each frame
-        points = self.points
-        shader_data = np.zeros(len(points), dtype=self.shader_dtype)
-        return shader_data
+        if len(self.shader_data) != length:
+            self.shader_data = resize_array(self.shader_data, length)
+        return self.shader_data
 
     def read_data_to_shader(self, shader_data, shader_data_key, data_key):
         if data_key in self.locked_data_keys:
@@ -2703,14 +3032,98 @@ def get_shader_uniforms(self):
     def get_shader_vert_indices(self):
         return self.shader_indices
 
-    @property
-    def submobjects(self):
-        return self._submobjects if hasattr(self, "_submobjects") else []
+    # Event Handlers
+    """
+        Event handling follows the Event Bubbling model of DOM in javascript.
+        Return false to stop the event bubbling.
+        To learn more visit https://www.quirksmode.org/js/events_order.html
 
-    @submobjects.setter
-    def submobjects(self, submobject_list):
-        self.remove(*self.submobjects)
-        self.add(*submobject_list)
+        Event Callback Argument is a callable function taking two arguments:
+            1. Mobject
+            2. EventData
+    """
+
+    def init_event_listeners(self):
+        self.event_listeners: list[EventListener] = []
+
+    def add_event_listener(
+        self,
+        event_type: EventType,
+        event_callback: Callable[[OpenGLMobject, dict[str, str]], None],
+    ):
+        event_listener = EventListener(self, event_type, event_callback)
+        self.event_listeners.append(event_listener)
+        EVENT_DISPATCHER.add_listener(event_listener)
+        return self
+
+    def remove_event_listener(
+        self,
+        event_type: EventType,
+        event_callback: Callable[[OpenGLMobject, dict[str, str]], None],
+    ):
+        event_listener = EventListener(self, event_type, event_callback)
+        while event_listener in self.event_listeners:
+            self.event_listeners.remove(event_listener)
+        EVENT_DISPATCHER.remove_listener(event_listener)
+        return self
+
+    def clear_event_listeners(self, recurse: bool = True):
+        self.event_listeners = []
+        if recurse:
+            for submob in self.submobjects:
+                submob.clear_event_listeners(recurse=recurse)
+        return self
+
+    def get_event_listeners(self):
+        return self.event_listeners
+
+    def get_family_event_listeners(self):
+        return list(it.chain(*[sm.get_event_listeners() for sm in self.get_family()]))
+
+    def get_has_event_listener(self):
+        return any(mob.get_event_listeners() for mob in self.get_family())
+
+    def add_mouse_motion_listener(self, callback):
+        self.add_event_listener(EventType.MouseMotionEvent, callback)
+
+    def remove_mouse_motion_listener(self, callback):
+        self.remove_event_listener(EventType.MouseMotionEvent, callback)
+
+    def add_mouse_press_listener(self, callback):
+        self.add_event_listener(EventType.MousePressEvent, callback)
+
+    def remove_mouse_press_listener(self, callback):
+        self.remove_event_listener(EventType.MousePressEvent, callback)
+
+    def add_mouse_release_listener(self, callback):
+        self.add_event_listener(EventType.MouseReleaseEvent, callback)
+
+    def remove_mouse_release_listener(self, callback):
+        self.remove_event_listener(EventType.MouseReleaseEvent, callback)
+
+    def add_mouse_drag_listener(self, callback):
+        self.add_event_listener(EventType.MouseDragEvent, callback)
+
+    def remove_mouse_drag_listener(self, callback):
+        self.remove_event_listener(EventType.MouseDragEvent, callback)
+
+    def add_mouse_scroll_listener(self, callback):
+        self.add_event_listener(EventType.MouseScrollEvent, callback)
+
+    def remove_mouse_scroll_listener(self, callback):
+        self.remove_event_listener(EventType.MouseScrollEvent, callback)
+
+    def add_key_press_listener(self, callback):
+        self.add_event_listener(EventType.KeyPressEvent, callback)
+
+    def remove_key_press_listener(self, callback):
+        self.remove_event_listener(EventType.KeyPressEvent, callback)
+
+    def add_key_release_listener(self, callback):
+        self.add_event_listener(EventType.KeyReleaseEvent, callback)
+
+    def remove_key_release_listener(self, callback):
+        self.remove_event_listener(EventType.KeyReleaseEvent, callback)
 
     # Errors
 
@@ -2722,6 +3135,44 @@ def throw_error_if_no_points(self):
             caller_name = sys._getframe(1).f_code.co_name
             raise Exception(message.format(caller_name))
 
+    @deprecated(
+        since="v0.17.2",
+        message="The usage of this method is discouraged please set attributes directly",
+    )
+    def set(self, **kwargs) -> OpenGLMobject:
+        """Sets attributes.
+
+        Mainly to be used along with :attr:`animate` to
+        animate setting attributes.
+
+        Examples
+        --------
+        ::
+
+            >>> mob = OpenGLMobject()
+            >>> mob.set(foo=0)
+            OpenGLMobject
+            >>> mob.foo
+            0
+
+        Parameters
+        ----------
+        **kwargs
+            The attributes and corresponding values to set.
+
+        Returns
+        -------
+        :class:`OpenGLMobject`
+            ``self``
+
+
+        """
+
+        for attr, value in kwargs.items():
+            setattr(self, attr, value)
+
+        return self
+
 
 class OpenGLGroup(OpenGLMobject):
     def __init__(self, *mobjects, **kwargs):
@@ -2730,6 +3181,10 @@ def __init__(self, *mobjects, **kwargs):
         super().__init__(**kwargs)
         self.add(*mobjects)
 
+    def __add__(self, other: OpenGLMobject | OpenGLGroup):
+        assert isinstance(other, OpenGLMobject)
+        return self.add(other)
+
 
 class OpenGLPoint(OpenGLMobject):
     def __init__(
@@ -2769,7 +3224,7 @@ def __init__(self, mobject):
         self.cannot_pass_args = False
         self.anim_args = {}
 
-    def __call__(self, **kwargs):
+    def __call__(self, **kwargs) -> _AnimationBuilder:
         if self.cannot_pass_args:
             raise ValueError(
                 "Animation arguments must be passed before accessing methods and can only be passed once",
diff --git a/manim/mobject/opengl/opengl_vectorized_mobject.py b/manim/mobject/opengl/opengl_vectorized_mobject.py
index 8dc174e12d..7fd491f9c7 100644
--- a/manim/mobject/opengl/opengl_vectorized_mobject.py
+++ b/manim/mobject/opengl/opengl_vectorized_mobject.py
@@ -11,55 +11,54 @@
 
 from manim import config
 from manim.constants import *
-from manim.mobject.opengl.opengl_mobject import OpenGLMobject, OpenGLPoint
+from manim.mobject.opengl.opengl_mobject import (
+    UNIFORM_DTYPE,
+    OpenGLMobject,
+    OpenGLPoint,
+)
 from manim.renderer.shader_wrapper import ShaderWrapper
 from manim.utils.bezier import (
     bezier,
     get_quadratic_approximation_of_cubic,
     get_smooth_cubic_bezier_handle_points,
+    get_smooth_quadratic_bezier_handle_points,
     integer_interpolate,
     interpolate,
+    inverse_interpolate,
     partial_quadratic_bezier_points,
     proportions_along_bezier_curve_for_point,
     quadratic_bezier_remap,
 )
 from manim.utils.color import *
-from manim.utils.config_ops import _Data
-from manim.utils.iterables import listify, make_even, resize_with_interpolation
+from manim.utils.iterables import (
+    listify,
+    make_even,
+    resize_array,
+    resize_with_interpolation,
+)
 from manim.utils.space_ops import (
     angle_between_vectors,
     cross2d,
     earclip_triangulation,
+    get_norm,
     get_unit_normal,
     shoelace_direction,
     z_to_vector,
 )
 
-
-def triggers_refreshed_triangulation(func):
-    @wraps(func)
-    def wrapper(self, *args, **kwargs):
-        old_points = np.empty((0, 3))
-        for mob in self.family_members_with_points():
-            old_points = np.concatenate((old_points, mob.points), axis=0)
-        func(self, *args, **kwargs)
-        new_points = np.empty((0, 3))
-        for mob in self.family_members_with_points():
-            new_points = np.concatenate((new_points, mob.points), axis=0)
-        if not np.array_equal(new_points, old_points):
-            self.refresh_triangulation()
-            self.refresh_unit_normal()
-        return self
-
-    return wrapper
+DEFAULT_STROKE_COLOR = GREY_A
+DEFAULT_FILL_COLOR = GREY_C
 
 
 class OpenGLVMobject(OpenGLMobject):
     """A vectorized mobject."""
 
+    n_points_per_curve: int = 3
+    stroke_shader_folder = "quadratic_bezier_stroke"
+    fill_shader_folder = "quadratic_bezier_fill"
     fill_dtype = [
         ("point", np.float32, (3,)),
-        ("unit_normal", np.float32, (3,)),
+        ("orientation", np.float32, (3,)),
         ("color", np.float32, (4,)),
         ("vert_index", np.float32, (1,)),
     ]
@@ -67,89 +66,48 @@ class OpenGLVMobject(OpenGLMobject):
         ("point", np.float32, (3,)),
         ("prev_point", np.float32, (3,)),
         ("next_point", np.float32, (3,)),
-        ("unit_normal", np.float32, (3,)),
         ("stroke_width", np.float32, (1,)),
         ("color", np.float32, (4,)),
     ]
-    stroke_shader_folder = "quadratic_bezier_stroke"
-    fill_shader_folder = "quadratic_bezier_fill"
+    render_primitive: int = moderngl.TRIANGLES
 
-    fill_rgba = _Data()
-    stroke_rgba = _Data()
-    stroke_width = _Data()
-    unit_normal = _Data()
+    pre_function_handle_to_anchor_scale_factor: float = 0.01
+    make_smooth_after_applying_functions: bool = False
+    tolerance_for_point_equality: float = 1e-8
 
     def __init__(
         self,
+        color: Color | None = None,
         fill_color: Color | None = None,
         fill_opacity: float = 0.0,
         stroke_color: Color | None = None,
         stroke_opacity: float = 1.0,
         stroke_width: float = DEFAULT_STROKE_WIDTH,
         draw_stroke_behind_fill: bool = False,
-        # Indicates that it will not be displayed, but
-        # that it should count in parent mobject's path
-        pre_function_handle_to_anchor_scale_factor: float = 0.01,
-        make_smooth_after_applying_functions: float = False,
         background_image_file: str | None = None,
-        # This is within a pixel
-        # TODO, do we care about accounting for
-        # varying zoom levels?
-        tolerance_for_point_equality: float = 1e-8,
-        n_points_per_curve: int = 3,
         long_lines: bool = False,
-        should_subdivide_sharp_curves: bool = False,
-        should_remove_null_curves: bool = False,
-        # Could also be "bevel", "miter", "round"
-        joint_type: LineJointType | None = None,
+        joint_type: LineJointType = LineJointType.AUTO,
         flat_stroke: bool = True,
-        render_primitive=moderngl.TRIANGLES,
-        triangulation_locked: bool = False,
+        # Measured in pixel widths
+        anti_alias_width: float = 1.0,
         **kwargs,
     ):
-        self.data = {}
+        self.fill_color = fill_color or color or DEFAULT_FILL_COLOR
         self.fill_opacity = fill_opacity
+        self.stroke_color = stroke_color or color or DEFAULT_STROKE_COLOR
         self.stroke_opacity = stroke_opacity
         self.stroke_width = stroke_width
         self.draw_stroke_behind_fill = draw_stroke_behind_fill
-        # Indicates that it will not be displayed, but
-        # that it should count in parent mobject's path
-        self.pre_function_handle_to_anchor_scale_factor = (
-            pre_function_handle_to_anchor_scale_factor
-        )
-        self.make_smooth_after_applying_functions = make_smooth_after_applying_functions
         self.background_image_file = background_image_file
-        # This is within a pixel
-        # TODO, do we care about accounting for
-        # varying zoom levels?
-        self.tolerance_for_point_equality = tolerance_for_point_equality
-        self.n_points_per_curve = n_points_per_curve
         self.long_lines = long_lines
-        self.should_subdivide_sharp_curves = should_subdivide_sharp_curves
-        self.should_remove_null_curves = should_remove_null_curves
-        if joint_type is None:
-            joint_type = LineJointType.AUTO
         self.joint_type = joint_type
         self.flat_stroke = flat_stroke
-        self.render_primitive = render_primitive
-        self.triangulation_locked = triangulation_locked
+        self.anti_alias_width = anti_alias_width
 
         self.needs_new_triangulation = True
         self.triangulation = np.zeros(0, dtype="i4")
-        self.orientation = 1
-        self.fill_data = None
-        self.stroke_data = None
-        self.fill_shader_wrapper = None
-        self.stroke_shader_wrapper = None
-        self.init_shader_data()
 
         super().__init__(**kwargs)
-        self.refresh_unit_normal()
-
-        if fill_color:
-            self.fill_color = Color(fill_color)
-        if stroke_color:
-            self.stroke_color = Color(stroke_color)
 
     def get_group_class(self):
         return OpenGLVGroup
@@ -158,13 +116,67 @@ def get_group_class(self):
     def get_mobject_type_class():
         return OpenGLVMobject
 
+    @property
+    def rgbas(self):
+        raise NotImplementedError(
+            "rgbas is not implemented for OpenGLVMobject. please use fill_rgba and stroke_rgba."
+        )
+
+    @rgbas.setter
+    def rgbas(self, value):
+        raise NotImplementedError(
+            "rgbas is not implemented for OpenGLVMobject. please use fill_rgba and stroke_rgba."
+        )
+
     def init_data(self):
         super().init_data()
         self.data.pop("rgbas")
-        self.fill_rgba = np.zeros((1, 4))
-        self.stroke_rgba = np.zeros((1, 4))
-        self.unit_normal = np.zeros((1, 3))
-        # stroke_width belongs to self.data, but is defined through init_colors+set_stroke
+        self.data.update(
+            {
+                "fill_rgba": np.zeros((1, 4)),
+                "stroke_rgba": np.zeros((1, 4)),
+                "stroke_width": np.zeros((1, 1)),
+                "orientation": np.zeros((1, 1)),
+            }
+        )
+
+    def init_uniforms(self):
+        super().init_uniforms()
+        self.uniforms["anti_alias_width"] = np.asarray(
+            self.anti_alias_width, dtype=UNIFORM_DTYPE
+        )
+        self.uniforms["joint_type"] = np.asarray(
+            self.joint_type.value, dtype=UNIFORM_DTYPE
+        )
+        self.uniforms["flat_stroke"] = np.asarray(
+            float(self.flat_stroke), dtype=UNIFORM_DTYPE
+        )
+
+    # These are here just to make type checkers happy
+    def get_family(self, recurse: bool = True) -> list[OpenGLVMobject]:  # type: ignore
+        return super().get_family(recurse)  # type: ignore
+
+    def family_members_with_points(self) -> list[OpenGLVMobject]:  # type: ignore
+        return super().family_members_with_points()  # type: ignore
+
+    def replicate(self, n: int) -> OpenGLVGroup:  # type: ignore
+        return super().replicate(n)  # type: ignore
+
+    def get_grid(self, *args, **kwargs) -> OpenGLVGroup:  # type: ignore
+        return super().get_grid(*args, **kwargs)  # type: ignore
+
+    def __getitem__(self, value: int | slice) -> OpenGLVMobject:  # type: ignore
+        return super().__getitem__(value)  # type: ignore
+
+    def add(self, *vmobjects: OpenGLVMobject):  # type: ignore
+        if not all(isinstance(m, OpenGLVMobject) for m in vmobjects):
+            raise Exception("All submobjects must be of type OpenGLVMobject")
+        super().add(*vmobjects)
+
+    def copy(self, deep: bool = False) -> OpenGLVMobject:
+        result = super().copy(deep)
+        result.shader_wrapper_list = [sw.copy() for sw in self.shader_wrapper_list]
+        return result
 
     # Colors
     def init_colors(self):
@@ -180,12 +192,28 @@ def init_colors(self):
         )
         self.set_gloss(self.gloss)
         self.set_flat_stroke(self.flat_stroke)
+        self.color = self.get_color()
+        return self
+
+    def set_rgba_array(
+        self, rgba_array: np.ndarray, name: str | None = None, recurse: bool = False
+    ) -> OpenGLVMobject:
+        if name is None:
+            names = ["fill_rgba", "stroke_rgba"]
+        else:
+            names = [name]
+
+        for name in names:
+            if name in self.data:
+                self.data[name] = rgba_array
+            else:
+                raise Exception(f"{name} is not a valid data name.")
         return self
 
     def set_fill(
         self,
-        color: Color | None = None,
-        opacity: float | None = None,
+        color: Color | Iterable[Color] | None = None,
+        opacity: float | Iterable[float] | None = None,
         recurse: bool = True,
     ) -> OpenGLVMobject:
         """Set the fill color and fill opacity of a :class:`OpenGLVMobject`.
@@ -223,13 +251,7 @@ def construct(self):
         --------
         :meth:`~.OpenGLVMobject.set_style`
         """
-        if opacity is not None:
-            self.fill_opacity = opacity
-        if recurse:
-            for submobject in self.submobjects:
-                submobject.set_fill(color, opacity, recurse)
-
-        self.set_rgba_array(color, opacity, "fill_rgba", recurse)
+        self.set_rgba_array_by_color(color, opacity, "fill_rgba", recurse)
         return self
 
     def set_stroke(
@@ -240,78 +262,98 @@ def set_stroke(
         background=None,
         recurse=True,
     ):
-        if opacity is not None:
-            self.stroke_opacity = opacity
-        if recurse:
-            for submobject in self.submobjects:
-                submobject.set_stroke(
-                    color=color,
-                    width=width,
-                    opacity=opacity,
-                    background=background,
-                    recurse=recurse,
-                )
-
-        self.set_rgba_array(color, opacity, "stroke_rgba", recurse)
+        self.set_rgba_array_by_color(color, opacity, "stroke_rgba", recurse)
 
         if width is not None:
             for mob in self.get_family(recurse):
-                mob.stroke_width = np.array([[width] for width in listify(width)])
+                if isinstance(width, np.ndarray):
+                    arr = width.reshape((len(width), 1))
+                else:
+                    arr = np.array([[w] for w in listify(width)], dtype=float)
+                mob.data["stroke_width"] = arr
 
         if background is not None:
             for mob in self.get_family(recurse):
                 mob.draw_stroke_behind_fill = background
         return self
 
-    def set_style(
+    def set_backstroke(
         self,
-        fill_color=None,
-        fill_opacity=None,
-        fill_rgba=None,
-        stroke_color=None,
-        stroke_opacity=None,
-        stroke_rgba=None,
-        stroke_width=None,
-        gloss=None,
-        shadow=None,
-        recurse=True,
-    ):
-        if fill_rgba is not None:
-            self.fill_rgba = resize_with_interpolation(fill_rgba, len(fill_rgba))
-        else:
-            self.set_fill(color=fill_color, opacity=fill_opacity, recurse=recurse)
+        color: Color | Iterable[Color] | None = None,
+        width: float | Iterable[float] = 3,
+        background: bool = True,
+    ) -> OpenGLVMobject:
+        self.set_stroke(color, width, background=background)
+        return self
 
-        if stroke_rgba is not None:
-            self.stroke_rgba = resize_with_interpolation(stroke_rgba, len(fill_rgba))
-            self.set_stroke(width=stroke_width)
-        else:
-            self.set_stroke(
-                color=stroke_color,
-                width=stroke_width,
-                opacity=stroke_opacity,
-                recurse=recurse,
+    def align_stroke_width_data_to_points(self, recurse: bool = True) -> None:
+        for mob in self.get_family(recurse):
+            mob.data["stroke_width"] = resize_with_interpolation(
+                mob.data["stroke_width"], len(mob.points)
             )
 
-        if gloss is not None:
-            self.set_gloss(gloss, recurse=recurse)
-        if shadow is not None:
-            self.set_shadow(shadow, recurse=recurse)
+    def set_style(
+        self,
+        fill_color: Color | Iterable[Color] | None = None,
+        fill_opacity: float | Iterable[float] | None = None,
+        fill_rgba: np.ndarray | None = None,
+        stroke_color: Color | Iterable[Color] | None = None,
+        stroke_opacity: float | Iterable[float] | None = None,
+        stroke_rgba: np.ndarray | None = None,
+        stroke_width: float | Iterable[float] | None = None,
+        stroke_background: bool = True,
+        reflectiveness: float | None = None,
+        gloss: float | None = None,
+        shadow: float | None = None,
+        recurse: bool = True,
+    ) -> OpenGLVMobject:
+        for mob in self.get_family(recurse):
+            if fill_rgba is not None:
+                mob.data["fill_rgba"] = resize_with_interpolation(
+                    fill_rgba, len(fill_rgba)
+                )
+            else:
+                mob.set_fill(color=fill_color, opacity=fill_opacity, recurse=False)
+
+            if stroke_rgba is not None:
+                mob.data["stroke_rgba"] = resize_with_interpolation(
+                    stroke_rgba, len(stroke_rgba)
+                )
+                mob.set_stroke(
+                    width=stroke_width,
+                    background=stroke_background,
+                    recurse=False,
+                )
+            else:
+                mob.set_stroke(
+                    color=stroke_color,
+                    width=stroke_width,
+                    opacity=stroke_opacity,
+                    recurse=False,
+                    background=stroke_background,
+                )
+
+            if reflectiveness is not None:
+                mob.set_reflectiveness(reflectiveness, recurse=False)
+            if gloss is not None:
+                mob.set_gloss(gloss, recurse=False)
+            if shadow is not None:
+                mob.set_shadow(shadow, recurse=False)
         return self
 
     def get_style(self):
         return {
-            "fill_rgba": self.fill_rgba,
-            "stroke_rgba": self.stroke_rgba,
-            "stroke_width": self.stroke_width,
-            "gloss": self.gloss,
-            "shadow": self.shadow,
+            "fill_rgba": self.data["fill_rgba"].copy(),
+            "stroke_rgba": self.data["stroke_rgba"].copy(),
+            "stroke_width": self.data["stroke_width"].copy(),
+            "stroke_background": self.draw_stroke_behind_fill,
+            "reflectiveness": self.get_reflectiveness(),
+            "gloss": self.get_gloss(),
+            "shadow": self.get_shadow(),
         }
 
-    def match_style(self, vmobject, recurse=True):
-        vmobject_style = vmobject.get_style()
-        if config.renderer == RendererType.OPENGL:
-            vmobject_style["stroke_width"] = vmobject_style["stroke_width"][0][0]
-        self.set_style(**vmobject_style, recurse=False)
+    def match_style(self, vmobject: OpenGLVMobject, recurse: bool = True):
+        self.set_style(**vmobject.get_style(), recurse=False)
         if recurse:
             # Does its best to match up submobject lists, and
             # match styles accordingly
@@ -324,112 +366,101 @@ def match_style(self, vmobject, recurse=True):
                 sm1.match_style(sm2)
         return self
 
-    def set_color(self, color, opacity=None, recurse=True):
-        if opacity is not None:
-            self.opacity = opacity
-
+    def set_color(self, color, opacity=None, recurse=True) -> OpenGLVMobject:
         self.set_fill(color, opacity=opacity, recurse=recurse)
         self.set_stroke(color, opacity=opacity, recurse=recurse)
         return self
 
-    def set_opacity(self, opacity, recurse=True):
+    def set_opacity(self, opacity, recurse=True) -> OpenGLVMobject:
         self.set_fill(opacity=opacity, recurse=recurse)
         self.set_stroke(opacity=opacity, recurse=recurse)
         return self
 
-    def fade(self, darkness=0.5, recurse=True):
-        factor = 1.0 - darkness
-        self.set_fill(
-            opacity=factor * self.get_fill_opacity(),
-            recurse=False,
-        )
-        self.set_stroke(
-            opacity=factor * self.get_stroke_opacity(),
-            recurse=False,
-        )
-        super().fade(darkness, recurse)
+    def fade(self, darkness=0.5, recurse=True) -> OpenGLVMobject:
+        mobs = self.get_family() if recurse else [self]
+        for mob in mobs:
+            factor = 1.0 - darkness
+            mob.set_fill(
+                opacity=factor * mob.get_fill_opacity(),
+                recurse=False,
+            )
+            mob.set_stroke(
+                opacity=factor * mob.get_stroke_opacity(),
+                recurse=False,
+            )
         return self
 
-    def get_fill_colors(self):
-        return [Color(rgb_to_hex(rgba[:3])) for rgba in self.fill_rgba]
+    def get_fill_colors(self) -> list[str]:
+        return [rgb_to_hex(rgba[:3]) for rgba in self.data["fill_rgba"]]
 
-    def get_fill_opacities(self):
-        return self.fill_rgba[:, 3]
+    def get_fill_opacities(self) -> np.ndarray:
+        return self.data["fill_rgba"][:, 3]
 
-    def get_stroke_colors(self):
-        return [Color(rgb_to_hex(rgba[:3])) for rgba in self.stroke_rgba]
+    def get_stroke_colors(self) -> list[str]:
+        return [rgb_to_hex(rgba[:3]) for rgba in self.data["stroke_rgba"]]
 
-    def get_stroke_opacities(self):
-        return self.stroke_rgba[:, 3]
+    def get_stroke_opacities(self) -> np.ndarray:
+        return self.data["stroke_rgba"][:, 3]
 
-    def get_stroke_widths(self):
-        return self.stroke_width
+    def get_stroke_widths(self) -> np.ndarray:
+        return self.data["stroke_width"][:, 0]
 
     # TODO, it's weird for these to return the first of various lists
     # rather than the full information
-    def get_fill_color(self):
+    def get_fill_color(self) -> str:
         """
         If there are multiple colors (for gradient)
         this returns the first one
         """
         return self.get_fill_colors()[0]
 
-    def get_fill_opacity(self):
+    def get_fill_opacity(self) -> float:
         """
         If there are multiple opacities, this returns the
         first
         """
         return self.get_fill_opacities()[0]
 
-    def get_stroke_color(self):
+    def get_stroke_color(self) -> str:
         return self.get_stroke_colors()[0]
 
-    def get_stroke_width(self):
+    def get_stroke_width(self) -> float | np.ndarray:
         return self.get_stroke_widths()[0]
 
-    def get_stroke_opacity(self):
+    def get_stroke_opacity(self) -> float:
         return self.get_stroke_opacities()[0]
 
-    def get_color(self):
-        if self.has_stroke():
-            return self.get_stroke_color()
-        return self.get_fill_color()
+    def get_color(self) -> str:
+        if self.has_fill():
+            return self.get_fill_color()
+        return self.get_stroke_color()
 
-    def get_colors(self):
-        if self.has_stroke():
-            return self.get_stroke_colors()
-        return self.get_fill_colors()
+    def has_stroke(self) -> bool:
+        return any(self.data["stroke_width"]) and any(self.data["stroke_rgba"][:, 3])
 
-    stroke_color = property(get_stroke_color, set_stroke)
-    color = property(get_color, set_color)
-    fill_color = property(get_fill_color, set_fill)
+    def has_fill(self) -> bool:
+        return any(self.data["fill_rgba"][:, 3])
 
-    def has_stroke(self):
-        stroke_widths = self.get_stroke_widths()
-        stroke_opacities = self.get_stroke_opacities()
-        return (
-            stroke_widths is not None
-            and stroke_opacities is not None
-            and any(stroke_widths)
-            and any(stroke_opacities)
-        )
-
-    def has_fill(self):
-        fill_opacities = self.get_fill_opacities()
-        return fill_opacities is not None and any(fill_opacities)
-
-    def get_opacity(self):
+    def get_opacity(self) -> float:
         if self.has_fill():
             return self.get_fill_opacity()
         return self.get_stroke_opacity()
 
-    def set_flat_stroke(self, flat_stroke=True, recurse=True):
+    def set_flat_stroke(self, flat_stroke: bool = True, recurse: bool = True):
         for mob in self.get_family(recurse):
-            mob.flat_stroke = flat_stroke
+            mob.uniforms["flat_stroke"] = np.asarray(float(flat_stroke))
         return self
 
-    def get_flat_stroke(self):
-        return self.flat_stroke
+    def get_flat_stroke(self) -> bool:
+        return self.uniforms["flat_stroke"] == 1.0
+
+    def set_joint_type(self, joint_type: LineJointType, recurse: bool = True):
+        for mob in self.get_family(recurse):
+            mob.uniforms["joint_type"] = np.asarray(joint_type.value)
+        return self
+
+    def get_joint_type(self) -> LineJointType:
+        return LineJointType(int(self.uniforms["joint_type"][0]))
 
     # Points
     def set_anchors_and_handles(self, anchors1, handles, anchors2):
@@ -511,9 +542,12 @@ def add_smooth_curve_to(self, point):
             self.add_quadratic_bezier_curve_to(new_handle, point)
         return self
 
-    def add_smooth_cubic_curve_to(self, handle, point):
+    def add_smooth_cubic_curve_to(self, handle: np.ndarray, point: np.ndarray):
         self.throw_error_if_no_points()
-        new_handle = self.get_reflection_of_last_handle()
+        if self.get_num_points() == 1:
+            new_handle = self.points[-1]
+        else:
+            new_handle = self.get_reflection_of_last_handle()
         self.add_cubic_bezier_curve_to(new_handle, handle, point)
 
     def has_new_path_started(self):
@@ -581,12 +615,15 @@ def set_points_as_corners(self, points: Iterable[float]) -> OpenGLVMobject:
         )
         return self
 
-    def set_points_smoothly(self, points, true_smooth=False):
+    def set_points_smoothly(self, points, true_smooth=False) -> OpenGLVMobject:
         self.set_points_as_corners(points)
-        self.make_smooth()
+        if true_smooth:
+            self.make_smooth()
+        else:
+            self.make_approximately_smooth()
         return self
 
-    def change_anchor_mode(self, mode):
+    def change_anchor_mode(self, mode) -> OpenGLVMobject:
         """Changes the anchor mode of the bezier curves. This will modify the handles.
 
         There can be only three modes, "jagged", "approx_smooth"  and "true_smooth".
@@ -605,17 +642,13 @@ def change_anchor_mode(self, mode):
                 anchors = np.vstack([subpath[::nppc], subpath[-1:]])
                 new_subpath = np.array(subpath)
                 if mode == "approx_smooth":
-                    # TODO: get_smooth_quadratic_bezier_handle_points is not defined
                     new_subpath[1::nppc] = get_smooth_quadratic_bezier_handle_points(
-                        anchors,
+                        anchors
                     )
                 elif mode == "true_smooth":
                     h1, h2 = get_smooth_cubic_bezier_handle_points(anchors)
                     new_subpath = get_quadratic_approximation_of_cubic(
-                        anchors[:-1],
-                        h1,
-                        h2,
-                        anchors[1:],
+                        anchors[:-1], h1, h2, anchors[1:]
                     )
                 elif mode == "jagged":
                     new_subpath[1::nppc] = 0.5 * (anchors[:-1] + anchors[1:])
@@ -823,6 +856,55 @@ def get_num_curves(self) -> int:
         """
         return self.get_num_points() // self.n_points_per_curve
 
+    def quick_point_from_proportion(self, alpha: float) -> np.ndarray:
+        # Assumes all curves have the same length, so is inaccurate
+        num_curves = self.get_num_curves()
+        n, residue = integer_interpolate(0, num_curves, alpha)
+        curve_func = self.get_nth_curve_function(n)
+        return curve_func(residue)
+
+    def point_from_proportion(self, alpha: float) -> np.ndarray:
+        """Gets the point at a proportion along the path of the :class:`OpenGLVMobject`.
+
+        Parameters
+        ----------
+        alpha
+            The proportion along the the path of the :class:`OpenGLVMobject`.
+
+        Returns
+        -------
+        :class:`numpy.ndarray`
+            The point on the :class:`OpenGLVMobject`.
+
+        Raises
+        ------
+        :exc:`ValueError`
+            If ``alpha`` is not between 0 and 1.
+        :exc:`Exception`
+            If the :class:`OpenGLVMobject` has no points.
+        """
+
+        if alpha <= 0:
+            return self.get_start()
+        elif alpha >= 1:
+            return self.get_end()
+
+        partials = [0.0]
+        for tup in self.get_bezier_tuples():
+            # Approximate length with straight line from start to end
+            arclen = get_norm(tup[0] - tup[-1])
+            partials.append(partials[-1] + arclen)
+        full = partials[-1]
+        if full == 0:
+            return self.get_start()
+        # First index where the partial length is more alpha times the full length
+        i = next(
+            (i for i, x in enumerate(partials) if x >= full * alpha),
+            len(partials),  # Default
+        )
+        residue = inverse_interpolate(partials[i - 1] / full, partials[i] / full, alpha)
+        return self.get_nth_curve_function(i - 1)(residue)  # type: ignore
+
     def get_nth_curve_length(
         self,
         n: int,
@@ -888,7 +970,7 @@ def get_nth_curve_length_pieces(
         curve = self.get_nth_curve_function(n)
         points = np.array([curve(a) for a in np.linspace(0, 1, sample_points)])
         diffs = points[1:] - points[:-1]
-        norms = np.apply_along_axis(np.linalg.norm, 1, diffs)
+        norms = np.apply_along_axis(np.linalg.norm, 1, diffs)  # type: ignore
 
         return norms
 
@@ -913,50 +995,6 @@ def get_curve_functions_with_lengths(
         for n in range(num_curves):
             yield self.get_nth_curve_function_with_length(n, **kwargs)
 
-    def point_from_proportion(self, alpha: float) -> np.ndarray:
-        """Gets the point at a proportion along the path of the :class:`OpenGLVMobject`.
-
-        Parameters
-        ----------
-        alpha
-            The proportion along the the path of the :class:`OpenGLVMobject`.
-
-        Returns
-        -------
-        :class:`numpy.ndarray`
-            The point on the :class:`OpenGLVMobject`.
-
-        Raises
-        ------
-        :exc:`ValueError`
-            If ``alpha`` is not between 0 and 1.
-        :exc:`Exception`
-            If the :class:`OpenGLVMobject` has no points.
-        """
-
-        if alpha < 0 or alpha > 1:
-            raise ValueError(f"Alpha {alpha} not between 0 and 1.")
-
-        self.throw_error_if_no_points()
-        if alpha == 1:
-            return self.points[-1]
-
-        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_and_lengths:
-            if current_length + length >= target_length:
-                if length != 0:
-                    residue = (target_length - current_length) / length
-                else:
-                    residue = 0
-
-                return curve(residue)
-
-            current_length += length
-
     def proportion_from_point(
         self,
         point: Iterable[float | int],
@@ -993,7 +1031,7 @@ def proportion_from_point(
 
         num_curves = self.get_num_curves()
         total_length = self.get_arc_length()
-        target_length = 0
+        target_length = 0.0
         for n in range(num_curves):
             control_points = self.get_nth_curve_points(n)
             length = self.get_nth_curve_length(n)
@@ -1063,8 +1101,8 @@ def get_anchors(self) -> np.ndarray:
                         self.get_start_anchors(),
                         self.get_end_anchors(),
                     )
-                ),
-            ),
+                )
+            )
         )
 
     def get_points_without_null_curves(self, atol=1e-9):
@@ -1079,13 +1117,14 @@ def get_points_without_null_curves(self, atol=1e-9):
         )
         return points[distinct_curves.repeat(nppc)]
 
-    def get_arc_length(self, sample_points_per_curve: int | None = None) -> float:
+    def get_arc_length(self, n_sample_points: int | None = 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.
+        n_sample_points
+            The number of points to sample. If ``None``, the number of points is calculated automatically.
+            Takes points on the outline of the :class:`OpenGLVMobject` and calculates the distance between them.
 
         Returns
         -------
@@ -1093,12 +1132,14 @@ def get_arc_length(self, sample_points_per_curve: int | None = None) -> 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,
-            )
+        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)]
         )
+        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
@@ -1113,6 +1154,11 @@ def get_area_vector(self):
         p0 = points[0::nppc]
         p1 = points[nppc - 1 :: nppc]
 
+        if len(p0) != len(p1):
+            m = min(len(p0), len(p1))
+            p0 = p0[:m]
+            p1 = p1[:m]
+
         # Each term goes through all edges [(x1, y1, z1), (x2, y2, z2)]
         return 0.5 * np.array(
             [
@@ -1148,28 +1194,21 @@ def get_direction(self):
         """
         return shoelace_direction(self.get_start_anchors())
 
-    def get_unit_normal(self, recompute=False):
-        if not recompute:
-            return self.unit_normal[0]
-
-        if len(self.points) < 3:
+    def get_unit_normal(self) -> np.ndarray:
+        if self.get_num_points() < 3:
             return OUT
 
         area_vect = self.get_area_vector()
-        area = np.linalg.norm(area_vect)
+        area = get_norm(area_vect)
         if area > 0:
-            return area_vect / area
+            normal = area_vect / area
         else:
             points = self.points
-            return get_unit_normal(
+            normal = get_unit_normal(
                 points[1] - points[0],
                 points[2] - points[1],
             )
-
-    def refresh_unit_normal(self):
-        for mob in self.get_family():
-            mob.unit_normal[:] = mob.get_unit_normal(recompute=True)
-        return self
+        return normal
 
     # Alignment
     def align_points(self, vmobject):
@@ -1202,14 +1241,14 @@ def get_nth_subpath(path_list, n):
                 # Create a null path at the very end
                 return [path_list[-1][-1]] * nppc
             path = path_list[n]
-            # Check for useless points at the end of the path and remove them
-            # https://github.com/ManimCommunity/manim/issues/1959
-            while len(path) > nppc:
-                # If the last nppc points are all equal to the preceding point
-                if self.consider_points_equals(path[-nppc:], path[-nppc - 1]):
-                    path = path[:-nppc]
-                else:
-                    break
+            # # Check for useless points at the end of the path and remove them
+            # # https://github.com/ManimCommunity/manim/issues/1959
+            # while len(path) > nppc:
+            #     # If the last nppc points are all equal to the preceding point
+            #     if self.consider_points_equals(path[-nppc:], path[-nppc - 1]):
+            #         path = path[:-nppc]
+            #     else:
+            #         break
             return path
 
         for n in range(n_subpaths):
@@ -1269,7 +1308,7 @@ def insert_n_curves_to_point_list(self, n: int, points: np.ndarray) -> np.ndarra
             return np.repeat(points, nppc * n, 0)
 
         bezier_groups = self.get_bezier_tuples_from_points(points)
-        norms = np.array([np.linalg.norm(bg[nppc - 1] - bg[0]) for bg in bezier_groups])
+        norms = np.array([get_norm(bg[nppc - 1] - bg[0]) for bg in bezier_groups])
         total_norm = sum(norms)
         # Calculate insertions per curve (ipc)
         if total_norm < 1e-6:
@@ -1295,6 +1334,7 @@ def insert_n_curves_to_point_list(self, n: int, points: np.ndarray) -> np.ndarra
 
     def interpolate(self, mobject1, mobject2, alpha, *args, **kwargs):
         super().interpolate(mobject1, mobject2, alpha, *args, **kwargs)
+        # TODO: Do we still need this? Because for many scenes it just doesn't work
         if config["use_projection_fill_shaders"]:
             self.refresh_triangulation()
         else:
@@ -1305,6 +1345,7 @@ def interpolate(self, mobject1, mobject2, alpha, *args, **kwargs):
                     self.refresh_triangulation()
         return self
 
+    # TODO: compare to 3b1b/manim again check if something changed so we don't need the cairo interpolation anymore
     def pointwise_become_partial(
         self, vmobject: OpenGLVMobject, a: float, b: float, remap: bool = True
     ) -> OpenGLVMobject:
@@ -1399,45 +1440,43 @@ def get_subcurve(self, a: float, b: float) -> OpenGLVMobject:
     def refresh_triangulation(self):
         for mob in self.get_family():
             mob.needs_new_triangulation = True
+            mob.data["orientation"] = resize_array(
+                mob.data["orientation"], mob.get_num_points()
+            )
         return self
 
-    def get_triangulation(self, normal_vector=None):
+    def get_triangulation(self):
         # Figure out how to triangulate the interior to know
         # how to send the points as to the vertex shader.
         # First triangles come directly from the points
-        if normal_vector is None:
-            normal_vector = self.get_unit_normal()
-
         if not self.needs_new_triangulation:
             return self.triangulation
 
-        points = self.points
+        points = self.get_points()
 
         if len(points) <= 1:
             self.triangulation = np.zeros(0, dtype="i4")
             self.needs_new_triangulation = False
             return self.triangulation
 
-        if not np.isclose(normal_vector, OUT).all():
-            # Rotate points such that unit normal vector is OUT
-            points = np.dot(points, z_to_vector(normal_vector))
+        normal_vector = self.get_unit_normal()
         indices = np.arange(len(points), dtype=int)
 
-        b0s = points[0::3]
-        b1s = points[1::3]
-        b2s = points[2::3]
-        v01s = b1s - b0s
-        v12s = b2s - b1s
-
-        crosses = cross2d(v01s, v12s)
-        convexities = np.sign(crosses)
+        # Rotate points such that unit normal vector is OUT
+        if not np.isclose(normal_vector, OUT).all():
+            points = np.dot(points, z_to_vector(normal_vector))
 
         atol = self.tolerance_for_point_equality
-        end_of_loop = np.zeros(len(b0s), dtype=bool)
-        end_of_loop[:-1] = (np.abs(b2s[:-1] - b0s[1:]) > atol).any(1)
+        end_of_loop = np.zeros(len(points) // 3, dtype=bool)
+        end_of_loop[:-1] = (np.abs(points[2:-3:3] - points[3::3]) > atol).any(1)
         end_of_loop[-1] = True
 
-        concave_parts = convexities < 0
+        v01s = points[1::3] - points[0::3]
+        v12s = points[2::3] - points[1::3]
+        curve_orientations = np.sign(cross2d(v01s, v12s))
+        self.data["orientation"] = np.transpose([curve_orientations.repeat(3)])
+
+        concave_parts = curve_orientations < 0
 
         # These are the vertices to which we'll apply a polygon triangulation
         inner_vert_indices = np.hstack(
@@ -1445,7 +1484,7 @@ def get_triangulation(self, normal_vector=None):
                 indices[0::3],
                 indices[1::3][concave_parts],
                 indices[2::3][end_of_loop],
-            ],
+            ]
         )
         inner_vert_indices.sort()
         rings = np.arange(1, len(inner_vert_indices) + 1)[inner_vert_indices % 3 == 2]
@@ -1461,11 +1500,28 @@ def get_triangulation(self, normal_vector=None):
         self.needs_new_triangulation = False
         return tri_indices
 
+    @staticmethod
+    def triggers_refreshed_triangulation(func: Callable):
+        @wraps(func)
+        def wrapper(self, *args, **kwargs):
+            func(self, *args, **kwargs)
+            self.refresh_triangulation()
+
+        return wrapper
+
     @triggers_refreshed_triangulation
     def set_points(self, points):
         super().set_points(points)
         return self
 
+    @triggers_refreshed_triangulation
+    def append_points(self, points):
+        return super().append_points(points)
+
+    @triggers_refreshed_triangulation
+    def reverse_points(self):
+        return super().reverse_points()
+
     @triggers_refreshed_triangulation
     def set_data(self, data):
         super().set_data(data)
@@ -1496,36 +1552,42 @@ def init_shader_data(self):
         self.fill_shader_wrapper = ShaderWrapper(
             vert_data=self.fill_data,
             vert_indices=np.zeros(0, dtype="i4"),
+            uniforms=self.uniforms,
             shader_folder=self.fill_shader_folder,
             render_primitive=self.render_primitive,
         )
         self.stroke_shader_wrapper = ShaderWrapper(
             vert_data=self.stroke_data,
+            uniforms=self.uniforms,
             shader_folder=self.stroke_shader_folder,
             render_primitive=self.render_primitive,
         )
 
+        self.shader_wrapper_list = [
+            self.stroke_shader_wrapper.copy(),  # Use for back stroke
+            self.fill_shader_wrapper.copy(),
+            self.stroke_shader_wrapper.copy(),
+        ]
+        for sw in self.shader_wrapper_list:
+            sw.uniforms = self.uniforms
+
     def refresh_shader_wrapper_id(self):
         for wrapper in [self.fill_shader_wrapper, self.stroke_shader_wrapper]:
             wrapper.refresh_id()
         return self
 
-    def get_fill_shader_wrapper(self):
-        self.update_fill_shader_wrapper()
-        return self.fill_shader_wrapper
-
-    def update_fill_shader_wrapper(self):
+    def get_fill_shader_wrapper(self) -> ShaderWrapper:
+        self.fill_shader_wrapper.vert_indices = self.get_fill_shader_vert_indices()
         self.fill_shader_wrapper.vert_data = self.get_fill_shader_data()
-        self.fill_shader_wrapper.vert_indices = self.get_triangulation()
-        self.fill_shader_wrapper.uniforms = self.get_fill_uniforms()
-
-    def get_stroke_shader_wrapper(self):
-        self.update_stroke_shader_wrapper()
-        return self.stroke_shader_wrapper
+        self.fill_shader_wrapper.uniforms = self.get_shader_uniforms()
+        self.fill_shader_wrapper.depth_test = self.depth_test
+        return self.fill_shader_wrapper
 
-    def update_stroke_shader_wrapper(self):
+    def get_stroke_shader_wrapper(self) -> ShaderWrapper:
         self.stroke_shader_wrapper.vert_data = self.get_stroke_shader_data()
-        self.stroke_shader_wrapper.uniforms = self.get_stroke_uniforms()
+        self.stroke_shader_wrapper.uniforms = self.get_shader_uniforms()
+        self.stroke_shader_wrapper.depth_test = self.depth_test
+        return self.stroke_shader_wrapper
 
     def get_shader_wrapper_list(self):
         # Build up data lists
@@ -1533,9 +1595,9 @@ def get_shader_wrapper_list(self):
         stroke_shader_wrappers = []
         back_stroke_shader_wrappers = []
         for submob in self.family_members_with_points():
-            if submob.has_fill() and not config["use_projection_fill_shaders"]:
+            if submob.has_fill():
                 fill_shader_wrappers.append(submob.get_fill_shader_wrapper())
-            if submob.has_stroke() and not config["use_projection_stroke_shaders"]:
+            if submob.has_stroke():
                 ssw = submob.get_stroke_shader_wrapper()
                 if submob.draw_stroke_behind_fill:
                     back_stroke_shader_wrappers.append(ssw)
@@ -1543,38 +1605,23 @@ def get_shader_wrapper_list(self):
                     stroke_shader_wrappers.append(ssw)
 
         # Combine data lists
-        wrapper_lists = [
+        sw_lists = [
             back_stroke_shader_wrappers,
             fill_shader_wrappers,
             stroke_shader_wrappers,
         ]
-        result = []
-        for wlist in wrapper_lists:
-            if wlist:
-                wrapper = wlist[0]
-                wrapper.combine_with(*wlist[1:])
-                result.append(wrapper)
-        return result
-
-    def get_stroke_uniforms(self):
-        result = dict(super().get_shader_uniforms())
-        result["joint_type"] = self.joint_type.value
-        result["flat_stroke"] = float(self.flat_stroke)
-        return result
-
-    def get_fill_uniforms(self):
-        return {
-            "is_fixed_in_frame": float(self.is_fixed_in_frame),
-            "is_fixed_orientation": float(self.is_fixed_orientation),
-            "fixed_orientation_center": self.fixed_orientation_center,
-            "gloss": self.gloss,
-            "shadow": self.shadow,
-        }
-
-    def get_stroke_shader_data(self):
+        for sw, sw_list in zip(self.shader_wrapper_list, sw_lists):
+            if not sw_list:
+                continue
+            sw.read_in(*sw_list)
+            sw.depth_test = any(sw.depth_test for sw in sw_list)
+            sw.uniforms.update(sw_list[0].uniforms)
+        return list(filter(lambda sw: len(sw.vert_data) > 0, self.shader_wrapper_list))
+
+    def get_stroke_shader_data(self) -> np.ndarray:
         points = self.points
         if len(self.stroke_data) != len(points):
-            self.stroke_data = np.zeros(len(points), dtype=OpenGLVMobject.stroke_dtype)
+            self.stroke_data = resize_array(self.stroke_data, len(points))
 
         if "points" not in self.locked_data_keys:
             nppc = self.n_points_per_curve
@@ -1586,19 +1633,18 @@ def get_stroke_shader_data(self):
 
         self.read_data_to_shader(self.stroke_data, "color", "stroke_rgba")
         self.read_data_to_shader(self.stroke_data, "stroke_width", "stroke_width")
-        self.read_data_to_shader(self.stroke_data, "unit_normal", "unit_normal")
 
         return self.stroke_data
 
-    def get_fill_shader_data(self):
+    def get_fill_shader_data(self) -> np.ndarray:
         points = self.points
         if len(self.fill_data) != len(points):
-            self.fill_data = np.zeros(len(points), dtype=OpenGLVMobject.fill_dtype)
+            self.fill_data = resize_array(self.fill_data, len(points))
             self.fill_data["vert_index"][:, 0] = range(len(points))
 
         self.read_data_to_shader(self.fill_data, "point", "points")
         self.read_data_to_shader(self.fill_data, "color", "fill_rgba")
-        self.read_data_to_shader(self.fill_data, "unit_normal", "unit_normal")
+        self.read_data_to_shader(self.fill_data, "orientation", "orientation")
 
         return self.fill_data
 
@@ -1606,7 +1652,7 @@ def refresh_shader_data(self):
         self.get_fill_shader_data()
         self.get_stroke_shader_data()
 
-    def get_fill_shader_vert_indices(self):
+    def get_fill_shader_vert_indices(self) -> np.ndarray:
         return self.get_triangulation()
 
 
@@ -1693,7 +1739,7 @@ def __str__(self):
             f"submobject{'s' if len(self.submobjects) > 0 else ''}"
         )
 
-    def add(self, *vmobjects: OpenGLVMobject):
+    def add(self, *vmobjects: OpenGLVMobject):  # type: ignore
         """Checks if all passed elements are an instance of OpenGLVMobject and then add them to submobjects
 
         Parameters
@@ -1790,7 +1836,7 @@ def __setitem__(self, key: int, value: OpenGLVMobject | Sequence[OpenGLVMobject]
         """
         if not all(isinstance(m, OpenGLVMobject) for m in value):
             raise TypeError("All submobjects must be of type OpenGLVMobject")
-        self.submobjects[key] = value
+        self.submobjects[key] = value  # type: ignore
 
 
 class OpenGLVectorizedPoint(OpenGLPoint, OpenGLVMobject):
@@ -1800,15 +1846,15 @@ def __init__(
         color=BLACK,
         fill_opacity=0,
         stroke_width=0,
-        artificial_width=0.01,
-        artificial_height=0.01,
         **kwargs,
     ):
-        self.artificial_width = artificial_width
-        self.artificial_height = artificial_height
-
-        super().__init__(
-            color=color, fill_opacity=fill_opacity, stroke_width=stroke_width, **kwargs
+        OpenGLPoint.__init__(self, location, **kwargs)
+        OpenGLVMobject.__init__(
+            self,
+            color=color,
+            fill_opacity=fill_opacity,
+            stroke_width=stroke_width,
+            **kwargs,
         )
         self.set_points(np.array([location]))
 
@@ -1878,32 +1924,50 @@ def __init__(
         self,
         vmobject: OpenGLVMobject,
         num_dashes: int = 15,
-        dashed_ratio: float = 0.5,
-        color: Color = WHITE,
+        positive_space_ratio: float = 0.5,
         **kwargs,
     ):
-        self.dashed_ratio = dashed_ratio
-        self.num_dashes = num_dashes
-        super().__init__(color=color, **kwargs)
-        r = self.dashed_ratio
-        n = self.num_dashes
+        super().__init__(**kwargs)
+
         if num_dashes > 0:
-            # Assuming total length is 1
-            dash_len = r / n
-            if vmobject.is_closed():
-                void_len = (1 - r) / n
-            else:
-                void_len = (1 - r) / (n - 1)
+            # End points of the unit interval for division
+            alphas = np.linspace(0, 1, num_dashes + 1)
+
+            # This determines the length of each "dash"
+            full_d_alpha = 1.0 / num_dashes
+            partial_d_alpha = full_d_alpha * positive_space_ratio
+
+            # Rescale so that the last point of vmobject will
+            # be the end of the last dash
+            alphas /= 1 - full_d_alpha + partial_d_alpha
 
             self.add(
-                *(
-                    vmobject.get_subcurve(
-                        i * (dash_len + void_len),
-                        i * (dash_len + void_len) + dash_len,
-                    )
-                    for i in range(n)
-                )
+                *[
+                    vmobject.get_subcurve(alpha, alpha + partial_d_alpha)
+                    for alpha in alphas[:-1]
+                ]
             )
         # Family is already taken care of by get_subcurve
         # implementation
         self.match_style(vmobject, recurse=False)
+
+
+class VHighlight(OpenGLVGroup):
+    def __init__(
+        self,
+        vmobject: OpenGLVMobject,
+        n_layers: int = 5,
+        color_bounds: tuple[Color, Color] = (GREY_C, GREY_E),
+        max_stroke_addition: float = 5.0,
+    ):
+        outline = vmobject.replicate(n_layers)
+        outline.set_fill(opacity=0)
+        added_widths = np.linspace(0, max_stroke_addition, n_layers + 1)[1:]
+        colors = color_gradient(color_bounds, n_layers)
+        for part, added_width, color in zip(reversed(outline), added_widths, colors):
+            for sm in part.family_members_with_points():
+                sm.set_stroke(
+                    width=sm.get_stroke_width() + added_width,
+                    color=color,
+                )
+        super().__init__(*outline)
diff --git a/manim/mobject/types/vectorized_mobject.py b/manim/mobject/types/vectorized_mobject.py
index 36bf146263..b5cf76ec08 100644
--- a/manim/mobject/types/vectorized_mobject.py
+++ b/manim/mobject/types/vectorized_mobject.py
@@ -2410,7 +2410,6 @@ def __init__(
         equal_lengths=True,
         **kwargs,
     ):
-
         self.dashed_ratio = dashed_ratio
         self.num_dashes = num_dashes
         super().__init__(color=color, **kwargs)
diff --git a/manim/renderer/opengl_renderer.py b/manim/renderer/opengl_renderer.py
index aa0bdbebf4..cdc6924be5 100644
--- a/manim/renderer/opengl_renderer.py
+++ b/manim/renderer/opengl_renderer.py
@@ -1,9 +1,14 @@
 from __future__ import annotations
 
 import itertools as it
+import math
 import sys
 import time
-from typing import Any
+from typing import Any, Iterable
+
+from manim.renderer.shader_wrapper import ShaderWrapper
+
+from ..constants import RADIANS
 
 if sys.version_info < (3, 8):
     from backports.cached_property import cached_property
@@ -12,22 +17,25 @@
 
 import moderngl
 import numpy as np
+import OpenGL.GL as gl
 from PIL import Image
+from scipy.spatial.transform import Rotation
 
 from manim import config, logger
 from manim.mobject.opengl.opengl_mobject import OpenGLMobject, OpenGLPoint
 from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject
 from manim.utils.caching import handle_caching_play
-from manim.utils.color import color_to_rgba
+from manim.utils.color import BLACK, color_to_rgba
 from manim.utils.exceptions import EndSceneEarlyException
 
 from ..constants import *
 from ..scene.scene_file_writer import SceneFileWriter
 from ..utils import opengl
 from ..utils.config_ops import _Data
-from ..utils.simple_functions import clip
+from ..utils.simple_functions import clip, fdiv
 from ..utils.space_ops import (
     angle_of_vector,
+    normalize,
     quaternion_from_angle_axis,
     quaternion_mult,
     rotation_matrix_transpose,
@@ -40,7 +48,494 @@
 )
 
 
-class OpenGLCamera(OpenGLMobject):
+class OpenGLCameraFrame(OpenGLMobject):
+    def __init__(
+        self,
+        frame_shape: tuple[float, float] = (config.frame_width, config.frame_height),
+        center_point: np.ndarray = ORIGIN,
+        focal_dist_to_height: float = 2.0,
+        **kwargs,
+    ):
+        self.frame_shape = frame_shape
+        self.center_point = center_point
+        self.focal_dist_to_height = focal_dist_to_height
+        super().__init__(**kwargs)
+
+    def init_uniforms(self):
+        super().init_uniforms()
+        # as a quarternion
+        self.uniforms["orientation"] = Rotation.identity().as_quat()
+        self.uniforms["focal_dist_to_height"] = self.focal_dist_to_height
+
+    def init_points(self) -> None:
+        self.set_points([ORIGIN, LEFT, RIGHT, DOWN, UP])
+        self.set_width(self.frame_shape[0], stretch=True)
+        self.set_height(self.frame_shape[1], stretch=True)
+        self.move_to(self.center_point)
+
+    def set_orientation(self, rotation: Rotation):
+        self.uniforms["orientation"] = rotation.as_quat()
+        return self
+
+    def get_orientation(self):
+        return Rotation.from_quat(self.uniforms["orientation"])
+
+    def to_default_state(self):
+        self.center()
+        self.set_height(config.frame_width)
+        self.set_width(config.frame_height)
+        self.set_orientation(Rotation.identity())
+        return self
+
+    def get_euler_angles(self):
+        return self.get_orientation().as_euler("zxz")[::-1]
+
+    def get_theta(self):
+        return self.get_euler_angles()[0]
+
+    def get_phi(self):
+        return self.get_euler_angles()[1]
+
+    def get_gamma(self):
+        return self.get_euler_angles()[2]
+
+    def get_inverse_camera_rotation_matrix(self):
+        return self.get_orientation().as_matrix().T
+
+    def rotate(self, angle: float, axis: np.ndarray = OUT, **kwargs):  # type: ignore
+        rot = Rotation.from_rotvec(axis * normalize(axis))  # type: ignore
+        self.set_orientation(rot * self.get_orientation())
+
+    def set_euler_angles(
+        self,
+        theta: float | None = None,
+        phi: float | None = None,
+        gamma: float | None = None,
+        units: float = RADIANS,
+    ):
+        eulers = self.get_euler_angles()  # theta, phi, gamma
+        for i, var in enumerate([theta, phi, gamma]):
+            if var is not None:
+                eulers[i] = var * units
+        self.set_orientation(Rotation.from_euler("zxz", eulers[::-1]))
+        return self
+
+    def reorient(
+        self,
+        theta_degrees: float | None = None,
+        phi_degrees: float | None = None,
+        gamma_degrees: float | None = None,
+    ):
+        """
+        Shortcut for set_euler_angles, defaulting to taking
+        in angles in degrees
+        """
+        self.set_euler_angles(theta_degrees, phi_degrees, gamma_degrees, units=DEGREES)
+        return self
+
+    def set_theta(self, theta: float):
+        return self.set_euler_angles(theta=theta)
+
+    def set_phi(self, phi: float):
+        return self.set_euler_angles(phi=phi)
+
+    def set_gamma(self, gamma: float):
+        return self.set_euler_angles(gamma=gamma)
+
+    def increment_theta(self, dtheta: float):
+        self.rotate(dtheta, OUT)
+        return self
+
+    def increment_phi(self, dphi: float):
+        self.rotate(dphi, self.get_inverse_camera_rotation_matrix()[0])
+        return self
+
+    def increment_gamma(self, dgamma: float):
+        self.rotate(dgamma, self.get_inverse_camera_rotation_matrix()[2])
+        return self
+
+    def set_focal_distance(self, focal_distance: float):
+        self.uniforms["focal_dist_to_height"] = focal_distance / self.get_height()
+        return self
+
+    def set_field_of_view(self, field_of_view: float):
+        self.uniforms["focal_dist_to_height"] = 2 * math.tan(field_of_view / 2)
+        return self
+
+    def get_shape(self):
+        return (self.get_width(), self.get_height())
+
+    def get_center(self) -> np.ndarray:
+        # Assumes first point is at the center
+        return self.points[0]
+
+    def get_width(self) -> float:
+        points = self.points
+        return points[2, 0] - points[1, 0]
+
+    def get_height(self) -> float:
+        points = self.points
+        return points[4, 1] - points[3, 1]
+
+    def get_focal_distance(self) -> float:
+        return self.uniforms["focal_dist_to_height"] * self.get_height()  # type: ignore
+
+    def get_field_of_view(self) -> float:
+        return 2 * math.atan(self.uniforms["focal_dist_to_height"] / 2)
+
+    def get_implied_camera_location(self) -> np.ndarray:
+        to_camera = self.get_inverse_camera_rotation_matrix()[2]
+        dist = self.get_focal_distance()
+        return self.get_center() + dist * to_camera
+
+
+class OpenGLCamera:
+    def __init__(
+        self,
+        ctx: moderngl.Context | None = None,
+        background_image: str | None = None,
+        frame_config: dict = {},
+        pixel_width: int = config.pixel_width,
+        pixel_height: int = config.pixel_height,
+        fps: int = config.frame_rate,
+        # Note: frame height and width will be resized to match the pixel aspect rati
+        background_color=BLACK,
+        background_opacity: float = 1.0,
+        # Points in vectorized mobjects with norm greater
+        # than this value will be rescaled
+        max_allowable_norm: float = 1.0,
+        image_mode: str = "RGBA",
+        n_channels: int = 4,
+        pixel_array_dtype: type = np.uint8,
+        light_source_position: np.ndarray = np.array([-10, 10, 10]),
+        # Although vector graphics handle antialiasing fine
+        # without multisampling, for 3d scenes one might want
+        # to set samples to be greater than 0.
+        samples: int = 0,
+    ) -> None:
+        self.background_image = background_image
+        self.pixel_width = pixel_width
+        self.pixel_height = pixel_height
+        self.fps = fps
+        self.max_allowable_norm = max_allowable_norm
+        self.image_mode = image_mode
+        self.n_channels = n_channels
+        self.pixel_array_dtype = pixel_array_dtype
+        self.light_source_position = light_source_position
+        self.samples = samples
+
+        self.rgb_max_val: float = np.iinfo(self.pixel_array_dtype).max
+        self.background_color: list[float] = list(
+            color_to_rgba(background_color, background_opacity)
+        )
+        self.init_frame(**frame_config)
+        self.init_context(ctx)
+        self.init_shaders()
+        self.init_textures()
+        self.init_light_source()
+        self.refresh_perspective_uniforms()
+        # A cached map from mobjects to their associated list of render groups
+        # so that these render groups are not regenerated unnecessarily for static
+        # mobjects
+        self.mob_to_render_groups: dict = {}
+
+    def init_frame(self, **config) -> None:
+        self.frame = OpenGLCameraFrame(**config)
+
+    def init_context(self, ctx: moderngl.Context | None = None) -> None:
+        if ctx is None:
+            ctx = moderngl.create_standalone_context()
+            fbo = self.get_fbo(ctx, 0)
+        else:
+            fbo = ctx.detect_framebuffer()
+
+        self.ctx = ctx
+        self.fbo = fbo
+        self.set_ctx_blending()
+
+        # For multisample antisampling
+        fbo_msaa = self.get_fbo(ctx, self.samples)
+        fbo_msaa.use()
+        self.fbo_msaa = fbo_msaa
+
+    def set_ctx_blending(self, enable: bool = True) -> None:
+        if enable:
+            self.ctx.enable(moderngl.BLEND)
+        else:
+            self.ctx.disable(moderngl.BLEND)
+
+    def set_ctx_depth_test(self, enable: bool = True) -> None:
+        if enable:
+            self.ctx.enable(moderngl.DEPTH_TEST)
+        else:
+            self.ctx.disable(moderngl.DEPTH_TEST)
+
+    def init_light_source(self) -> None:
+        self.light_source = OpenGLPoint(self.light_source_position)
+
+    # Methods associated with the frame buffer
+    def get_fbo(self, ctx: moderngl.Context, samples: int = 0) -> moderngl.Framebuffer:
+        pw = self.pixel_width
+        ph = self.pixel_height
+        return ctx.framebuffer(
+            color_attachments=ctx.texture(
+                (pw, ph), components=self.n_channels, samples=samples
+            ),
+            depth_attachment=ctx.depth_renderbuffer((pw, ph), samples=samples),
+        )
+
+    def clear(self) -> None:
+        self.fbo.clear(*self.background_color)
+        self.fbo_msaa.clear(*self.background_color)
+
+    def reset_pixel_shape(self, new_width: int, new_height: int) -> None:
+        self.pixel_width = new_width
+        self.pixel_height = new_height
+        self.refresh_perspective_uniforms()
+
+    def get_raw_fbo_data(self, dtype: str = "f1") -> bytes:
+        # Copy blocks from the fbo_msaa to the drawn fbo using Blit
+        pw, ph = (self.pixel_width, self.pixel_height)
+        gl.glBindFrameBuffer(gl.GL_READ_FRAMEBUFFER, self.fbo_msaa.glo)
+        gl.glBindFrameBuffer(gl.GL_DRAW_FRAMEBUFFER, self.fbo.glo)
+        gl.glBlitFramebuffer(
+            0, 0, pw, ph, 0, 0, pw, ph, gl.GL_COLOR_BUFFER_BIT, gl.GL_LINEAR
+        )
+        return self.fbo.read(
+            viewport=self.fbo.viewport,
+            components=self.n_channels,
+            dtype=dtype,
+        )
+
+    def get_image(self) -> Image.Image:
+        return Image.frombytes(
+            "RGBA",
+            self.get_pixel_shape(),
+            self.get_raw_fbo_data(),
+            "raw",
+            "RGBA",
+            0,
+            -1,
+        )
+
+    def get_pixel_array(self) -> np.ndarray:
+        raw = self.get_raw_fbo_data(dtype="f4")
+        flat_arr = np.frombuffer(raw, dtype="f4")
+        arr = flat_arr.reshape([*self.fbo.size, self.n_channels])
+        # Convert from float
+        return (self.rgb_max_val * arr).astype(self.pixel_array_dtype)
+
+    def get_texture(self):
+        texture = self.ctx.texture(
+            size=self.fbo.size, components=4, data=self.get_raw_fbo_data(), dtype="f4"
+        )
+        return texture
+
+    # Getting camera attributes
+    def get_pixel_shape(self) -> tuple[int, int]:
+        return self.fbo.viewport[2:4]
+        # return (self.pixel_width, self.pixel_height)
+
+    def get_pixel_width(self) -> int:
+        return self.get_pixel_shape()[0]
+
+    def get_pixel_height(self) -> int:
+        return self.get_pixel_shape()[1]
+
+    def get_frame_height(self) -> float:
+        return self.frame.get_height()
+
+    def get_frame_width(self) -> float:
+        return self.frame.get_width()
+
+    def get_frame_shape(self) -> tuple[float, float]:
+        return (self.get_frame_width(), self.get_frame_height())
+
+    def get_frame_center(self) -> np.ndarray:
+        return self.frame.get_center()
+
+    def get_location(self) -> tuple[float, float, float] | np.ndarray:
+        return self.frame.get_implied_camera_location()
+
+    def resize_frame_shape(self, fixed_dimension: bool = False) -> None:
+        """
+        Changes frame_shape to match the aspect ratio
+        of the pixels, where fixed_dimension determines
+        whether frame_height or frame_width
+        remains fixed while the other changes accordingly.
+        """
+        pixel_height = self.get_pixel_height()
+        pixel_width = self.get_pixel_width()
+        frame_height = self.get_frame_height()
+        frame_width = self.get_frame_width()
+        aspect_ratio = fdiv(pixel_width, pixel_height)
+        if not fixed_dimension:
+            frame_height = frame_width / aspect_ratio
+        else:
+            frame_width = aspect_ratio * frame_height
+        self.frame.set_height(frame_height)
+        self.frame.set_width(frame_width)
+
+    # Rendering
+    def capture(self, *mobjects: OpenGLMobject) -> None:
+        self.refresh_perspective_uniforms()
+        for mobject in mobjects:
+            for render_group in self.get_render_group_list(mobject):
+                self.render(render_group)
+
+    def render(self, render_group: dict[str, Any]) -> None:
+        shader_wrapper: ShaderWrapper = render_group["shader_wrapper"]
+        shader_program = render_group["prog"]
+        self.set_shader_uniforms(shader_program, shader_wrapper)
+        self.set_ctx_depth_test(shader_wrapper.depth_test)
+        render_group["vao"].render(int(shader_wrapper.render_primitive))
+        if render_group["single_use"]:
+            self.release_render_group(render_group)
+
+    def get_render_group_list(self, mobject: OpenGLMobject) -> Iterable[dict[str, Any]]:
+        if mobject.is_changing():
+            return self.generate_render_group_list(mobject)
+
+        # Otherwise, cache result for later use
+        key = id(mobject)
+        if key not in self.mob_to_render_groups:
+            self.mob_to_render_groups[key] = list(
+                self.generate_render_group_list(mobject)
+            )
+        return self.mob_to_render_groups[key]
+
+    def generate_render_group_list(
+        self, mobject: OpenGLMobject
+    ) -> Iterable[dict[str, Any]]:
+        return (
+            self.get_render_group(sw, single_use=mobject.is_changing())
+            for sw in mobject.get_shader_wrapper_list()
+        )
+
+    def get_render_group(
+        self, shader_wrapper: ShaderWrapper, single_use: bool = True
+    ) -> dict[str, Any]:
+        # Data buffers
+        vbo = self.ctx.buffer(shader_wrapper.vert_data.tobytes())
+        if shader_wrapper.vert_indices is None:
+            ibo = None
+        else:
+            vert_index_data = shader_wrapper.vert_indices.astype("i4").tobytes()
+            if vert_index_data:
+                ibo = self.ctx.buffer(vert_index_data)
+            else:
+                ibo = None
+
+        # Program an vertex array
+        shader_program, vert_format = self.get_shader_program(shader_wrapper)  # type: ignore
+        vao = self.ctx.vertex_array(
+            program=shader_program,
+            content=[(vbo, vert_format, *shader_wrapper.vert_attributes)],
+            index_buffer=ibo,
+        )
+        return {
+            "vbo": vbo,
+            "ibo": ibo,
+            "vao": vao,
+            "prog": shader_program,
+            "shader_wrapper": shader_wrapper,
+            "single_use": single_use,
+        }
+
+    def release_render_group(self, render_group: dict[str, Any]) -> None:
+        for key in ["vbo", "ibo", "vao"]:
+            if render_group[key] is not None:
+                render_group[key].release()
+
+    def refresh_static_mobjects(self) -> None:
+        for render_group in it.chain(*self.mob_to_render_groups.values()):
+            self.release_render_group(render_group)
+        self.mob_to_render_groups = {}
+
+    # Shaders
+    def init_shaders(self) -> None:
+        # Initialize with the null id going to None
+        self.id_to_shader_program: dict[int, tuple[moderngl.Program, str] | None] = {
+            hash(""): None
+        }
+
+    def get_shader_program(
+        self, shader_wrapper: ShaderWrapper
+    ) -> tuple[moderngl.Program, str] | None:
+        sid = shader_wrapper.get_program_id()
+        if sid not in self.id_to_shader_program:
+            # Create shader program for the first time, then cache
+            # in the id_to_shader_program dictionary
+            program = self.ctx.program(**shader_wrapper.get_program_code())
+            vert_format = moderngl.detect_format(
+                program, shader_wrapper.vert_attributes
+            )
+            self.id_to_shader_program[sid] = (program, vert_format)
+
+        return self.id_to_shader_program[sid]
+
+    def set_shader_uniforms(
+        self,
+        shader: moderngl.Program,
+        shader_wrapper: ShaderWrapper,
+    ) -> None:
+        for name, path in shader_wrapper.texture_paths.items():
+            tid = self.get_texture_id(path)
+            shader[name].value = tid
+        for name, value in it.chain(
+            self.perspective_uniforms.items(), shader_wrapper.uniforms.items()
+        ):
+            if name in shader:
+                if isinstance(value, np.ndarray) and value.ndim > 0:
+                    value = tuple(value)
+                shader[name].value = value
+
+    def refresh_perspective_uniforms(self) -> None:
+        frame = self.frame
+        # Orient light
+        rotation = frame.get_inverse_camera_rotation_matrix()
+        offset = frame.get_center()
+        light_pos = np.dot(rotation, self.light_source.get_location() + offset)
+        cam_pos = self.frame.get_implied_camera_location()  # TODO
+
+        self.perspective_uniforms = {
+            "frame_shape": frame.get_shape(),
+            "pixel_shape": self.get_pixel_shape(),
+            "camera_offset": tuple(offset),
+            "camera_rotation": tuple(np.array(rotation).T.flatten()),
+            "camera_position": tuple(cam_pos),
+            "light_source_position": tuple(light_pos),
+            "focal_distance": frame.get_focal_distance(),
+        }
+
+    def init_textures(self) -> None:
+        self.n_textures: int = 0
+        self.path_to_texture: dict[str, tuple[int, moderngl.Texture]] = {}
+
+    def get_texture_id(self, path: str) -> int:
+        if path not in self.path_to_texture:
+            if self.n_textures == 15:  # I have no clue why this is needed
+                self.n_textures += 1
+            tid = self.n_textures
+            self.n_textures += 1
+            im = Image.open(path).convert("RGBA")
+            texture = self.ctx.texture(
+                size=im.size,
+                components=len(im.getbands()),
+                data=im.tobytes(),
+            )
+            texture.use(location=tid)
+            self.path_to_texture[path] = (tid, texture)
+        return self.path_to_texture[path][0]
+
+    def release_texture(self, path: str):
+        tid_and_texture = self.path_to_texture.pop(path, None)
+        if tid_and_texture:
+            tid_and_texture[1].release()
+        return self
+
+
+class OpenGLCameraLegacy(OpenGLMobject):
     euler_angles = _Data()
 
     def __init__(
@@ -465,8 +960,6 @@ def update_frame(self, scene):
         self.refresh_perspective_uniforms(scene.camera)
 
         for mobject in scene.mobjects:
-            if not mobject.should_render:
-                continue
             self.render_mobject(mobject)
 
         for obj in scene.meshes:
diff --git a/manim/renderer/shader_wrapper.py b/manim/renderer/shader_wrapper.py
index 8eff1772c1..0c32441ad0 100644
--- a/manim/renderer/shader_wrapper.py
+++ b/manim/renderer/shader_wrapper.py
@@ -7,6 +7,8 @@
 import moderngl
 import numpy as np
 
+from manim.utils.iterables import resize_array
+
 from .. import logger
 
 # Mobjects that should be rendered with
@@ -55,6 +57,29 @@ def __init__(
         self.init_program_code()
         self.refresh_id()
 
+    def __eq__(self, shader_wrapper: object):
+        if not isinstance(shader_wrapper, ShaderWrapper):
+            raise TypeError(
+                f"Cannot compare ShaderWrapper with non-ShaderWrapper object of type {type(shader_wrapper)}"
+            )
+        return all(
+            (
+                np.all(self.vert_data == shader_wrapper.vert_data),
+                np.all(self.vert_indices == shader_wrapper.vert_indices),
+                self.shader_folder == shader_wrapper.shader_folder,
+                all(
+                    np.all(self.uniforms[key] == shader_wrapper.uniforms[key])
+                    for key in self.uniforms
+                ),
+                all(
+                    self.texture_paths[key] == shader_wrapper.texture_paths[key]
+                    for key in self.texture_paths
+                ),
+                self.depth_test == shader_wrapper.depth_test,
+                self.render_primitive == shader_wrapper.render_primitive,
+            )
+        )
+
     def copy(self):
         result = copy.copy(self)
         result.vert_data = np.array(self.vert_data)
@@ -125,30 +150,34 @@ def get_program_code(self):
 
     def replace_code(self, old, new):
         code_map = self.program_code
-        for (name, _code) in code_map.items():
+        for name, _code in code_map.items():
             if code_map[name] is None:
                 continue
             code_map[name] = re.sub(old, new, code_map[name])
         self.refresh_id()
 
-    def combine_with(self, *shader_wrappers):
-        # Assume they are of the same type
-        if len(shader_wrappers) == 0:
-            return
+    def combine_with(self, *shader_wrappers: ShaderWrapper) -> ShaderWrapper:
+        self.read_in(self.copy(), *shader_wrappers)
+        return self
+
+    def read_in(self, *shader_wrappers: ShaderWrapper) -> ShaderWrapper:
+        # Assume all are of the same type
+        total_len = sum(len(sw.vert_data) for sw in shader_wrappers)
+        self.vert_data = resize_array(self.vert_data, total_len)
         if self.vert_indices is not None:
-            num_verts = len(self.vert_data)
-            indices_list = [self.vert_indices]
-            data_list = [self.vert_data]
-            for sw in shader_wrappers:
-                indices_list.append(sw.vert_indices + num_verts)
-                data_list.append(sw.vert_data)
-                num_verts += len(sw.vert_data)
-            self.vert_indices = np.hstack(indices_list)
-            self.vert_data = np.hstack(data_list)
-        else:
-            self.vert_data = np.hstack(
-                [self.vert_data, *(sw.vert_data for sw in shader_wrappers)],
-            )
+            total_verts = sum(len(sw.vert_indices) for sw in shader_wrappers)
+            self.vert_indices = resize_array(self.vert_indices, total_verts)
+
+        n_points = 0
+        n_verts = 0
+        for sw in shader_wrappers:
+            new_n_points = n_points + len(sw.vert_data)
+            self.vert_data[n_points:new_n_points] = sw.vert_data
+            if self.vert_indices is not None and sw.vert_indices is not None:
+                new_n_verts = n_verts + len(sw.vert_indices)
+                self.vert_indices[n_verts:new_n_verts] = sw.vert_indices + n_points
+                n_verts = new_n_verts
+            n_points = new_n_points
         return self
 
 
diff --git a/manim/utils/bezier.py b/manim/utils/bezier.py
index ce5fff437f..8943bfd2a5 100644
--- a/manim/utils/bezier.py
+++ b/manim/utils/bezier.py
@@ -288,6 +288,37 @@ def match_interpolate(
 # Figuring out which bezier curves most smoothly connect a sequence of points
 
 
+def get_smooth_quadratic_bezier_handle_points(points: FloatArray) -> FloatArray:
+    """
+    Figuring out which bezier curves most smoothly connect a sequence of points.
+
+    Given three successive points, P0, P1 and P2, you can compute that by defining
+    h = (1/4) P0 + P1 - (1/4)P2, the bezier curve defined by (P0, h, P1) will pass
+    through the point P2.
+
+    So for a given set of four successive points, P0, P1, P2, P3, if we want to add
+    a handle point h between P1 and P2 so that the quadratic bezier (P1, h, P2) is
+    part of a smooth curve passing through all four points, we calculate one solution
+    for h that would produce a parbola passing through P3, call it smooth_to_right, and
+    another that would produce a parabola passing through P0, call it smooth_to_left,
+    and use the midpoint between the two.
+    """
+    if len(points) == 2:
+        return midpoint(*points)
+    smooth_to_right, smooth_to_left = (
+        0.25 * ps[0:-2] + ps[1:-1] - 0.25 * ps[2:] for ps in (points, points[::-1])
+    )
+    if np.isclose(points[0], points[-1]).all():
+        last_str = 0.25 * points[-2] + points[-1] - 0.25 * points[1]
+        last_stl = 0.25 * points[1] + points[0] - 0.25 * points[-2]
+    else:
+        last_str = smooth_to_left[0]
+        last_stl = smooth_to_right[0]
+    handles = 0.5 * np.vstack([smooth_to_right, [last_str]])
+    handles += 0.5 * np.vstack([last_stl, smooth_to_left[::-1]])
+    return handles
+
+
 def get_smooth_cubic_bezier_handle_points(points):
     points = np.array(points)
     num_handles = len(points) - 1
diff --git a/manim/utils/color.py b/manim/utils/color.py
index 6356fb3f04..c48525a4ed 100644
--- a/manim/utils/color.py
+++ b/manim/utils/color.py
@@ -2,6 +2,8 @@
 
 from __future__ import annotations
 
+from manim.utils.iterables import resize_with_interpolation
+
 __all__ = [
     "color_to_rgb",
     "color_to_rgba",
@@ -550,3 +552,28 @@ def get_shaded_rgb(
         factor *= 0.5
     result = rgb + factor
     return result
+
+
+COLORMAP_3B1B: list[Color] = [BLUE_E, GREEN, YELLOW, RED]
+
+
+def get_colormap_list(map_name: str = "viridis", n_colors: int = 9) -> np.ndarray:
+    """
+    Options for map_name:
+    3b1b_colormap
+    magma
+    inferno
+    plasma
+    viridis
+    cividis
+    twilight
+    twilight_shifted
+    turbo
+    """
+    from matplotlib.cm import get_cmap
+
+    if map_name == "3b1b_colormap":
+        rgbs = np.array([color_to_rgb(color) for color in COLORMAP_3B1B])
+    else:
+        rgbs = get_cmap(map_name).colors  # Make more general?
+    return resize_with_interpolation(np.array(rgbs), n_colors)
diff --git a/manim/utils/directories.py b/manim/utils/directories.py
new file mode 100644
index 0000000000..e51f3c3c59
--- /dev/null
+++ b/manim/utils/directories.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+import os
+
+from manim._config import config
+from manim.utils.file_ops import guarantee_existence
+
+
+def get_directories() -> dict[str, str]:
+    return config["directories"]
+
+
+def get_temp_dir() -> str:
+    return get_directories()["temporary_storage"]
+
+
+def get_tex_dir() -> str:
+    return guarantee_existence(os.path.join(get_temp_dir(), "Tex"))
+
+
+def get_text_dir() -> str:
+    return guarantee_existence(os.path.join(get_temp_dir(), "Text"))
+
+
+def get_mobject_data_dir() -> str:
+    return guarantee_existence(os.path.join(get_temp_dir(), "mobject_data"))
+
+
+def get_downloads_dir() -> str:
+    return guarantee_existence(os.path.join(get_temp_dir(), "manim_downloads"))
+
+
+def get_output_dir() -> str:
+    return guarantee_existence(get_directories()["output"])
+
+
+def get_raster_image_dir() -> str:
+    return get_directories()["raster_images"]
+
+
+def get_vector_image_dir() -> str:
+    return get_directories()["vector_images"]
+
+
+def get_sound_dir() -> str:
+    return get_directories()["sounds"]
+
+
+def get_shader_dir() -> str:
+    return get_directories()["shaders"]
diff --git a/manim/utils/file_ops.py b/manim/utils/file_ops.py
index 1bbb048214..21bda0b8c6 100644
--- a/manim/utils/file_ops.py
+++ b/manim/utils/file_ops.py
@@ -27,8 +27,11 @@
 from shutil import copyfile
 from typing import TYPE_CHECKING
 
+import validators
+
 if TYPE_CHECKING:
     from ..scene.scene_file_writer import SceneFileWriter
+    from typing import Iterable
 
 from manim import __version__, config, logger
 
diff --git a/manim/utils/simple_functions.py b/manim/utils/simple_functions.py
index 2a4a7afed6..c7f617bf59 100644
--- a/manim/utils/simple_functions.py
+++ b/manim/utils/simple_functions.py
@@ -117,6 +117,19 @@ def clip(a, min_a, max_a):
     return a
 
 
+def fdiv(
+    a: Scalable, b: Scalable, zero_over_zero_value: Scalable | None = None
+) -> Scalable:
+    if zero_over_zero_value is not None:
+        out = np.full_like(a, zero_over_zero_value)
+        where = np.logical_or(a != 0, b != 0)
+    else:
+        out = None
+        where = True
+
+    return np.true_divide(a, b, out=out, where=where)
+
+
 def get_parameters(function: Callable) -> MappingProxyType[str, inspect.Parameter]:
     """Return the parameters of ``function`` as an ordered mapping of parameters'
     names to their corresponding ``Parameter`` objects.
diff --git a/manim/utils/space_ops.py b/manim/utils/space_ops.py
index 203462057a..43b8b140c1 100644
--- a/manim/utils/space_ops.py
+++ b/manim/utils/space_ops.py
@@ -285,6 +285,32 @@ def rotation_about_z(angle: float) -> np.ndarray:
     )
 
 
+def get_norm(vector: np.ndarray) -> float:
+    """Returns the norm of the vector.
+
+    Parameters
+    ----------
+    vector
+        The vector for which you want to find the norm.
+
+    Returns
+    -------
+    float
+        The norm of the vector.
+    """
+    return np.linalg.norm(vector)
+
+
+def normalize(vect: list[float], fall_back: list[float] | None = None) -> np.ndarray:
+    norm = get_norm(vect)
+    if norm > 0:
+        return np.array(vect) / norm
+    elif fall_back is not None:
+        return np.array(fall_back)
+    else:
+        return np.zeros(len(vect))
+
+
 def z_to_vector(vector: np.ndarray) -> np.ndarray:
     """
     Returns some matrix in SO(3) which takes the z-axis to the


Code review comments:
diff_hunk: @@ -2,22 +2,30 @@
 
 import copy
 import itertools as it
+import numbers
+import os
+import pickle
 import random
 import sys
 from functools import partialmethod, wraps
 from math import ceil
-from typing import Iterable, Sequence
+from typing import TYPE_CHECKING
 
 import moderngl
 import numpy as np
 from colour import Color
+from typing_extensions import TypedDict
path: manim/mobject/opengl/opengl_mobject.py
text: @user1:
## Unused import

Import of 'TypedDict' is not used.

[Show more details](https://github.com/ManimCommunity/manim/security/code-scanning/563)