深入探究Manim的内部实现#
作者: Benjamin Hackl
免责声明
这份指南反映的是Manim v0.16.0版本的状态,主要涉及Cairo渲染器。最新版本的Manim情况可能会有所不同;如果有重大变化,我们将在下面添加说明。
介绍#
如果Manim表现出您期望的行为,它可以成为一个非常出色的库。但不幸的是,这并不总是情况(如果您已经尝试过一些manimation,可能已经有所了解)。为了了解问题出在哪里,有时必须深入研究库的源代码,但为了做到这一点,您需要知道从哪里开始挖掘。
本文旨在作为渲染过程的某种生命线。我们旨在适当地描述Manim读取您的场景代码并生成相应动画时发生的事情的详细程度。在整篇文章中,我们将重点关注以下示例::
from manim import *
class ToyExample(Scene):
def construct(self):
orange_square = Square(color=ORANGE, fill_opacity=0.5)
blue_circle = Circle(color=BLUE, fill_opacity=0.5)
self.add(orange_square)
self.play(ReplacementTransform(orange_square, blue_circle, run_time=3))
small_dot = Dot()
small_dot.add_updater(lambda mob: mob.next_to(blue_circle, DOWN))
self.play(Create(small_dot))
self.play(blue_circle.animate.shift(RIGHT))
self.wait()
self.play(FadeOut(blue_circle, small_dot))
在我们详细了解或查看此场景的渲染输出之前,让我们先口头描述一下这个 manimation中发生的事情。
在construct
方法的前三行中,初始化了一个Square正方形
和一个
Circle圆形
,然后将正方形添加到场景中。
渲染输出的第一帧应该显示一个橙色的正方形。
然后,实际的动画效果发生了:正方形首先变换成圆形,
然后创建了一个Dot点
(当它首次添加到场景中时,您猜这个点位于哪里?回答这个问题已经需要详细了解渲染过程了)。
这个点有一个更新器附加到它上面,当圆形向右移动时,点也会跟随它移动。最后,所有的 mobject 都会faded out(淡出)。
实际渲染这段代码会产生以下视频:
对于这个示例,输出(幸运的是)与我们的预期相符。
概述#
由于这篇文章包含了很多信息,因此以下简要概述了接下来各章节的内容,从高层次上进行了讨论。
-
Preliminaries预备知识:在本章中,我们将揭示准备场景进行渲染的所有步骤, 直到运行用户重写的
construct
方法的那一刻。 这包括简要讨论使用Manim的CLI与其他渲染方式(例如通过Jupyter notebooks或通过调用Scene.render()
方法在您的Python脚本中自行进行渲染) 的区别。 -
Mobject 初始化 :在第二章中,我们深入探讨了创建和处理Mobjects, 即应该在场景中显示的基本元素。我们将讨论
Mobject
基类, 以及基本上有三种不同类型的Mobjects,然后讨论其中最重要的矢量化Mobjects。 特别是,我们将描述内部点数据结构, 该数据结构控制着负责将矢量化Mobject绘制到屏幕上的机制如何设置相应的贝塞尔曲线。 最后,我们将介绍Scene.add()
,这是控制哪些mobjects应该被渲染的记录机制。 -
动画和渲染循环 :最后,在最后一章中, 我们将通过实例化Animation对象(保存有关Mobjects在渲染循环运行时如何修改的蓝图)来详细介绍动画, 然后调查臭名昭著的Scene.play()调用。我们将看到Scene.play()调用中有三个相关部分; 第一部分处理和准备传递的动画和关键字参数,接下来是实际的“渲染循环”,在此循环中, 库通过时间线逐帧渲染。最后一部分对一段短视频进行一些后处理(“部分电影文件”)和清理,为下一次Scene.play()调用做准备。 最终,在运行完整个Scene.construct()之后,库将这些部分电影文件合并为一个视频。
好的,那么让我们开始吧。
Preliminaries预备知识#
导入库 #
不管您是通过manim -qm -p file_name.py ToyExample这样的命令运行系统来渲染场景,还是通过像下面这样的Python脚本直接渲染场景:
with tempconfig({"quality": "medium_quality", "preview": True}):
scene = ToyExample()
scene.render()
或者您是在 Jupyter notebook 中渲染代码,您仍然会告诉Python解释器导入库。通常用于这样做的模式是:
from manim import *
这种导入方法一般来说存在争议,但它会导入许多随库提供的类和函数,并在全局名称空间中使它们可用。 我可以明确的说,并不会导入导入all 类和函数: Manim使用了了 Section 6.4.1 of the Python tutorial中的特性, 并且所有应在运行*-import时向用户公开的模块成员都在模块的__all__变量中明确声明。
Manim在自己内部也使用者策略: 查看在调用导入初始化的文件, __init__.py
(see
here),
你会注意到大部分的代码都涉及从不同的子模块中导入成员,而且通常使用星号(* -imports
)导入方式。
提示
如果您想为 Manim 贡献一个新的子模块,那么在导入库后,该子模块的成员只有在主 __init__.py
中列出后才能被用户访问。
这句话的意思是,在该文件中,有一个特定的导入语句位于文件开头,即 :
from ._config import *
该导入语句初始化了 Manim 的全局配置系统,该系统在整个库中的各个地方都被使用。运行该行代码后,
当前的配置选项就会被设置。这段代码负责读取您的 .cfg
文件中的选项
(所有用户至少都有一个与库一起发布的全局配置文件),并正确地处理命令行参数(如果您使用 CLI 来进行渲染)。
您可以在相应的corresponding thematic guide主题指南中了解更多有关配置系统的信息。 如果您想了解更多关于配置系统内部工作原理和初始化方式的信息,请跟随the config module’s init file配置初始化文件进行学习。
现在库已经被导入,我们可以将注意力转向下一步:读取您的场景代码(这并不特别令人兴奋,
Python 只是基于我们的代码创建了一个名为 ToyExample
的新类;
Manim 在这一步中几乎没有参与,唯一的例外是ToyExample
继承自Scene
)类)
然而,随着ToyExample
类已经创建并准备就绪,现在有一个新的重要问题需要回答:我们在construct
方法中编写的代码究竟是如何执行的?
场景实例化和渲染 #
回答这个问题取决于您如何运行代码。为了使情况更清晰一些,让我们首先考虑您创建了一个名为toy_example.py
的文件,其内容如下:
from manim import *
class ToyExample(Scene):
def construct(self):
orange_square = Square(color=ORANGE, fill_opacity=0.5)
blue_circle = Circle(color=BLUE, fill_opacity=0.5)
self.add(orange_square)
self.play(ReplacementTransform(orange_square, blue_circle, run_time=3))
small_dot = Dot()
small_dot.add_updater(lambda mob: mob.next_to(blue_circle, DOWN))
self.play(Create(small_dot))
self.play(blue_circle.animate.shift(RIGHT))
self.wait()
self.play(FadeOut(blue_circle, small_dot))
with tempconfig({"quality": "medium_quality", "preview": True}):
scene = ToyExample()
scene.render()
有了这样一个文件,只需通过python toy_example.py
命令运行此Python脚本即可渲染所需的场景。
然后,就像上面描述的那样,库被导入,Python已经读取并定义了ToyExample
类(但请仔细阅读:尚未创建该类的任何实例)。
此时解释器即将进入 tempconfig
上下文管理器。
即使您以前没有见过 Manim 的 tempconfig
,它的名称已经暗示了它的功能:它创建了当前配置状态的副本,
将传递的字典中的key-value 对更改应用到副本中,并在离开上下文时恢复原始配置的版本。简而言之,它提供了一种临时设置配置选项的高级方法。
在上下文管理器内部,发生了两件事情:实例化了一个名为 ToyExample
的场景对象,并调用了其 render
方法。无论使用 Manim
的方式是什么,在底层都会执行这些操作,
即库始终会实例化场景对象,
然后调用其 render
方法。
为了说明这一点,让我们简要地看一下渲染场景的两种最常见的方式:
命令行界面.
在使用命令行界面并在终端中运行manim -qm -p toy_example.py ToyExample
命令时,实际的入口点是 Manim 的__main__.py
文件(here)。
Manim 使用Click
来实现命令行界面,相应的代码位于 Manim 的cli
模块中
(https://github.com/ManimCommunity/manim/tree/main/manim/cli)。
创建场景类并调用其 render 方法的相应代码位于here。
Jupyter notebooks. 在 Jupyter notebooks,
与库的通信由 %%manim
魔术命令处理,
该命令实现在manim.utils.ipython_magic
模块中。
该魔术命令有some documentation
可供参考,创建场景类并调用其 render 方法的代码位于here。
既然我们知道无论哪种方式,都会创建一个 Scene
对象,
让我们来探究一下 Manim 在这种情况下的具体操作。在实例化场景对象时,Manim 会执行以下操作:
scene = ToyExample()
如果我们没有实现自己的初始化方法,则会调用Scene.__init__
方法。
检查相应的代码(参见here)
可以发现,Scene.__init__
首先设置了场景对象的几个属性,
这些属性不依赖于在config
中设置的任何配置选项。
然后场景会检查config.renderer
的值,
并根据其值实例化一个 CairoRenderer
或OpenGLRenderer
对象,并将其分配给其 renderer 属性。
然后场景会请求其渲染器通过调用以下方法来初始化场景:
self.renderer.init_scene(self)
检查默认的 Cairo 渲染器和 OpenGL 渲染器可以发现,init_scene
方法实际上使渲染器实例化一个 SceneFileWriter
对象,
它基本上是 Manim 与 ffmpeg
的接口,并实际编写电影文件。
Cairo 渲染器(见here的实现)不需要任何进一步的初始化。
OpenGL 渲染器还执行一些额外的设置,以启用实时渲染预览窗口,这里不再详细介绍。
警告
目前,场景和其渲染器之间存在很多相互作用。这是 Manim 当前架构中的一个缺陷,我们正在努力减少这种相互依赖,以实现更简单的代码流程。
在渲染器被实例化并初始化其文件写入器之后,场景会填充更多的初始属性(值得一提的是mobjects
属性,它跟踪已添加到场景中的
mobjects)。然后场景完成其实例化,并准备好被渲染。
本文的其余部分关注于我们的示例脚本中的最后一行代码:
scene.render()
这就是实际的魔法发生的地方。
检查 implementation
of the render method
方法的实现会发现,有几个钩子可以用于预处理或后处理场景。毫不奇怪,Scene.render()
描述了场景的完整渲染周期。
在这个生命周期中,有三个自定义方法,它们的基本实现为空,可以根据您的需求进行重写。按照调用顺序,这些可自定义的方法如下:
-
Scene.setup()
, 用于准备和设置场景的动画(例如,添加初始的 mobjects,为场景类分配自定义属性等)。 -
Scene.construct()
, 是屏幕剧本的脚本,包含您的动画的编程描述。 -
Scene.tear_down()
, 用于在最后一帧已经被渲染之后运行的任何操作(例如,可能会运行一些代码,根据场景中对象的状态生成自定义缩略图 - 此钩子对于在其他 Python 脚本中使用 Manim 的情况更为相关)。
动画完全渲染后,Manim调用CairoRenderer.scene_finished()
来检查是否有任何动画被播放。如果有动画被播放,Manim会告诉SceneFileWriter
关闭到ffmpeg
的管道,ffmpeg是一个可以将渲染帧编码为视频文件的程序。
运行这三个方法后,动画已经完全渲染,在定义并添加了动画到场景中之后,
如果没有播放任何动画,Manim会假定应输出静态图像,并调用渲染循环一次来渲染图像。
回到我们的例子中, 调用Scene.render()
首先触发Scene.setup()
(其中只包含pass
),
然后调用Scene.construct()
。
此时,我们的animation script动画脚本开始运行,从初始化开始。
在Manim中渲染场景的过程。
当调用Scene.render()时,会依次触发Scene.setup()
和Scene.construct()
。
在这个过程中,Manim会执行动画脚本中的代码,从而定义和初始化场景中的对象和动画。
在这个特定的例子中,orange_square
是一个场景中的对象,可能是一个形状或图形,它被定义并初始化为一个橙色正方形。
.
Mobject 初始化#
MMobjects是Python对象,它们代表我们想要在场景中显示的所有内容。在我们跟随调试器的步骤进入mobject初始化代码的深处之前,讨论Manim的不同类型的Mobjects和它们的基本数据结构是有意义的。
什幺是Mobject?#
Mobject
Mobject代表数学对象或Manim对象(这取决于你问谁😄)。Python类Mobject是所有应该显示在屏幕上的对象的基类。
查看
of Mobject
的initialization
method初始化方法,你会发现这里发生的事情并不太多:
-
在Mobject的初始化方法中,会分配一些初始属性值,例如name(这使得渲染日志提及mobject的名称而不是它的类型)、submobjects(最初是一个空列表)、颜色和其他一些属性。 Mobject初始化方法中所进行的一些操作。 在Mobject初始化方法中,会为Mobject分配一些初始属性值,例如名称、子对象列表、颜色等。 这些属性值可以在之后的代码中被修改,以实现不同的功能和效果。 其中,name属性用于在渲染日志中标识Mobject的名称,而submobjects属性用于存储Mobject的子对象列表。 其他属性的具体作用取决于每个具体Mobject的实现。
-
接下来,调用与points点相关的两个方法:
reset_points
和generate_points
。 这段文字描述了Mobject初始化方法中调用的两个与点相关的方法。reset_points方法用于将Mobject的点集重置为空集, 而generate_points方法用于根据Mobject的属性值生成Mobject的点集。 点集是Mobject的基本信息之一,它描述了Mobject的形状、位置和大小等属性。 在generate_points方法中,会根据具体Mobject的实现来计算Mobject的点集,以实现不同的功能和效果。 -
最后,
init_colors
被调用。
更深入地了解,您会发现 Mobject.reset_points()
方法只是将 Mobject 类的 points
属性设置为空的 NumPy 向量,
而另外两个方法 Mobject.generate_points()
和
Mobject.init_colors()
则只是被实现为空操作(pass
)。
这很有道理:Mobject
不应该被用作实际显示在屏幕上的对象;
实际上相机(我们稍后会更详细地讨论它;它是 Cairo 渲染器中负责“拍摄”当前场景的类)不会以任何方式处理“纯粹”的 Mobjects
,
它们甚至不会出现在渲染输出中。
这就是不同类型的 mobjects 起作用的地方。粗略地说,Cairo 渲染器设置了三种不同类型的 mobjects,可以进行渲染:
-
ImageMobject
, 代表您可以在场景中显示的图像。 -
PMobject
, 是非常特殊的 mobjects,用于表示点云;我们在本指南中不会进一步讨论它们。 -
VMobject
, 这些是矢量化的 mobjects,即由通过曲线连接的点组成的 mobjects。 它们几乎无处不在,我们将在下一节中详细讨论它们。
… 什么是 VMobjects?#
正如刚才提到的,VMobjects
表示矢量化的 mobject。
要渲染 VMobject
,
相机会查看 VMobject
的points
属性,
并将其分成每组四个点。
然后,每个组都用于构建一个立方贝塞尔曲线,其中第一个和最后一个点描述曲线的端点(“锚点”),
第二个和第三个点描述其之间的控制点(“手柄”)
提示
要了解更多关于Bézier curves(贝塞尔曲线)的知识, 可以查看 Pomax 编写的在线教程 《A Primer on Bézier curves》。 在第一章节中有一个代表立方贝塞尔曲线的in §1,红色和黄色的点是“锚点”,绿色和蓝色的点是“手柄”。
与 Mobject
不同,
VMobject
可以在屏幕上显示(即使在技术上,它仍被认为是一个基类)。
为了说明如何处理点,考虑以下简短的示例,其中包含 8 个点的 VMobject
(因此由 8/4 = 2 个立方贝塞尔曲线组成)。绘制的 VMobject
为绿色。手柄绘制为红色点,与其最近的锚点之间有一条线。
Example: VMobjectDemo ¶
from manim import *
class VMobjectDemo(Scene):
def construct(self):
plane = NumberPlane()
my_vmobject = VMobject(color=GREEN)
my_vmobject.points = [
np.array([-2, -1, 0]), # start of first curve
np.array([-3, 1, 0]),
np.array([0, 3, 0]),
np.array([1, 3, 0]), # end of first curve
np.array([1, 3, 0]), # start of second curve
np.array([0, 1, 0]),
np.array([4, 3, 0]),
np.array([4, -2, 0]), # end of second curve
]
handles = [
Dot(point, color=RED) for point in
[[-3, 1, 0], [0, 3, 0], [0, 1, 0], [4, 3, 0]]
]
handle_lines = [
Line(
my_vmobject.points[ind],
my_vmobject.points[ind+1],
color=RED,
stroke_width=2
) for ind in range(0, len(my_vmobject.points), 2)
]
self.add(plane, *handles, *handle_lines, my_vmobject)
正方形和圆形:回到我们的玩具例子#
通过对不同类型的 mobject 有基本的理解,以及对矢量化 mobject 如何构建的概念,我们现在可以回到我们的示例和Scene.construct()
方法的执行中。
在动画脚本的前两行中,初始化了orange_square
和 blue_circle
。
创建橙色正方形并运行
Square(color=ORANGE, fill_opacity=0.5)
当调用Square
的初始化方法Square.__init__
时,
看看这个实现,我们可以看到正方形的side_length
属性被设置,然后
super().__init__(height=side_length, width=side_length, **kwargs)
被调用。
接下来执行的是super
函数调用,这是Python中调用父类初始化函数的方式。
由于Square
继承自Rectangle
,
因此下一个被调用的方法是Rectangle.__init__
。在这里,只有前三行对我们真正有用:
super().__init__(UR, UL, DL, DR, color=color, **kwargs)
self.stretch_to_fit_width(width)
self.stretch_to_fit_height(height)
首先,调用Rectangle
的父类
– Polygon
的初始化函数。
传递的四个位置参数是多边形的四个角:UR
代表右上角(等于UP + RIGHT),
UL
代表左上角(等于UP +
LEFT),以此类推。
在我们继续深入调试器之前,让我们观察一下构建的多边形会发生什么:
剩下的两行代码将多边形拉伸以适应指定的宽度和高度,从而创建具有所需尺寸的矩形。
Polygon
的初始化函数特别简单,
它只调用其父类Polygram
的初始化函数。在那里,我们几乎到达了链的末端:
Polygram
继承自
VMobject
,
其初始化函数主要设置了一些属性的值(与Mobject.__init__
非常相似,
但更具体地针对组成mobject的贝塞尔曲线)。
在调用VMobject
的初始化函数后,Polygram
的构造函数还做了一件有点奇怪的事情:
它设置了点(你可能还记得上面提到过,这些点实际上应该在Polygram
的相应generate_points
方法中设置)。
Warning
在几个实例中,Mobject的实现并没有完全遵循Manim的接口规范。这是不幸的,我们正在积极努力提高一致性。欢迎大家提供帮助!
不详细展开,Polygram
通过VMobject.start_new_path()
和VMobject.add_points_as_corners()
设置其points
属性,这些方法会适当地设置锚点和手柄的四元组。在设置了这些点之后,Python继续处理调用堆栈,直到它到达最初调用的方法:
Square
的初始化方法。
在此之后,正方形被初始化并赋值给orange_square
变量。
blue_circle
的初始化与orange_square
的初始化类似,主要区别在于Circle
的继承链不同。
让我们简要地跟随调试器的追踪:
Circle.__init__()
的实现立即调用Arc
的初始化方法,因为在Manim中,圆形只是一个\(\tau = 2\pi\)的弧。
在初始化弧时,设置了一些基本属性(如Arc.radius
、Arc.arc_center
、Arc.start_angle
和Arc.angle
),
然后调用了其父类TipableVMobject
的初始化方法(它是一个相当抽象的基类,用于连接箭头尖端的mobject)。
请注意,与Polygram
不同,这个类不会预先生成圆的点。
之后,事情就不那么激动人心了:TipableVMobject
再次设置了一些与添加箭头尖端相关的属性,然后传递到 VMobject
的初始化方法。
从那里开始,Mobject
被初始化并调用Mobject.generate_points()
,
它实际上运行了在Arc.generate_points()
中实现的方法。
在我们的 orange_square
和blue_circle
都被初始化之后,
正方形实际上被添加到了场景中。Scene.add()
方法实际上执行了一些有趣的操作,
因此值得在下一节中深入挖掘。
将 Mobjects 加到 Scene 中#
下面是我们construct
方法中运行的代码:
self.add(orange_square)
从高层次的角度来看,Scene.add()
将
orange_square
添加到应该被渲染的mobjects
列表中,
该列表存储在场景的mobjects属性中。
然而,它以非常谨慎的方式这样做,以避免将mobject添加到场景中超过一次的情况。
乍一看,这听起来像是一个简单的任务,
但问题是Scene.mobjects
不是一个“扁平”的mobjects列表,而是一个可能包含mobjects本身等的mobjects列表。
通过对Scene.add()
中的代码进行逐步调试,
我们可以看到首先检查当前是否使用OpenGL渲染器(我们没有使用)——将mobjects添加到场景的工作对于OpenGL渲染器略有不同(实际上更容易!)。然后,进入Cairo渲染器的代码分支,并将所谓的前景mobjects列表(它们在所有其他mobjects之上呈现)添加到传递mobjects的列表中。
这是为了确保前景mobjects将保持在其他mobjects之上,即使添加了新的mobjects。在我们的情况下,前景mobjects列表实际上为空,因此什么也不会改变。
接下来,Scene.restructure_mobjects()
被调用,
传递要添加的mobjects列表作为要 to_remove
的参数,这可能一开始听起来有点奇怪。
实际上,这确保了mobjects不会被添加两次,
如上所述:如果它们之前存在于场景Scene.mobjects
列表中(即使它们是作为其他mobject的子项包含的),
则首先从列表中删除它们。Scene.restrucutre_mobjects()
的工作方式相当“激进”:它总是在给定的mobjects列表上操作;
在add
方法中出现了两个不同的列表:
默认列表Scene.mobjects
(没有传递额外的关键字参数)
和Scene.moving_mobjects
(稍后我们将更详细地讨论它们)。它遍历列表中的所有成员,并检查任何一个传递给to_remove
的mobject是否作为子项(在任何嵌套级别)包含在其中。如果是这样,它们的父mobject将被解构,它们的兄弟将直接插入到更高的一级中。考虑以下示例:
>>> from manim import Scene, Square, Circle, Group
>>> test_scene = Scene()
>>> mob1 = Square()
>>> mob2 = Circle()
>>> mob_group = Group(mob1, mob2)
>>> test_scene.add(mob_group)
<manim.scene.scene.Scene object at ...>
>>> test_scene.mobjects
[Group]
>>> test_scene.restructure_mobjects(to_remove=[mob1])
<manim.scene.scene.Scene object at ...>
>>> test_scene.mobjects
[Circle]
请注意,该组被解散,圆形移动到test_scene.mobjects
的根层中。
在“重构”mobject列表之后,要添加的mobject被简单地附加到Scene.mobjects
中。在我们的玩具示例中,Scene.mobjects
列表实际上为空,
因此restructure_mobjects
方法实际上不会执行任何操作。
orange_square
只是添加到Scene.mobjects
中,
并且在此时Scene.moving_mobjects
列表也仍然为空,
因此什么也不会发生,Scene.add()
方法返回。
我们在讨论渲染循环时将更多地涉及到moving_mobject
列表。
在此之前,让我们看一下我们玩具示例中的下一行代码,其中包括初始化一个动画类。
ReplacementTransform(orange_square, blue_circle, run_time=3)
因此,是时候谈谈 Animation
.
Animations(动画) 和 Render(渲染) 循环#
初始化 animations(动画)#
在我们跟随调试器的跟踪之前,让我们简要讨论一下(抽象的)基类Animation
的一般结构。
一个动画对象包含了生成相应帧所需的所有信息。在Manim中,动画(指动画对象)总是与特定的mobject相关联;
即使是AnimationGroup
(实际上应该将其视为对一组mobject的动画,而不是一组动画)也是如此。
此外,除了特定的特殊情况外,动画的运行时间也是固定并预先知道的。
这意味着渲染器知道每个动画需要生成多少帧,并可以利用这些信息来优化渲染过程。
实际上,动画的初始化并不是很令人兴奋,Animation.__init__()
只是设置一些从关键字参数传递而来的属性,
并确保Animation.starting_mobject
和Animation.mobject
属性被填充。
一旦动画开始播放,starting_mobject
属性将保存动画所附加的mobject的未修改副本;
在初始化期间,它被设置为占位符mobject。mobject
属性被设置为动画所附加的mobject。
动画有几个在渲染循环期间调用的特殊方法:
-
Animation.begin()
, 是在每个动画开始之前调用的一个特殊方法,它的名字也暗示了这一点。在这个方法中,动画的所有必要设置都会发生。也就是说,在第一帧渲染之前会调用它。 -
Animation.finish()
是与begin
方法对应的方法,在动画的生命周期结束时调用(即在最后一帧渲染之后)。 它完成一些清理工作和资源回收,以确保动画在播放完毕后能够正确地结束。 -
Animation.interpolate()
它将附加到动画的mobject更新为相应的动画完成百分比。例如,在渲染循环中,如果调用了some_animation.interpolate(0.5)
, 则附加的mobject将被更新为完成50%动画的状态。 这个方法的作用是实现动画效果的平滑过渡,使得在渲染过程中每一帧的状态都能够正确反映动画当前的完成度。
当我们进入实际的渲染循环时,我们将讨论有关这些动画方法的详细信息。
现在,我们继续我们的玩具示例,并查看在初始化ReplacementTransform
动画时运行的代码。
ReplacementTransform
的初始化方法只包括对其父类Transform
的构造函数的调用,
并附加了一个关键字参数replace_mobject_with_target_in_scene
设置为True
。
Transform
随后设置控制起始mobject的点如何变形为目标mobject的属性,然后传递给Animation
的初始化方法。
在Animation的初始化方法中,还会处理动画的其他基本属性,例如它的运行时间(run_time
)、rate_func
,等等。
然后,动画对象就被完全初始化并准备好播放了。
play
调用:
准备进入 Manim 的渲染循环#
我们终于到了渲染循环了。让我们来看一下当调用Scene.play()
时运行的代码。
提示
请注意,本文特别讨论Cairo渲染器。到目前为止, OpenGL渲染器的情况与之类似;虽然一些基本mobject可能不同, 但mobject的控制流和生命周期仍然基本相同。但是,在渲染循环方面存在更实质性的差异。
当你检查play方法时会发现,Scene.play()
几乎立即转移到渲染器的play
方法,对于我们的场景,这是CairoRenderer.play
。
Scene.play()
处理的唯一事情是管理你可能传递给它的子标题(详见Scene.play()
和Scene.add_subcaption()
的文档)。
警告
正如之前所说,此时场景和渲染器之间的通信并不是非常清晰,因此如果您不运行调试器并自己逐步执行代码,下面的段落可能会令人困惑。
在CairoRenderer.play()
方法内部,渲染器首先检查是否可以跳过当前的播放调用。
这可能发生在几种情况下,例如当在CLI中传递了-s
参数(即只渲染最后一帧),
或者当传递了-n
标志并且当前的播放调用在指定的渲染范围之外时。
"跳过状态"会通过调用CairoRenderer.update_skipping_status()
来更新。
接下来,渲染器会要求场景处理播放调用中的动画,以便渲染器获取所有所需的信息。
更具体地说,会调用Scene.compile_animation_data()
,然后这个方法会处理以下几件事情:
-
该方法会处理所有的动画和传递给
Scene.play()
调用的关键字参数。特别地,这意味着它会确保传递给play 调用的所有参数实际上都是动画(或者是.animate
语法调用,在这个时候也会组装成实际的Animation
对象)。它还会将传递给Scene.play
的任何与动画相关的关键字参数(例如run_time
或rate_func
)传递给每个单独的动画。 处理后的动画然后存储在场景的animations
属性中(稍后由渲染器读取...)。 -
该方法将所有播放的动画绑定的mobject 添加到场景中(前提是该动画不是引入mobject 的动画 - 对于这些动画,将稍后添加到场景中)。
-
如果播放的动画是
Wait
动画(即在Scene.wait()
调用中),该方法会检查是否应该渲染静态图像, 或者是否应该像通常一样处理渲染循环(详见Scene.should_update_mobjects()
,其中包括检查是否有任何依赖时间的更新函数等等)。 -
最后,该方法确定播放调用的总运行时间(此时计算为传递的动画运行时间的最大值)。这个运行时间被存储在场景的
duration
属性中。
在场景编译动画数据之后,渲染器继续准备进入渲染循环。现在,它检查之前确定的跳过状态。 如果渲染器可以跳过此播放调用,它将设置当前播放调用的哈希值(稍后我们将回到此处),并将渲染器的时间增加确定的动画运行时间。
否则,渲染器会检查是否应该使用Manim的缓存系统。缓存系统的思想很简单:对于每个播放调用,
计算一个哈希值,然后将其存储起来。在重新渲染场景时,再次生成哈希并与存储的值进行比较。如果相同,则重用缓存输出,否则重新完全重新渲染。我们在这里不会详细介绍缓存系统;如果您想了解更多信息,
utils.hashing
模块中的get_hash_from_play_call()
函数实际上是缓存机制的入口点。
如果必须渲染动画,则渲染器会要求其SceneFileWriter
启动写入过程。
这个过程是通过调用ffmpeg
启动的,并打开了一个管道,可以将渲染的原始帧写入。
只要管道是打开的,就可以通过文件写入器的writing_process
属性访问该进程。在写入过程准备好后,渲染器会要求场景“开始”动画。
首先,它通过调用它们的设置方法(Animation._setup_scene()
,
Animation.begin()
)
实际开始所有动画。这样做时,由动画(例如通过Create
等)
引入的新mobject将被添加到场景中。
此外,动画会暂停在其mobject上调用的更新器函数,并将其mobject设置为对应于动画的第一帧的状态。
在当前play
调用中的所有动画都完成上述操作之后,Cairo
渲染器将确定场景中哪些mobject可以静态地绘制到背景中,哪些必须在每帧中重新绘制。它通过调用
Scene.get_moving_and_static_mobjects()
,
来实现这一点,将结果划分为相应的moving_mobjects
和static_mobjects
属性中。
注意
确定静态和运动的mobject的机制是针对Cairo渲染器的,
OpenGL渲染器的工作方式不同。
基本上,运动的mobject是通过检查它们、
它们的任何子节点或任何"在它们下面"的mobject
(按照场景中处理mobject的顺序)是否具有附加的更新函数,
或者它们是否出现在当前的任何动画中来确定的。有关详细信息,
请参见Scene.get_moving_mobjects()
的实现。
到目前为止,我们实际上还没有从场景中渲染任何(部分)图像或电影文件。
然而,这将要改变。在我们进入渲染循环之前,
让我们简要回顾一下我们的玩具示例,
并讨论一下那里通用的Scene.play()
调用设置是什么样子的。
对于播放ReplacementTransform
的调用,没有需要处理的字幕。
然后,渲染器要求场景编译动画数据:
传递的参数已经是一个动画(不需要额外的准备工作),
也不需要处理任何关键字参数(因为我们没有指定任何额外的参数来play
)。
绑定到动画的mobject(orange_square
)已经是场景的一部分(因此不需要任何操作)。
最后,运行时间(3秒钟)被提取并存储在Scene.duration
中。
然后,渲染器检查是否应该跳过(不应该),然后检查动画是否已经被缓存(没有)。
相应的动画哈希值被确定并传递给文件写入器,后者随后调用ffmpeg
来启动等待来自库的渲染帧的写入过程。
然后场景begin
动画:
对于ReplacementTransform
,
这意味着动画填充其所有相关的动画属性(即,
起始和目标mobject的兼容副本,
以便可以安全地在两者之间进行插值)。
确定静态和运动的mobject
的机制考虑到场景中的所有mobject
此时只有orange_square
),
并确定orange_square
绑定到当前正在播放的动画。
因此,该正方形被归类为"运动的mobject"。
是时候渲染一些帧了。
渲染循环(这次是真正的)#
如上所述,由于在场景中确定静态和运动的mobject的机制,渲染器知道哪些mobject可以静态绘制到场景的背景中。实际上,这意味着它部分地渲染场景(以产生背景图像),然后在迭代动画的时间进度时,只有"运动的mobject"被重新绘制在静态背景的上方。
渲染器调用CairoRenderer.save_static_frame_data()
,
首先检查当前是否有任何静态mobject,
如果有,则更新帧(仅使用静态mobject;稍后详细介绍如何实现),
然后将表示渲染帧的NumPy数组保存在static_image
属性中。
在我们的玩具示例中,没有静态mobject,
因此static_image属性简单地设置为None
。
接下来,渲染器询问场景当前的动画是否是"冻结帧"动画,这意味着在动画时间进度的每一帧中,渲染器实际上不需要重新绘制运动的mobject。它可以只取最新的静态帧,并在整个动画中显示它。
Note
如果只播放静态的Wait
动画,动画被认为是"冻结帧"动画。
有关详细信息,请参见上面的Scene.compile_animation_data()
的描述,
或者请参见Scene.should_update_mobjects()
的实现。
如果不是这种情况(就像我们的玩具例子一样),
渲染器接下来调用Scene.play_internal()
方法,
这是渲染循环的重要部分(在该循环中,库会遍历动画的时间进度并渲染相应的帧)。
在Scene.play_internal()
,
中,执行以下步骤:
-
场景通过调用
Scene.get_run_time()
方法确定动画的运行时间。该方法基本上获取Scene.play()
调用中所有动画的最大run_time
属性。 -
然后,时间进度是通过(内部的)
Scene._get_animation_time_progression()
方法构造的, 该方法包装了实际的Scene.get_time_progression()
方法。 时间进度是一个tqdm
进度条对象, 用于迭代np.arange(0, run_time, 1 / config.frame_rate)
。 换句话说,time progression时间进度保存了时间戳(相对于当前动画,因此从0开始, 以总动画运行时间结束, 步长由渲染帧率确定)的时间线,在该时间线上应该渲染新的动画帧。 -
然后,场景在时间进度上进行迭代:对于每个时间戳
t
,调用Scene.update_to_time()
方法,它的作用是...-
...首先计算自上次更新以来经过的时间(可能为0,特别是在初始调用时),并将其称为
dt
, -
然后(按照传递给
Scene.play()
的动画的顺序), 调用Animation.update_mobjects()
来触发附加到相应动画的所有更新器函数, 但不包括动画的"主要mobject"(例如,对于Transform
, 是起始和目标mobject的未修改副本——有关更多详细信息, 请参见Animation.get_all_mobjects_to_update()
), -
然后,相对于当前动画计算相对时间进度(
alpha = t / animation.run_time
), 然后使用调用Animation.interpolate()
更新动画状态。 -
在处理所有传递的动画之后,运行场景中所有mobject、所有网格和最后附加到场景本身的更新器函数。
-
此时,所有mobject的内部(Python)状态已更新以匹配当前处理的时间戳。如果不应该跳过渲染,则现在是拍照的时候了!
注意
一旦进入 Scene.play_internal()
,
内部状态的更新(在时间进度上的迭代)就会发生。
这确保即使帧不需要被渲染(例如,因为传递了-n
CLI标志、某些内容已被缓存,或者因为我们可能在跳过渲染的部分),
更新器函数仍然可以正确运行,并且第一个被渲染的帧的状态保持一致。
为了渲染图像,场景调用其渲染器的相应方法CairoRenderer.render()
,
并仅传递运动的moving mobjects列表 (请记住,假定static 静态 mobjectsmobject已经被静态地绘制到场景的背景中)。
当渲染器通过调用CairoRenderer.update_frame()
更新其当前帧时,所有的艰苦工作都发生了。
首先,渲染器通过检查是否已经存储了不同于None
的static_image
来准备其Camera相机
。
如果是这样,它通过Camera.set_frame_to_background()
将图像设置为相机的background
image,
否则它只是通过Camera.reset()
重置相机。
然后,通过调用Camera.capture_mobjects()
要求相机捕获场景。
这里的事情有点技术性, 而且在某个时候深入到实现中会更有效, 但是这里是一份摘要,介绍一下相机被要求捕获场景后会发生什么:
-
首先,创建一个mobjects的扁平列表(因此,子mobjects会从其父级中提取出来)。然后,按照相同类型的mobjects对此列表进行分组处理(例如,一批矢量化的mobjects,接着是一批图像mobjects,接着是更多的矢量化的mobjects等——在许多情况下,只会有一批矢量化的mobjects)。
-
根据当前处理的批次的类型,相机使用专用的display functions显示函数将
Mobject
对象转换为存储在相机的pixel_array
属性中的NumPy 数组。在这个上下文中最重要的例子是矢量化mobjects 的显示函数Camera.display_multiple_vectorized_mobjects()
,或者更具体地说(如果您没有将背景图像添加到您的VMobject 中),Camera.display_multiple_non_background_colored_vmobjects()
。该方法首先获取当前的Cairo上下文,然后针对批次中的每个VMobject
,调用Camera.display_vectorized()
。在那里,mobject的实际背景描边、填充,然后描边被绘制到上下文中。有关更多详细信息,请参见Camera.apply_stroke()
和Camera.set_cairo_context_color()
——但在后者方法中, 根据mobject 的点确定的实际Bézier曲线被绘制;这是与Cairo的低级交互发生的地方。
处理完所有批次后,相机在其pixel_array
属性中以NumPy数组的形式具有场景在当前时间戳的图像表示。
然后,渲染器获取此数组并将其传递给其SceneFileWriter
。
这结束了渲染循环的一个迭代,在处理完时间进度之后,Scene.play_internal()
调用完成之前会进行一些最终的清理工作。
在我们的玩具示例的上下文中,渲染循环的 TL;DR 如下所示:
-
场景发现应该播放一个3秒长的动画(
ReplacementTransform
将橙色正方形变为蓝色圆形)。根据请求的中等渲染质量, 帧率为每秒30帧,因此创建了具有步长[0, 1/30, 2/30, ..., 89/30]
的时间进度。 -
在内部渲染循环中,处理每个这样的时间戳: 没有更新器函数,所以实际上场景将转换动画的状态更新到所需的时间戳上 (例如,在时间戳
t = 45/30
时, 动画完成了alpha = 0.5
的速率)。 -
然后,场景要求渲染器执行其工作。渲染器要求其相机捕获场景,在这一点上需要处理的唯一mobject是附加到变换的主要mobject;相机将mobject的当前状态转换为NumPy数组中的条目。渲染器将此数组传递给文件写入器。
-
在循环结束时,已将90个帧传递给文件写入器。
完成渲染循环 #
在Scene.play_internal()
调用的最后几个步骤不太令人激动:对于每个动画,都会调用相应的Animation.finish()
和Animation.clean_up_from_scene()
方法。
注意
请注意,作为Animation.finish()
的一部分,将使用参数1.0调用Animation.interpolate()
方法——您可能已经注意到,动画的最后一帧有时可能会有些偏差或不完整。
这是当前的设计!在渲染循环中呈现的最后一帧
(在渲染的视频中以1 / frame_rate
秒的持续时间显示)对应于动画结束前1 / frame_rate
秒的状态。为了在视频中显示最后一帧,
我们需要在视频末尾添加另外1 / frame_rate
秒——这意味着渲染为1秒的Manim视频将略长于1秒。我们曾经决定反对这种做法。
最后,在终端中关闭时间进度条来结束时间进度。随着时间进度的关闭,Scene.play_internal()
调用完成,我们回到渲染器,现在它会命令SceneFileWriter
关闭为该动画打开的电影管道:一个部分电影文件被写入。
这基本上结束了Scene.play
调用的演示,实际上对于我们的玩具例子,也没有太多要说的了:
此时,一个代表ReplacementTransform
播放的部分电影文件已经被写入。Dot
的初始化类似于上面讨论的blue_circle
的初始化。Mobject.add_updater()
调用仅将函数附加到 small_dot
的updaters
属性上。剩下的Scene.play()
和Scene.wait()
调用遵循上面讨论的渲染循环部分的完全相同的过程;每次此类调用都会产生一个相应的部分电影文件。
一旦Scene.construct()
方法已经完全处理(因此所有相应的部分电影文件都已经被写入),场景调用其清理方法Scene.tear_down()
,然后要求其渲染器完成场景。渲染器又会要求其场景文件写入器通过调用SceneFileWriter.finish()
来结束,这将触发将部分电影文件组合成最终成品。
是的!这就是Manim在幕后运作的更或多或少详细的描述。虽然我们没有在这个演示中详细讨论每一行代码,但它应该给你一个相当好的想法,即库的一般结构设计和至少Cairo渲染流程的外观。