diff --git a/manimlib/animation/animation.py b/manimlib/animation/animation.py index df05ffea..a8ece9ca 100644 --- a/manimlib/animation/animation.py +++ b/manimlib/animation/animation.py @@ -65,7 +65,6 @@ class Animation(object): self.rate_func = squish_rate_func( self.rate_func, start / self.run_time, end / self.run_time, ) - self.mobject.refresh_shader_data() self.mobject.set_animating_status(True) self.starting_mobject = self.create_starting_mobject() if self.suspend_mobject_updating: diff --git a/manimlib/animation/creation.py b/manimlib/animation/creation.py index 8271eb2c..cec23ab0 100644 --- a/manimlib/animation/creation.py +++ b/manimlib/animation/creation.py @@ -30,15 +30,6 @@ class ShowPartial(Animation, ABC): self.should_match_start = should_match_start super().__init__(mobject, **kwargs) - def begin(self) -> None: - super().begin() - if not self.should_match_start: - self.mobject.lock_matching_data(self.mobject, self.starting_mobject) - - def finish(self) -> None: - super().finish() - self.mobject.unlock_data() - def interpolate_submobject( self, submob: VMobject, @@ -114,11 +105,9 @@ class DrawBorderThenFill(Animation): self.outline = self.get_outline() super().begin() self.mobject.match_style(self.outline) - self.mobject.lock_matching_data(self.mobject, self.outline) def finish(self) -> None: super().finish() - self.mobject.unlock_data() self.mobject.refresh_joint_products() def get_outline(self) -> VMobject: @@ -146,9 +135,6 @@ class DrawBorderThenFill(Animation): if index == 1 and self.sm_to_index[hash(submob)] == 0: # First time crossing over submob.set_data(outline.data) - submob.unlock_data() - if not self.mobject.has_updaters: - submob.lock_matching_data(submob, start) submob.needs_new_triangulation = False self.sm_to_index[hash(submob)] = 1 diff --git a/manimlib/animation/transform.py b/manimlib/animation/transform.py index 9c07747d..012bfad9 100644 --- a/manimlib/animation/transform.py +++ b/manimlib/animation/transform.py @@ -70,6 +70,8 @@ class Transform(Animation): def finish(self) -> None: super().finish() self.mobject.unlock_data() + if self.target_mobject is not None: + self.mobject.become(self.target_mobject) def create_target(self) -> Mobject: # Has no meaningful effect here, but may be useful diff --git a/manimlib/camera/camera.py b/manimlib/camera/camera.py index b5bbc2cc..f9906e9e 100644 --- a/manimlib/camera/camera.py +++ b/manimlib/camera/camera.py @@ -1,188 +1,24 @@ from __future__ import annotations -import itertools as it -import math - import moderngl import numpy as np import OpenGL.GL as gl from PIL import Image -from scipy.spatial.transform import Rotation +from manimlib.camera.camera_frame import CameraFrame from manimlib.constants import BLACK -from manimlib.constants import DEGREES, RADIANS from manimlib.constants import DEFAULT_FPS from manimlib.constants import DEFAULT_PIXEL_HEIGHT, DEFAULT_PIXEL_WIDTH -from manimlib.constants import FRAME_HEIGHT, FRAME_WIDTH -from manimlib.constants import DOWN, LEFT, ORIGIN, OUT, RIGHT, UP +from manimlib.constants import FRAME_WIDTH from manimlib.mobject.mobject import Mobject from manimlib.mobject.mobject import Point from manimlib.utils.color import color_to_rgba -from manimlib.utils.simple_functions import fdiv -from manimlib.utils.space_ops import normalize from typing import TYPE_CHECKING if TYPE_CHECKING: - from manimlib.shader_wrapper import ShaderWrapper from manimlib.typing import ManimColor, Vect3 from manimlib.window import Window - from typing import Any, Iterable - -class CameraFrame(Mobject): - def __init__( - self, - frame_shape: tuple[float, float] = (FRAME_WIDTH, FRAME_HEIGHT), - center_point: Vect3 = 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 - self.view_matrix = np.identity(4) - super().__init__(**kwargs) - - def init_uniforms(self) -> None: - super().init_uniforms() - # As a quaternion - 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(FRAME_HEIGHT) - self.set_width(FRAME_WIDTH) - 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 get_view_matrix(self): - """ - Returns a 4x4 for the affine transformation mapping a point - into the camera's internal coordinate system - """ - result = self.view_matrix - result[:] = np.identity(4) - result[:3, 3] = -self.get_center() - rotation = np.identity(4) - rotation[:3, :3] = self.get_inverse_camera_rotation_matrix() - result[:] = np.dot(rotation, result) - return result - - def rotate(self, angle: float, axis: np.ndarray = OUT, **kwargs): - rot = Rotation.from_rotvec(angle * normalize(axis)) - self.set_orientation(rot * self.get_orientation()) - return self - - 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.get_points()[0] - - def get_width(self) -> float: - points = self.get_points() - return points[2, 0] - points[1, 0] - - def get_height(self) -> float: - points = self.get_points() - return points[4, 1] - points[3, 1] - - def get_focal_distance(self) -> float: - return self.uniforms["focal_dist_to_height"] * self.get_height() - - 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 Camera(object): @@ -224,109 +60,43 @@ class Camera(object): self.background_rgba: list[float] = list(color_to_rgba( background_color, background_opacity )) - self.perspective_uniforms = dict() + self.uniforms = dict() self.init_frame(**frame_config) self.init_context(window) - self.init_shaders() - self.init_textures() self.init_light_source() - self.refresh_perspective_uniforms() - self.init_fill_fbo(self.ctx) # Experimental - # 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 = {} def init_frame(self, **config) -> None: self.frame = CameraFrame(**config) def init_context(self, window: Window | None = None) -> None: + self.window = window if window is None: self.ctx = moderngl.create_standalone_context() self.fbo = self.get_fbo(self.samples) else: self.ctx = window.ctx - self.fbo = self.ctx.detect_framebuffer() + self.window_fbo = self.ctx.detect_framebuffer() + self.fbo_for_files = self.get_fbo(self.samples) + self.fbo = self.window_fbo + self.fbo.use() - self.set_ctx_blending() self.ctx.enable(moderngl.PROGRAM_POINT_SIZE) + self.ctx.enable(moderngl.BLEND) # This is the frame buffer we'll draw into when emitting frames self.draw_fbo = self.get_fbo(samples=0) - def init_fill_fbo(self, ctx: moderngl.context.Context): - # Experimental - size = self.get_pixel_shape() - self.fill_texture = ctx.texture( - size=size, - components=4, - # Important to make sure floating point (not fixed point) is - # used so that alpha values are not clipped - dtype='f2', - ) - # TODO, depth buffer is not really used yet - fill_depth = ctx.depth_renderbuffer(size) - self.fill_fbo = ctx.framebuffer(self.fill_texture, fill_depth) - self.fill_prog = ctx.program( - vertex_shader=''' - #version 330 - - in vec2 texcoord; - out vec2 v_textcoord; - - void main() { - gl_Position = vec4((2.0 * texcoord - 1.0), 0.0, 1.0); - v_textcoord = texcoord; - } - ''', - fragment_shader=''' - #version 330 - - uniform sampler2D Texture; - - in vec2 v_textcoord; - out vec4 frag_color; - - void main() { - frag_color = texture(Texture, v_textcoord); - frag_color = abs(frag_color); - if(frag_color.a == 0) discard; - //TODO, set gl_FragDepth; - } - ''', - ) - - tid = self.n_textures - self.fill_texture.use(tid) - self.fill_prog['Texture'].value = tid - self.n_textures += 1 - verts = np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) - self.fill_texture_vao = ctx.simple_vertex_array( - self.fill_prog, - ctx.buffer(verts.astype('f4').tobytes()), - 'texcoord', - ) - - 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 set_ctx_clip_plane(self, enable: bool = True) -> None: - if enable: - gl.glEnable(gl.GL_CLIP_DISTANCE0) - def init_light_source(self) -> None: self.light_source = Point(self.light_source_position) + def use_window_fbo(self, use: bool = True): + assert(self.window is not None) + if use: + self.fbo = self.window_fbo + else: + self.fbo = self.fbo_for_files + # Methods associated with the frame buffer def get_fbo( self, @@ -351,10 +121,7 @@ class Camera(object): # Copy blocks from fbo into draw_fbo using Blit gl.glBindFramebuffer(gl.GL_READ_FRAMEBUFFER, self.fbo.glo) gl.glBindFramebuffer(gl.GL_DRAW_FRAMEBUFFER, self.draw_fbo.glo) - if self.window is not None: - src_viewport = self.window.viewport - else: - src_viewport = self.fbo.viewport + src_viewport = self.fbo.viewport gl.glBlitFramebuffer( *src_viewport, *self.draw_fbo.viewport, @@ -443,153 +210,18 @@ class Camera(object): # Rendering def capture(self, *mobjects: Mobject) -> 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 = render_group["shader_wrapper"] - shader_program = render_group["prog"] - primitive = int(shader_wrapper.render_primitive) - self.set_shader_uniforms(shader_program, shader_wrapper) - self.set_ctx_depth_test(shader_wrapper.depth_test) - self.set_ctx_clip_plane(shader_wrapper.use_clip_plane) - - if shader_wrapper.is_fill: - self.render_fill(render_group["vao"], primitive, shader_wrapper.vert_indices) - else: - render_group["vao"].render(primitive) - - if render_group["single_use"]: - self.release_render_group(render_group) - - def render_fill(self, vao, render_primitive: int, indices: np.ndarray): - """ - VMobject fill is handled in a special way, where emited triangles - must be blended with moderngl.FUNC_SUBTRACT so as to effectively compute - a winding number around each pixel. This is rendered to a separate texture, - then that texture is overlayed onto the current fbo - """ - winding = (len(indices) == 0) - vao.program['winding'].value = winding - if not winding: - vao.render(moderngl.TRIANGLES) - return - self.fill_fbo.clear() - self.fill_fbo.use() - self.ctx.blend_func = (moderngl.ONE, moderngl.ONE) - vao.render(render_primitive) - self.ctx.blend_func = moderngl.DEFAULT_BLENDING + self.refresh_uniforms() self.fbo.use() - self.fill_texture_vao.render(moderngl.TRIANGLE_STRIP) + for mobject in mobjects: + mobject.render(self.ctx, self.uniforms) - def get_render_group_list(self, mobject: Mobject) -> 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: Mobject) -> 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 buffer - vert_data = shader_wrapper.vert_data - indices = shader_wrapper.vert_indices - if len(indices) == 0: - ibo = None - elif single_use: - ibo = self.ctx.buffer(indices.astype(np.uint32)) - else: - ibo = self.ctx.buffer(indices.astype(np.uint32)) - # # The vao.render call is strangely longer - # # when an index buffer is used, so if the - # # mobject is not changing, meaning only its - # # uniforms are being updated, just create - # # a larger data array based on the indices - # # and don't bother with the ibo - # vert_data = vert_data[indices] - # ibo = None - vbo = self.ctx.buffer(vert_data) - - # Program and vertex array - shader_program, vert_format = self.get_shader_program(shader_wrapper) - attributes = shader_wrapper.vert_attributes - vao = self.ctx.vertex_array( - program=shader_program, - content=[(vbo, vert_format, *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: + def refresh_uniforms(self) -> None: frame = self.frame view_matrix = frame.get_view_matrix() light_pos = self.light_source.get_location() cam_pos = self.frame.get_implied_camera_location() - self.perspective_uniforms.update( + self.uniforms.update( frame_shape=frame.get_shape(), pixel_size=self.get_pixel_size(), view=tuple(view_matrix.T.flatten()), @@ -598,32 +230,6 @@ class Camera(object): 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: - 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 - # Mostly just defined so old scenes don't break class ThreeDCamera(Camera): diff --git a/manimlib/camera/camera_frame.py b/manimlib/camera/camera_frame.py new file mode 100644 index 00000000..ce0a5846 --- /dev/null +++ b/manimlib/camera/camera_frame.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import math + +import numpy as np +from scipy.spatial.transform import Rotation + +from manimlib.constants import DEGREES, RADIANS +from manimlib.constants import FRAME_HEIGHT, FRAME_WIDTH +from manimlib.constants import DOWN, LEFT, ORIGIN, OUT, RIGHT, UP +from manimlib.mobject.mobject import Mobject +from manimlib.utils.space_ops import normalize + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from manimlib.typing import Vect3 + + +class CameraFrame(Mobject): + def __init__( + self, + frame_shape: tuple[float, float] = (FRAME_WIDTH, FRAME_HEIGHT), + center_point: Vect3 = 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 + self.view_matrix = np.identity(4) + super().__init__(**kwargs) + + def init_uniforms(self) -> None: + super().init_uniforms() + # As a quaternion + 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(FRAME_HEIGHT) + self.set_width(FRAME_WIDTH) + 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 get_view_matrix(self): + """ + Returns a 4x4 for the affine transformation mapping a point + into the camera's internal coordinate system + """ + result = self.view_matrix + result[:] = np.identity(4) + result[:3, 3] = -self.get_center() + rotation = np.identity(4) + rotation[:3, :3] = self.get_inverse_camera_rotation_matrix() + result[:] = np.dot(rotation, result) + return result + + def rotate(self, angle: float, axis: np.ndarray = OUT, **kwargs): + rot = Rotation.from_rotvec(angle * normalize(axis)) + self.set_orientation(rot * self.get_orientation()) + return self + + 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.get_points()[0] + + def get_width(self) -> float: + points = self.get_points() + return points[2, 0] - points[1, 0] + + def get_height(self) -> float: + points = self.get_points() + return points[4, 1] - points[3, 1] + + def get_focal_distance(self) -> float: + return self.uniforms["focal_dist_to_height"] * self.get_height() + + 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 diff --git a/manimlib/config.py b/manimlib/config.py index 26ddb708..e3e93420 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -414,10 +414,7 @@ def get_window_config(args: Namespace, custom_config: dict, camera_config: dict) if not (args.full_screen or custom_config["full_screen"]): window_width //= 2 window_height = int(window_width / aspect_ratio) - return dict( - full_size=(camera_config["pixel_width"], camera_config["pixel_height"]), - size=(window_width, window_height), - ) + return dict(size=(window_width, window_height)) def get_camera_config(args: Namespace, custom_config: dict) -> dict: diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 874c81a1..313517ee 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -51,6 +51,7 @@ if TYPE_CHECKING: from typing import Callable, Iterable, Union, Tuple import numpy.typing as npt from manimlib.typing import ManimColor, Vect3, Vect4, Vect3Array + from moderngl.context import Context TimeBasedUpdater = Callable[["Mobject", float], "Mobject" | None] NonTimeUpdater = Callable[["Mobject"], "Mobject" | None] @@ -101,6 +102,8 @@ class Mobject(object): self.saved_state = None self.target = None self.bounding_box: Vect3Array = np.zeros((3, 3)) + self._shaders_initialized: bool = False + self._data_has_changed: bool = True self.init_data() self._data_defaults = np.ones(1, dtype=self.data.dtype) @@ -109,7 +112,6 @@ class Mobject(object): self.init_event_listners() self.init_points() self.init_colors() - self.init_shader_data() if self.depth_test: self.apply_depth_test() @@ -141,11 +143,6 @@ class Mobject(object): # Typically implemented in subclass, unlpess purposefully left blank pass - def set_data(self, data: np.ndarray): - assert(data.dtype == self.data.dtype) - self.data = data - return self - def set_uniforms(self, uniforms: dict): for key, value in uniforms.items(): if isinstance(value, np.ndarray): @@ -158,8 +155,36 @@ class Mobject(object): # Borrowed from https://github.com/ManimCommunity/manim/ return _AnimationBuilder(self) - # Only these methods should directly affect points + def note_changed_data(self, recurse_up: bool = True): + self._data_has_changed = True + if recurse_up: + for mob in self.parents: + mob.note_changed_data() + def affects_data(func: Callable): + @wraps(func) + def wrapper(self, *args, **kwargs): + func(self, *args, **kwargs) + self.note_changed_data() + return wrapper + + def affects_family_data(func: Callable): + @wraps(func) + def wrapper(self, *args, **kwargs): + func(self, *args, **kwargs) + for mob in self.family_members_with_points(): + mob.note_changed_data() + return self + return wrapper + + # Only these methods should directly affect points + @affects_data + def set_data(self, data: np.ndarray): + assert(data.dtype == self.data.dtype) + self.data = data.copy() + return self + + @affects_data def resize_points( self, new_length: int, @@ -175,11 +200,13 @@ class Mobject(object): self.refresh_bounding_box() return self + @affects_data def set_points(self, points: Vect3Array): self.resize_points(len(points), resize_func=resize_preserving_order) self.data["point"][:] = points return self + @affects_data def append_points(self, new_points: Vect3Array): n = self.get_num_points() self.resize_points(n + len(new_points)) @@ -190,11 +217,13 @@ class Mobject(object): self.refresh_bounding_box() return self + @affects_family_data def reverse_points(self): for mob in self.get_family(): mob.data = mob.data[::-1] return self + @affects_family_data def apply_points_function( self, func: Callable[[np.ndarray], np.ndarray], @@ -328,6 +357,7 @@ class Mobject(object): def split(self) -> list[Mobject]: return self.submobjects + @affects_data def assemble_family(self): sub_families = (sm.get_family() for sm in self.submobjects) self.family = [self, *it.chain(*sub_families)] @@ -557,11 +587,9 @@ class Mobject(object): return self def deepcopy(self): - try: - # Often faster than deepcopy - return pickle.loads(pickle.dumps(self)) - except AttributeError: - return copy.deepcopy(self) + result = copy.deepcopy(self) + result._shaders_initialized = False + result._data_has_changed = True @stash_mobject_pointers def copy(self, deep: bool = False): @@ -591,6 +619,7 @@ class Mobject(object): # 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) + result._data_has_changed = True family = self.get_family() for attr, value in list(self.__dict__.items()): @@ -654,7 +683,6 @@ class Mobject(object): for attr, value in list(mobject.__dict__.items()): if isinstance(value, Mobject) 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 @@ -1214,6 +1242,7 @@ class Mobject(object): # Color functions + @affects_family_data def set_rgba_array( self, rgba_array: npt.ArrayLike, @@ -1252,6 +1281,7 @@ class Mobject(object): mob.set_rgba_array(rgba_array) return self + @affects_family_data def set_rgba_array_by_color( self, color: ManimColor | Iterable[ManimColor] | None = None, @@ -1618,9 +1648,6 @@ class Mobject(object): def align_data(self, mobject: Mobject) -> None: for mob1, mob2 in zip(self.get_family(), mobject.get_family()): - # In case any data arrays get resized when aligned to shader data - mob1.refresh_shader_data() - mob2.refresh_shader_data() mob1.align_points(mob2) def align_points(self, mobject: Mobject): @@ -1690,6 +1717,8 @@ class Mobject(object): path_func: Callable[[np.ndarray, np.ndarray, float], np.ndarray] = straight_path ): keys = [k for k in self.data.dtype.names if k not in self.locked_data_keys] + if keys: + self.note_changed_data() for key in keys: func = path_func if key in self.pointlike_data_keys else interpolate md1 = mobject1.data[key] @@ -1700,6 +1729,8 @@ class Mobject(object): self.data[key] = func(md1, md2, alpha) for key in self.uniforms: + if key not in mobject1.uniforms or key not in mobject2.uniforms: + continue self.uniforms[key] = interpolate( mobject1.uniforms[key], mobject2.uniforms[key], @@ -1731,8 +1762,6 @@ class Mobject(object): """ 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: Mobject, mobject2: Mobject): @@ -1842,10 +1871,10 @@ class Mobject(object): # For shader data - def init_shader_data(self): - # TODO, only call this when needed? + def init_shader_data(self, ctx: Context): self.shader_indices = np.zeros(0) self.shader_wrapper = ShaderWrapper( + ctx=ctx, vert_data=self.data, shader_folder=self.shader_folder, texture_paths=self.texture_paths, @@ -1854,20 +1883,25 @@ class Mobject(object): ) def refresh_shader_wrapper_id(self): - self.shader_wrapper.refresh_id() + if self._shaders_initialized: + self.shader_wrapper.refresh_id() return self - def get_shader_wrapper(self) -> ShaderWrapper: + def get_shader_wrapper(self, ctx: Context) -> ShaderWrapper: + if not self._shaders_initialized: + self.init_shader_data(ctx) + self._shaders_initialized = True + 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_uniforms() + self.shader_wrapper.uniforms.update(self.get_uniforms()) self.shader_wrapper.depth_test = self.depth_test return self.shader_wrapper - def get_shader_wrapper_list(self) -> list[ShaderWrapper]: + def get_shader_wrapper_list(self, ctx: Context) -> list[ShaderWrapper]: shader_wrappers = it.chain( - [self.get_shader_wrapper()], - *[sm.get_shader_wrapper_list() for sm in self.submobjects] + [self.get_shader_wrapper(ctx)], + *[sm.get_shader_wrapper_list(ctx) for sm in self.submobjects] ) batches = batch_by_property(shader_wrappers, lambda sw: sw.get_id()) @@ -1884,15 +1918,24 @@ class Mobject(object): def get_shader_data(self): return self.data - def refresh_shader_data(self): - pass - def get_uniforms(self): return self.uniforms def get_shader_vert_indices(self): return self.shader_indices + def render(self, ctx: Context, camera_uniforms: dict): + if self._data_has_changed: + self.shader_wrappers = self.get_shader_wrapper_list(ctx) + for shader_wrapper in self.shader_wrappers: + shader_wrapper.generate_vao() + self._data_has_changed = False + for shader_wrapper in self.shader_wrappers: + shader_wrapper.uniforms.update(self.get_uniforms()) + shader_wrapper.uniforms.update(camera_uniforms) + shader_wrapper.pre_render() + shader_wrapper.render() + # Event Handlers """ Event handling follows the Event Bubbling model of DOM in javascript. @@ -2005,6 +2048,8 @@ class Group(Mobject): raise Exception("All submobjects must be of type Mobject") Mobject.__init__(self, **kwargs) self.add(*mobjects) + if any(m.is_fixed_in_frame for m in mobjects): + self.fix_in_frame() def __add__(self, other: Mobject | Group): assert(isinstance(other, Mobject)) diff --git a/manimlib/mobject/svg/svg_mobject.py b/manimlib/mobject/svg/svg_mobject.py index 6ca56596..d140dd19 100644 --- a/manimlib/mobject/svg/svg_mobject.py +++ b/manimlib/mobject/svg/svg_mobject.py @@ -320,8 +320,9 @@ class VMobjectFromSVGPath(VMobject): self.set_points(self.get_points_without_null_curves()) # So triangulation doesn't get messed up self.subdivide_intersections() - # Always default to orienting outward - if self.get_unit_normal()[2] < 0: + # Always default to orienting outward, account + # for the fact that this will get flipped in SVG.__init__ + if self.get_unit_normal()[2] > 0: self.reverse_points() # Save for future use PATH_TO_POINTS[path_string] = self.get_points().copy() diff --git a/manimlib/mobject/types/dot_cloud.py b/manimlib/mobject/types/dot_cloud.py index 613c6e0b..83aa9b07 100644 --- a/manimlib/mobject/types/dot_cloud.py +++ b/manimlib/mobject/types/dot_cloud.py @@ -5,6 +5,7 @@ import numpy as np from manimlib.constants import GREY_C, YELLOW from manimlib.constants import ORIGIN, NULL_POINTS +from manimlib.mobject.mobject import Mobject from manimlib.mobject.types.point_cloud_mobject import PMobject from manimlib.utils.iterables import resize_with_interpolation @@ -94,6 +95,7 @@ class DotCloud(PMobject): self.center() return self + @Mobject.affects_data def set_radii(self, radii: npt.ArrayLike): n_points = self.get_num_points() radii = np.array(radii).reshape((len(radii), 1)) @@ -104,6 +106,7 @@ class DotCloud(PMobject): def get_radii(self) -> np.ndarray: return self.data["radius"] + @Mobject.affects_data def set_radius(self, radius: float): data = self.data if self.get_num_points() > 0 else self._data_defaults data["radius"][:] = radius diff --git a/manimlib/mobject/types/image_mobject.py b/manimlib/mobject/types/image_mobject.py index 8de5b10e..80efe5aa 100644 --- a/manimlib/mobject/types/image_mobject.py +++ b/manimlib/mobject/types/image_mobject.py @@ -47,6 +47,7 @@ class ImageMobject(Mobject): self.set_width(2 * size[0] / size[1], stretch=True) self.set_height(self.height) + @Mobject.affects_data def set_opacity(self, opacity: float, recurse: bool = True): self.data["opacity"][:, 0] = resize_with_interpolation( np.array(listify(opacity)), diff --git a/manimlib/mobject/types/point_cloud_mobject.py b/manimlib/mobject/types/point_cloud_mobject.py index ee1b384b..f6180b3f 100644 --- a/manimlib/mobject/types/point_cloud_mobject.py +++ b/manimlib/mobject/types/point_cloud_mobject.py @@ -5,7 +5,6 @@ import numpy as np from manimlib.mobject.mobject import Mobject from manimlib.utils.color import color_gradient from manimlib.utils.color import color_to_rgba -from manimlib.utils.iterables import resize_array from manimlib.utils.iterables import resize_with_interpolation from typing import TYPE_CHECKING @@ -52,6 +51,7 @@ class PMobject(Mobject): self.add_points([point], rgbas, color, opacity) return self + @Mobject.affects_data def set_color_by_gradient(self, *colors: ManimColor): self.data["rgba"][:] = np.array(list(map( color_to_rgba, @@ -59,17 +59,20 @@ class PMobject(Mobject): ))) return self + @Mobject.affects_data def match_colors(self, pmobject: PMobject): self.data["rgba"][:] = resize_with_interpolation( pmobject.data["rgba"], self.get_num_points() ) return self + @Mobject.affects_data def filter_out(self, condition: Callable[[np.ndarray], bool]): for mob in self.family_members_with_points(): mob.data = mob.data[~np.apply_along_axis(condition, 1, mob.get_points())] return self + @Mobject.affects_data def sort_points(self, function: Callable[[Vect3], None] = lambda p: p[0]): """ function is any map from R^3 to R @@ -81,6 +84,7 @@ class PMobject(Mobject): mob.data[:] = mob.data[indices] return self + @Mobject.affects_data def ingest_submobjects(self): self.data = np.vstack([ sm.data for sm in self.get_family() @@ -91,6 +95,7 @@ class PMobject(Mobject): index = alpha * (self.get_num_points() - 1) return self.get_points()[int(index)] + @Mobject.affects_data def pointwise_become_partial(self, pmobject: PMobject, a: float, b: float): lower_index = int(a * pmobject.get_num_points()) upper_index = int(b * pmobject.get_num_points()) diff --git a/manimlib/mobject/types/surface.py b/manimlib/mobject/types/surface.py index f022ecaa..81855bac 100644 --- a/manimlib/mobject/types/surface.py +++ b/manimlib/mobject/types/surface.py @@ -72,6 +72,7 @@ class Surface(Mobject): # To be implemented in subclasses return (u, v, 0.0) + @Mobject.affects_data def init_points(self): dim = self.dim nu, nv = self.resolution @@ -130,6 +131,7 @@ class Surface(Mobject): ) return normalize_along_axis(normals, 1) + @Mobject.affects_data def pointwise_become_partial( self, smobject: "Surface", @@ -218,12 +220,10 @@ class Surface(Mobject): self.uniforms["clip_plane"][:3] = vect if threshold is not None: self.uniforms["clip_plane"][3] = threshold - self.shader_wrapper.use_clip_plane = True return self def deactivate_clip_plane(self): self.uniforms["clip_plane"][:] = 0 - self.shader_wrapper.use_clip_plane = False return self def get_shader_vert_indices(self) -> np.ndarray: @@ -300,6 +300,7 @@ class TexturedSurface(Surface): **kwargs ) + @Mobject.affects_data def init_points(self): surf = self.uv_surface nu, nv = surf.resolution @@ -317,6 +318,7 @@ class TexturedSurface(Surface): super().init_uniforms() self.uniforms["num_textures"] = self.num_textures + @Mobject.affects_data def set_opacity(self, opacity: float | Iterable[float]): op_arr = np.array(listify(opacity)) self.data["opacity"][:, 0] = resize_with_interpolation(op_arr, len(self.data)) diff --git a/manimlib/mobject/types/vectorized_mobject.py b/manimlib/mobject/types/vectorized_mobject.py index aba18c60..e83c76e3 100644 --- a/manimlib/mobject/types/vectorized_mobject.py +++ b/manimlib/mobject/types/vectorized_mobject.py @@ -40,12 +40,14 @@ from manimlib.utils.space_ops import midpoint from manimlib.utils.space_ops import normalize_along_axis from manimlib.utils.space_ops import z_to_vector from manimlib.shader_wrapper import ShaderWrapper +from manimlib.shader_wrapper import FillShaderWrapper from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Callable, Iterable, Tuple from manimlib.typing import ManimColor, Vect3, Vect4, Vect3Array, Vect4Array + from moderngl.context import Context DEFAULT_STROKE_COLOR = GREY_A DEFAULT_FILL_COLOR = GREY_C @@ -231,6 +233,7 @@ class VMobject(Mobject): self.set_stroke(color, width, background=background) return self + @Mobject.affects_family_data def set_style( self, fill_color: ManimColor | Iterable[ManimColor] | None = None, @@ -414,6 +417,7 @@ class VMobject(Mobject): def get_joint_type(self) -> float: return self.uniforms["joint_type"] + @Mobject.affects_family_data def use_winding_fill(self, value: bool = True, recurse: bool = True): for submob in self.get_family(recurse): submob._use_winding_fill = value @@ -654,7 +658,7 @@ class VMobject(Mobject): return self def add_subpath(self, points: Vect3Array): - assert(len(points) % 2 == 1) + assert(len(points) % 2 == 1 or len(points) == 0) if not self.has_points(): self.set_points(points) return self @@ -832,7 +836,7 @@ class VMobject(Mobject): # If both have fill, and they have the same shape, just # give them the same triangulation so that it's not recalculated # needlessly throughout an animation - if self._use_winding_fill and self.has_fill() \ + if not self._use_winding_fill and self.has_fill() \ and vmobject.has_fill() and self.has_same_shape_as(vmobject): vmobject.triangulation = self.triangulation return self @@ -881,6 +885,8 @@ class VMobject(Mobject): def invisible_copy(self): result = self.copy() + if not result.has_fill() or result.get_num_points() == 0: + return result result.append_vectorized_mobject(self.copy().reverse_points()) result.set_opacity(0) return result @@ -937,8 +943,9 @@ class VMobject(Mobject): def pointwise_become_partial(self, vmobject: VMobject, a: float, b: float): assert(isinstance(vmobject, VMobject)) vm_points = vmobject.get_points() + self.data["joint_product"] = vmobject.data["joint_product"] if a <= 0 and b >= 1: - self.set_points(vm_points) + self.set_points(vm_points, refresh_joints=False) return self num_curves = vmobject.get_num_curves() @@ -971,7 +978,9 @@ class VMobject(Mobject): # Keep new_points i2:i3 as they are new_points[i3:i4] = high_tup new_points[i4:] = high_tup[2] - self.set_points(new_points) + self.data["joint_product"][:i1] = [0, 0, 0, 1] + self.data["joint_product"][i4:] = [0, 0, 0, 1] + self.set_points(new_points, refresh_joints=False) return self def get_subcurve(self, a: float, b: float) -> VMobject: @@ -1046,8 +1055,10 @@ class VMobject(Mobject): null2 = (iti[0::3] - 1 == iti[1::3]) & (iti[0::3] - 2 == iti[2::3]) inner_tri_indices = iti[~(null1 | null2).repeat(3)] - outer_tri_indices = self.get_outer_vert_indices() - tri_indices = np.hstack([outer_tri_indices, inner_tri_indices]) + ovi = self.get_outer_vert_indices() + # Flip outer triangles with negative orientation + ovi[0::3][concave_parts], ovi[2::3][concave_parts] = ovi[2::3][concave_parts], ovi[0::3][concave_parts] + tri_indices = np.hstack([ovi, inner_tri_indices]) self.triangulation = tri_indices self.needs_new_triangulation = False return tri_indices @@ -1069,6 +1080,7 @@ class VMobject(Mobject): return self.data["joint_product"] self.needs_new_joint_products = False + self._data_has_changed = True points = self.get_points() @@ -1107,6 +1119,11 @@ class VMobject(Mobject): self.data["joint_product"][:, 3] = (vect_to_vert * vect_from_vert).sum(1) return self.data["joint_product"] + def lock_matching_data(self, vmobject1: VMobject, vmobject2: VMobject): + for mob in [self, vmobject1, vmobject2]: + mob.get_joint_products() + super().lock_matching_data(vmobject1, vmobject2) + def triggers_refreshed_triangulation(func: Callable): @wraps(func) def wrapper(self, *args, refresh=True, **kwargs): @@ -1117,10 +1134,12 @@ class VMobject(Mobject): return self return wrapper - @triggers_refreshed_triangulation - def set_points(self, points: Vect3Array): + def set_points(self, points: Vect3Array, refresh_joints: bool = True): assert(len(points) == 0 or len(points) % 2 == 1) super().set_points(points) + self.refresh_triangulation() + if refresh_joints: + self.get_joint_products(refresh=True) return self @triggers_refreshed_triangulation @@ -1164,7 +1183,7 @@ class VMobject(Mobject): self.refresh_joint_products() # For shaders - def init_shader_data(self): + def init_shader_data(self, ctx: Context): dtype = self.shader_dtype fill_dtype, stroke_dtype = ( np.dtype([ @@ -1175,27 +1194,39 @@ class VMobject(Mobject): ) fill_data = np.zeros(0, dtype=fill_dtype) stroke_data = np.zeros(0, dtype=stroke_dtype) - self.fill_shader_wrapper = ShaderWrapper( + self.fill_shader_wrapper = FillShaderWrapper( + ctx=ctx, vert_data=fill_data, uniforms=self.uniforms, shader_folder=self.fill_shader_folder, render_primitive=self.fill_render_primitive, - is_fill=True, ) self.stroke_shader_wrapper = ShaderWrapper( + ctx=ctx, vert_data=stroke_data, uniforms=self.uniforms, shader_folder=self.stroke_shader_folder, render_primitive=self.stroke_render_primitive, ) self.back_stroke_shader_wrapper = self.stroke_shader_wrapper.copy() + self.shader_wrappers = [ + self.back_stroke_shader_wrapper, + self.fill_shader_wrapper, + self.stroke_shader_wrapper, + ] def refresh_shader_wrapper_id(self): - for wrapper in self.get_shader_wrapper_list(): + if not self._shaders_initialized: + return self + for wrapper in self.shader_wrappers: wrapper.refresh_id() return self - def get_shader_wrapper_list(self) -> list[ShaderWrapper]: + def get_shader_wrapper_list(self, ctx: Context) -> list[ShaderWrapper]: + if not self._shaders_initialized: + self.init_shader_data(ctx) + self._shaders_initialized = True + family = self.family_members_with_points() if not family: return [] @@ -1236,13 +1267,10 @@ class VMobject(Mobject): for sw in shader_wrappers: # Assume uniforms of the first family member - sw.uniforms = family[0].get_uniforms() + sw.uniforms.update(family[0].get_uniforms()) sw.depth_test = family[0].depth_test return [sw for sw in shader_wrappers if len(sw.vert_data) > 0] - def refresh_shader_data(self): - self.get_shader_wrapper_list() - class VGroup(VMobject): def __init__(self, *vmobjects: VMobject, **kwargs): diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index 80c4c548..fe794bc3 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -317,14 +317,12 @@ class InteractiveScene(Scene): mob.refresh_bounding_box() else: self.add_to_selection(mob) - self.refresh_static_mobjects() def clear_selection(self): for mob in self.selection: mob.set_animating_status(False) mob.refresh_bounding_box() self.selection.set_submobjects([]) - self.refresh_static_mobjects() def disable_interaction(self, *mobjects: Mobject): for mob in mobjects: diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index a37b057e..3d933970 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -189,14 +189,13 @@ class Scene(object): "Press `command + q` or `esc` to quit" ) self.skip_animations = False - self.refresh_static_mobjects() while not self.is_window_closing(): self.update_frame(1 / self.camera.fps) def embed( self, close_scene_on_exit: bool = True, - show_animation_progress: bool = True, + show_animation_progress: bool = False, ) -> None: if not self.preview: return # Embed is only relevant with a preview @@ -251,7 +250,6 @@ class Scene(object): # Operation to run after each ipython command def post_cell_func(): - self.refresh_static_mobjects() if not self.is_window_closing(): self.update_frame(dt=0, ignore_skipping=True) self.save_state() @@ -562,8 +560,6 @@ class Scene(object): self.real_animation_start_time = time.time() self.virtual_animation_start_time = self.time - self.refresh_static_mobjects() - def post_play(self): if not self.skip_animations: self.file_writer.end_animation() @@ -574,9 +570,6 @@ class Scene(object): self.num_plays += 1 - def refresh_static_mobjects(self) -> None: - self.camera.refresh_static_mobjects() - def begin_animations(self, animations: Iterable[Animation]) -> None: for animation in animations: animation.begin() @@ -651,7 +644,6 @@ class Scene(object): self.emit_frame() if stop_condition is not None and stop_condition(): break - self.refresh_static_mobjects() self.post_play() def hold_loop(self): @@ -711,15 +703,18 @@ class Scene(object): if self.undo_stack: self.redo_stack.append(self.get_state()) self.restore_state(self.undo_stack.pop()) - self.refresh_static_mobjects() def redo(self): if self.redo_stack: self.undo_stack.append(self.get_state()) self.restore_state(self.redo_stack.pop()) - self.refresh_static_mobjects() - def checkpoint_paste(self, skip: bool = False, record: bool = False): + def checkpoint_paste( + self, + skip: bool = False, + record: bool = False, + progress_bar: bool = True + ): """ Used during interactive development to run (or re-run) a block of scene code. @@ -746,21 +741,21 @@ class Scene(object): prev_skipping = self.skip_animations self.skip_animations = skip + prev_progress = self.show_animation_progress + self.show_animation_progress = progress_bar + if record: - # Resize window so rendering happens at the appropriate size - self.window.size = self.camera.get_pixel_shape() - self.window.swap_buffers() - self.update_frame() + self.camera.use_window_fbo(False) self.file_writer.begin_insert() shell.run_cell(pasted) if record: self.file_writer.end_insert() - # Put window back to how it started - self.window.to_default_position() + self.camera.use_window_fbo(True) self.skip_animations = prev_skipping + self.show_animation_progress = prev_progress def checkpoint(self, key: str): self.checkpoint_states[key] = self.get_state() diff --git a/manimlib/scene/scene_file_writer.py b/manimlib/scene/scene_file_writer.py index d17f1dbd..d288e7dd 100644 --- a/manimlib/scene/scene_file_writer.py +++ b/manimlib/scene/scene_file_writer.py @@ -293,13 +293,10 @@ class SceneFileWriter(object): self.write_to_movie = True self.init_output_directories() movie_path = self.get_movie_file_path() - folder, file = os.path.split(movie_path) - scene_name, ext = file.split(".") - n_inserts = len(list(filter( - lambda f: f.startswith(scene_name + "_insert"), - os.listdir(folder) - ))) - self.inserted_file_path = movie_path.replace(".", f"_insert_{n_inserts}.") + count = 0 + while os.path.exists(name := movie_path.replace(".", f"_insert_{count}.")): + count += 1 + self.inserted_file_path = name self.open_movie_pipe(self.inserted_file_path) def end_insert(self): diff --git a/manimlib/shader_wrapper.py b/manimlib/shader_wrapper.py index 8d867128..aa733f8e 100644 --- a/manimlib/shader_wrapper.py +++ b/manimlib/shader_wrapper.py @@ -4,16 +4,23 @@ import copy import os import re +import OpenGL.GL as gl import moderngl import numpy as np +from functools import lru_cache from manimlib.utils.iterables import resize_array from manimlib.utils.shaders import get_shader_code_from_file +from manimlib.utils.shaders import get_shader_program +from manimlib.utils.shaders import image_path_to_texture +from manimlib.utils.shaders import get_texture_id +from manimlib.utils.shaders import get_fill_palette +from manimlib.utils.shaders import release_texture from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import List + from typing import List, Optional # Mobjects that should be rendered with @@ -26,29 +33,60 @@ if TYPE_CHECKING: class ShaderWrapper(object): def __init__( self, + ctx: moderngl.context.Context, vert_data: np.ndarray, - vert_indices: np.ndarray | None = None, - shader_folder: str | None = None, - uniforms: dict[str, float | np.ndarray] | None = None, # A dictionary mapping names of uniform variables - texture_paths: dict[str, str] | None = None, # A dictionary mapping names to filepaths for textures. + vert_indices: Optional[np.ndarray] = None, + shader_folder: Optional[str] = None, + uniforms: Optional[dict[str, float | np.ndarray]] = None, # A dictionary mapping names of uniform variables + texture_paths: Optional[dict[str, str]] = None, # A dictionary mapping names to filepaths for textures. depth_test: bool = False, - use_clip_plane: bool = False, render_primitive: int = moderngl.TRIANGLE_STRIP, - is_fill: bool = False, ): + self.ctx = ctx self.vert_data = vert_data self.vert_indices = (vert_indices or np.zeros(0)).astype(int) self.vert_attributes = vert_data.dtype.names self.shader_folder = shader_folder - self.uniforms = uniforms or dict() - self.texture_paths = texture_paths or dict() + self.uniforms = dict(uniforms or {}) self.depth_test = depth_test - self.use_clip_plane = use_clip_plane - self.render_primitive = str(render_primitive) - self.is_fill = is_fill + self.render_primitive = render_primitive + self.init_program_code() + self.init_program() + if texture_paths is not None: + self.init_textures(texture_paths) self.refresh_id() + self.vbo = None + self.ibo = None + self.vao = None + + def init_program_code(self) -> None: + def get_code(name: str) -> str | None: + return get_shader_code_from_file( + os.path.join(self.shader_folder, f"{name}.glsl") + ) + + self.program_code: dict[str, str | None] = { + "vertex_shader": get_code("vert"), + "geometry_shader": get_code("geom"), + "fragment_shader": get_code("frag"), + } + + def init_program(self): + if not self.shader_folder: + self.program = None + self.vert_format = None + return + self.program = get_shader_program(self.ctx, **self.program_code) + self.vert_format = moderngl.detect_format(self.program, self.vert_attributes) + + def init_textures(self, texture_paths: dict[str, str]): + for name, path in texture_paths.items(): + texture = image_path_to_texture(path, self.ctx) + tid = get_texture_id(texture) + self.uniforms[name] = tid + def __eq__(self, shader_wrapper: ShaderWrapper): return all(( np.all(self.vert_data == shader_wrapper.vert_data), @@ -58,22 +96,20 @@ class ShaderWrapper(object): 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.ctx = self.ctx result.vert_data = self.vert_data.copy() result.vert_indices = self.vert_indices.copy() if self.uniforms: result.uniforms = {key: np.array(value) for key, value in self.uniforms.items()} - if self.texture_paths: - result.texture_paths = dict(self.texture_paths) + result.vao = None + result.vbo = None + result.ibo = None return result def is_valid(self) -> bool: @@ -94,7 +130,6 @@ class ShaderWrapper(object): return "|".join(map(str, [ self.program_id, self.uniforms, - self.texture_paths, self.depth_test, self.render_primitive, ])) @@ -109,29 +144,33 @@ class ShaderWrapper(object): for name in ("vertex", "geometry", "fragment") ))) - def init_program_code(self) -> None: - def get_code(name: str) -> str | None: - return get_shader_code_from_file( - os.path.join(self.shader_folder, f"{name}.glsl") - ) - - self.program_code: dict[str, str | None] = { - "vertex_shader": get_code("vert"), - "geometry_shader": get_code("geom"), - "fragment_shader": get_code("frag"), - } - - def get_program_code(self) -> dict[str, str | None]: - return self.program_code - def replace_code(self, old: str, new: str) -> None: code_map = self.program_code 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.init_program() self.refresh_id() + # Changing context + def use_clip_plane(self): + if "clip_plane" not in self.uniforms: + return False + return any(self.uniforms["clip_plane"]) + + 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 set_ctx_clip_plane(self, enable: bool = True) -> None: + if enable: + gl.glEnable(gl.GL_CLIP_DISTANCE0) + + # Adding data + def combine_with(self, *shader_wrappers: ShaderWrapper) -> ShaderWrapper: if len(shader_wrappers) > 0: data_list = [self.vert_data, *(sw.vert_data for sw in shader_wrappers)] @@ -173,3 +212,86 @@ class ShaderWrapper(object): n_verts = new_n_verts n_points += len(data) return self + + # Related to data and rendering + def pre_render(self): + self.set_ctx_depth_test(self.depth_test) + self.set_ctx_clip_plane(self.use_clip_plane()) + self.update_program_uniforms() + + def render(self): + assert(self.vao is not None) + self.vao.render() + + def update_program_uniforms(self): + if self.program is None: + return + for name, value in self.uniforms.items(): + if name in self.program: + if isinstance(value, np.ndarray) and value.ndim > 0: + value = tuple(value) + self.program[name].value = value + + def get_vertex_buffer_object(self, refresh: bool = True): + if refresh: + self.vbo = self.ctx.buffer(self.vert_data) + return self.vbo + + def get_index_buffer_object(self, refresh: bool = True): + if refresh and len(self.vert_indices) > 0: + self.ibo = self.ctx.buffer(self.vert_indices.astype(np.uint32)) + return self.ibo + + def generate_vao(self, refresh: bool = True): + self.release() + # Data buffer + vbo = self.get_vertex_buffer_object(refresh) + ibo = self.get_index_buffer_object(refresh) + # Vertex array object + self.vao = self.ctx.vertex_array( + program=self.program, + content=[(vbo, self.vert_format, *self.vert_attributes)], + index_buffer=ibo, + mode=self.render_primitive, + ) + return self.vao + + def release(self): + for obj in (self.vbo, self.ibo, self.vao): + if obj is not None: + obj.release() + self.vbo = None + self.ibo = None + self.vao = None + + +class FillShaderWrapper(ShaderWrapper): + def __init__( + self, + ctx: moderngl.context.Context, + *args, + **kwargs + ): + super().__init__(ctx, *args, **kwargs) + + + def render(self): + vao = self.vao + assert(vao is not None) + winding = (len(self.vert_indices) == 0) + vao.program['winding'].value = winding + if not winding: + vao.render(moderngl.TRIANGLES) + return + + original_fbo = self.ctx.fbo + texture_fbo, texture_vao = get_fill_palette(self.ctx) + + texture_fbo.clear() + texture_fbo.use() + vao.render() + + original_fbo.use() + self.ctx.blend_func = (moderngl.ONE, moderngl.ONE_MINUS_SRC_ALPHA) + texture_vao.render(moderngl.TRIANGLE_STRIP) + self.ctx.blend_func = (moderngl.DEFAULT_BLENDING) diff --git a/manimlib/shaders/quadratic_bezier_fill/frag.glsl b/manimlib/shaders/quadratic_bezier_fill/frag.glsl index c672455a..352ad949 100644 --- a/manimlib/shaders/quadratic_bezier_fill/frag.glsl +++ b/manimlib/shaders/quadratic_bezier_fill/frag.glsl @@ -6,20 +6,41 @@ in vec4 color; in float fill_all; in float orientation; in vec2 uv_coords; +in vec3 point; out vec4 frag_color; +#INSERT finalize_color.glsl + void main() { if (color.a == 0) discard; - frag_color = color; + frag_color = finalize_color(color, point, vec3(0.0, 0.0, 1.0)); + /* + We want negatively oriented triangles to be canceled with positively + oriented ones. The easiest way to do this is to give them negative alpha, + and change the blend function to just add them. However, this messes with + usual blending, so instead the following line is meant to let this canceling + work even for the normal blending equation: - if(winding && orientation > 0) frag_color *= -1; + (1 - alpha) * dst + alpha * src + + We want the effect of blending with a positively oriented triangle followed + by a negatively oriented one to return to whatever the original frag value + was. You can work out this will work if the alpha for negative orientations + is changed to -alpha / (1 - alpha). This has a singularity at alpha = 1, + so we cap it at a value very close to 1. Effectively, the purpose of this + cap is to make sure the original fragment color can be recovered even after + blending with an alpha = 1 color. + */ + float a = 0.999 * frag_color.a; + if(winding && orientation < 0) a = -a / (1 - a); + frag_color.a = a; if (bool(fill_all)) return; float x = uv_coords.x; float y = uv_coords.y; float Fxy = (y - x * x); - if(!winding && orientation > 0) Fxy *= -1; + if(!winding && orientation < 0) Fxy *= -1; if(Fxy < 0) discard; } diff --git a/manimlib/shaders/quadratic_bezier_fill/geom.glsl b/manimlib/shaders/quadratic_bezier_fill/geom.glsl index fae7998b..242f8621 100644 --- a/manimlib/shaders/quadratic_bezier_fill/geom.glsl +++ b/manimlib/shaders/quadratic_bezier_fill/geom.glsl @@ -13,6 +13,7 @@ in float v_vert_index[3]; out vec4 color; out float fill_all; out float orientation; +out vec3 point; // uv space is where the curve coincides with y = x^2 out vec2 uv_coords; @@ -26,16 +27,18 @@ const vec2 SIMPLE_QUADRATIC[3] = vec2[3]( // Analog of import for manim only #INSERT get_gl_Position.glsl #INSERT get_unit_normal.glsl -#INSERT finalize_color.glsl void emit_triangle(vec3 points[3], vec4 v_color[3]){ vec3 unit_normal = get_unit_normal(points[0], points[1], points[2]); - orientation = sign(unit_normal.z); + orientation = winding ? sign(unit_normal.z) : 1.0; for(int i = 0; i < 3; i++){ uv_coords = SIMPLE_QUADRATIC[i]; - color = finalize_color(v_color[i], points[i], unit_normal); + color = v_color[i]; + point = points[i]; + // Pure black will be used to discard fragments later + if(winding && color.rgb == vec3(0.0)) color.rgb += vec3(0.01); gl_Position = get_gl_Position(points[i]); EmitVertex(); } diff --git a/manimlib/utils/shaders.py b/manimlib/utils/shaders.py index 2f71aefa..1556015b 100644 --- a/manimlib/utils/shaders.py +++ b/manimlib/utils/shaders.py @@ -3,17 +3,66 @@ from __future__ import annotations import os import re from functools import lru_cache +import moderngl +from PIL import Image +import numpy as np +from manimlib.constants import DEFAULT_PIXEL_HEIGHT +from manimlib.constants import DEFAULT_PIXEL_WIDTH from manimlib.utils.directories import get_shader_dir from manimlib.utils.file_ops import find_file from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Sequence + from typing import Sequence, Optional, Tuple + from moderngl.vertex_array import VertexArray + from moderngl.framebuffer import Framebuffer -@lru_cache(maxsize=12) +ID_TO_TEXTURE: dict[int, moderngl.Texture] = dict() + + +@lru_cache() +def image_path_to_texture(path: str, ctx: moderngl.Context) -> moderngl.Texture: + im = Image.open(path).convert("RGBA") + return ctx.texture( + size=im.size, + components=len(im.getbands()), + data=im.tobytes(), + ) + + +def get_texture_id(texture: moderngl.Texture) -> int: + tid = 0 + while tid in ID_TO_TEXTURE: + tid += 1 + ID_TO_TEXTURE[tid] = texture + texture.use(location=tid) + return tid + + +def release_texture(texture_id: int): + texture = ID_TO_TEXTURE.pop(texture_id, None) + if texture is not None: + texture.release() + + +@lru_cache() +def get_shader_program( + ctx: moderngl.context.Context, + vertex_shader: str, + fragment_shader: Optional[str] = None, + geometry_shader: Optional[str] = None, + ) -> moderngl.Program: + return ctx.program( + vertex_shader=vertex_shader, + fragment_shader=fragment_shader, + geometry_shader=geometry_shader, + ) + + +@lru_cache() def get_shader_code_from_file(filename: str) -> str | None: if not filename: return None @@ -49,3 +98,70 @@ def get_colormap_code(rgb_list: Sequence[float]) -> str: for rgb in rgb_list ) return f"vec3[{len(rgb_list)}]({data})" + + + +@lru_cache() +def get_fill_palette(ctx) -> Tuple[Framebuffer, VertexArray]: + """ + Creates a texture, loaded into a frame buffer, and a vao + which can display that texture as a simple quad onto a screen. + """ + size = (2 * DEFAULT_PIXEL_WIDTH, 2 * DEFAULT_PIXEL_HEIGHT) + # Important to make sure dtype is floating point (not fixed point) + # so that alpha values can be negative and are not clipped + texture = ctx.texture(size=size, components=4, dtype='f4') + depth_buffer = ctx.depth_renderbuffer(size) # TODO, currently not used + texture_fbo = ctx.framebuffer(texture, depth_buffer) + + simple_program = ctx.program( + vertex_shader=''' + #version 330 + + in vec2 texcoord; + out vec2 v_textcoord; + + void main() { + gl_Position = vec4((2.0 * texcoord - 1.0), 0.0, 1.0); + v_textcoord = texcoord; + } + ''', + fragment_shader=''' + #version 330 + + uniform sampler2D Texture; + uniform float v_nudge; + uniform float h_nudge; + + in vec2 v_textcoord; + out vec4 frag_color; + + void main() { + // Apply poor man's anti-aliasing + vec2 tc0 = v_textcoord + vec2(0, 0); + vec2 tc1 = v_textcoord + vec2(0, h_nudge); + vec2 tc2 = v_textcoord + vec2(v_nudge, 0); + vec2 tc3 = v_textcoord + vec2(v_nudge, h_nudge); + frag_color = + 0.25 * texture(Texture, tc0) + + 0.25 * texture(Texture, tc1) + + 0.25 * texture(Texture, tc2) + + 0.25 * texture(Texture, tc3); + if(distance(frag_color.rgb, vec3(0.0)) < 1e-3) discard; + //TODO, set gl_FragDepth; + } + ''', + ) + + simple_program['Texture'].value = get_texture_id(texture) + # Half pixel width/height + simple_program['h_nudge'].value = 0.5 / size[0] + simple_program['v_nudge'].value = 0.5 / size[1] + + verts = np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) + fill_texture_vao = ctx.simple_vertex_array( + simple_program, + ctx.buffer(verts.astype('f4').tobytes()), + 'texcoord', + ) + return (texture_fbo, fill_texture_vao) diff --git a/manimlib/window.py b/manimlib/window.py index 93337654..8006b382 100644 --- a/manimlib/window.py +++ b/manimlib/window.py @@ -26,12 +26,10 @@ class Window(PygletWindow): self, scene: Scene, size: tuple[int, int] = (1280, 720), - full_size: tuple[int, int] = (1920, 1080), samples = 0 ): - super().__init__(size=full_size, samples=samples) + super().__init__(size=size, samples=samples) - self.full_size = full_size self.default_size = size self.default_position = self.find_initial_position(size) self.scene = scene