From 71a4c8c97362478429df8cda36e31addb4a13c39 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Tue, 1 Sep 2026 15:27:37 +0200 Subject: [PATCH 01/13] Package the OpenGL renderer backend --- manim/cli/render/commands.py | 2 +- manim/manager.py | 2 +- manim/mobject/opengl/opengl_mobject.py | 6 +- .../opengl/opengl_vectorized_mobject.py | 2 +- manim/opengl/__init__.py | 2 +- manim/renderer/opengl/__init__.py | 20 + manim/renderer/opengl/renderer.py | 1207 +++++++++++++++++ manim/renderer/{ => opengl}/shader.py | 6 +- manim/renderer/{ => opengl}/shader_wrapper.py | 22 +- .../{ => opengl}/shaders/default/frag.glsl | 0 .../{ => opengl}/shaders/default/vert.glsl | 0 .../renderer/{ => opengl}/shaders/design.frag | 0 .../{ => opengl}/shaders/design_2.frag | 0 .../{ => opengl}/shaders/design_3.frag | 0 .../{ => opengl}/shaders/image/frag.glsl | 0 .../{ => opengl}/shaders/image/vert.glsl | 0 .../{ => opengl}/shaders/include/NOTE.md | 0 .../shaders/include/add_light.glsl | 0 .../include/camera_uniform_declarations.glsl | 0 .../shaders/include/finalize_color.glsl | 0 .../shaders/include/get_gl_Position.glsl | 0 ...et_rotated_surface_unit_normal_vector.glsl | 0 .../shaders/include/get_unit_normal.glsl | 0 .../include/position_point_into_frame.glsl | 0 .../include/quadratic_bezier_distance.glsl | 0 .../quadratic_bezier_geometry_functions.glsl | 0 .../shaders/manim_coords/frag.glsl | 0 .../shaders/manim_coords/vert.glsl | 0 .../shaders/quadratic_bezier_fill/frag.glsl | 0 .../shaders/quadratic_bezier_fill/geom.glsl | 0 .../shaders/quadratic_bezier_fill/vert.glsl | 0 .../shaders/quadratic_bezier_stroke/frag.glsl | 0 .../shaders/quadratic_bezier_stroke/geom.glsl | 0 .../shaders/quadratic_bezier_stroke/vert.glsl | 0 .../{ => opengl}/shaders/simple_vert.glsl | 0 .../{ => opengl}/shaders/surface/frag.glsl | 0 .../{ => opengl}/shaders/surface/vert.glsl | 0 .../{ => opengl}/shaders/test/frag.glsl | 0 .../{ => opengl}/shaders/test/vert.glsl | 0 .../shaders/textured_surface/frag.glsl | 0 .../shaders/textured_surface/vert.glsl | 0 .../{ => opengl}/shaders/true_dot/frag.glsl | 0 .../{ => opengl}/shaders/true_dot/geom.glsl | 0 .../{ => opengl}/shaders/true_dot/vert.glsl | 0 .../shaders/vectorized_mobject_fill/frag.glsl | 0 .../shaders/vectorized_mobject_fill/vert.glsl | 0 .../vectorized_mobject_stroke/frag.glsl | 0 .../vectorized_mobject_stroke/vert.glsl | 0 .../shaders/vertex_colors/frag.glsl | 0 .../shaders/vertex_colors/vert.glsl | 0 .../vectorized_mobject_rendering.py | 11 +- .../window.py} | 4 +- manim/renderer/opengl_renderer.py | 1206 +--------------- manim/scene/scene.py | 6 +- manim/scene/three_d_scene.py | 2 +- manim/scene/zoomed_scene.py | 2 +- manim/utils/caching.py | 2 +- manim/utils/hashing.py | 2 +- manim/utils/ipython_magic.py | 4 +- manim/utils/testing/_test_class_makers.py | 2 +- manim/utils/testing/frames_comparison.py | 2 +- tests/test_graphical_units/test_opengl.py | 2 +- .../opengl/test_opengl_renderer.py | 8 +- 63 files changed, 1276 insertions(+), 1246 deletions(-) create mode 100644 manim/renderer/opengl/__init__.py create mode 100644 manim/renderer/opengl/renderer.py rename manim/renderer/{ => opengl}/shader.py (99%) rename manim/renderer/{ => opengl}/shader_wrapper.py (93%) rename manim/renderer/{ => opengl}/shaders/default/frag.glsl (100%) rename manim/renderer/{ => opengl}/shaders/default/vert.glsl (100%) rename manim/renderer/{ => opengl}/shaders/design.frag (100%) rename manim/renderer/{ => opengl}/shaders/design_2.frag (100%) rename manim/renderer/{ => opengl}/shaders/design_3.frag (100%) rename manim/renderer/{ => opengl}/shaders/image/frag.glsl (100%) rename manim/renderer/{ => opengl}/shaders/image/vert.glsl (100%) rename manim/renderer/{ => opengl}/shaders/include/NOTE.md (100%) rename manim/renderer/{ => opengl}/shaders/include/add_light.glsl (100%) rename manim/renderer/{ => opengl}/shaders/include/camera_uniform_declarations.glsl (100%) rename manim/renderer/{ => opengl}/shaders/include/finalize_color.glsl (100%) rename manim/renderer/{ => opengl}/shaders/include/get_gl_Position.glsl (100%) rename manim/renderer/{ => opengl}/shaders/include/get_rotated_surface_unit_normal_vector.glsl (100%) rename manim/renderer/{ => opengl}/shaders/include/get_unit_normal.glsl (100%) rename manim/renderer/{ => opengl}/shaders/include/position_point_into_frame.glsl (100%) rename manim/renderer/{ => opengl}/shaders/include/quadratic_bezier_distance.glsl (100%) rename manim/renderer/{ => opengl}/shaders/include/quadratic_bezier_geometry_functions.glsl (100%) rename manim/renderer/{ => opengl}/shaders/manim_coords/frag.glsl (100%) rename manim/renderer/{ => opengl}/shaders/manim_coords/vert.glsl (100%) rename manim/renderer/{ => opengl}/shaders/quadratic_bezier_fill/frag.glsl (100%) rename manim/renderer/{ => opengl}/shaders/quadratic_bezier_fill/geom.glsl (100%) rename manim/renderer/{ => opengl}/shaders/quadratic_bezier_fill/vert.glsl (100%) rename manim/renderer/{ => opengl}/shaders/quadratic_bezier_stroke/frag.glsl (100%) rename manim/renderer/{ => opengl}/shaders/quadratic_bezier_stroke/geom.glsl (100%) rename manim/renderer/{ => opengl}/shaders/quadratic_bezier_stroke/vert.glsl (100%) rename manim/renderer/{ => opengl}/shaders/simple_vert.glsl (100%) rename manim/renderer/{ => opengl}/shaders/surface/frag.glsl (100%) rename manim/renderer/{ => opengl}/shaders/surface/vert.glsl (100%) rename manim/renderer/{ => opengl}/shaders/test/frag.glsl (100%) rename manim/renderer/{ => opengl}/shaders/test/vert.glsl (100%) rename manim/renderer/{ => opengl}/shaders/textured_surface/frag.glsl (100%) rename manim/renderer/{ => opengl}/shaders/textured_surface/vert.glsl (100%) rename manim/renderer/{ => opengl}/shaders/true_dot/frag.glsl (100%) rename manim/renderer/{ => opengl}/shaders/true_dot/geom.glsl (100%) rename manim/renderer/{ => opengl}/shaders/true_dot/vert.glsl (100%) rename manim/renderer/{ => opengl}/shaders/vectorized_mobject_fill/frag.glsl (100%) rename manim/renderer/{ => opengl}/shaders/vectorized_mobject_fill/vert.glsl (100%) rename manim/renderer/{ => opengl}/shaders/vectorized_mobject_stroke/frag.glsl (100%) rename manim/renderer/{ => opengl}/shaders/vectorized_mobject_stroke/vert.glsl (100%) rename manim/renderer/{ => opengl}/shaders/vertex_colors/frag.glsl (100%) rename manim/renderer/{ => opengl}/shaders/vertex_colors/vert.glsl (100%) rename manim/renderer/{ => opengl}/vectorized_mobject_rendering.py (97%) rename manim/renderer/{opengl_renderer_window.py => opengl/window.py} (98%) diff --git a/manim/cli/render/commands.py b/manim/cli/render/commands.py index 063c13dcaa..f954545a6b 100644 --- a/manim/cli/render/commands.py +++ b/manim/cli/render/commands.py @@ -95,7 +95,7 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: _validate_scene_batch_output_name(scene_classes) if config.renderer == RendererType.OPENGL: - from manim.renderer.opengl_renderer import OpenGLRenderer + from manim.renderer.opengl import OpenGLRenderer renderer = OpenGLRenderer() keep_running = True diff --git a/manim/manager.py b/manim/manager.py index 4a1acd4de3..a730191bbd 100644 --- a/manim/manager.py +++ b/manim/manager.py @@ -19,7 +19,7 @@ from .camera.camera import Camera from .mobject.mobject import Mobject, _AnimationBuilder from .renderer.cairo_renderer import CairoRenderer - from .renderer.opengl_renderer import OpenGLCamera, OpenGLRenderer + from .renderer.opengl import OpenGLCamera, OpenGLRenderer from .scene.scene import Scene from .scene.scene_file_writer import SceneFileWriter diff --git a/manim/mobject/opengl/opengl_mobject.py b/manim/mobject/opengl/opengl_mobject.py index b8e33da418..57fcbf9f7c 100644 --- a/manim/mobject/opengl/opengl_mobject.py +++ b/manim/mobject/opengl/opengl_mobject.py @@ -34,7 +34,7 @@ from manim import config, logger from manim.constants import * from manim.data_structures import MethodWithArgs -from manim.renderer.shader_wrapper import get_colormap_code +from manim.renderer.opengl.shader_wrapper import get_colormap_code from manim.typing import ( Point3D, Point3D_Array, @@ -72,7 +72,7 @@ if TYPE_CHECKING: from manim.animation.animation import Animation - from manim.renderer.shader_wrapper import ShaderWrapper + from manim.renderer.opengl.shader_wrapper import ShaderWrapper from manim.typing import ( FloatRGB_Array, FloatRGBA_Array, @@ -3013,7 +3013,7 @@ def refresh_shader_wrapper_id(self) -> Self: return self def get_shader_wrapper(self) -> "ShaderWrapper": # noqa: UP037 - from manim.renderer.shader_wrapper import ShaderWrapper + from manim.renderer.opengl.shader_wrapper import ShaderWrapper # if hasattr(self, "shader_wrapper"): # return self.shader_wrapper diff --git a/manim/mobject/opengl/opengl_vectorized_mobject.py b/manim/mobject/opengl/opengl_vectorized_mobject.py index cc1756d198..62a3c3a053 100644 --- a/manim/mobject/opengl/opengl_vectorized_mobject.py +++ b/manim/mobject/opengl/opengl_vectorized_mobject.py @@ -12,7 +12,7 @@ from manim import config from manim.constants import * from manim.mobject.opengl.opengl_mobject import OpenGLMobject, OpenGLPoint -from manim.renderer.shader_wrapper import ShaderWrapper +from manim.renderer.opengl.shader_wrapper import ShaderWrapper from manim.typing import Point3D, Point3DLike, Point3DLike_Array from manim.utils.bezier import ( bezier, diff --git a/manim/opengl/__init__.py b/manim/opengl/__init__.py index e5bad5cd2c..0333865ea5 100644 --- a/manim/opengl/__init__.py +++ b/manim/opengl/__init__.py @@ -14,5 +14,5 @@ from manim.mobject.opengl.opengl_three_dimensions import * from manim.mobject.opengl.opengl_vectorized_mobject import * -from ..renderer.shader import * +from ..renderer.opengl.shader import * from ..utils.opengl import * diff --git a/manim/renderer/opengl/__init__.py b/manim/renderer/opengl/__init__.py new file mode 100644 index 0000000000..37e881b8c1 --- /dev/null +++ b/manim/renderer/opengl/__init__.py @@ -0,0 +1,20 @@ +"""OpenGL rendering backend.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .renderer import OpenGLCamera, OpenGLRenderer + +__all__ = ["OpenGLCamera", "OpenGLRenderer"] + + +def __getattr__(name: str) -> Any: + if name not in __all__: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + from .renderer import OpenGLCamera, OpenGLRenderer + + value = {"OpenGLCamera": OpenGLCamera, "OpenGLRenderer": OpenGLRenderer}[name] + globals()[name] = value + return value diff --git a/manim/renderer/opengl/renderer.py b/manim/renderer/opengl/renderer.py new file mode 100644 index 0000000000..e5dc745ecf --- /dev/null +++ b/manim/renderer/opengl/renderer.py @@ -0,0 +1,1207 @@ +from __future__ import annotations + +import contextlib +import itertools as it +import time +import typing +from functools import cached_property +from typing import TYPE_CHECKING, Any, Self + +import moderngl +import numpy as np +from moderngl import Framebuffer +from PIL import Image +from typing_extensions import override + +from manim import config +from manim.mobject.opengl.opengl_mobject import ( + OpenGLMobject, + OpenGLPoint, +) +from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject +from manim.typing import MatrixMN, Point3D +from manim.utils.caching import handle_caching_play +from manim.utils.color import color_to_rgba +from manim.utils.exceptions import EndSceneEarlyException +from manim.utils.paths import straight_path + +from ...constants import * +from ...scene.scene_file_writer import SceneFileWriter +from ...utils import opengl +from ...utils.simple_functions import clip +from ...utils.space_ops import ( + angle_of_vector, + quaternion_from_angle_axis, + quaternion_mult, + rotation_matrix_transpose, + rotation_matrix_transpose_from_quaternion, +) +from ..protocol import RendererCapabilities +from .shader import Mesh, Shader +from .vectorized_mobject_rendering import ( + render_opengl_vectorized_mobject_fill, + render_opengl_vectorized_mobject_stroke, +) + +if TYPE_CHECKING: + from collections.abc import Iterable + from typing import Self + + from manim._config.render_session import RenderSessionSpec + from manim.animation.animation import Animation + from manim.mobject.mobject import Mobject, _AnimationBuilder + from manim.scene.scene import Scene + from manim.scene.scene_file_writer import _SceneFileWriterSettings + from manim.typing import ( + FloatRGBA, + PathFuncType, + Point3DLike, + RGBAPixelArray, + Vector3DLike, + ) + from manim.utils.color.core import ParsableManimColor + from manim.utils.opengl import FlattenedMatrix4x4 + + from .window import Window + + +__all__ = ["OpenGLCamera", "OpenGLRenderer"] + + +class OpenGLCamera(OpenGLMobject): + """ + An OpenGL-based camera for 3D scene rendering. + + + Attributes + ---------- + frame_shape : tuple[float, float] + The width and height of the camera frame. + center_point : np.ndarray + The center point of the camera in 3D space. + euler_angles : np.ndarray + The Euler angles (theta, phi, gamma) representing the camera's orientation. + focal_distance : float + The focal distance of the camera. + light_source_position : np.ndarray + The position of the light source in 3D space. + orthographic : bool + Whether the camera uses orthographic projection instead of perspective. + minimum_polar_angle : float + The minimum polar angle for camera rotation. + maximum_polar_angle : float + The maximum polar angle for camera rotation. + inverse_rotation_matrix : np.ndarray + The inverse rotation matrix of the camera. + """ + + def __init__( + self, + frame_shape: tuple[float, float] | None = None, + center_point: Point3DLike | None = None, + # Theta, phi, gamma + euler_angles: Point3DLike | None = None, + focal_distance: float = 2.0, + light_source_position: Point3DLike | None = None, + orthographic: bool = False, + minimum_polar_angle: float = -PI / 2, + maximum_polar_angle: float = PI / 2, + model_matrix: MatrixMN | None = None, + **kwargs: Any, + ) -> None: + """ + Initializes an OpenGLCamera instance. + + Parameters + ---------- + frame_shape : tuple[float, float], optional + The width and height of the camera frame. If not provided, defaults to + the global manim config values `frame_width` and `frame_height`. + center_point : Point3DLike, optional + The center point of the camera in 3D space. + If not provided, defaults to the origin (0, 0, 0). + euler_angles : Point3DLike, optional + The Euler angles (theta, phi, gamma) representing the camera's orientation. + If not provided, defaults to (0, 0, 0) (i.e., no rotation). + focal_distance : float, optional + The focal distance of the camera. Default is 2.0. + light_source_position : Point3DLike, optional + The position of the light source in 3D space. + If not provided, defaults to (-10, 10, 10). + orthographic : bool, optional + Whether the camera uses orthographic projection instead of perspective. + Default is False (perspective). + minimum_polar_angle : float, optional + The minimum polar angle in radian for camera rotation. Default is -π/2, + i.e. no restriction. + maximum_polar_angle : float, optional + The maximum polar angle in radian for camera rotation. Default is π/2, + i.e. no restriction. + model_matrix : MatrixMN, optional + The initial model matrix [1]_ for the camera. If not provided, + defaults to a translation matrix that positions the camera at (0, 0, 11). + **kwargs : Any + Additional keyword arguments passed to the OpenGLMobject constructor. + + References + ---------- + .. [1] Wikipedia, "Camera matrix", + https://en.wikipedia.org/wiki/Camera_matrix + """ + self.use_z_index = True + self.frame_rate = 60 + self.orthographic = orthographic + self.minimum_polar_angle = minimum_polar_angle + self.maximum_polar_angle = maximum_polar_angle + if self.orthographic: + self.projection_matrix = opengl.orthographic_projection_matrix() + self.unformatted_projection_matrix = opengl.orthographic_projection_matrix( + format_=False, + ) + else: + self.projection_matrix = opengl.perspective_projection_matrix() + self.unformatted_projection_matrix = opengl.perspective_projection_matrix( + format_=False, + ) + + if frame_shape is None: + self.frame_shape = (config["frame_width"], config["frame_height"]) + else: + self.frame_shape = frame_shape + + if center_point is None: + self.center_point = ORIGIN + else: + self.center_point = np.asarray(center_point, dtype=float) + + if model_matrix is None: + model_matrix = opengl.translation_matrix(0, 0, 11) + + self.focal_distance = focal_distance + + self.light_source_position = np.asarray( + light_source_position or [-10, 10, 10], dtype=float + ) + + self.light_source = OpenGLPoint(self.light_source_position) + + self.default_model_matrix = model_matrix + super().__init__(model_matrix=model_matrix, should_render=False, **kwargs) + + euler_angles = np.asarray(euler_angles or [0, 0, 0], dtype=float) + + self.euler_angles: Point3D = euler_angles + self.refresh_rotation_matrix() + + def get_position(self) -> Point3D: + """Retrieve the camera's position in 3D space.""" + return self.model_matrix[:, 3][:3] + + def set_position(self, position: Point3D) -> Self: + """Set the camera's position in 3D space.""" + self.model_matrix[:, 3][:3] = position + return self + + @cached_property + def formatted_view_matrix(self) -> FlattenedMatrix4x4: + """The formatted view matrix for shader input.""" + return opengl.matrix_to_shader_input(self.unformatted_view_matrix) + + @cached_property + def unformatted_view_matrix(self) -> MatrixMN: + return typing.cast(MatrixMN, np.linalg.inv(self.model_matrix)) + + def init_points(self) -> Self: + """Initialize the camera's points based on frame shape and center point.""" + 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) + return self + + def to_default_state(self) -> Self: + """Reset the camera to its default state + (config frame size, centered at origin, no rotation). + """ + self.center() + self.set_height(config["frame_height"]) + self.set_width(config["frame_width"]) + self.set_euler_angles(0, 0, 0) + self.model_matrix = self.default_model_matrix + return self + + def refresh_rotation_matrix(self) -> Self: + """Refresh the camera's inverse rotation matrix based on its Euler angles.""" + # Rotate based on camera orientation + theta, phi, gamma = self.euler_angles + quat = quaternion_mult( + quaternion_from_angle_axis(theta, OUT, axis_normalized=True), + quaternion_from_angle_axis(phi, RIGHT, axis_normalized=True), + quaternion_from_angle_axis(gamma, OUT, axis_normalized=True), + ) + self.inverse_rotation_matrix = rotation_matrix_transpose_from_quaternion( + np.asarray(quat, dtype=float) + ) + return self + + @override + def rotate( + self, + angle: float, + axis: Vector3DLike = OUT, + about_point: Point3DLike | None = None, + **kwargs: Any, + ) -> Self: + """ + Rotate the camera by a given angle around a specified axis. + + Parameters + ---------- + angle : float + The angle in radians to rotate the camera. + axis : Vector3DLike, optional + The axis around which to rotate the camera. Default is OUT (z-axis). + about_point : Point3DLike, optional + Ignored. For OpenGLCamera, rotation is always about the camera's center. + + **kwargs : Any + Not used for OpenGLCamera. Passing additional keyword arguments + has no effect. + + Returns + ------- + Self + The rotated camera instance. Returned for chaining. + """ + curr_rot_T = self.inverse_rotation_matrix + added_rot_T = rotation_matrix_transpose(angle, axis) + new_rot_T = np.dot(curr_rot_T, added_rot_T) + Fz = new_rot_T[2] + phi = np.arccos(Fz[2]) + theta = angle_of_vector(Fz[:2]) + PI / 2 + partial_rot_T = np.dot( + rotation_matrix_transpose(phi, RIGHT), + rotation_matrix_transpose(theta, OUT), + ) + gamma = angle_of_vector(np.dot(partial_rot_T, new_rot_T.T)[:, 0]) + self.set_euler_angles(theta, phi, gamma) + return self + + def set_euler_angles( + self, + theta: float | None = None, + phi: float | None = None, + gamma: float | None = None, + ) -> Self: + """ + Set the camera's Euler angles [1]_ (theta, phi, gamma). + + Parameters + ---------- + theta : float | None, optional + The angle in radians for rotation around the OUT (z) axis. + If None, the current theta value is retained. + phi : float | None, optional + The angle in radians for rotation around the RIGHT (x) axis. + If None, the current phi value is retained. + gamma : float | None, optional + The angle in radians for rotation around the OUT (z) axis. + If None, the current gamma value is retained. + + Returns + ------- + Self + The camera instance with updated Euler angles. Returned for chaining. + + See Also + -------- + set_theta : Set the theta Euler angle. + set_phi : Set the phi Euler angle. + set_gamma : Set the gamma Euler angle. + + References + ---------- + .. [1] Wikipedia, "Euler angles", + https://en.wikipedia.org/wiki/Euler_angles + """ + if theta is not None: + self.euler_angles[0] = theta + if phi is not None: + self.euler_angles[1] = phi + if gamma is not None: + self.euler_angles[2] = gamma + self.refresh_rotation_matrix() + return self + + def set_theta(self, theta: float) -> Self: + """ + Set the camera's theta Euler angle (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_phi : Set the phi Euler angle. + set_gamma : Set the gamma Euler angle. + """ + return self.set_euler_angles(theta=theta) + + def set_phi(self, phi: float) -> Self: + """ + Set the camera's phi Euler angle (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_theta : Set the theta Euler angle. + set_gamma : Set the gamma Euler angle. + """ + return self.set_euler_angles(phi=phi) + + def set_gamma(self, gamma: float) -> Self: + """ + Set the camera's gamma Euler angle (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_theta : Set the theta Euler angle. + set_phi : Set the phi Euler angle. + """ + return self.set_euler_angles(gamma=gamma) + + def increment_theta(self, dtheta: float) -> Self: + """ + Increment the camera's theta Euler angle by a given amount (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_theta : Set the theta Euler angle. + """ + self.euler_angles[0] += dtheta + self.refresh_rotation_matrix() + return self + + def increment_phi(self, dphi: float) -> Self: + """ + Increment the camera's phi Euler angle by a given amount (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_phi : Set the phi Euler angle. + """ + phi = self.euler_angles[1] + new_phi = clip(phi + dphi, -PI / 2, PI / 2) + self.euler_angles[1] = new_phi + self.refresh_rotation_matrix() + return self + + def increment_gamma(self, dgamma: float) -> Self: + """ + Increment the camera's gamma Euler angle by a given amount (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_gamma : Set the gamma Euler angle. + """ + self.euler_angles[2] += dgamma + self.refresh_rotation_matrix() + return self + + def get_shape(self) -> tuple[float, float]: + """Retrieve the width and height of the camera frame.""" + return (self.get_width(), self.get_height()) + + def get_center(self) -> Point3D: + """ + Retrieve the center point of the camera in 3D space. + + Notes + ----- + The center point is assumed to be the first point in the camera's points array. + """ + # Assumes first point is at the center + return typing.cast(Point3D, self.points[0]) + + def get_width(self) -> float: + """Retrieve the width of the camera frame.""" + points = self.points + out = points[2, 0] - points[1, 0] + return float(out) + + def get_height(self) -> float: + """Retrieve the height of the camera frame.""" + points = self.points + out = points[4, 1] - points[3, 1] + return float(out) + # return points[4, 1] - points[3, 1] + + def get_focal_distance(self) -> float: + """Retrieve the focal distance of the camera.""" + return self.focal_distance * self.get_height() + + @override + def interpolate( + self, + mobject1: OpenGLMobject, + mobject2: OpenGLMobject, + alpha: float, + path_func: PathFuncType = straight_path(), + ) -> Self: + super().interpolate(mobject1, mobject2, alpha, path_func) + self.refresh_rotation_matrix() + return self + + +class OpenGLRenderer: + """ + An OpenGL-based renderer. + + Attributes + ---------- + animation_elapsed_time : float + The elapsed time of the current animation. + animation_start_time : float + The start time of the current animation. + animations_hashes : list[str | None] + List of animation hashes for caching. + anti_alias_width : float + The width used for anti-aliasing in pixel units. + background_color : FloatRGBA + The background color of the renderer. + camera : OpenGLCamera + The camera used for rendering. + num_plays : float + The number of animation plays executed. + path_to_texture_id : dict[str, int] + Mapping from texture file paths to OpenGL texture IDs. + pressed_keys : set[int] + Set of currently pressed key codes. + skip_animations : bool + Whether animations are currently being skipped. + time : float + The total elapsed time for the renderer. + window : Window | None + The window used for previewing, if any. + """ + + capabilities = RendererCapabilities(live_preview=True) + + def __init__( + self, + file_writer_class: type[SceneFileWriter] = SceneFileWriter, + skip_animations: bool = False, + ) -> None: + """Initializes the OpenGLRenderer. + + Parameters + ---------- + file_writer_class : type[SceneFileWriter], optional + The class to use for writing scene files, by default SceneFileWriter. + skip_animations : bool, optional + Whether to skip animations during rendering, by default False. + """ + # Measured in pixel widths, used for vector graphics + self.anti_alias_width = 1.5 + self._file_writer_class = file_writer_class + + self._original_skipping_status = skip_animations + self.skip_animations = skip_animations + self.animation_start_time = 0.0 + self.animation_elapsed_time = 0.0 + self.time = 0.0 + self.animations_hashes: list[str | None] = [] + self.num_plays = 0 + + self.camera = OpenGLCamera() + self.pressed_keys: set[int] = set() + self.window: Window | None = None + self.path_to_texture_id: dict[str, int] = {} + self.background_color = config["background_color"] + + def init_scene( + self, + scene: Scene, + session_spec: RenderSessionSpec, + file_writer_settings: _SceneFileWriterSettings, + ) -> None: + """ + Initializes the OpenGL rendering context and related resources + for the given scene. + + Set up: + - the file writer + - the background color + - the OpenGL context + - the window (if needed) + + Parameters + ---------- + scene : Scene + The scene to be rendered + """ + self.partial_movie_files: list[str | None] = [] + self.file_writer: SceneFileWriter = self._file_writer_class( + file_writer_settings, + ) + self.scene = scene + + self.background_color = config["background_color"] + if self.should_create_window(session_spec): + from .window import Window + + self.window = Window(self) + self.context = self.window.ctx + self.frame_buffer_object = self.context.detect_framebuffer() + else: + # self.window = None + try: + self.context = moderngl.create_context(standalone=True) + except Exception: + self.context = moderngl.create_context( + standalone=True, + backend="egl", + ) + self.frame_buffer_object = self.get_frame_buffer_object(self.context, 0) + self.frame_buffer_object.use() + self.context.enable(moderngl.BLEND) + self.context.wireframe = config["enable_wireframe"] + self.context.blend_func = ( + moderngl.SRC_ALPHA, + moderngl.ONE_MINUS_SRC_ALPHA, + moderngl.ONE, + moderngl.ONE, + ) + + def should_create_window(self, session_spec: RenderSessionSpec) -> bool: + """ + Determine whether a window should be created for rendering + based on the current configuration. + + """ + return session_spec.presentation.live_preview + + def get_pixel_shape(self) -> tuple[int, int] | None: + """ + Retrieve the pixel dimensions of the current frame buffer object (2D). + + Returns + ------- + width : int + The width of the frame buffer in pixels. + height : int + The height of the frame buffer in pixels. + """ + frame_buffer: Framebuffer | None = getattr(self, "frame_buffer_object", None) + if frame_buffer is None: + return None + _, _, pixel_width, pixel_height = frame_buffer.viewport + return pixel_width, pixel_height + + def refresh_perspective_uniforms(self, camera: OpenGLCamera) -> None: + """ + Update the perspective-related uniform variables used in the + OpenGL renderer based on the current camera settings. + + Parameters + ---------- + camera : OpenGLCamera + The camera object from which to extract perspective and lighting information. + + Raises + ------ + ValueError + If the renderer's pixel shape is not available. + """ + pixel_shape = self.get_pixel_shape() + if pixel_shape is None: + msg = "Pixel shape is None, cannot refresh perspective uniforms." + raise ValueError(msg) + + pixel_width, pixel_height = pixel_shape + frame_width, frame_height = camera.get_shape() + # TODO, this should probably be a mobject uniform, with + # the camera taking care of the conversion factor + anti_alias_width = self.anti_alias_width / (pixel_height / frame_height) + # Orient light + rotation = camera.inverse_rotation_matrix + light_pos: Point3D = camera.light_source.get_location() + light_pos = np.dot(rotation, light_pos) + + self.perspective_uniforms = { + "frame_shape": camera.get_shape(), + "anti_alias_width": anti_alias_width, + "camera_center": tuple(camera.get_center()), + "camera_rotation": tuple(np.array(rotation).T.flatten()), + "light_source_position": tuple(light_pos), + "focal_distance": camera.get_focal_distance(), + } + + def render_mobject(self, mobject: OpenGLMobject | OpenGLVMobject) -> None: + """ + Render an OpenGL mobject (either OpenGLMobject or OpenGLVMobject) + using the appropriate shaders and rendering pipeline. + + Parameters + ---------- + mobject : OpenGLMobject | OpenGLVMobject + The mobject to render. Must be an instance of OpenGLMobject or OpenGLVMobject. + + Raises + ------ + TypeError + If a shader texture is not a moderngl.Uniform or moderngl.UniformBlock. + """ + if isinstance(mobject, OpenGLVMobject): + if config["use_projection_fill_shaders"]: + render_opengl_vectorized_mobject_fill(self, mobject) + + if config["use_projection_stroke_shaders"]: + render_opengl_vectorized_mobject_stroke(self, mobject) + + shader_wrapper_list = mobject.get_shader_wrapper_list() + # Convert ShaderWrappers to Meshes. + for shader_wrapper in shader_wrapper_list: + folder = shader_wrapper.shader_folder + shader = Shader( + context=self.context, name=str(folder) if folder is not None else None + ) + + # Set textures. + for name, path in shader_wrapper.texture_paths.items(): + tid = self.get_texture_id(str(path)) + shader_texture = shader.shader_program[name] + if not isinstance( + shader_texture, (moderngl.Uniform, moderngl.UniformBlock) + ): + msg = ( + f"Shader texture must be a uniform, got {type(shader_texture)}" + ) + raise TypeError(msg) + shader_texture.value = tid + + # Set uniforms. + for name, value in it.chain( + shader_wrapper.uniforms.items(), + self.perspective_uniforms.items(), + ): + with contextlib.suppress(KeyError): + shader.set_uniform(name, value) + try: + # TODO: make the type of 'camera' generic in the 'Scene' class + # to avoid the cast here + cam = typing.cast("OpenGLCamera", self.scene.camera) + shader.set_uniform("u_view_matrix", cam.formatted_view_matrix) + shader.set_uniform("u_projection_matrix", cam.projection_matrix) + except KeyError: + pass + + # Set depth test. + if shader_wrapper.depth_test: + self.context.enable(moderngl.DEPTH_TEST) + else: + self.context.disable(moderngl.DEPTH_TEST) + + # Render. + vert_indices = shader_wrapper.vert_indices + mesh = Mesh( + shader, + shader_wrapper.vert_data, + indices=np.asarray(vert_indices) if vert_indices is not None else None, + use_depth_test=shader_wrapper.depth_test, + primitive=mobject.render_primitive, + ) + mesh.set_uniforms(self) + mesh.render() + + def get_texture_id(self, path: str) -> int: + """ + Retrieves the OpenGL texture ID associated with the given image file path. + + Automatically creates a new texture it it has not been loaded before. + + Parameters + ---------- + path : str + The file path to the texture image. + + Returns + ------- + int + The OpenGL texture ID corresponding to the given path. + """ + return ( + self.path_to_texture_id[path] + if path in self.path_to_texture_id + else self._create_texture(path) + ) + + def _create_texture(self, image_path: str) -> int: + """ + Create an OpenGL texture from the given image file path, get its texture ID, + and store it in `self.path_to_texture_id[image_path]`. + + Parameters + ---------- + image_path : str + The file path to the image to be loaded as a texture. + + Returns + ------- + int + The texture ID assigned to the newly created texture. + """ + with Image.open(image_path) as img: + tid = len(self.path_to_texture_id) + + # grayscale image + if img.mode == "L": + components = 1 + swizzle = "RRR1" + else: + # convert everything to RGBA for consistency + img = img.convert("RGBA") + components = 4 + swizzle = "RGBA" + + texture = self.context.texture( + size=img.size, + components=components, + data=img.tobytes(), + ) + texture.repeat_x = False + texture.repeat_y = False + texture.filter = (moderngl.NEAREST, moderngl.NEAREST) + texture.swizzle = swizzle + texture.use(location=tid) + self.path_to_texture_id[image_path] = tid + return tid + + def update_skipping_status(self) -> None: + """ + Check and update the skipping status for the current animation + (self.skip_animations flag) based on the configuration settings. + + Parameters + ---------- + None + + Raises + ------ + EndSceneEarlyException + If the number of played animations exceeds the configured upper bound. + """ + # there is always at least one section -> no out of bounds here + if self.file_writer.sections[-1].skip_animations: + self.skip_animations = True + if self.file_writer.output_spec.is_still: + self.skip_animations = True + if ( + config.from_animation_number > 0 + and self.num_plays < config.from_animation_number + ): + self.skip_animations = True + if ( + config.upto_animation_number >= 0 + and self.num_plays > config.upto_animation_number + ): + self.skip_animations = True + raise EndSceneEarlyException() + + @handle_caching_play + def play( + self, + scene: Scene, + *animations: Animation | Mobject | _AnimationBuilder, + **kwargs: Any, + ) -> None: + """ + Plays the given animations or mobjects in the specified scene. + + "Playing" here refers to the process of compiling animation data, + beginning the animations, updating frames, and finalizing the animation + in the context of the renderer. + + Parameters + ---------- + scene Scene + The scene in which to play the animations. + *animations Animation | Mobject | _AnimationBuilder + The animations, mobjects, or animation builders to play. + **kwargs Any + Additional keyword arguments to pass to the animation compilation. + """ + # TODO: Handle data locking / unlocking. + self.animation_start_time = time.time() + self.file_writer.begin_animation( + not self.skip_animations, + animation_index=self.num_plays, + ) + + scene.compile_animation_data(*animations, **kwargs) + scene.begin_animations() + if scene.is_current_animation_frozen_frame(): + self.update_frame(scene) + + output = self.file_writer.output_spec + if not self.skip_animations and ( + output.is_video or output.is_image_sequence + ): + self.file_writer.write_frame( + self.get_frame(), + repeat=int(config.frame_rate * scene.duration), + ) + + if self.window is not None: + self.window.swap_buffers() + while time.time() - self.animation_start_time < scene.duration: + pass + self.animation_elapsed_time = scene.duration + + else: + scene.play_internal() + + self.file_writer.end_animation(not self.skip_animations) + self.time += scene.duration + self.num_plays += 1 + + def clear_screen(self) -> None: + """ + Clears the current frame buffer and updates the display window + accordingly. + + The screen is cleared using the background color specified + in the renderer. + """ + self.frame_buffer_object.clear(*self.background_color) + if self.window is None: + return + self.window.swap_buffers() + + def render( + self, scene: Scene, frame_offset: float, moving_mobjects: list[Mobject] + ) -> None: + """ + Renders a single frame of the given scene using OpenGL. + + Parameters + ---------- + scene : Scene + The scene to render. + frame_offset : float + The time offset for the current frame in seconds. If no window is present, + this parameter is ignored, and a frame is a true snapshot of + the scene at the current time. + moving_mobjects : list[Mobject] + List of mobjects that are currently moving and need to be updated. + Not used at all, kept for compatibility with other renderers. + + Notes + ----- + - Updates the frame for the scene. + - If animations are skipped, the method returns early. + - Writes the current frame using the file writer. + - If a window is present, swaps buffers and continues + updating frames until the animation elapsed time reaches the frame offset. + """ + self.update_frame(scene) + + if self.skip_animations: + return + + output = self.file_writer.output_spec + if output.is_video or output.is_image_sequence: + self.file_writer.write_frame(self.get_frame()) + + if self.window is not None: + self.window.swap_buffers() + while self.animation_elapsed_time < frame_offset: + self.update_frame(scene) + self.window.swap_buffers() + + def update_frame(self, scene: Scene) -> None: + """ + Update and render the current frame for the given scene. + + Performs the following steps: + 1. Clear the frame buffer with the background color. + 2. Refresh camera perspective uniforms for rendering. + 3. Iterate through all mobjects in the scene, rendering those marked for display. + 4. Iterate through all mesh objects in the scene, setting their uniforms and rendering them. + 5. Update the elapsed animation time. + + Parameters + ---------- + scene : Scene + The scene to render the frame for. + """ + self.frame_buffer_object.clear(*self.background_color) + + # TODO: make the type of 'camera' generic in the 'Scene' class + # to avoid the cast here + cam = typing.cast("OpenGLCamera", scene.camera) + self.refresh_perspective_uniforms(cam) + + for mobject in scene.mobjects: + if not mobject.should_render: + continue + + # TODO: make the type of 'mobject' generic in the 'Scene' class + # to avoid the cast here + mobj = typing.cast("OpenGLMobject | OpenGLVMobject", mobject) + self.render_mobject(mobj) + + for obj in scene.meshes: + for mesh in obj.get_meshes(): + mesh.set_uniforms(self) + mesh.render() + + self.animation_elapsed_time = time.time() - self.animation_start_time + + def scene_finished(self, scene: Scene) -> None: + """Finalize configured output for the scene. + + Parameters + ---------- + scene + The scene that has finished rendering. + """ + output = self.file_writer.output_spec + if self.num_plays > 0 and (output.is_video or output.is_image_sequence): + self.file_writer.finish() + elif self.num_plays == 0: + # Keep the framebuffer useful for direct renderer access and + # graphical tests even when no media artifact was requested. + self.update_frame(scene) + + if self.should_save_last_frame(): + if self.num_plays > 0: + self.update_frame(scene) + self.file_writer.save_image(self.get_frame()) + + def should_save_last_frame(self) -> bool: + """ + Determine whether the last frame of the scene should be saved. + + This is true for explicit last-frame PNG output and for automatic video + output when the scene has no play calls. Interactive scenes do not use + the automatic fallback. + """ + output = self.file_writer.output_spec + if output.is_still: + return True + if self.scene.interactive_mode: + return False + return self.num_plays == 0 and output.fallback_to_still + + def get_image(self) -> Image.Image: + """ + Get the current OpenGL frame buffer as a PIL Image. + + Returns + ------- + Image.Image + The image representation of the current frame buffer. + + Raises + ------ + ValueError + If the pixel shape cannot be determined. + + Notes + ----- + The image is constructed from raw RGBA buffer data, with the + origin at the bottom-left. + """ + raw_buffer_data = self.get_raw_frame_buffer_object_data() + pixel_shape = self.get_pixel_shape() + if pixel_shape is None: + msg = "Pixel shape is None, cannot get image." + raise ValueError(msg) + + image = Image.frombytes( + "RGBA", # mode (rgb, a for alpha (transparency))) + pixel_shape, # size + raw_buffer_data, # data + "raw", # decoder_name + # *args for the decoder + "RGBA", # raw mode + 0, # stride (O = no extra padding) + -1, # orientation (-1 = bottom to top, 1 = top to bottom) + ) + return image + + def save_static_frame_data( + self, scene: Scene, static_mobjects: Iterable[Mobject] + ) -> None: + pass + + def get_frame_buffer_object( + self, context: moderngl.Context, samples: int = 0 + ) -> Framebuffer: + """ + Creates and returns a framebuffer object configured with color + and depth attachments. + + Parameters + ---------- + context : moderngl.Context + The ModernGL context used to create the framebuffer and + its attachments. + samples : int, optional + The number of samples for multisample anti-aliasing (MSAA)[1]_. + Default is 0 (no MSAA). + + Returns + ------- + Framebuffer + A framebuffer object with a color texture attachment and + a depth renderbuffer attachment, both sized according to + the current configuration's pixel width and height. + + Notes + ----- + Framebuffer's color attachment is supposed RGBA. + Pixel dimensions are taken from the global config of Manim. + + References + ---------- + .. [1] Wikipedia, "Multisample anti-aliasing", + https://en.wikipedia.org/wiki/Multisample_anti-aliasing + """ + pixel_width = config["pixel_width"] + pixel_height = config["pixel_height"] + num_channels = 4 + return context.framebuffer( + color_attachments=context.texture( + (pixel_width, pixel_height), + components=num_channels, + samples=samples, + ), + depth_attachment=context.depth_renderbuffer( + (pixel_width, pixel_height), + samples=samples, + ), + ) + + def get_raw_frame_buffer_object_data(self, dtype: str = "f1") -> bytes: + """ + Get the raw data from the current frame buffer object as bytes. + + This method reads the pixel data from the frame buffer object using the specified data type. + The data is read with 4 color channels (typically RGBA). + + Args: + dtype (str, optional): The data type to use when reading the buffer. + Defaults to "f1" (i.e., float with 1 byte). + + Returns: + bytes: The raw pixel data from the frame buffer object. + """ + # Copy blocks from the fbo_msaa to the drawn fbo using Blit + # pw, ph = self.get_pixel_shape() + # gl.glBindFramebuffer(gl.GL_READ_FRAMEBUFFER, self.fbo_msaa.glo) + # gl.glBindFramebuffer(gl.GL_DRAW_FRAMEBUFFER, self.fbo.glo) + # gl.glBlitFramebuffer( + # 0, 0, pw, ph, 0, 0, pw, ph, gl.GL_COLOR_BUFFER_BIT, gl.GL_LINEAR + # ) + num_channels = 4 + ret: bytes = self.frame_buffer_object.read( + viewport=self.frame_buffer_object.viewport, + components=num_channels, + dtype=dtype, + ) + return ret + + def get_frame(self) -> RGBAPixelArray: + """ + Get the current frame buffer as a Numpy array of RGBA pixel values. + + Returns + ------- + RGBAPixelArray + A Numpy array of shape (height, width, 4) containing the + RGBA pixel data of the current frame, with dtype uint8. + + Raises + ------ + ValueError + If the pixel shape cannot be determined. + """ + # get current pixel values as numpy data in order to test output + raw = self.get_raw_frame_buffer_object_data(dtype="f1") + pixel_shape = self.get_pixel_shape() + if pixel_shape is None: + msg = "Pixel shape is None, cannot get frame." + raise ValueError(msg) + + result_dimensions = (pixel_shape[1], pixel_shape[0], 4) + np_buf = np.frombuffer(raw, dtype="uint8").reshape(result_dimensions) + return np.flipud(np_buf).copy() + + # Returns offset from the bottom left corner in pixels. + # top_left flag should be set to True when using a GUI framework + # where the (0,0) is at the top left: e.g. PySide6 + def pixel_coords_to_space_coords( + self, px: float, py: float, relative: bool = False, top_left: bool = False + ) -> Point3D: + """ + Converts pixel coordinates to space (scene) coordinates. + + top_left flag should be set to True when using a GUI framework + where the (0,0) is at the top left: e.g. PySide6. + + Parameters + ---------- + px : float + The x-coordinate in pixel space. + py : float + The y-coordinate in pixel space. + relative : bool, optional + If True, returns coordinates relative to the frame (normalized to [-1, 1]). + If False, returns absolute space coordinates. Default is False. + top_left : bool, optional + If True, treats the origin (0, 0) as the top-left corner of the pixel space. + If False, treats the origin as the bottom-left. Default is False. + + Returns + ------- + Point3D + The corresponding coordinates in space as a NumPy array of shape (3,). + + Notes + ----- + If the pixel shape is not available, returns the origin [0, 0, 0]. + """ + pixel_shape = self.get_pixel_shape() + if pixel_shape is None: + return typing.cast(Point3D, np.array([0.0, 0.0, 0.0])) + pixel_width, pixel_height = pixel_shape + frame_height = config["frame_height"] + frame_center = self.camera.get_center() + if relative: + # relative -> just normalize to [-1, 1] + return 2 * np.array([px / pixel_width, py / pixel_height, 0]) + + scale = frame_height / pixel_height + y_direction = -1 if top_left else 1 + + return typing.cast( + Point3D, + frame_center + + scale + * np.array( + [(px - pixel_width / 2), y_direction * (py - pixel_height / 2), 0.0] + ), + ) + + @property + def background_color(self) -> FloatRGBA: + """The background color of the renderer (RGBA format).""" + return self._background_color + + @background_color.setter + def background_color(self, value: ParsableManimColor) -> None: + self._background_color = color_to_rgba(value, 1.0) diff --git a/manim/renderer/shader.py b/manim/renderer/opengl/shader.py similarity index 99% rename from manim/renderer/shader.py rename to manim/renderer/opengl/shader.py index e57964daa3..c074301d3a 100644 --- a/manim/renderer/shader.py +++ b/manim/renderer/opengl/shader.py @@ -13,7 +13,7 @@ import numpy.typing as npt if TYPE_CHECKING: - from manim.renderer.opengl_renderer import OpenGLRenderer + from manim.renderer.opengl.renderer import OpenGLRenderer MeshTimeBasedUpdater: TypeAlias = Callable[["Object3D", float], None] MeshNonTimeBasedUpdater: TypeAlias = Callable[["Object3D"], None] @@ -21,8 +21,8 @@ from manim.typing import MatrixMN, Point3D -from .. import config -from ..utils import opengl +from ... import config +from ...utils import opengl SHADER_FOLDER = Path(__file__).parent / "shaders" shader_program_cache: dict[str, moderngl.Program] = {} diff --git a/manim/renderer/shader_wrapper.py b/manim/renderer/opengl/shader_wrapper.py similarity index 93% rename from manim/renderer/shader_wrapper.py rename to manim/renderer/opengl/shader_wrapper.py index d35953a622..73d0bb737f 100644 --- a/manim/renderer/shader_wrapper.py +++ b/manim/renderer/opengl/shader_wrapper.py @@ -25,7 +25,7 @@ logger = logging.getLogger("manim") -def get_shader_dir(): +def get_shader_dir() -> Path: return Path(__file__).parent / "shaders" @@ -58,7 +58,7 @@ def __init__( texture_paths: Mapping[str, Path | str] | None = None, depth_test: bool = False, render_primitive: int | str = moderngl.TRIANGLE_STRIP, - ): + ) -> None: self.vert_data: _ShaderData = vert_data self.vert_indices: Sequence[int] | None = vert_indices self.vert_attributes: tuple[str, ...] | None = vert_data.dtype.names @@ -70,8 +70,8 @@ def __init__( self.init_program_code() self.refresh_id() - def copy(self): - result = copy.copy(self) + def copy(self) -> Self: + result: Self = copy.copy(self) result.vert_data = np.array(self.vert_data) if result.vert_indices is not None: result.vert_indices = np.array(self.vert_indices) @@ -96,7 +96,7 @@ def get_id(self) -> str: def get_program_id(self) -> int: return self.program_id - def create_id(self): + def create_id(self) -> str: # A unique id for a shader return "|".join( map( @@ -115,7 +115,7 @@ def refresh_id(self) -> None: self.program_id: int = self.create_program_id() self.id: str = self.create_id() - def create_program_id(self): + def create_program_id(self) -> int: return hash( "".join( self.program_code[f"{name}_shader"] or "" @@ -123,7 +123,7 @@ def create_program_id(self): ), ) - def init_program_code(self): + def init_program_code(self) -> None: def get_code(name: str) -> str | None: return get_shader_code_from_file( self.shader_folder / f"{name}.glsl", @@ -135,7 +135,7 @@ def get_code(name: str) -> str | None: "fragment_shader": get_code("frag"), } - def get_program_code(self): + def get_program_code(self) -> dict[str, str | None]: return self.program_code def replace_code(self, old: str, new: str) -> None: @@ -151,10 +151,10 @@ def combine_with(self, *shader_wrappers: "ShaderWrapper") -> Self: # noqa: UP03 return self if self.vert_indices is not None: num_verts = len(self.vert_data) - indices_list = [self.vert_indices] + indices_list = [np.asarray(self.vert_indices)] data_list = [self.vert_data] for sw in shader_wrappers: - indices_list.append(sw.vert_indices + num_verts) + indices_list.append(np.asarray(sw.vert_indices) + num_verts) data_list.append(sw.vert_data) num_verts += len(sw.vert_data) self.vert_indices = np.hstack(indices_list) @@ -167,7 +167,7 @@ def combine_with(self, *shader_wrappers: "ShaderWrapper") -> Self: # noqa: UP03 # For caching -filename_to_code_map: dict = {} +filename_to_code_map: dict[Path, str] = {} def get_shader_code_from_file(filename: Path) -> str | None: diff --git a/manim/renderer/shaders/default/frag.glsl b/manim/renderer/opengl/shaders/default/frag.glsl similarity index 100% rename from manim/renderer/shaders/default/frag.glsl rename to manim/renderer/opengl/shaders/default/frag.glsl diff --git a/manim/renderer/shaders/default/vert.glsl b/manim/renderer/opengl/shaders/default/vert.glsl similarity index 100% rename from manim/renderer/shaders/default/vert.glsl rename to manim/renderer/opengl/shaders/default/vert.glsl diff --git a/manim/renderer/shaders/design.frag b/manim/renderer/opengl/shaders/design.frag similarity index 100% rename from manim/renderer/shaders/design.frag rename to manim/renderer/opengl/shaders/design.frag diff --git a/manim/renderer/shaders/design_2.frag b/manim/renderer/opengl/shaders/design_2.frag similarity index 100% rename from manim/renderer/shaders/design_2.frag rename to manim/renderer/opengl/shaders/design_2.frag diff --git a/manim/renderer/shaders/design_3.frag b/manim/renderer/opengl/shaders/design_3.frag similarity index 100% rename from manim/renderer/shaders/design_3.frag rename to manim/renderer/opengl/shaders/design_3.frag diff --git a/manim/renderer/shaders/image/frag.glsl b/manim/renderer/opengl/shaders/image/frag.glsl similarity index 100% rename from manim/renderer/shaders/image/frag.glsl rename to manim/renderer/opengl/shaders/image/frag.glsl diff --git a/manim/renderer/shaders/image/vert.glsl b/manim/renderer/opengl/shaders/image/vert.glsl similarity index 100% rename from manim/renderer/shaders/image/vert.glsl rename to manim/renderer/opengl/shaders/image/vert.glsl diff --git a/manim/renderer/shaders/include/NOTE.md b/manim/renderer/opengl/shaders/include/NOTE.md similarity index 100% rename from manim/renderer/shaders/include/NOTE.md rename to manim/renderer/opengl/shaders/include/NOTE.md diff --git a/manim/renderer/shaders/include/add_light.glsl b/manim/renderer/opengl/shaders/include/add_light.glsl similarity index 100% rename from manim/renderer/shaders/include/add_light.glsl rename to manim/renderer/opengl/shaders/include/add_light.glsl diff --git a/manim/renderer/shaders/include/camera_uniform_declarations.glsl b/manim/renderer/opengl/shaders/include/camera_uniform_declarations.glsl similarity index 100% rename from manim/renderer/shaders/include/camera_uniform_declarations.glsl rename to manim/renderer/opengl/shaders/include/camera_uniform_declarations.glsl diff --git a/manim/renderer/shaders/include/finalize_color.glsl b/manim/renderer/opengl/shaders/include/finalize_color.glsl similarity index 100% rename from manim/renderer/shaders/include/finalize_color.glsl rename to manim/renderer/opengl/shaders/include/finalize_color.glsl diff --git a/manim/renderer/shaders/include/get_gl_Position.glsl b/manim/renderer/opengl/shaders/include/get_gl_Position.glsl similarity index 100% rename from manim/renderer/shaders/include/get_gl_Position.glsl rename to manim/renderer/opengl/shaders/include/get_gl_Position.glsl diff --git a/manim/renderer/shaders/include/get_rotated_surface_unit_normal_vector.glsl b/manim/renderer/opengl/shaders/include/get_rotated_surface_unit_normal_vector.glsl similarity index 100% rename from manim/renderer/shaders/include/get_rotated_surface_unit_normal_vector.glsl rename to manim/renderer/opengl/shaders/include/get_rotated_surface_unit_normal_vector.glsl diff --git a/manim/renderer/shaders/include/get_unit_normal.glsl b/manim/renderer/opengl/shaders/include/get_unit_normal.glsl similarity index 100% rename from manim/renderer/shaders/include/get_unit_normal.glsl rename to manim/renderer/opengl/shaders/include/get_unit_normal.glsl diff --git a/manim/renderer/shaders/include/position_point_into_frame.glsl b/manim/renderer/opengl/shaders/include/position_point_into_frame.glsl similarity index 100% rename from manim/renderer/shaders/include/position_point_into_frame.glsl rename to manim/renderer/opengl/shaders/include/position_point_into_frame.glsl diff --git a/manim/renderer/shaders/include/quadratic_bezier_distance.glsl b/manim/renderer/opengl/shaders/include/quadratic_bezier_distance.glsl similarity index 100% rename from manim/renderer/shaders/include/quadratic_bezier_distance.glsl rename to manim/renderer/opengl/shaders/include/quadratic_bezier_distance.glsl diff --git a/manim/renderer/shaders/include/quadratic_bezier_geometry_functions.glsl b/manim/renderer/opengl/shaders/include/quadratic_bezier_geometry_functions.glsl similarity index 100% rename from manim/renderer/shaders/include/quadratic_bezier_geometry_functions.glsl rename to manim/renderer/opengl/shaders/include/quadratic_bezier_geometry_functions.glsl diff --git a/manim/renderer/shaders/manim_coords/frag.glsl b/manim/renderer/opengl/shaders/manim_coords/frag.glsl similarity index 100% rename from manim/renderer/shaders/manim_coords/frag.glsl rename to manim/renderer/opengl/shaders/manim_coords/frag.glsl diff --git a/manim/renderer/shaders/manim_coords/vert.glsl b/manim/renderer/opengl/shaders/manim_coords/vert.glsl similarity index 100% rename from manim/renderer/shaders/manim_coords/vert.glsl rename to manim/renderer/opengl/shaders/manim_coords/vert.glsl diff --git a/manim/renderer/shaders/quadratic_bezier_fill/frag.glsl b/manim/renderer/opengl/shaders/quadratic_bezier_fill/frag.glsl similarity index 100% rename from manim/renderer/shaders/quadratic_bezier_fill/frag.glsl rename to manim/renderer/opengl/shaders/quadratic_bezier_fill/frag.glsl diff --git a/manim/renderer/shaders/quadratic_bezier_fill/geom.glsl b/manim/renderer/opengl/shaders/quadratic_bezier_fill/geom.glsl similarity index 100% rename from manim/renderer/shaders/quadratic_bezier_fill/geom.glsl rename to manim/renderer/opengl/shaders/quadratic_bezier_fill/geom.glsl diff --git a/manim/renderer/shaders/quadratic_bezier_fill/vert.glsl b/manim/renderer/opengl/shaders/quadratic_bezier_fill/vert.glsl similarity index 100% rename from manim/renderer/shaders/quadratic_bezier_fill/vert.glsl rename to manim/renderer/opengl/shaders/quadratic_bezier_fill/vert.glsl diff --git a/manim/renderer/shaders/quadratic_bezier_stroke/frag.glsl b/manim/renderer/opengl/shaders/quadratic_bezier_stroke/frag.glsl similarity index 100% rename from manim/renderer/shaders/quadratic_bezier_stroke/frag.glsl rename to manim/renderer/opengl/shaders/quadratic_bezier_stroke/frag.glsl diff --git a/manim/renderer/shaders/quadratic_bezier_stroke/geom.glsl b/manim/renderer/opengl/shaders/quadratic_bezier_stroke/geom.glsl similarity index 100% rename from manim/renderer/shaders/quadratic_bezier_stroke/geom.glsl rename to manim/renderer/opengl/shaders/quadratic_bezier_stroke/geom.glsl diff --git a/manim/renderer/shaders/quadratic_bezier_stroke/vert.glsl b/manim/renderer/opengl/shaders/quadratic_bezier_stroke/vert.glsl similarity index 100% rename from manim/renderer/shaders/quadratic_bezier_stroke/vert.glsl rename to manim/renderer/opengl/shaders/quadratic_bezier_stroke/vert.glsl diff --git a/manim/renderer/shaders/simple_vert.glsl b/manim/renderer/opengl/shaders/simple_vert.glsl similarity index 100% rename from manim/renderer/shaders/simple_vert.glsl rename to manim/renderer/opengl/shaders/simple_vert.glsl diff --git a/manim/renderer/shaders/surface/frag.glsl b/manim/renderer/opengl/shaders/surface/frag.glsl similarity index 100% rename from manim/renderer/shaders/surface/frag.glsl rename to manim/renderer/opengl/shaders/surface/frag.glsl diff --git a/manim/renderer/shaders/surface/vert.glsl b/manim/renderer/opengl/shaders/surface/vert.glsl similarity index 100% rename from manim/renderer/shaders/surface/vert.glsl rename to manim/renderer/opengl/shaders/surface/vert.glsl diff --git a/manim/renderer/shaders/test/frag.glsl b/manim/renderer/opengl/shaders/test/frag.glsl similarity index 100% rename from manim/renderer/shaders/test/frag.glsl rename to manim/renderer/opengl/shaders/test/frag.glsl diff --git a/manim/renderer/shaders/test/vert.glsl b/manim/renderer/opengl/shaders/test/vert.glsl similarity index 100% rename from manim/renderer/shaders/test/vert.glsl rename to manim/renderer/opengl/shaders/test/vert.glsl diff --git a/manim/renderer/shaders/textured_surface/frag.glsl b/manim/renderer/opengl/shaders/textured_surface/frag.glsl similarity index 100% rename from manim/renderer/shaders/textured_surface/frag.glsl rename to manim/renderer/opengl/shaders/textured_surface/frag.glsl diff --git a/manim/renderer/shaders/textured_surface/vert.glsl b/manim/renderer/opengl/shaders/textured_surface/vert.glsl similarity index 100% rename from manim/renderer/shaders/textured_surface/vert.glsl rename to manim/renderer/opengl/shaders/textured_surface/vert.glsl diff --git a/manim/renderer/shaders/true_dot/frag.glsl b/manim/renderer/opengl/shaders/true_dot/frag.glsl similarity index 100% rename from manim/renderer/shaders/true_dot/frag.glsl rename to manim/renderer/opengl/shaders/true_dot/frag.glsl diff --git a/manim/renderer/shaders/true_dot/geom.glsl b/manim/renderer/opengl/shaders/true_dot/geom.glsl similarity index 100% rename from manim/renderer/shaders/true_dot/geom.glsl rename to manim/renderer/opengl/shaders/true_dot/geom.glsl diff --git a/manim/renderer/shaders/true_dot/vert.glsl b/manim/renderer/opengl/shaders/true_dot/vert.glsl similarity index 100% rename from manim/renderer/shaders/true_dot/vert.glsl rename to manim/renderer/opengl/shaders/true_dot/vert.glsl diff --git a/manim/renderer/shaders/vectorized_mobject_fill/frag.glsl b/manim/renderer/opengl/shaders/vectorized_mobject_fill/frag.glsl similarity index 100% rename from manim/renderer/shaders/vectorized_mobject_fill/frag.glsl rename to manim/renderer/opengl/shaders/vectorized_mobject_fill/frag.glsl diff --git a/manim/renderer/shaders/vectorized_mobject_fill/vert.glsl b/manim/renderer/opengl/shaders/vectorized_mobject_fill/vert.glsl similarity index 100% rename from manim/renderer/shaders/vectorized_mobject_fill/vert.glsl rename to manim/renderer/opengl/shaders/vectorized_mobject_fill/vert.glsl diff --git a/manim/renderer/shaders/vectorized_mobject_stroke/frag.glsl b/manim/renderer/opengl/shaders/vectorized_mobject_stroke/frag.glsl similarity index 100% rename from manim/renderer/shaders/vectorized_mobject_stroke/frag.glsl rename to manim/renderer/opengl/shaders/vectorized_mobject_stroke/frag.glsl diff --git a/manim/renderer/shaders/vectorized_mobject_stroke/vert.glsl b/manim/renderer/opengl/shaders/vectorized_mobject_stroke/vert.glsl similarity index 100% rename from manim/renderer/shaders/vectorized_mobject_stroke/vert.glsl rename to manim/renderer/opengl/shaders/vectorized_mobject_stroke/vert.glsl diff --git a/manim/renderer/shaders/vertex_colors/frag.glsl b/manim/renderer/opengl/shaders/vertex_colors/frag.glsl similarity index 100% rename from manim/renderer/shaders/vertex_colors/frag.glsl rename to manim/renderer/opengl/shaders/vertex_colors/frag.glsl diff --git a/manim/renderer/shaders/vertex_colors/vert.glsl b/manim/renderer/opengl/shaders/vertex_colors/vert.glsl similarity index 100% rename from manim/renderer/shaders/vertex_colors/vert.glsl rename to manim/renderer/opengl/shaders/vertex_colors/vert.glsl diff --git a/manim/renderer/vectorized_mobject_rendering.py b/manim/renderer/opengl/vectorized_mobject_rendering.py similarity index 97% rename from manim/renderer/vectorized_mobject_rendering.py rename to manim/renderer/opengl/vectorized_mobject_rendering.py index f4c85b05d6..f173cf9f01 100644 --- a/manim/renderer/vectorized_mobject_rendering.py +++ b/manim/renderer/opengl/vectorized_mobject_rendering.py @@ -7,14 +7,13 @@ import numpy as np if TYPE_CHECKING: - from manim.renderer.opengl_renderer import ( - OpenGLRenderer, - OpenGLVMobject, - ) + from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject from manim.typing import MatrixMN -from ..utils import opengl -from ..utils.space_ops import cross2d, earclip_triangulation + from .renderer import OpenGLRenderer + +from ...utils import opengl +from ...utils.space_ops import cross2d, earclip_triangulation from .shader import Shader __all__ = [ diff --git a/manim/renderer/opengl_renderer_window.py b/manim/renderer/opengl/window.py similarity index 98% rename from manim/renderer/opengl_renderer_window.py rename to manim/renderer/opengl/window.py index 3cf2215d26..b7391716b2 100644 --- a/manim/renderer/opengl_renderer_window.py +++ b/manim/renderer/opengl/window.py @@ -7,10 +7,10 @@ from moderngl_window.timers.clock import Timer from screeninfo import Monitor, get_monitors -from .. import __version__, config +from ... import __version__, config if TYPE_CHECKING: - from .opengl_renderer import OpenGLRenderer + from .renderer import OpenGLRenderer __all__ = ["Window"] diff --git a/manim/renderer/opengl_renderer.py b/manim/renderer/opengl_renderer.py index cbaee2bc9b..cfc8cc85fc 100644 --- a/manim/renderer/opengl_renderer.py +++ b/manim/renderer/opengl_renderer.py @@ -1,1207 +1,5 @@ -from __future__ import annotations - -import contextlib -import itertools as it -import time -import typing -from functools import cached_property -from typing import TYPE_CHECKING, Any, Self - -import moderngl -import numpy as np -from moderngl import Framebuffer -from PIL import Image -from typing_extensions import override - -from manim import config -from manim.mobject.opengl.opengl_mobject import ( - OpenGLMobject, - OpenGLPoint, -) -from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject -from manim.typing import MatrixMN, Point3D -from manim.utils.caching import handle_caching_play -from manim.utils.color import color_to_rgba -from manim.utils.exceptions import EndSceneEarlyException -from manim.utils.paths import straight_path - -from ..constants import * -from ..scene.scene_file_writer import SceneFileWriter -from ..utils import opengl -from ..utils.simple_functions import clip -from ..utils.space_ops import ( - angle_of_vector, - quaternion_from_angle_axis, - quaternion_mult, - rotation_matrix_transpose, - rotation_matrix_transpose_from_quaternion, -) -from .protocol import RendererCapabilities -from .shader import Mesh, Shader -from .vectorized_mobject_rendering import ( - render_opengl_vectorized_mobject_fill, - render_opengl_vectorized_mobject_stroke, -) - -if TYPE_CHECKING: - from collections.abc import Iterable - from typing import Self - - from manim._config.render_session import RenderSessionSpec - from manim.animation.animation import Animation - from manim.mobject.mobject import Mobject, _AnimationBuilder - from manim.scene.scene import Scene - from manim.scene.scene_file_writer import _SceneFileWriterSettings - from manim.typing import ( - FloatRGBA, - PathFuncType, - Point3DLike, - RGBAPixelArray, - Vector3DLike, - ) - from manim.utils.color.core import ParsableManimColor - from manim.utils.opengl import FlattenedMatrix4x4 - - from .opengl_renderer_window import Window +"""Compatibility imports for the OpenGL rendering backend.""" +from .opengl import OpenGLCamera, OpenGLRenderer __all__ = ["OpenGLCamera", "OpenGLRenderer"] - - -class OpenGLCamera(OpenGLMobject): - """ - An OpenGL-based camera for 3D scene rendering. - - - Attributes - ---------- - frame_shape : tuple[float, float] - The width and height of the camera frame. - center_point : np.ndarray - The center point of the camera in 3D space. - euler_angles : np.ndarray - The Euler angles (theta, phi, gamma) representing the camera's orientation. - focal_distance : float - The focal distance of the camera. - light_source_position : np.ndarray - The position of the light source in 3D space. - orthographic : bool - Whether the camera uses orthographic projection instead of perspective. - minimum_polar_angle : float - The minimum polar angle for camera rotation. - maximum_polar_angle : float - The maximum polar angle for camera rotation. - inverse_rotation_matrix : np.ndarray - The inverse rotation matrix of the camera. - """ - - def __init__( - self, - frame_shape: tuple[float, float] | None = None, - center_point: Point3DLike | None = None, - # Theta, phi, gamma - euler_angles: Point3DLike | None = None, - focal_distance: float = 2.0, - light_source_position: Point3DLike | None = None, - orthographic: bool = False, - minimum_polar_angle: float = -PI / 2, - maximum_polar_angle: float = PI / 2, - model_matrix: MatrixMN | None = None, - **kwargs: Any, - ) -> None: - """ - Initializes an OpenGLCamera instance. - - Parameters - ---------- - frame_shape : tuple[float, float], optional - The width and height of the camera frame. If not provided, defaults to - the global manim config values `frame_width` and `frame_height`. - center_point : Point3DLike, optional - The center point of the camera in 3D space. - If not provided, defaults to the origin (0, 0, 0). - euler_angles : Point3DLike, optional - The Euler angles (theta, phi, gamma) representing the camera's orientation. - If not provided, defaults to (0, 0, 0) (i.e., no rotation). - focal_distance : float, optional - The focal distance of the camera. Default is 2.0. - light_source_position : Point3DLike, optional - The position of the light source in 3D space. - If not provided, defaults to (-10, 10, 10). - orthographic : bool, optional - Whether the camera uses orthographic projection instead of perspective. - Default is False (perspective). - minimum_polar_angle : float, optional - The minimum polar angle in radian for camera rotation. Default is -π/2, - i.e. no restriction. - maximum_polar_angle : float, optional - The maximum polar angle in radian for camera rotation. Default is π/2, - i.e. no restriction. - model_matrix : MatrixMN, optional - The initial model matrix [1]_ for the camera. If not provided, - defaults to a translation matrix that positions the camera at (0, 0, 11). - **kwargs : Any - Additional keyword arguments passed to the OpenGLMobject constructor. - - References - ---------- - .. [1] Wikipedia, "Camera matrix", - https://en.wikipedia.org/wiki/Camera_matrix - """ - self.use_z_index = True - self.frame_rate = 60 - self.orthographic = orthographic - self.minimum_polar_angle = minimum_polar_angle - self.maximum_polar_angle = maximum_polar_angle - if self.orthographic: - self.projection_matrix = opengl.orthographic_projection_matrix() - self.unformatted_projection_matrix = opengl.orthographic_projection_matrix( - format_=False, - ) - else: - self.projection_matrix = opengl.perspective_projection_matrix() - self.unformatted_projection_matrix = opengl.perspective_projection_matrix( - format_=False, - ) - - if frame_shape is None: - self.frame_shape = (config["frame_width"], config["frame_height"]) - else: - self.frame_shape = frame_shape - - if center_point is None: - self.center_point = ORIGIN - else: - self.center_point = np.asarray(center_point, dtype=float) - - if model_matrix is None: - model_matrix = opengl.translation_matrix(0, 0, 11) - - self.focal_distance = focal_distance - - self.light_source_position = np.asarray( - light_source_position or [-10, 10, 10], dtype=float - ) - - self.light_source = OpenGLPoint(self.light_source_position) - - self.default_model_matrix = model_matrix - super().__init__(model_matrix=model_matrix, should_render=False, **kwargs) - - euler_angles = np.asarray(euler_angles or [0, 0, 0], dtype=float) - - self.euler_angles: Point3D = euler_angles - self.refresh_rotation_matrix() - - def get_position(self) -> Point3D: - """Retrieve the camera's position in 3D space.""" - return self.model_matrix[:, 3][:3] - - def set_position(self, position: Point3D) -> Self: - """Set the camera's position in 3D space.""" - self.model_matrix[:, 3][:3] = position - return self - - @cached_property - def formatted_view_matrix(self) -> FlattenedMatrix4x4: - """The formatted view matrix for shader input.""" - return opengl.matrix_to_shader_input(self.unformatted_view_matrix) - - @cached_property - def unformatted_view_matrix(self) -> MatrixMN: - return typing.cast(MatrixMN, np.linalg.inv(self.model_matrix)) - - def init_points(self) -> Self: - """Initialize the camera's points based on frame shape and center point.""" - 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) - return self - - def to_default_state(self) -> Self: - """Reset the camera to its default state - (config frame size, centered at origin, no rotation). - """ - self.center() - self.set_height(config["frame_height"]) - self.set_width(config["frame_width"]) - self.set_euler_angles(0, 0, 0) - self.model_matrix = self.default_model_matrix - return self - - def refresh_rotation_matrix(self) -> Self: - """Refresh the camera's inverse rotation matrix based on its Euler angles.""" - # Rotate based on camera orientation - theta, phi, gamma = self.euler_angles - quat = quaternion_mult( - quaternion_from_angle_axis(theta, OUT, axis_normalized=True), - quaternion_from_angle_axis(phi, RIGHT, axis_normalized=True), - quaternion_from_angle_axis(gamma, OUT, axis_normalized=True), - ) - self.inverse_rotation_matrix = rotation_matrix_transpose_from_quaternion( - np.asarray(quat, dtype=float) - ) - return self - - @override - def rotate( - self, - angle: float, - axis: Vector3DLike = OUT, - about_point: Point3DLike | None = None, - **kwargs: Any, - ) -> Self: - """ - Rotate the camera by a given angle around a specified axis. - - Parameters - ---------- - angle : float - The angle in radians to rotate the camera. - axis : Vector3DLike, optional - The axis around which to rotate the camera. Default is OUT (z-axis). - about_point : Point3DLike, optional - Ignored. For OpenGLCamera, rotation is always about the camera's center. - - **kwargs : Any - Not used for OpenGLCamera. Passing additional keyword arguments - has no effect. - - Returns - ------- - Self - The rotated camera instance. Returned for chaining. - """ - curr_rot_T = self.inverse_rotation_matrix - added_rot_T = rotation_matrix_transpose(angle, axis) - new_rot_T = np.dot(curr_rot_T, added_rot_T) - Fz = new_rot_T[2] - phi = np.arccos(Fz[2]) - theta = angle_of_vector(Fz[:2]) + PI / 2 - partial_rot_T = np.dot( - rotation_matrix_transpose(phi, RIGHT), - rotation_matrix_transpose(theta, OUT), - ) - gamma = angle_of_vector(np.dot(partial_rot_T, new_rot_T.T)[:, 0]) - self.set_euler_angles(theta, phi, gamma) - return self - - def set_euler_angles( - self, - theta: float | None = None, - phi: float | None = None, - gamma: float | None = None, - ) -> Self: - """ - Set the camera's Euler angles [1]_ (theta, phi, gamma). - - Parameters - ---------- - theta : float | None, optional - The angle in radians for rotation around the OUT (z) axis. - If None, the current theta value is retained. - phi : float | None, optional - The angle in radians for rotation around the RIGHT (x) axis. - If None, the current phi value is retained. - gamma : float | None, optional - The angle in radians for rotation around the OUT (z) axis. - If None, the current gamma value is retained. - - Returns - ------- - Self - The camera instance with updated Euler angles. Returned for chaining. - - See Also - -------- - set_theta : Set the theta Euler angle. - set_phi : Set the phi Euler angle. - set_gamma : Set the gamma Euler angle. - - References - ---------- - .. [1] Wikipedia, "Euler angles", - https://en.wikipedia.org/wiki/Euler_angles - """ - if theta is not None: - self.euler_angles[0] = theta - if phi is not None: - self.euler_angles[1] = phi - if gamma is not None: - self.euler_angles[2] = gamma - self.refresh_rotation_matrix() - return self - - def set_theta(self, theta: float) -> Self: - """ - Set the camera's theta Euler angle (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_phi : Set the phi Euler angle. - set_gamma : Set the gamma Euler angle. - """ - return self.set_euler_angles(theta=theta) - - def set_phi(self, phi: float) -> Self: - """ - Set the camera's phi Euler angle (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_theta : Set the theta Euler angle. - set_gamma : Set the gamma Euler angle. - """ - return self.set_euler_angles(phi=phi) - - def set_gamma(self, gamma: float) -> Self: - """ - Set the camera's gamma Euler angle (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_theta : Set the theta Euler angle. - set_phi : Set the phi Euler angle. - """ - return self.set_euler_angles(gamma=gamma) - - def increment_theta(self, dtheta: float) -> Self: - """ - Increment the camera's theta Euler angle by a given amount (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_theta : Set the theta Euler angle. - """ - self.euler_angles[0] += dtheta - self.refresh_rotation_matrix() - return self - - def increment_phi(self, dphi: float) -> Self: - """ - Increment the camera's phi Euler angle by a given amount (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_phi : Set the phi Euler angle. - """ - phi = self.euler_angles[1] - new_phi = clip(phi + dphi, -PI / 2, PI / 2) - self.euler_angles[1] = new_phi - self.refresh_rotation_matrix() - return self - - def increment_gamma(self, dgamma: float) -> Self: - """ - Increment the camera's gamma Euler angle by a given amount (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_gamma : Set the gamma Euler angle. - """ - self.euler_angles[2] += dgamma - self.refresh_rotation_matrix() - return self - - def get_shape(self) -> tuple[float, float]: - """Retrieve the width and height of the camera frame.""" - return (self.get_width(), self.get_height()) - - def get_center(self) -> Point3D: - """ - Retrieve the center point of the camera in 3D space. - - Notes - ----- - The center point is assumed to be the first point in the camera's points array. - """ - # Assumes first point is at the center - return typing.cast(Point3D, self.points[0]) - - def get_width(self) -> float: - """Retrieve the width of the camera frame.""" - points = self.points - out = points[2, 0] - points[1, 0] - return float(out) - - def get_height(self) -> float: - """Retrieve the height of the camera frame.""" - points = self.points - out = points[4, 1] - points[3, 1] - return float(out) - # return points[4, 1] - points[3, 1] - - def get_focal_distance(self) -> float: - """Retrieve the focal distance of the camera.""" - return self.focal_distance * self.get_height() - - @override - def interpolate( - self, - mobject1: OpenGLMobject, - mobject2: OpenGLMobject, - alpha: float, - path_func: PathFuncType = straight_path(), - ) -> Self: - super().interpolate(mobject1, mobject2, alpha, path_func) - self.refresh_rotation_matrix() - return self - - -class OpenGLRenderer: - """ - An OpenGL-based renderer. - - Attributes - ---------- - animation_elapsed_time : float - The elapsed time of the current animation. - animation_start_time : float - The start time of the current animation. - animations_hashes : list[str | None] - List of animation hashes for caching. - anti_alias_width : float - The width used for anti-aliasing in pixel units. - background_color : FloatRGBA - The background color of the renderer. - camera : OpenGLCamera - The camera used for rendering. - num_plays : float - The number of animation plays executed. - path_to_texture_id : dict[str, int] - Mapping from texture file paths to OpenGL texture IDs. - pressed_keys : set[int] - Set of currently pressed key codes. - skip_animations : bool - Whether animations are currently being skipped. - time : float - The total elapsed time for the renderer. - window : Window | None - The window used for previewing, if any. - """ - - capabilities = RendererCapabilities(live_preview=True) - - def __init__( - self, - file_writer_class: type[SceneFileWriter] = SceneFileWriter, - skip_animations: bool = False, - ) -> None: - """Initializes the OpenGLRenderer. - - Parameters - ---------- - file_writer_class : type[SceneFileWriter], optional - The class to use for writing scene files, by default SceneFileWriter. - skip_animations : bool, optional - Whether to skip animations during rendering, by default False. - """ - # Measured in pixel widths, used for vector graphics - self.anti_alias_width = 1.5 - self._file_writer_class = file_writer_class - - self._original_skipping_status = skip_animations - self.skip_animations = skip_animations - self.animation_start_time = 0.0 - self.animation_elapsed_time = 0.0 - self.time = 0.0 - self.animations_hashes: list[str | None] = [] - self.num_plays = 0 - - self.camera = OpenGLCamera() - self.pressed_keys: set[int] = set() - self.window: Window | None = None - self.path_to_texture_id: dict[str, int] = {} - self.background_color = config["background_color"] - - def init_scene( - self, - scene: Scene, - session_spec: RenderSessionSpec, - file_writer_settings: _SceneFileWriterSettings, - ) -> None: - """ - Initializes the OpenGL rendering context and related resources - for the given scene. - - Set up: - - the file writer - - the background color - - the OpenGL context - - the window (if needed) - - Parameters - ---------- - scene : Scene - The scene to be rendered - """ - self.partial_movie_files: list[str | None] = [] - self.file_writer: SceneFileWriter = self._file_writer_class( - file_writer_settings, - ) - self.scene = scene - - self.background_color = config["background_color"] - if self.should_create_window(session_spec): - from .opengl_renderer_window import Window - - self.window = Window(self) - self.context = self.window.ctx - self.frame_buffer_object = self.context.detect_framebuffer() - else: - # self.window = None - try: - self.context = moderngl.create_context(standalone=True) - except Exception: - self.context = moderngl.create_context( - standalone=True, - backend="egl", - ) - self.frame_buffer_object = self.get_frame_buffer_object(self.context, 0) - self.frame_buffer_object.use() - self.context.enable(moderngl.BLEND) - self.context.wireframe = config["enable_wireframe"] - self.context.blend_func = ( - moderngl.SRC_ALPHA, - moderngl.ONE_MINUS_SRC_ALPHA, - moderngl.ONE, - moderngl.ONE, - ) - - def should_create_window(self, session_spec: RenderSessionSpec) -> bool: - """ - Determine whether a window should be created for rendering - based on the current configuration. - - """ - return session_spec.presentation.live_preview - - def get_pixel_shape(self) -> tuple[int, int] | None: - """ - Retrieve the pixel dimensions of the current frame buffer object (2D). - - Returns - ------- - width : int - The width of the frame buffer in pixels. - height : int - The height of the frame buffer in pixels. - """ - frame_buffer: Framebuffer | None = getattr(self, "frame_buffer_object", None) - if frame_buffer is None: - return None - _, _, pixel_width, pixel_height = frame_buffer.viewport - return pixel_width, pixel_height - - def refresh_perspective_uniforms(self, camera: OpenGLCamera) -> None: - """ - Update the perspective-related uniform variables used in the - OpenGL renderer based on the current camera settings. - - Parameters - ---------- - camera : OpenGLCamera - The camera object from which to extract perspective and lighting information. - - Raises - ------ - ValueError - If the renderer's pixel shape is not available. - """ - pixel_shape = self.get_pixel_shape() - if pixel_shape is None: - msg = "Pixel shape is None, cannot refresh perspective uniforms." - raise ValueError(msg) - - pixel_width, pixel_height = pixel_shape - frame_width, frame_height = camera.get_shape() - # TODO, this should probably be a mobject uniform, with - # the camera taking care of the conversion factor - anti_alias_width = self.anti_alias_width / (pixel_height / frame_height) - # Orient light - rotation = camera.inverse_rotation_matrix - light_pos: Point3D = camera.light_source.get_location() - light_pos = np.dot(rotation, light_pos) - - self.perspective_uniforms = { - "frame_shape": camera.get_shape(), - "anti_alias_width": anti_alias_width, - "camera_center": tuple(camera.get_center()), - "camera_rotation": tuple(np.array(rotation).T.flatten()), - "light_source_position": tuple(light_pos), - "focal_distance": camera.get_focal_distance(), - } - - def render_mobject(self, mobject: OpenGLMobject | OpenGLVMobject) -> None: - """ - Render an OpenGL mobject (either OpenGLMobject or OpenGLVMobject) - using the appropriate shaders and rendering pipeline. - - Parameters - ---------- - mobject : OpenGLMobject | OpenGLVMobject - The mobject to render. Must be an instance of OpenGLMobject or OpenGLVMobject. - - Raises - ------ - TypeError - If a shader texture is not a moderngl.Uniform or moderngl.UniformBlock. - """ - if isinstance(mobject, OpenGLVMobject): - if config["use_projection_fill_shaders"]: - render_opengl_vectorized_mobject_fill(self, mobject) - - if config["use_projection_stroke_shaders"]: - render_opengl_vectorized_mobject_stroke(self, mobject) - - shader_wrapper_list = mobject.get_shader_wrapper_list() - # Convert ShaderWrappers to Meshes. - for shader_wrapper in shader_wrapper_list: - folder = shader_wrapper.shader_folder - shader = Shader( - context=self.context, name=str(folder) if folder is not None else None - ) - - # Set textures. - for name, path in shader_wrapper.texture_paths.items(): - tid = self.get_texture_id(str(path)) - shader_texture = shader.shader_program[name] - if not isinstance( - shader_texture, (moderngl.Uniform, moderngl.UniformBlock) - ): - msg = ( - f"Shader texture must be a uniform, got {type(shader_texture)}" - ) - raise TypeError(msg) - shader_texture.value = tid - - # Set uniforms. - for name, value in it.chain( - shader_wrapper.uniforms.items(), - self.perspective_uniforms.items(), - ): - with contextlib.suppress(KeyError): - shader.set_uniform(name, value) - try: - # TODO: make the type of 'camera' generic in the 'Scene' class - # to avoid the cast here - cam = typing.cast("OpenGLCamera", self.scene.camera) - shader.set_uniform("u_view_matrix", cam.formatted_view_matrix) - shader.set_uniform("u_projection_matrix", cam.projection_matrix) - except KeyError: - pass - - # Set depth test. - if shader_wrapper.depth_test: - self.context.enable(moderngl.DEPTH_TEST) - else: - self.context.disable(moderngl.DEPTH_TEST) - - # Render. - vert_indices = shader_wrapper.vert_indices - mesh = Mesh( - shader, - shader_wrapper.vert_data, - indices=np.asarray(vert_indices) if vert_indices is not None else None, - use_depth_test=shader_wrapper.depth_test, - primitive=mobject.render_primitive, - ) - mesh.set_uniforms(self) - mesh.render() - - def get_texture_id(self, path: str) -> int: - """ - Retrieves the OpenGL texture ID associated with the given image file path. - - Automatically creates a new texture it it has not been loaded before. - - Parameters - ---------- - path : str - The file path to the texture image. - - Returns - ------- - int - The OpenGL texture ID corresponding to the given path. - """ - return ( - self.path_to_texture_id[path] - if path in self.path_to_texture_id - else self._create_texture(path) - ) - - def _create_texture(self, image_path: str) -> int: - """ - Create an OpenGL texture from the given image file path, get its texture ID, - and store it in `self.path_to_texture_id[image_path]`. - - Parameters - ---------- - image_path : str - The file path to the image to be loaded as a texture. - - Returns - ------- - int - The texture ID assigned to the newly created texture. - """ - with Image.open(image_path) as img: - tid = len(self.path_to_texture_id) - - # grayscale image - if img.mode == "L": - components = 1 - swizzle = "RRR1" - else: - # convert everything to RGBA for consistency - img = img.convert("RGBA") - components = 4 - swizzle = "RGBA" - - texture = self.context.texture( - size=img.size, - components=components, - data=img.tobytes(), - ) - texture.repeat_x = False - texture.repeat_y = False - texture.filter = (moderngl.NEAREST, moderngl.NEAREST) - texture.swizzle = swizzle - texture.use(location=tid) - self.path_to_texture_id[image_path] = tid - return tid - - def update_skipping_status(self) -> None: - """ - Check and update the skipping status for the current animation - (self.skip_animations flag) based on the configuration settings. - - Parameters - ---------- - None - - Raises - ------ - EndSceneEarlyException - If the number of played animations exceeds the configured upper bound. - """ - # there is always at least one section -> no out of bounds here - if self.file_writer.sections[-1].skip_animations: - self.skip_animations = True - if self.file_writer.output_spec.is_still: - self.skip_animations = True - if ( - config.from_animation_number > 0 - and self.num_plays < config.from_animation_number - ): - self.skip_animations = True - if ( - config.upto_animation_number >= 0 - and self.num_plays > config.upto_animation_number - ): - self.skip_animations = True - raise EndSceneEarlyException() - - @handle_caching_play - def play( - self, - scene: Scene, - *animations: Animation | Mobject | _AnimationBuilder, - **kwargs: Any, - ) -> None: - """ - Plays the given animations or mobjects in the specified scene. - - "Playing" here refers to the process of compiling animation data, - beginning the animations, updating frames, and finalizing the animation - in the context of the renderer. - - Parameters - ---------- - scene Scene - The scene in which to play the animations. - *animations Animation | Mobject | _AnimationBuilder - The animations, mobjects, or animation builders to play. - **kwargs Any - Additional keyword arguments to pass to the animation compilation. - """ - # TODO: Handle data locking / unlocking. - self.animation_start_time = time.time() - self.file_writer.begin_animation( - not self.skip_animations, - animation_index=self.num_plays, - ) - - scene.compile_animation_data(*animations, **kwargs) - scene.begin_animations() - if scene.is_current_animation_frozen_frame(): - self.update_frame(scene) - - output = self.file_writer.output_spec - if not self.skip_animations and ( - output.is_video or output.is_image_sequence - ): - self.file_writer.write_frame( - self.get_frame(), - repeat=int(config.frame_rate * scene.duration), - ) - - if self.window is not None: - self.window.swap_buffers() - while time.time() - self.animation_start_time < scene.duration: - pass - self.animation_elapsed_time = scene.duration - - else: - scene.play_internal() - - self.file_writer.end_animation(not self.skip_animations) - self.time += scene.duration - self.num_plays += 1 - - def clear_screen(self) -> None: - """ - Clears the current frame buffer and updates the display window - accordingly. - - The screen is cleared using the background color specified - in the renderer. - """ - self.frame_buffer_object.clear(*self.background_color) - if self.window is None: - return - self.window.swap_buffers() - - def render( - self, scene: Scene, frame_offset: float, moving_mobjects: list[Mobject] - ) -> None: - """ - Renders a single frame of the given scene using OpenGL. - - Parameters - ---------- - scene : Scene - The scene to render. - frame_offset : float - The time offset for the current frame in seconds. If no window is present, - this parameter is ignored, and a frame is a true snapshot of - the scene at the current time. - moving_mobjects : list[Mobject] - List of mobjects that are currently moving and need to be updated. - Not used at all, kept for compatibility with other renderers. - - Notes - ----- - - Updates the frame for the scene. - - If animations are skipped, the method returns early. - - Writes the current frame using the file writer. - - If a window is present, swaps buffers and continues - updating frames until the animation elapsed time reaches the frame offset. - """ - self.update_frame(scene) - - if self.skip_animations: - return - - output = self.file_writer.output_spec - if output.is_video or output.is_image_sequence: - self.file_writer.write_frame(self.get_frame()) - - if self.window is not None: - self.window.swap_buffers() - while self.animation_elapsed_time < frame_offset: - self.update_frame(scene) - self.window.swap_buffers() - - def update_frame(self, scene: Scene) -> None: - """ - Update and render the current frame for the given scene. - - Performs the following steps: - 1. Clear the frame buffer with the background color. - 2. Refresh camera perspective uniforms for rendering. - 3. Iterate through all mobjects in the scene, rendering those marked for display. - 4. Iterate through all mesh objects in the scene, setting their uniforms and rendering them. - 5. Update the elapsed animation time. - - Parameters - ---------- - scene : Scene - The scene to render the frame for. - """ - self.frame_buffer_object.clear(*self.background_color) - - # TODO: make the type of 'camera' generic in the 'Scene' class - # to avoid the cast here - cam = typing.cast("OpenGLCamera", scene.camera) - self.refresh_perspective_uniforms(cam) - - for mobject in scene.mobjects: - if not mobject.should_render: - continue - - # TODO: make the type of 'mobject' generic in the 'Scene' class - # to avoid the cast here - mobj = typing.cast("OpenGLMobject | OpenGLVMobject", mobject) - self.render_mobject(mobj) - - for obj in scene.meshes: - for mesh in obj.get_meshes(): - mesh.set_uniforms(self) - mesh.render() - - self.animation_elapsed_time = time.time() - self.animation_start_time - - def scene_finished(self, scene: Scene) -> None: - """Finalize configured output for the scene. - - Parameters - ---------- - scene - The scene that has finished rendering. - """ - output = self.file_writer.output_spec - if self.num_plays > 0 and (output.is_video or output.is_image_sequence): - self.file_writer.finish() - elif self.num_plays == 0: - # Keep the framebuffer useful for direct renderer access and - # graphical tests even when no media artifact was requested. - self.update_frame(scene) - - if self.should_save_last_frame(): - if self.num_plays > 0: - self.update_frame(scene) - self.file_writer.save_image(self.get_frame()) - - def should_save_last_frame(self) -> bool: - """ - Determine whether the last frame of the scene should be saved. - - This is true for explicit last-frame PNG output and for automatic video - output when the scene has no play calls. Interactive scenes do not use - the automatic fallback. - """ - output = self.file_writer.output_spec - if output.is_still: - return True - if self.scene.interactive_mode: - return False - return self.num_plays == 0 and output.fallback_to_still - - def get_image(self) -> Image.Image: - """ - Get the current OpenGL frame buffer as a PIL Image. - - Returns - ------- - Image.Image - The image representation of the current frame buffer. - - Raises - ------ - ValueError - If the pixel shape cannot be determined. - - Notes - ----- - The image is constructed from raw RGBA buffer data, with the - origin at the bottom-left. - """ - raw_buffer_data = self.get_raw_frame_buffer_object_data() - pixel_shape = self.get_pixel_shape() - if pixel_shape is None: - msg = "Pixel shape is None, cannot get image." - raise ValueError(msg) - - image = Image.frombytes( - "RGBA", # mode (rgb, a for alpha (transparency))) - pixel_shape, # size - raw_buffer_data, # data - "raw", # decoder_name - # *args for the decoder - "RGBA", # raw mode - 0, # stride (O = no extra padding) - -1, # orientation (-1 = bottom to top, 1 = top to bottom) - ) - return image - - def save_static_frame_data( - self, scene: Scene, static_mobjects: Iterable[Mobject] - ) -> None: - pass - - def get_frame_buffer_object( - self, context: moderngl.Context, samples: int = 0 - ) -> Framebuffer: - """ - Creates and returns a framebuffer object configured with color - and depth attachments. - - Parameters - ---------- - context : moderngl.Context - The ModernGL context used to create the framebuffer and - its attachments. - samples : int, optional - The number of samples for multisample anti-aliasing (MSAA)[1]_. - Default is 0 (no MSAA). - - Returns - ------- - Framebuffer - A framebuffer object with a color texture attachment and - a depth renderbuffer attachment, both sized according to - the current configuration's pixel width and height. - - Notes - ----- - Framebuffer's color attachment is supposed RGBA. - Pixel dimensions are taken from the global config of Manim. - - References - ---------- - .. [1] Wikipedia, "Multisample anti-aliasing", - https://en.wikipedia.org/wiki/Multisample_anti-aliasing - """ - pixel_width = config["pixel_width"] - pixel_height = config["pixel_height"] - num_channels = 4 - return context.framebuffer( - color_attachments=context.texture( - (pixel_width, pixel_height), - components=num_channels, - samples=samples, - ), - depth_attachment=context.depth_renderbuffer( - (pixel_width, pixel_height), - samples=samples, - ), - ) - - def get_raw_frame_buffer_object_data(self, dtype: str = "f1") -> bytes: - """ - Get the raw data from the current frame buffer object as bytes. - - This method reads the pixel data from the frame buffer object using the specified data type. - The data is read with 4 color channels (typically RGBA). - - Args: - dtype (str, optional): The data type to use when reading the buffer. - Defaults to "f1" (i.e., float with 1 byte). - - Returns: - bytes: The raw pixel data from the frame buffer object. - """ - # Copy blocks from the fbo_msaa to the drawn fbo using Blit - # pw, ph = self.get_pixel_shape() - # gl.glBindFramebuffer(gl.GL_READ_FRAMEBUFFER, self.fbo_msaa.glo) - # gl.glBindFramebuffer(gl.GL_DRAW_FRAMEBUFFER, self.fbo.glo) - # gl.glBlitFramebuffer( - # 0, 0, pw, ph, 0, 0, pw, ph, gl.GL_COLOR_BUFFER_BIT, gl.GL_LINEAR - # ) - num_channels = 4 - ret: bytes = self.frame_buffer_object.read( - viewport=self.frame_buffer_object.viewport, - components=num_channels, - dtype=dtype, - ) - return ret - - def get_frame(self) -> RGBAPixelArray: - """ - Get the current frame buffer as a Numpy array of RGBA pixel values. - - Returns - ------- - RGBAPixelArray - A Numpy array of shape (height, width, 4) containing the - RGBA pixel data of the current frame, with dtype uint8. - - Raises - ------ - ValueError - If the pixel shape cannot be determined. - """ - # get current pixel values as numpy data in order to test output - raw = self.get_raw_frame_buffer_object_data(dtype="f1") - pixel_shape = self.get_pixel_shape() - if pixel_shape is None: - msg = "Pixel shape is None, cannot get frame." - raise ValueError(msg) - - result_dimensions = (pixel_shape[1], pixel_shape[0], 4) - np_buf = np.frombuffer(raw, dtype="uint8").reshape(result_dimensions) - return np.flipud(np_buf).copy() - - # Returns offset from the bottom left corner in pixels. - # top_left flag should be set to True when using a GUI framework - # where the (0,0) is at the top left: e.g. PySide6 - def pixel_coords_to_space_coords( - self, px: float, py: float, relative: bool = False, top_left: bool = False - ) -> Point3D: - """ - Converts pixel coordinates to space (scene) coordinates. - - top_left flag should be set to True when using a GUI framework - where the (0,0) is at the top left: e.g. PySide6. - - Parameters - ---------- - px : float - The x-coordinate in pixel space. - py : float - The y-coordinate in pixel space. - relative : bool, optional - If True, returns coordinates relative to the frame (normalized to [-1, 1]). - If False, returns absolute space coordinates. Default is False. - top_left : bool, optional - If True, treats the origin (0, 0) as the top-left corner of the pixel space. - If False, treats the origin as the bottom-left. Default is False. - - Returns - ------- - Point3D - The corresponding coordinates in space as a NumPy array of shape (3,). - - Notes - ----- - If the pixel shape is not available, returns the origin [0, 0, 0]. - """ - pixel_shape = self.get_pixel_shape() - if pixel_shape is None: - return typing.cast(Point3D, np.array([0.0, 0.0, 0.0])) - pixel_width, pixel_height = pixel_shape - frame_height = config["frame_height"] - frame_center = self.camera.get_center() - if relative: - # relative -> just normalize to [-1, 1] - return 2 * np.array([px / pixel_width, py / pixel_height, 0]) - - scale = frame_height / pixel_height - y_direction = -1 if top_left else 1 - - return typing.cast( - Point3D, - frame_center - + scale - * np.array( - [(px - pixel_width / 2), y_direction * (py - pixel_height / 2), 0.0] - ), - ) - - @property - def background_color(self) -> FloatRGBA: - """The background color of the renderer (RGBA format).""" - return self._background_color - - @background_color.setter - def background_color(self, value: ParsableManimColor) -> None: - self._background_color = color_to_rgba(value, 1.0) diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 29f69d5c52..9dd853cddc 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -40,7 +40,7 @@ from manim import __version__ from manim.data_structures import MethodWithArgs from manim.mobject.mobject import Mobject -from manim.mobject.opengl.opengl_mobject import OpenGLPoint +from manim.mobject.opengl.opengl_mobject import OpenGLMobject, OpenGLPoint from .. import config, logger from .._config.logger_utils import set_file_logger @@ -57,8 +57,8 @@ from ..constants import * from ..manager import Manager from ..renderer.cairo_renderer import CairoRenderer -from ..renderer.opengl_renderer import OpenGLCamera, OpenGLMobject, OpenGLRenderer -from ..renderer.shader import Object3D +from ..renderer.opengl import OpenGLCamera, OpenGLRenderer +from ..renderer.opengl.shader import Object3D from ..scene.scene_file_writer import _SceneFileWriterSettings from ..utils import opengl, space_ops from ..utils.exceptions import RerunSceneException diff --git a/manim/scene/three_d_scene.py b/manim/scene/three_d_scene.py index 7d2337437e..7d412b91e9 100644 --- a/manim/scene/three_d_scene.py +++ b/manim/scene/three_d_scene.py @@ -23,7 +23,7 @@ from ..constants import DEGREES, RendererType from ..mobject.mobject import Mobject from ..mobject.types.vectorized_mobject import VectorizedPoint, VGroup -from ..renderer.opengl_renderer import OpenGLCamera +from ..renderer.opengl import OpenGLCamera from ..scene.scene import Scene from ..utils.config_ops import merge_dicts_recursively diff --git a/manim/scene/zoomed_scene.py b/manim/scene/zoomed_scene.py index 57c89b1ad6..72f0120b0f 100644 --- a/manim/scene/zoomed_scene.py +++ b/manim/scene/zoomed_scene.py @@ -57,7 +57,7 @@ def construct(self): from ..camera.multi_camera import MultiCamera from ..constants import * from ..mobject.types.image_mobject import ImageMobjectFromCamera -from ..renderer.opengl_renderer import OpenGLCamera +from ..renderer.opengl import OpenGLCamera from ..scene.moving_camera_scene import MovingCameraScene if TYPE_CHECKING: diff --git a/manim/utils/caching.py b/manim/utils/caching.py index 78e011071a..e226957f36 100644 --- a/manim/utils/caching.py +++ b/manim/utils/caching.py @@ -79,7 +79,7 @@ def clear_segment_cache(directory: Path) -> int: if TYPE_CHECKING: - from manim.renderer.opengl_renderer import OpenGLRenderer + from manim.renderer.opengl import OpenGLRenderer from manim.scene.scene import Scene diff --git a/manim/utils/hashing.py b/manim/utils/hashing.py index 3b36e00176..8a5353f774 100644 --- a/manim/utils/hashing.py +++ b/manim/utils/hashing.py @@ -20,7 +20,7 @@ from manim.animation.animation import Animation from manim.camera.camera import Camera from manim.mobject.mobject import Mobject - from manim.renderer.opengl_renderer import OpenGLCamera + from manim.renderer.opengl import OpenGLCamera from manim.scene.scene import Scene __all__ = ["KEYS_TO_FILTER_OUT", "get_hash_from_play_call", "get_json"] diff --git a/manim/utils/ipython_magic.py b/manim/utils/ipython_magic.py index 7585768d11..b11187c124 100644 --- a/manim/utils/ipython_magic.py +++ b/manim/utils/ipython_magic.py @@ -9,7 +9,7 @@ from typing import Any from manim import config, logger, tempconfig -from manim.renderer.shader import shader_program_cache +from manim.renderer.opengl.shader import shader_program_cache from ..constants import RendererType @@ -136,7 +136,7 @@ def construct(self): renderer = None if config.renderer == RendererType.OPENGL: - from manim.renderer.opengl_renderer import OpenGLRenderer + from manim.renderer.opengl import OpenGLRenderer renderer = OpenGLRenderer() diff --git a/manim/utils/testing/_test_class_makers.py b/manim/utils/testing/_test_class_makers.py index 49a1a85148..ac8f011c20 100644 --- a/manim/utils/testing/_test_class_makers.py +++ b/manim/utils/testing/_test_class_makers.py @@ -4,7 +4,7 @@ from typing import Any from manim.renderer.cairo_renderer import CairoRenderer -from manim.renderer.opengl_renderer import OpenGLRenderer +from manim.renderer.opengl import OpenGLRenderer from manim.scene.scene import Scene from manim.scene.scene_file_writer import SceneFileWriter, _SceneFileWriterSettings from manim.typing import PixelArray, StrPath diff --git a/manim/utils/testing/frames_comparison.py b/manim/utils/testing/frames_comparison.py index 01061d6860..fd8b26ce05 100644 --- a/manim/utils/testing/frames_comparison.py +++ b/manim/utils/testing/frames_comparison.py @@ -15,7 +15,7 @@ from manim._config.utils import ManimConfig from manim.camera.three_d_camera import ThreeDCamera from manim.renderer.cairo_renderer import CairoRenderer -from manim.renderer.opengl_renderer import OpenGLRenderer +from manim.renderer.opengl import OpenGLRenderer from manim.scene.three_d_scene import ThreeDScene from manim.typing import StrPath diff --git a/tests/test_graphical_units/test_opengl.py b/tests/test_graphical_units/test_opengl.py index 6d36f8761a..c35a30fd2c 100644 --- a/tests/test_graphical_units/test_opengl.py +++ b/tests/test_graphical_units/test_opengl.py @@ -1,7 +1,7 @@ from __future__ import annotations from manim import * -from manim.renderer.opengl_renderer import OpenGLRenderer +from manim.renderer.opengl import OpenGLRenderer from manim.utils.testing.frames_comparison import frames_comparison __module_test__ = "opengl" diff --git a/tests/test_scene_rendering/opengl/test_opengl_renderer.py b/tests/test_scene_rendering/opengl/test_opengl_renderer.py index c2925b7372..416720f005 100644 --- a/tests/test_scene_rendering/opengl/test_opengl_renderer.py +++ b/tests/test_scene_rendering/opengl/test_opengl_renderer.py @@ -6,11 +6,17 @@ import numpy as np import pytest -from manim.renderer.opengl_renderer import OpenGLRenderer +from manim.renderer.opengl import OpenGLRenderer from tests.assert_utils import assert_file_exists from tests.test_scene_rendering.simple_scenes import * +def test_opengl_renderer_import_compatibility(): + from manim.renderer.opengl_renderer import OpenGLRenderer as LegacyOpenGLRenderer + + assert OpenGLRenderer is LegacyOpenGLRenderer + + def test_file_output_disables_window( config, using_temp_opengl_config, disabling_caching ): From 288ce99f2c53c7b88ec71647889e968b8a54c341 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Tue, 1 Sep 2026 15:29:48 +0200 Subject: [PATCH 02/13] Move Cairo rasterization behind semantic cameras --- manim/__init__.py | 2 +- manim/camera/camera.py | 1572 ++--------------- manim/camera/moving_camera.py | 287 +-- manim/camera/multi_camera.py | 105 +- manim/camera/three_d_camera.py | 21 +- manim/manager.py | 2 +- manim/mobject/mobject.py | 14 +- manim/mobject/types/image_mobject.py | 83 +- manim/renderer/cairo/__init__.py | 19 + manim/renderer/cairo/renderer.py | 375 ++++ manim/renderer/cairo/rendering.py | 480 +++++ manim/renderer/cairo/target.py | 202 +++ manim/renderer/cairo_renderer.py | 298 +--- manim/scene/moving_camera_scene.py | 49 +- manim/scene/scene.py | 17 +- manim/scene/three_d_scene.py | 126 +- manim/utils/testing/_test_class_makers.py | 2 +- manim/utils/testing/frames_comparison.py | 2 +- .../logs_data/BasicSceneLoggingTest.txt | 2 +- tests/test_camera.py | 268 ++- .../camera/moving_camera_frame.npz | Bin 0 -> 8497 bytes .../camera/zoomed_camera_view.npz | Bin 0 -> 3736 bytes tests/test_graphical_units/test_camera.py | 41 + .../test_cairo_renderer.py | 13 +- 24 files changed, 1755 insertions(+), 2225 deletions(-) create mode 100644 manim/renderer/cairo/__init__.py create mode 100644 manim/renderer/cairo/renderer.py create mode 100644 manim/renderer/cairo/rendering.py create mode 100644 manim/renderer/cairo/target.py create mode 100644 tests/test_graphical_units/control_data/camera/moving_camera_frame.npz create mode 100644 tests/test_graphical_units/control_data/camera/zoomed_camera_view.npz create mode 100644 tests/test_graphical_units/test_camera.py diff --git a/manim/__init__.py b/manim/__init__.py index 9fd8e65f1d..b2e35fd555 100644 --- a/manim/__init__.py +++ b/manim/__init__.py @@ -83,7 +83,7 @@ from .mobject.types.vectorized_mobject import * from .mobject.value_tracker import * from .mobject.vector_field import * -from .renderer.cairo_renderer import * +from .renderer.cairo import * from .scene.moving_camera_scene import * from .scene.scene import * from .scene.scene_file_writer import * diff --git a/manim/camera/camera.py b/manim/camera/camera.py index 1d54e5cf95..c4b39901d2 100644 --- a/manim/camera/camera.py +++ b/manim/camera/camera.py @@ -1,178 +1,106 @@ -"""A camera converts the mobjects contained in a Scene into an array of pixels.""" +"""Semantic camera state shared with rendering backends.""" from __future__ import annotations -__all__ = ["Camera", "BackgroundColoredVMobjectDisplayer"] +__all__ = ["Camera"] -import copy -import itertools as it import operator as op -import pathlib -from collections.abc import Callable, Iterable +from collections.abc import Iterable from functools import reduce -from typing import TYPE_CHECKING, Any, Self +from typing import TYPE_CHECKING, Literal, overload -import cairo import numpy as np -from PIL import Image -from manim._config import config, logger -from manim.constants import * -from manim.mobject.mobject import Mobject -from manim.mobject.types.point_cloud_mobject import PMobject +from manim._config import config +from manim.constants import DOWN, LEFT, RIGHT, UP +from manim.mobject.frame import ScreenRectangle +from manim.mobject.mobject import Mobject, _AnimationBuilder from manim.mobject.types.vectorized_mobject import VMobject -from manim.utils.color import ManimColor, ParsableManimColor, color_to_int_rgba +from manim.utils.color import WHITE, ManimColor, ParsableManimColor from manim.utils.family import extract_mobject_family_members -from manim.utils.images import get_full_raster_image_path from manim.utils.iterables import list_difference_update -from manim.utils.space_ops import cross2d if TYPE_CHECKING: - import numpy.typing as npt - - from manim.mobject.types.image_mobject import AbstractImageMobject from manim.typing import ( FloatRGBA_Array, - FloatRGBALike_Array, - ManimFloat, - ManimInt, - PixelArray, Point3D, Point3D_Array, + Point3DLike, ) -LINE_JOIN_MAP = { - LineJointType.AUTO: None, # TODO: this could be improved - LineJointType.ROUND: cairo.LineJoin.ROUND, - LineJointType.BEVEL: cairo.LineJoin.BEVEL, - LineJointType.MITER: cairo.LineJoin.MITER, -} - - -CAP_STYLE_MAP = { - CapStyleType.AUTO: None, # TODO: this could be improved - CapStyleType.ROUND: cairo.LineCap.ROUND, - CapStyleType.BUTT: cairo.LineCap.BUTT, - CapStyleType.SQUARE: cairo.LineCap.SQUARE, -} - - class Camera: - """Base camera class. + """Describe the logical view used by a rendering backend. - This is the object which takes care of what exactly is displayed - on screen at any given moment. - - Parameters - ---------- - background_image - The path to an image that should be the background image. - If not set, the background is filled with :attr:`self.background_color` - background - What :attr:`background` is set to. By default, ``None``. - pixel_height - The height of the scene in pixels. - pixel_width - The width of the scene in pixels. - kwargs - Additional arguments (``background_color``, ``background_opacity``) - to be set. + Camera owns an animatable frame, semantic background settings, display ordering, + and pure point transformations. Raster targets, pixel dimensions, image buffers, + and backend contexts belong to renderers. """ def __init__( self, background_image: str | None = None, - frame_center: Point3D = ORIGIN, - image_mode: str = "RGBA", - n_channels: int = 4, - pixel_array_dtype: str = "uint8", - cairo_line_width_multiple: float = 0.01, + frame_center: Point3DLike | Mobject | None = None, + frame: Mobject | None = None, + default_frame_stroke_color: ManimColor = WHITE, + default_frame_stroke_width: float = 0, use_z_index: bool = True, - background: PixelArray | None = None, - pixel_height: int | None = None, - pixel_width: int | None = None, frame_height: float | None = None, frame_width: float | None = None, - frame_rate: float | None = None, background_color: ParsableManimColor | None = None, background_opacity: float | None = None, - **kwargs: Any, ) -> None: self.background_image = background_image - self.frame_center = frame_center - self.image_mode = image_mode - self.n_channels = n_channels - self.pixel_array_dtype = pixel_array_dtype - self.cairo_line_width_multiple = cairo_line_width_multiple self.use_z_index = use_z_index - self.background = background - self.background_colored_vmobject_displayer: ( - BackgroundColoredVMobjectDisplayer | None - ) = None - - if pixel_height is None: - pixel_height = config["pixel_height"] - self.pixel_height = pixel_height - - if pixel_width is None: - pixel_width = config["pixel_width"] - self.pixel_width = pixel_width - - if frame_height is None: - frame_height = config["frame_height"] - self.frame_height = frame_height - if frame_width is None: - frame_width = config["frame_width"] - self.frame_width = frame_width - - if frame_rate is None: - frame_rate = config["frame_rate"] - self.frame_rate = frame_rate - - if background_color is None: - self._background_color: ManimColor = ManimColor.parse( - config["background_color"] + if frame is None: + resolved_height = ( + float(config["frame_height"]) if frame_height is None else frame_height + ) + resolved_width = ( + float(config["frame_width"]) if frame_width is None else frame_width + ) + if resolved_height <= 0 or resolved_width <= 0: + raise ValueError("Camera frame dimensions must be positive.") + frame = ScreenRectangle( + aspect_ratio=resolved_width / resolved_height, + height=resolved_height, + ) + frame.set_stroke( + ManimColor(default_frame_stroke_color), + default_frame_stroke_width, ) else: - self._background_color = ManimColor.parse(background_color) - if background_opacity is None: - self._background_opacity: float = config["background_opacity"] - else: - self._background_opacity = background_opacity - - # This one is in the same boat as the above, but it doesn't have the - # same name as the corresponding key so it has to be handled on its own - self.max_allowable_norm = config["frame_width"] - - self.rgb_max_val = np.iinfo(self.pixel_array_dtype).max - self.pixel_array_to_cairo_context: dict[int, cairo.Context] = {} - - # Contains the correct method to process a list of Mobjects of the - # corresponding class. If a Mobject is not an instance of a class in - # this dict (or an instance of a class that inherits from a class in - # this dict), then it cannot be rendered. - - self.init_background() - self.resize_frame_shape() - self.reset() - - def __deepcopy__(self, memo: Any) -> Camera: - # This is to address a strange bug where deepcopying - # will result in a segfault, which is somehow related - # to the aggdraw library - self.canvas = None - return copy.copy(self) + if frame_height is not None: + if frame_height <= 0: + raise ValueError("Camera frame height must be positive.") + frame.stretch_to_fit_height(frame_height) + if frame_width is not None: + if frame_width <= 0: + raise ValueError("Camera frame width must be positive.") + frame.stretch_to_fit_width(frame_width) + self.frame = frame + if frame_center is not None: + self.frame_center = frame_center + + self._background_color = ManimColor( + config["background_color"] + if background_color is None + else background_color, + ) + self._background_opacity = ( + config["background_opacity"] + if background_opacity is None + else background_opacity + ) @property def background_color(self) -> ManimColor: return self._background_color @background_color.setter - def background_color(self, color: ManimColor) -> None: - self._background_color = color - self.init_background() + def background_color(self, color: ParsableManimColor) -> None: + self._background_color = ManimColor(color) @property def background_opacity(self) -> float: @@ -181,293 +109,41 @@ def background_opacity(self) -> float: @background_opacity.setter def background_opacity(self, alpha: float) -> None: self._background_opacity = alpha - self.init_background() - - def type_or_raise( - self, mobject: Mobject - ) -> type[VMobject] | type[PMobject] | type[AbstractImageMobject] | type[Mobject]: - """Return the type of mobject, if it is a type that can be rendered. - - If `mobject` is an instance of a class that inherits from a class that - can be rendered, return the super class. For example, an instance of a - Square is also an instance of VMobject, and these can be rendered. - Therefore, `type_or_raise(Square())` returns True. - - Parameters - ---------- - mobject - The object to take the type of. - Notes - ----- - For a list of classes that can currently be rendered, see :meth:`display_funcs`. - - Returns - ------- - Type[:class:`~.Mobject`] - The type of mobjects, if it can be rendered. - - Raises - ------ - :exc:`TypeError` - When mobject is not an instance of a class that can be rendered. - """ - from ..mobject.types.image_mobject import AbstractImageMobject - - self.display_funcs: dict[ - type[Mobject], Callable[[list[Mobject], PixelArray], Any] - ] = { - VMobject: self.display_multiple_vectorized_mobjects, # type: ignore[dict-item] - PMobject: self.display_multiple_point_cloud_mobjects, # type: ignore[dict-item] - AbstractImageMobject: self.display_multiple_image_mobjects, # type: ignore[dict-item] - Mobject: lambda batch, pa: batch, # Do nothing - } - # We have to check each type in turn because we are dealing with - # super classes. For example, if square = Square(), then - # type(square) != VMobject, but isinstance(square, VMobject) == True. - for _type in self.display_funcs: - if isinstance(mobject, _type): - return _type - raise TypeError(f"Displaying an object of class {_type} is not supported") - - def reset_pixel_shape(self, new_height: float, new_width: float) -> None: - """This method resets the height and width - of a single pixel to the passed new_height and new_width. - - Parameters - ---------- - new_height - The new height of the entire scene in pixels - new_width - The new width of the entire scene in pixels - """ - self.pixel_width = new_width - self.pixel_height = new_height - self.init_background() - self.resize_frame_shape() - self.reset() - - def resize_frame_shape(self, fixed_dimension: int = 0) -> None: - """ - Changes frame_shape to match the aspect ratio - of the pixels, where fixed_dimension determines - whether frame_height or frame_width - remains fixed while the other changes accordingly. - - Parameters - ---------- - fixed_dimension - If 0, height is scaled with respect to width - else, width is scaled with respect to height. - """ - pixel_height = self.pixel_height - pixel_width = self.pixel_width - frame_height = self.frame_height - frame_width = self.frame_width - aspect_ratio = pixel_width / pixel_height - if fixed_dimension == 0: - frame_height = frame_width / aspect_ratio - else: - frame_width = aspect_ratio * frame_height - self.frame_height = frame_height - self.frame_width = frame_width - - def init_background(self) -> None: - """Initialize the background. - If self.background_image is the path of an image - the image is set as background; else, the default - background color fills the background. - """ - height = self.pixel_height - width = self.pixel_width - if self.background_image is not None: - path = get_full_raster_image_path(self.background_image) - image = Image.open(path).convert(self.image_mode) - # TODO, how to gracefully handle backgrounds - # with different sizes? - self.background = np.array(image)[:height, :width] - self.background = self.background.astype(self.pixel_array_dtype) - else: - background_rgba = color_to_int_rgba( - self.background_color, - self.background_opacity, - ) - self.background = np.zeros( - (height, width, self.n_channels), - dtype=self.pixel_array_dtype, - ) - self.background[:, :] = background_rgba - - def get_image( - self, pixel_array: PixelArray | list | tuple | None = None - ) -> Image.Image: - """Returns an image from the passed - pixel array, or from the current frame - if the passed pixel array is none. - - Parameters - ---------- - pixel_array - The pixel array from which to get an image, by default None - - Returns - ------- - PIL.Image.Image - The PIL image of the array. - """ - if pixel_array is None: - pixel_array = self.pixel_array - return Image.fromarray(pixel_array, mode=self.image_mode) - - def convert_pixel_array(self, pixel_array: PixelArray | list | tuple) -> PixelArray: - """Converts a pixel array with float values to proper RGB values. - - Parameters - ---------- - pixel_array - Pixel array to convert. - - Returns - ------- - np.array - The new, converted pixel array. - """ - pixel_array = np.asarray(pixel_array) - return np.apply_along_axis( - lambda f: (f * self.rgb_max_val).astype(self.pixel_array_dtype), - 2, - pixel_array, - ) - - def set_pixel_array( - self, pixel_array: PixelArray | list | tuple, convert_from_floats: bool = False - ) -> None: - """Sets the pixel array of the camera to the passed pixel array. - - Parameters - ---------- - pixel_array - The pixel array to convert and then set as the camera's pixel array. - convert_from_floats - Whether or not to convert float values to proper RGB values, by default False - """ - converted_array: PixelArray = ( - self.convert_pixel_array(pixel_array) - if convert_from_floats - else np.asarray(pixel_array) - ) - if ( - hasattr(self, "pixel_array") - and self.pixel_array.shape == converted_array.shape - ): - # Set in place - np.copyto(self.pixel_array, converted_array, casting="unsafe") - else: - self.pixel_array: PixelArray = converted_array.copy() - - def set_background( - self, pixel_array: PixelArray | list | tuple, convert_from_floats: bool = False - ) -> None: - """Sets the background to the passed pixel_array after converting - to valid RGB values. - - Parameters - ---------- - pixel_array - The pixel array to set the background to. - convert_from_floats - Whether or not to convert floats values to proper RGB valid ones, by default False - """ - self.background = ( - self.convert_pixel_array(pixel_array) - if convert_from_floats - else np.array(pixel_array) - ) - - # TODO, this should live in utils, not as a method of Camera - def make_background_from_func( - self, coords_to_colors_func: Callable[[np.ndarray], np.ndarray] - ) -> PixelArray: - """ - Makes a pixel array for the background by using coords_to_colors_func to determine each pixel's color. Each input - pixel's color. Each input to coords_to_colors_func is an (x, y) pair in space (in ordinary space coordinates; not - pixel coordinates), and each output is expected to be an RGBA array of 4 floats. - - Parameters - ---------- - coords_to_colors_func - The function whose input is an (x,y) pair of coordinates and - whose return values must be the colors for that point - - Returns - ------- - np.array - The pixel array which can then be passed to set_background. - """ - logger.info("Starting set_background") - coords = self.get_coords_of_all_pixels() - new_background = np.apply_along_axis(coords_to_colors_func, 2, coords) - logger.info("Ending set_background") - - return self.convert_pixel_array(new_background) - - def set_background_from_func( - self, coords_to_colors_func: Callable[[np.ndarray], np.ndarray] - ) -> None: - """ - Sets the background to a pixel array using coords_to_colors_func to determine each pixel's color. Each input - pixel's color. Each input to coords_to_colors_func is an (x, y) pair in space (in ordinary space coordinates; not - pixel coordinates), and each output is expected to be an RGBA array of 4 floats. + @property + def frame_height(self) -> float: + """Height of the logical camera frame in Manim units.""" + return self.frame.height - Parameters - ---------- - coords_to_colors_func - The function whose input is an (x,y) pair of coordinates and - whose return values must be the colors for that point - """ - self.set_background(self.make_background_from_func(coords_to_colors_func)) + @frame_height.setter + def frame_height(self, frame_height: float) -> None: + self.frame.stretch_to_fit_height(frame_height) - def reset(self) -> Self: - """Resets the camera's pixel array - to that of the background + @property + def frame_width(self) -> float: + """Width of the logical camera frame in Manim units.""" + return self.frame.width - Returns - ------- - Camera - The camera object after setting the pixel array. - """ - assert self.background is not None - self.set_pixel_array(self.background) - return self + @frame_width.setter + def frame_width(self, frame_width: float) -> None: + self.frame.stretch_to_fit_width(frame_width) - def set_frame_to_background(self, background: PixelArray) -> None: - self.set_pixel_array(background) + @property + def frame_center(self) -> Point3D: + """Center of the logical camera frame.""" + return self.frame.get_center() - #### + @frame_center.setter + def frame_center(self, frame_center: Point3DLike | Mobject) -> None: + self.frame.move_to(frame_center) def get_mobjects_to_display( self, mobjects: Iterable[Mobject], include_submobjects: bool = True, - excluded_mobjects: list | None = None, + excluded_mobjects: list[Mobject] | None = None, ) -> list[Mobject]: - """Used to get the list of mobjects to display - with the camera. - - Parameters - ---------- - mobjects - The Mobjects - include_submobjects - Whether or not to include the submobjects of mobjects, by default True - excluded_mobjects - Any mobjects to exclude, by default None - - Returns - ------- - list - list of mobjects - """ + """Return the camera-ordered family members visible to the renderer.""" if include_submobjects: mobjects = extract_mobject_family_members( mobjects, @@ -483,1023 +159,123 @@ def get_mobjects_to_display( return list(mobjects) def is_in_frame(self, mobject: Mobject) -> bool: - """Checks whether the passed mobject is in - frame or not. - - Parameters - ---------- - mobject - The mobject for which the checking needs to be done. - - Returns - ------- - bool - True if in frame, False otherwise. - """ - fc = self.frame_center - fh = self.frame_height - fw = self.frame_width + """Whether ``mobject`` intersects the logical frame bounds.""" + center = self.frame_center + height = self.frame_height + width = self.frame_width return not reduce( op.or_, [ - mobject.get_right()[0] < fc[0] - fw / 2, - mobject.get_bottom()[1] > fc[1] + fh / 2, - mobject.get_left()[0] > fc[0] + fw / 2, - mobject.get_top()[1] < fc[1] - fh / 2, + mobject.get_right()[0] < center[0] - width / 2, + mobject.get_bottom()[1] > center[1] + height / 2, + mobject.get_left()[0] > center[0] + width / 2, + mobject.get_top()[1] < center[1] - height / 2, ], ) - def capture_mobject(self, mobject: Mobject, **kwargs: Any) -> None: - """Capture mobjects by storing it in :attr:`pixel_array`. - - This is a single-mobject version of :meth:`capture_mobjects`. - - Parameters - ---------- - mobject - Mobject to capture. - - kwargs - Keyword arguments to be passed to :meth:`get_mobjects_to_display`. - - """ - return self.capture_mobjects([mobject], **kwargs) - - def capture_mobjects(self, mobjects: Iterable[Mobject], **kwargs: Any) -> None: - """Capture mobjects by printing them on :attr:`pixel_array`. - - This is the essential function that converts the contents of a Scene - into an array, which is then converted to an image or video. - - Parameters - ---------- - mobjects - Mobjects to capture. - - kwargs - Keyword arguments to be passed to :meth:`get_mobjects_to_display`. - - Notes - ----- - For a list of classes that can currently be rendered, see :meth:`display_funcs`. - - """ - # The mobjects will be processed in batches (or runs) of mobjects of - # the same type. That is, if the list mobjects contains objects of - # types [VMobject, VMobject, VMobject, PMobject, PMobject, VMobject], - # then they will be captured in three batches: [VMobject, VMobject, - # VMobject], [PMobject, PMobject], and [VMobject]. This must be done - # without altering their order. it.groupby computes exactly this - # partition while at the same time preserving order. - mobjects = self.get_mobjects_to_display(mobjects, **kwargs) - for group_type, group in it.groupby(mobjects, self.type_or_raise): - self.display_funcs[group_type](list(group), self.pixel_array) - - # Methods associated with svg rendering - - # NOTE: None of the methods below have been mentioned outside of their definitions. Their DocStrings are not as - # detailed as possible. - - def get_cached_cairo_context(self, pixel_array: PixelArray) -> cairo.Context | None: - """Returns the cached cairo context of the passed - pixel array if it exists, and None if it doesn't. - - Parameters - ---------- - pixel_array - The pixel array to check. - - Returns - ------- - cairo.Context - The cached cairo context. - """ - return self.pixel_array_to_cairo_context.get(id(pixel_array), None) - - def cache_cairo_context(self, pixel_array: PixelArray, ctx: cairo.Context) -> None: - """Caches the passed Pixel array into a Cairo Context - - Parameters - ---------- - pixel_array - The pixel array to cache - ctx - The context to cache it into. - """ - self.pixel_array_to_cairo_context[id(pixel_array)] = ctx - - def get_cairo_context(self, pixel_array: PixelArray) -> cairo.Context: - """Returns the cairo context for a pixel array after - caching it to self.pixel_array_to_cairo_context - If that array has already been cached, it returns the - cached version instead. - - Parameters - ---------- - pixel_array - The Pixel array to get the cairo context of. - - Returns - ------- - cairo.Context - The cairo context of the pixel array. - """ - cached_ctx = self.get_cached_cairo_context(pixel_array) - if cached_ctx: - return cached_ctx - pw = self.pixel_width - ph = self.pixel_height - fw = self.frame_width - fh = self.frame_height - fc = self.frame_center - surface = cairo.ImageSurface.create_for_data( - pixel_array.data, - cairo.FORMAT_ARGB32, - pw, - ph, - ) - ctx = cairo.Context(surface) - ctx.scale(pw, ph) - ctx.set_matrix( - cairo.Matrix( - (pw / fw), - 0, - 0, - -(ph / fh), - (pw / 2) - fc[0] * (pw / fw), - (ph / 2) + fc[1] * (ph / fh), - ), - ) - self.cache_cairo_context(pixel_array, ctx) - return ctx - - def display_multiple_vectorized_mobjects( - self, vmobjects: list[VMobject], pixel_array: PixelArray - ) -> None: - """Displays multiple VMobjects in the pixel_array - - Parameters - ---------- - vmobjects - list of VMobjects to display - pixel_array - The pixel array - """ - if len(vmobjects) == 0: - return - batch_image_pairs = it.groupby(vmobjects, lambda vm: vm.get_background_image()) - for image, batch in batch_image_pairs: - if image: - self.display_multiple_background_colored_vmobjects(batch, pixel_array) - else: - self.display_multiple_non_background_colored_vmobjects( - batch, - pixel_array, - ) - - def display_multiple_non_background_colored_vmobjects( - self, vmobjects: Iterable[VMobject], pixel_array: PixelArray - ) -> None: - """Displays multiple VMobjects in the cairo context, as long as they don't have - background colors. - - Parameters - ---------- - vmobjects - list of the VMobjects - pixel_array - The Pixel array to add the VMobjects to. - """ - ctx = self.get_cairo_context(pixel_array) - for vmobject in vmobjects: - self.display_vectorized(vmobject, ctx) - - def display_vectorized(self, vmobject: VMobject, ctx: cairo.Context) -> Self: - """Displays a VMobject in the cairo context - - Parameters - ---------- - vmobject - The Vectorized Mobject to display - ctx - The cairo context to use. + def get_mobjects_indicating_movement(self) -> list[Mobject]: + """Camera controls whose animation changes every projected pixel.""" + return [self.frame] - Returns - ------- - Camera - The camera object - """ - self.set_cairo_context_path(ctx, vmobject) - self.apply_stroke(ctx, vmobject, background=True) - self.apply_fill(ctx, vmobject) - self.apply_stroke(ctx, vmobject) - return self - - def set_cairo_context_path(self, ctx: cairo.Context, vmobject: VMobject) -> Self: - """Sets a path for the cairo context with the vmobject passed - - Parameters - ---------- - ctx - The cairo context - vmobject - The VMobject - - Returns - ------- - Camera - Camera object after setting cairo_context_path - """ - points = self.transform_points_pre_display(vmobject, vmobject.points) - if len(points) == 0: - return self - - nppcc = vmobject.n_points_per_cubic_curve # 4 for cubic bezier - - ctx.new_path() - - # Subpath boundaries are computed by VMobject; a split occurs wherever - # one curve's end anchor is not close to the next curve's start anchor. - split_indices = vmobject.get_subpath_split_indices_from_points(points, n_dims=2) - if len(split_indices) == 0: - return self - - # Precompute flat xy array for fast indexing - pts_xy = points[:, :2].ravel() # [x0, y0, x1, y1, ...] + @overload + def auto_zoom( + self, + mobjects: Iterable[Mobject], + margin: float = 0, + only_mobjects_in_frame: bool = False, + animate: Literal[False] = False, + ) -> Mobject: ... - # Local references for speed (avoid attribute lookups in loop) - _move_to = ctx.move_to - _curve_to = ctx.curve_to - _new_sub_path = ctx.new_sub_path - _close_path = ctx.close_path + @overload + def auto_zoom( + self, + mobjects: Iterable[Mobject], + margin: float = 0, + only_mobjects_in_frame: bool = False, + animate: Literal[True] = True, + ) -> _AnimationBuilder: ... - for start_idx, end_idx in split_indices: - start_idx = int(start_idx) - end_idx = int(end_idx) - if end_idx - start_idx < nppcc: + def auto_zoom( + self, + mobjects: Iterable[Mobject], + margin: float = 0, + only_mobjects_in_frame: bool = False, + animate: bool = True, + ) -> _AnimationBuilder | Mobject: + """Move and resize the frame to contain the supplied 2D mobjects.""" + ( + left, + right, + top, + bottom, + ) = self._get_bounding_box(mobjects, only_mobjects_in_frame) + x = (left + right) / 2 + y = (top + bottom) / 2 + new_width = abs(left - right) + new_height = abs(top - bottom) + target = self.frame.animate if animate else self.frame + if new_width / self.frame.width > new_height / self.frame.height: + return target.set_x(x).set_y(y).set(width=new_width + margin) + return target.set_x(x).set_y(y).set(height=new_height + margin) + + def _get_bounding_box( + self, + mobjects: Iterable[Mobject], + only_mobjects_in_frame: bool, + ) -> tuple[float, float, float, float]: + bounds: tuple[float, float, float, float] | None = None + for mobject in mobjects: + if mobject is self.frame or ( + only_mobjects_in_frame and not self.is_in_frame(mobject) + ): continue - - _new_sub_path() - # move_to first point - base = start_idx * 2 - _move_to(pts_xy[base], pts_xy[base + 1]) - - # Emit all cubic curves in this subpath. - # Points are: [anchor, handle1, handle2, anchor, handle1, handle2, anchor, ...] - # Each curve uses indices 1,2,3 relative to the start of each group of 4. - for i in range(start_idx, end_idx - nppcc + 1, nppcc): - b = (i + 1) * 2 # handle1 - _curve_to( - pts_xy[b], - pts_xy[b + 1], - pts_xy[b + 2], - pts_xy[b + 3], - pts_xy[b + 4], - pts_xy[b + 5], + mobject_bounds = ( + float(mobject.get_critical_point(LEFT)[0]), + float(mobject.get_critical_point(RIGHT)[0]), + float(mobject.get_critical_point(UP)[1]), + float(mobject.get_critical_point(DOWN)[1]), + ) + if bounds is None: + bounds = mobject_bounds + else: + bounds = ( + min(bounds[0], mobject_bounds[0]), + max(bounds[1], mobject_bounds[1]), + max(bounds[2], mobject_bounds[2]), + min(bounds[3], mobject_bounds[3]), ) + if bounds is None: + raise ValueError( + "Could not determine the bounding box of the mobjects given to " + "Camera.auto_zoom().", + ) + return bounds - # Close if first and last points are equal. - if vmobject.consider_points_equals_2d( - points[start_idx], points[end_idx - 1] - ): - _close_path() - - return self - - def set_cairo_context_color( - self, ctx: cairo.Context, rgbas: FloatRGBALike_Array, vmobject: VMobject - ) -> Self: - """Sets the color of the cairo context - - Parameters - ---------- - ctx - The cairo context - rgbas - The RGBA array with which to color the context. - vmobject - The VMobject with which to set the color. - - Returns - ------- - Camera - The camera object - """ - if len(rgbas) == 1: - # Use reversed rgb because cairo surface is - # encodes it in reverse order - ctx.set_source_rgba(*rgbas[0][2::-1], rgbas[0][3]) - else: - points = vmobject.get_gradient_start_and_end_points() - points = self.transform_points_pre_display(vmobject, points) - pat = cairo.LinearGradient(*it.chain(*(point[:2] for point in points))) - offsets = np.linspace(0, 1, len(rgbas)) - for rgba, offset in zip(rgbas, offsets, strict=True): - pat.add_color_stop_rgba(offset, *rgba[2::-1], rgba[3]) - ctx.set_source(pat) - return self - - def apply_fill(self, ctx: cairo.Context, vmobject: VMobject) -> Self: - """Fills the cairo context - - Parameters - ---------- - ctx - The cairo context - vmobject - The VMobject - - Returns - ------- - Camera - The camera object. - """ - self.set_cairo_context_color(ctx, self.get_fill_rgbas(vmobject), vmobject) - ctx.fill_preserve() - return self - - def apply_stroke( - self, ctx: cairo.Context, vmobject: VMobject, background: bool = False - ) -> Self: - """Applies a stroke to the VMobject in the cairo context. + def _prepare_for_render(self) -> None: + """Refresh derived semantic view state before a renderer borrows it.""" - Parameters - ---------- - ctx - The cairo context - vmobject - The VMobject - background - Whether or not to consider the background when applying this - stroke width, by default False - - Returns - ------- - Camera - The camera object with the stroke applied. - """ - width = vmobject.get_stroke_width(background) - if width == 0: - return self - self.set_cairo_context_color( - ctx, - self.get_stroke_rgbas(vmobject, background=background), - vmobject, - ) - ctx.set_line_width( - width - * self.cairo_line_width_multiple - * (self.frame_width / self.frame_width), - # This ensures lines have constant width as you zoom in on them. - ) - if vmobject.joint_type != LineJointType.AUTO: - ctx.set_line_join(LINE_JOIN_MAP[vmobject.joint_type]) - if vmobject.cap_style != CapStyleType.AUTO: - ctx.set_line_cap(CAP_STYLE_MAP[vmobject.cap_style]) - ctx.stroke_preserve() - return self + def get_view_transform_center(self) -> Point3D: + """Return the center applied by the renderer's 2D view transform.""" + return self.frame_center def get_stroke_rgbas( - self, vmobject: VMobject, background: bool = False + self, + vmobject: VMobject, + background: bool = False, ) -> FloatRGBA_Array: - """Gets the RGBA array for the stroke of the passed - VMobject. - - Parameters - ---------- - vmobject - The VMobject - background - Whether or not to consider the background when getting the stroke - RGBAs, by default False - - Returns - ------- - np.ndarray - The RGBA array of the stroke. - """ + """Return stroke colors after camera-specific semantic shading.""" return vmobject.get_stroke_rgbas(background) def get_fill_rgbas(self, vmobject: VMobject) -> FloatRGBA_Array: - """Returns the RGBA array of the fill of the passed VMobject - - Parameters - ---------- - vmobject - The VMobject - - Returns - ------- - np.array - The RGBA Array of the fill of the VMobject - """ + """Return fill colors after camera-specific semantic shading.""" return vmobject.get_fill_rgbas() - def get_background_colored_vmobject_displayer( - self, - ) -> BackgroundColoredVMobjectDisplayer: - """Returns the background_colored_vmobject_displayer - if it exists or makes one and returns it if not. - - Returns - ------- - BackgroundColoredVMobjectDisplayer - Object that displays VMobjects that have the same color - as the background. - """ - if self.background_colored_vmobject_displayer is None: - self.background_colored_vmobject_displayer = ( - BackgroundColoredVMobjectDisplayer(self) - ) - return self.background_colored_vmobject_displayer - - def display_multiple_background_colored_vmobjects( - self, cvmobjects: Iterable[VMobject], pixel_array: PixelArray - ) -> Self: - """Displays multiple vmobjects that have the same color as the background. - - Parameters - ---------- - cvmobjects - List of Colored VMobjects - pixel_array - The pixel array. - - Returns - ------- - Camera - The camera object. - """ - displayer = self.get_background_colored_vmobject_displayer() - cvmobject_pixel_array = displayer.display(*cvmobjects) - self.overlay_rgba_array(pixel_array, cvmobject_pixel_array) - return self - - # Methods for other rendering - - # NOTE: Out of the following methods, only `transform_points_pre_display` and `points_to_pixel_coords` have been mentioned outside of their definitions. - # As a result, the other methods do not have as detailed docstrings as would be preferred. - - def display_multiple_point_cloud_mobjects( - self, pmobjects: Iterable[PMobject], pixel_array: PixelArray - ) -> None: - """Displays multiple PMobjects by modifying the passed pixel array. - - Parameters - ---------- - pmobjects - List of PMobjects - pixel_array - The pixel array to modify. - """ - for pmobject in pmobjects: - self.display_point_cloud( - pmobject, - pmobject.points, - pmobject.rgbas, - self.adjusted_thickness(pmobject.stroke_width), - pixel_array, - ) - - def display_point_cloud( - self, - pmobject: PMobject, - points: Point3D_Array, - rgbas: FloatRGBA_Array, - thickness: float, - pixel_array: PixelArray, - ) -> None: - """Displays a PMobject by modifying the pixel array suitably. - - TODO: Write a description for the rgbas argument. - - Parameters - ---------- - pmobject - Point Cloud Mobject - points - The points to display in the point cloud mobject - rgbas - - thickness - The thickness of each point of the PMobject - pixel_array - The pixel array to modify. - - """ - if len(points) == 0: - return - pixel_coords = self.points_to_pixel_coords(pmobject, points) - pixel_coords = self.thickened_coordinates(pixel_coords, thickness) - rgba_len = pixel_array.shape[2] - - rgbas = (self.rgb_max_val * rgbas).astype(self.pixel_array_dtype) - target_len = len(pixel_coords) - factor = target_len // len(rgbas) - rgbas = np.array([rgbas] * factor).reshape((target_len, rgba_len)) - - on_screen_indices = self.on_screen_pixels(pixel_coords) - pixel_coords = pixel_coords[on_screen_indices] - rgbas = rgbas[on_screen_indices] - - ph = self.pixel_height - pw = self.pixel_width - - flattener = np.array([1, pw], dtype="int") - flattener = flattener.reshape((2, 1)) - indices = np.dot(pixel_coords, flattener)[:, 0] - indices = indices.astype("int") - - new_pa = pixel_array.reshape((ph * pw, rgba_len)) - new_pa[indices] = rgbas - pixel_array[:, :] = new_pa.reshape((ph, pw, rgba_len)) - - def display_multiple_image_mobjects( - self, - image_mobjects: Iterable[AbstractImageMobject], - pixel_array: PixelArray, - ) -> None: - """Displays multiple image mobjects by modifying the passed pixel_array. - - Parameters - ---------- - image_mobjects - list of ImageMobjects - pixel_array - The pixel array to modify. - """ - for image_mobject in image_mobjects: - self.display_image_mobject(image_mobject, pixel_array) - - def display_image_mobject( - self, image_mobject: AbstractImageMobject, pixel_array: np.ndarray - ) -> None: - """Display an :class:`~.ImageMobject` by changing the ``pixel_array`` suitably. - - Parameters - ---------- - image_mobject - The :class:`~.ImageMobject` to display. - pixel_array - The pixel array to put the :class:`~.ImageMobject` in. - """ - sub_image = Image.fromarray(image_mobject.get_pixel_array(), mode="RGBA") - original_coords = np.array( - [ - [0, 0], - [sub_image.width, 0], - [0, sub_image.height], - [sub_image.width, sub_image.height], - ] - ) - target_coords = self.points_to_subpixel_coords( - image_mobject, image_mobject.points - ) - int_target_coords = target_coords.astype(np.int64) - - # Temporarily translate target coords to upper left corner to calculate the - # smallest possible size for the target image. - shift_vector = np.array( - [ - min(*[x for x, y in int_target_coords]), - min(*[y for x, y in int_target_coords]), - ] - ) - target_coords -= shift_vector - int_target_coords -= shift_vector - target_size = ( - max(*[x for x, y in int_target_coords]), - max(*[y for x, y in int_target_coords]), - ) - - # Check that the quadrilateral of the transformed image can actually contain any - # pixels by checking that its height from the longest side is longer than 0.5 pixels. - # If it's not, do not render the image. Otherwise, the perspective transform - # coefficients below might have broken values due to the extreme distortion (for - # example, when the image is perpendicular to the camera). - ordered_vertices = [target_coords[i] for i in (0, 1, 3, 2)] - sides = [ordered_vertices[(i + 1) % 4] - ordered_vertices[i] for i in range(4)] - side_lengths_in_pixels = np.linalg.norm(sides, axis=1) - - longest_side_index = np.argmax(side_lengths_in_pixels) - longest_side = sides[longest_side_index] - longest_side_length_in_pixels = side_lengths_in_pixels[longest_side_index] - if longest_side_length_in_pixels == 0: - return - - previous_side = sides[(longest_side_index - 1) % 4] - next_side = sides[(longest_side_index - 1) % 4] - - # height = area / base - h1 = abs(cross2d(longest_side, previous_side)) / longest_side_length_in_pixels - h2 = abs(cross2d(longest_side, next_side)) / longest_side_length_in_pixels - height_from_longest_side_in_pixels = max(h1, h2) - - if height_from_longest_side_in_pixels < 0.5: - return - - # Use PIL.Image.Image.transform() to apply a perspective transform to the image. - # The transform coefficients must be calculated. The following is adapted from: - # https://pc-pillow.readthedocs.io/en/latest/Image_class/Image_transform.html#transform-perspective-coefficients - # https://stackoverflow.com/questions/14177744/how-does-perspective-transformation-work-in-pil - # The derivation can be found here: - # https://web.archive.org/web/20150222120106/xenia.media.mit.edu/~cwren/interpolator/ - homography_matrix = [] - for (x, y), (X, Y) in zip(target_coords, original_coords, strict=True): - homography_matrix.append([x, y, 1, 0, 0, 0, -X * x, -X * y]) - homography_matrix.append([0, 0, 0, x, y, 1, -Y * x, -Y * y]) - - A = np.array(homography_matrix, dtype=np.float64) - b = original_coords.reshape(8).astype(np.float64) - - try: - transform_coefficients = np.linalg.solve(A, b) - except np.linalg.LinAlgError: - # The matrix A might be singular if three points are collinear. - # In this case, do nothing and return. - return - - sub_image = sub_image.transform( - size=target_size, # Use the smallest possible size for speed. - method=Image.Transform.PERSPECTIVE, - data=transform_coefficients, - resample=image_mobject.resampling_algorithm, - ) - - # Paste into an image as large as the camera's pixel array. - full_image = Image.fromarray( - np.zeros((self.pixel_height, self.pixel_width)), - mode="RGBA", - ) - full_image.paste( - sub_image, - box=( - shift_vector[0], - shift_vector[1], - shift_vector[0] + target_size[0], - shift_vector[1] + target_size[1], - ), - ) - # Paint on top of existing pixel array. - self.overlay_PIL_image(pixel_array, full_image) - - def overlay_rgba_array( - self, pixel_array: np.ndarray, new_array: np.ndarray - ) -> None: - """Overlays an RGBA array on top of the given Pixel array. - - Parameters - ---------- - pixel_array - The original pixel array to modify. - new_array - The new pixel array to overlay. - """ - self.overlay_PIL_image(pixel_array, self.get_image(new_array)) - - def overlay_PIL_image(self, pixel_array: np.ndarray, image: Image) -> None: - """Overlays a PIL image on the passed pixel array. - - Parameters - ---------- - pixel_array - The Pixel array - image - The Image to overlay. - """ - pixel_array[:, :] = np.array( - Image.alpha_composite(self.get_image(pixel_array), image), - dtype="uint8", - ) - - def adjust_out_of_range_points(self, points: np.ndarray) -> np.ndarray: - """If any of the points in the passed array are out of - the viable range, they are adjusted suitably. - - Parameters - ---------- - points - The points to adjust - - Returns - ------- - np.array - The adjusted points. - """ - if not np.any(points > self.max_allowable_norm): - return points - norms = np.apply_along_axis(np.linalg.norm, 1, points) - violator_indices = norms > self.max_allowable_norm - violators = points[violator_indices, :] - violator_norms = norms[violator_indices] - reshaped_norms = np.repeat( - violator_norms.reshape((len(violator_norms), 1)), - points.shape[1], - 1, - ) - rescaled = self.max_allowable_norm * violators / reshaped_norms - points[violator_indices] = rescaled - return points - def transform_points_pre_display( self, mobject: Mobject, points: Point3D_Array, - ) -> Point3D_Array: # TODO: Write more detailed docstrings for this method. - # NOTE: There seems to be an unused argument `mobject`. - - # Subclasses (like ThreeDCamera) may want to - # adjust points further before they're shown + ) -> Point3D_Array: + """Apply camera-specific pure projection before display.""" if not np.all(np.isfinite(points)): - # TODO, print some kind of warning about - # mobject having invalid points? - points = np.zeros((1, 3)) + return np.zeros((1, 3)) return points - - def points_to_subpixel_coords( - self, - mobject: Mobject, - points: Point3D_Array, - ) -> npt.NDArray[ - ManimFloat - ]: # TODO: Write more detailed docstrings for this method. - points = self.transform_points_pre_display(mobject, points) - shifted_points = points - self.frame_center - - result = np.zeros((len(points), 2)) - pixel_height = self.pixel_height - pixel_width = self.pixel_width - frame_height = self.frame_height - frame_width = self.frame_width - width_mult = pixel_width / frame_width - width_add = pixel_width / 2 - height_mult = pixel_height / frame_height - height_add = pixel_height / 2 - # Flip on y-axis as you go - height_mult *= -1 - - result[:, 0] = shifted_points[:, 0] * width_mult + width_add - result[:, 1] = shifted_points[:, 1] * height_mult + height_add - return result - - def points_to_pixel_coords( - self, - mobject: Mobject, - points: Point3D_Array, - ) -> npt.NDArray[ManimInt]: # TODO: Write more detailed docstrings for this method. - return self.points_to_subpixel_coords(mobject, points).astype(np.int64) - - def on_screen_pixels(self, pixel_coords: np.ndarray) -> PixelArray: - """Returns array of pixels that are on the screen from a given - array of pixel_coordinates - - Parameters - ---------- - pixel_coords - The pixel coords to check. - - Returns - ------- - np.array - The pixel coords on screen. - """ - return reduce( - op.and_, - [ - pixel_coords[:, 0] >= 0, - pixel_coords[:, 0] < self.pixel_width, - pixel_coords[:, 1] >= 0, - pixel_coords[:, 1] < self.pixel_height, - ], - ) - - def adjusted_thickness(self, thickness: float) -> float: - """Computes the adjusted stroke width for a zoomed camera. - - Parameters - ---------- - thickness - The stroke width of a mobject. - - Returns - ------- - float - The adjusted stroke width that reflects zooming in with - the camera. - """ - # TODO: This seems...unsystematic - big_sum: float = op.add(config["pixel_height"], config["pixel_width"]) - this_sum: float = op.add(self.pixel_height, self.pixel_width) - factor = big_sum / this_sum - return 1 + (thickness - 1) * factor - - def get_thickening_nudges(self, thickness: float) -> PixelArray: - """Determine a list of vectors used to nudge - two-dimensional pixel coordinates. - - Parameters - ---------- - thickness - - Returns - ------- - np.array - - """ - thickness = int(thickness) - _range = list(range(-thickness // 2 + 1, thickness // 2 + 1)) - return np.array(list(it.product(_range, _range))) - - def thickened_coordinates( - self, pixel_coords: np.ndarray, thickness: float - ) -> PixelArray: - """Returns thickened coordinates for a passed array of pixel coords and - a thickness to thicken by. - - Parameters - ---------- - pixel_coords - Pixel coordinates - thickness - Thickness - - Returns - ------- - np.array - Array of thickened pixel coords. - """ - nudges = self.get_thickening_nudges(thickness) - pixel_coords = np.array([pixel_coords + nudge for nudge in nudges]) - size = pixel_coords.size - return pixel_coords.reshape((size // 2, 2)) - - # TODO, reimplement using cairo matrix - def get_coords_of_all_pixels(self) -> PixelArray: - """Returns the cartesian coordinates of each pixel. - - Returns - ------- - np.ndarray - The array of cartesian coordinates. - """ - # These are in x, y order, to help me keep things straight - full_space_dims = np.array([self.frame_width, self.frame_height]) - full_pixel_dims = np.array([self.pixel_width, self.pixel_height]) - - # These are addressed in the same y, x order as in pixel_array, but the values in them - # are listed in x, y order - uncentered_pixel_coords = np.indices([self.pixel_height, self.pixel_width])[ - ::-1 - ].transpose(1, 2, 0) - uncentered_space_coords = ( - uncentered_pixel_coords * full_space_dims - ) / full_pixel_dims - # Could structure above line's computation slightly differently, but figured (without much - # thought) multiplying by frame_shape first, THEN dividing by pixel_shape, is probably - # better than the other order, for avoiding underflow quantization in the division (whereas - # overflow is unlikely to be a problem) - - centered_space_coords = uncentered_space_coords - (full_space_dims / 2) - - # Have to also flip the y coordinates to account for pixel array being listed in - # top-to-bottom order, opposite of screen coordinate convention - centered_space_coords = centered_space_coords * (1, -1) - - return centered_space_coords - - -# NOTE: The methods of the following class have not been mentioned outside of their definitions. -# Their DocStrings are not as detailed as preferred. -class BackgroundColoredVMobjectDisplayer: - """Auxiliary class that handles displaying vectorized mobjects with - a set background image. - - Parameters - ---------- - camera - Camera object to use. - """ - - def __init__(self, camera: Camera): - self.camera = camera - self.file_name_to_pixel_array_map: dict[str, PixelArray] = {} - self.pixel_array = np.array(camera.pixel_array) - self.reset_pixel_array() - - def reset_pixel_array(self) -> None: - self.pixel_array[:, :] = 0 - - def resize_background_array( - self, - background_array: PixelArray, - new_width: float, - new_height: float, - mode: str = "RGBA", - ) -> PixelArray: - """Resizes the pixel array representing the background. - - Parameters - ---------- - background_array - The pixel - new_width - The new width of the background - new_height - The new height of the background - mode - The PIL image mode, by default "RGBA" - - Returns - ------- - np.array - The numpy pixel array of the resized background. - """ - image = Image.fromarray(background_array) - image = image.convert(mode) - resized_image = image.resize((new_width, new_height)) - return np.array(resized_image) - - def resize_background_array_to_match( - self, background_array: PixelArray, pixel_array: PixelArray - ) -> PixelArray: - """Resizes the background array to match the passed pixel array. - - Parameters - ---------- - background_array - The prospective pixel array. - pixel_array - The pixel array whose width and height should be matched. - - Returns - ------- - np.array - The resized background array. - """ - height, width = pixel_array.shape[:2] - mode = "RGBA" if pixel_array.shape[2] == 4 else "RGB" - return self.resize_background_array(background_array, width, height, mode) - - def get_background_array( - self, image: Image.Image | pathlib.Path | str - ) -> PixelArray: - """Gets the background array that has the passed file_name. - - Parameters - ---------- - image - The background image or its file name. - - Returns - ------- - np.ndarray - The pixel array of the image. - """ - image_key = str(image) - - if image_key in self.file_name_to_pixel_array_map: - return self.file_name_to_pixel_array_map[image_key] - if isinstance(image, str): - full_path = get_full_raster_image_path(image) - image = Image.open(full_path) - back_array = np.array(image) - - pixel_array = self.pixel_array - if not np.all(pixel_array.shape == back_array.shape): - back_array = self.resize_background_array_to_match(back_array, pixel_array) - - self.file_name_to_pixel_array_map[image_key] = back_array - return back_array - - def display(self, *cvmobjects: VMobject) -> PixelArray | None: - """Displays the colored VMobjects. - - Parameters - ---------- - *cvmobjects - The VMobjects - - Returns - ------- - np.array - The pixel array with the `cvmobjects` displayed. - """ - batch_image_pairs = it.groupby(cvmobjects, lambda cv: cv.get_background_image()) - curr_array = None - for image, batch in batch_image_pairs: - background_array = self.get_background_array(image) - pixel_array = self.pixel_array - self.camera.display_multiple_non_background_colored_vmobjects( - batch, - pixel_array, - ) - new_array = np.array( - (background_array * pixel_array.astype("float") / 255), - dtype=self.camera.pixel_array_dtype, - ) - if curr_array is None: - curr_array = new_array - else: - curr_array = np.maximum(curr_array, new_array) - self.reset_pixel_array() - return curr_array diff --git a/manim/camera/moving_camera.py b/manim/camera/moving_camera.py index 3bb61120d2..f6d7836a52 100644 --- a/manim/camera/moving_camera.py +++ b/manim/camera/moving_camera.py @@ -1,292 +1,15 @@ -"""Defines the MovingCamera class, a camera that can pan and zoom through a scene. - -.. SEEALSO:: - - :mod:`.moving_camera_scene` -""" +"""Compatibility name for the now-movable default Cairo camera.""" from __future__ import annotations __all__ = ["MovingCamera"] -from collections.abc import Iterable -from typing import Any, Literal, overload - -from cairo import Context - -from manim.typing import PixelArray, Point3D, Point3DLike - -from .. import config -from ..camera.camera import Camera -from ..constants import DOWN, LEFT, RIGHT, UP -from ..mobject.frame import ScreenRectangle -from ..mobject.mobject import Mobject, _AnimationBuilder -from ..utils.color import WHITE, ManimColor +from .camera import Camera class MovingCamera(Camera): - """A camera that follows and matches the size and position of its 'frame', a Rectangle (or similar Mobject). - - The frame defines the region of space the camera displays and can move or resize dynamically. - - .. SEEALSO:: + """Compatibility subclass of :class:`~manim.camera.camera.Camera`. - :class:`.MovingCameraScene` + The default Cairo camera now owns the same animatable frame and + :meth:`~manim.camera.camera.Camera.auto_zoom` behavior. """ - - def __init__( - self, - frame: Mobject | None = None, - fixed_dimension: int = 0, # width - default_frame_stroke_color: ManimColor = WHITE, - default_frame_stroke_width: int = 0, - **kwargs: Any, - ): - """Frame is a Mobject, (should almost certainly be a rectangle) - determining which region of space the camera displays - """ - self.fixed_dimension = fixed_dimension - self.default_frame_stroke_color = default_frame_stroke_color - self.default_frame_stroke_width = default_frame_stroke_width - if frame is None: - frame = ScreenRectangle(height=config["frame_height"]) - frame.set_stroke( - self.default_frame_stroke_color, - self.default_frame_stroke_width, - ) - self.frame = frame - super().__init__(**kwargs) - - # TODO, make these work for a rotated frame - @property - def frame_height(self) -> float: - """Returns the height of the frame. - - Returns - ------- - float - The height of the frame. - """ - return self.frame.height - - @frame_height.setter - def frame_height(self, frame_height: float) -> None: - """Sets the height of the frame in MUnits. - - Parameters - ---------- - frame_height - The new frame_height. - """ - self.frame.stretch_to_fit_height(frame_height) - - @property - def frame_width(self) -> float: - """Returns the width of the frame - - Returns - ------- - float - The width of the frame. - """ - return self.frame.width - - @frame_width.setter - def frame_width(self, frame_width: float) -> None: - """Sets the width of the frame in MUnits. - - Parameters - ---------- - frame_width - The new frame_width. - """ - self.frame.stretch_to_fit_width(frame_width) - - @property - def frame_center(self) -> Point3D: - """Returns the centerpoint of the frame in cartesian coordinates. - - Returns - ------- - np.array - The cartesian coordinates of the center of the frame. - """ - return self.frame.get_center() - - @frame_center.setter - def frame_center(self, frame_center: Point3DLike | Mobject) -> None: - """Sets the centerpoint of the frame. - - Parameters - ---------- - frame_center - The point to which the frame must be moved. - If is of type mobject, the frame will be moved to - the center of that mobject. - """ - self.frame.move_to(frame_center) - - def capture_mobjects(self, mobjects: Iterable[Mobject], **kwargs: Any) -> None: - # self.reset_frame_center() - # self.realign_frame_shape() - super().capture_mobjects(mobjects, **kwargs) - - def get_cached_cairo_context(self, pixel_array: PixelArray) -> None: - """Since the frame can be moving around, the cairo - context used for updating should be regenerated - at each frame. So no caching. - """ - return None - - def cache_cairo_context(self, pixel_array: PixelArray, ctx: Context) -> None: - """Since the frame can be moving around, the cairo - context used for updating should be regenerated - at each frame. So no caching. - """ - pass - - # def reset_frame_center(self): - # self.frame_center = self.frame.get_center() - - # def realign_frame_shape(self): - # height, width = self.frame_shape - # if self.fixed_dimension == 0: - # self.frame_shape = (height, self.frame.width - # else: - # self.frame_shape = (self.frame.height, width) - # self.resize_frame_shape(fixed_dimension=self.fixed_dimension) - - def get_mobjects_indicating_movement(self) -> list[Mobject]: - """Returns all mobjects whose movement implies that the camera - should think of all other mobjects on the screen as moving - - Returns - ------- - list[Mobject] - """ - return [self.frame] - - @overload - def auto_zoom( - self, - mobjects: Iterable[Mobject], - margin: float, - only_mobjects_in_frame: bool, - animate: Literal[False], - ) -> Mobject: ... - - @overload - def auto_zoom( - self, - mobjects: Iterable[Mobject], - margin: float, - only_mobjects_in_frame: bool, - animate: Literal[True], - ) -> _AnimationBuilder: ... - - def auto_zoom( - self, - mobjects: Iterable[Mobject], - margin: float = 0, - only_mobjects_in_frame: bool = False, - animate: bool = True, - ) -> _AnimationBuilder | Mobject: - """Zooms on to a given array of mobjects (or a singular mobject) - and automatically resizes to frame all the mobjects. - - .. NOTE:: - - This method only works when 2D-objects in the XY-plane are considered, it - will not work correctly when the camera has been rotated. - - Parameters - ---------- - mobjects - The mobject or array of mobjects that the camera will focus on. - - margin - The width of the margin that is added to the frame (optional, 0 by default). - - only_mobjects_in_frame - If set to ``True``, only allows focusing on mobjects that are already in frame. - - animate - If set to ``False``, applies the changes instead of returning the corresponding animation - - Returns - ------- - Union[_AnimationBuilder, ScreenRectangle] - _AnimationBuilder that zooms the camera view to a given list of mobjects - or ScreenRectangle with position and size updated to zoomed position. - - """ - ( - scene_critical_x_left, - scene_critical_x_right, - scene_critical_y_up, - scene_critical_y_down, - ) = self._get_bounding_box(mobjects, only_mobjects_in_frame) - - # calculate center x and y - x = (scene_critical_x_left + scene_critical_x_right) / 2 - y = (scene_critical_y_up + scene_critical_y_down) / 2 - - # calculate proposed width and height of zoomed scene - new_width = abs(scene_critical_x_left - scene_critical_x_right) - new_height = abs(scene_critical_y_up - scene_critical_y_down) - - m_target = self.frame.animate if animate else self.frame - # zoom to fit all mobjects along the side that has the largest size - if new_width / self.frame.width > new_height / self.frame.height: - return m_target.set_x(x).set_y(y).set(width=new_width + margin) - else: - return m_target.set_x(x).set_y(y).set(height=new_height + margin) - - def _get_bounding_box( - self, mobjects: Iterable[Mobject], only_mobjects_in_frame: bool - ) -> tuple[float, float, float, float]: - bounding_box_located = False - scene_critical_x_left: float = 0 - scene_critical_x_right: float = 1 - scene_critical_y_up: float = 1 - scene_critical_y_down: float = 0 - - for m in mobjects: - if (m == self.frame) or ( - only_mobjects_in_frame and not self.is_in_frame(m) - ): - # detected camera frame, should not be used to calculate final position of camera - continue - - # initialize scene critical points with first mobjects critical points - if not bounding_box_located: - scene_critical_x_left = m.get_critical_point(LEFT)[0] - scene_critical_x_right = m.get_critical_point(RIGHT)[0] - scene_critical_y_up = m.get_critical_point(UP)[1] - scene_critical_y_down = m.get_critical_point(DOWN)[1] - bounding_box_located = True - - else: - if m.get_critical_point(LEFT)[0] < scene_critical_x_left: - scene_critical_x_left = m.get_critical_point(LEFT)[0] - - if m.get_critical_point(RIGHT)[0] > scene_critical_x_right: - scene_critical_x_right = m.get_critical_point(RIGHT)[0] - - if m.get_critical_point(UP)[1] > scene_critical_y_up: - scene_critical_y_up = m.get_critical_point(UP)[1] - - if m.get_critical_point(DOWN)[1] < scene_critical_y_down: - scene_critical_y_down = m.get_critical_point(DOWN)[1] - - if not bounding_box_located: - raise Exception( - "Could not determine bounding box of the mobjects given to 'auto_zoom'." - ) - - return ( - scene_critical_x_left, - scene_critical_x_right, - scene_critical_y_up, - scene_critical_y_down, - ) diff --git a/manim/camera/multi_camera.py b/manim/camera/multi_camera.py index 1ccf11fd1b..a06d7fa28e 100644 --- a/manim/camera/multi_camera.py +++ b/manim/camera/multi_camera.py @@ -1,107 +1,54 @@ -"""A camera supporting multiple perspectives.""" +"""Semantic camera state for nested Cairo camera views.""" from __future__ import annotations __all__ = ["MultiCamera"] - from collections.abc import Iterable -from typing import Any, Self +from typing import Any from manim.mobject.mobject import Mobject from manim.mobject.types.image_mobject import ImageMobjectFromCamera -from ..camera.moving_camera import MovingCamera -from ..utils.iterables import list_difference_update +from .camera import Camera -class MultiCamera(MovingCamera): - """Camera Object that allows for multiple perspectives.""" +class MultiCamera(Camera): + """Describe a primary view with camera-backed image mobjects.""" def __init__( self, image_mobjects_from_cameras: Iterable[ImageMobjectFromCamera] | None = None, - allow_cameras_to_capture_their_own_display: bool = False, **kwargs: Any, ) -> None: - """Initialises the MultiCamera - - Parameters - ---------- - image_mobjects_from_cameras - - kwargs - Any valid keyword arguments of MovingCamera. - """ self.image_mobjects_from_cameras: list[ImageMobjectFromCamera] = [] if image_mobjects_from_cameras is not None: - for imfc in image_mobjects_from_cameras: - self.add_image_mobject_from_camera(imfc) - self.allow_cameras_to_capture_their_own_display = ( - allow_cameras_to_capture_their_own_display - ) + for image_mobject in image_mobjects_from_cameras: + self.add_image_mobject_from_camera(image_mobject) super().__init__(**kwargs) def add_image_mobject_from_camera( - self, image_mobject_from_camera: ImageMobjectFromCamera + self, + image_mobject_from_camera: ImageMobjectFromCamera, ) -> None: - """Adds an ImageMobject that's been obtained from the camera - into the list ``self.image_mobject_from_cameras`` - - Parameters - ---------- - image_mobject_from_camera - The ImageMobject to add to self.image_mobject_from_cameras - """ - # A silly method to have right now, but maybe there are things - # we want to guarantee about any imfc's added later. - imfc = image_mobject_from_camera - assert isinstance(imfc.camera, MovingCamera) - self.image_mobjects_from_cameras.append(imfc) + """Register a camera-backed image for renderer-owned composition.""" + if not isinstance(image_mobject_from_camera.camera, Camera): + raise TypeError("Nested Cairo views require a Cairo Camera.") + self.image_mobjects_from_cameras.append(image_mobject_from_camera) - def update_sub_cameras(self) -> None: - """Reshape sub_camera pixel_arrays""" - for imfc in self.image_mobjects_from_cameras: - pixel_height, pixel_width = self.pixel_array.shape[:2] - # imfc.camera.frame_shape = ( - # imfc.camera.frame.height, - # imfc.camera.frame.width, - # ) - imfc.camera.reset_pixel_shape( - int(pixel_height * imfc.height / self.frame_height), - int(pixel_width * imfc.width / self.frame_width), - ) - - def reset(self) -> Self: - """Resets the MultiCamera. - - Returns - ------- - MultiCamera - The reset MultiCamera - """ - for imfc in self.image_mobjects_from_cameras: - imfc.camera.reset() - super().reset() - return self + def get_mobjects_indicating_movement(self) -> list[Mobject]: + """Return controls whose movement changes a primary or nested view.""" - def capture_mobjects(self, mobjects: Iterable[Mobject], **kwargs: Any) -> None: - self.update_sub_cameras() - for imfc in self.image_mobjects_from_cameras: - to_add = list(mobjects) - if not self.allow_cameras_to_capture_their_own_display: - to_add = list_difference_update(to_add, imfc.get_family()) - imfc.camera.capture_mobjects(to_add, **kwargs) - super().capture_mobjects(mobjects, **kwargs) + def collect(camera: Camera, visited: set[int]) -> list[Mobject]: + if id(camera) in visited: + return [] + visited.add(id(camera)) + if not isinstance(camera, MultiCamera): + return camera.get_mobjects_indicating_movement() - def get_mobjects_indicating_movement(self) -> list[Mobject]: - """Returns all mobjects whose movement implies that the camera - should think of all other mobjects on the screen as moving + indicators = Camera.get_mobjects_indicating_movement(camera) + for image_mobject in camera.image_mobjects_from_cameras: + indicators.extend(collect(image_mobject.camera, visited)) + return indicators - Returns - ------- - list - """ - return [self.frame] + [ - imfc.camera.frame for imfc in self.image_mobjects_from_cameras - ] + return collect(self, set()) diff --git a/manim/camera/three_d_camera.py b/manim/camera/three_d_camera.py index e20512ab9e..6175b4cde9 100644 --- a/manim/camera/three_d_camera.py +++ b/manim/camera/three_d_camera.py @@ -5,7 +5,7 @@ __all__ = ["ThreeDCamera"] -from collections.abc import Callable, Iterable +from collections.abc import Callable from typing import Any import numpy as np @@ -27,7 +27,6 @@ Point3DLike, ) -from .. import config from ..camera.camera import Camera from ..constants import * from ..mobject.types.point_cloud_mobject import Point @@ -58,7 +57,6 @@ def __init__( *kwargs Any keyword argument of Camera. """ - self._frame_center = Point(kwargs.get("frame_center", ORIGIN), stroke_width=0) super().__init__(**kwargs) self.focal_distance = focal_distance self.phi = phi @@ -71,7 +69,6 @@ def __init__( self.light_source = Point(self.light_source_start_point) self.should_apply_shading = should_apply_shading self.exponential_projection = exponential_projection - self.max_allowable_norm = 3 * config["frame_width"] self.phi_tracker = ValueTracker(self.phi) self.theta_tracker = ValueTracker(self.theta) self.focal_distance_tracker = ValueTracker(self.focal_distance) @@ -81,17 +78,15 @@ def __init__( self.fixed_in_frame_mobjects: set[Mobject] = set() self.reset_rotation_matrix() - @property - def frame_center(self) -> Point3D: - return self._frame_center.points[0] + def _prepare_for_render(self) -> None: + self.reset_rotation_matrix() - @frame_center.setter - def frame_center(self, point: Point3DLike) -> None: - self._frame_center.move_to(point) + def get_view_transform_center(self) -> Point3D: + # project_points() already translates by frame_center. + return ORIGIN.copy() - def capture_mobjects(self, mobjects: Iterable[Mobject], **kwargs: Any) -> None: - self.reset_rotation_matrix() - super().capture_mobjects(mobjects, **kwargs) + def get_mobjects_indicating_movement(self) -> list[Mobject]: + return [self.frame, self.light_source, *self.get_value_trackers()] def get_value_trackers(self) -> list[ValueTracker]: """A list of :class:`ValueTrackers <.ValueTracker>` of phi, theta, focal_distance, diff --git a/manim/manager.py b/manim/manager.py index a730191bbd..db2aa1f0d1 100644 --- a/manim/manager.py +++ b/manim/manager.py @@ -18,7 +18,7 @@ from .animation.animation import Animation from .camera.camera import Camera from .mobject.mobject import Mobject, _AnimationBuilder - from .renderer.cairo_renderer import CairoRenderer + from .renderer.cairo import CairoRenderer from .renderer.opengl import OpenGLCamera, OpenGLRenderer from .scene.scene import Scene from .scene.scene_file_writer import SceneFileWriter diff --git a/manim/mobject/mobject.py b/manim/mobject/mobject.py index 0084cb7382..eb190ab842 100644 --- a/manim/mobject/mobject.py +++ b/manim/mobject/mobject.py @@ -997,10 +997,16 @@ def apply_over_attr_arrays(self, func: MultiMappingFunction) -> Self: # Displaying def get_image(self, camera: Camera | None = None) -> Image.Image: - if camera is None: - camera = Camera() - camera.capture_mobject(self) - return camera.get_image() + """Render this mobject with an explicit temporary Cairo renderer.""" + from manim.camera.camera import Camera + from manim.renderer.cairo import CairoRenderer + + renderer = CairoRenderer(camera=Camera() if camera is None else camera) + try: + renderer.render_mobjects([self]) + return renderer.get_image() + finally: + renderer.close() def show(self, camera: Camera | None = None) -> None: self.get_image(camera=camera).show() diff --git a/manim/mobject/types/image_mobject.py b/manim/mobject/types/image_mobject.py index 0fc5a8f86d..406b37987d 100644 --- a/manim/mobject/types/image_mobject.py +++ b/manim/mobject/types/image_mobject.py @@ -14,7 +14,7 @@ from manim.mobject.geometry.shape_matchers import SurroundingRectangle from ... import config -from ...camera.moving_camera import MovingCamera +from ...camera.camera import Camera from ...constants import * from ...mobject.mobject import Mobject from ...utils.bezier import interpolate @@ -36,7 +36,15 @@ from manim.typing import PixelArray, StrPath - from ...camera.moving_camera import MovingCamera + +def _validate_resampling_algorithm(resampling_algorithm: int) -> None: + if resampling_algorithm not in RESAMPLING_ALGORITHMS.values(): + raise ValueError( + "resampling_algorithm has to be an int, one of the values defined in " + "RESAMPLING_ALGORITHMS or a Pillow resampling filter constant. " + "Available algorithms: 'bicubic' (or 'cubic'), 'nearest' (or 'none'), " + "'bilinear' (or 'linear').", + ) class AbstractImageMobject(Mobject): @@ -98,14 +106,7 @@ def set_resampling_algorithm(self, resampling_algorithm: int) -> Self: * 'hamming' * 'lanczos' or 'antialias' """ - if resampling_algorithm not in RESAMPLING_ALGORITHMS.values(): - raise ValueError( - "resampling_algorithm has to be an int, one of the values defined in " - "RESAMPLING_ALGORITHMS or a Pillow resampling filter constant. " - "Available algorithms: 'bicubic' (or 'cubic'), 'nearest' (or 'none'), " - "'bilinear' (or 'linear').", - ) - + _validate_resampling_algorithm(resampling_algorithm) self.resampling_algorithm = resampling_algorithm return self @@ -124,7 +125,7 @@ def reset_points(self) -> Self: if self.scale_to_resolution: height = h / self.scale_to_resolution * config["frame_height"] else: - height = 3 # this is the case for ImageMobjectFromCamera + height = 3 self.stretch_to_fit_height(height) self.stretch_to_fit_width(height * w / h) return self @@ -308,16 +309,18 @@ def get_style(self) -> dict[str, Any]: } -# TODO, add the ability to have the dimensions/orientation of this -# mobject more strongly tied to the frame of the camera it contains, -# in the case where that's a MovingCamera +class ImageMobjectFromCamera(Mobject): + """A semantic camera view whose pixels are supplied by CairoRenderer. + The mobject describes where a secondary camera view is composited. It owns + geometry and sampling settings, but it never exposes or stores raster pixels. + """ -class ImageMobjectFromCamera(AbstractImageMobject): def __init__( self, - camera: MovingCamera, + camera: Camera, default_display_frame_config: dict[str, Any] | None = None, + resampling_algorithm: Resampling = Resampling.BICUBIC, **kwargs: Any, ) -> None: self.camera = camera @@ -328,13 +331,31 @@ def __init__( "buff": 0, } self.default_display_frame_config = default_display_frame_config - self.pixel_array = self.camera.pixel_array - super().__init__(scale_to_resolution=False, **kwargs) + self.set_resampling_algorithm(resampling_algorithm) + super().__init__(**kwargs) - # TODO: Get rid of this. - def get_pixel_array(self) -> PixelArray: - self.pixel_array = self.camera.pixel_array - return self.pixel_array + def reset_points(self) -> Self: + """Set the view geometry from the source camera's logical aspect ratio.""" + self.points = np.array( + [ + UP + LEFT, + UP + RIGHT, + DOWN + LEFT, + DOWN + RIGHT, + ], + ) + self.center() + self.stretch_to_fit_height(3) + self.stretch_to_fit_width( + self.height * self.camera.frame_width / self.camera.frame_height, + ) + return self + + def set_resampling_algorithm(self, resampling_algorithm: int) -> Self: + """Set the Pillow sampling algorithm used when compositing the view.""" + _validate_resampling_algorithm(resampling_algorithm) + self.resampling_algorithm = resampling_algorithm + return self def add_display_frame(self, **kwargs: Any) -> Self: config = dict(self.default_display_frame_config) @@ -344,18 +365,10 @@ def add_display_frame(self, **kwargs: Any) -> Self: return self def interpolate_color( - self, mobject1: Mobject, mobject2: Mobject, alpha: float + self, + mobject1: Mobject, + mobject2: Mobject, + alpha: float, ) -> Self: - assert isinstance(mobject1, ImageMobjectFromCamera) - assert isinstance(mobject2, ImageMobjectFromCamera) - assert mobject1.pixel_array.shape == mobject2.pixel_array.shape, ( - f"Mobject pixel array shapes incompatible for interpolation.\n" - f"Mobject 1 ({mobject1}) : {mobject1.pixel_array.shape}\n" - f"Mobject 2 ({mobject2}) : {mobject2.pixel_array.shape}" - ) - self.pixel_array = interpolate( - mobject1.pixel_array, - mobject2.pixel_array, - alpha, - ).astype(self.pixel_array_dtype) + """Keep renderer-supplied camera pixels independent of mobject styling.""" return self diff --git a/manim/renderer/cairo/__init__.py b/manim/renderer/cairo/__init__.py new file mode 100644 index 0000000000..0e14f6ab91 --- /dev/null +++ b/manim/renderer/cairo/__init__.py @@ -0,0 +1,19 @@ +"""Cairo rendering backend.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .renderer import CairoRenderer + +__all__ = ["CairoRenderer"] + + +def __getattr__(name: str) -> Any: + if name != "CairoRenderer": + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + from .renderer import CairoRenderer + + globals()[name] = CairoRenderer + return CairoRenderer diff --git a/manim/renderer/cairo/renderer.py b/manim/renderer/cairo/renderer.py new file mode 100644 index 0000000000..5907f3ee16 --- /dev/null +++ b/manim/renderer/cairo/renderer.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any + +from PIL import Image + +from manim.utils.hashing import get_hash_from_play_call + +from ... import config, logger +from ..._config.video_encoder import video_encoder_fingerprint +from ...camera.camera import Camera +from ...camera.multi_camera import MultiCamera +from ...mobject.mobject import Mobject, _AnimationBuilder +from ...mobject.types.image_mobject import ImageMobjectFromCamera +from ...scene.scene_file_writer import SceneFileWriter +from ...utils.exceptions import EndSceneEarlyException +from ...utils.iterables import list_update +from ..protocol import RendererCapabilities +from .rendering import _CairoDrawingContext +from .target import _CairoRasterSettings, _CairoRenderTarget + +if TYPE_CHECKING: + from manim._config.render_session import RenderSessionSpec + from manim.animation.animation import Animation + from manim.scene.scene import Scene + from manim.scene.scene_file_writer import _SceneFileWriterSettings + + from ..typing import RGBAPixelArray + +__all__ = ["CairoRenderer"] + + +class CairoRenderer: + """A renderer using Cairo. + + Cameras supplied to this renderer contain semantic view/projection state only. + CairoRenderer owns all pixel arrays, PyCairo contexts, nested targets, drawing, + static raster reuse, and readback. + """ + + capabilities = RendererCapabilities(live_preview=False) + + def __init__( + self, + file_writer_class: type[SceneFileWriter] = SceneFileWriter, + camera_class: type[Camera] | None = None, + camera: Camera | None = None, + skip_animations: bool = False, + **kwargs: Any, + ) -> None: + if camera is not None and camera_class is not None: + raise ValueError("Pass either camera or camera_class, not both.") + self._file_writer_class = file_writer_class + camera_cls = camera_class if camera_class is not None else Camera + self.camera = camera if camera is not None else camera_cls() + self._original_skipping_status = skip_animations + self.skip_animations = skip_animations + self.animations_hashes: list[str | None] = [] + self.num_plays = 0 + self.time = 0.0 + self._frame_rate = float(config["frame_rate"]) + settings = _CairoRasterSettings( + pixel_width=int(config["pixel_width"]), + pixel_height=int(config["pixel_height"]), + base_pixel_width=int(config["pixel_width"]), + base_pixel_height=int(config["pixel_height"]), + ) + self._target = _CairoRenderTarget(settings) + self._sub_targets: dict[int, _CairoRenderTarget] = {} + self._camera_view_pixels: dict[int, RGBAPixelArray] = {} + self.static_image: RGBAPixelArray | None = None + self._render_all_mobjects = False + + def init_scene( + self, + scene: Scene, + session_spec: RenderSessionSpec, + file_writer_settings: _SceneFileWriterSettings, + ) -> None: + self.file_writer: Any = self._file_writer_class(file_writer_settings) + + def play( + self, + scene: Scene, + *args: Animation | Mobject | _AnimationBuilder, + **kwargs: Any, + ) -> None: + self.skip_animations = self._original_skipping_status + self.update_skipping_status() + scene.compile_animation_data(*args, **kwargs) + + if self.skip_animations: + logger.debug(f"Skipping animation {self.num_plays}") + hash_current_animation = None + self.time += scene.duration + else: + if config["disable_caching"]: + logger.info("Caching disabled.") + hash_current_animation = f"uncached_{self.num_plays:05}" + else: + assert scene.animations is not None + hash_current_animation = get_hash_from_play_call( + scene, + self.camera, + scene.animations, + scene.mobjects, + backend="cairo", + encoder_fingerprint=video_encoder_fingerprint( + scene.session_spec.video_encoder, + ), + renderer_state=(), + ) + if self.file_writer.is_already_cached(hash_current_animation): + logger.info( + f"Animation {self.num_plays} : Using cached data (hash : %(hash_current_animation)s)", + {"hash_current_animation": hash_current_animation}, + ) + self.skip_animations = True + self.time += scene.duration + self.file_writer.add_partial_movie_file(hash_current_animation) + self.animations_hashes.append(hash_current_animation) + logger.debug( + "List of the first few animation hashes of the scene: %(h)s", + {"h": str(self.animations_hashes[:5])}, + ) + + self.file_writer.begin_animation( + not self.skip_animations, + animation_index=self.num_plays, + ) + scene.begin_animations() + self.save_static_frame_data(scene, scene.static_mobjects) + + if scene.is_current_animation_frozen_frame(): + self.update_frame(scene, mobjects=scene.moving_mobjects) + self.freeze_current_frame(scene.duration) + else: + scene.play_internal() + self.file_writer.end_animation(not self.skip_animations) + self.num_plays += 1 + + def _sub_target_for( + self, + image_mobject: ImageMobjectFromCamera, + *, + parent_camera: Camera, + parent_target: _CairoRenderTarget, + ) -> _CairoRenderTarget: + parent_settings = parent_target.settings + pixel_height = max( + 1, + int( + parent_settings.pixel_height + * image_mobject.height + / parent_camera.frame_height + ), + ) + pixel_width = max( + 1, + int( + parent_settings.pixel_width + * image_mobject.width + / parent_camera.frame_width + ), + ) + key = id(image_mobject) + target = self._sub_targets.get(key) + if target is not None and ( + target.settings.pixel_width != pixel_width + or target.settings.pixel_height != pixel_height + ): + target.close() + target = None + if target is None: + target = _CairoRenderTarget( + parent_settings.resized( + pixel_width=pixel_width, + pixel_height=pixel_height, + ), + ) + self._sub_targets[key] = target + return target + + def _render_camera( + self, + *, + camera: Camera, + target: _CairoRenderTarget, + mobjects: Iterable[Mobject], + include_submobjects: bool, + excluded_mobjects: list[Mobject] | None, + camera_stack: tuple[int, ...], + ) -> None: + camera_id = id(camera) + if camera_id in camera_stack: + raise RuntimeError("Cairo camera views cannot contain a composition cycle.") + next_stack = (*camera_stack, camera_id) + mobject_list = list(mobjects) + + if isinstance(camera, MultiCamera): + for image_mobject in camera.image_mobjects_from_cameras: + sub_target = self._sub_target_for( + image_mobject, + parent_camera=camera, + parent_target=target, + ) + sub_target.reset(image_mobject.camera) + sub_excluded_mobjects = list_update( + list(excluded_mobjects or []), + [image_mobject], + ) + self._render_camera( + camera=image_mobject.camera, + target=sub_target, + mobjects=mobject_list, + include_submobjects=include_submobjects, + excluded_mobjects=sub_excluded_mobjects, + camera_stack=next_stack, + ) + self._camera_view_pixels[id(image_mobject)] = sub_target.pixels + + def resolve_image( + image_mobject: ImageMobjectFromCamera, + ) -> RGBAPixelArray | None: + return self._camera_view_pixels.get(id(image_mobject)) + + _CairoDrawingContext( + camera=camera, + target=target, + image_resolver=resolve_image, + ).draw( + mobject_list, + include_submobjects=include_submobjects, + excluded_mobjects=excluded_mobjects, + ) + + def update_frame( + self, + scene: Scene, + mobjects: Iterable[Mobject] | None = None, + include_submobjects: bool = True, + ignore_skipping: bool = True, + **kwargs: Any, + ) -> None: + """Render one scene state into the owned Cairo target.""" + if self.skip_animations and not ignore_skipping: + return + if not mobjects: + mobjects = list_update(scene.mobjects, scene.foreground_mobjects) + if self.static_image is not None: + self._target.set_pixels(self.static_image) + else: + self._target.reset(self.camera) + + self._camera_view_pixels.clear() + self._render_camera( + camera=self.camera, + target=self._target, + mobjects=mobjects, + include_submobjects=include_submobjects, + excluded_mobjects=kwargs.get("excluded_mobjects"), + camera_stack=(), + ) + + def render_mobjects( + self, + mobjects: Iterable[Mobject], + *, + camera: Camera | None = None, + ) -> None: + """Render explicit mobjects for direct image materialization.""" + render_camera = self.camera if camera is None else camera + self._target.reset(render_camera) + self._camera_view_pixels.clear() + self._render_camera( + camera=render_camera, + target=self._target, + mobjects=mobjects, + include_submobjects=True, + excluded_mobjects=None, + camera_stack=(), + ) + + def render( + self, + scene: Scene, + time: float, + moving_mobjects: Iterable[Mobject] | None = None, + ) -> None: + if self._render_all_mobjects: + moving_mobjects = None + self.update_frame(scene, moving_mobjects) + self.add_frame(self.get_frame()) + + def get_frame(self) -> RGBAPixelArray: + """Return a fresh owned top-left-origin RGBA frame.""" + return self._target.read_pixels() + + def get_image(self) -> Image.Image: + """Return the current target as a PIL image.""" + return Image.fromarray(self.get_frame()) + + def add_frame(self, frame: RGBAPixelArray, num_frames: int = 1) -> None: + if self.skip_animations: + return + self.time += num_frames / self._frame_rate + self.file_writer.write_frame(frame, repeat=num_frames) + + def freeze_current_frame(self, duration: float) -> None: + self.add_frame( + self.get_frame(), + num_frames=int(duration * self._frame_rate), + ) + + def show_frame(self, scene: Scene) -> None: + self.update_frame(scene, ignore_skipping=True) + self.get_image().show() + + def save_static_frame_data( + self, + scene: Scene, + static_mobjects: Iterable[Mobject], + ) -> RGBAPixelArray | None: + self.static_image = None + # A nested view can contain any dynamic scene mobject regardless of the + # view's position in the primary draw order. Keep all primary inputs + # dynamic until the Manager cutover supplies explicit dynamic roots. + self._render_all_mobjects = isinstance(self.camera, MultiCamera) and bool( + scene.moving_mobjects, + ) + if self._render_all_mobjects or not static_mobjects: + return None + self.update_frame(scene, mobjects=static_mobjects) + self.static_image = self.get_frame() + return self.static_image + + def update_skipping_status(self) -> None: + if self.file_writer.sections[-1].skip_animations: + self.skip_animations = True + if self.file_writer.output_spec.is_still: + self.skip_animations = True + if ( + config.from_animation_number > 0 + and self.num_plays < config.from_animation_number + ): + self.skip_animations = True + if ( + config.upto_animation_number >= 0 + and self.num_plays > config.upto_animation_number + ): + self.skip_animations = True + raise EndSceneEarlyException() + + def scene_finished(self, scene: Scene) -> None: + output = self.file_writer.output_spec + if self.num_plays and (output.is_video or output.is_image_sequence): + self.file_writer.finish() + elif not self.num_plays: + self.static_image = None + self.update_frame(scene) + + if output.is_still or (not self.num_plays and output.fallback_to_still): + if self.num_plays: + self.static_image = None + self.update_frame(scene) + self.file_writer.save_image(self.get_frame()) + + def close(self) -> None: + """Release all renderer-owned Cairo targets.""" + self._target.close() + for target in self._sub_targets.values(): + target.close() + self._sub_targets.clear() + self._camera_view_pixels.clear() diff --git a/manim/renderer/cairo/rendering.py b/manim/renderer/cairo/rendering.py new file mode 100644 index 0000000000..162ecaa29c --- /dev/null +++ b/manim/renderer/cairo/rendering.py @@ -0,0 +1,480 @@ +"""Private drawing helpers for :class:`~manim.renderer.cairo.CairoRenderer`.""" + +from __future__ import annotations + +import itertools as it +import operator as op +from collections.abc import Callable, Iterable +from functools import reduce +from typing import TYPE_CHECKING, Any + +import cairo +import numpy as np +from PIL import Image + +from manim.constants import CapStyleType, LineJointType +from manim.mobject.mobject import Mobject +from manim.mobject.types.image_mobject import ( + AbstractImageMobject, + ImageMobjectFromCamera, +) +from manim.mobject.types.point_cloud_mobject import PMobject +from manim.mobject.types.vectorized_mobject import VMobject +from manim.utils.space_ops import cross2d + +from .target import _CairoRenderTarget + +if TYPE_CHECKING: + from manim.camera.camera import Camera + from manim.typing import ( + FloatRGBA_Array, + FloatRGBALike_Array, + Point3D_Array, + RGBAPixelArray, + ) + +_LINE_JOIN_MAP = { + LineJointType.AUTO: None, + LineJointType.ROUND: cairo.LineJoin.ROUND, + LineJointType.BEVEL: cairo.LineJoin.BEVEL, + LineJointType.MITER: cairo.LineJoin.MITER, +} + +_CAP_STYLE_MAP = { + CapStyleType.AUTO: None, + CapStyleType.ROUND: cairo.LineCap.ROUND, + CapStyleType.BUTT: cairo.LineCap.BUTT, + CapStyleType.SQUARE: cairo.LineCap.SQUARE, +} + +_ImageResolver = Callable[[ImageMobjectFromCamera], np.ndarray | None] + + +class _CairoDrawingContext: + """Draw mobjects into one renderer-owned target using one semantic camera.""" + + def __init__( + self, + *, + camera: Camera, + target: _CairoRenderTarget, + image_resolver: _ImageResolver, + ) -> None: + self.camera = camera + self.target = target + self.image_resolver = image_resolver + + def draw( + self, + mobjects: Iterable[Mobject], + *, + include_submobjects: bool = True, + excluded_mobjects: list[Mobject] | None = None, + ) -> None: + self.camera._prepare_for_render() + to_display = self.camera.get_mobjects_to_display( + mobjects, + include_submobjects=include_submobjects, + excluded_mobjects=excluded_mobjects, + ) + for group_type, group in it.groupby(to_display, self._type_or_raise): + self._display_funcs[group_type](list(group)) + + @property + def _display_funcs(self) -> dict[type[Mobject], Callable[[list[Any]], None]]: + return { + VMobject: self._display_vectorized_mobjects, + PMobject: self._display_point_cloud_mobjects, + ImageMobjectFromCamera: self._display_image_mobjects, + AbstractImageMobject: self._display_image_mobjects, + Mobject: lambda batch: None, + } + + def _type_or_raise(self, mobject: Mobject) -> type[Mobject]: + for mobject_type in self._display_funcs: + if isinstance(mobject, mobject_type): + return mobject_type + raise TypeError( + f"Displaying an object of class {type(mobject).__name__} is not supported", + ) + + def _display_vectorized_mobjects(self, vmobjects: list[VMobject]) -> None: + if not vmobjects: + return + for image, batch in it.groupby( + vmobjects, + lambda vmobject: vmobject.get_background_image(), + ): + if image: + self._display_background_colored_vmobjects(list(batch)) + else: + self._display_non_background_colored_vmobjects(batch) + + def _display_non_background_colored_vmobjects( + self, + vmobjects: Iterable[VMobject], + ) -> None: + context = self.target.get_context(self.camera) + for vmobject in vmobjects: + self._display_vectorized(vmobject, context) + + def _display_vectorized( + self, + vmobject: VMobject, + context: cairo.Context, + ) -> None: + self._set_cairo_context_path(context, vmobject) + self._apply_stroke(context, vmobject, background=True) + self._apply_fill(context, vmobject) + self._apply_stroke(context, vmobject) + + def _set_cairo_context_path( + self, + context: cairo.Context, + vmobject: VMobject, + ) -> None: + points = self.camera.transform_points_pre_display(vmobject, vmobject.points) + if len(points) == 0: + return + + nppcc = vmobject.n_points_per_cubic_curve + split_indices = vmobject.get_subpath_split_indices_from_points(points, n_dims=2) + if len(split_indices) == 0: + return + + points_xy = points[:, :2].ravel() + context.new_path() + move_to = context.move_to + curve_to = context.curve_to + new_sub_path = context.new_sub_path + close_path = context.close_path + + for start_index, end_index in split_indices: + start_index = int(start_index) + end_index = int(end_index) + if end_index - start_index < nppcc: + continue + + new_sub_path() + base = start_index * 2 + move_to(points_xy[base], points_xy[base + 1]) + for index in range( + start_index, + end_index - nppcc + 1, + nppcc, + ): + handle = (index + 1) * 2 + curve_to( + points_xy[handle], + points_xy[handle + 1], + points_xy[handle + 2], + points_xy[handle + 3], + points_xy[handle + 4], + points_xy[handle + 5], + ) + if vmobject.consider_points_equals_2d( + points[start_index], + points[end_index - 1], + ): + close_path() + + def _set_cairo_context_color( + self, + context: cairo.Context, + rgbas: FloatRGBALike_Array, + vmobject: VMobject, + ) -> None: + if len(rgbas) == 1: + context.set_source_rgba(*rgbas[0][2::-1], rgbas[0][3]) + return + + points = vmobject.get_gradient_start_and_end_points() + points = self.camera.transform_points_pre_display(vmobject, points) + pattern = cairo.LinearGradient( + *it.chain(*(point[:2] for point in points)), + ) + for rgba, offset in zip( + rgbas, + np.linspace(0, 1, len(rgbas)), + strict=True, + ): + pattern.add_color_stop_rgba(offset, *rgba[2::-1], rgba[3]) + context.set_source(pattern) + + def _apply_fill(self, context: cairo.Context, vmobject: VMobject) -> None: + self._set_cairo_context_color( + context, + self.camera.get_fill_rgbas(vmobject), + vmobject, + ) + context.fill_preserve() + + def _apply_stroke( + self, + context: cairo.Context, + vmobject: VMobject, + background: bool = False, + ) -> None: + width = vmobject.get_stroke_width(background) + if width == 0: + return + self._set_cairo_context_color( + context, + self.camera.get_stroke_rgbas(vmobject, background=background), + vmobject, + ) + context.set_line_width( + width * self.target.settings.cairo_line_width_multiple, + ) + if vmobject.joint_type != LineJointType.AUTO: + context.set_line_join(_LINE_JOIN_MAP[vmobject.joint_type]) + if vmobject.cap_style != CapStyleType.AUTO: + context.set_line_cap(_CAP_STYLE_MAP[vmobject.cap_style]) + context.stroke_preserve() + + def _display_background_colored_vmobjects( + self, + vmobjects: list[VMobject], + ) -> None: + scratch = self.target.get_scratch_target() + scratch_context = _CairoDrawingContext( + camera=self.camera, + target=scratch, + image_resolver=self.image_resolver, + ) + current: RGBAPixelArray | None = None + for image, batch in it.groupby( + vmobjects, + lambda vmobject: vmobject.get_background_image(), + ): + scratch.clear() + scratch_context._display_non_background_colored_vmobjects(batch) + background = self.target.get_background_image(image) + colored = np.asarray( + background * scratch.pixels.astype(float) / 255, + dtype=np.uint8, + ) + current = colored if current is None else np.maximum(current, colored) + if current is not None: + self._overlay_rgba_array(self.target.pixels, current) + + def _display_point_cloud_mobjects(self, pmobjects: list[PMobject]) -> None: + for pmobject in pmobjects: + self._display_point_cloud( + pmobject, + pmobject.points, + pmobject.rgbas, + self._adjusted_thickness(pmobject.stroke_width), + ) + + def _display_point_cloud( + self, + pmobject: PMobject, + points: Point3D_Array, + rgbas: FloatRGBA_Array, + thickness: float, + ) -> None: + if len(points) == 0: + return + pixel_coords = self._points_to_pixel_coords(pmobject, points) + pixel_coords = self._thickened_coordinates(pixel_coords, thickness) + pixel_array = self.target.pixels + rgba_len = pixel_array.shape[2] + + int_rgbas = (255 * rgbas).astype(np.uint8) + target_len = len(pixel_coords) + factor = target_len // len(int_rgbas) + int_rgbas = np.array([int_rgbas] * factor).reshape((target_len, rgba_len)) + + on_screen_indices = self._on_screen_pixels(pixel_coords) + pixel_coords = pixel_coords[on_screen_indices] + int_rgbas = int_rgbas[on_screen_indices] + + height = self.target.settings.pixel_height + width = self.target.settings.pixel_width + flattener = np.array([1, width], dtype="int").reshape((2, 1)) + indices = np.dot(pixel_coords, flattener)[:, 0].astype("int") + flattened = pixel_array.reshape((height * width, rgba_len)) + flattened[indices] = int_rgbas + pixel_array[:, :] = flattened.reshape((height, width, rgba_len)) + + def _display_image_mobjects( + self, + image_mobjects: list[AbstractImageMobject | ImageMobjectFromCamera], + ) -> None: + for image_mobject in image_mobjects: + self._display_image_mobject(image_mobject) + + def _display_image_mobject( + self, + image_mobject: AbstractImageMobject | ImageMobjectFromCamera, + ) -> None: + source_pixels = ( + self.image_resolver(image_mobject) + if isinstance(image_mobject, ImageMobjectFromCamera) + else image_mobject.get_pixel_array() + ) + if source_pixels is None: + return + sub_image = Image.fromarray(source_pixels, mode="RGBA") + original_coords = np.array( + [ + [0, 0], + [sub_image.width, 0], + [0, sub_image.height], + [sub_image.width, sub_image.height], + ], + ) + target_coords = self._points_to_subpixel_coords( + image_mobject, + image_mobject.points, + ) + int_target_coords = target_coords.astype(np.int64) + shift_vector = np.array( + [ + min(x for x, _ in int_target_coords), + min(y for _, y in int_target_coords), + ], + ) + target_coords -= shift_vector + int_target_coords -= shift_vector + target_size = ( + max(x for x, _ in int_target_coords), + max(y for _, y in int_target_coords), + ) + if min(target_size) <= 0: + return + + ordered_vertices = [target_coords[index] for index in (0, 1, 3, 2)] + sides = [ + ordered_vertices[(index + 1) % 4] - ordered_vertices[index] + for index in range(4) + ] + side_lengths = np.linalg.norm(sides, axis=1) + longest_index = int(np.argmax(side_lengths)) + longest_side = sides[longest_index] + longest_length = side_lengths[longest_index] + if longest_length == 0: + return + previous_side = sides[(longest_index - 1) % 4] + next_side = sides[(longest_index - 1) % 4] + height_1 = abs(cross2d(longest_side, previous_side)) / longest_length + height_2 = abs(cross2d(longest_side, next_side)) / longest_length + if max(height_1, height_2) < 0.5: + return + + homography_matrix = [] + for (x, y), (target_x, target_y) in zip( + target_coords, + original_coords, + strict=True, + ): + homography_matrix.append( + [x, y, 1, 0, 0, 0, -target_x * x, -target_x * y], + ) + homography_matrix.append( + [0, 0, 0, x, y, 1, -target_y * x, -target_y * y], + ) + matrix = np.array(homography_matrix, dtype=np.float64) + target = original_coords.reshape(8).astype(np.float64) + try: + coefficients = np.linalg.solve(matrix, target) + except np.linalg.LinAlgError: + return + + sub_image = sub_image.transform( + size=target_size, + method=Image.Transform.PERSPECTIVE, + data=coefficients, + resample=image_mobject.resampling_algorithm, + ) + settings = self.target.settings + full_image = Image.new( + "RGBA", + (settings.pixel_width, settings.pixel_height), + (0, 0, 0, 0), + ) + full_image.paste( + sub_image, + box=( + int(shift_vector[0]), + int(shift_vector[1]), + int(shift_vector[0] + target_size[0]), + int(shift_vector[1] + target_size[1]), + ), + ) + self._overlay_pil_image(self.target.pixels, full_image) + + def _overlay_rgba_array( + self, + pixel_array: RGBAPixelArray, + new_array: RGBAPixelArray, + ) -> None: + self._overlay_pil_image(pixel_array, Image.fromarray(new_array)) + + @staticmethod + def _overlay_pil_image( + pixel_array: RGBAPixelArray, + image: Image.Image, + ) -> None: + pixel_array[:, :] = np.asarray( + Image.alpha_composite(Image.fromarray(pixel_array), image), + dtype=np.uint8, + ) + + def _points_to_subpixel_coords( + self, + mobject: Mobject, + points: Point3D_Array, + ) -> np.ndarray: + points = self.camera.transform_points_pre_display(mobject, points) + shifted_points = points - self.camera.frame_center + settings = self.target.settings + result = np.zeros((len(points), 2)) + result[:, 0] = ( + shifted_points[:, 0] * settings.pixel_width / self.camera.frame_width + + settings.pixel_width / 2 + ) + result[:, 1] = ( + -shifted_points[:, 1] * settings.pixel_height / self.camera.frame_height + + settings.pixel_height / 2 + ) + return result + + def _points_to_pixel_coords( + self, + mobject: Mobject, + points: Point3D_Array, + ) -> np.ndarray: + return self._points_to_subpixel_coords(mobject, points).astype(np.int64) + + def _on_screen_pixels(self, pixel_coords: np.ndarray) -> np.ndarray: + settings = self.target.settings + return reduce( + op.and_, + [ + pixel_coords[:, 0] >= 0, + pixel_coords[:, 0] < settings.pixel_width, + pixel_coords[:, 1] >= 0, + pixel_coords[:, 1] < settings.pixel_height, + ], + ) + + def _adjusted_thickness(self, thickness: float) -> float: + settings = self.target.settings + base_sum = settings.base_pixel_height + settings.base_pixel_width + target_sum = settings.pixel_height + settings.pixel_width + return 1 + (thickness - 1) * base_sum / target_sum + + @staticmethod + def _thickened_coordinates( + pixel_coords: np.ndarray, + thickness: float, + ) -> np.ndarray: + thickness = int(thickness) + coordinate_range = list( + range(-thickness // 2 + 1, thickness // 2 + 1), + ) + nudges = np.array(list(it.product(coordinate_range, coordinate_range))) + thickened = np.array([pixel_coords + nudge for nudge in nudges]) + return thickened.reshape((thickened.size // 2, 2)) diff --git a/manim/renderer/cairo/target.py b/manim/renderer/cairo/target.py new file mode 100644 index 0000000000..87a7a5b797 --- /dev/null +++ b/manim/renderer/cairo/target.py @@ -0,0 +1,202 @@ +"""Private raster-target primitives for :mod:`manim.renderer.cairo`.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +import cairo +import numpy as np +from PIL import Image + +from manim.utils.color import color_to_int_rgba +from manim.utils.images import get_full_raster_image_path + +if TYPE_CHECKING: + from manim.camera.camera import Camera + from manim.typing import RGBAPixelArray + + +@dataclass(frozen=True, slots=True) +class _CairoRasterSettings: + """Immutable dimensions and scaling used by one Cairo raster target.""" + + pixel_width: int + pixel_height: int + base_pixel_width: int + base_pixel_height: int + cairo_line_width_multiple: float = 0.01 + + def __post_init__(self) -> None: + if ( + min( + self.pixel_width, + self.pixel_height, + self.base_pixel_width, + self.base_pixel_height, + ) + <= 0 + ): + raise ValueError("Cairo raster dimensions must be positive.") + + def resized(self, *, pixel_width: int, pixel_height: int) -> _CairoRasterSettings: + return _CairoRasterSettings( + pixel_width=pixel_width, + pixel_height=pixel_height, + base_pixel_width=self.base_pixel_width, + base_pixel_height=self.base_pixel_height, + cairo_line_width_multiple=self.cairo_line_width_multiple, + ) + + +class _CairoRenderTarget: + """Own one top-left-origin RGBA target and its PyCairo context.""" + + def __init__(self, settings: _CairoRasterSettings) -> None: + self.settings = settings + self._pixels = np.zeros( + (settings.pixel_height, settings.pixel_width, 4), + dtype=np.uint8, + ) + self._background_key: tuple[object, ...] | None = None + self._background = np.zeros_like(self._pixels) + self._context_key: tuple[float, ...] | None = None + self._context: cairo.Context | None = None + self._scratch_target: _CairoRenderTarget | None = None + self.background_image_cache: dict[str, RGBAPixelArray] = {} + self._closed = False + + @property + def pixels(self) -> RGBAPixelArray: + if self._closed: + raise RuntimeError("The Cairo render target is closed.") + return self._pixels + + def _camera_background_key(self, camera: Camera) -> tuple[object, ...]: + return ( + camera.background_image, + tuple( + camera.background_color.to_rgba_with_alpha( + camera.background_opacity, + ), + ), + ) + + def _load_background(self, camera: Camera) -> RGBAPixelArray: + settings = self.settings + if camera.background_image is None: + background = np.empty_like(self._pixels) + background[:, :] = color_to_int_rgba( + camera.background_color, + camera.background_opacity, + ) + return background + + path = get_full_raster_image_path(camera.background_image) + with Image.open(path) as source: + image = source.convert("RGBA") + if image.size != (settings.pixel_width, settings.pixel_height): + image = image.resize((settings.pixel_width, settings.pixel_height)) + return np.asarray(image, dtype=np.uint8).copy() + + def reset(self, camera: Camera) -> None: + key = self._camera_background_key(camera) + if key != self._background_key: + self._background = self._load_background(camera) + self._background_key = key + np.copyto(self._pixels, self._background) + + def clear(self) -> None: + """Clear this target to transparent black without allocating an array.""" + self.pixels.fill(0) + + def get_scratch_target(self) -> _CairoRenderTarget: + """Return a reusable same-sized target for intermediate composition.""" + if self._closed: + raise RuntimeError("The Cairo render target is closed.") + if self._scratch_target is None: + self._scratch_target = _CairoRenderTarget(self.settings) + return self._scratch_target + + def set_pixels(self, pixels: RGBAPixelArray) -> None: + if pixels.shape != self._pixels.shape: + raise ValueError( + f"Cairo target pixels must have shape {self._pixels.shape}; " + f"got {pixels.shape}.", + ) + if pixels.dtype != np.uint8: + raise TypeError("Cairo target pixels must use uint8.") + np.copyto(self._pixels, pixels) + + def get_context(self, camera: Camera) -> cairo.Context: + settings = self.settings + center = camera.get_view_transform_center() + view_key = ( + float(center[0]), + float(center[1]), + float(camera.frame_width), + float(camera.frame_height), + ) + if self._context is not None and self._context_key == view_key: + return self._context + + surface = cairo.ImageSurface.create_for_data( + self._pixels.data, + cairo.FORMAT_ARGB32, + settings.pixel_width, + settings.pixel_height, + ) + context = cairo.Context(surface) + context.scale(settings.pixel_width, settings.pixel_height) + context.set_matrix( + cairo.Matrix( + settings.pixel_width / camera.frame_width, + 0, + 0, + -(settings.pixel_height / camera.frame_height), + (settings.pixel_width / 2) + - center[0] * (settings.pixel_width / camera.frame_width), + (settings.pixel_height / 2) + + center[1] * (settings.pixel_height / camera.frame_height), + ), + ) + self._context = context + self._context_key = view_key + return context + + def get_background_image(self, image: Image.Image | Path | str) -> RGBAPixelArray: + image_key = str(image) + cached = self.background_image_cache.get(image_key) + if cached is not None: + return cached + + if isinstance(image, (str, Path)): + path = get_full_raster_image_path(image) + with Image.open(path) as source: + source_image = source.convert("RGBA") + array = np.asarray(source_image, dtype=np.uint8).copy() + else: + array = np.asarray(image.convert("RGBA"), dtype=np.uint8).copy() + + expected_shape = self._pixels.shape + if array.shape != expected_shape: + resized = Image.fromarray(array).resize( + (self.settings.pixel_width, self.settings.pixel_height), + ) + array = np.asarray(resized, dtype=np.uint8).copy() + self.background_image_cache[image_key] = array + return array + + def read_pixels(self) -> RGBAPixelArray: + return self.pixels.copy() + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._context = None + if self._scratch_target is not None: + self._scratch_target.close() + self._scratch_target = None + self.background_image_cache.clear() diff --git a/manim/renderer/cairo_renderer.py b/manim/renderer/cairo_renderer.py index 755ecb0688..bf044d1745 100644 --- a/manim/renderer/cairo_renderer.py +++ b/manim/renderer/cairo_renderer.py @@ -1,299 +1,5 @@ -from __future__ import annotations +"""Compatibility imports for the Cairo rendering backend.""" -from collections.abc import Iterable -from typing import TYPE_CHECKING, Any - -from manim.utils.hashing import get_hash_from_play_call - -from .. import config, logger -from .._config.video_encoder import video_encoder_fingerprint -from ..camera.camera import Camera -from ..mobject.mobject import Mobject, _AnimationBuilder -from ..scene.scene_file_writer import SceneFileWriter -from ..utils.exceptions import EndSceneEarlyException -from ..utils.iterables import list_update -from .protocol import RendererCapabilities - -if TYPE_CHECKING: - from manim._config.render_session import RenderSessionSpec - from manim.animation.animation import Animation - from manim.scene.scene import Scene - from manim.scene.scene_file_writer import _SceneFileWriterSettings - - from ..typing import PixelArray +from .cairo import CairoRenderer __all__ = ["CairoRenderer"] - - -class CairoRenderer: - """A renderer using Cairo. - - Attributes - ---------- - num_plays : int - Number of play() functions in the scene. - - time : float - Time elapsed since initialisation of scene. - """ - - capabilities = RendererCapabilities(live_preview=False) - - def __init__( - self, - file_writer_class: type[SceneFileWriter] = SceneFileWriter, - camera_class: type[Camera] | None = None, - skip_animations: bool = False, - **kwargs: Any, - ): - # All of the following are set to EITHER the value passed via kwargs, - # OR the value stored in the global config dict at the time of - # _instance construction_. - self._file_writer_class = file_writer_class - camera_cls = camera_class if camera_class is not None else Camera - self.camera = camera_cls() - self._original_skipping_status = skip_animations - self.skip_animations = skip_animations - self.animations_hashes: list[str | None] = [] - self.num_plays = 0 - self.time = 0.0 - self.static_image: PixelArray | None = None - - def init_scene( - self, - scene: Scene, - session_spec: RenderSessionSpec, - file_writer_settings: _SceneFileWriterSettings, - ) -> None: - self.file_writer: Any = self._file_writer_class(file_writer_settings) - - def play( - self, - scene: Scene, - *args: Animation | Mobject | _AnimationBuilder, - **kwargs: Any, - ) -> None: - # Reset skip_animations to the original state. - # Needed when rendering only some animations, and skipping others. - self.skip_animations = self._original_skipping_status - self.update_skipping_status() - - scene.compile_animation_data(*args, **kwargs) - - if self.skip_animations: - logger.debug(f"Skipping animation {self.num_plays}") - hash_current_animation = None - self.time += scene.duration - else: - if config["disable_caching"]: - logger.info("Caching disabled.") - hash_current_animation = f"uncached_{self.num_plays:05}" - else: - assert scene.animations is not None - hash_current_animation = get_hash_from_play_call( - scene, - self.camera, - scene.animations, - scene.mobjects, - backend="cairo", - encoder_fingerprint=video_encoder_fingerprint( - scene.session_spec.video_encoder, - ), - renderer_state=(), - ) - if self.file_writer.is_already_cached(hash_current_animation): - logger.info( - f"Animation {self.num_plays} : Using cached data (hash : %(hash_current_animation)s)", - {"hash_current_animation": hash_current_animation}, - ) - self.skip_animations = True - self.time += scene.duration - # adding None as a partial movie file will make file_writer ignore the latter. - self.file_writer.add_partial_movie_file(hash_current_animation) - self.animations_hashes.append(hash_current_animation) - logger.debug( - "List of the first few animation hashes of the scene: %(h)s", - {"h": str(self.animations_hashes[:5])}, - ) - - self.file_writer.begin_animation( - not self.skip_animations, - animation_index=self.num_plays, - ) - scene.begin_animations() - - # Save a static image, to avoid rendering non moving objects. - self.save_static_frame_data(scene, scene.static_mobjects) - - if scene.is_current_animation_frozen_frame(): - self.update_frame(scene, mobjects=scene.moving_mobjects) - # self.duration stands for the total run time of all the animations. - # In this case, as there is only a wait, it will be the length of the wait. - self.freeze_current_frame(scene.duration) - else: - scene.play_internal() - self.file_writer.end_animation(not self.skip_animations) - - self.num_plays += 1 - - def update_frame( # TODO Description in Docstring - self, - scene: Scene, - mobjects: Iterable[Mobject] | None = None, - include_submobjects: bool = True, - ignore_skipping: bool = True, - **kwargs: Any, - ) -> None: - """Update the frame. - - Parameters - ---------- - scene - - mobjects - list of mobjects - - include_submobjects - - ignore_skipping - - **kwargs - """ - if self.skip_animations and not ignore_skipping: - return - if not mobjects: - mobjects = list_update( - scene.mobjects, - scene.foreground_mobjects, - ) - if self.static_image is not None: - self.camera.set_frame_to_background(self.static_image) - else: - self.camera.reset() - - kwargs["include_submobjects"] = include_submobjects - self.camera.capture_mobjects(mobjects, **kwargs) - - def render( - self, - scene: Scene, - time: float, - moving_mobjects: Iterable[Mobject] | None = None, - ) -> None: - self.update_frame(scene, moving_mobjects) - self.add_frame(self.get_frame()) - - def get_frame(self) -> PixelArray: - """Gets the current frame as NumPy array. - - Returns - ------- - PixelArray - NumPy array of pixel values of each pixel in screen. - The shape of the array is height x width x 3. - """ - return self.camera.pixel_array.copy() - - def add_frame(self, frame: PixelArray, num_frames: int = 1) -> None: - """Adds a frame to the video_file_stream - - Parameters - ---------- - frame - The frame to add, as a pixel array. - num_frames - The number of times to add frame. - """ - dt = 1 / self.camera.frame_rate - if self.skip_animations: - return - self.time += num_frames * dt - self.file_writer.write_frame(frame, repeat=num_frames) - - def freeze_current_frame(self, duration: float) -> None: - """Adds a static frame to the movie for a given duration. The static frame is the current frame. - - Parameters - ---------- - duration - [description] - """ - dt = 1 / self.camera.frame_rate - self.add_frame( - self.get_frame(), - num_frames=int(duration / dt), - ) - - def show_frame(self, scene: Scene) -> None: - """Opens the current frame in the Default Image Viewer - of your system. - """ - self.update_frame(scene, ignore_skipping=True) - self.camera.get_image().show() - - def save_static_frame_data( - self, - scene: Scene, - static_mobjects: Iterable[Mobject], - ) -> PixelArray | None: - """Compute and save the static frame, that will be reused at each frame - to avoid unnecessarily computing static mobjects. - - Parameters - ---------- - scene - The scene played. - static_mobjects - Static mobjects of the scene. If None, self.static_image is set to None. - - Returns - ------- - PixelArray | None - The static image computed. The return value is None if there are no static mobjects in the scene. - """ - self.static_image = None - if not static_mobjects: - return None - self.update_frame(scene, mobjects=static_mobjects) - self.static_image = self.get_frame() - return self.static_image - - def update_skipping_status(self) -> None: - """This method is used internally to check if the current - animation needs to be skipped or not. It also checks if - the number of animations that were played correspond to - the number of animations that need to be played, and - raises an EndSceneEarlyException if they don't correspond. - """ - # there is always at least one section -> no out of bounds here - if self.file_writer.sections[-1].skip_animations: - self.skip_animations = True - if self.file_writer.output_spec.is_still: - self.skip_animations = True - if ( - config.from_animation_number > 0 - and self.num_plays < config.from_animation_number - ): - self.skip_animations = True - if ( - config.upto_animation_number >= 0 - and self.num_plays > config.upto_animation_number - ): - self.skip_animations = True - raise EndSceneEarlyException() - - def scene_finished(self, scene: Scene) -> None: - output = self.file_writer.output_spec - if self.num_plays and (output.is_video or output.is_image_sequence): - self.file_writer.finish() - elif not self.num_plays: - self.static_image = None - self.update_frame(scene) - - # Automatically selected video output falls back to a last-frame PNG - # when a scene has no play calls. - if output.is_still or (not self.num_plays and output.fallback_to_still): - if self.num_plays: - self.static_image = None - self.update_frame(scene) - self.file_writer.save_image(self.get_frame()) diff --git a/manim/scene/moving_camera_scene.py b/manim/scene/moving_camera_scene.py index 70157898ef..b5582ea058 100644 --- a/manim/scene/moving_camera_scene.py +++ b/manim/scene/moving_camera_scene.py @@ -1,4 +1,4 @@ -"""A scene whose camera can be moved around. +"""Scene variant configured with :class:`MovingCamera`. .. SEEALSO:: @@ -10,7 +10,7 @@ .. manim:: ChangingCameraWidthAndRestore - class ChangingCameraWidthAndRestore(MovingCameraScene): + class ChangingCameraWidthAndRestore(Scene): def construct(self): text = Text("Hello World").set_color(BLUE) self.add(text) @@ -22,7 +22,7 @@ def construct(self): .. manim:: MovingCameraCenter - class MovingCameraCenter(MovingCameraScene): + class MovingCameraCenter(Scene): def construct(self): s = Square(color=RED, fill_opacity=0.5).move_to(2 * LEFT) t = Triangle(color=GREEN, fill_opacity=0.5).move_to(2 * RIGHT) @@ -35,7 +35,7 @@ def construct(self): .. manim:: MovingAndZoomingCamera - class MovingAndZoomingCamera(MovingCameraScene): + class MovingAndZoomingCamera(Scene): def construct(self): s = Square(color=BLUE, fill_opacity=0.5).move_to(2 * LEFT) t = Triangle(color=YELLOW, fill_opacity=0.5).move_to(2 * RIGHT) @@ -48,7 +48,7 @@ def construct(self): .. manim:: MovingCameraOnGraph - class MovingCameraOnGraph(MovingCameraScene): + class MovingCameraOnGraph(Scene): def construct(self): self.camera.frame.save_state() @@ -66,7 +66,7 @@ def construct(self): .. manim:: SlidingMultipleFrames - class SlidingMultipleFrames(MovingCameraScene): + class SlidingMultipleFrames(Scene): def construct(self): def create_frame(number): frame = Rectangle(width=16, height=9) @@ -91,51 +91,18 @@ def create_frame(number): from typing import Any -from manim.animation.animation import Animation -from manim.mobject.mobject import Mobject - from ..camera.camera import Camera from ..camera.moving_camera import MovingCamera from ..scene.scene import Scene -from ..utils.family import extract_mobject_family_members -from ..utils.iterables import list_update class MovingCameraScene(Scene): - """ - This is a Scene, with special configurations and properties that - make it suitable for cases where the camera must be moved around. - - Note: Examples are included in the moving_camera_scene module - documentation, see below in the 'see also' section. + """A scene whose default camera class is :class:`MovingCamera`. - .. SEEALSO:: - - :mod:`.moving_camera_scene` - :class:`.MovingCamera` + Its camera exposes the standard animatable frame and auto-zoom behavior. """ def __init__( self, camera_class: type[Camera] = MovingCamera, **kwargs: Any ) -> None: super().__init__(camera_class=camera_class, **kwargs) - - def get_moving_mobjects(self, *animations: Animation) -> list[Mobject]: - """ - This method returns a list of all of the Mobjects in the Scene that - are moving, that are also in the animations passed. - - Parameters - ---------- - *animations - The Animations whose mobjects will be checked. - """ - moving_mobjects = super().get_moving_mobjects(*animations) - all_moving_mobjects = extract_mobject_family_members(moving_mobjects) - movement_indicators = self.renderer.camera.get_mobjects_indicating_movement() # type: ignore[union-attr] - for movement_indicator in movement_indicators: - if movement_indicator in all_moving_mobjects: - # When one of these is moving, the camera should - # consider all mobjects to be moving - return list_update(self.mobjects, moving_mobjects) - return moving_mobjects diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 9dd853cddc..01524abc5e 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -56,7 +56,7 @@ from ..camera.camera import Camera from ..constants import * from ..manager import Manager -from ..renderer.cairo_renderer import CairoRenderer +from ..renderer.cairo import CairoRenderer from ..renderer.opengl import OpenGLCamera, OpenGLRenderer from ..renderer.opengl.shader import Object3D from ..scene.scene_file_writer import _SceneFileWriterSettings @@ -970,8 +970,21 @@ def consider_moving(mob: Mobject) -> bool: or any(consider_moving(m) for m in mob.submobjects) ) + if self.always_update_mobjects or self.updaters: + return list(self.mobjects) + i = next((i for i, mob in enumerate(mobjects) if consider_moving(mob)), None) - return [] if i is None else mobjects[i:] + moving_mobjects = [] if i is None else mobjects[i:] + movement_indicators: list[Mobject] = getattr( + self.camera, + "get_mobjects_indicating_movement", + lambda: [], + )() + moving_family = extract_mobject_family_members(moving_mobjects) + if any(indicator in moving_family for indicator in movement_indicators): + # A camera control changes the projection of all scene mobjects. + return list_update(self.mobjects, moving_mobjects) + return moving_mobjects def get_moving_and_static_mobjects( self, animations: Iterable[Animation] diff --git a/manim/scene/three_d_scene.py b/manim/scene/three_d_scene.py index 7d412b91e9..78cf9a473f 100644 --- a/manim/scene/three_d_scene.py +++ b/manim/scene/three_d_scene.py @@ -20,7 +20,7 @@ from ..animation.animation import Animation from ..animation.transform import Transform from ..camera.three_d_camera import ThreeDCamera -from ..constants import DEGREES, RendererType +from ..constants import DEGREES from ..mobject.mobject import Mobject from ..mobject.types.vectorized_mobject import VectorizedPoint, VGroup from ..renderer.opengl import OpenGLCamera @@ -86,18 +86,35 @@ def set_camera_orientation( The new center of the camera frame in cartesian coordinates. """ - if phi is not None: - self.renderer.camera.set_phi(phi) - if theta is not None: - self.renderer.camera.set_theta(theta) - if focal_distance is not None: - self.renderer.camera.set_focal_distance(focal_distance) - if gamma is not None: - self.renderer.camera.set_gamma(gamma) - if zoom is not None: - self.renderer.camera.set_zoom(zoom) - if frame_center is not None: - self.renderer.camera._frame_center.move_to(frame_center) + if isinstance(self.camera, ThreeDCamera): + if phi is not None: + self.camera.set_phi(phi) + if theta is not None: + self.camera.set_theta(theta) + if focal_distance is not None: + self.camera.set_focal_distance(focal_distance) + if gamma is not None: + self.camera.set_gamma(gamma) + if zoom is not None: + self.camera.set_zoom(zoom) + if frame_center is not None: + self.camera.frame.move_to(frame_center) + elif isinstance(self.camera, OpenGLCamera): + if phi is not None: + self.camera.set_phi(phi) + if theta is not None: + self.camera.set_theta(theta) + if gamma is not None: + self.camera.set_gamma(gamma) + if zoom is not None: + self.camera.scale(config.frame_height / (zoom * self.camera.height)) + if focal_distance is not None: + warnings.warn( + "focal distance of OpenGLCamera can not be adjusted.", + stacklevel=2, + ) + if frame_center is not None: + self.camera.move_to(frame_center) def begin_ambient_camera_rotation(self, rate: float = 0.02, about: str = "theta"): """ @@ -116,7 +133,7 @@ def begin_ambient_camera_rotation(self, rate: float = 0.02, about: str = "theta" # can begin and end smoothly about: str = about.lower() try: - if config.renderer == RendererType.CAIRO: + if isinstance(self.camera, ThreeDCamera): trackers = { "theta": self.camera.theta_tracker, "phi": self.camera.phi_tracker, @@ -125,14 +142,15 @@ def begin_ambient_camera_rotation(self, rate: float = 0.02, about: str = "theta" x: ValueTracker = trackers[about] x.add_updater(lambda m, dt: x.increment_value(rate * dt)) self.add(x) - elif config.renderer == RendererType.OPENGL: - cam: OpenGLCamera = self.camera + elif isinstance(self.camera, OpenGLCamera): methods = { - "theta": cam.increment_theta, - "phi": cam.increment_phi, - "gamma": cam.increment_gamma, + "theta": self.camera.increment_theta, + "phi": self.camera.increment_phi, + "gamma": self.camera.increment_gamma, } - cam.add_updater(lambda m, dt: methods[about](rate * dt)) + self.camera.add_updater( + lambda m, dt: methods[about](rate * dt), + ) self.add(self.camera) except Exception as e: raise ValueError("Invalid ambient rotation angle.") from e @@ -141,7 +159,7 @@ def stop_ambient_camera_rotation(self, about="theta"): """This method stops all ambient camera rotation.""" about: str = about.lower() try: - if config.renderer == RendererType.CAIRO: + if isinstance(self.camera, ThreeDCamera): trackers = { "theta": self.camera.theta_tracker, "phi": self.camera.phi_tracker, @@ -150,7 +168,7 @@ def stop_ambient_camera_rotation(self, about="theta"): x: ValueTracker = trackers[about] x.clear_updaters() self.remove(x) - elif config.renderer == RendererType.OPENGL: + elif isinstance(self.camera, OpenGLCamera): self.camera.clear_updaters() except Exception as e: raise ValueError("Invalid ambient rotation angle.") from e @@ -249,8 +267,7 @@ def move_camera( """ anims = [] - if config.renderer == RendererType.CAIRO: - self.camera: ThreeDCamera + if isinstance(self.camera, ThreeDCamera): value_tracker_pairs = [ (phi, self.camera.phi_tracker), (theta, self.camera.theta_tracker), @@ -262,10 +279,9 @@ def move_camera( if value is not None: anims.append(tracker.animate.set_value(value)) if frame_center is not None: - anims.append(self.camera._frame_center.animate.move_to(frame_center)) - elif config.renderer == RendererType.OPENGL: - cam: OpenGLCamera = self.camera - cam2 = cam.copy() + anims.append(self.camera.frame.animate.move_to(frame_center)) + elif isinstance(self.camera, OpenGLCamera): + cam2 = self.camera.copy() methods = { "theta": cam2.set_theta, "phi": cam2.set_phi, @@ -280,7 +296,7 @@ def move_camera( zoom_value = None if zoom is not None: - zoom_value = config.frame_height / (zoom * cam.height) + zoom_value = config.frame_height / (zoom * self.camera.height) for value, method in [ [theta, "theta"], @@ -298,34 +314,13 @@ def move_camera( stacklevel=2, ) - anims += [Transform(cam, cam2)] + anims += [Transform(self.camera, cam2)] self.play(*anims + added_anims, **kwargs) - # These lines are added to improve performance. If manim thinks that frame_center is moving, - # it is required to redraw every object. These lines remove frame_center from the Scene once - # its animation is done, ensuring that manim does not think that it is moving. Since the - # frame_center is never actually drawn, this shouldn't break anything. - if frame_center is not None and config.renderer == RendererType.CAIRO: - self.remove(self.camera._frame_center) - - def get_moving_mobjects(self, *animations: Animation): - """ - This method returns a list of all of the Mobjects in the Scene that - are moving, that are also in the animations passed. - - Parameters - ---------- - *animations - The animations whose mobjects will be checked. - """ - moving_mobjects = super().get_moving_mobjects(*animations) - camera_mobjects = self.renderer.camera.get_value_trackers() + [ - self.renderer.camera._frame_center, - ] - if any(cm in moving_mobjects for cm in camera_mobjects): - return self.mobjects - return moving_mobjects + # The semantic camera frame is a control mobject, not scene content. + if frame_center is not None and isinstance(self.camera, ThreeDCamera): + self.remove(self.camera.frame) def add_fixed_orientation_mobjects(self, *mobjects: Mobject, **kwargs): """ @@ -345,10 +340,10 @@ def add_fixed_orientation_mobjects(self, *mobjects: Mobject, **kwargs): use_static_center_func : bool center_func : function """ - if config.renderer == RendererType.CAIRO: + if isinstance(self.camera, ThreeDCamera): self.add(*mobjects) - self.renderer.camera.add_fixed_orientation_mobjects(*mobjects, **kwargs) - elif config.renderer == RendererType.OPENGL: + self.camera.add_fixed_orientation_mobjects(*mobjects, **kwargs) + elif isinstance(self.camera, OpenGLCamera): for mob in mobjects: mob: OpenGLMobject mob.fix_orientation() @@ -366,11 +361,10 @@ def add_fixed_in_frame_mobjects(self, *mobjects: Mobject): *mobjects The Mobjects whose orientation must be fixed. """ - if config.renderer == RendererType.CAIRO: + if isinstance(self.camera, ThreeDCamera): self.add(*mobjects) - self.camera: ThreeDCamera self.camera.add_fixed_in_frame_mobjects(*mobjects) - elif config.renderer == RendererType.OPENGL: + elif isinstance(self.camera, OpenGLCamera): for mob in mobjects: mob: OpenGLMobject mob.fix_in_frame() @@ -388,9 +382,9 @@ def remove_fixed_orientation_mobjects(self, *mobjects: Mobject): *mobjects The Mobjects whose orientation must be unfixed. """ - if config.renderer == RendererType.CAIRO: - self.renderer.camera.remove_fixed_orientation_mobjects(*mobjects) - elif config.renderer == RendererType.OPENGL: + if isinstance(self.camera, ThreeDCamera): + self.camera.remove_fixed_orientation_mobjects(*mobjects) + elif isinstance(self.camera, OpenGLCamera): for mob in mobjects: mob: OpenGLMobject mob.unfix_orientation() @@ -407,9 +401,9 @@ def remove_fixed_in_frame_mobjects(self, *mobjects: Mobject): *mobjects The Mobjects whose position and orientation must be unfixed. """ - if config.renderer == RendererType.CAIRO: - self.renderer.camera.remove_fixed_in_frame_mobjects(*mobjects) - elif config.renderer == RendererType.OPENGL: + if isinstance(self.camera, ThreeDCamera): + self.camera.remove_fixed_in_frame_mobjects(*mobjects) + elif isinstance(self.camera, OpenGLCamera): for mob in mobjects: mob: OpenGLMobject mob.unfix_from_frame() diff --git a/manim/utils/testing/_test_class_makers.py b/manim/utils/testing/_test_class_makers.py index ac8f011c20..9915db180b 100644 --- a/manim/utils/testing/_test_class_makers.py +++ b/manim/utils/testing/_test_class_makers.py @@ -3,7 +3,7 @@ from collections.abc import Callable from typing import Any -from manim.renderer.cairo_renderer import CairoRenderer +from manim.renderer.cairo import CairoRenderer from manim.renderer.opengl import OpenGLRenderer from manim.scene.scene import Scene from manim.scene.scene_file_writer import SceneFileWriter, _SceneFileWriterSettings diff --git a/manim/utils/testing/frames_comparison.py b/manim/utils/testing/frames_comparison.py index fd8b26ce05..1eebf2c1e6 100644 --- a/manim/utils/testing/frames_comparison.py +++ b/manim/utils/testing/frames_comparison.py @@ -14,7 +14,7 @@ from manim._config import tempconfig from manim._config.utils import ManimConfig from manim.camera.three_d_camera import ThreeDCamera -from manim.renderer.cairo_renderer import CairoRenderer +from manim.renderer.cairo import CairoRenderer from manim.renderer.opengl import OpenGLRenderer from manim.scene.three_d_scene import ThreeDScene from manim.typing import StrPath diff --git a/tests/control_data/logs_data/BasicSceneLoggingTest.txt b/tests/control_data/logs_data/BasicSceneLoggingTest.txt index 0f49c5618f..e0df62e239 100644 --- a/tests/control_data/logs_data/BasicSceneLoggingTest.txt +++ b/tests/control_data/logs_data/BasicSceneLoggingTest.txt @@ -2,7 +2,7 @@ {"levelname": "DEBUG", "module": "hashing", "message": "Hashing ..."} {"levelname": "DEBUG", "module": "hashing", "message": "Hashing done in <> s."} {"levelname": "DEBUG", "module": "hashing", "message": "Hash generated : <>"} -{"levelname": "DEBUG", "module": "cairo_renderer", "message": "List of the first few animation hashes of the scene: <>"} +{"levelname": "DEBUG", "module": "renderer", "message": "List of the first few animation hashes of the scene: <>"} {"levelname": "INFO", "module": "scene_file_writer", "message": "Animation 0 : Partial movie file written in <>"} {"levelname": "INFO", "module": "scene_file_writer", "message": "Combining to Movie file."} {"levelname": "DEBUG", "module": "scene_file_writer", "message": "Partial movie files to combine (1 files): <>"} diff --git a/tests/test_camera.py b/tests/test_camera.py index 44c54e7e4f..b5dd2eb8c3 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -1,6 +1,29 @@ from __future__ import annotations -from manim import MovingCamera, Square +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np +import pytest +from PIL import Image + +from manim import ( + BLUE, + RED, + Camera, + Group, + ImageMobjectFromCamera, + Mobject, + MovingCamera, + MultiCamera, + Scene, + Square, + ThreeDCamera, + tempconfig, +) +from manim.renderer.cairo import CairoRenderer +from manim.renderer.cairo.rendering import _CairoDrawingContext +from manim.utils.color import color_to_int_rgba def test_movingcamera_auto_zoom(): @@ -9,3 +32,246 @@ def test_movingcamera_auto_zoom(): margin = 0.5 camera.auto_zoom([square], margin=margin, animate=False) assert camera.frame.height == square.height + margin + + +def test_default_camera_is_movable_and_resource_free(): + camera = Camera() + + assert not hasattr(camera, "pixel_array") + assert not hasattr(camera, "capture_mobjects") + camera.frame.move_to([2, 1, 0]).set(width=6) + + assert camera.frame_center.tolist() == [2, 1, 0] + assert camera.frame_width == 6 + + +def test_camera_frame_geometry_is_semantic_and_explicit(): + camera = Camera(frame_width=8, frame_height=4) + assert camera.frame_width == 8 + assert camera.frame_height == 4 + + custom_frame = Square(side_length=3).move_to([2, 1, 0]) + custom_camera = Camera(frame=custom_frame) + assert custom_camera.frame is custom_frame + assert custom_camera.frame_width == 3 + assert custom_camera.frame_height == 3 + np.testing.assert_array_equal(custom_camera.frame_center, [2, 1, 0]) + + +@pytest.mark.parametrize( + "removed_setting", + [ + {"pixel_width": 100}, + {"frame_rate": 30}, + {"cairo_line_width_multiple": 0.02}, + {"fixed_dimension": 1}, + ], +) +def test_camera_rejects_removed_raster_settings(removed_setting): + with pytest.raises(TypeError, match="unexpected keyword argument"): + Camera(**removed_setting) + + +def test_default_scene_camera_auto_zoom(): + with tempconfig({"dry_run": True, "quality": "low_quality"}): + scene = Scene() + square = Square().move_to([2, 0, 0]) + scene.play(scene.camera.auto_zoom([square], margin=0.5)) + + assert scene.camera.frame_center.tolist() == square.get_center().tolist() + assert scene.camera.frame_height == square.height + 0.5 + + +def test_mobject_get_image_uses_temporary_renderer(): + with tempconfig({"pixel_width": 32, "pixel_height": 18}): + image = Square(color=BLUE, fill_opacity=1).get_image() + + pixels = np.asarray(image) + assert image.size == (32, 18) + assert np.any(pixels[:, :, 2] > 0) + + +def test_camera_backed_image_constructs_without_camera_pixels(): + camera = Camera() + + image = ImageMobjectFromCamera(camera) + + assert image.camera is camera + assert not hasattr(image, "pixel_array") + assert "get_pixel_array" not in type(image).__dict__ + assert image.width / image.height == pytest.approx( + camera.frame_width / camera.frame_height, + ) + + +def test_renderer_owns_background_readback(): + with tempconfig({"pixel_width": 8, "pixel_height": 4}): + renderer = CairoRenderer( + camera=Camera(background_color=RED, background_opacity=0.5), + ) + try: + renderer.update_frame(None, mobjects=[Mobject()]) + pixels = renderer.get_frame() + expected = color_to_int_rgba(RED, 0.5) + assert np.all(pixels == expected) + + pixels[:] = 0 + assert np.all(renderer.get_frame() == expected) + finally: + renderer.close() + + +def test_background_image_is_loaded_by_renderer(tmp_path): + source = np.array( + [ + [[255, 0, 0, 255], [0, 255, 0, 192]], + [[0, 0, 255, 128], [255, 255, 0, 64]], + ], + dtype=np.uint8, + ) + image_path = tmp_path / "asymmetric.png" + Image.fromarray(source, mode="RGBA").save(image_path) + + with tempconfig({"pixel_width": 2, "pixel_height": 2}): + camera = Camera(background_image=str(image_path)) + assert not hasattr(camera, "background") + renderer = CairoRenderer(camera=camera) + try: + renderer.update_frame(None, mobjects=[Mobject()]) + np.testing.assert_array_equal(renderer.get_frame(), source) + finally: + renderer.close() + + +def test_background_image_is_resized_to_target(tmp_path): + source = np.zeros((2, 4, 4), dtype=np.uint8) + source[:, :2] = [255, 0, 0, 255] + source[:, 2:] = [0, 0, 255, 255] + image_path = tmp_path / "wide.png" + Image.fromarray(source, mode="RGBA").save(image_path) + + with tempconfig({"pixel_width": 2, "pixel_height": 2}): + renderer = CairoRenderer(camera=Camera(background_image=str(image_path))) + try: + renderer.update_frame(None, mobjects=[Mobject()]) + expected = np.asarray( + Image.fromarray(source, mode="RGBA").resize((2, 2)), + dtype=np.uint8, + ) + np.testing.assert_array_equal(renderer.get_frame(), expected) + finally: + renderer.close() + + +def test_background_colored_mobjects_reuse_renderer_scratch_target(tmp_path): + image_path = tmp_path / "background.png" + Image.new("RGBA", (8, 4), (20, 40, 60, 255)).save(image_path) + + with tempconfig({"pixel_width": 8, "pixel_height": 4}): + square = Square().color_using_background_image(str(image_path)) + renderer = CairoRenderer() + try: + renderer.update_frame(None, mobjects=[square]) + scratch = renderer._target.get_scratch_target() + renderer.update_frame(None, mobjects=[square]) + assert renderer._target.get_scratch_target() is scratch + finally: + renderer.close() + + with pytest.raises(RuntimeError, match="closed"): + _ = scratch.pixels + + +def test_nested_view_excludes_its_own_display(): + nested_camera = Camera() + view = ImageMobjectFromCamera(nested_camera) + primary_camera = MultiCamera([view]) + renderer = CairoRenderer(camera=primary_camera) + + try: + with patch.object(_CairoDrawingContext, "draw", autospec=True) as draw: + renderer.update_frame(None, mobjects=[Group(view)]) + nested_draw = next( + call for call in draw.call_args_list if call.args[0].camera is nested_camera + ) + excluded = nested_draw.kwargs["excluded_mobjects"] + assert view in excluded + finally: + renderer.close() + + +def test_nested_target_size_tracks_display_size_and_closes(): + with tempconfig({"pixel_width": 100, "pixel_height": 50}): + view = ImageMobjectFromCamera(Camera()) + view.stretch_to_fit_width(4).stretch_to_fit_height(2) + primary_camera = MultiCamera([view]) + renderer = CairoRenderer(camera=primary_camera) + main_target = renderer._target + + renderer.update_frame(None, mobjects=[view]) + first_target = renderer._sub_targets[id(view)] + assert first_target.settings.pixel_width == max( + 1, + int(100 * view.width / primary_camera.frame_width), + ) + assert first_target.settings.pixel_height == max( + 1, + int(50 * view.height / primary_camera.frame_height), + ) + + view.stretch_to_fit_width(6) + renderer.update_frame(None, mobjects=[view]) + second_target = renderer._sub_targets[id(view)] + assert second_target is not first_target + with pytest.raises(RuntimeError, match="closed"): + _ = first_target.pixels + + renderer.close() + renderer.close() + assert renderer._sub_targets == {} + with pytest.raises(RuntimeError, match="closed"): + _ = main_target.pixels + with pytest.raises(RuntimeError, match="closed"): + _ = second_target.pixels + + +def test_nested_views_disable_unsafe_static_reuse(): + view = ImageMobjectFromCamera(Camera()) + renderer = CairoRenderer(camera=MultiCamera([view])) + scene = SimpleNamespace(moving_mobjects=[Square()]) + + try: + assert renderer.save_static_frame_data(scene, [view]) is None + assert renderer._render_all_mobjects is True + finally: + renderer.close() + + +def test_multicamera_reports_recursive_view_controls_without_recursing_cycles(): + three_d_camera = ThreeDCamera() + nested_camera = MultiCamera([ImageMobjectFromCamera(three_d_camera)]) + primary_camera = MultiCamera([ImageMobjectFromCamera(nested_camera)]) + nested_camera.add_image_mobject_from_camera(ImageMobjectFromCamera(primary_camera)) + + indicators = primary_camera.get_mobjects_indicating_movement() + + assert primary_camera.frame in indicators + assert nested_camera.frame in indicators + assert three_d_camera.theta_tracker in indicators + assert three_d_camera.zoom_tracker in indicators + + +def test_nested_camera_cycles_are_rejected(): + first = MultiCamera() + second = MultiCamera() + second_view = ImageMobjectFromCamera(second) + first_view = ImageMobjectFromCamera(first) + first.add_image_mobject_from_camera(second_view) + second.add_image_mobject_from_camera(first_view) + renderer = CairoRenderer(camera=first) + + try: + with pytest.raises(RuntimeError, match="composition cycle"): + renderer.render_mobjects([first_view, second_view]) + finally: + renderer.close() diff --git a/tests/test_graphical_units/control_data/camera/moving_camera_frame.npz b/tests/test_graphical_units/control_data/camera/moving_camera_frame.npz new file mode 100644 index 0000000000000000000000000000000000000000..0b2a666e3b7cc998fb350b775b146018525a561a GIT binary patch literal 8497 zcmeHN>r)d~6kni}4r z#bQX+B1(ingAxb{VhX9lBTxi+M+_1q5E2py2?n~GXb1ZTbn49R&hDAL_s;I#bAIQZ zbM86*-Ugr80|4+DcX zv;>$N6VS#D^>{L&S(4S~RvQ0Q_wSlU3%_>0D)rj!)9}XAB4c-5+LlSOTZhHSq2T8Q zR~>F2GQPR_ryB>d@Fgc4{=ocN-@HY3xRzyxrp&)`D?b`{D}6kf$Ty%nHb<0GEIB3~ z1Od^NFV81()}C-!oSfcKZaR6EDUu!(xHSoBa(AMFi*m>F=a(f0M@T7(9*rRPk#Q!hwVWt=qOQ)Ytc0agQeb@<<|igoqa zAe8KF%t&2j1lGlacs4bNy|Bc(OEqB#e~AkZU08GTBZCH^@U~w_TkfV8rElc1%%w~D z=uSlSo#0`cU|Lytc2a5(g(zb8@{Ir>Vqg#p0JXn+D+Q<_g3PM6%9hSry`T(Ys|Kb; z+}To$tn-y~T*v7VomsK2j$#)kbSiS3Li5_u$k?L50K24XN_|G4~X`9#zz%li5Cm}5-EiIwe!y|So`^2xkZr7QZX&kM|J#*m-#h! z{<1M<6+eaOK$Li@t7HWqhRVqiRF{w})$Nl40a}VFq;Dd*5cpDdG}Y0fL>pP+brI&n zshYVUpK;f0-dfUwbaRffyMW}hl*5o|K?2665N&G=t$*1o&hqVknCfTm{I*P(ho@-F zqcMnY5UgS^+MZeH7rbb$HzJQ!XZhK?B+}}TMixaMd?+S+E?kA`_iAYvZkb!?MVeS# zSl@5wGMW{Iny(D$5^umHbWHi)#W=LbX=CG4I>Xg5)*_yfKYnmeGNx+lMQ#%^X!q-( ziPu8(^=ke6fSf}M8_w&PCSQ~dL2Lc{|Nnt2TB%W}jw1)8P8e1uIDnk09k6cmlQ0tmI(5SRIj^im`Nw=wjZy}W zNVt^1Z1@ zFhZym*?ZCTZ=?(JG%qd1xtRVix+dCC8}kn8wnFO6xcSzFd1`jO^13NTaCUiEn#ENz zj~I%g)`LUrOu$Q%R!ojU>hW-bkCxwf^ub3C=w@@LM+enCS}uU91I-hJ58SQQV3;j1 z93b6?wM1)B;nhKF0$@1k{3{p^|2-UvE2^6^*2YS@wuS4{niiVGm_aO#>HkWvc%L*($dqrO+e@nY=9^vADli4rxt(PU0D<`aFZh0s zE%S^6Gr7V_v#fqwT#GyO+M1lDwzh=sC0_YX4WV??L27;R;`W+-eWI?EIr9}lsbz>K zxa&8*p);fr^uLt)$3Qo%U?tEW&f>t?D>!QhCo|zB?nj%v_BpwT^!L`&w>S8(zyT^< jfXWyUx(WvE{hS^|J}h_e_cr_#YSjZz50DCWK~H}JRKo^j literal 0 HcmV?d00001 diff --git a/tests/test_graphical_units/control_data/camera/zoomed_camera_view.npz b/tests/test_graphical_units/control_data/camera/zoomed_camera_view.npz new file mode 100644 index 0000000000000000000000000000000000000000..48d4bb5fa241e32e5f91f8218b1c08c0a01ba5d7 GIT binary patch literal 3736 zcmeHKdrVVT82@6(&;~(3OT|YYwxxqo+tPODT!@RwmTk%WXXD=7dvbHX z*ZH0E{l4?1B>MQS27u?#^D2-_3!^-v!{Cl)781tQu)t`{c;;wXwG0 z!-|&i&i>|O*{;i{ABMHssy30gQFxmEcO;4eSEnS6JKm-1yZ+fm>L9k=R5-LgI!z&DYMOhJj*8%AY1Qp5o^5*8Y%3~Pxw zy>O{~0r1~qB*ZfOAih#uUya08B+>h*bT2q55bYL<05+>=G8MA(osKl5BLOJcK}Kw3 zkGwc%PJIr#xotC-GJj1Mskd`IK1Gsk7k>%Kq8KI%#`gCTw6dQw-Re^D)c0P9s^K`= zvm>iT{PUHe;iG5@j~7(cw!DUG(VvSkZBejL zH3O$|gjVXg*KlP|Pd3xRF(BWAW&R9~y`;G;P8ehAMJ27HdW*JEwYyq1(tu*FJ1!nl zld&#?-S5u9(Q4~OQ_YEJcgF`aeo!#*MC95=mr;q7dlTr!gJpP*WcglR3$=4&9M?2{ zxo9B3JZ&g#rZ`OzMPWSSaZBuc02S7X@lQ@W;vRSM+PROfY?8RjY#z(6nku!OJAKCs zI#yycrhZFwe|4pPtJ&qs>}diR_0=I&IlaeYf9R2(TW6izr!G;i1t{Nu{13EoT?gr= zAzGN{{zua$WMv7qg6lK8kLDcaNZhpwpXo~?cfdjbYB8Fj_=t5p(ClstN_Xgk@8$*q z2zs&PqITO*wSwsy{J5Qo&M?%kvFzzXM_E>>A}zMn*;mW@Y4S!y%~s^Zo0zezXwetiCcm^-fP z>Z;vZaP#+-d?4O{mZf8v7H2TPc_H4o02gX-dZHg(oWN))4Md913H);$?id+Xtu>HU z5WqZvDHr9gVM@G{Ky{zCQMwVsywrBv)6vv3+?|U_-RD^zdgpBa&kz`WWDiV9^zvTq m^K>7HJ4DEv%0`NjN1&!|!3cmr|lr_r$ literal 0 HcmV?d00001 diff --git a/tests/test_graphical_units/test_camera.py b/tests/test_graphical_units/test_camera.py new file mode 100644 index 0000000000..228075ce8a --- /dev/null +++ b/tests/test_graphical_units/test_camera.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from manim import ( + BLUE, + GREEN, + RED, + RIGHT, + Dot, + MultiCamera, + NumberPlane, + Scene, + Square, + ZoomedScene, +) +from manim.utils.testing.frames_comparison import frames_comparison + +__module_test__ = "camera" + + +@frames_comparison(base_scene=Scene) +def test_moving_camera_frame(scene: Scene): + plane = NumberPlane() + marker = Dot(2 * RIGHT, color=RED) + scene.add(plane, marker) + scene.play(scene.camera.frame.animate.set(width=6).move_to(marker)) + + +class _ZoomedCameraControlScene(ZoomedScene): + def __init__(self, renderer=None, **kwargs): + if renderer is not None: + renderer.camera = MultiCamera() + super().__init__(renderer=renderer, **kwargs) + + +@frames_comparison(base_scene=_ZoomedCameraControlScene) +def test_zoomed_camera_view(scene: ZoomedScene): + square = Square(color=BLUE, fill_opacity=0.35) + dot = Dot(color=GREEN) + scene.add(square, dot) + scene.activate_zooming(animate=False) + scene.play(dot.animate.shift(RIGHT)) diff --git a/tests/test_scene_rendering/test_cairo_renderer.py b/tests/test_scene_rendering/test_cairo_renderer.py index 0a2f38d28f..afc61e7ea1 100644 --- a/tests/test_scene_rendering/test_cairo_renderer.py +++ b/tests/test_scene_rendering/test_cairo_renderer.py @@ -10,6 +10,13 @@ from .simple_scenes import * +def test_cairo_renderer_import_compatibility(): + from manim.renderer.cairo import CairoRenderer as PackagedCairoRenderer + from manim.renderer.cairo_renderer import CairoRenderer as LegacyCairoRenderer + + assert PackagedCairoRenderer is LegacyCairoRenderer + + def test_render(using_temp_config, disabling_caching): scene = SquareToCircle() renderer = scene.renderer @@ -88,7 +95,7 @@ def test_hash_logic_is_not_called_when_caching_is_disabled( using_temp_config, disabling_caching, ): - with patch("manim.renderer.cairo_renderer.get_hash_from_play_call") as mocked: + with patch("manim.renderer.cairo.renderer.get_hash_from_play_call") as mocked: scene = SquareToCircle() scene.render() mocked.assert_not_called() @@ -96,10 +103,10 @@ def test_hash_logic_is_not_called_when_caching_is_disabled( def test_hash_logic_is_called_when_caching_is_enabled(using_temp_config): - from manim.renderer.cairo_renderer import get_hash_from_play_call + from manim.renderer.cairo.renderer import get_hash_from_play_call with patch( - "manim.renderer.cairo_renderer.get_hash_from_play_call", + "manim.renderer.cairo.renderer.get_hash_from_play_call", wraps=get_hash_from_play_call, ) as mocked: scene = SquareToCircle() From 4a2f751b4b4dfe271fa9bfdcaf718cd34cfea646 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Tue, 1 Sep 2026 15:31:06 +0200 Subject: [PATCH 03/13] Remove legacy Cairo camera implementations --- docs/source/changelog/0.12.0-changelog.rst | 2 +- docs/source/guides/deep_dive.rst | 79 +++++----- docs/source/reference.rst | 1 - docs/source/reference_index/cameras.rst | 1 - manim/__init__.py | 1 - manim/camera/mapping_camera.py | 170 --------------------- 6 files changed, 36 insertions(+), 218 deletions(-) delete mode 100644 manim/camera/mapping_camera.py diff --git a/docs/source/changelog/0.12.0-changelog.rst b/docs/source/changelog/0.12.0-changelog.rst index b67a3b7440..9126312885 100644 --- a/docs/source/changelog/0.12.0-changelog.rst +++ b/docs/source/changelog/0.12.0-changelog.rst @@ -246,7 +246,7 @@ Code quality improvements and similar refactors * :pr:`2200`: Addressed some maintenance TODOs - Changed an `Exception` to `ValueError` - - Fixed :meth:`.MappingCamera.points_to_pixel_coords` by adding the ``mobject`` argument of the parent + - Fixed ``MappingCamera.points_to_pixel_coords`` by adding the ``mobject`` argument of the parent - Rounded up width in :class:`.SplitScreenCamera` - Added docstring to :meth:`.Camera.capture_mobject` diff --git a/docs/source/guides/deep_dive.rst b/docs/source/guides/deep_dive.rst index e315d5b0d4..768f8292a7 100644 --- a/docs/source/guides/deep_dive.rst +++ b/docs/source/guides/deep_dive.rst @@ -326,7 +326,7 @@ renderer reference or read mutable global configuration. Directories are created lazily when their owning operation first writes. The writer remains Manim's interface to ``libav`` for media assembly. The Cairo renderer (see the implementation `here -`__) +`__) does not require further renderer-specific initialization. OpenGL creates a window only when the resolved presentation specification requests a live preview. The ``-p`` / ``--preview`` option does not create this window; it opens the @@ -998,44 +998,35 @@ the *static mobjects* are assumed to have already been painted statically to the background of the scene). All of the hard work then happens when the renderer updates its current frame via a call to :meth:`.CairoRenderer.update_frame`: -First, the renderer prepares its :class:`.Camera` by checking whether the renderer -has a ``static_image`` different from ``None`` stored already. If so, it sets the -image as the *background image* of the camera via :meth:`.Camera.set_frame_to_background`, -and otherwise it just resets the camera via :meth:`.Camera.reset`. The camera is then -asked to capture the scene with a call to :meth:`.Camera.capture_mobjects`. - -Things get a bit technical here, and at some point it is more efficient to -delve into the implementation -- but here is a summary of what happens once the -camera is asked to capture the scene: - -- First, a flat list of mobjects is created (so submobjects get extracted from - their parents). This list is then processed in groups of the same type of - mobjects (e.g., a batch of vectorized mobjects, followed by a batch of image mobjects, - followed by more vectorized mobjects, etc. -- in many cases there will just be - one batch of vectorized mobjects). -- Depending on the type of the currently processed batch, the camera uses dedicated - *display functions* to convert the :class:`.Mobject` Python object to - a NumPy array stored in the camera's ``pixel_array`` attribute. - The most important example in that context is the display function for - vectorized mobjects, :meth:`.Camera.display_multiple_vectorized_mobjects`, - or the more particular (in case you did not add a background image to your - :class:`.VMobject`), :meth:`.Camera.display_multiple_non_background_colored_vmobjects`. - This method first gets the current Cairo context, and then, for every (vectorized) - mobject in the batch, calls :meth:`.Camera.display_vectorized`. There, - the actual background stroke, fill, and then stroke of the mobject is - drawn onto the context. See :meth:`.Camera.apply_stroke` and - :meth:`.Camera.set_cairo_context_color` for more details -- but it does not get - much deeper than that, in the latter method the actual Bézier curves - determined by the points of the mobject are drawn; this is where the low-level - interaction with Cairo happens. - -After all batches have been processed, the camera has an image representation -of the Scene at the current time stamp in form of a NumPy array stored in its -``pixel_array`` attribute. The renderer passes a top-left-origin, -C-contiguous ``uint8`` RGBA array to its :class:`.SceneFileWriter`. OpenGL uses -the same array contract and performs GPU readback at this renderer boundary only -when file output needs a frame. This concludes one iteration of the render loop, -and once the time progression has been processed completely, a final bit +First, the renderer prepares its own Cairo raster target. If a reusable +``static_image`` is available, the renderer copies it into that target; otherwise it +resets the target from the semantic background settings of :class:`.Camera`. +Background images whose dimensions differ from the target are resized to the target +dimensions. The camera itself does not own pixels or a Cairo context; its constructor +accepts semantic view settings rather than pixel dimensions or frame-rate options. + +Things get a bit technical here, and at some point it is more efficient to delve into +the implementation -- but the renderer-owned drawing process can be summarized as +follows: + +- The camera supplies a flat, ordered list of visible mobjects and applies pure + view/projection and shading transformations. Its animatable ``frame`` describes the + logical region being viewed. +- Private Cairo renderer helpers process consecutive batches of vectorized, point + cloud, and image mobjects without changing their draw order. +- Vectorized mobjects are converted to Cairo paths and drawn with their background + stroke, fill, and foreground stroke. Point clouds and image mobjects are converted + to target pixel coordinates and composited by renderer helpers. +- A :class:`.MultiCamera` describes nested camera-backed views. Their + :class:`.ImageMobjectFromCamera` display mobjects contain geometry and sampling + settings but no placeholder or live pixels. The renderer creates secondary targets + lazily, excludes each view's own display from its source camera, and composites the + result into the primary target. + +After all batches have been processed, :class:`.CairoRenderer` owns the image +representation of the Scene. It passes a fresh top-left-origin, C-contiguous ``uint8`` +RGBA array to its :class:`.SceneFileWriter`. This concludes one iteration of the +render loop, and once the time progression has been processed completely, a final bit of cleanup is performed before the :meth:`.Scene.play_internal` call is completed. A TL;DR for the render loop, in the context of our toy example, reads as follows: @@ -1049,11 +1040,11 @@ A TL;DR for the render loop, in the context of our toy example, reads as follows state of the transformation animation to the desired time stamp (for example, at time stamp ``t = 45/30``, the animation is completed to a rate of ``alpha = 0.5``). -- Then the scene asks the renderer to do its job. The renderer asks its camera - to capture the scene, the only mobject that needs to be processed at this point - is the main mobject attached to the transformation; the camera converts the - current state of the mobject to entries in a NumPy array. The renderer passes - this array to the file writer. +- Then the scene asks the renderer to do its job. The only mobject that needs to + be processed at this point is the main mobject attached to the transformation. + The camera supplies its semantic view transform, while renderer-owned Cairo + helpers draw the current mobject state into the renderer's raster target. The + renderer reads that target and passes an owned array to the file writer. - At the end of the loop, 90 frames have been passed to the file writer. Completing the render loop diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 5352f83223..284b9c1881 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -39,7 +39,6 @@ Cameras .. inheritance-diagram:: manim.camera.camera - manim.camera.mapping_camera manim.camera.moving_camera manim.camera.multi_camera manim.camera.three_d_camera diff --git a/docs/source/reference_index/cameras.rst b/docs/source/reference_index/cameras.rst index b56577bf22..a2352359da 100644 --- a/docs/source/reference_index/cameras.rst +++ b/docs/source/reference_index/cameras.rst @@ -7,7 +7,6 @@ Cameras :toctree: ../reference ~camera.camera - ~camera.mapping_camera ~camera.moving_camera ~camera.multi_camera ~camera.three_d_camera diff --git a/manim/__init__.py b/manim/__init__.py index b2e35fd555..cb694ba075 100644 --- a/manim/__init__.py +++ b/manim/__init__.py @@ -42,7 +42,6 @@ from .animation.updaters.mobject_update_utils import * from .animation.updaters.update import * from .camera.camera import * -from .camera.mapping_camera import * from .camera.moving_camera import * from .camera.multi_camera import * from .camera.three_d_camera import * diff --git a/manim/camera/mapping_camera.py b/manim/camera/mapping_camera.py deleted file mode 100644 index 4d347d02a3..0000000000 --- a/manim/camera/mapping_camera.py +++ /dev/null @@ -1,170 +0,0 @@ -"""A camera module that supports spatial mapping between objects for distortion effects.""" - -from __future__ import annotations - -__all__ = ["MappingCamera", "OldMultiCamera", "SplitScreenCamera"] - -import math - -import numpy as np - -from ..camera.camera import Camera -from ..mobject.types.vectorized_mobject import VMobject -from ..utils.config_ops import DictAsObject - -# TODO: Add an attribute to mobjects under which they can specify that they should just -# map their centers but remain otherwise undistorted (useful for labels, etc.) - - -class MappingCamera(Camera): - """Parameters - ---------- - mapping_func : callable - Function to map 3D points to new 3D points (identity by default). - min_num_curves : int - Minimum number of curves for VMobjects to avoid visual glitches. - allow_object_intrusion : bool - If True, modifies original mobjects; else works on copies. - kwargs : dict - Additional arguments passed to Camera base class. - """ - - def __init__( - self, - mapping_func=lambda p: p, - min_num_curves=50, - allow_object_intrusion=False, - **kwargs, - ): - self.mapping_func = mapping_func - self.min_num_curves = min_num_curves - self.allow_object_intrusion = allow_object_intrusion - super().__init__(**kwargs) - - def points_to_pixel_coords(self, mobject, points): - # Map points with custom function before converting to pixels - return super().points_to_pixel_coords( - mobject, - np.apply_along_axis(self.mapping_func, 1, points), - ) - - def capture_mobjects(self, mobjects, **kwargs): - """Capture mobjects for rendering after applying the spatial mapping. - - Copies mobjects unless intrusion is allowed, and ensures - vector objects have enough curves for smooth distortion. - """ - mobjects = self.get_mobjects_to_display(mobjects, **kwargs) - if self.allow_object_intrusion: - mobject_copies = mobjects - else: - mobject_copies = [mobject.copy() for mobject in mobjects] - for mobject in mobject_copies: - if ( - isinstance(mobject, VMobject) - and 0 < mobject.get_num_curves() < self.min_num_curves - ): - mobject.insert_n_curves(self.min_num_curves) - super().capture_mobjects( - mobject_copies, - include_submobjects=False, - excluded_mobjects=None, - ) - - -# Note: This allows layering of multiple cameras onto the same portion of the pixel array, -# the later cameras overwriting the former -# -# TODO: Add optional separator borders between cameras (or perhaps peel this off into a -# CameraPlusOverlay class) - - -# TODO, the classes below should likely be deleted -class OldMultiCamera(Camera): - """Parameters - ---------- - cameras_with_start_positions : tuple - Tuples of (Camera, (start_y, start_x)) indicating camera and - its pixel offset on the final frame. - """ - - def __init__(self, *cameras_with_start_positions, **kwargs): - self.shifted_cameras = [ - DictAsObject( - { - "camera": camera_with_start_positions[0], - "start_x": camera_with_start_positions[1][1], - "start_y": camera_with_start_positions[1][0], - "end_x": camera_with_start_positions[1][1] - + camera_with_start_positions[0].pixel_width, - "end_y": camera_with_start_positions[1][0] - + camera_with_start_positions[0].pixel_height, - }, - ) - for camera_with_start_positions in cameras_with_start_positions - ] - super().__init__(**kwargs) - - def capture_mobjects(self, mobjects, **kwargs): - for shifted_camera in self.shifted_cameras: - shifted_camera.camera.capture_mobjects(mobjects, **kwargs) - - self.pixel_array[ - shifted_camera.start_y : shifted_camera.end_y, - shifted_camera.start_x : shifted_camera.end_x, - ] = shifted_camera.camera.pixel_array - - def set_background(self, pixel_array, **kwargs): - for shifted_camera in self.shifted_cameras: - shifted_camera.camera.set_background( - pixel_array[ - shifted_camera.start_y : shifted_camera.end_y, - shifted_camera.start_x : shifted_camera.end_x, - ], - **kwargs, - ) - - def set_pixel_array(self, pixel_array, **kwargs): - super().set_pixel_array(pixel_array, **kwargs) - for shifted_camera in self.shifted_cameras: - shifted_camera.camera.set_pixel_array( - pixel_array[ - shifted_camera.start_y : shifted_camera.end_y, - shifted_camera.start_x : shifted_camera.end_x, - ], - **kwargs, - ) - - def init_background(self): - super().init_background() - for shifted_camera in self.shifted_cameras: - shifted_camera.camera.init_background() - - -# A OldMultiCamera which, when called with two full-size cameras, initializes itself -# as a split screen, also taking care to resize each individual camera within it - - -class SplitScreenCamera(OldMultiCamera): - """Initializes a split screen camera setup with two side-by-side cameras. - - Parameters - ---------- - left_camera : Camera - right_camera : Camera - kwargs : dict - """ - - def __init__(self, left_camera, right_camera, **kwargs): - Camera.__init__(self, **kwargs) # to set attributes such as pixel_width - self.left_camera = left_camera - self.right_camera = right_camera - - half_width = math.ceil(self.pixel_width / 2) - for camera in [self.left_camera, self.right_camera]: - camera.reset_pixel_shape(camera.pixel_height, half_width) - - super().__init__( - (left_camera, (0, 0)), - (right_camera, (0, half_width)), - ) From bb803bee9504542f6e49c8a974adf360987d96f3 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Tue, 1 Sep 2026 17:17:30 +0200 Subject: [PATCH 04/13] fix: read frame rate from config instead from camera --- benchmarks/bench_lissajous.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/bench_lissajous.py b/benchmarks/bench_lissajous.py index aa5b75deaa..bac01c3336 100644 --- a/benchmarks/bench_lissajous.py +++ b/benchmarks/bench_lissajous.py @@ -354,7 +354,7 @@ def construct(self) -> None: self.add_path_updaters() self.wait_until(lambda: self.is_path_traced_once()) - self.wait(1 / self.camera.frame_rate) + self.wait(1 / config.frame_rate) self.suspend_circles_updating() self.wait(2) From d19d5c4a0c4231f41e976f59ee598500761f8e08 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Tue, 1 Sep 2026 18:26:40 +0200 Subject: [PATCH 05/13] Consolidate backend camera implementations --- docs/source/reference.rst | 8 +- docs/source/reference_index/cameras.rst | 6 +- manim/__init__.py | 4 - manim/camera/__init__.py | 0 manim/camera/camera.py | 281 ------------ manim/camera/moving_camera.py | 15 - manim/camera/multi_camera.py | 54 --- manim/manager.py | 5 +- manim/mobject/mobject.py | 4 +- manim/mobject/types/image_mobject.py | 2 +- manim/renderer/cairo/__init__.py | 32 +- .../cairo/camera.py} | 340 +++++++++++++- manim/renderer/cairo/renderer.py | 5 +- manim/renderer/cairo/rendering.py | 3 +- manim/renderer/cairo/target.py | 3 +- manim/renderer/cairo_renderer.py | 5 - manim/renderer/opengl/__init__.py | 16 +- manim/renderer/opengl/camera.py | 413 ++++++++++++++++++ manim/renderer/opengl/renderer.py | 412 +---------------- manim/renderer/opengl_renderer.py | 5 - manim/scene/moving_camera_scene.py | 3 +- manim/scene/scene.py | 5 +- manim/scene/three_d_scene.py | 4 +- manim/scene/vector_space_scene.py | 2 +- manim/scene/zoomed_scene.py | 6 +- manim/utils/hashing.py | 4 +- manim/utils/testing/frames_comparison.py | 2 +- .../opengl/test_opengl_renderer.py | 6 - .../test_cairo_renderer.py | 7 - 29 files changed, 806 insertions(+), 846 deletions(-) delete mode 100644 manim/camera/__init__.py delete mode 100644 manim/camera/camera.py delete mode 100644 manim/camera/moving_camera.py delete mode 100644 manim/camera/multi_camera.py rename manim/{camera/three_d_camera.py => renderer/cairo/camera.py} (55%) delete mode 100644 manim/renderer/cairo_renderer.py create mode 100644 manim/renderer/opengl/camera.py delete mode 100644 manim/renderer/opengl_renderer.py diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 284b9c1881..7889ef2373 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -38,12 +38,10 @@ Cameras ******* .. inheritance-diagram:: - manim.camera.camera - manim.camera.moving_camera - manim.camera.multi_camera - manim.camera.three_d_camera + manim.renderer.cairo.camera + manim.renderer.opengl.camera :parts: 1 - :top-classes: manim.camera.camera.Camera, manim.mobject.mobject.Mobject + :top-classes: manim.renderer.cairo.camera.Camera, manim.mobject.mobject.Mobject, manim.mobject.opengl.opengl_mobject.OpenGLMobject Mobjects ******** diff --git a/docs/source/reference_index/cameras.rst b/docs/source/reference_index/cameras.rst index a2352359da..edac5c9428 100644 --- a/docs/source/reference_index/cameras.rst +++ b/docs/source/reference_index/cameras.rst @@ -6,7 +6,5 @@ Cameras .. autosummary:: :toctree: ../reference - ~camera.camera - ~camera.moving_camera - ~camera.multi_camera - ~camera.three_d_camera + ~renderer.cairo.camera + ~renderer.opengl.camera diff --git a/manim/__init__.py b/manim/__init__.py index cb694ba075..1e929b0a54 100644 --- a/manim/__init__.py +++ b/manim/__init__.py @@ -41,10 +41,6 @@ from .animation.transform_matching_parts import * from .animation.updaters.mobject_update_utils import * from .animation.updaters.update import * -from .camera.camera import * -from .camera.moving_camera import * -from .camera.multi_camera import * -from .camera.three_d_camera import * from .constants import * from .manager import * from .mobject.frame import * diff --git a/manim/camera/__init__.py b/manim/camera/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/manim/camera/camera.py b/manim/camera/camera.py deleted file mode 100644 index c4b39901d2..0000000000 --- a/manim/camera/camera.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Semantic camera state shared with rendering backends.""" - -from __future__ import annotations - -__all__ = ["Camera"] - -import operator as op -from collections.abc import Iterable -from functools import reduce -from typing import TYPE_CHECKING, Literal, overload - -import numpy as np - -from manim._config import config -from manim.constants import DOWN, LEFT, RIGHT, UP -from manim.mobject.frame import ScreenRectangle -from manim.mobject.mobject import Mobject, _AnimationBuilder -from manim.mobject.types.vectorized_mobject import VMobject -from manim.utils.color import WHITE, ManimColor, ParsableManimColor -from manim.utils.family import extract_mobject_family_members -from manim.utils.iterables import list_difference_update - -if TYPE_CHECKING: - from manim.typing import ( - FloatRGBA_Array, - Point3D, - Point3D_Array, - Point3DLike, - ) - - -class Camera: - """Describe the logical view used by a rendering backend. - - Camera owns an animatable frame, semantic background settings, display ordering, - and pure point transformations. Raster targets, pixel dimensions, image buffers, - and backend contexts belong to renderers. - """ - - def __init__( - self, - background_image: str | None = None, - frame_center: Point3DLike | Mobject | None = None, - frame: Mobject | None = None, - default_frame_stroke_color: ManimColor = WHITE, - default_frame_stroke_width: float = 0, - use_z_index: bool = True, - frame_height: float | None = None, - frame_width: float | None = None, - background_color: ParsableManimColor | None = None, - background_opacity: float | None = None, - ) -> None: - self.background_image = background_image - self.use_z_index = use_z_index - - if frame is None: - resolved_height = ( - float(config["frame_height"]) if frame_height is None else frame_height - ) - resolved_width = ( - float(config["frame_width"]) if frame_width is None else frame_width - ) - if resolved_height <= 0 or resolved_width <= 0: - raise ValueError("Camera frame dimensions must be positive.") - frame = ScreenRectangle( - aspect_ratio=resolved_width / resolved_height, - height=resolved_height, - ) - frame.set_stroke( - ManimColor(default_frame_stroke_color), - default_frame_stroke_width, - ) - else: - if frame_height is not None: - if frame_height <= 0: - raise ValueError("Camera frame height must be positive.") - frame.stretch_to_fit_height(frame_height) - if frame_width is not None: - if frame_width <= 0: - raise ValueError("Camera frame width must be positive.") - frame.stretch_to_fit_width(frame_width) - self.frame = frame - if frame_center is not None: - self.frame_center = frame_center - - self._background_color = ManimColor( - config["background_color"] - if background_color is None - else background_color, - ) - self._background_opacity = ( - config["background_opacity"] - if background_opacity is None - else background_opacity - ) - - @property - def background_color(self) -> ManimColor: - return self._background_color - - @background_color.setter - def background_color(self, color: ParsableManimColor) -> None: - self._background_color = ManimColor(color) - - @property - def background_opacity(self) -> float: - return self._background_opacity - - @background_opacity.setter - def background_opacity(self, alpha: float) -> None: - self._background_opacity = alpha - - @property - def frame_height(self) -> float: - """Height of the logical camera frame in Manim units.""" - return self.frame.height - - @frame_height.setter - def frame_height(self, frame_height: float) -> None: - self.frame.stretch_to_fit_height(frame_height) - - @property - def frame_width(self) -> float: - """Width of the logical camera frame in Manim units.""" - return self.frame.width - - @frame_width.setter - def frame_width(self, frame_width: float) -> None: - self.frame.stretch_to_fit_width(frame_width) - - @property - def frame_center(self) -> Point3D: - """Center of the logical camera frame.""" - return self.frame.get_center() - - @frame_center.setter - def frame_center(self, frame_center: Point3DLike | Mobject) -> None: - self.frame.move_to(frame_center) - - def get_mobjects_to_display( - self, - mobjects: Iterable[Mobject], - include_submobjects: bool = True, - excluded_mobjects: list[Mobject] | None = None, - ) -> list[Mobject]: - """Return the camera-ordered family members visible to the renderer.""" - if include_submobjects: - mobjects = extract_mobject_family_members( - mobjects, - use_z_index=self.use_z_index, - only_those_with_points=True, - ) - if excluded_mobjects: - all_excluded = extract_mobject_family_members( - excluded_mobjects, - use_z_index=self.use_z_index, - ) - mobjects = list_difference_update(mobjects, all_excluded) - return list(mobjects) - - def is_in_frame(self, mobject: Mobject) -> bool: - """Whether ``mobject`` intersects the logical frame bounds.""" - center = self.frame_center - height = self.frame_height - width = self.frame_width - return not reduce( - op.or_, - [ - mobject.get_right()[0] < center[0] - width / 2, - mobject.get_bottom()[1] > center[1] + height / 2, - mobject.get_left()[0] > center[0] + width / 2, - mobject.get_top()[1] < center[1] - height / 2, - ], - ) - - def get_mobjects_indicating_movement(self) -> list[Mobject]: - """Camera controls whose animation changes every projected pixel.""" - return [self.frame] - - @overload - def auto_zoom( - self, - mobjects: Iterable[Mobject], - margin: float = 0, - only_mobjects_in_frame: bool = False, - animate: Literal[False] = False, - ) -> Mobject: ... - - @overload - def auto_zoom( - self, - mobjects: Iterable[Mobject], - margin: float = 0, - only_mobjects_in_frame: bool = False, - animate: Literal[True] = True, - ) -> _AnimationBuilder: ... - - def auto_zoom( - self, - mobjects: Iterable[Mobject], - margin: float = 0, - only_mobjects_in_frame: bool = False, - animate: bool = True, - ) -> _AnimationBuilder | Mobject: - """Move and resize the frame to contain the supplied 2D mobjects.""" - ( - left, - right, - top, - bottom, - ) = self._get_bounding_box(mobjects, only_mobjects_in_frame) - x = (left + right) / 2 - y = (top + bottom) / 2 - new_width = abs(left - right) - new_height = abs(top - bottom) - target = self.frame.animate if animate else self.frame - if new_width / self.frame.width > new_height / self.frame.height: - return target.set_x(x).set_y(y).set(width=new_width + margin) - return target.set_x(x).set_y(y).set(height=new_height + margin) - - def _get_bounding_box( - self, - mobjects: Iterable[Mobject], - only_mobjects_in_frame: bool, - ) -> tuple[float, float, float, float]: - bounds: tuple[float, float, float, float] | None = None - for mobject in mobjects: - if mobject is self.frame or ( - only_mobjects_in_frame and not self.is_in_frame(mobject) - ): - continue - mobject_bounds = ( - float(mobject.get_critical_point(LEFT)[0]), - float(mobject.get_critical_point(RIGHT)[0]), - float(mobject.get_critical_point(UP)[1]), - float(mobject.get_critical_point(DOWN)[1]), - ) - if bounds is None: - bounds = mobject_bounds - else: - bounds = ( - min(bounds[0], mobject_bounds[0]), - max(bounds[1], mobject_bounds[1]), - max(bounds[2], mobject_bounds[2]), - min(bounds[3], mobject_bounds[3]), - ) - if bounds is None: - raise ValueError( - "Could not determine the bounding box of the mobjects given to " - "Camera.auto_zoom().", - ) - return bounds - - def _prepare_for_render(self) -> None: - """Refresh derived semantic view state before a renderer borrows it.""" - - def get_view_transform_center(self) -> Point3D: - """Return the center applied by the renderer's 2D view transform.""" - return self.frame_center - - def get_stroke_rgbas( - self, - vmobject: VMobject, - background: bool = False, - ) -> FloatRGBA_Array: - """Return stroke colors after camera-specific semantic shading.""" - return vmobject.get_stroke_rgbas(background) - - def get_fill_rgbas(self, vmobject: VMobject) -> FloatRGBA_Array: - """Return fill colors after camera-specific semantic shading.""" - return vmobject.get_fill_rgbas() - - def transform_points_pre_display( - self, - mobject: Mobject, - points: Point3D_Array, - ) -> Point3D_Array: - """Apply camera-specific pure projection before display.""" - if not np.all(np.isfinite(points)): - return np.zeros((1, 3)) - return points diff --git a/manim/camera/moving_camera.py b/manim/camera/moving_camera.py deleted file mode 100644 index f6d7836a52..0000000000 --- a/manim/camera/moving_camera.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Compatibility name for the now-movable default Cairo camera.""" - -from __future__ import annotations - -__all__ = ["MovingCamera"] - -from .camera import Camera - - -class MovingCamera(Camera): - """Compatibility subclass of :class:`~manim.camera.camera.Camera`. - - The default Cairo camera now owns the same animatable frame and - :meth:`~manim.camera.camera.Camera.auto_zoom` behavior. - """ diff --git a/manim/camera/multi_camera.py b/manim/camera/multi_camera.py deleted file mode 100644 index a06d7fa28e..0000000000 --- a/manim/camera/multi_camera.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Semantic camera state for nested Cairo camera views.""" - -from __future__ import annotations - -__all__ = ["MultiCamera"] - -from collections.abc import Iterable -from typing import Any - -from manim.mobject.mobject import Mobject -from manim.mobject.types.image_mobject import ImageMobjectFromCamera - -from .camera import Camera - - -class MultiCamera(Camera): - """Describe a primary view with camera-backed image mobjects.""" - - def __init__( - self, - image_mobjects_from_cameras: Iterable[ImageMobjectFromCamera] | None = None, - **kwargs: Any, - ) -> None: - self.image_mobjects_from_cameras: list[ImageMobjectFromCamera] = [] - if image_mobjects_from_cameras is not None: - for image_mobject in image_mobjects_from_cameras: - self.add_image_mobject_from_camera(image_mobject) - super().__init__(**kwargs) - - def add_image_mobject_from_camera( - self, - image_mobject_from_camera: ImageMobjectFromCamera, - ) -> None: - """Register a camera-backed image for renderer-owned composition.""" - if not isinstance(image_mobject_from_camera.camera, Camera): - raise TypeError("Nested Cairo views require a Cairo Camera.") - self.image_mobjects_from_cameras.append(image_mobject_from_camera) - - def get_mobjects_indicating_movement(self) -> list[Mobject]: - """Return controls whose movement changes a primary or nested view.""" - - def collect(camera: Camera, visited: set[int]) -> list[Mobject]: - if id(camera) in visited: - return [] - visited.add(id(camera)) - if not isinstance(camera, MultiCamera): - return camera.get_mobjects_indicating_movement() - - indicators = Camera.get_mobjects_indicating_movement(camera) - for image_mobject in camera.image_mobjects_from_cameras: - indicators.extend(collect(image_mobject.camera, visited)) - return indicators - - return collect(self, set()) diff --git a/manim/manager.py b/manim/manager.py index db2aa1f0d1..1f0605ae8c 100644 --- a/manim/manager.py +++ b/manim/manager.py @@ -16,10 +16,11 @@ from ._config.output import OutputSpec from ._config.render_session import RenderSessionSpec from .animation.animation import Animation - from .camera.camera import Camera from .mobject.mobject import Mobject, _AnimationBuilder from .renderer.cairo import CairoRenderer - from .renderer.opengl import OpenGLCamera, OpenGLRenderer + from .renderer.cairo.camera import Camera + from .renderer.opengl.camera import OpenGLCamera + from .renderer.opengl.renderer import OpenGLRenderer from .scene.scene import Scene from .scene.scene_file_writer import SceneFileWriter diff --git a/manim/mobject/mobject.py b/manim/mobject/mobject.py index eb190ab842..623598ca82 100644 --- a/manim/mobject/mobject.py +++ b/manim/mobject/mobject.py @@ -66,7 +66,7 @@ ) from ..animation.animation import Animation - from ..camera.camera import Camera + from ..renderer.cairo.camera import Camera _TimeBasedUpdater: TypeAlias = Callable[["Mobject", float], object] @@ -998,8 +998,8 @@ def apply_over_attr_arrays(self, func: MultiMappingFunction) -> Self: # Displaying def get_image(self, camera: Camera | None = None) -> Image.Image: """Render this mobject with an explicit temporary Cairo renderer.""" - from manim.camera.camera import Camera from manim.renderer.cairo import CairoRenderer + from manim.renderer.cairo.camera import Camera renderer = CairoRenderer(camera=Camera() if camera is None else camera) try: diff --git a/manim/mobject/types/image_mobject.py b/manim/mobject/types/image_mobject.py index 406b37987d..a834755284 100644 --- a/manim/mobject/types/image_mobject.py +++ b/manim/mobject/types/image_mobject.py @@ -14,7 +14,6 @@ from manim.mobject.geometry.shape_matchers import SurroundingRectangle from ... import config -from ...camera.camera import Camera from ...constants import * from ...mobject.mobject import Mobject from ...utils.bezier import interpolate @@ -34,6 +33,7 @@ import numpy.typing as npt + from manim.renderer.cairo.camera import Camera from manim.typing import PixelArray, StrPath diff --git a/manim/renderer/cairo/__init__.py b/manim/renderer/cairo/__init__.py index 0e14f6ab91..8e1eab36fd 100644 --- a/manim/renderer/cairo/__init__.py +++ b/manim/renderer/cairo/__init__.py @@ -5,15 +5,37 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from .camera import Camera, MovingCamera, MultiCamera, ThreeDCamera from .renderer import CairoRenderer -__all__ = ["CairoRenderer"] +__all__ = [ + "Camera", + "CairoRenderer", + "MovingCamera", + "MultiCamera", + "ThreeDCamera", +] def __getattr__(name: str) -> Any: - if name != "CairoRenderer": + if name not in __all__: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - from .renderer import CairoRenderer - globals()[name] = CairoRenderer - return CairoRenderer + value: Any + if name == "CairoRenderer": + from .renderer import CairoRenderer + + value = CairoRenderer + else: + from .camera import Camera, MovingCamera, MultiCamera, ThreeDCamera + + camera_classes: dict[str, Any] = { + "Camera": Camera, + "MovingCamera": MovingCamera, + "MultiCamera": MultiCamera, + "ThreeDCamera": ThreeDCamera, + } + value = camera_classes[name] + + globals()[name] = value + return value diff --git a/manim/camera/three_d_camera.py b/manim/renderer/cairo/camera.py similarity index 55% rename from manim/camera/three_d_camera.py rename to manim/renderer/cairo/camera.py index 6175b4cde9..85d313f2c1 100644 --- a/manim/camera/three_d_camera.py +++ b/manim/renderer/cairo/camera.py @@ -1,38 +1,340 @@ -"""A camera that can be positioned and oriented in three-dimensional space.""" +"""Semantic camera implementations for the Cairo rendering backend.""" from __future__ import annotations -__all__ = ["ThreeDCamera"] +__all__ = ["Camera", "MovingCamera", "MultiCamera", "ThreeDCamera"] - -from collections.abc import Callable -from typing import Any +import operator as op +from collections.abc import Callable, Iterable +from functools import reduce +from typing import TYPE_CHECKING, Any, Literal, overload import numpy as np -from manim.mobject.mobject import Mobject +from manim._config import config +from manim.constants import DEGREES, DOWN, LEFT, ORIGIN, OUT, RIGHT, UP +from manim.mobject.frame import ScreenRectangle +from manim.mobject.mobject import Mobject, _AnimationBuilder from manim.mobject.three_d.three_d_utils import ( get_3d_vmob_end_corner, get_3d_vmob_end_corner_unit_normal, get_3d_vmob_start_corner, get_3d_vmob_start_corner_unit_normal, ) +from manim.mobject.types.image_mobject import ImageMobjectFromCamera +from manim.mobject.types.point_cloud_mobject import Point from manim.mobject.types.vectorized_mobject import VMobject from manim.mobject.value_tracker import ValueTracker -from manim.typing import ( - FloatRGBA_Array, - MatrixMN, - Point3D, - Point3D_Array, - Point3DLike, -) +from manim.utils.color import WHITE, ManimColor, ParsableManimColor, get_shaded_rgb +from manim.utils.family import extract_mobject_family_members +from manim.utils.iterables import list_difference_update +from manim.utils.space_ops import rotation_about_z, rotation_matrix + +if TYPE_CHECKING: + from manim.typing import ( + FloatRGBA_Array, + MatrixMN, + Point3D, + Point3D_Array, + Point3DLike, + ) + + +class Camera: + """Describe the logical view used by a rendering backend. + + Camera owns an animatable frame, semantic background settings, display ordering, + and pure point transformations. Raster targets, pixel dimensions, image buffers, + and backend contexts belong to renderers. + """ + + def __init__( + self, + background_image: str | None = None, + frame_center: Point3DLike | Mobject | None = None, + frame: Mobject | None = None, + default_frame_stroke_color: ManimColor = WHITE, + default_frame_stroke_width: float = 0, + use_z_index: bool = True, + frame_height: float | None = None, + frame_width: float | None = None, + background_color: ParsableManimColor | None = None, + background_opacity: float | None = None, + ) -> None: + self.background_image = background_image + self.use_z_index = use_z_index + + if frame is None: + resolved_height = ( + float(config["frame_height"]) if frame_height is None else frame_height + ) + resolved_width = ( + float(config["frame_width"]) if frame_width is None else frame_width + ) + if resolved_height <= 0 or resolved_width <= 0: + raise ValueError("Camera frame dimensions must be positive.") + frame = ScreenRectangle( + aspect_ratio=resolved_width / resolved_height, + height=resolved_height, + ) + frame.set_stroke( + ManimColor(default_frame_stroke_color), + default_frame_stroke_width, + ) + else: + if frame_height is not None: + if frame_height <= 0: + raise ValueError("Camera frame height must be positive.") + frame.stretch_to_fit_height(frame_height) + if frame_width is not None: + if frame_width <= 0: + raise ValueError("Camera frame width must be positive.") + frame.stretch_to_fit_width(frame_width) + self.frame = frame + if frame_center is not None: + self.frame_center = frame_center + + self._background_color = ManimColor( + config["background_color"] + if background_color is None + else background_color, + ) + self._background_opacity = ( + config["background_opacity"] + if background_opacity is None + else background_opacity + ) + + @property + def background_color(self) -> ManimColor: + return self._background_color + + @background_color.setter + def background_color(self, color: ParsableManimColor) -> None: + self._background_color = ManimColor(color) + + @property + def background_opacity(self) -> float: + return self._background_opacity + + @background_opacity.setter + def background_opacity(self, alpha: float) -> None: + self._background_opacity = alpha + + @property + def frame_height(self) -> float: + """Height of the logical camera frame in Manim units.""" + return self.frame.height + + @frame_height.setter + def frame_height(self, frame_height: float) -> None: + self.frame.stretch_to_fit_height(frame_height) + + @property + def frame_width(self) -> float: + """Width of the logical camera frame in Manim units.""" + return self.frame.width + + @frame_width.setter + def frame_width(self, frame_width: float) -> None: + self.frame.stretch_to_fit_width(frame_width) + + @property + def frame_center(self) -> Point3D: + """Center of the logical camera frame.""" + return self.frame.get_center() + + @frame_center.setter + def frame_center(self, frame_center: Point3DLike | Mobject) -> None: + self.frame.move_to(frame_center) + + def get_mobjects_to_display( + self, + mobjects: Iterable[Mobject], + include_submobjects: bool = True, + excluded_mobjects: list[Mobject] | None = None, + ) -> list[Mobject]: + """Return the camera-ordered family members visible to the renderer.""" + if include_submobjects: + mobjects = extract_mobject_family_members( + mobjects, + use_z_index=self.use_z_index, + only_those_with_points=True, + ) + if excluded_mobjects: + all_excluded = extract_mobject_family_members( + excluded_mobjects, + use_z_index=self.use_z_index, + ) + mobjects = list_difference_update(mobjects, all_excluded) + return list(mobjects) + + def is_in_frame(self, mobject: Mobject) -> bool: + """Whether ``mobject`` intersects the logical frame bounds.""" + center = self.frame_center + height = self.frame_height + width = self.frame_width + return not reduce( + op.or_, + [ + mobject.get_right()[0] < center[0] - width / 2, + mobject.get_bottom()[1] > center[1] + height / 2, + mobject.get_left()[0] > center[0] + width / 2, + mobject.get_top()[1] < center[1] - height / 2, + ], + ) + + def get_mobjects_indicating_movement(self) -> list[Mobject]: + """Camera controls whose animation changes every projected pixel.""" + return [self.frame] + + @overload + def auto_zoom( + self, + mobjects: Iterable[Mobject], + margin: float = 0, + only_mobjects_in_frame: bool = False, + animate: Literal[False] = False, + ) -> Mobject: ... + + @overload + def auto_zoom( + self, + mobjects: Iterable[Mobject], + margin: float = 0, + only_mobjects_in_frame: bool = False, + animate: Literal[True] = True, + ) -> _AnimationBuilder: ... + + def auto_zoom( + self, + mobjects: Iterable[Mobject], + margin: float = 0, + only_mobjects_in_frame: bool = False, + animate: bool = True, + ) -> _AnimationBuilder | Mobject: + """Move and resize the frame to contain the supplied 2D mobjects.""" + ( + left, + right, + top, + bottom, + ) = self._get_bounding_box(mobjects, only_mobjects_in_frame) + x = (left + right) / 2 + y = (top + bottom) / 2 + new_width = abs(left - right) + new_height = abs(top - bottom) + target = self.frame.animate if animate else self.frame + if new_width / self.frame.width > new_height / self.frame.height: + return target.set_x(x).set_y(y).set(width=new_width + margin) + return target.set_x(x).set_y(y).set(height=new_height + margin) + + def _get_bounding_box( + self, + mobjects: Iterable[Mobject], + only_mobjects_in_frame: bool, + ) -> tuple[float, float, float, float]: + bounds: tuple[float, float, float, float] | None = None + for mobject in mobjects: + if mobject is self.frame or ( + only_mobjects_in_frame and not self.is_in_frame(mobject) + ): + continue + mobject_bounds = ( + float(mobject.get_critical_point(LEFT)[0]), + float(mobject.get_critical_point(RIGHT)[0]), + float(mobject.get_critical_point(UP)[1]), + float(mobject.get_critical_point(DOWN)[1]), + ) + if bounds is None: + bounds = mobject_bounds + else: + bounds = ( + min(bounds[0], mobject_bounds[0]), + max(bounds[1], mobject_bounds[1]), + max(bounds[2], mobject_bounds[2]), + min(bounds[3], mobject_bounds[3]), + ) + if bounds is None: + raise ValueError( + "Could not determine the bounding box of the mobjects given to " + "Camera.auto_zoom().", + ) + return bounds + + def _prepare_for_render(self) -> None: + """Refresh derived semantic view state before a renderer borrows it.""" + + def get_view_transform_center(self) -> Point3D: + """Return the center applied by the renderer's 2D view transform.""" + return self.frame_center + + def get_stroke_rgbas( + self, + vmobject: VMobject, + background: bool = False, + ) -> FloatRGBA_Array: + """Return stroke colors after camera-specific semantic shading.""" + return vmobject.get_stroke_rgbas(background) + + def get_fill_rgbas(self, vmobject: VMobject) -> FloatRGBA_Array: + """Return fill colors after camera-specific semantic shading.""" + return vmobject.get_fill_rgbas() + + def transform_points_pre_display( + self, + mobject: Mobject, + points: Point3D_Array, + ) -> Point3D_Array: + """Apply camera-specific pure projection before display.""" + if not np.all(np.isfinite(points)): + return np.zeros((1, 3)) + return points + + +class MovingCamera(Camera): + """Named camera subclass with the standard movable-frame behavior.""" + + +class MultiCamera(Camera): + """Describe a primary view with camera-backed image mobjects.""" + + def __init__( + self, + image_mobjects_from_cameras: Iterable[ImageMobjectFromCamera] | None = None, + **kwargs: Any, + ) -> None: + self.image_mobjects_from_cameras: list[ImageMobjectFromCamera] = [] + if image_mobjects_from_cameras is not None: + for image_mobject in image_mobjects_from_cameras: + self.add_image_mobject_from_camera(image_mobject) + super().__init__(**kwargs) + + def add_image_mobject_from_camera( + self, + image_mobject_from_camera: ImageMobjectFromCamera, + ) -> None: + """Register a camera-backed image for renderer-owned composition.""" + if not isinstance(image_mobject_from_camera.camera, Camera): + raise TypeError("Nested Cairo views require a Cairo Camera.") + self.image_mobjects_from_cameras.append(image_mobject_from_camera) + + def get_mobjects_indicating_movement(self) -> list[Mobject]: + """Return controls whose movement changes a primary or nested view.""" + + def collect(camera: Camera, visited: set[int]) -> list[Mobject]: + if id(camera) in visited: + return [] + visited.add(id(camera)) + if not isinstance(camera, MultiCamera): + return camera.get_mobjects_indicating_movement() + + indicators = Camera.get_mobjects_indicating_movement(camera) + for image_mobject in camera.image_mobjects_from_cameras: + indicators.extend(collect(image_mobject.camera, visited)) + return indicators -from ..camera.camera import Camera -from ..constants import * -from ..mobject.types.point_cloud_mobject import Point -from ..utils.color import get_shaded_rgb -from ..utils.family import extract_mobject_family_members -from ..utils.space_ops import rotation_about_z, rotation_matrix + return collect(self, set()) class ThreeDCamera(Camera): diff --git a/manim/renderer/cairo/renderer.py b/manim/renderer/cairo/renderer.py index 5907f3ee16..4acce926e2 100644 --- a/manim/renderer/cairo/renderer.py +++ b/manim/renderer/cairo/renderer.py @@ -9,14 +9,13 @@ from ... import config, logger from ..._config.video_encoder import video_encoder_fingerprint -from ...camera.camera import Camera -from ...camera.multi_camera import MultiCamera from ...mobject.mobject import Mobject, _AnimationBuilder from ...mobject.types.image_mobject import ImageMobjectFromCamera from ...scene.scene_file_writer import SceneFileWriter from ...utils.exceptions import EndSceneEarlyException from ...utils.iterables import list_update from ..protocol import RendererCapabilities +from .camera import Camera, MultiCamera from .rendering import _CairoDrawingContext from .target import _CairoRasterSettings, _CairoRenderTarget @@ -26,7 +25,7 @@ from manim.scene.scene import Scene from manim.scene.scene_file_writer import _SceneFileWriterSettings - from ..typing import RGBAPixelArray + from ...typing import RGBAPixelArray __all__ = ["CairoRenderer"] diff --git a/manim/renderer/cairo/rendering.py b/manim/renderer/cairo/rendering.py index 162ecaa29c..a1588d07b3 100644 --- a/manim/renderer/cairo/rendering.py +++ b/manim/renderer/cairo/rendering.py @@ -25,7 +25,6 @@ from .target import _CairoRenderTarget if TYPE_CHECKING: - from manim.camera.camera import Camera from manim.typing import ( FloatRGBA_Array, FloatRGBALike_Array, @@ -33,6 +32,8 @@ RGBAPixelArray, ) + from .camera import Camera + _LINE_JOIN_MAP = { LineJointType.AUTO: None, LineJointType.ROUND: cairo.LineJoin.ROUND, diff --git a/manim/renderer/cairo/target.py b/manim/renderer/cairo/target.py index 87a7a5b797..913b5c55e6 100644 --- a/manim/renderer/cairo/target.py +++ b/manim/renderer/cairo/target.py @@ -14,9 +14,10 @@ from manim.utils.images import get_full_raster_image_path if TYPE_CHECKING: - from manim.camera.camera import Camera from manim.typing import RGBAPixelArray + from .camera import Camera + @dataclass(frozen=True, slots=True) class _CairoRasterSettings: diff --git a/manim/renderer/cairo_renderer.py b/manim/renderer/cairo_renderer.py deleted file mode 100644 index bf044d1745..0000000000 --- a/manim/renderer/cairo_renderer.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility imports for the Cairo rendering backend.""" - -from .cairo import CairoRenderer - -__all__ = ["CairoRenderer"] diff --git a/manim/renderer/opengl/__init__.py b/manim/renderer/opengl/__init__.py index 37e881b8c1..120c1f0dda 100644 --- a/manim/renderer/opengl/__init__.py +++ b/manim/renderer/opengl/__init__.py @@ -5,16 +5,24 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from .renderer import OpenGLCamera, OpenGLRenderer + from .camera import OpenGLCamera + from .renderer import OpenGLRenderer __all__ = ["OpenGLCamera", "OpenGLRenderer"] def __getattr__(name: str) -> Any: - if name not in __all__: + value: Any + if name == "OpenGLCamera": + from .camera import OpenGLCamera + + value = OpenGLCamera + elif name == "OpenGLRenderer": + from .renderer import OpenGLRenderer + + value = OpenGLRenderer + else: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - from .renderer import OpenGLCamera, OpenGLRenderer - value = {"OpenGLCamera": OpenGLCamera, "OpenGLRenderer": OpenGLRenderer}[name] globals()[name] = value return value diff --git a/manim/renderer/opengl/camera.py b/manim/renderer/opengl/camera.py new file mode 100644 index 0000000000..0836ba98a4 --- /dev/null +++ b/manim/renderer/opengl/camera.py @@ -0,0 +1,413 @@ +"""Semantic camera state for the OpenGL rendering backend.""" + +from __future__ import annotations + +import typing +from functools import cached_property +from typing import TYPE_CHECKING, Any, Self + +import numpy as np +from typing_extensions import override + +from manim import config +from manim.constants import * +from manim.mobject.opengl.opengl_mobject import OpenGLMobject, OpenGLPoint +from manim.typing import MatrixMN, Point3D +from manim.utils import opengl +from manim.utils.paths import straight_path +from manim.utils.simple_functions import clip +from manim.utils.space_ops import ( + angle_of_vector, + quaternion_from_angle_axis, + quaternion_mult, + rotation_matrix_transpose, + rotation_matrix_transpose_from_quaternion, +) + +if TYPE_CHECKING: + from manim.typing import PathFuncType, Point3DLike, Vector3DLike + from manim.utils.opengl import FlattenedMatrix4x4 + +__all__ = ["OpenGLCamera"] + + +class OpenGLCamera(OpenGLMobject): + """ + An OpenGL-based camera for 3D scene rendering. + + + Attributes + ---------- + frame_shape : tuple[float, float] + The width and height of the camera frame. + center_point : np.ndarray + The center point of the camera in 3D space. + euler_angles : np.ndarray + The Euler angles (theta, phi, gamma) representing the camera's orientation. + focal_distance : float + The focal distance of the camera. + light_source_position : np.ndarray + The position of the light source in 3D space. + orthographic : bool + Whether the camera uses orthographic projection instead of perspective. + minimum_polar_angle : float + The minimum polar angle for camera rotation. + maximum_polar_angle : float + The maximum polar angle for camera rotation. + inverse_rotation_matrix : np.ndarray + The inverse rotation matrix of the camera. + """ + + def __init__( + self, + frame_shape: tuple[float, float] | None = None, + center_point: Point3DLike | None = None, + # Theta, phi, gamma + euler_angles: Point3DLike | None = None, + focal_distance: float = 2.0, + light_source_position: Point3DLike | None = None, + orthographic: bool = False, + minimum_polar_angle: float = -PI / 2, + maximum_polar_angle: float = PI / 2, + model_matrix: MatrixMN | None = None, + **kwargs: Any, + ) -> None: + """ + Initializes an OpenGLCamera instance. + + Parameters + ---------- + frame_shape : tuple[float, float], optional + The width and height of the camera frame. If not provided, defaults to + the global manim config values `frame_width` and `frame_height`. + center_point : Point3DLike, optional + The center point of the camera in 3D space. + If not provided, defaults to the origin (0, 0, 0). + euler_angles : Point3DLike, optional + The Euler angles (theta, phi, gamma) representing the camera's orientation. + If not provided, defaults to (0, 0, 0) (i.e., no rotation). + focal_distance : float, optional + The focal distance of the camera. Default is 2.0. + light_source_position : Point3DLike, optional + The position of the light source in 3D space. + If not provided, defaults to (-10, 10, 10). + orthographic : bool, optional + Whether the camera uses orthographic projection instead of perspective. + Default is False (perspective). + minimum_polar_angle : float, optional + The minimum polar angle in radian for camera rotation. Default is -π/2, + i.e. no restriction. + maximum_polar_angle : float, optional + The maximum polar angle in radian for camera rotation. Default is π/2, + i.e. no restriction. + model_matrix : MatrixMN, optional + The initial model matrix for the camera. If not provided, defaults to a + translation matrix that positions the camera at (0, 0, 11). + **kwargs : Any + Additional keyword arguments passed to the OpenGLMobject constructor. + """ + self.use_z_index = True + self.orthographic = orthographic + self.minimum_polar_angle = minimum_polar_angle + self.maximum_polar_angle = maximum_polar_angle + if self.orthographic: + self.projection_matrix = opengl.orthographic_projection_matrix() + self.unformatted_projection_matrix = opengl.orthographic_projection_matrix( + format_=False, + ) + else: + self.projection_matrix = opengl.perspective_projection_matrix() + self.unformatted_projection_matrix = opengl.perspective_projection_matrix( + format_=False, + ) + + if frame_shape is None: + self.frame_shape = (config["frame_width"], config["frame_height"]) + else: + self.frame_shape = frame_shape + + if center_point is None: + self.center_point = ORIGIN + else: + self.center_point = np.asarray(center_point, dtype=float) + + if model_matrix is None: + model_matrix = opengl.translation_matrix(0, 0, 11) + + self.focal_distance = focal_distance + + self.light_source_position = np.asarray( + light_source_position or [-10, 10, 10], dtype=float + ) + + self.light_source = OpenGLPoint(self.light_source_position) + + self.default_model_matrix = model_matrix + super().__init__(model_matrix=model_matrix, should_render=False, **kwargs) + + euler_angles = np.asarray(euler_angles or [0, 0, 0], dtype=float) + + self.euler_angles: Point3D = euler_angles + self.refresh_rotation_matrix() + + def get_position(self) -> Point3D: + """Retrieve the camera's position in 3D space.""" + return self.model_matrix[:, 3][:3] + + def set_position(self, position: Point3D) -> Self: + """Set the camera's position in 3D space.""" + self.model_matrix[:, 3][:3] = position + return self + + @cached_property + def formatted_view_matrix(self) -> FlattenedMatrix4x4: + """The formatted view matrix for shader input.""" + return opengl.matrix_to_shader_input(self.unformatted_view_matrix) + + @cached_property + def unformatted_view_matrix(self) -> MatrixMN: + return typing.cast(MatrixMN, np.linalg.inv(self.model_matrix)) + + def init_points(self) -> Self: + """Initialize the camera's points based on frame shape and center point.""" + 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) + return self + + def to_default_state(self) -> Self: + """Reset the camera to its default state + (config frame size, centered at origin, no rotation). + """ + self.center() + self.set_height(config["frame_height"]) + self.set_width(config["frame_width"]) + self.set_euler_angles(0, 0, 0) + self.model_matrix = self.default_model_matrix + return self + + def refresh_rotation_matrix(self) -> Self: + """Refresh the camera's inverse rotation matrix based on its Euler angles.""" + # Rotate based on camera orientation + theta, phi, gamma = self.euler_angles + quat = quaternion_mult( + quaternion_from_angle_axis(theta, OUT, axis_normalized=True), + quaternion_from_angle_axis(phi, RIGHT, axis_normalized=True), + quaternion_from_angle_axis(gamma, OUT, axis_normalized=True), + ) + self.inverse_rotation_matrix = rotation_matrix_transpose_from_quaternion( + np.asarray(quat, dtype=float) + ) + return self + + @override + def rotate( + self, + angle: float, + axis: Vector3DLike = OUT, + about_point: Point3DLike | None = None, + **kwargs: Any, + ) -> Self: + """ + Rotate the camera by a given angle around a specified axis. + + Parameters + ---------- + angle : float + The angle in radians to rotate the camera. + axis : Vector3DLike, optional + The axis around which to rotate the camera. Default is OUT (z-axis). + about_point : Point3DLike, optional + Ignored. For OpenGLCamera, rotation is always about the camera's center. + + **kwargs : Any + Not used for OpenGLCamera. Passing additional keyword arguments + has no effect. + + Returns + ------- + Self + The rotated camera instance. Returned for chaining. + """ + curr_rot_T = self.inverse_rotation_matrix + added_rot_T = rotation_matrix_transpose(angle, axis) + new_rot_T = np.dot(curr_rot_T, added_rot_T) + Fz = new_rot_T[2] + phi = np.arccos(Fz[2]) + theta = angle_of_vector(Fz[:2]) + PI / 2 + partial_rot_T = np.dot( + rotation_matrix_transpose(phi, RIGHT), + rotation_matrix_transpose(theta, OUT), + ) + gamma = angle_of_vector(np.dot(partial_rot_T, new_rot_T.T)[:, 0]) + self.set_euler_angles(theta, phi, gamma) + return self + + def set_euler_angles( + self, + theta: float | None = None, + phi: float | None = None, + gamma: float | None = None, + ) -> Self: + """ + Set the camera's Euler angles [2]_ (theta, phi, gamma). + + Parameters + ---------- + theta : float | None, optional + The angle in radians for rotation around the OUT (z) axis. + If None, the current theta value is retained. + phi : float | None, optional + The angle in radians for rotation around the RIGHT (x) axis. + If None, the current phi value is retained. + gamma : float | None, optional + The angle in radians for rotation around the OUT (z) axis. + If None, the current gamma value is retained. + + Returns + ------- + Self + The camera instance with updated Euler angles. Returned for chaining. + + See Also + -------- + set_theta : Set the theta Euler angle. + set_phi : Set the phi Euler angle. + set_gamma : Set the gamma Euler angle. + + References + ---------- + .. [2] Wikipedia, "Euler angles", + https://en.wikipedia.org/wiki/Euler_angles + """ + if theta is not None: + self.euler_angles[0] = theta + if phi is not None: + self.euler_angles[1] = phi + if gamma is not None: + self.euler_angles[2] = gamma + self.refresh_rotation_matrix() + return self + + def set_theta(self, theta: float) -> Self: + """ + Set the camera's theta Euler angle (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_phi : Set the phi Euler angle. + set_gamma : Set the gamma Euler angle. + """ + return self.set_euler_angles(theta=theta) + + def set_phi(self, phi: float) -> Self: + """ + Set the camera's phi Euler angle (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_theta : Set the theta Euler angle. + set_gamma : Set the gamma Euler angle. + """ + return self.set_euler_angles(phi=phi) + + def set_gamma(self, gamma: float) -> Self: + """ + Set the camera's gamma Euler angle (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_theta : Set the theta Euler angle. + set_phi : Set the phi Euler angle. + """ + return self.set_euler_angles(gamma=gamma) + + def increment_theta(self, dtheta: float) -> Self: + """ + Increment the camera's theta Euler angle by a given amount (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_theta : Set the theta Euler angle. + """ + self.euler_angles[0] += dtheta + self.refresh_rotation_matrix() + return self + + def increment_phi(self, dphi: float) -> Self: + """ + Increment the camera's phi Euler angle by a given amount (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_phi : Set the phi Euler angle. + """ + phi = self.euler_angles[1] + new_phi = clip(phi + dphi, -PI / 2, PI / 2) + self.euler_angles[1] = new_phi + self.refresh_rotation_matrix() + return self + + def increment_gamma(self, dgamma: float) -> Self: + """ + Increment the camera's gamma Euler angle by a given amount (in radians). + + See Also + -------- + set_euler_angles : Set all Euler angles at once. + set_gamma : Set the gamma Euler angle. + """ + self.euler_angles[2] += dgamma + self.refresh_rotation_matrix() + return self + + def get_shape(self) -> tuple[float, float]: + """Retrieve the width and height of the camera frame.""" + return (self.get_width(), self.get_height()) + + def get_center(self) -> Point3D: + """ + Retrieve the center point of the camera in 3D space. + + Notes + ----- + The center point is assumed to be the first point in the camera's points array. + """ + # Assumes first point is at the center + return typing.cast(Point3D, self.points[0]) + + def get_width(self) -> float: + """Retrieve the width of the camera frame.""" + points = self.points + out = points[2, 0] - points[1, 0] + return float(out) + + def get_height(self) -> float: + """Retrieve the height of the camera frame.""" + points = self.points + out = points[4, 1] - points[3, 1] + return float(out) + # return points[4, 1] - points[3, 1] + + def get_focal_distance(self) -> float: + """Retrieve the focal distance of the camera.""" + return self.focal_distance * self.get_height() + + @override + def interpolate( + self, + mobject1: OpenGLMobject, + mobject2: OpenGLMobject, + alpha: float, + path_func: PathFuncType = straight_path(), + ) -> Self: + """Interpolate camera mobject state and refresh its rotation matrix.""" + super().interpolate(mobject1, mobject2, alpha, path_func) + self.refresh_rotation_matrix() + return self diff --git a/manim/renderer/opengl/renderer.py b/manim/renderer/opengl/renderer.py index e5dc745ecf..9de68b86c9 100644 --- a/manim/renderer/opengl/renderer.py +++ b/manim/renderer/opengl/renderer.py @@ -4,38 +4,25 @@ import itertools as it import time import typing -from functools import cached_property -from typing import TYPE_CHECKING, Any, Self +from typing import TYPE_CHECKING, Any import moderngl import numpy as np from moderngl import Framebuffer from PIL import Image -from typing_extensions import override from manim import config from manim.mobject.opengl.opengl_mobject import ( OpenGLMobject, - OpenGLPoint, ) from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject -from manim.typing import MatrixMN, Point3D +from manim.typing import Point3D from manim.utils.caching import handle_caching_play from manim.utils.color import color_to_rgba from manim.utils.exceptions import EndSceneEarlyException -from manim.utils.paths import straight_path from ...constants import * from ...scene.scene_file_writer import SceneFileWriter -from ...utils import opengl -from ...utils.simple_functions import clip -from ...utils.space_ops import ( - angle_of_vector, - quaternion_from_angle_axis, - quaternion_mult, - rotation_matrix_transpose, - rotation_matrix_transpose_from_quaternion, -) from ..protocol import RendererCapabilities from .shader import Mesh, Shader from .vectorized_mobject_rendering import ( @@ -45,7 +32,6 @@ if TYPE_CHECKING: from collections.abc import Iterable - from typing import Self from manim._config.render_session import RenderSessionSpec from manim.animation.animation import Animation @@ -54,405 +40,15 @@ from manim.scene.scene_file_writer import _SceneFileWriterSettings from manim.typing import ( FloatRGBA, - PathFuncType, - Point3DLike, RGBAPixelArray, - Vector3DLike, ) from manim.utils.color.core import ParsableManimColor - from manim.utils.opengl import FlattenedMatrix4x4 from .window import Window +from .camera import OpenGLCamera -__all__ = ["OpenGLCamera", "OpenGLRenderer"] - - -class OpenGLCamera(OpenGLMobject): - """ - An OpenGL-based camera for 3D scene rendering. - - - Attributes - ---------- - frame_shape : tuple[float, float] - The width and height of the camera frame. - center_point : np.ndarray - The center point of the camera in 3D space. - euler_angles : np.ndarray - The Euler angles (theta, phi, gamma) representing the camera's orientation. - focal_distance : float - The focal distance of the camera. - light_source_position : np.ndarray - The position of the light source in 3D space. - orthographic : bool - Whether the camera uses orthographic projection instead of perspective. - minimum_polar_angle : float - The minimum polar angle for camera rotation. - maximum_polar_angle : float - The maximum polar angle for camera rotation. - inverse_rotation_matrix : np.ndarray - The inverse rotation matrix of the camera. - """ - - def __init__( - self, - frame_shape: tuple[float, float] | None = None, - center_point: Point3DLike | None = None, - # Theta, phi, gamma - euler_angles: Point3DLike | None = None, - focal_distance: float = 2.0, - light_source_position: Point3DLike | None = None, - orthographic: bool = False, - minimum_polar_angle: float = -PI / 2, - maximum_polar_angle: float = PI / 2, - model_matrix: MatrixMN | None = None, - **kwargs: Any, - ) -> None: - """ - Initializes an OpenGLCamera instance. - - Parameters - ---------- - frame_shape : tuple[float, float], optional - The width and height of the camera frame. If not provided, defaults to - the global manim config values `frame_width` and `frame_height`. - center_point : Point3DLike, optional - The center point of the camera in 3D space. - If not provided, defaults to the origin (0, 0, 0). - euler_angles : Point3DLike, optional - The Euler angles (theta, phi, gamma) representing the camera's orientation. - If not provided, defaults to (0, 0, 0) (i.e., no rotation). - focal_distance : float, optional - The focal distance of the camera. Default is 2.0. - light_source_position : Point3DLike, optional - The position of the light source in 3D space. - If not provided, defaults to (-10, 10, 10). - orthographic : bool, optional - Whether the camera uses orthographic projection instead of perspective. - Default is False (perspective). - minimum_polar_angle : float, optional - The minimum polar angle in radian for camera rotation. Default is -π/2, - i.e. no restriction. - maximum_polar_angle : float, optional - The maximum polar angle in radian for camera rotation. Default is π/2, - i.e. no restriction. - model_matrix : MatrixMN, optional - The initial model matrix [1]_ for the camera. If not provided, - defaults to a translation matrix that positions the camera at (0, 0, 11). - **kwargs : Any - Additional keyword arguments passed to the OpenGLMobject constructor. - - References - ---------- - .. [1] Wikipedia, "Camera matrix", - https://en.wikipedia.org/wiki/Camera_matrix - """ - self.use_z_index = True - self.frame_rate = 60 - self.orthographic = orthographic - self.minimum_polar_angle = minimum_polar_angle - self.maximum_polar_angle = maximum_polar_angle - if self.orthographic: - self.projection_matrix = opengl.orthographic_projection_matrix() - self.unformatted_projection_matrix = opengl.orthographic_projection_matrix( - format_=False, - ) - else: - self.projection_matrix = opengl.perspective_projection_matrix() - self.unformatted_projection_matrix = opengl.perspective_projection_matrix( - format_=False, - ) - - if frame_shape is None: - self.frame_shape = (config["frame_width"], config["frame_height"]) - else: - self.frame_shape = frame_shape - - if center_point is None: - self.center_point = ORIGIN - else: - self.center_point = np.asarray(center_point, dtype=float) - - if model_matrix is None: - model_matrix = opengl.translation_matrix(0, 0, 11) - - self.focal_distance = focal_distance - - self.light_source_position = np.asarray( - light_source_position or [-10, 10, 10], dtype=float - ) - - self.light_source = OpenGLPoint(self.light_source_position) - - self.default_model_matrix = model_matrix - super().__init__(model_matrix=model_matrix, should_render=False, **kwargs) - - euler_angles = np.asarray(euler_angles or [0, 0, 0], dtype=float) - - self.euler_angles: Point3D = euler_angles - self.refresh_rotation_matrix() - - def get_position(self) -> Point3D: - """Retrieve the camera's position in 3D space.""" - return self.model_matrix[:, 3][:3] - - def set_position(self, position: Point3D) -> Self: - """Set the camera's position in 3D space.""" - self.model_matrix[:, 3][:3] = position - return self - - @cached_property - def formatted_view_matrix(self) -> FlattenedMatrix4x4: - """The formatted view matrix for shader input.""" - return opengl.matrix_to_shader_input(self.unformatted_view_matrix) - - @cached_property - def unformatted_view_matrix(self) -> MatrixMN: - return typing.cast(MatrixMN, np.linalg.inv(self.model_matrix)) - - def init_points(self) -> Self: - """Initialize the camera's points based on frame shape and center point.""" - 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) - return self - - def to_default_state(self) -> Self: - """Reset the camera to its default state - (config frame size, centered at origin, no rotation). - """ - self.center() - self.set_height(config["frame_height"]) - self.set_width(config["frame_width"]) - self.set_euler_angles(0, 0, 0) - self.model_matrix = self.default_model_matrix - return self - - def refresh_rotation_matrix(self) -> Self: - """Refresh the camera's inverse rotation matrix based on its Euler angles.""" - # Rotate based on camera orientation - theta, phi, gamma = self.euler_angles - quat = quaternion_mult( - quaternion_from_angle_axis(theta, OUT, axis_normalized=True), - quaternion_from_angle_axis(phi, RIGHT, axis_normalized=True), - quaternion_from_angle_axis(gamma, OUT, axis_normalized=True), - ) - self.inverse_rotation_matrix = rotation_matrix_transpose_from_quaternion( - np.asarray(quat, dtype=float) - ) - return self - - @override - def rotate( - self, - angle: float, - axis: Vector3DLike = OUT, - about_point: Point3DLike | None = None, - **kwargs: Any, - ) -> Self: - """ - Rotate the camera by a given angle around a specified axis. - - Parameters - ---------- - angle : float - The angle in radians to rotate the camera. - axis : Vector3DLike, optional - The axis around which to rotate the camera. Default is OUT (z-axis). - about_point : Point3DLike, optional - Ignored. For OpenGLCamera, rotation is always about the camera's center. - - **kwargs : Any - Not used for OpenGLCamera. Passing additional keyword arguments - has no effect. - - Returns - ------- - Self - The rotated camera instance. Returned for chaining. - """ - curr_rot_T = self.inverse_rotation_matrix - added_rot_T = rotation_matrix_transpose(angle, axis) - new_rot_T = np.dot(curr_rot_T, added_rot_T) - Fz = new_rot_T[2] - phi = np.arccos(Fz[2]) - theta = angle_of_vector(Fz[:2]) + PI / 2 - partial_rot_T = np.dot( - rotation_matrix_transpose(phi, RIGHT), - rotation_matrix_transpose(theta, OUT), - ) - gamma = angle_of_vector(np.dot(partial_rot_T, new_rot_T.T)[:, 0]) - self.set_euler_angles(theta, phi, gamma) - return self - - def set_euler_angles( - self, - theta: float | None = None, - phi: float | None = None, - gamma: float | None = None, - ) -> Self: - """ - Set the camera's Euler angles [1]_ (theta, phi, gamma). - - Parameters - ---------- - theta : float | None, optional - The angle in radians for rotation around the OUT (z) axis. - If None, the current theta value is retained. - phi : float | None, optional - The angle in radians for rotation around the RIGHT (x) axis. - If None, the current phi value is retained. - gamma : float | None, optional - The angle in radians for rotation around the OUT (z) axis. - If None, the current gamma value is retained. - - Returns - ------- - Self - The camera instance with updated Euler angles. Returned for chaining. - - See Also - -------- - set_theta : Set the theta Euler angle. - set_phi : Set the phi Euler angle. - set_gamma : Set the gamma Euler angle. - - References - ---------- - .. [1] Wikipedia, "Euler angles", - https://en.wikipedia.org/wiki/Euler_angles - """ - if theta is not None: - self.euler_angles[0] = theta - if phi is not None: - self.euler_angles[1] = phi - if gamma is not None: - self.euler_angles[2] = gamma - self.refresh_rotation_matrix() - return self - - def set_theta(self, theta: float) -> Self: - """ - Set the camera's theta Euler angle (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_phi : Set the phi Euler angle. - set_gamma : Set the gamma Euler angle. - """ - return self.set_euler_angles(theta=theta) - - def set_phi(self, phi: float) -> Self: - """ - Set the camera's phi Euler angle (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_theta : Set the theta Euler angle. - set_gamma : Set the gamma Euler angle. - """ - return self.set_euler_angles(phi=phi) - - def set_gamma(self, gamma: float) -> Self: - """ - Set the camera's gamma Euler angle (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_theta : Set the theta Euler angle. - set_phi : Set the phi Euler angle. - """ - return self.set_euler_angles(gamma=gamma) - - def increment_theta(self, dtheta: float) -> Self: - """ - Increment the camera's theta Euler angle by a given amount (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_theta : Set the theta Euler angle. - """ - self.euler_angles[0] += dtheta - self.refresh_rotation_matrix() - return self - - def increment_phi(self, dphi: float) -> Self: - """ - Increment the camera's phi Euler angle by a given amount (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_phi : Set the phi Euler angle. - """ - phi = self.euler_angles[1] - new_phi = clip(phi + dphi, -PI / 2, PI / 2) - self.euler_angles[1] = new_phi - self.refresh_rotation_matrix() - return self - - def increment_gamma(self, dgamma: float) -> Self: - """ - Increment the camera's gamma Euler angle by a given amount (in radians). - - See Also - -------- - set_euler_angles : Set all Euler angles at once. - set_gamma : Set the gamma Euler angle. - """ - self.euler_angles[2] += dgamma - self.refresh_rotation_matrix() - return self - - def get_shape(self) -> tuple[float, float]: - """Retrieve the width and height of the camera frame.""" - return (self.get_width(), self.get_height()) - - def get_center(self) -> Point3D: - """ - Retrieve the center point of the camera in 3D space. - - Notes - ----- - The center point is assumed to be the first point in the camera's points array. - """ - # Assumes first point is at the center - return typing.cast(Point3D, self.points[0]) - - def get_width(self) -> float: - """Retrieve the width of the camera frame.""" - points = self.points - out = points[2, 0] - points[1, 0] - return float(out) - - def get_height(self) -> float: - """Retrieve the height of the camera frame.""" - points = self.points - out = points[4, 1] - points[3, 1] - return float(out) - # return points[4, 1] - points[3, 1] - - def get_focal_distance(self) -> float: - """Retrieve the focal distance of the camera.""" - return self.focal_distance * self.get_height() - - @override - def interpolate( - self, - mobject1: OpenGLMobject, - mobject2: OpenGLMobject, - alpha: float, - path_func: PathFuncType = straight_path(), - ) -> Self: - super().interpolate(mobject1, mobject2, alpha, path_func) - self.refresh_rotation_matrix() - return self +__all__ = ["OpenGLRenderer"] class OpenGLRenderer: diff --git a/manim/renderer/opengl_renderer.py b/manim/renderer/opengl_renderer.py deleted file mode 100644 index cfc8cc85fc..0000000000 --- a/manim/renderer/opengl_renderer.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility imports for the OpenGL rendering backend.""" - -from .opengl import OpenGLCamera, OpenGLRenderer - -__all__ = ["OpenGLCamera", "OpenGLRenderer"] diff --git a/manim/scene/moving_camera_scene.py b/manim/scene/moving_camera_scene.py index b5582ea058..e5e2d0a858 100644 --- a/manim/scene/moving_camera_scene.py +++ b/manim/scene/moving_camera_scene.py @@ -91,8 +91,7 @@ def create_frame(number): from typing import Any -from ..camera.camera import Camera -from ..camera.moving_camera import MovingCamera +from ..renderer.cairo.camera import Camera, MovingCamera from ..scene.scene import Scene diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 01524abc5e..5ef00fe8d3 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -53,11 +53,12 @@ ) from .._config.render_session import resolve_render_session from ..animation.animation import Animation, Wait, prepare_animation -from ..camera.camera import Camera from ..constants import * from ..manager import Manager from ..renderer.cairo import CairoRenderer -from ..renderer.opengl import OpenGLCamera, OpenGLRenderer +from ..renderer.cairo.camera import Camera +from ..renderer.opengl.camera import OpenGLCamera +from ..renderer.opengl.renderer import OpenGLRenderer from ..renderer.opengl.shader import Object3D from ..scene.scene_file_writer import _SceneFileWriterSettings from ..utils import opengl, space_ops diff --git a/manim/scene/three_d_scene.py b/manim/scene/three_d_scene.py index 78cf9a473f..9d3edab568 100644 --- a/manim/scene/three_d_scene.py +++ b/manim/scene/three_d_scene.py @@ -19,11 +19,11 @@ from .. import config from ..animation.animation import Animation from ..animation.transform import Transform -from ..camera.three_d_camera import ThreeDCamera from ..constants import DEGREES from ..mobject.mobject import Mobject from ..mobject.types.vectorized_mobject import VectorizedPoint, VGroup -from ..renderer.opengl import OpenGLCamera +from ..renderer.cairo.camera import ThreeDCamera +from ..renderer.opengl.camera import OpenGLCamera from ..scene.scene import Scene from ..utils.config_ops import merge_dicts_recursively diff --git a/manim/scene/vector_space_scene.py b/manim/scene/vector_space_scene.py index 4efdfed6dd..1bbc89797c 100644 --- a/manim/scene/vector_space_scene.py +++ b/manim/scene/vector_space_scene.py @@ -10,13 +10,13 @@ import numpy as np from manim.animation.creation import DrawBorderThenFill, Group -from manim.camera.camera import Camera from manim.mobject.geometry.arc import Dot from manim.mobject.geometry.line import Arrow, Line, Vector from manim.mobject.geometry.polygram import Rectangle from manim.mobject.graphing.coordinate_systems import Axes, NumberPlane from manim.mobject.opengl.opengl_mobject import OpenGLMobject from manim.mobject.text.tex_mobject import MathTex, Tex +from manim.renderer.cairo.camera import Camera from manim.utils.config_ops import update_dict_recursively from .. import config diff --git a/manim/scene/zoomed_scene.py b/manim/scene/zoomed_scene.py index 72f0120b0f..8e0d7908bd 100644 --- a/manim/scene/zoomed_scene.py +++ b/manim/scene/zoomed_scene.py @@ -52,12 +52,10 @@ def construct(self): from typing import TYPE_CHECKING, Any from ..animation.transform import ApplyMethod -from ..camera.camera import Camera -from ..camera.moving_camera import MovingCamera -from ..camera.multi_camera import MultiCamera from ..constants import * from ..mobject.types.image_mobject import ImageMobjectFromCamera -from ..renderer.opengl import OpenGLCamera +from ..renderer.cairo.camera import Camera, MovingCamera, MultiCamera +from ..renderer.opengl.camera import OpenGLCamera from ..scene.moving_camera_scene import MovingCameraScene if TYPE_CHECKING: diff --git a/manim/utils/hashing.py b/manim/utils/hashing.py index 8a5353f774..a58743b349 100644 --- a/manim/utils/hashing.py +++ b/manim/utils/hashing.py @@ -18,9 +18,9 @@ if TYPE_CHECKING: from manim.animation.animation import Animation - from manim.camera.camera import Camera from manim.mobject.mobject import Mobject - from manim.renderer.opengl import OpenGLCamera + from manim.renderer.cairo.camera import Camera + from manim.renderer.opengl.camera import OpenGLCamera from manim.scene.scene import Scene __all__ = ["KEYS_TO_FILTER_OUT", "get_hash_from_play_call", "get_json"] diff --git a/manim/utils/testing/frames_comparison.py b/manim/utils/testing/frames_comparison.py index 1eebf2c1e6..ead2b866e4 100644 --- a/manim/utils/testing/frames_comparison.py +++ b/manim/utils/testing/frames_comparison.py @@ -13,8 +13,8 @@ from manim import Scene from manim._config import tempconfig from manim._config.utils import ManimConfig -from manim.camera.three_d_camera import ThreeDCamera from manim.renderer.cairo import CairoRenderer +from manim.renderer.cairo.camera import ThreeDCamera from manim.renderer.opengl import OpenGLRenderer from manim.scene.three_d_scene import ThreeDScene from manim.typing import StrPath diff --git a/tests/test_scene_rendering/opengl/test_opengl_renderer.py b/tests/test_scene_rendering/opengl/test_opengl_renderer.py index 416720f005..537b9703e9 100644 --- a/tests/test_scene_rendering/opengl/test_opengl_renderer.py +++ b/tests/test_scene_rendering/opengl/test_opengl_renderer.py @@ -11,12 +11,6 @@ from tests.test_scene_rendering.simple_scenes import * -def test_opengl_renderer_import_compatibility(): - from manim.renderer.opengl_renderer import OpenGLRenderer as LegacyOpenGLRenderer - - assert OpenGLRenderer is LegacyOpenGLRenderer - - def test_file_output_disables_window( config, using_temp_opengl_config, disabling_caching ): diff --git a/tests/test_scene_rendering/test_cairo_renderer.py b/tests/test_scene_rendering/test_cairo_renderer.py index afc61e7ea1..b0d713c371 100644 --- a/tests/test_scene_rendering/test_cairo_renderer.py +++ b/tests/test_scene_rendering/test_cairo_renderer.py @@ -10,13 +10,6 @@ from .simple_scenes import * -def test_cairo_renderer_import_compatibility(): - from manim.renderer.cairo import CairoRenderer as PackagedCairoRenderer - from manim.renderer.cairo_renderer import CairoRenderer as LegacyCairoRenderer - - assert PackagedCairoRenderer is LegacyCairoRenderer - - def test_render(using_temp_config, disabling_caching): scene = SquareToCircle() renderer = scene.renderer From 53bf048d27adc575138993b6c5fc77a7d3b0ee28 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Sat, 5 Sep 2026 14:13:11 +0200 Subject: [PATCH 06/13] Fix default Cairo camera aspect without changing explicit geometry --- manim/renderer/cairo/camera.py | 20 +++++-- tests/test_camera.py | 62 ++++++++++++++++++++ tests/test_scene_rendering/test_cli_flags.py | 39 ++++++++++++ 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/manim/renderer/cairo/camera.py b/manim/renderer/cairo/camera.py index 85d313f2c1..ddf27944ac 100644 --- a/manim/renderer/cairo/camera.py +++ b/manim/renderer/cairo/camera.py @@ -45,7 +45,10 @@ class Camera: Camera owns an animatable frame, semantic background settings, display ordering, and pure point transformations. Raster targets, pixel dimensions, image buffers, - and backend contexts belong to renderers. + and backend contexts belong to renderers. With no explicit frame dimensions, + the configured logical width is preserved and height follows the configured + output aspect ratio. Supplying one dimension derives the other from that ratio; + supplying both dimensions or a custom frame preserves the requested geometry. """ def __init__( @@ -65,11 +68,18 @@ def __init__( self.use_z_index = use_z_index if frame is None: + if frame_width is None: + resolved_width = ( + float(config["frame_width"]) + if frame_height is None + else frame_height * config.aspect_ratio + ) + else: + resolved_width = frame_width resolved_height = ( - float(config["frame_height"]) if frame_height is None else frame_height - ) - resolved_width = ( - float(config["frame_width"]) if frame_width is None else frame_width + resolved_width / config.aspect_ratio + if frame_height is None + else frame_height ) if resolved_height <= 0 or resolved_width <= 0: raise ValueError("Camera frame dimensions must be positive.") diff --git a/tests/test_camera.py b/tests/test_camera.py index b5dd2eb8c3..3731540110 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -19,6 +19,7 @@ Scene, Square, ThreeDCamera, + config, tempconfig, ) from manim.renderer.cairo import CairoRenderer @@ -58,6 +59,48 @@ def test_camera_frame_geometry_is_semantic_and_explicit(): np.testing.assert_array_equal(custom_camera.frame_center, [2, 1, 0]) +@pytest.mark.parametrize( + "camera_class", [Camera, MovingCamera, MultiCamera, ThreeDCamera] +) +@pytest.mark.parametrize("pixel_shape", [(128, 128), (96, 160), (192, 96)]) +def test_default_camera_preserves_square_geometry(camera_class, pixel_shape): + width, height = pixel_shape + with tempconfig({"pixel_width": width, "pixel_height": height}): + camera = camera_class() + assert camera.frame_width == pytest.approx(config.frame_width) + assert camera.frame_width / camera.frame_height == pytest.approx(width / height) + frame_points = camera.frame.points.copy() + renderer = CairoRenderer(camera=camera) + try: + renderer.render_mobjects( + [Square(fill_color="#ffffff", fill_opacity=1, stroke_width=0)], + ) + pixels = renderer.get_frame() + rows, columns = np.where(pixels[:, :, 0] > 128) + assert len(rows) > 0 + assert np.ptp(columns) == pytest.approx(np.ptp(rows), abs=1) + np.testing.assert_array_equal(camera.frame.points, frame_points) + finally: + renderer.close() + + +@pytest.mark.parametrize( + ("dimensions", "expected_width", "expected_height"), + [ + ({"frame_width": 6}, 6, 12), + ({"frame_height": 6}, 3, 6), + ({"frame_width": 6, "frame_height": 3}, 6, 3), + ], +) +def test_camera_resolves_only_unspecified_dimensions( + dimensions, expected_width, expected_height +): + with tempconfig({"pixel_width": 60, "pixel_height": 120}): + camera = Camera(**dimensions) + assert camera.frame_width == pytest.approx(expected_width) + assert camera.frame_height == pytest.approx(expected_height) + + @pytest.mark.parametrize( "removed_setting", [ @@ -235,6 +278,25 @@ def test_nested_target_size_tracks_display_size_and_closes(): _ = second_target.pixels +@pytest.mark.parametrize("pixel_shape", [(128, 128), (96, 160)]) +def test_nested_view_preserves_square_geometry(pixel_shape): + width, height = pixel_shape + with tempconfig({"pixel_width": width, "pixel_height": height}): + view = ImageMobjectFromCamera(Camera()) + view.set(width=8) + renderer = CairoRenderer(camera=MultiCamera([view])) + try: + renderer.render_mobjects( + [Square(fill_color="#ffffff", fill_opacity=1, stroke_width=0), view] + ) + pixels = renderer._sub_targets[id(view)].read_pixels() + rows, columns = np.where(pixels[:, :, 0] > 128) + assert len(rows) > 0 + assert np.ptp(columns) == pytest.approx(np.ptp(rows), abs=1) + finally: + renderer.close() + + def test_nested_views_disable_unsafe_static_reuse(): view = ImageMobjectFromCamera(Camera()) renderer = CairoRenderer(camera=MultiCamera([view])) diff --git a/tests/test_scene_rendering/test_cli_flags.py b/tests/test_scene_rendering/test_cli_flags.py index ba9f38e904..06a679a8eb 100644 --- a/tests/test_scene_rendering/test_cli_flags.py +++ b/tests/test_scene_rendering/test_cli_flags.py @@ -72,6 +72,45 @@ def test_resolution_flag(tmp_path, manim_cfg_file, simple_scenes_path): assert (width, height) == (meta["width"], meta["height"]) +@pytest.mark.slow +@pytest.mark.parametrize("resolution", [(128, 128), (96, 160)]) +def test_resolution_flag_preserves_square_geometry(tmp_path, resolution): + source = tmp_path / "square_scene.py" + source.write_text( + "from manim import Scene, Square\n" + "class SquareScene(Scene):\n" + " def construct(self):\n" + ' self.add(Square(fill_color="#ffffff", fill_opacity=1, stroke_width=0))\n', + encoding="utf-8", + ) + output = tmp_path / "square.png" + width, height = resolution + _, err, exit_code = capture( + [ + sys.executable, + "-m", + "manim", + "--renderer=cairo", + "--format=png", + "--resolution", + f"{width},{height}", + "--media_dir", + str(tmp_path / "media"), + "-o", + str(output), + str(source), + "SquareScene", + ] + ) + assert exit_code == 0, err + with Image.open(output) as image: + assert image.size == resolution + pixels = np.asarray(image) + rows, columns = np.where(pixels[:, :, 0] > 128) + assert len(rows) > 0 + assert np.ptp(columns) == pytest.approx(np.ptp(rows), abs=1) + + @pytest.mark.slow @video_comparison( "SquareToCircleWithlFlag.json", From 6fbe2d4d859174e3b74bc216802792f065ec4caf Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Sat, 5 Sep 2026 14:13:32 +0200 Subject: [PATCH 07/13] Release Cairo raster resources and reject closed drawing Retire unused nested targets, release retained buffers, and guard closed operations. Reject unused renderer keywords and build dispatch tables once per draw. --- manim/renderer/cairo/renderer.py | 60 ++++++++++++++----- manim/renderer/cairo/rendering.py | 35 ++++++----- manim/renderer/cairo/target.py | 18 ++++-- tests/module/test_cairo_target.py | 98 +++++++++++++++++++++++++++++++ tests/test_camera.py | 94 +++++++++++++++++++++++++++++ 5 files changed, 268 insertions(+), 37 deletions(-) create mode 100644 tests/module/test_cairo_target.py diff --git a/manim/renderer/cairo/renderer.py b/manim/renderer/cairo/renderer.py index 4acce926e2..30af769a12 100644 --- a/manim/renderer/cairo/renderer.py +++ b/manim/renderer/cairo/renderer.py @@ -46,7 +46,6 @@ def __init__( camera_class: type[Camera] | None = None, camera: Camera | None = None, skip_animations: bool = False, - **kwargs: Any, ) -> None: if camera is not None and camera_class is not None: raise ValueError("Pass either camera or camera_class, not both.") @@ -70,6 +69,11 @@ def __init__( self._camera_view_pixels: dict[int, RGBAPixelArray] = {} self.static_image: RGBAPixelArray | None = None self._render_all_mobjects = False + self._closed = False + + def _ensure_open(self) -> None: + if self._closed: + raise RuntimeError("The Cairo renderer is closed.") def init_scene( self, @@ -77,6 +81,7 @@ def init_scene( session_spec: RenderSessionSpec, file_writer_settings: _SceneFileWriterSettings, ) -> None: + self._ensure_open() self.file_writer: Any = self._file_writer_class(file_writer_settings) def play( @@ -85,6 +90,7 @@ def play( *args: Animation | Mobject | _AnimationBuilder, **kwargs: Any, ) -> None: + self._ensure_open() self.skip_animations = self._original_skipping_status self.update_skipping_status() scene.compile_animation_data(*args, **kwargs) @@ -234,6 +240,31 @@ def resolve_image( excluded_mobjects=excluded_mobjects, ) + def _draw_frame( + self, + *, + camera: Camera, + mobjects: Iterable[Mobject], + include_submobjects: bool = True, + excluded_mobjects: list[Mobject] | None = None, + ) -> None: + self._camera_view_pixels.clear() + try: + self._render_camera( + camera=camera, + target=self._target, + mobjects=mobjects, + include_submobjects=include_submobjects, + excluded_mobjects=excluded_mobjects, + camera_stack=(), + ) + finally: + # Only completed views lend pixels to this frame. Retire targets for + # removed views and any view whose drawing failed before completion. + unused = self._sub_targets.keys() - self._camera_view_pixels.keys() + for key in unused: + self._sub_targets.pop(key).close() + def update_frame( self, scene: Scene, @@ -243,6 +274,7 @@ def update_frame( **kwargs: Any, ) -> None: """Render one scene state into the owned Cairo target.""" + self._ensure_open() if self.skip_animations and not ignore_skipping: return if not mobjects: @@ -252,14 +284,11 @@ def update_frame( else: self._target.reset(self.camera) - self._camera_view_pixels.clear() - self._render_camera( + self._draw_frame( camera=self.camera, - target=self._target, mobjects=mobjects, include_submobjects=include_submobjects, excluded_mobjects=kwargs.get("excluded_mobjects"), - camera_stack=(), ) def render_mobjects( @@ -269,17 +298,10 @@ def render_mobjects( camera: Camera | None = None, ) -> None: """Render explicit mobjects for direct image materialization.""" + self._ensure_open() render_camera = self.camera if camera is None else camera self._target.reset(render_camera) - self._camera_view_pixels.clear() - self._render_camera( - camera=render_camera, - target=self._target, - mobjects=mobjects, - include_submobjects=True, - excluded_mobjects=None, - camera_stack=(), - ) + self._draw_frame(camera=render_camera, mobjects=mobjects) def render( self, @@ -301,6 +323,7 @@ def get_image(self) -> Image.Image: return Image.fromarray(self.get_frame()) def add_frame(self, frame: RGBAPixelArray, num_frames: int = 1) -> None: + self._ensure_open() if self.skip_animations: return self.time += num_frames / self._frame_rate @@ -321,6 +344,7 @@ def save_static_frame_data( scene: Scene, static_mobjects: Iterable[Mobject], ) -> RGBAPixelArray | None: + self._ensure_open() self.static_image = None # A nested view can contain any dynamic scene mobject regardless of the # view's position in the primary draw order. Keep all primary inputs @@ -352,6 +376,7 @@ def update_skipping_status(self) -> None: raise EndSceneEarlyException() def scene_finished(self, scene: Scene) -> None: + self._ensure_open() output = self.file_writer.output_spec if self.num_plays and (output.is_video or output.is_image_sequence): self.file_writer.finish() @@ -366,9 +391,14 @@ def scene_finished(self, scene: Scene) -> None: self.file_writer.save_image(self.get_frame()) def close(self) -> None: - """Release all renderer-owned Cairo targets.""" + """Release all Cairo targets and static pixels; subsequent drawing fails.""" + if self._closed: + return + self._closed = True self._target.close() for target in self._sub_targets.values(): target.close() self._sub_targets.clear() self._camera_view_pixels.clear() + self.static_image = None + self._render_all_mobjects = False diff --git a/manim/renderer/cairo/rendering.py b/manim/renderer/cairo/rendering.py index a1588d07b3..1950d0979e 100644 --- a/manim/renderer/cairo/rendering.py +++ b/manim/renderer/cairo/rendering.py @@ -72,18 +72,8 @@ def draw( include_submobjects: bool = True, excluded_mobjects: list[Mobject] | None = None, ) -> None: - self.camera._prepare_for_render() - to_display = self.camera.get_mobjects_to_display( - mobjects, - include_submobjects=include_submobjects, - excluded_mobjects=excluded_mobjects, - ) - for group_type, group in it.groupby(to_display, self._type_or_raise): - self._display_funcs[group_type](list(group)) - - @property - def _display_funcs(self) -> dict[type[Mobject], Callable[[list[Any]], None]]: - return { + self.target._ensure_open() + display_funcs: dict[type[Mobject], Callable[[list[Any]], None]] = { VMobject: self._display_vectorized_mobjects, PMobject: self._display_point_cloud_mobjects, ImageMobjectFromCamera: self._display_image_mobjects, @@ -91,13 +81,22 @@ def _display_funcs(self) -> dict[type[Mobject], Callable[[list[Any]], None]]: Mobject: lambda batch: None, } - def _type_or_raise(self, mobject: Mobject) -> type[Mobject]: - for mobject_type in self._display_funcs: - if isinstance(mobject, mobject_type): - return mobject_type - raise TypeError( - f"Displaying an object of class {type(mobject).__name__} is not supported", + def type_or_raise(mobject: Mobject) -> type[Mobject]: + for mobject_type in display_funcs: + if isinstance(mobject, mobject_type): + return mobject_type + raise TypeError( + f"Displaying an object of class {type(mobject).__name__} is not supported", + ) + + self.camera._prepare_for_render() + to_display = self.camera.get_mobjects_to_display( + mobjects, + include_submobjects=include_submobjects, + excluded_mobjects=excluded_mobjects, ) + for group_type, group in it.groupby(to_display, type_or_raise): + display_funcs[group_type](list(group)) def _display_vectorized_mobjects(self, vmobjects: list[VMobject]) -> None: if not vmobjects: diff --git a/manim/renderer/cairo/target.py b/manim/renderer/cairo/target.py index 913b5c55e6..00ec5f80ae 100644 --- a/manim/renderer/cairo/target.py +++ b/manim/renderer/cairo/target.py @@ -68,10 +68,13 @@ def __init__(self, settings: _CairoRasterSettings) -> None: self.background_image_cache: dict[str, RGBAPixelArray] = {} self._closed = False - @property - def pixels(self) -> RGBAPixelArray: + def _ensure_open(self) -> None: if self._closed: raise RuntimeError("The Cairo render target is closed.") + + @property + def pixels(self) -> RGBAPixelArray: + self._ensure_open() return self._pixels def _camera_background_key(self, camera: Camera) -> tuple[object, ...]: @@ -102,6 +105,7 @@ def _load_background(self, camera: Camera) -> RGBAPixelArray: return np.asarray(image, dtype=np.uint8).copy() def reset(self, camera: Camera) -> None: + self._ensure_open() key = self._camera_background_key(camera) if key != self._background_key: self._background = self._load_background(camera) @@ -114,13 +118,13 @@ def clear(self) -> None: def get_scratch_target(self) -> _CairoRenderTarget: """Return a reusable same-sized target for intermediate composition.""" - if self._closed: - raise RuntimeError("The Cairo render target is closed.") + self._ensure_open() if self._scratch_target is None: self._scratch_target = _CairoRenderTarget(self.settings) return self._scratch_target def set_pixels(self, pixels: RGBAPixelArray) -> None: + self._ensure_open() if pixels.shape != self._pixels.shape: raise ValueError( f"Cairo target pixels must have shape {self._pixels.shape}; " @@ -131,6 +135,7 @@ def set_pixels(self, pixels: RGBAPixelArray) -> None: np.copyto(self._pixels, pixels) def get_context(self, camera: Camera) -> cairo.Context: + self._ensure_open() settings = self.settings center = camera.get_view_transform_center() view_key = ( @@ -167,6 +172,7 @@ def get_context(self, camera: Camera) -> cairo.Context: return context def get_background_image(self, image: Image.Image | Path | str) -> RGBAPixelArray: + self._ensure_open() image_key = str(image) cached = self.background_image_cache.get(image_key) if cached is not None: @@ -197,7 +203,11 @@ def close(self) -> None: return self._closed = True self._context = None + self._context_key = None if self._scratch_target is not None: self._scratch_target.close() self._scratch_target = None self.background_image_cache.clear() + self._background_key = None + self._pixels = np.empty((0, 0, 4), dtype=np.uint8) + self._background = self._pixels diff --git a/tests/module/test_cairo_target.py b/tests/module/test_cairo_target.py new file mode 100644 index 0000000000..870723e6e0 --- /dev/null +++ b/tests/module/test_cairo_target.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import gc +import weakref + +import numpy as np +import pytest +from PIL import Image + +from manim import Camera +from manim.renderer.cairo.rendering import _CairoDrawingContext +from manim.renderer.cairo.target import _CairoRasterSettings, _CairoRenderTarget + + +@pytest.fixture +def target(): + target = _CairoRenderTarget( + _CairoRasterSettings( + pixel_width=8, + pixel_height=4, + base_pixel_width=8, + base_pixel_height=4, + ) + ) + try: + yield target + finally: + target.close() + + +@pytest.mark.parametrize( + "operation", + [ + lambda target: target.pixels, + lambda target: target.reset(Camera(background_image="missing.png")), + lambda target: target.clear(), + lambda target: target.get_scratch_target(), + lambda target: target.set_pixels(np.zeros((4, 8, 4), dtype=np.uint8)), + lambda target: target.get_context(Camera()), + lambda target: target.get_background_image("missing.png"), + lambda target: target.read_pixels(), + lambda target: _CairoDrawingContext( + camera=Camera(), target=target, image_resolver=lambda _: None + ).draw([]), + ], + ids=[ + "pixels", + "reset", + "clear", + "scratch", + "set", + "context", + "background", + "read", + "draw", + ], +) +def test_closed_target_rejects_use(target, operation): + target.close() + with pytest.raises(RuntimeError, match="closed"): + operation(target) + + +def test_transferred_pixels_survive_close(target): + target.reset(Camera()) + pixels = target.read_pixels() + expected = pixels.copy() + target.close() + np.testing.assert_array_equal(pixels, expected) + + +def test_close_releases_owned_buffers_and_scratch(target): + camera = Camera() + target.reset(camera) + target.get_context(camera) + target.get_background_image(Image.new("RGBA", (8, 4))) + scratch = target.get_scratch_target() + scratch.reset(camera) + scratch.get_context(camera) + buffers = [ + weakref.ref(array) + for array in ( + target.pixels, + target._background, + scratch.pixels, + scratch._background, + *target.background_image_cache.values(), + ) + ] + + target.close() + target.close() + gc.collect() + + assert all(reference() is None for reference in buffers) + assert target.background_image_cache == {} + with pytest.raises(RuntimeError, match="closed"): + _ = scratch.pixels diff --git a/tests/test_camera.py b/tests/test_camera.py index 3731540110..1988151c66 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -1,5 +1,7 @@ from __future__ import annotations +import gc +import weakref from types import SimpleNamespace from unittest.mock import patch @@ -115,6 +117,11 @@ def test_camera_rejects_removed_raster_settings(removed_setting): Camera(**removed_setting) +def test_renderer_rejects_unknown_constructor_settings(): + with pytest.raises(TypeError, match="unexpected keyword argument"): + CairoRenderer(pixel_width=100) + + def test_default_scene_camera_auto_zoom(): with tempconfig({"dry_run": True, "quality": "low_quality"}): scene = Scene() @@ -297,6 +304,93 @@ def test_nested_view_preserves_square_geometry(pixel_shape): renderer.close() +def test_removed_nested_views_release_their_target_subtree(): + with tempconfig({"pixel_width": 64, "pixel_height": 32}): + leaf = ImageMobjectFromCamera(Camera()) + branch = ImageMobjectFromCamera(MultiCamera([leaf])) + retained = ImageMobjectFromCamera(Camera()) + camera = MultiCamera([branch, retained]) + renderer = CairoRenderer(camera=camera) + try: + renderer.render_mobjects([branch, retained]) + branch_target = renderer._sub_targets[id(branch)] + leaf_target = renderer._sub_targets[id(leaf)] + retained_target = renderer._sub_targets[id(retained)] + + camera.image_mobjects_from_cameras.remove(branch) + renderer.render_mobjects([retained]) + + assert renderer._sub_targets == {id(retained): retained_target} + for retired in (branch_target, leaf_target): + with pytest.raises(RuntimeError, match="closed"): + _ = retired.pixels + assert retained_target.pixels.size > 0 + finally: + renderer.close() + + +def test_failed_nested_draw_releases_unfinished_target(): + with tempconfig({"pixel_width": 64, "pixel_height": 32}): + view = ImageMobjectFromCamera(Camera()) + renderer = CairoRenderer(camera=MultiCamera([view])) + try: + renderer.render_mobjects([view]) + target = renderer._sub_targets[id(view)] + with ( + patch.object( + _CairoDrawingContext, "draw", side_effect=ValueError("draw failed") + ), + pytest.raises(ValueError, match="draw failed"), + ): + renderer.render_mobjects([view]) + + assert renderer._sub_targets == {} + with pytest.raises(RuntimeError, match="closed"): + _ = target.pixels + renderer.render_mobjects([view]) + assert renderer._sub_targets[id(view)].pixels.size > 0 + finally: + renderer.close() + + +@pytest.mark.parametrize( + "operation", + [ + lambda renderer: renderer.render_mobjects([]), + lambda renderer: renderer.update_frame(None), + lambda renderer: renderer.update_frame(None, ignore_skipping=False), + lambda renderer: renderer.get_frame(), + lambda renderer: renderer.get_image(), + lambda renderer: renderer.save_static_frame_data( + SimpleNamespace(moving_mobjects=[]), [] + ), + ], + ids=["draw", "update", "skipped-update", "pixels", "image", "static-frame"], +) +def test_closed_renderer_rejects_drawing(operation): + with tempconfig({"pixel_width": 8, "pixel_height": 4}): + renderer = CairoRenderer(skip_animations=True) + renderer.close() + with pytest.raises(RuntimeError, match="closed"): + operation(renderer) + + +def test_renderer_close_releases_static_pixels(): + with tempconfig({"pixel_width": 32, "pixel_height": 18}): + renderer = CairoRenderer() + try: + renderer.save_static_frame_data( + SimpleNamespace(moving_mobjects=[]), [Square()] + ) + snapshot = weakref.ref(renderer.static_image) + renderer.close() + gc.collect() + assert renderer.static_image is None + assert snapshot() is None + finally: + renderer.close() + + def test_nested_views_disable_unsafe_static_reuse(): view = ImageMobjectFromCamera(Camera()) renderer = CairoRenderer(camera=MultiCamera([view])) From 873c0460a2766abffc57a56af980f345867ba906 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Sat, 5 Sep 2026 14:13:49 +0200 Subject: [PATCH 08/13] Add fresh scene image capture for Cairo and OpenGL Expose Scene.get_image() and Scene.show() through Manager without stepping animations or emitting movie frames. Preserve active raster targets and capture OpenGL meshes on the context-owning thread. --- manim/manager.py | 6 + manim/renderer/cairo/renderer.py | 17 +- manim/renderer/opengl/renderer.py | 41 +++- manim/scene/scene.py | 25 +++ .../opengl/test_opengl_renderer.py | 12 +- .../test_scene_rendering/test_scene_images.py | 184 ++++++++++++++++++ 6 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 tests/test_scene_rendering/test_scene_images.py diff --git a/manim/manager.py b/manim/manager.py index 1f0605ae8c..a048908552 100644 --- a/manim/manager.py +++ b/manim/manager.py @@ -13,6 +13,8 @@ from .utils.file_ops import open_media_file if TYPE_CHECKING: + from PIL.Image import Image + from ._config.output import OutputSpec from ._config.render_session import RenderSessionSpec from .animation.animation import Animation @@ -185,6 +187,10 @@ def render(self, preview: bool = False) -> bool: return False + def get_image(self) -> Image: + """Materialize current state without entering a timed/output transaction.""" + return self.renderer._get_scene_image(self.scene) + def setup(self) -> None: """Run the managed scene's :meth:`~manim.scene.scene.Scene.setup` hook.""" self.scene.setup() diff --git a/manim/renderer/cairo/renderer.py b/manim/renderer/cairo/renderer.py index 30af769a12..ea85d5a470 100644 --- a/manim/renderer/cairo/renderer.py +++ b/manim/renderer/cairo/renderer.py @@ -46,6 +46,8 @@ def __init__( camera_class: type[Camera] | None = None, camera: Camera | None = None, skip_animations: bool = False, + *, + _raster_settings: _CairoRasterSettings | None = None, ) -> None: if camera is not None and camera_class is not None: raise ValueError("Pass either camera or camera_class, not both.") @@ -58,7 +60,7 @@ def __init__( self.num_plays = 0 self.time = 0.0 self._frame_rate = float(config["frame_rate"]) - settings = _CairoRasterSettings( + settings = _raster_settings or _CairoRasterSettings( pixel_width=int(config["pixel_width"]), pixel_height=int(config["pixel_height"]), base_pixel_width=int(config["pixel_width"]), @@ -318,6 +320,19 @@ def get_frame(self) -> RGBAPixelArray: """Return a fresh owned top-left-origin RGBA frame.""" return self._target.read_pixels() + def _get_scene_image(self, scene: Scene) -> Image.Image: + """Draw current state in an independent scope, even after raster cleanup.""" + renderer = CairoRenderer( + camera=self.camera, _raster_settings=self._target.settings + ) + try: + renderer.render_mobjects( + list_update(scene.mobjects, scene.foreground_mobjects) + ) + return renderer.get_image() + finally: + renderer.close() + def get_image(self) -> Image.Image: """Return the current target as a PIL image.""" return Image.fromarray(self.get_frame()) diff --git a/manim/renderer/opengl/renderer.py b/manim/renderer/opengl/renderer.py index 9de68b86c9..f59f21458d 100644 --- a/manim/renderer/opengl/renderer.py +++ b/manim/renderer/opengl/renderer.py @@ -2,6 +2,7 @@ import contextlib import itertools as it +import threading import time import typing from typing import TYPE_CHECKING, Any @@ -162,6 +163,8 @@ def init_scene( ) self.frame_buffer_object = self.get_frame_buffer_object(self.context, 0) self.frame_buffer_object.use() + self._context_thread = threading.get_ident() + self._capturing_image = False self.context.enable(moderngl.BLEND) self.context.wireframe = config["enable_wireframe"] self.context.blend_func = ( @@ -533,6 +536,11 @@ def update_frame(self, scene: Scene) -> None: scene : Scene The scene to render the frame for. """ + self._draw_scene(scene) + self.animation_elapsed_time = time.time() - self.animation_start_time + + def _draw_scene(self, scene: Scene) -> None: + """Draw current objects and meshes without updating animation timing.""" self.frame_buffer_object.clear(*self.background_color) # TODO: make the type of 'camera' generic in the 'Scene' class @@ -554,7 +562,38 @@ def update_frame(self, scene: Scene) -> None: mesh.set_uniforms(self) mesh.render() - self.animation_elapsed_time = time.time() - self.animation_start_time + def _get_scene_image(self, scene: Scene) -> Image.Image: + """Capture on the context's owning thread without touching its live target.""" + if threading.get_ident() != self._context_thread: + raise RuntimeError( + "OpenGL scene images must be requested on the render thread." + ) + if self._capturing_image: + raise RuntimeError("Recursive OpenGL scene image capture is not supported.") + target = self.frame_buffer_object + bound = self.context.fbo + viewport = self.context.viewport + size = target.size + self._capturing_image = True + try: + with contextlib.ExitStack() as resources: + color = self.context.texture(size, components=4) + resources.callback(color.release) + depth = self.context.depth_renderbuffer(size) + resources.callback(depth.release) + frame = self.context.framebuffer(color, depth) + resources.callback(frame.release) + try: + self.frame_buffer_object = frame + frame.use() + self._draw_scene(scene) + return Image.fromarray(self.get_frame()) + finally: + self.frame_buffer_object = target + (target if bound is None else bound).use() + self.context.viewport = viewport + finally: + self._capturing_image = False def scene_finished(self, scene: Scene) -> None: """Finalize configured output for the scene. diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 5ef00fe8d3..6d059a00c3 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -72,6 +72,8 @@ from types import FrameType from typing import Self, TypeAlias + from PIL.Image import Image + from manim.typing import Point3D SceneInteractAction: TypeAlias = ( @@ -320,6 +322,29 @@ def render(self, preview: bool = False) -> bool: """ return self._get_manager().render(preview) + def get_image(self) -> Image: + """Draw the current scene and return an independent PIL image. + + Uses the scene's camera and renderer dimensions, including manual changes + made since the last animation. Does not run updaters, advance time, execute + construction, or append a movie frame. Saving or displaying is explicit:: + + self.add(Square()) + self.get_image().save("checkpoint.png") + + Call between animations or at an idle prompt. OpenGL requests must run on + the render/context thread. This is current-state inspection, not seeking + to an earlier animation sample or reading the last movie frame. + """ + return self._get_manager().get_image() + + def show(self) -> None: + """Draw current state and open it with PIL's external image viewer. + + In a notebook, display :meth:`get_image`'s result directly instead. + """ + self.get_image().show() + def _get_manager(self) -> Manager[Self]: """Return this scene's manager, creating it for legacy entry points.""" manager = self.manager diff --git a/tests/test_scene_rendering/opengl/test_opengl_renderer.py b/tests/test_scene_rendering/opengl/test_opengl_renderer.py index 537b9703e9..419c8ed9c1 100644 --- a/tests/test_scene_rendering/opengl/test_opengl_renderer.py +++ b/tests/test_scene_rendering/opengl/test_opengl_renderer.py @@ -6,6 +6,7 @@ import numpy as np import pytest +from manim import Square from manim.renderer.opengl import OpenGLRenderer from tests.assert_utils import assert_file_exists from tests.test_scene_rendering.simple_scenes import * @@ -96,7 +97,16 @@ def test_get_frame_with_live_preview_enabled(config, using_opengl_renderer): assert renderer.get_pixel_shape()[1] == frame.shape[0] assert frame.dtype == np.uint8 assert frame.flags.c_contiguous - renderer.window.close() + try: + elapsed = renderer.animation_elapsed_time + scene.add(Square(fill_opacity=1)) + image = scene.get_image() + assert image.size == renderer.get_pixel_shape() + assert not np.array_equal(image, frame) + np.testing.assert_array_equal(renderer.get_frame(), frame) + assert renderer.animation_elapsed_time == elapsed + finally: + renderer.window.close() def test_render_without_frame_output_skips_gpu_readback( diff --git a/tests/test_scene_rendering/test_scene_images.py b/tests/test_scene_rendering/test_scene_images.py new file mode 100644 index 0000000000..a3edcb099e --- /dev/null +++ b/tests/test_scene_rendering/test_scene_images.py @@ -0,0 +1,184 @@ +"""Current-state inspection is independent of animation and movie emission.""" + +from concurrent.futures import ThreadPoolExecutor + +import numpy as np +import pytest +from PIL import Image + +from manim import RIGHT, Scene, Square, ThreeDScene, ZoomedScene, tempconfig + + +@pytest.fixture(params=["cairo", "opengl"]) +def image_scene(request): + with tempconfig( + { + "renderer": request.param, + "dry_run": True, + "pixel_width": 128, + "pixel_height": 128, + "frame_rate": 4, + } + ): + scene = Scene() + yield scene + if request.param == "cairo": + scene.renderer.close() + else: + scene.renderer.context.release() + + +def test_fresh_image_without_evaluation(image_scene, monkeypatch): + scene = image_scene + calls = [] + square = Square(fill_opacity=1, stroke_width=0) + square.add_updater(lambda m, dt: calls.append(dt)) + scene.add(square) + calls.clear() + + def unexpected(*args, **kwargs): + pytest.fail("Image inspection must not execute or emit movie frames") + + monkeypatch.setattr(scene, "construct", unexpected) + monkeypatch.setattr(scene.renderer.file_writer, "write_frame", unexpected) + scene.renderer.update_frame(scene) + old = scene.renderer.get_frame() + square.shift(3 * RIGHT) + image = scene.get_image() + assert isinstance(image, Image.Image) + assert image.size == (128, 128) + assert not np.array_equal(old, np.asarray(image)) + np.testing.assert_array_equal(scene.renderer.get_frame(), old) + assert calls == [] + assert scene.time == 0 + assert scene.renderer.num_plays == 0 + square.shift(-3 * RIGHT) + np.testing.assert_array_equal(np.asarray(scene.get_image()), old) + + +def test_image_uses_existing_resolution(image_scene): + image_scene.add(Square()) + with tempconfig({"pixel_width": 72, "pixel_height": 40}): + assert image_scene.get_image().size == (128, 128) + + +def test_show_uses_fresh_image(image_scene, monkeypatch): + images = [] + monkeypatch.setattr(Image.Image, "show", lambda image: images.append(image)) + image_scene.add(Square()) + image_scene.show() + assert len(images) == 1 + np.testing.assert_array_equal(images[0], image_scene.get_image()) + + +def test_capture_during_construct_and_after_render(image_scene, monkeypatch): + images = [] + + def construct(): + square = Square(fill_opacity=1) + image_scene.add(square) + images.append(image_scene.get_image()) + image_scene.play(square.animate.shift(3 * RIGHT)) + clock = image_scene.time + images.append(image_scene.get_image()) + assert image_scene.time == clock + assert image_scene.renderer.num_plays == 1 + + monkeypatch.setattr(image_scene, "construct", construct) + image_scene.render() + assert not np.array_equal(images[0], images[1]) + np.testing.assert_array_equal(images[1], image_scene.get_image()) + + +def test_cairo_image_after_close_and_with_static_cache(): + with tempconfig({"dry_run": True, "pixel_width": 128, "pixel_height": 128}): + scene = Scene() + scene.add(Square(fill_opacity=1)) + renderer = scene.renderer + renderer.static_image = np.full((128, 128, 4), 77, dtype=np.uint8) + static = renderer.static_image + image = scene.get_image() + assert renderer.static_image is static + renderer.close() + np.testing.assert_array_equal(image, scene.get_image()) + assert renderer._closed + assert renderer._target._pixels.size == 0 + + +@pytest.mark.parametrize("scene_class", [Scene, ThreeDScene, ZoomedScene]) +def test_cairo_snapshot_camera_and_nested_view_parity(scene_class): + with tempconfig({"dry_run": True, "pixel_width": 128, "pixel_height": 128}): + scene = scene_class() + try: + scene.setup() + scene.add(Square(fill_opacity=1)) + if isinstance(scene, ZoomedScene): + scene.activate_zooming(animate=False) + scene.camera.frame.shift(RIGHT) + image = scene.get_image() + scene.renderer.update_frame(scene) + np.testing.assert_array_equal(image, scene.renderer.get_frame()) + finally: + scene.renderer.close() + + +def test_opengl_snapshot_restores_target_on_failure(image_scene, monkeypatch): + renderer = image_scene.renderer + if not hasattr(renderer, "context"): + pytest.skip("OpenGL-specific resource test") + target = renderer.frame_buffer_object + viewport = renderer.context.viewport + elapsed = renderer.animation_elapsed_time + original = renderer._draw_scene + + def fail(scene): + raise ValueError("drawing failed") + + monkeypatch.setattr(renderer, "_draw_scene", fail) + with pytest.raises(ValueError, match="drawing failed"): + image_scene.get_image() + assert renderer.frame_buffer_object is target + assert renderer.context.fbo is target + assert renderer.context.viewport == viewport + assert renderer.animation_elapsed_time == elapsed + monkeypatch.setattr(renderer, "_draw_scene", original) + assert image_scene.get_image().size == (128, 128) + + +def test_opengl_snapshot_includes_meshes(image_scene): + if not hasattr(image_scene.renderer, "context"): + pytest.skip("OpenGL-specific mesh test") + from manim.renderer.opengl.shader import Mesh, Shader + + shader = Shader( + image_scene.renderer.context, + source={ + "vertex_shader": """ + #version 330 + in vec3 point; + void main() { gl_Position = vec4(point, 1.0); } + """, + "fragment_shader": """ + #version 330 + out vec4 color; + void main() { color = vec4(1.0, 0.0, 0.0, 1.0); } + """, + }, + ) + attributes = np.zeros(3, dtype=[("point", np.float32, (3,))]) + attributes["point"] = [(-0.5, -0.5, 0), (0.5, -0.5, 0), (0, 0.5, 0)] + mesh = Mesh(shader=shader, attributes=attributes) + image_scene.add(mesh) + pixels = np.asarray(image_scene.get_image()) + np.testing.assert_array_equal(pixels[64, 64, :3], [255, 0, 0]) + image_scene.renderer.update_frame(image_scene) + np.testing.assert_array_equal(pixels, image_scene.renderer.get_frame()) + + +def test_opengl_snapshot_requires_owner_thread(image_scene): + if not hasattr(image_scene.renderer, "context"): + pytest.skip("OpenGL-specific thread test") + with ThreadPoolExecutor(1) as pool: + future = pool.submit(image_scene.get_image) + with pytest.raises(RuntimeError, match="render thread"): + future.result() From 70761d5928c44e68326bb2430ca87d3f64747027 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Sat, 5 Sep 2026 14:13:49 +0200 Subject: [PATCH 09/13] Document camera controls, snapshots, and multi-camera composition --- docs/source/guides/cameras.rst | 190 +++++++++++++++++++++++++++++++++ docs/source/guides/index.rst | 1 + 2 files changed, 191 insertions(+) create mode 100644 docs/source/guides/cameras.rst diff --git a/docs/source/guides/cameras.rst b/docs/source/guides/cameras.rst new file mode 100644 index 0000000000..bdc2239829 --- /dev/null +++ b/docs/source/guides/cameras.rst @@ -0,0 +1,190 @@ +Working with cameras and scene images +===================================== + +A camera describes the logical view of a scene: its position, visible extent, and +projection. A renderer turns that view into pixels. You normally work with the camera +through ``self.camera`` and request images through the scene, without managing a renderer. + +Moving the Cairo camera +----------------------- + +The ordinary Cairo :class:`.Camera` has an animatable ``frame``. You do not need a +special scene subclass to pan or zoom:: + + class CameraExample(Scene): + def construct(self): + square = Square().shift(2 * RIGHT) + self.add(square) + self.play(self.camera.frame.animate.move_to(square)) + self.play(self.camera.frame.animate.scale(0.5)) + +A smaller frame zooms in; a larger frame shows more of the scene. Save and restore the +frame with the usual mobject operations:: + + self.camera.frame.save_state() + self.play(self.camera.auto_zoom([square])) + self.play(Restore(self.camera.frame)) + +:class:`.MovingCameraScene` remains available as a descriptive name for this behavior. +These frame examples describe the Cairo camera; OpenGL uses its own camera controls. + +Logical view and image resolution +--------------------------------- + +Frame dimensions are in scene units; pixel dimensions specify the raster resolution. +Configure pixel dimensions before constructing the scene or renderer:: + + with tempconfig({"pixel_width": 640, "pixel_height": 360}): + scene = Scene() + scene.add(Square()) + image = scene.get_image() + +A default Cairo camera preserves ``config.frame_width`` and derives height from the +configured pixel aspect ratio. Square and portrait output therefore preserve ordinary +geometry. One explicit camera dimension determines the other using that aspect ratio; +two dimensions or a custom frame preserve the geometry you specify:: + + camera = Camera(frame_width=8, frame_height=4) + camera.frame.move_to([2, 1, 0]) + +With both dimensions explicit, choose the same logical and raster aspect ratio when +undistorted output is required. Drawing does not resize your semantic frame. Scene +image requests use the existing renderer dimensions, not later pixel-config edits. + +Inspecting the current scene +---------------------------- + +:meth:`.Scene.get_image` freshly draws the current scene and returns a PIL image. +It includes manual changes since the last animation and the current camera view:: + + class InspectExample(Scene): + def construct(self): + square = Square() + self.add(square) + self.get_image().save("before.png") + self.play(square.animate.shift(RIGHT)) + self.get_image().save("after.png") + +Use ``scene.show()`` to open a fresh image in PIL's external image viewer. In a notebook, +display the returned PIL image directly. Saving and opening an image are explicit; +``get_image()`` itself does not write a media artifact or open a viewer. + +An image request does not execute construction, run updaters, advance scene time, or +append a movie frame. It photographs the graph as it stands, even if updater-derived +geometry has not yet been refreshed. The post-animation graph may differ from the last +encoded sample because animation finish/cleanup has already run. This is inspection, +not seeking or replaying an earlier animation position. + +Request images between plays or at an idle prompt. OpenGL capture must run on the thread +that owns the rendering context; arbitrary worker-thread calls, including background +embedded-shell calls, are not dispatched automatically. Both Cairo and OpenGL draw into +independent temporary targets rather than replacing the active frame. Returned images +remain usable after those temporary targets are released. Explicit image requests also +work in dry-run mode; they are intentional raster work requested by your Python code. + +Inspecting individual mobjects +------------------------------ + +For ordinary Cairo mobjects, use :meth:`.Mobject.get_image` or :meth:`.Mobject.show`:: + + Square().show() + Group(Square().shift(LEFT), Circle().shift(RIGHT)).get_image().save("objects.png") + image = square.get_image(camera=self.camera) + +The optional camera selects the view, not the scene contents: only the supplied mobject +and its family are drawn. Without it, a default camera is used. These standalone helpers +are Cairo-specific; use ``scene.get_image()`` for an OpenGL scene, including its meshes. + +Three-dimensional and nested views +---------------------------------- + +Use :class:`.ThreeDScene` and its camera orientation methods for three-dimensional +scenes. Image inspection uses the current projection and fixed-object declarations, +just like ordinary drawing. + +For an inset magnified view, :class:`.ZoomedScene` provides the camera and display +relationship:: + + class DetailExample(ZoomedScene): + def construct(self): + self.add(Square()) + self.activate_zooming(animate=False) + self.get_image().save("detail.png") + +Multiple camera views +--------------------- + +The Cairo backend supports several camera views within one scene through +:class:`.MultiCamera`. The primary camera draws the overall scene; each secondary +camera supplies an image displayed by an :class:`.ImageMobjectFromCamera` mobject. +This is live composition of the same scene, not separate Scene executions or separate +video outputs. This API is not supported by the OpenGL backend. + +There are two independent controls: + +* The secondary camera's ``frame`` selects the region to look at. Move it to pan, + or shrink it to zoom in. +* The display mobject selects where that view appears in the primary scene. Move or + scale it like another mobject, without changing the secondary camera's view. + +For example, this scene places two detail views above the original objects:: + + class TwoCameraViews(Scene): + def __init__(self, **kwargs): + super().__init__(camera_class=MultiCamera, **kwargs) + + def construct(self): + circle = Circle(color=YELLOW).shift(2 * LEFT + DOWN) + square = Square(color=BLUE).shift(2 * RIGHT + DOWN) + self.add(circle, square) + + left_camera = Camera(frame_width=4, frame_height=3) + right_camera = Camera(frame_width=4, frame_height=3) + left_camera.frame.move_to(circle) + right_camera.frame.move_to(square) + + left_view = ImageMobjectFromCamera(left_camera) + right_view = ImageMobjectFromCamera(right_camera) + left_view.scale_to_fit_width(3).to_corner(UL) + right_view.scale_to_fit_width(3).to_corner(UR) + + for view in (left_view, right_view): + view.add_display_frame() + self.camera.add_image_mobject_from_camera(view) + self.add(view) + + # Zoom the left view without resizing its display. + self.play(left_camera.frame.animate.scale(0.5)) + # Pan the right view from the square to the circle. + self.play(right_camera.frame.animate.move_to(circle)) + self.wait() + +Run this example with ``--renderer=cairo``. Selecting ``MultiCamera`` in the constructor +ensures it is installed before the scene's renderer is initialized. + +Both registration and scene membership matter: registering a display tells MultiCamera +to produce its view; ``self.add(view)`` places the display in the scene's draw order. +``add_display_frame()`` adds an optional visible border. The secondary camera's own +``frame`` is a view control and is not automatically shown as an outline in the scene. + +A display initially matches its source camera's aspect ratio. Scale it uniformly to +preserve that ratio; stretching only its width or height can distort the image. +The renderer chooses the secondary raster size from the display's size relative to +the primary view and manages resizing and pixel transfer automatically. + +Secondary cameras share the scene's contents rather than having separate object lists. +Each display and its border are excluded from their own source view. In the example, +the detail cameras look below the insets so neither inset appears in the other. +Sibling views are processed in registration order; do not rely on them recursively +containing each other. For deeper nesting, a secondary camera may itself be a +MultiCamera with its own registered displays. Cyclic camera registrations are rejected +rather than rendered recursively forever. + +To remove a view entirely, remove both its visible mobject and its registration:: + + self.remove(left_view) + self.camera.image_mobjects_from_cameras.remove(left_view) + +The renderer retires unused secondary targets on the next draw. A scene image requested +with ``self.get_image()`` includes all currently registered and visible views; there is +no need to copy camera pixels or refresh each inset yourself. diff --git a/docs/source/guides/index.rst b/docs/source/guides/index.rst index 0f0a92b98d..55af61c2da 100644 --- a/docs/source/guides/index.rst +++ b/docs/source/guides/index.rst @@ -7,6 +7,7 @@ Thematic Guides :glob: configuration + cameras deep_dive using_text add_voiceovers From c6cd3734921b2cddb64fc1e3175d0c18f3778720 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Sat, 5 Sep 2026 15:10:51 +0200 Subject: [PATCH 10/13] Reduce default camera frame query and ThreeD projection overhead Compute default frame bounds directly without a semantic cache, preserve custom frame hooks, and share perspective scaling between axes. Add mutation, boundary, and projection parity tests. --- manim/renderer/cairo/camera.py | 48 +++++++++---- tests/module/test_camera_projection.py | 94 ++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 tests/module/test_camera_projection.py diff --git a/manim/renderer/cairo/camera.py b/manim/renderer/cairo/camera.py index ddf27944ac..b7b8090e3f 100644 --- a/manim/renderer/cairo/camera.py +++ b/manim/renderer/cairo/camera.py @@ -40,6 +40,29 @@ ) +class _CameraFrame(ScreenRectangle): + """Default frame with a direct bounding-box center query, not a cached center.""" + + def get_points_defining_boundary(self) -> Point3D_Array: + if self.submobjects or len(self.points) <= 1: + return super().get_points_defining_boundary() + # Same start/end anchors as VMobject, without family/list assembly for + # the ordinary childless frame. Do not assume it remains rectangular. + curves = self.points.reshape( + -1, self.n_points_per_cubic_curve, self.points.shape[1] + ) + return curves[:, [0, -1], :].reshape(-1, self.points.shape[1]) + + def get_center(self) -> Point3D: + points = self.get_points_defining_boundary() + if len(points) == 0: + return np.zeros(self.dim) + # Projecting many small mobjects asks for this center repeatedly. Reduce + # all axes together instead of the generic per-axis critical-point path. + points = points[:, : self.dim] + return ((points.min(axis=0) + points.max(axis=0)) / 2).astype(float, copy=False) + + class Camera: """Describe the logical view used by a rendering backend. @@ -83,7 +106,7 @@ def __init__( ) if resolved_height <= 0 or resolved_width <= 0: raise ValueError("Camera frame dimensions must be positive.") - frame = ScreenRectangle( + frame = _CameraFrame( aspect_ratio=resolved_width / resolved_height, height=resolved_height, ) @@ -631,19 +654,18 @@ def project_points(self, points: Point3D_Array) -> Point3D_Array: points = points - frame_center points = np.dot(points, rot_matrix.T) zs = points[:, 2] + if self.exponential_projection: + # Proper projection would involve multiplying x and y by d / (d-z). + # The exponential avoids artifacts for high positive z values. + factor = np.exp(zs / focal_distance) + lt0 = zs < 0 + factor[lt0] = focal_distance / (focal_distance - zs[lt0]) + else: + factor = focal_distance / (focal_distance - zs) + factor[(focal_distance - zs) < 0] = 10**6 + scale = factor * zoom for i in 0, 1: - if self.exponential_projection: - # Proper projection would involve multiplying - # x and y by d / (d-z). But for points with high - # z value that causes weird artifacts, and applying - # the exponential helps smooth it out. - factor = np.exp(zs / focal_distance) - lt0 = zs < 0 - factor[lt0] = focal_distance / (focal_distance - zs[lt0]) - else: - factor = focal_distance / (focal_distance - zs) - factor[(focal_distance - zs) < 0] = 10**6 - points[:, i] *= factor * zoom + points[:, i] *= scale return points def project_point(self, point: Point3D) -> Point3D: diff --git a/tests/module/test_camera_projection.py b/tests/module/test_camera_projection.py new file mode 100644 index 0000000000..30585b156e --- /dev/null +++ b/tests/module/test_camera_projection.py @@ -0,0 +1,94 @@ +"""Default frame queries stay fresh without changing projection arithmetic.""" + +import numpy as np +import pytest + +from manim import ( + RIGHT, + Camera, + Mobject, + ScreenRectangle, + Square, + ThreeDCamera, + VMobject, +) + + +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +def test_default_frame_center_matches_generic_query_after_mutations(dtype): + camera = Camera() + frame = camera.frame + frame.points = frame.points.astype(dtype) + + def check(): + np.testing.assert_array_equal( + frame.get_points_defining_boundary(), + VMobject.get_points_defining_boundary(frame), + ) + np.testing.assert_array_equal(camera.frame_center, Mobject.get_center(frame)) + assert camera.frame_center.dtype == np.float64 + + check() + frame.shift([2, -1, 0.5]).rotate(0.3).stretch(1.7, 0) + check() + # Direct point edits must not require a draw or cache invalidation. + frame.points[0] += [3, 4, 5] + check() + child = Square().shift(10 * RIGHT) + frame.add(child) + check() + child.shift(5 * RIGHT) + check() + frame.remove(child) + frame.clear_points() + check() + frame.set_points(np.array([[1.0, 2.0, 3.0]])) + check() + boundary = frame.get_points_defining_boundary() + boundary[:] = 0 + np.testing.assert_array_equal(frame.points, [[1, 2, 3]]) + + +def test_custom_frame_center_and_critical_point_hooks_remain_supported(): + class CustomFrame(ScreenRectangle): + def get_critical_point(self, direction): + return np.array([3.0, 4.0, 5.0]) + + frame = CustomFrame() + camera = ThreeDCamera(frame=frame) + np.testing.assert_array_equal(camera.frame_center, [3, 4, 5]) + frame.get_center = lambda: np.array([6.0, 7.0, 8.0]) + np.testing.assert_array_equal(camera.frame_center, [6, 7, 8]) + assert camera.frame is frame + + +@pytest.mark.parametrize("exponential", [False, True]) +@pytest.mark.parametrize("empty", [False, True]) +def test_projection_matches_previous_arithmetic(exponential, empty): + camera = ThreeDCamera( + phi=0.2, + theta=-0.7, + gamma=0.1, + focal_distance=4, + zoom=1.5, + exponential_projection=exponential, + ) + camera.frame.shift([1, 2, 3]) + points = np.array([[1, 2, -10], [1, 0, 0], [2, -3, 6], [0, 2, 20]], dtype=float) + if empty: + points = points[:0] + original = points.copy() + expected = np.dot(points - camera.frame_center, camera.get_rotation_matrix().T) + zs = expected[:, 2] + distance = camera.get_focal_distance() + for i in (0, 1): + if exponential: + factor = np.exp(zs / distance) + negative = zs < 0 + factor[negative] = distance / (distance - zs[negative]) + else: + factor = distance / (distance - zs) + factor[(distance - zs) < 0] = 10**6 + expected[:, i] *= factor * camera.get_zoom() + np.testing.assert_array_equal(camera.project_points(points), expected) + np.testing.assert_array_equal(points, original) From 96ae6082c5aebf3776652d68f23f946403528517 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Tue, 8 Sep 2026 12:17:45 +0200 Subject: [PATCH 11/13] Apply batched suggestions from code review Co-authored-by: nikolajmunk <28557236+nikolajmunk@users.noreply.github.com> --- docs/source/guides/cameras.rst | 65 ++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/docs/source/guides/cameras.rst b/docs/source/guides/cameras.rst index bdc2239829..dd21911ce6 100644 --- a/docs/source/guides/cameras.rst +++ b/docs/source/guides/cameras.rst @@ -1,15 +1,29 @@ Working with cameras and scene images ===================================== -A camera describes the logical view of a scene: its position, visible extent, and -projection. A renderer turns that view into pixels. You normally work with the camera -through ``self.camera`` and request images through the scene, without managing a renderer. +A camera represents a view of the current scene: in short, the camera describes +where the scene is being viewed from and how much of the scene is visible. The +renderer "draws" the scene from this view and turns it into an image. + +Most often, interaction with the camera happens inside a scene class using +``self.camera``. + +The camera frame +---------------- + +In 2D scenes, the camera's view is described by its *frame* (not to be confused +with a frame of a video). This frame is roughly equivalent to a picture frame +that is laid on top of the scene, with the camera "seeing" everything that lies +inside the frame. When the camera pans you can think of it as the frame sliding +across the "surface" of the scene, and when the camera zooms in or out, you can +think of it as the frame getting smaller or bigger (since less or more of the +scene, respectively, will fit into the picture frame). Moving the Cairo camera ----------------------- -The ordinary Cairo :class:`.Camera` has an animatable ``frame``. You do not need a -special scene subclass to pan or zoom:: +In the ordinary Cairo :class:`.Camera`, the frame is an actual mobject called +``frame``. You can modify or animate this frame like any other mobject:: class CameraExample(Scene): def construct(self): @@ -28,11 +42,13 @@ frame with the usual mobject operations:: :class:`.MovingCameraScene` remains available as a descriptive name for this behavior. These frame examples describe the Cairo camera; OpenGL uses its own camera controls. -Logical view and image resolution ---------------------------------- +Camera view and image resolution +-------------------------------- -Frame dimensions are in scene units; pixel dimensions specify the raster resolution. -Configure pixel dimensions before constructing the scene or renderer:: +The dimensions of the camera's frame and of the images output by the renderer +are specified separately. Camera frame dimensions are defined in scene units, +while output dimensions are defined in pixels. You must configure pixel +dimensions before constructing the scene or renderer:: with tempconfig({"pixel_width": 640, "pixel_height": 360}): scene = Scene() @@ -47,9 +63,9 @@ two dimensions or a custom frame preserve the geometry you specify:: camera = Camera(frame_width=8, frame_height=4) camera.frame.move_to([2, 1, 0]) -With both dimensions explicit, choose the same logical and raster aspect ratio when -undistorted output is required. Drawing does not resize your semantic frame. Scene -image requests use the existing renderer dimensions, not later pixel-config edits. +When both the width and height of the camera frame are explicitly provided, you +should ensure that frame dimensions and pixel dimensions have the same aspect +ratio; otherwise, the camera's output will be distorted when it is rendered. Inspecting the current scene ---------------------------- @@ -66,8 +82,8 @@ It includes manual changes since the last animation and the current camera view: self.get_image().save("after.png") Use ``scene.show()`` to open a fresh image in PIL's external image viewer. In a notebook, -display the returned PIL image directly. Saving and opening an image are explicit; -``get_image()`` itself does not write a media artifact or open a viewer. +the returned PIL image is displayed directly. ``get_image()`` only generates the +snapshot; the image must be saved to disk explicitly. An image request does not execute construction, run updaters, advance scene time, or append a movie frame. It photographs the graph as it stands, even if updater-derived @@ -91,9 +107,11 @@ For ordinary Cairo mobjects, use :meth:`.Mobject.get_image` or :meth:`.Mobject.s Group(Square().shift(LEFT), Circle().shift(RIGHT)).get_image().save("objects.png") image = square.get_image(camera=self.camera) -The optional camera selects the view, not the scene contents: only the supplied mobject -and its family are drawn. Without it, a default camera is used. These standalone helpers -are Cairo-specific; use ``scene.get_image()`` for an OpenGL scene, including its meshes. +The ``camera`` parameter allows for a different camera to be used to generate +the image. Without it, the scene's default camera is used. + +These standalone helpers are Cairo-specific; use ``scene.get_image()`` for an +OpenGL scene, including its meshes. Three-dimensional and nested views ---------------------------------- @@ -115,17 +133,18 @@ Multiple camera views --------------------- The Cairo backend supports several camera views within one scene through -:class:`.MultiCamera`. The primary camera draws the overall scene; each secondary -camera supplies an image displayed by an :class:`.ImageMobjectFromCamera` mobject. -This is live composition of the same scene, not separate Scene executions or separate -video outputs. This API is not supported by the OpenGL backend. +:class:`.MultiCamera`. The primary camera draws the overall scene; each +secondary camera supplies an image displayed by an +:class:`.ImageMobjectFromCamera` mobject. +During the execution of the scene, each camera records the scene from its own +view. This API is not supported by the OpenGL backend. There are two independent controls: * The secondary camera's ``frame`` selects the region to look at. Move it to pan, or shrink it to zoom in. -* The display mobject selects where that view appears in the primary scene. Move or - scale it like another mobject, without changing the secondary camera's view. +* The display mobject selects where that view appears in the primary scene, as a + "picture-in-picture" display. This mobject can be manipulated like any other. For example, this scene places two detail views above the original objects:: From 94ad6c236217e26e6df8f8087237e7d2b1665be0 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Tue, 8 Sep 2026 13:14:47 +0200 Subject: [PATCH 12/13] Clarify camera guides and snapshot documentation Address review feedback on camera terminology, backend controls, custom camera selection, snapshot behavior, and inset views. Preserve the earlier camera test cleanup. --- docs/source/guides/cameras.rst | 118 +++++++++++------- manim/mobject/mobject.py | 6 +- manim/renderer/cairo/camera.py | 54 ++++---- manim/scene/scene.py | 21 ++-- tests/test_camera.py | 28 +---- .../test_scene_rendering/test_scene_images.py | 9 +- 6 files changed, 129 insertions(+), 107 deletions(-) diff --git a/docs/source/guides/cameras.rst b/docs/source/guides/cameras.rst index dd21911ce6..44930dea93 100644 --- a/docs/source/guides/cameras.rst +++ b/docs/source/guides/cameras.rst @@ -39,26 +39,57 @@ frame with the usual mobject operations:: self.play(self.camera.auto_zoom([square])) self.play(Restore(self.camera.frame)) -:class:`.MovingCameraScene` remains available as a descriptive name for this behavior. -These frame examples describe the Cairo camera; OpenGL uses its own camera controls. +Moving the OpenGL camera +------------------------ + +With the OpenGL renderer, the camera itself is a mobject. Animate +``self.camera`` directly to pan or zoom:: + + class OpenGLCameraExample(Scene): + def construct(self): + square = Square().shift(2 * RIGHT) + self.add(square) + self.play(self.camera.animate.move_to(square)) + self.play(self.camera.animate.scale(0.5)) + +Run this example with ``--renderer=opengl``. For orientation controls, see +:class:`.OpenGLCamera` and :class:`.ThreeDScene`. + +Choosing a camera class +----------------------- + +To select a different Cairo camera, pass ``camera_class`` to the scene's +constructor. For example, :class:`.MultiCamera` supports picture-in-picture views:: + + class CustomCameraScene(Scene): + def __init__(self, **kwargs): + super().__init__(camera_class=MultiCamera, **kwargs) + +Use the same pattern with your own :class:`.Camera` subclass to customize its +settings or projection. The renderer creates the camera during scene +initialization, before ``setup()`` and ``construct()`` are called. Camera view and image resolution -------------------------------- The dimensions of the camera's frame and of the images output by the renderer are specified separately. Camera frame dimensions are defined in scene units, -while output dimensions are defined in pixels. You must configure pixel -dimensions before constructing the scene or renderer:: +while output dimensions are defined in pixels. The output image's rectangular +pixel area is called the *viewport*. Configure its pixel dimensions before +constructing a camera, scene, or renderer:: with tempconfig({"pixel_width": 640, "pixel_height": 360}): scene = Scene() scene.add(Square()) image = scene.get_image() -A default Cairo camera preserves ``config.frame_width`` and derives height from the -configured pixel aspect ratio. Square and portrait output therefore preserve ordinary -geometry. One explicit camera dimension determines the other using that aspect ratio; -two dimensions or a custom frame preserve the geometry you specify:: +A default Cairo camera uses ``config.frame_width`` for its width and derives its +height from the viewport's aspect ratio. This keeps circles circular and squares +square, including in square or portrait output. + +Passing only ``frame_width`` or ``frame_height`` to :class:`.Camera` derives the +other dimension from that same aspect ratio. Passing both dimensions or a custom +``frame`` uses the dimensions you specify:: camera = Camera(frame_width=8, frame_height=4) camera.frame.move_to([2, 1, 0]) @@ -81,22 +112,18 @@ It includes manual changes since the last animation and the current camera view: self.play(square.animate.shift(RIGHT)) self.get_image().save("after.png") -Use ``scene.show()`` to open a fresh image in PIL's external image viewer. In a notebook, -the returned PIL image is displayed directly. ``get_image()`` only generates the -snapshot; the image must be saved to disk explicitly. +Use ``scene.show()`` to open a fresh image in PIL's external image viewer. In a +notebook, call ``display(scene.get_image())`` or put ``scene.get_image()`` as the +cell's final expression. Saving the image to disk is explicit, as in the example. + +Request snapshots between animations or at an idle prompt to inspect the mobjects +as they currently stand. Animation playback and updaters run separately, so a +snapshot after ``self.play()`` shows the state after the animation has finished. +See :meth:`.Scene.get_image` for details on snapshot timing. -An image request does not execute construction, run updaters, advance scene time, or -append a movie frame. It photographs the graph as it stands, even if updater-derived -geometry has not yet been refreshed. The post-animation graph may differ from the last -encoded sample because animation finish/cleanup has already run. This is inspection, -not seeking or replaying an earlier animation position. +.. note:: -Request images between plays or at an idle prompt. OpenGL capture must run on the thread -that owns the rendering context; arbitrary worker-thread calls, including background -embedded-shell calls, are not dispatched automatically. Both Cairo and OpenGL draw into -independent temporary targets rather than replacing the active frame. Returned images -remain usable after those temporary targets are released. Explicit image requests also -work in dry-run mode; they are intentional raster work requested by your Python code. + For OpenGL, request snapshots on the thread that created the rendering context. Inspecting individual mobjects ------------------------------ @@ -108,7 +135,9 @@ For ordinary Cairo mobjects, use :meth:`.Mobject.get_image` or :meth:`.Mobject.s image = square.get_image(camera=self.camera) The ``camera`` parameter allows for a different camera to be used to generate -the image. Without it, the scene's default camera is used. +the image. Without it, a new default :class:`.Camera` is created. Only the +mobject and its submobjects are drawn; pass ``camera=self.camera`` to use the +scene's current view. These standalone helpers are Cairo-specific; use ``scene.get_image()`` for an OpenGL scene, including its meshes. @@ -136,8 +165,8 @@ The Cairo backend supports several camera views within one scene through :class:`.MultiCamera`. The primary camera draws the overall scene; each secondary camera supplies an image displayed by an :class:`.ImageMobjectFromCamera` mobject. -During the execution of the scene, each camera records the scene from its own -view. This API is not supported by the OpenGL backend. +During the execution of the scene, the renderer draws each camera's view into its +display mobject. This API is not supported by the OpenGL backend. There are two independent controls: @@ -178,32 +207,35 @@ For example, this scene places two detail views above the original objects:: self.play(right_camera.frame.animate.move_to(circle)) self.wait() -Run this example with ``--renderer=cairo``. Selecting ``MultiCamera`` in the constructor -ensures it is installed before the scene's renderer is initialized. +Run this example with ``--renderer=cairo``. -Both registration and scene membership matter: registering a display tells MultiCamera -to produce its view; ``self.add(view)`` places the display in the scene's draw order. -``add_display_frame()`` adds an optional visible border. The secondary camera's own -``frame`` is a view control and is not automatically shown as an outline in the scene. +Call ``self.camera.add_image_mobject_from_camera(view)`` to refresh the display's +image from its source camera on each draw, then ``self.add(view)`` to show it in +the scene. +``view.add_display_frame()`` adds the visible border around the display. To also +show the region that the secondary camera looks at, give its ``frame`` a visible +stroke and add it to the scene:: + + left_camera.frame.set_stroke(YELLOW, width=2) + self.add(left_camera.frame) A display initially matches its source camera's aspect ratio. Scale it uniformly to preserve that ratio; stretching only its width or height can distort the image. -The renderer chooses the secondary raster size from the display's size relative to -the primary view and manages resizing and pixel transfer automatically. +Each inset's pixel resolution follows its display size relative to the primary +camera frame. + +All cameras view the same scene contents. Each display and its border are excluded +from their own camera's view. In the example, both detail cameras look below the +insets, keeping the insets out of each other's views. -Secondary cameras share the scene's contents rather than having separate object lists. -Each display and its border are excluded from their own source view. In the example, -the detail cameras look below the insets so neither inset appears in the other. -Sibling views are processed in registration order; do not rely on them recursively -containing each other. For deeper nesting, a secondary camera may itself be a -MultiCamera with its own registered displays. Cyclic camera registrations are rejected -rather than rendered recursively forever. +For nested insets, use a :class:`.MultiCamera` as a secondary camera and register +its displays there. Keep this hierarchy acyclic: camera registrations that form +a cycle raise an error. Cameras registered at the same level are drawn in order; +place their displays outside each other's views, as above, for independent insets. To remove a view entirely, remove both its visible mobject and its registration:: self.remove(left_view) self.camera.image_mobjects_from_cameras.remove(left_view) -The renderer retires unused secondary targets on the next draw. A scene image requested -with ``self.get_image()`` includes all currently registered and visible views; there is -no need to copy camera pixels or refresh each inset yourself. +``self.get_image()`` captures the scene together with its current inset views. diff --git a/manim/mobject/mobject.py b/manim/mobject/mobject.py index 623598ca82..7f43655700 100644 --- a/manim/mobject/mobject.py +++ b/manim/mobject/mobject.py @@ -997,7 +997,11 @@ def apply_over_attr_arrays(self, func: MultiMappingFunction) -> Self: # Displaying def get_image(self, camera: Camera | None = None) -> Image.Image: - """Render this mobject with an explicit temporary Cairo renderer.""" + """Draw this mobject and its submobjects using Cairo and return a PIL image. + + Pass a ``camera`` to select the view, or omit it to create a default + :class:`.Camera`. To use a scene's current view, pass ``camera=scene.camera``. + """ from manim.renderer.cairo import CairoRenderer from manim.renderer.cairo.camera import Camera diff --git a/manim/renderer/cairo/camera.py b/manim/renderer/cairo/camera.py index b7b8090e3f..159c204ea6 100644 --- a/manim/renderer/cairo/camera.py +++ b/manim/renderer/cairo/camera.py @@ -1,4 +1,4 @@ -"""Semantic camera implementations for the Cairo rendering backend.""" +"""Camera views and projection controls for the Cairo renderer.""" from __future__ import annotations @@ -41,13 +41,13 @@ class _CameraFrame(ScreenRectangle): - """Default frame with a direct bounding-box center query, not a cached center.""" + """Frame whose center is computed directly from its boundary points.""" def get_points_defining_boundary(self) -> Point3D_Array: if self.submobjects or len(self.points) <= 1: return super().get_points_defining_boundary() - # Same start/end anchors as VMobject, without family/list assembly for - # the ordinary childless frame. Do not assume it remains rectangular. + # Use VMobject's boundary anchors directly for a frame without submobjects. + # These also give the bounds when the frame has been deformed. curves = self.points.reshape( -1, self.n_points_per_cubic_curve, self.points.shape[1] ) @@ -64,14 +64,16 @@ def get_center(self) -> Point3D: class Camera: - """Describe the logical view used by a rendering backend. - - Camera owns an animatable frame, semantic background settings, display ordering, - and pure point transformations. Raster targets, pixel dimensions, image buffers, - and backend contexts belong to renderers. With no explicit frame dimensions, - the configured logical width is preserved and height follows the configured - output aspect ratio. Supplying one dimension derives the other from that ratio; - supplying both dimensions or a custom frame preserves the requested geometry. + """Configure the camera view and background for Cairo rendering. + + A camera has an animatable frame, background settings, and methods for ordering + and projecting mobjects. The renderer is responsible for drawing this view + into an image. + + By default, the frame width is ``config.frame_width`` and its height is derived + from the viewport's pixel aspect ratio. Supplying one frame dimension derives + the other from that ratio; supplying both dimensions or a custom ``frame`` uses + the dimensions you specify. Frame dimensions are measured in Manim units. """ def __init__( @@ -156,7 +158,7 @@ def background_opacity(self, alpha: float) -> None: @property def frame_height(self) -> float: - """Height of the logical camera frame in Manim units.""" + """Height of the camera frame in Manim units.""" return self.frame.height @frame_height.setter @@ -165,7 +167,7 @@ def frame_height(self, frame_height: float) -> None: @property def frame_width(self) -> float: - """Width of the logical camera frame in Manim units.""" + """Width of the camera frame in Manim units.""" return self.frame.width @frame_width.setter @@ -174,7 +176,7 @@ def frame_width(self, frame_width: float) -> None: @property def frame_center(self) -> Point3D: - """Center of the logical camera frame.""" + """Center of the camera frame in scene coordinates.""" return self.frame.get_center() @frame_center.setter @@ -187,7 +189,7 @@ def get_mobjects_to_display( include_submobjects: bool = True, excluded_mobjects: list[Mobject] | None = None, ) -> list[Mobject]: - """Return the camera-ordered family members visible to the renderer.""" + """Return the mobjects and included submobjects in drawing order.""" if include_submobjects: mobjects = extract_mobject_family_members( mobjects, @@ -203,7 +205,7 @@ def get_mobjects_to_display( return list(mobjects) def is_in_frame(self, mobject: Mobject) -> bool: - """Whether ``mobject`` intersects the logical frame bounds.""" + """Whether ``mobject`` intersects the camera frame's bounds.""" center = self.frame_center height = self.frame_height width = self.frame_width @@ -218,7 +220,11 @@ def is_in_frame(self, mobject: Mobject) -> bool: ) def get_mobjects_indicating_movement(self) -> list[Mobject]: - """Camera controls whose animation changes every projected pixel.""" + """Return mobjects whose animation requires the scene to be redrawn. + + The scene uses these controls to decide whether it can reuse an image of + its stationary mobjects during an animation. + """ return [self.frame] @overload @@ -296,7 +302,7 @@ def _get_bounding_box( return bounds def _prepare_for_render(self) -> None: - """Refresh derived semantic view state before a renderer borrows it.""" + """Update derived camera values before drawing.""" def get_view_transform_center(self) -> Point3D: """Return the center applied by the renderer's 2D view transform.""" @@ -307,11 +313,11 @@ def get_stroke_rgbas( vmobject: VMobject, background: bool = False, ) -> FloatRGBA_Array: - """Return stroke colors after camera-specific semantic shading.""" + """Return stroke colors after camera-specific shading.""" return vmobject.get_stroke_rgbas(background) def get_fill_rgbas(self, vmobject: VMobject) -> FloatRGBA_Array: - """Return fill colors after camera-specific semantic shading.""" + """Return fill colors after camera-specific shading.""" return vmobject.get_fill_rgbas() def transform_points_pre_display( @@ -319,7 +325,7 @@ def transform_points_pre_display( mobject: Mobject, points: Point3D_Array, ) -> Point3D_Array: - """Apply camera-specific pure projection before display.""" + """Project the mobject's points for display.""" if not np.all(np.isfinite(points)): return np.zeros((1, 3)) return points @@ -347,13 +353,13 @@ def add_image_mobject_from_camera( self, image_mobject_from_camera: ImageMobjectFromCamera, ) -> None: - """Register a camera-backed image for renderer-owned composition.""" + """Register a display whose image is rendered from another camera.""" if not isinstance(image_mobject_from_camera.camera, Camera): raise TypeError("Nested Cairo views require a Cairo Camera.") self.image_mobjects_from_cameras.append(image_mobject_from_camera) def get_mobjects_indicating_movement(self) -> list[Mobject]: - """Return controls whose movement changes a primary or nested view.""" + """Return camera controls for the primary and nested views.""" def collect(camera: Camera, visited: set[int]) -> list[Mobject]: if id(camera) in visited: diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 6d059a00c3..dd4d16e4b2 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -323,18 +323,25 @@ def render(self, preview: bool = False) -> bool: return self._get_manager().render(preview) def get_image(self) -> Image: - """Draw the current scene and return an independent PIL image. + """Return a snapshot of the scene's current mobjects as a PIL image. - Uses the scene's camera and renderer dimensions, including manual changes - made since the last animation. Does not run updaters, advance time, execute - construction, or append a movie frame. Saving or displaying is explicit:: + The snapshot uses the current camera view, including manual changes since + the last animation, and the pixel dimensions chosen when the renderer was + created. Call between animations or at an idle prompt:: self.add(Square()) self.get_image().save("checkpoint.png") - Call between animations or at an idle prompt. OpenGL requests must run on - the render/context thread. This is current-state inspection, not seeking - to an earlier animation sample or reading the last movie frame. + Notes + ----- + A snapshot leaves animations, updaters, and scene time unchanged. It draws + the existing mobjects without calling :meth:`construct` or appending a + movie frame. After :meth:`play`, it shows the result of animation finish + and cleanup, which can differ from the last frame written to the video. + + The returned image retains its pixels after the renderer is closed. + OpenGL snapshots must be requested on the thread that created the + rendering context. """ return self._get_manager().get_image() diff --git a/tests/test_camera.py b/tests/test_camera.py index 1988151c66..461547bf9b 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -37,11 +37,9 @@ def test_movingcamera_auto_zoom(): assert camera.frame.height == square.height + margin -def test_default_camera_is_movable_and_resource_free(): +def test_default_camera_is_movable(): camera = Camera() - assert not hasattr(camera, "pixel_array") - assert not hasattr(camera, "capture_mobjects") camera.frame.move_to([2, 1, 0]).set(width=6) assert camera.frame_center.tolist() == [2, 1, 0] @@ -103,25 +101,6 @@ def test_camera_resolves_only_unspecified_dimensions( assert camera.frame_height == pytest.approx(expected_height) -@pytest.mark.parametrize( - "removed_setting", - [ - {"pixel_width": 100}, - {"frame_rate": 30}, - {"cairo_line_width_multiple": 0.02}, - {"fixed_dimension": 1}, - ], -) -def test_camera_rejects_removed_raster_settings(removed_setting): - with pytest.raises(TypeError, match="unexpected keyword argument"): - Camera(**removed_setting) - - -def test_renderer_rejects_unknown_constructor_settings(): - with pytest.raises(TypeError, match="unexpected keyword argument"): - CairoRenderer(pixel_width=100) - - def test_default_scene_camera_auto_zoom(): with tempconfig({"dry_run": True, "quality": "low_quality"}): scene = Scene() @@ -141,14 +120,12 @@ def test_mobject_get_image_uses_temporary_renderer(): assert np.any(pixels[:, :, 2] > 0) -def test_camera_backed_image_constructs_without_camera_pixels(): +def test_camera_backed_image_preserves_camera_aspect(): camera = Camera() image = ImageMobjectFromCamera(camera) assert image.camera is camera - assert not hasattr(image, "pixel_array") - assert "get_pixel_array" not in type(image).__dict__ assert image.width / image.height == pytest.approx( camera.frame_width / camera.frame_height, ) @@ -184,7 +161,6 @@ def test_background_image_is_loaded_by_renderer(tmp_path): with tempconfig({"pixel_width": 2, "pixel_height": 2}): camera = Camera(background_image=str(image_path)) - assert not hasattr(camera, "background") renderer = CairoRenderer(camera=camera) try: renderer.update_frame(None, mobjects=[Mobject()]) diff --git a/tests/test_scene_rendering/test_scene_images.py b/tests/test_scene_rendering/test_scene_images.py index a3edcb099e..7f17dccefa 100644 --- a/tests/test_scene_rendering/test_scene_images.py +++ b/tests/test_scene_rendering/test_scene_images.py @@ -122,10 +122,9 @@ def test_cairo_snapshot_camera_and_nested_view_parity(scene_class): scene.renderer.close() +@pytest.mark.parametrize("image_scene", ["opengl"], indirect=True) def test_opengl_snapshot_restores_target_on_failure(image_scene, monkeypatch): renderer = image_scene.renderer - if not hasattr(renderer, "context"): - pytest.skip("OpenGL-specific resource test") target = renderer.frame_buffer_object viewport = renderer.context.viewport elapsed = renderer.animation_elapsed_time @@ -145,9 +144,8 @@ def fail(scene): assert image_scene.get_image().size == (128, 128) +@pytest.mark.parametrize("image_scene", ["opengl"], indirect=True) def test_opengl_snapshot_includes_meshes(image_scene): - if not hasattr(image_scene.renderer, "context"): - pytest.skip("OpenGL-specific mesh test") from manim.renderer.opengl.shader import Mesh, Shader shader = Shader( @@ -175,9 +173,8 @@ def test_opengl_snapshot_includes_meshes(image_scene): np.testing.assert_array_equal(pixels, image_scene.renderer.get_frame()) +@pytest.mark.parametrize("image_scene", ["opengl"], indirect=True) def test_opengl_snapshot_requires_owner_thread(image_scene): - if not hasattr(image_scene.renderer, "context"): - pytest.skip("OpenGL-specific thread test") with ThreadPoolExecutor(1) as pool: future = pool.submit(image_scene.get_image) with pytest.raises(RuntimeError, match="render thread"): From 5623b1f5ccf5a9eb2cf3b523e844366bc0736bda Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Wed, 9 Sep 2026 20:00:39 +0200 Subject: [PATCH 13/13] Clarify camera image rendering and inset documentation --- docs/source/guides/cameras.rst | 24 +++++++++++-------- docs/source/guides/deep_dive.rst | 40 ++++++++++++++------------------ 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/docs/source/guides/cameras.rst b/docs/source/guides/cameras.rst index 44930dea93..93551d8bbc 100644 --- a/docs/source/guides/cameras.rst +++ b/docs/source/guides/cameras.rst @@ -134,10 +134,13 @@ For ordinary Cairo mobjects, use :meth:`.Mobject.get_image` or :meth:`.Mobject.s Group(Square().shift(LEFT), Circle().shift(RIGHT)).get_image().save("objects.png") image = square.get_image(camera=self.camera) +These methods render an image from the view of a camera such that only the +chosen mobject and its submobjects are drawn; anything else in the scene is +ignored. + The ``camera`` parameter allows for a different camera to be used to generate -the image. Without it, a new default :class:`.Camera` is created. Only the -mobject and its submobjects are drawn; pass ``camera=self.camera`` to use the -scene's current view. +the image. Without it, a new default :class:`.Camera` is created. To use the +view of the current camera, pass ``camera=self.camera``. These standalone helpers are Cairo-specific; use ``scene.get_image()`` for an OpenGL scene, including its meshes. @@ -149,8 +152,8 @@ Use :class:`.ThreeDScene` and its camera orientation methods for three-dimension scenes. Image inspection uses the current projection and fixed-object declarations, just like ordinary drawing. -For an inset magnified view, :class:`.ZoomedScene` provides the camera and display -relationship:: +:class:`.ZoomedScene` sets up a secondary camera and an inset display to show a +magnified region of the scene:: class DetailExample(ZoomedScene): def construct(self): @@ -212,15 +215,16 @@ Run this example with ``--renderer=cairo``. Call ``self.camera.add_image_mobject_from_camera(view)`` to refresh the display's image from its source camera on each draw, then ``self.add(view)`` to show it in the scene. -``view.add_display_frame()`` adds the visible border around the display. To also -show the region that the secondary camera looks at, give its ``frame`` a visible -stroke and add it to the scene:: +``view.add_display_frame()`` adds a visible border around the display. +To show the region which the secondary camera is currently looking at, give its +``frame`` a visible stroke and add it to the scene:: left_camera.frame.set_stroke(YELLOW, width=2) self.add(left_camera.frame) -A display initially matches its source camera's aspect ratio. Scale it uniformly to -preserve that ratio; stretching only its width or height can distort the image. +A display initially matches the aspect ratio of its source camera. When resizing +this display, make sure it is scaled uniformly to preserve its aspect ratio; +stretching only its width or height can distort the image. Each inset's pixel resolution follows its display size relative to the primary camera frame. diff --git a/docs/source/guides/deep_dive.rst b/docs/source/guides/deep_dive.rst index 768f8292a7..b507a12757 100644 --- a/docs/source/guides/deep_dive.rst +++ b/docs/source/guides/deep_dive.rst @@ -998,34 +998,30 @@ the *static mobjects* are assumed to have already been painted statically to the background of the scene). All of the hard work then happens when the renderer updates its current frame via a call to :meth:`.CairoRenderer.update_frame`: -First, the renderer prepares its own Cairo raster target. If a reusable -``static_image`` is available, the renderer copies it into that target; otherwise it -resets the target from the semantic background settings of :class:`.Camera`. -Background images whose dimensions differ from the target are resized to the target -dimensions. The camera itself does not own pixels or a Cairo context; its constructor -accepts semantic view settings rather than pixel dimensions or frame-rate options. +First, the renderer prepares its Cairo image buffer. If a reusable ``static_image`` +is available, the renderer copies it into that buffer; otherwise it resets the buffer +using the camera's background color or image. Background images are resized to match +the buffer's dimensions. Things get a bit technical here, and at some point it is more efficient to delve into -the implementation -- but the renderer-owned drawing process can be summarized as -follows: +the implementation -- but the drawing process can be summarized as follows: -- The camera supplies a flat, ordered list of visible mobjects and applies pure +- The camera supplies a flat, ordered list of visible mobjects and applies view/projection and shading transformations. Its animatable ``frame`` describes the - logical region being viewed. + region being viewed. - Private Cairo renderer helpers process consecutive batches of vectorized, point cloud, and image mobjects without changing their draw order. - Vectorized mobjects are converted to Cairo paths and drawn with their background stroke, fill, and foreground stroke. Point clouds and image mobjects are converted to target pixel coordinates and composited by renderer helpers. -- A :class:`.MultiCamera` describes nested camera-backed views. Their - :class:`.ImageMobjectFromCamera` display mobjects contain geometry and sampling - settings but no placeholder or live pixels. The renderer creates secondary targets - lazily, excludes each view's own display from its source camera, and composites the - result into the primary target. - -After all batches have been processed, :class:`.CairoRenderer` owns the image -representation of the Scene. It passes a fresh top-left-origin, C-contiguous ``uint8`` -RGBA array to its :class:`.SceneFileWriter`. This concludes one iteration of the +- With a :class:`.MultiCamera`, the renderer draws each secondary camera's view into + a separate image buffer, excluding that camera's own display mobject. It then draws + the resulting image in the corresponding :class:`.ImageMobjectFromCamera` in the + primary view. + +After all batches have been processed, :meth:`.CairoRenderer.get_frame` copies the +rendered image into a top-left-origin, C-contiguous ``uint8`` RGBA array. The renderer +passes this array to :class:`.SceneFileWriter`. This concludes one iteration of the render loop, and once the time progression has been processed completely, a final bit of cleanup is performed before the :meth:`.Scene.play_internal` call is completed. @@ -1042,9 +1038,9 @@ A TL;DR for the render loop, in the context of our toy example, reads as follows ``alpha = 0.5``). - Then the scene asks the renderer to do its job. The only mobject that needs to be processed at this point is the main mobject attached to the transformation. - The camera supplies its semantic view transform, while renderer-owned Cairo - helpers draw the current mobject state into the renderer's raster target. The - renderer reads that target and passes an owned array to the file writer. + The camera supplies its view transform, while Cairo drawing helpers draw the + transformed square into the renderer's image buffer. The renderer passes a copy + of that buffer's RGBA pixels to the file writer. - At the end of the loop, 90 frames have been passed to the file writer. Completing the render loop