diff --git a/manimlib/__init__.py b/manimlib/__init__.py index 063cfa62..ca4d4185 100644 --- a/manimlib/__init__.py +++ b/manimlib/__init__.py @@ -73,6 +73,7 @@ from manimlib.utils.iterables import * from manimlib.utils.paths import * from manimlib.utils.rate_functions import * from manimlib.utils.simple_functions import * +from manimlib.utils.shaders import * from manimlib.utils.sounds import * from manimlib.utils.space_ops import * from manimlib.utils.tex import * diff --git a/manimlib/animation/composition.py b/manimlib/animation/composition.py index 9e6beac4..a1c0ed37 100644 --- a/manimlib/animation/composition.py +++ b/manimlib/animation/composition.py @@ -6,6 +6,8 @@ from manimlib.animation.animation import Animation from manimlib.animation.animation import prepare_animation from manimlib.mobject.mobject import _AnimationBuilder from manimlib.mobject.mobject import Group +from manimlib.mobject.types.vectorized_mobject import VGroup +from manimlib.mobject.types.vectorized_mobject import VMobject from manimlib.utils.bezier import integer_interpolate from manimlib.utils.bezier import interpolate from manimlib.utils.iterables import remove_list_redundancies @@ -14,7 +16,7 @@ from manimlib.utils.simple_functions import clip from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Callable + from typing import Callable, Optional from manimlib.mobject.mobject import Mobject from manimlib.scene.scene import Scene @@ -28,8 +30,8 @@ class AnimationGroup(Animation): *animations: Animation | _AnimationBuilder, run_time: float = -1, # If negative, default to sum of inputed animation runtimes lag_ratio: float = 0.0, - group: Mobject | None = None, - group_type: type = Group, + group: Optional[Mobject] = None, + group_type: Optional[type] = None, **kwargs ): self.animations = [prepare_animation(anim) for anim in animations] @@ -37,11 +39,15 @@ class AnimationGroup(Animation): self.max_end_time = max((awt[2] for awt in self.anims_with_timings), default=0) self.run_time = self.max_end_time if run_time < 0 else run_time self.lag_ratio = lag_ratio - self.group = group - if self.group is None: - self.group = group_type(*remove_list_redundancies( - [anim.mobject for anim in self.animations] - )) + mobs = remove_list_redundancies([a.mobject for a in self.animations]) + if group is not None: + self.group = group + if group_type is not None: + self.group = group_type(*mobs) + elif all(isinstance(anim.mobject, VMobject) for anim in animations): + self.group = VGroup(*mobs) + else: + self.group = Group(*mobs) super().__init__( self.group, diff --git a/manimlib/animation/creation.py b/manimlib/animation/creation.py index cec23ab0..4ad116bd 100644 --- a/manimlib/animation/creation.py +++ b/manimlib/animation/creation.py @@ -100,7 +100,8 @@ class DrawBorderThenFill(Animation): def begin(self) -> None: # Trigger triangulation calculation for submob in self.mobject.get_family(): - submob.get_triangulation() + if not submob._use_winding_fill: + submob.get_triangulation() self.outline = self.get_outline() super().begin() diff --git a/manimlib/animation/indication.py b/manimlib/animation/indication.py index 8275a26a..cd01c359 100644 --- a/manimlib/animation/indication.py +++ b/manimlib/animation/indication.py @@ -265,7 +265,7 @@ class FlashAround(VShowPassingFlash): **kwargs ): path = self.get_path(mobject, buff) - if mobject.is_fixed_in_frame: + if mobject.is_fixed_in_frame(): path.fix_in_frame() path.insert_n_curves(n_inserted_curves) path.set_points(path.get_points_without_null_curves()) diff --git a/manimlib/animation/transform_matching_parts.py b/manimlib/animation/transform_matching_parts.py index 5fc8737b..6f44c16d 100644 --- a/manimlib/animation/transform_matching_parts.py +++ b/manimlib/animation/transform_matching_parts.py @@ -32,7 +32,6 @@ class TransformMatchingParts(AnimationGroup): mismatch_animation: type = Transform, run_time: float = 2, lag_ratio: float = 0, - group_type: type = Group, **kwargs, ): self.source = source @@ -76,7 +75,6 @@ class TransformMatchingParts(AnimationGroup): *self.anims, run_time=run_time, lag_ratio=lag_ratio, - group_type=group_type, ) def add_transform( @@ -151,7 +149,6 @@ class TransformMatchingStrings(TransformMatchingParts): super().__init__( source, target, matched_pairs=matched_pairs, - group_type=VGroup, **kwargs, ) diff --git a/manimlib/camera/camera.py b/manimlib/camera/camera.py index f9906e9e..8d6c0ec3 100644 --- a/manimlib/camera/camera.py +++ b/manimlib/camera/camera.py @@ -17,6 +17,7 @@ from manimlib.utils.color import color_to_rgba from typing import TYPE_CHECKING if TYPE_CHECKING: + from typing import Optional from manimlib.typing import ManimColor, Vect3 from manimlib.window import Window @@ -24,8 +25,8 @@ if TYPE_CHECKING: class Camera(object): def __init__( self, - window: Window | None = None, - background_image: str | None = None, + window: Optional[Window] = None, + background_image: Optional[str] = None, frame_config: dict = dict(), pixel_width: int = DEFAULT_PIXEL_WIDTH, pixel_height: int = DEFAULT_PIXEL_HEIGHT, @@ -62,31 +63,38 @@ class Camera(object): )) self.uniforms = dict() self.init_frame(**frame_config) - self.init_context(window) + self.init_context() + self.init_fbo() self.init_light_source() 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: + def init_context(self) -> None: + if self.window is None: self.ctx = moderngl.create_standalone_context() - self.fbo = self.get_fbo(self.samples) else: - self.ctx = window.ctx - 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.ctx = self.window.ctx self.ctx.enable(moderngl.PROGRAM_POINT_SIZE) self.ctx.enable(moderngl.BLEND) + def init_fbo(self) -> None: + # This is the buffer used when writing to a video/image file + self.fbo_for_files = self.get_fbo(self.samples) + # This is the frame buffer we'll draw into when emitting frames self.draw_fbo = self.get_fbo(samples=0) + if self.window is None: + self.window_fbo = None + self.fbo = self.fbo_for_files + else: + self.window_fbo = self.ctx.detect_framebuffer() + self.fbo = self.window_fbo + + self.fbo.use() + def init_light_source(self) -> None: self.light_source = Point(self.light_source_position) @@ -210,6 +218,7 @@ class Camera(object): # Rendering def capture(self, *mobjects: Mobject) -> None: + self.clear() self.refresh_uniforms() self.fbo.use() for mobject in mobjects: diff --git a/manimlib/mobject/geometry.py b/manimlib/mobject/geometry.py index 7e73b8a0..a8b665d7 100644 --- a/manimlib/mobject/geometry.py +++ b/manimlib/mobject/geometry.py @@ -685,7 +685,6 @@ class Arrow(Line): self.width_to_tip_len = width_to_tip_len self.max_tip_length_to_length_ratio = max_tip_length_to_length_ratio self.max_width_to_length_ratio = max_width_to_length_ratio - self.max_stroke_width = stroke_width super().__init__( start, end, stroke_color=stroke_color, @@ -715,22 +714,21 @@ class Arrow(Line): else: alpha = tip_len / arc_len self.pointwise_become_partial(self, 0, 1 - alpha) - self.start_new_path(self.get_points()[-1]) + # Dumb that this is needed + self.start_new_path(self.point_from_proportion(1 - 1e-5)) self.add_line_to(prev_end) return self + @Mobject.affects_data def create_tip_with_stroke_width(self): - if not self.has_points(): + if self.get_num_points() < 3: return self - width = min( - self.max_stroke_width, + tip_width = self.tip_width_ratio * min( + float(self.get_stroke_width()), self.max_width_to_length_ratio * self.get_length(), ) - widths_array = np.full(self.get_num_points(), width) - if len(widths_array) > 3: - tip_width = self.tip_width_ratio * width - widths_array[-3:] = tip_width * np.linspace(1, 0, 3) - self.set_stroke(width=widths_array) + self.data['stroke_width'][:-3] = self.data['stroke_width'][0] + self.data['stroke_width'][-3:, 0] = tip_width * np.linspace(1, 0, 3) return self def reset_tip(self): @@ -747,13 +745,14 @@ class Arrow(Line): *args, **kwargs ): super().set_stroke(color=color, width=width, *args, **kwargs) - if isinstance(width, numbers.Number): - self.max_stroke_width = width - self.create_tip_with_stroke_width() + if self.has_points(): + self.reset_tip() return self def _handle_scale_side_effects(self, scale_factor: float): - return self.reset_tip() + if scale_factor != 1.0: + self.reset_tip() + return self class FillArrow(Line): diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 313517ee..ebe82af6 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -50,7 +50,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Callable, Iterable, Union, Tuple import numpy.typing as npt - from manimlib.typing import ManimColor, Vect3, Vect4, Vect3Array + from manimlib.typing import ManimColor, Vect3, Vect4, Vect3Array, UniformDict from moderngl.context import Context TimeBasedUpdater = Callable[["Mobject", float], "Mobject" | None] @@ -88,7 +88,7 @@ class Mobject(object): self.opacity = opacity self.shading = shading self.texture_paths = texture_paths - self.is_fixed_in_frame = is_fixed_in_frame + self._is_fixed_in_frame = is_fixed_in_frame self.depth_test = depth_test # Internal state @@ -131,8 +131,8 @@ class Mobject(object): self.data = np.zeros(length, dtype=self.shader_dtype) def init_uniforms(self): - self.uniforms: dict[str, float | np.ndarray] = { - "is_fixed_in_frame": float(self.is_fixed_in_frame), + self.uniforms: UniformDict = { + "is_fixed_in_frame": float(self._is_fixed_in_frame), "shading": np.array(self.shading, dtype=float), } @@ -408,14 +408,15 @@ class Mobject(object): self.assemble_family() return self - def remove(self, *mobjects: Mobject, reassemble: bool = True): - for mobject in mobjects: - if mobject in self.submobjects: - self.submobjects.remove(mobject) - if self in mobject.parents: - mobject.parents.remove(self) - if reassemble: - self.assemble_family() + def remove(self, *to_remove: Mobject, reassemble: bool = True): + for parent in self.get_family(): + for child in to_remove: + if child in parent.submobjects: + parent.submobjects.remove(child) + if parent in child.parents: + child.parents.remove(parent) + if reassemble: + parent.assemble_family() return self def add_to_back(self, *mobjects: Mobject): @@ -591,19 +592,22 @@ class Mobject(object): result._shaders_initialized = False result._data_has_changed = True - @stash_mobject_pointers def copy(self, deep: bool = False): if deep: return self.deepcopy() result = copy.copy(self) - # The line above is only a shallow copy, so the internal - # data which are numpyu arrays or other mobjects still + result.parents = [] + result.target = None + result.saved_state = None + + # copy.copy is only a shallow copy, so the internal + # data which are numpy arrays or other mobjects still # need to be further copied. result.data = self.data.copy() result.uniforms = { - key: np.array(value) + key: value.copy() if isinstance(value, np.ndarray) else value for key, value in self.uniforms.items() } @@ -622,7 +626,7 @@ class Mobject(object): result._data_has_changed = True family = self.get_family() - for attr, value in list(self.__dict__.items()): + for attr, value in self.__dict__.items(): if isinstance(value, Mobject) and value is not self: if value in family: setattr(result, attr, result.family[self.family.index(value)]) @@ -1765,20 +1769,26 @@ class Mobject(object): self.locked_data_keys = set(keys) def lock_matching_data(self, mobject1: Mobject, mobject2: Mobject): - for sm, sm1, sm2 in zip(self.get_family(), mobject1.get_family(), mobject2.get_family()): - if sm.data.dtype == sm1.data.dtype == sm2.data.dtype: - names = sm.data.dtype.names - sm.lock_data(filter( - lambda name: arrays_match(sm1.data[name], sm2.data[name]), - names, - )) - sm.const_data_keys = set(filter( - lambda name: all( - array_is_constant(mob.data[name]) - for mob in (sm, sm1, sm2) - ), - names - )) + tuples = zip( + self.get_family(), + mobject1.get_family(), + mobject2.get_family(), + ) + for sm, sm1, sm2 in tuples: + if not sm.data.dtype == sm1.data.dtype == sm2.data.dtype: + continue + names = sm.data.dtype.names + sm.lock_data(filter( + lambda name: arrays_match(sm1.data[name], sm2.data[name]), + names, + )) + sm.const_data_keys = set(filter( + lambda name: all( + array_is_constant(mob.data[name]) + for mob in (sm, sm1, sm2) + ), + names + )) return self @@ -1799,17 +1809,19 @@ class Mobject(object): return wrapper @affects_shader_info_id - def fix_in_frame(self): - self.uniforms["is_fixed_in_frame"] = 1.0 - self.is_fixed_in_frame = True + def fix_in_frame(self, recurse: bool = True): + for mob in self.get_family(recurse): + mob.uniforms["is_fixed_in_frame"] = 1.0 return self @affects_shader_info_id def unfix_from_frame(self): self.uniforms["is_fixed_in_frame"] = 0.0 - self.is_fixed_in_frame = False return self + def is_fixed_in_frame(self) -> bool: + return bool(self.uniforms["is_fixed_in_frame"]) + @affects_shader_info_id def apply_depth_test(self): self.depth_test = True @@ -1894,7 +1906,7 @@ class Mobject(object): self.shader_wrapper.vert_data = self.get_shader_data() self.shader_wrapper.vert_indices = self.get_shader_vert_indices() - self.shader_wrapper.uniforms.update(self.get_uniforms()) + self.shader_wrapper.update_program_uniforms(self.get_uniforms()) self.shader_wrapper.depth_test = self.depth_test return self.shader_wrapper @@ -1931,8 +1943,9 @@ class Mobject(object): 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.depth_test = self.depth_test + shader_wrapper.update_program_uniforms(self.get_uniforms()) + shader_wrapper.update_program_uniforms(camera_uniforms, universal=True) shader_wrapper.pre_render() shader_wrapper.render() @@ -2048,7 +2061,7 @@ 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): + if any(m.is_fixed_in_frame() for m in mobjects): self.fix_in_frame() def __add__(self, other: Mobject | Group): diff --git a/manimlib/mobject/svg/svg_mobject.py b/manimlib/mobject/svg/svg_mobject.py index d140dd19..cf921085 100644 --- a/manimlib/mobject/svg/svg_mobject.py +++ b/manimlib/mobject/svg/svg_mobject.py @@ -74,6 +74,7 @@ class SVGMobject(VMobject): super().__init__(**kwargs ) self.init_svg_mobject() + self.ensure_positive_orientation() # Rather than passing style into super().__init__ # do it after svg has been taken in @@ -320,10 +321,6 @@ 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, 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() else: diff --git a/manimlib/mobject/types/surface.py b/manimlib/mobject/types/surface.py index 81855bac..faaa764d 100644 --- a/manimlib/mobject/types/surface.py +++ b/manimlib/mobject/types/surface.py @@ -195,6 +195,7 @@ class Surface(Mobject): ).reshape(shape) return points.reshape((nu * nv, *resolution[2:])) + @Mobject.affects_data def sort_faces_back_to_front(self, vect: Vect3 = OUT): tri_is = self.triangle_indices points = self.get_points() diff --git a/manimlib/mobject/types/vectorized_mobject.py b/manimlib/mobject/types/vectorized_mobject.py index e83c76e3..f2c5e452 100644 --- a/manimlib/mobject/types/vectorized_mobject.py +++ b/manimlib/mobject/types/vectorized_mobject.py @@ -62,8 +62,10 @@ class VMobject(Mobject): ('joint_product', np.float32, (4,)), ('fill_rgba', np.float32, (4,)), ('base_point', np.float32, (3,)), + ('unit_normal', np.float32, (3,)), + ('fill_border_width', np.float32, (1,)), ]) - fill_data_names = ['point', 'fill_rgba', 'base_point'] + fill_data_names = ['point', 'fill_rgba', 'base_point', 'unit_normal'] stroke_data_names = ['point', 'stroke_rgba', 'stroke_width', 'joint_product'] fill_render_primitive: int = moderngl.TRIANGLE_STRIP @@ -91,6 +93,7 @@ class VMobject(Mobject): use_simple_quadratic_approx: bool = False, # Measured in pixel widths anti_alias_width: float = 1.0, + fill_border_width: float = 0.5, use_winding_fill: bool = True, **kwargs ): @@ -106,6 +109,7 @@ class VMobject(Mobject): self.flat_stroke = flat_stroke self.use_simple_quadratic_approx = use_simple_quadratic_approx self.anti_alias_width = anti_alias_width + self.fill_border_width = fill_border_width self._use_winding_fill = use_winding_fill self.needs_new_triangulation = True @@ -163,6 +167,7 @@ class VMobject(Mobject): self.set_fill( color=self.fill_color, opacity=self.fill_opacity, + border_width=self.fill_border_width, ) self.set_stroke( color=self.stroke_color, @@ -194,9 +199,13 @@ class VMobject(Mobject): self, color: ManimColor | Iterable[ManimColor] = None, opacity: float | Iterable[float] | None = None, + border_width: float | None = None, recurse: bool = True ): self.set_rgba_array_by_color(color, opacity, 'fill_rgba', recurse) + if border_width is not None: + for mob in self.get_family(recurse): + mob.data["fill_border_width"] = border_width return self def set_stroke( @@ -669,6 +678,8 @@ class VMobject(Mobject): def append_vectorized_mobject(self, vmobject: VMobject): self.add_subpath(vmobject.get_points()) + n = vmobject.get_num_points() + self.data[-n:] = vmobject.data return self # @@ -825,8 +836,31 @@ class VMobject(Mobject): points[1] - points[0], points[2] - points[1], ) + self.data["unit_normal"][:] = normal return normal + def refresh_unit_normal(self): + self.get_unit_normal() + return self + + def rotate( + self, + angle: float, + axis: Vect3 = OUT, + about_point: Vect3 | None = None, + **kwargs + ): + super().rotate(angle, axis, about_point, **kwargs) + for mob in self.get_family(): + mob.refresh_unit_normal() + return self + + def ensure_positive_orientation(self, recurse=True): + for mob in self.get_family(recurse): + if mob.get_unit_normal()[2] < 0: + mob.reverse_points() + return self + # Alignment def align_points(self, vmobject: VMobject): winding = self._use_winding_fill and vmobject._use_winding_fill @@ -1056,8 +1090,6 @@ class VMobject(Mobject): inner_tri_indices = iti[~(null1 | null2).repeat(3)] 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 @@ -1140,6 +1172,7 @@ class VMobject(Mobject): self.refresh_triangulation() if refresh_joints: self.get_joint_products(refresh=True) + self.get_unit_normal() return self @triggers_refreshed_triangulation @@ -1149,14 +1182,15 @@ class VMobject(Mobject): return self @triggers_refreshed_triangulation - def reverse_points(self): + def reverse_points(self, recurse: bool = True): # This will reset which anchors are # considered path ends - for mob in self.get_family(): + for mob in self.get_family(recurse): if not mob.has_points(): continue inner_ends = mob.get_subpath_end_indices()[:-1] mob.data["point"][inner_ends + 1] = mob.data["point"][inner_ends + 2] + mob.data["unit_normal"] *= -1 super().reverse_points() return self @@ -1235,24 +1269,33 @@ class VMobject(Mobject): # Build up data lists fill_datas = [] + fill_border_datas = [] fill_indices = [] stroke_datas = [] - back_stroke_data = [] + back_stroke_datas = [] for submob in family: - if submob.has_fill(): - submob.data["base_point"][:] = submob.data["point"][0] - fill_datas.append(submob.data[fill_names]) + submob.get_joint_products() + has_fill = submob.has_fill() + has_stroke = submob.has_stroke() + if has_fill: + data = submob.data[fill_names] + data["base_point"][:] = data["point"][0] + fill_datas.append(data) if self._use_winding_fill: # Add dummy - fill_datas.append(submob.data[fill_names][-1:]) + fill_datas.append(data[-1:]) else: fill_indices.append(submob.get_triangulation()) - if submob.has_stroke(): - submob.get_joint_products() - if submob.stroke_behind: - lst = back_stroke_data - else: - lst = stroke_datas + # Add fill border + if not has_stroke: + names = list(stroke_names) + names[names.index('stroke_rgba')] = 'fill_rgba' + names[names.index('stroke_width')] = 'fill_border_width' + border_stroke_data = submob.data[names] + fill_border_datas.append(border_stroke_data) + fill_border_datas.append(border_stroke_data[-1:]) + if has_stroke: + lst = back_stroke_datas if submob.stroke_behind else stroke_datas lst.append(submob.data[stroke_names]) # Set data array to be one longer than number of points, # with a dummy vertex added at the end. This is to ensure @@ -1260,15 +1303,14 @@ class VMobject(Mobject): lst.append(submob.data[stroke_names][-1:]) shader_wrappers = [ - self.back_stroke_shader_wrapper.read_in(back_stroke_data), + self.back_stroke_shader_wrapper.read_in( + [*back_stroke_datas, *fill_border_datas] + ), self.fill_shader_wrapper.read_in(fill_datas, fill_indices or None), self.stroke_shader_wrapper.read_in(stroke_datas), ] - - for sw in shader_wrappers: - # Assume uniforms of the first family member - sw.uniforms.update(family[0].get_uniforms()) - sw.depth_test = family[0].depth_test + # TODO, account for submob uniforms separately? + self.uniforms.update(family[0].uniforms) return [sw for sw in shader_wrappers if len(sw.vert_data) > 0] diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 3d933970..5c29d3e6 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -288,13 +288,11 @@ class Scene(object): def get_image(self) -> Image: if self.window is not None: - self.window.size = self.camera.get_pixel_shape() - self.window.swap_buffers() - self.update_frame() - self.window.swap_buffers() + self.camera.use_window_fbo(False) + self.camera.capture(*self.mobjects) image = self.camera.get_image() if self.window is not None: - self.window.to_default_position() + self.camera.use_window_fbo(True) return image def show(self) -> None: @@ -312,7 +310,6 @@ class Scene(object): if self.window: self.window.clear() - self.camera.clear() self.camera.capture(*self.mobjects) if self.window: diff --git a/manimlib/shader_wrapper.py b/manimlib/shader_wrapper.py index aa733f8e..434f1802 100644 --- a/manimlib/shader_wrapper.py +++ b/manimlib/shader_wrapper.py @@ -7,20 +7,20 @@ 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 get_fill_canvas from manimlib.utils.shaders import release_texture from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import List, Optional + from typing import List, Optional, Dict + from manimlib.typing import UniformDict # Mobjects that should be rendered with @@ -37,7 +37,7 @@ class ShaderWrapper(object): vert_data: np.ndarray, 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 + uniforms: Optional[UniformDict] = 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, render_primitive: int = moderngl.TRIANGLE_STRIP, @@ -47,20 +47,18 @@ class ShaderWrapper(object): 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 = dict(uniforms or {}) + self.uniforms: UniformDict = dict() self.depth_test = depth_test self.render_primitive = render_primitive self.init_program_code() self.init_program() + self.update_program_uniforms(uniforms or dict()) if texture_paths is not None: self.init_textures(texture_paths) + self.init_vao() 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( @@ -82,10 +80,16 @@ class ShaderWrapper(object): 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 + names_to_ids = { + name: get_texture_id(image_path_to_texture(path, self.ctx)) + for name, path in texture_paths.items() + } + self.update_program_uniforms(names_to_ids) + + def init_vao(self): + self.vbo = None + self.ibo = None + self.vao = None def __eq__(self, shader_wrapper: ShaderWrapper): return all(( @@ -93,7 +97,7 @@ class ShaderWrapper(object): 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]) + self.uniforms[key] == shader_wrapper.uniforms[key] for key in self.uniforms ), self.depth_test == shader_wrapper.depth_test, @@ -105,11 +109,7 @@ class ShaderWrapper(object): 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()} - result.vao = None - result.vbo = None - result.ibo = None + result.init_vao() return result def is_valid(self) -> bool: @@ -217,20 +217,23 @@ class ShaderWrapper(object): 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): + def update_program_uniforms(self, uniforms: UniformDict, universal: bool = False): 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 + for name, value in uniforms.items(): + if name not in self.program: + continue + if isinstance(value, np.ndarray) and value.ndim > 0: + value = tuple(value) + if universal and self.uniforms.get(name, None) == value: + continue + self.program[name].value = value + self.uniforms[name] = value def get_vertex_buffer_object(self, refresh: bool = True): if refresh: @@ -245,8 +248,9 @@ class ShaderWrapper(object): 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) + vbo = self.vbo = self.get_vertex_buffer_object(refresh) + ibo = self.ibo = self.get_index_buffer_object(refresh) + # Vertex array object self.vao = self.ctx.vertex_array( program=self.program, @@ -273,7 +277,7 @@ class FillShaderWrapper(ShaderWrapper): **kwargs ): super().__init__(ctx, *args, **kwargs) - + self.fill_canvas = get_fill_canvas(self.ctx) def render(self): vao = self.vao @@ -285,13 +289,26 @@ class FillShaderWrapper(ShaderWrapper): return original_fbo = self.ctx.fbo - texture_fbo, texture_vao = get_fill_palette(self.ctx) + texture_fbo, texture_vao, null_rgb = self.fill_canvas - texture_fbo.clear() + texture_fbo.clear(*null_rgb, 0.0) texture_fbo.use() - vao.render() + gl.glBlendFuncSeparate( + # Ordinary blending for colors + gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA, + # Just take the max of the alphas, given the shenanigans + # with how alphas are being used to compute winding numbers + gl.GL_ONE, gl.GL_ONE, + ) + gl.glBlendEquationSeparate(gl.GL_FUNC_ADD, gl.GL_MAX) + self.ctx.blend_equation = moderngl.FUNC_ADD, moderngl.MAX + + vao.render(moderngl.TRIANGLE_STRIP) original_fbo.use() - self.ctx.blend_func = (moderngl.ONE, moderngl.ONE_MINUS_SRC_ALPHA) + gl.glBlendFunc(gl.GL_ONE, gl.GL_ONE_MINUS_SRC_ALPHA) + gl.glBlendEquation(gl.GL_FUNC_ADD) + texture_vao.render(moderngl.TRIANGLE_STRIP) - self.ctx.blend_func = (moderngl.DEFAULT_BLENDING) + + gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) diff --git a/manimlib/shaders/quadratic_bezier_fill/frag.glsl b/manimlib/shaders/quadratic_bezier_fill/frag.glsl index 352ad949..199efbec 100644 --- a/manimlib/shaders/quadratic_bezier_fill/frag.glsl +++ b/manimlib/shaders/quadratic_bezier_fill/frag.glsl @@ -7,6 +7,7 @@ in float fill_all; in float orientation; in vec2 uv_coords; in vec3 point; +in vec3 unit_normal; out vec4 frag_color; @@ -14,7 +15,7 @@ out vec4 frag_color; void main() { if (color.a == 0) discard; - frag_color = finalize_color(color, point, vec3(0.0, 0.0, 1.0)); + frag_color = finalize_color(color, point, unit_normal); /* We want negatively oriented triangles to be canceled with positively oriented ones. The easiest way to do this is to give them negative alpha, @@ -30,9 +31,9 @@ void main() { 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. + blending with an (alpha = 1) color. */ - float a = 0.999 * frag_color.a; + float a = 0.99 * frag_color.a; if(winding && orientation < 0) a = -a / (1 - a); frag_color.a = a; diff --git a/manimlib/shaders/quadratic_bezier_fill/geom.glsl b/manimlib/shaders/quadratic_bezier_fill/geom.glsl index 242f8621..462f00aa 100644 --- a/manimlib/shaders/quadratic_bezier_fill/geom.glsl +++ b/manimlib/shaders/quadratic_bezier_fill/geom.glsl @@ -9,11 +9,13 @@ in vec3 verts[3]; in vec4 v_color[3]; in vec3 v_base_point[3]; in float v_vert_index[3]; +in vec3 v_unit_normal[3]; out vec4 color; out float fill_all; out float orientation; out vec3 point; +out vec3 unit_normal; // uv space is where the curve coincides with y = x^2 out vec2 uv_coords; @@ -26,19 +28,19 @@ const vec2 SIMPLE_QUADRATIC[3] = vec2[3]( // Analog of import for manim only #INSERT get_gl_Position.glsl -#INSERT get_unit_normal.glsl void emit_triangle(vec3 points[3], vec4 v_color[3]){ - vec3 unit_normal = get_unit_normal(points[0], points[1], points[2]); - orientation = winding ? sign(unit_normal.z) : 1.0; + orientation = sign(determinant(mat3( + unit_normal, + points[1] - points[0], + points[2] - points[0] + ))); for(int i = 0; i < 3; i++){ uv_coords = SIMPLE_QUADRATIC[i]; 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(); } @@ -63,7 +65,8 @@ void main(){ // the first anchor is set equal to that anchor if (verts[0] == verts[1]) return; - vec3 mid_vert; + unit_normal = v_unit_normal[1]; + if(winding){ // Emit main triangle fill_all = 1.0; diff --git a/manimlib/shaders/quadratic_bezier_fill/vert.glsl b/manimlib/shaders/quadratic_bezier_fill/vert.glsl index 5818216a..a15752c4 100644 --- a/manimlib/shaders/quadratic_bezier_fill/vert.glsl +++ b/manimlib/shaders/quadratic_bezier_fill/vert.glsl @@ -3,15 +3,18 @@ in vec3 point; in vec4 fill_rgba; in vec3 base_point; +in vec3 unit_normal; out vec3 verts; // Bezier control point out vec4 v_color; out vec3 v_base_point; +out vec3 v_unit_normal; out float v_vert_index; void main(){ verts = point; v_color = fill_rgba; v_base_point = base_point; + v_unit_normal = unit_normal; v_vert_index = gl_VertexID; } \ No newline at end of file diff --git a/manimlib/shaders/quadratic_bezier_stroke/geom.glsl b/manimlib/shaders/quadratic_bezier_stroke/geom.glsl index 96e3f1b2..201ab4ab 100644 --- a/manimlib/shaders/quadratic_bezier_stroke/geom.glsl +++ b/manimlib/shaders/quadratic_bezier_stroke/geom.glsl @@ -49,6 +49,12 @@ vec3 get_joint_unit_normal(vec4 joint_product){ } +vec4 normalized_joint_product(vec4 joint_product){ + float norm = length(joint_product); + return (norm > 1e-10) ? joint_product / norm : vec4(0.0, 0.0, 0.0, 1.0); +} + + void create_joint( vec4 joint_product, vec3 unit_tan, @@ -78,6 +84,25 @@ void create_joint( changing_c1 = static_c1 + shift * unit_tan; } +vec3 get_perp(int index, vec4 joint_product, vec3 point, vec3 tangent, float aaw){ + /* + Perpendicular vectors to the left of the curve + */ + float buff = 0.5 * v_stroke_width[index] + aaw; + // Add correction for sharp angles to prevent weird bevel effects + if(joint_product.w < -0.9) buff *= 10 * (joint_product.w + 1.0); + vec3 normal = get_joint_unit_normal(joint_product); + // Set global unit normal + unit_normal = normal; + // Choose the "outward" normal direction + if(normal.z < 0) normal *= -1; + if(bool(flat_stroke)){ + return buff * normalize(cross(normal, tangent)); + }else{ + return buff * normalize(cross(camera_position - point, tangent)); + } +} + // This function is responsible for finding the corners of // a bounding region around the bezier curve, which can be // emitted as a triangle fan, with vertices vaguely close @@ -95,40 +120,15 @@ void get_corners( float aaw, out vec3 corners[6] ){ - - float buff0 = 0.5 * v_stroke_width[0] + aaw; - float buff2 = 0.5 * v_stroke_width[2] + aaw; - - vec4 jp0 = normalize(v_joint_product[0]); - vec4 jp2 = normalize(v_joint_product[2]); - - // Add correction for sharp angles to prevent weird bevel effects - if(jp0.w < -0.9) buff0 *= 10 * (jp0.w + 1.0); - if(jp2.w < -0.9) buff2 *= 10 * (jp2.w + 1.0); - - // Unit normal and joint angles - vec3 normal0 = get_joint_unit_normal(jp0); - vec3 normal2 = get_joint_unit_normal(jp2); - // Set global unit normal - unit_normal = normal0; - - // Choose the "outward" normal direction - normal0 *= sign(normal0.z); - normal2 *= sign(normal2.z); - - vec3 p0_perp; - vec3 p2_perp; - if(bool(flat_stroke)){ - // Perpendicular vectors to the left of the curve - p0_perp = buff0 * normalize(cross(normal0, v01)); - p2_perp = buff2 * normalize(cross(normal2, v12)); - }else{ - // p0_perp = buff0 * normal0; - // p2_perp = buff2 * normal2; - p0_perp = buff0 * normalize(cross(camera_position - p0, v01)); - p2_perp = buff2 * normalize(cross(camera_position - p2, v12)); - } + bool linear = bool(is_linear); + vec4 jp0 = normalized_joint_product(v_joint_product[0]); + vec4 jp2 = normalized_joint_product(v_joint_product[2]); + vec3 p0_perp = get_perp(0, jp0, p0, v01, aaw); + vec3 p2_perp = get_perp(2, jp2, p2, v12, aaw); vec3 p1_perp = 0.5 * (p0_perp + p2_perp); + if(linear){ + p1_perp *= (0.5 * v_stroke_width[1] + aaw) / length(p1_perp); + } // The order of corners should be for a triangle_strip. vec3 c0 = p0 + p0_perp; @@ -139,14 +139,15 @@ void get_corners( vec3 c5 = p2 - p2_perp; // Move the inner middle control point to make // room for the curve - float orientation = dot(normal0, v_joint_product[1].xyz); - if(orientation >= 0.0) c2 = 0.5 * (c0 + c4); - else if(orientation < 0.0) c3 = 0.5 * (c1 + c5); + // float orientation = dot(unit_normal, v_joint_product[1].xyz); + float orientation = v_joint_product[1].z; + if(!linear && orientation >= 0.0) c2 = 0.5 * (c0 + c4); + else if(!linear && orientation < 0.0) c3 = 0.5 * (c1 + c5); // Account for previous and next control points if(bool(flat_stroke)){ - create_joint(jp0, v01, buff0, c1, c1, c0, c0); - create_joint(jp2, -v12, buff2, c5, c5, c4, c4); + create_joint(jp0, v01, length(p0_perp), c1, c1, c0, c0); + create_joint(jp2, -v12, length(p2_perp), c5, c5, c4, c4); } corners = vec3[6](c0, c1, c2, c3, c4, c5); @@ -167,8 +168,9 @@ void main() { vec3 v01 = normalize(p1 - p0); vec3 v12 = normalize(p2 - p1); - float cos_angle = normalize(v_joint_product[1]).w; - is_linear = float(cos_angle > COS_THRESHOLD); + + vec4 jp1 = normalized_joint_product(v_joint_product[1]); + is_linear = float(jp1.w > COS_THRESHOLD); // We want to change the coordinates to a space where the curve // coincides with y = x^2, between some values x0 and x2. Or, in @@ -195,7 +197,7 @@ void main() { float sign = vec2(-1, 1)[i % 2]; // In this case, we only really care about // the v coordinate - uv_coords = vec2(0, sign * (0.5 * max_sw + scaled_aaw)); + uv_coords = vec2(0, sign * (0.5 * stroke_width + scaled_aaw)); uv_anti_alias_width = scaled_aaw; uv_stroke_width = stroke_width; }else{ diff --git a/manimlib/typing.py b/manimlib/typing.py index d4b68b3f..6ef9d22e 100644 --- a/manimlib/typing.py +++ b/manimlib/typing.py @@ -19,6 +19,8 @@ if TYPE_CHECKING: ] Selector = Union[SingleSelector, Iterable[SingleSelector]] + UniformDict = Dict[str, float | bool | np.ndarray | tuple] + # These are various alternate names for np.ndarray meant to specify # certain shapes. # diff --git a/manimlib/utils/iterables.py b/manimlib/utils/iterables.py index b57225e8..3b7dbfba 100644 --- a/manimlib/utils/iterables.py +++ b/manimlib/utils/iterables.py @@ -133,7 +133,7 @@ def arrays_match(arr1: np.ndarray, arr2: np.ndarray) -> bool: def array_is_constant(arr: np.ndarray) -> bool: - return len(arr) > 0 and not (arr - arr[0]).any() + return len(arr) > 0 and (arr == arr[0]).all() def hash_obj(obj: object) -> int: diff --git a/manimlib/utils/shaders.py b/manimlib/utils/shaders.py index 1556015b..8d261e49 100644 --- a/manimlib/utils/shaders.py +++ b/manimlib/utils/shaders.py @@ -9,6 +9,7 @@ import numpy as np from manimlib.constants import DEFAULT_PIXEL_HEIGHT from manimlib.constants import DEFAULT_PIXEL_WIDTH +from manimlib.utils.customization import get_customization from manimlib.utils.directories import get_shader_dir from manimlib.utils.file_ops import find_file @@ -102,18 +103,37 @@ def get_colormap_code(rgb_list: Sequence[float]) -> str: @lru_cache() -def get_fill_palette(ctx) -> Tuple[Framebuffer, VertexArray]: +def get_fill_canvas(ctx) -> Tuple[Framebuffer, VertexArray, Tuple[float, float, float]]: """ - Creates a texture, loaded into a frame buffer, and a vao - which can display that texture as a simple quad onto a screen. + Because VMobjects with fill are rendered in a funny way, using + alpha blending to effectively compute the winding number around + each pixel, they need to be rendered to a separate texture, which + is then composited onto the ordinary frame buffer. + + This returns a texture, loaded into a frame buffer, and a vao + which can display that texture as a simple quad onto a screen, + along with the rgb value which is meant to be discarded. """ - size = (2 * DEFAULT_PIXEL_WIDTH, 2 * DEFAULT_PIXEL_HEIGHT) + cam_config = get_customization()['camera_resolutions'] + res_name = cam_config['default_resolution'] + size = tuple(map(int, cam_config[res_name].split("x"))) + # 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') + texture = ctx.texture(size=size, components=4, dtype='f2') depth_buffer = ctx.depth_renderbuffer(size) # TODO, currently not used texture_fbo = ctx.framebuffer(texture, depth_buffer) + # We'll paint onto a canvas with initially negative rgbs, and + # discard any pixels remaining close to this value. This is + # because alphas are effectively being used for another purpose, + # and we don't want to overlap with any colors one might actually + # use. It should be negative enough to be distinguishable from + # ordinary colors with some margin, but the farther it's pulled back + # from zero the more it will be true that overlapping filled objects + # with transparency have an unnaturally bright composition. + null_rgb = (-0.25, -0.25, -0.25) + simple_program = ctx.program( vertex_shader=''' #version 330 @@ -130,33 +150,27 @@ def get_fill_palette(ctx) -> Tuple[Framebuffer, VertexArray]: #version 330 uniform sampler2D Texture; - uniform float v_nudge; - uniform float h_nudge; + uniform vec3 null_rgb; in vec2 v_textcoord; - out vec4 frag_color; + out vec4 color; + + const float MIN_DIST_TO_NULL = 0.2; 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; + color = texture(Texture, v_textcoord); + if(distance(color.rgb, null_rgb) < MIN_DIST_TO_NULL) discard; + + // Un-blend from the null value + color.rgb -= (1 - color.a) * null_rgb; + //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] + simple_program['null_rgb'].value = null_rgb verts = np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) fill_texture_vao = ctx.simple_vertex_array( @@ -164,4 +178,4 @@ def get_fill_palette(ctx) -> Tuple[Framebuffer, VertexArray]: ctx.buffer(verts.astype('f4').tobytes()), 'texcoord', ) - return (texture_fbo, fill_texture_vao) + return (texture_fbo, fill_texture_vao, null_rgb)