Merge pull request #1971 from 3b1b/video-work

Video work
This commit is contained in:
Grant Sanderson
2023-01-25 09:54:55 -08:00
committed by GitHub
9 changed files with 169 additions and 80 deletions

View File

@ -26,6 +26,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from manimlib.shader_wrapper import ShaderWrapper from manimlib.shader_wrapper import ShaderWrapper
from manimlib.typing import ManimColor, Vect3 from manimlib.typing import ManimColor, Vect3
from manimlib.window import Window
from typing import Any, Iterable from typing import Any, Iterable
class CameraFrame(Mobject): class CameraFrame(Mobject):
@ -39,7 +40,7 @@ class CameraFrame(Mobject):
self.frame_shape = frame_shape self.frame_shape = frame_shape
self.center_point = center_point self.center_point = center_point
self.focal_dist_to_height = focal_dist_to_height self.focal_dist_to_height = focal_dist_to_height
self.perspective_transform = np.identity(4) self.view_matrix = np.identity(4)
super().__init__(**kwargs) super().__init__(**kwargs)
def init_uniforms(self) -> None: def init_uniforms(self) -> None:
@ -83,12 +84,12 @@ class CameraFrame(Mobject):
def get_inverse_camera_rotation_matrix(self): def get_inverse_camera_rotation_matrix(self):
return self.get_orientation().as_matrix().T return self.get_orientation().as_matrix().T
def get_perspective_transform(self): def get_view_matrix(self):
""" """
Returns a 4x4 for the affine transformation mapping a point Returns a 4x4 for the affine transformation mapping a point
into the camera's internal coordinate system into the camera's internal coordinate system
""" """
result = self.perspective_transform result = self.view_matrix
result[:] = np.identity(4) result[:] = np.identity(4)
result[:3, 3] = -self.get_center() result[:3, 3] = -self.get_center()
rotation = np.identity(4) rotation = np.identity(4)
@ -187,7 +188,7 @@ class CameraFrame(Mobject):
class Camera(object): class Camera(object):
def __init__( def __init__(
self, self,
ctx: moderngl.Context | None = None, window: Window | None = None,
background_image: str | None = None, background_image: str | None = None,
frame_config: dict = dict(), frame_config: dict = dict(),
pixel_width: int = DEFAULT_PIXEL_WIDTH, pixel_width: int = DEFAULT_PIXEL_WIDTH,
@ -209,8 +210,8 @@ class Camera(object):
samples: int = 0, samples: int = 0,
): ):
self.background_image = background_image self.background_image = background_image
self.pixel_width = pixel_width self.window = window
self.pixel_height = pixel_height self.default_pixel_shape = (pixel_width, pixel_height)
self.fps = fps self.fps = fps
self.max_allowable_norm = max_allowable_norm self.max_allowable_norm = max_allowable_norm
self.image_mode = image_mode self.image_mode = image_mode
@ -225,7 +226,7 @@ class Camera(object):
)) ))
self.perspective_uniforms = dict() self.perspective_uniforms = dict()
self.init_frame(**frame_config) self.init_frame(**frame_config)
self.init_context(ctx) self.init_context(window)
self.init_shaders() self.init_shaders()
self.init_textures() self.init_textures()
self.init_light_source() self.init_light_source()
@ -238,21 +239,20 @@ class Camera(object):
def init_frame(self, **config) -> None: def init_frame(self, **config) -> None:
self.frame = CameraFrame(**config) self.frame = CameraFrame(**config)
def init_context(self, ctx: moderngl.Context | None = None) -> None: def init_context(self, window: Window | None = None) -> None:
if ctx is None: if window is None:
ctx = moderngl.create_standalone_context() self.ctx = moderngl.create_standalone_context()
fbo = self.get_fbo(ctx, self.samples) self.fbo = self.get_fbo(self.samples)
else: else:
fbo = ctx.detect_framebuffer() self.ctx = window.ctx
self.ctx = ctx self.fbo = self.ctx.detect_framebuffer()
self.fbo = fbo
self.fbo.use() self.fbo.use()
self.set_ctx_blending() self.set_ctx_blending()
self.ctx.enable(moderngl.PROGRAM_POINT_SIZE) self.ctx.enable(moderngl.PROGRAM_POINT_SIZE)
# This is the frame buffer we'll draw into when emitting frames # This is the frame buffer we'll draw into when emitting frames
self.draw_fbo = self.get_fbo(ctx, 0) self.draw_fbo = self.get_fbo(samples=0)
def set_ctx_blending(self, enable: bool = True) -> None: def set_ctx_blending(self, enable: bool = True) -> None:
if enable: if enable:
@ -269,8 +269,6 @@ class Camera(object):
def set_ctx_clip_plane(self, enable: bool = True) -> None: def set_ctx_clip_plane(self, enable: bool = True) -> None:
if enable: if enable:
gl.glEnable(gl.GL_CLIP_DISTANCE0) gl.glEnable(gl.GL_CLIP_DISTANCE0)
else:
gl.glDisable(gl.GL_CLIP_DISTANCE0)
def init_light_source(self) -> None: def init_light_source(self) -> None:
self.light_source = Point(self.light_source_position) self.light_source = Point(self.light_source_position)
@ -278,19 +276,16 @@ class Camera(object):
# Methods associated with the frame buffer # Methods associated with the frame buffer
def get_fbo( def get_fbo(
self, self,
ctx: moderngl.Context,
samples: int = 0 samples: int = 0
) -> moderngl.Framebuffer: ) -> moderngl.Framebuffer:
pw = self.pixel_width return self.ctx.framebuffer(
ph = self.pixel_height color_attachments=self.ctx.texture(
return ctx.framebuffer( self.default_pixel_shape,
color_attachments=ctx.texture(
(pw, ph),
components=self.n_channels, components=self.n_channels,
samples=samples, samples=samples,
), ),
depth_attachment=ctx.depth_renderbuffer( depth_attachment=self.ctx.depth_renderbuffer(
(pw, ph), self.default_pixel_shape,
samples=samples samples=samples
) )
) )
@ -298,17 +293,19 @@ class Camera(object):
def clear(self) -> None: def clear(self) -> None:
self.fbo.clear(*self.background_rgba) self.fbo.clear(*self.background_rgba)
def reset_pixel_shape(self, new_width: int, new_height: int) -> None:
self.pixel_width = new_width
self.pixel_height = new_height
self.refresh_perspective_uniforms()
def get_raw_fbo_data(self, dtype: str = 'f1') -> bytes: def get_raw_fbo_data(self, dtype: str = 'f1') -> bytes:
# Copy blocks from fbo into draw_fbo using Blit # Copy blocks from fbo into draw_fbo using Blit
pw, ph = (self.pixel_width, self.pixel_height)
gl.glBindFramebuffer(gl.GL_READ_FRAMEBUFFER, self.fbo.glo) gl.glBindFramebuffer(gl.GL_READ_FRAMEBUFFER, self.fbo.glo)
gl.glBindFramebuffer(gl.GL_DRAW_FRAMEBUFFER, self.draw_fbo.glo) gl.glBindFramebuffer(gl.GL_DRAW_FRAMEBUFFER, self.draw_fbo.glo)
gl.glBlitFramebuffer(0, 0, pw, ph, 0, 0, pw, ph, gl.GL_COLOR_BUFFER_BIT, gl.GL_LINEAR) if self.window is not None:
src_viewport = self.window.viewport
else:
src_viewport = self.fbo.viewport
gl.glBlitFramebuffer(
*src_viewport,
*self.draw_fbo.viewport,
gl.GL_COLOR_BUFFER_BIT, gl.GL_LINEAR
)
return self.draw_fbo.read( return self.draw_fbo.read(
viewport=self.draw_fbo.viewport, viewport=self.draw_fbo.viewport,
components=self.n_channels, components=self.n_channels,
@ -326,7 +323,7 @@ class Camera(object):
def get_pixel_array(self) -> np.ndarray: def get_pixel_array(self) -> np.ndarray:
raw = self.get_raw_fbo_data(dtype='f4') raw = self.get_raw_fbo_data(dtype='f4')
flat_arr = np.frombuffer(raw, dtype='f4') flat_arr = np.frombuffer(raw, dtype='f4')
arr = flat_arr.reshape([*reversed(self.fbo.size), self.n_channels]) arr = flat_arr.reshape([*reversed(self.draw_fbo.size), self.n_channels])
arr = arr[::-1] arr = arr[::-1]
# Convert from float # Convert from float
return (self.rgb_max_val * arr).astype(self.pixel_array_dtype) return (self.rgb_max_val * arr).astype(self.pixel_array_dtype)
@ -342,9 +339,11 @@ class Camera(object):
return texture return texture
# Getting camera attributes # Getting camera attributes
def get_pixel_size(self) -> float:
return self.frame.get_shape()[0] / self.get_pixel_shape()[0]
def get_pixel_shape(self) -> tuple[int, int]: def get_pixel_shape(self) -> tuple[int, int]:
return self.fbo.viewport[2:4] return self.draw_fbo.size
# return (self.pixel_width, self.pixel_height)
def get_pixel_width(self) -> int: def get_pixel_width(self) -> int:
return self.get_pixel_shape()[0] return self.get_pixel_shape()[0]
@ -352,6 +351,10 @@ class Camera(object):
def get_pixel_height(self) -> int: def get_pixel_height(self) -> int:
return self.get_pixel_shape()[1] return self.get_pixel_shape()[1]
def get_aspect_ratio(self):
pw, ph = self.get_pixel_shape()
return pw / ph
def get_frame_height(self) -> float: def get_frame_height(self) -> float:
return self.frame.get_height() return self.frame.get_height()
@ -374,17 +377,15 @@ class Camera(object):
whether frame_height or frame_width whether frame_height or frame_width
remains fixed while the other changes accordingly. remains fixed while the other changes accordingly.
""" """
pixel_height = self.get_pixel_height()
pixel_width = self.get_pixel_width()
frame_height = self.get_frame_height() frame_height = self.get_frame_height()
frame_width = self.get_frame_width() frame_width = self.get_frame_width()
aspect_ratio = fdiv(pixel_width, pixel_height) aspect_ratio = self.get_aspect_ratio()
if not fixed_dimension: if not fixed_dimension:
frame_height = frame_width / aspect_ratio frame_height = frame_width / aspect_ratio
else: else:
frame_width = aspect_ratio * frame_height frame_width = aspect_ratio * frame_height
self.frame.set_height(frame_height) self.frame.set_height(frame_height, stretch=true)
self.frame.set_width(frame_width) self.frame.set_width(frame_width, stretch=true)
# Rendering # Rendering
def capture(self, *mobjects: Mobject) -> None: def capture(self, *mobjects: Mobject) -> None:
@ -444,9 +445,10 @@ class Camera(object):
# Program and vertex array # Program and vertex array
shader_program, vert_format = self.get_shader_program(shader_wrapper) shader_program, vert_format = self.get_shader_program(shader_wrapper)
attributes = shader_wrapper.vert_attributes
vao = self.ctx.vertex_array( vao = self.ctx.vertex_array(
program=shader_program, program=shader_program,
content=[(vbo, vert_format, *shader_wrapper.vert_attributes)], content=[(vbo, vert_format, *attributes)],
index_buffer=ibo, index_buffer=ibo,
) )
return { return {
@ -502,16 +504,14 @@ class Camera(object):
def refresh_perspective_uniforms(self) -> None: def refresh_perspective_uniforms(self) -> None:
frame = self.frame frame = self.frame
# Orient light view_matrix = frame.get_view_matrix()
perspective_transform = frame.get_perspective_transform()
light_pos = self.light_source.get_location() light_pos = self.light_source.get_location()
cam_pos = self.frame.get_implied_camera_location() cam_pos = self.frame.get_implied_camera_location()
frame_shape = frame.get_shape()
self.perspective_uniforms.update( self.perspective_uniforms.update(
frame_shape=frame_shape, frame_shape=frame.get_shape(),
pixel_size=frame_shape[0] / self.get_pixel_shape()[0], pixel_size=self.get_pixel_size(),
perspective=tuple(perspective_transform.T.flatten()), view=tuple(view_matrix.T.flatten()),
camera_position=tuple(cam_pos), camera_position=tuple(cam_pos),
light_position=tuple(light_pos), light_position=tuple(light_pos),
focal_distance=frame.get_focal_distance(), focal_distance=frame.get_focal_distance(),

View File

@ -414,9 +414,10 @@ def get_window_config(args: Namespace, custom_config: dict, camera_config: dict)
if not (args.full_screen or custom_config["full_screen"]): if not (args.full_screen or custom_config["full_screen"]):
window_width //= 2 window_width //= 2
window_height = int(window_width / aspect_ratio) window_height = int(window_width / aspect_ratio)
return { return dict(
"size": (window_width, window_height), full_size=(camera_config["pixel_width"], camera_config["pixel_height"]),
} size=(window_width, window_height),
)
def get_camera_config(args: Namespace, custom_config: dict) -> dict: def get_camera_config(args: Namespace, custom_config: dict) -> dict:

View File

@ -27,16 +27,19 @@ from manimlib.constants import OUT
from manimlib.constants import PI from manimlib.constants import PI
from manimlib.constants import RED from manimlib.constants import RED
from manimlib.constants import RIGHT from manimlib.constants import RIGHT
from manimlib.constants import RIGHT
from manimlib.constants import SMALL_BUFF from manimlib.constants import SMALL_BUFF
from manimlib.constants import SMALL_BUFF from manimlib.constants import SMALL_BUFF
from manimlib.constants import UP from manimlib.constants import UP
from manimlib.constants import UP from manimlib.constants import UL
from manimlib.constants import UR
from manimlib.constants import DL
from manimlib.constants import DR
from manimlib.constants import WHITE from manimlib.constants import WHITE
from manimlib.constants import YELLOW from manimlib.constants import YELLOW
from manimlib.mobject.boolean_ops import Difference from manimlib.mobject.boolean_ops import Difference
from manimlib.mobject.geometry import Arc from manimlib.mobject.geometry import Arc
from manimlib.mobject.geometry import Circle from manimlib.mobject.geometry import Circle
from manimlib.mobject.geometry import Dot
from manimlib.mobject.geometry import Line from manimlib.mobject.geometry import Line
from manimlib.mobject.geometry import Polygon from manimlib.mobject.geometry import Polygon
from manimlib.mobject.geometry import Rectangle from manimlib.mobject.geometry import Rectangle
@ -577,3 +580,50 @@ class Piano3D(VGroup):
if piano_2d[i] in piano_2d.black_keys: if piano_2d[i] in piano_2d.black_keys:
key.shift(black_key_shift * OUT) key.shift(black_key_shift * OUT)
key.set_color(BLACK) key.set_color(BLACK)
class DieFace(VGroup):
def __init__(
self,
value: int,
side_length: float = 1.0,
corner_radius: float = 0.15,
stroke_color: ManimColor = WHITE,
stroke_width: float = 2.0,
fill_color: ManimColor = GREY_E,
dot_radius: float = 0.08,
dot_color: ManimColor = BLUE_B,
dot_coalesce_factor: float = 0.5
):
dot = Dot(radius=dot_radius, fill_color=dot_color)
square = Square(
side_length=side_length,
stroke_color=stroke_color,
stroke_width=stroke_width,
fill_color=fill_color,
fill_opacity=1.0,
)
square.round_corners(corner_radius)
if not (1 <= value <= 6):
raise Exception("DieFace only accepts integer inputs between 1 and 6")
edge_group = [
(ORIGIN,),
(UL, DR),
(UL, ORIGIN, DR),
(UL, UR, DL, DR),
(UL, UR, ORIGIN, DL, DR),
(UL, UR, LEFT, RIGHT, DL, DR),
][value - 1]
arrangement = VGroup(*(
dot.copy().move_to(square.get_bounding_box_point(vect))
for vect in edge_group
))
arrangement.space_out_submobjects(dot_coalesce_factor)
super().__init__(square, arrangement)
self.value = value
self.index = value

View File

@ -68,8 +68,8 @@ class InteractiveScene(Scene):
""" """
corner_dot_config = dict( corner_dot_config = dict(
color=WHITE, color=WHITE,
radius=0.05, radius=0.025,
glow_factor=1.0, glow_factor=2.0,
) )
selection_rectangle_stroke_color = WHITE selection_rectangle_stroke_color = WHITE
selection_rectangle_stroke_width = 1.0 selection_rectangle_stroke_width = 1.0

View File

@ -101,7 +101,7 @@ class Scene(object):
if self.preview: if self.preview:
from manimlib.window import Window from manimlib.window import Window
self.window = Window(scene=self, **self.window_config) self.window = Window(scene=self, **self.window_config)
self.camera_config["ctx"] = self.window.ctx self.camera_config["window"] = self.window
self.camera_config["fps"] = 30 # Where's that 30 from? self.camera_config["fps"] = 30 # Where's that 30 from?
else: else:
self.window = None self.window = None
@ -289,7 +289,15 @@ class Scene(object):
# Only these methods should touch the camera # Only these methods should touch the camera
def get_image(self) -> Image: def get_image(self) -> Image:
return self.camera.get_image() if self.window is not None:
self.window.size = self.camera.get_pixel_shape()
self.window.swap_buffers()
self.update_frame()
self.window.swap_buffers()
image = self.camera.get_image()
if self.window is not None:
self.window.to_default_position()
return image
def show(self) -> None: def show(self) -> None:
self.update_frame(ignore_skipping=True) self.update_frame(ignore_skipping=True)
@ -711,7 +719,7 @@ class Scene(object):
self.restore_state(self.redo_stack.pop()) self.restore_state(self.redo_stack.pop())
self.refresh_static_mobjects() self.refresh_static_mobjects()
def checkpoint_paste(self, skip: bool = False): def checkpoint_paste(self, skip: bool = False, record: bool = False):
""" """
Used during interactive development to run (or re-run) Used during interactive development to run (or re-run)
a block of scene code. a block of scene code.
@ -721,7 +729,7 @@ class Scene(object):
was called on a block of code starting with that comment. was called on a block of code starting with that comment.
""" """
shell = get_ipython() shell = get_ipython()
if shell is None: if shell is None or self.window is None:
raise Exception( raise Exception(
"Scene.checkpoint_paste cannot be called outside of " + "Scene.checkpoint_paste cannot be called outside of " +
"an ipython shell" "an ipython shell"
@ -738,8 +746,20 @@ class Scene(object):
prev_skipping = self.skip_animations prev_skipping = self.skip_animations
self.skip_animations = skip self.skip_animations = skip
if record:
# Resize window so rendering happens at the appropriate size
self.window.size = self.camera.get_pixel_shape()
self.window.swap_buffers()
self.update_frame()
self.file_writer.begin_insert()
shell.run_cell(pasted) shell.run_cell(pasted)
if record:
self.file_writer.end_insert()
# Put window back to how it started
self.window.to_default_position()
self.skip_animations = prev_skipping self.skip_animations = prev_skipping
def checkpoint(self, key: str): def checkpoint(self, key: str):
@ -901,7 +921,7 @@ class Scene(object):
self.hold_on_wait = False self.hold_on_wait = False
def on_resize(self, width: int, height: int) -> None: def on_resize(self, width: int, height: int) -> None:
self.camera.reset_pixel_shape(width, height) pass
def on_show(self) -> None: def on_show(self) -> None:
pass pass

View File

@ -288,6 +288,25 @@ class SceneFileWriter(object):
) )
self.set_progress_display_description() self.set_progress_display_description()
def begin_insert(self):
# Begin writing process
self.write_to_movie = True
self.init_output_directories()
movie_path = self.get_movie_file_path()
folder, file = os.path.split(movie_path)
scene_name, ext = file.split(".")
n_inserts = len(list(filter(
lambda f: f.startswith(scene_name + "_insert"),
os.listdir(folder)
)))
self.inserted_file_path = movie_path.replace(".", f"_insert_{n_inserts}.")
self.open_movie_pipe(self.inserted_file_path)
def end_insert(self):
self.close_movie_pipe()
self.write_to_movie = False
self.print_file_ready_message(self.inserted_file_path)
def has_progress_display(self): def has_progress_display(self):
return self.progress_display is not None return self.progress_display is not None

View File

@ -1,18 +1,18 @@
uniform float is_fixed_in_frame; uniform float is_fixed_in_frame;
uniform mat4 perspective; uniform mat4 view;
uniform vec2 frame_shape; uniform vec2 frame_shape;
uniform float focal_distance; uniform float focal_distance;
const vec2 DEFAULT_FRAME_SHAPE = vec2(8.0 * 16.0 / 9.0, 8.0); const vec2 DEFAULT_FRAME_SHAPE = vec2(8.0 * 16.0 / 9.0, 8.0);
vec4 get_gl_Position(vec3 point){ vec4 get_gl_Position(vec3 point){
bool is_fixed = bool(is_fixed_in_frame);
vec4 result = vec4(point, 1.0); vec4 result = vec4(point, 1.0);
vec2 shape = DEFAULT_FRAME_SHAPE; if(!is_fixed){
if(!bool(is_fixed_in_frame)){ result = view * result;
result = perspective * result;
shape = frame_shape;
} }
vec2 shape = is_fixed ? DEFAULT_FRAME_SHAPE : frame_shape;
result.x *= 2.0 / shape.x; result.x *= 2.0 / shape.x;
result.y *= 2.0 / shape.y; result.y *= 2.0 / shape.y;
result.z /= focal_distance; result.z /= focal_distance;

View File

@ -42,12 +42,8 @@ vec3 unit_normal = vec3(0.0, 0.0, 1.0);
vec3 get_joint_unit_normal(vec4 joint_product){ vec3 get_joint_unit_normal(vec4 joint_product){
vec3 result; vec3 result = (joint_product.w < COS_THRESHOLD) ?
if(joint_product.w < COS_THRESHOLD){ joint_product.xyz : v_joint_product[1].xyz;
result = joint_product.xyz;
}else{
result = v_joint_product[1].xyz;
}
float norm = length(result); float norm = length(result);
return (norm > 1e-5) ? result / norm : vec3(0.0, 0.0, 1.0); return (norm > 1e-5) ? result / norm : vec3(0.0, 0.0, 1.0);
} }
@ -104,8 +100,8 @@ void get_corners(
float buff2 = 0.5 * v_stroke_width[2] + aaw; float buff2 = 0.5 * v_stroke_width[2] + aaw;
// Add correction for sharp angles to prevent weird bevel effects // Add correction for sharp angles to prevent weird bevel effects
if(v_joint_product[0].w < -0.75) buff0 *= 4 * (v_joint_product[0].w + 1.0); if(v_joint_product[0].w < -0.9) buff0 *= 10 * (v_joint_product[0].w + 1.0);
if(v_joint_product[2].w < -0.75) buff2 *= 4 * (v_joint_product[2].w + 1.0); if(v_joint_product[2].w < -0.9) buff2 *= 10 * (v_joint_product[2].w + 1.0);
// Unit normal and joint angles // Unit normal and joint angles
vec3 normal0 = get_joint_unit_normal(v_joint_product[0]); vec3 normal0 = get_joint_unit_normal(v_joint_product[0]);

View File

@ -26,10 +26,14 @@ class Window(PygletWindow):
self, self,
scene: Scene, scene: Scene,
size: tuple[int, int] = (1280, 720), size: tuple[int, int] = (1280, 720),
full_size: tuple[int, int] = (1920, 1080),
samples = 0 samples = 0
): ):
super().__init__(size=size, samples=samples) super().__init__(size=full_size, samples=samples)
self.full_size = full_size
self.default_size = size
self.default_position = self.find_initial_position(size)
self.scene = scene self.scene = scene
self.pressed_keys = set() self.pressed_keys = set()
self.title = str(scene) self.title = str(scene)
@ -40,13 +44,12 @@ class Window(PygletWindow):
self.config = mglw.WindowConfig(ctx=self.ctx, wnd=self, timer=self.timer) self.config = mglw.WindowConfig(ctx=self.ctx, wnd=self, timer=self.timer)
self.timer.start() self.timer.start()
# No idea why, but when self.position is set once self.to_default_position()
# it sometimes doesn't actually change the position
# to the specified tuple on the rhs, but doing it def to_default_position(self):
# twice seems to make it work. ¯\_(ツ)_/¯ self.size = self.default_size
initial_position = self.find_initial_position(size) self.position = self.default_position
self.position = initial_position self.swap_buffers()
self.position = initial_position
def find_initial_position(self, size: tuple[int, int]) -> tuple[int, int]: def find_initial_position(self, size: tuple[int, int]) -> tuple[int, int]:
custom_position = get_customization()["window_position"] custom_position = get_customization()["window_position"]