Manim 内部机制深度解析¶
作者: Benjamin Hackl
免责声明
本指南反映了库在 v0.16.0
版本时的状态,并主要关注 Cairo 渲染器。Manim 最新版本的情况可能有所不同;如果存在重大差异,我们将在下方添加注释。
引言¶
Manim 是一个出色的库,如果它能按照您期望或希望的方式运行。不幸的是,情况并非总是如此(如果您自己玩过一些 Manim 动画,可能已经知道了)。要了解问题*出在哪里*,有时深入研究库的源代码是唯一的选择——但为此,您需要知道从何处入手。
本文旨在为渲染过程提供一种生命线。我们旨在提供适当的细节,描述当 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))
在深入细节或查看此场景的渲染输出之前,让我们先口头描述此*Manim 动画*中发生的事情。在 construct
方法的前三行中,一个 Square
和一个 Circle
被初始化,然后正方形被添加到场景中。因此,渲染输出的第一帧应该显示一个橙色正方形。
然后实际的动画发生:正方形首先变换成一个圆形,然后创建一个 Dot
(您猜点第一次添加到场景时位于何处?回答这个问题已经需要对渲染过程有详细的了解)。该点附加了一个更新器,随着圆形的向右移动,点也随之移动。最后,所有 Mobject 都淡出。
实际渲染代码会生成以下视频:
对于此示例,输出(幸运的是)与我们的预期一致。
概述¶
由于本文包含大量信息,这里简要概述了以下章节的主要内容。
准备工作:本章将阐明准备场景渲染所涉及的所有步骤;直到用户重写的
construct
方法运行为止。这包括对使用 Manim 的命令行界面 (CLI) 与其他渲染方式(例如,通过 Jupyter Notebook,或在您的 Python 脚本中自行调用Scene.render()
方法)的简要讨论。Mobject 初始化:第二章,我们将深入探讨 Mobject 的创建和处理,Mobject 是我们场景中应显示的基本元素。我们将讨论
Mobject
基类,它基本上有三种不同类型,然后详细讨论其中最重要的类型:矢量化 Mobject。特别是,我们将描述控制矢量化 Mobject 如何在屏幕上绘制相应贝塞尔曲线的内部点数据结构。本章最后将介绍Scene.add()
,这是控制哪些 Mobject 应该被渲染的簿记机制。动画和渲染循环:最后,在最后一章中,我们将介绍
Animation
对象的实例化(它们是蓝图,包含有关渲染循环运行时 Mobject 应如何修改的信息),然后调查臭名昭著的Scene.play()
调用。我们将看到Scene.play()
调用中有三个相关部分;一个部分处理传递的动画和关键字参数并进行准备,然后是实际的“渲染循环”,其中库逐步遍历时间线并逐帧渲染。最后一部分进行一些后处理,以保存短视频片段(“部分影片文件”)并为下一次Scene.play()
调用进行清理。最后,在所有Scene.construct()
运行完毕后,库将这些部分影片文件组合成一个视频。
至此,让我们直入主题。
准备工作¶
导入库¶
无论您如何精确地告诉系统渲染场景,即无论是运行 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 *
这种做法(虽然通常有争议)导入了库中附带的许多类和函数,并使它们在您的全局命名空间中可用。我特意避免声明它导入了库中的**所有**类和函数,因为它没有这样做:Manim 利用了 Python 教程第 6.4.1 节中描述的实践,所有在运行 *
导入时应暴露给用户的模块成员都在模块的 __all__
变量中明确声明。
Manim 在内部也使用了这种策略:查看调用 import 时运行的文件 __init__.py
(参见这里),您会注意到该模块中的大部分代码都涉及从各种子模块导入成员,同样使用了 *
导入。
提示
如果您想为 Manim 贡献一个新的子模块,那么主 __init__.py
是您必须将其列入的位置,以便其成员在导入库后可供用户访问。
然而,在该文件中,开头有一个特别的导入,即:
from ._config import *
这会初始化 Manim 的全局配置系统,该系统在库的各个地方都有使用。在库运行此行之后,当前的配置选项就被设置了。其中的代码负责读取您的 .cfg
文件中的选项(所有用户至少都有一个随库附带的全局配置文件),以及正确处理命令行参数(如果您使用 CLI 进行渲染)。
您可以在相应的专题指南中阅读有关配置系统的更多信息,如果您有兴趣了解配置系统内部及其初始化方式的更多信息,请从配置模块的 init 文件开始跟随代码流。
现在库已导入,我们可以将注意力转向下一步:读取您的场景代码(这并不特别令人兴奋,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
,它的名字也已经暗示了它的作用:它会创建当前配置状态的副本,应用传入字典中键值对的更改,并在离开上下文时恢复原始版本的配置。TL;DR:它提供了一种暂时设置配置选项的巧妙方法。
在上下文管理器内部,会发生两件事:实例化一个实际的 ToyExample
场景对象,并调用 render
方法。Manim 的每种使用方式最终都遵循这些路线,库总是实例化场景对象,然后调用其 render
方法。为了说明确实如此,让我们简要看看两种最常见的场景渲染方式:
命令行界面 (CLI)。 当您在终端中使用 CLI 运行命令 manim -qm -p toy_example.py ToyExample
时,实际入口点是 Manim 的 __main__.py
文件(位于此处)。Manim 使用 Click 来实现命令行界面,相应的代码位于 Manim 的 cli
模块中(https://github.com/ManimCommunity/manim/tree/main/manim/cli)。创建场景类并调用其渲染方法的相应代码位于此处。
Jupyter Notebook。 在 Jupyter Notebook 中,与库的通信由 %%manim
魔法命令处理,该命令在 manim.utils.ipython_magic
模块中实现。有关该魔法命令的一些文档
可用,创建场景类并调用其渲染方法的代码位于此处。
现在我们知道,无论哪种方式,都会创建一个 Scene
对象,让我们来研究一下 Manim 在此时会做什么。实例化我们的场景对象时:
scene = ToyExample()
如果未实现自己的初始化方法,则调用 Scene.__init__
方法。检查相应的代码(参见此处)显示,Scene.__init__
首先设置场景对象的几个属性,这些属性不依赖于 config
中设置的任何配置选项。然后场景检查 config.renderer
的值,并根据其值实例化一个 CairoRenderer
或 OpenGLRenderer
对象,并将其分配给其 renderer
属性。
然后场景会要求其渲染器通过调用以下方法来初始化场景:
self.renderer.init_scene(self)
检查默认的 Cairo 渲染器和 OpenGL 渲染器可以发现,init_scene
方法实际上使得渲染器实例化了一个 SceneFileWriter
对象,该对象基本上是 Manim 与 libav
(FFMPEG) 的接口,并实际写入电影文件。Cairo 渲染器(参见此处的实现)不需要进一步初始化。OpenGL 渲染器会进行一些额外的设置,以启用实时渲染预览窗口,此处不再详述。
警告
目前,场景与其渲染器之间存在大量的相互作用。这是 Manim 当前架构中的一个缺陷,我们正在努力减少这种相互依赖性,以实现更不复杂的代码流。
在渲染器实例化并初始化其文件写入器之后,场景会填充更多初始属性(值得一提的是 mobjects
属性,它跟踪已添加到场景中的 Mobject)。然后,它的实例化就完成了,可以准备渲染了。
本文的其余部分关注我们的示例代码中的最后一行:
scene.render()
这正是奇迹发生的地方。
检查render 方法的实现可以发现,有几个可用于场景预处理或后处理的钩子。不出所料,Scene.render()
描述了场景的完整*渲染周期*。在这个生命周期中,有三个自定义方法,其基本实现为空,可以根据您的需要进行重写。按照它们的调用顺序,这些可定制的方法是:
Scene.setup()
,旨在为您的动画准备和*设置*场景(例如,添加初始 Mobject,为您的场景类分配自定义属性等),Scene.construct()
,这是您动画的*脚本*,包含动画的程序化描述,以及Scene.tear_down()
,旨在在最后一帧已渲染后对场景运行的任何操作(例如,这可以运行一些基于场景中对象状态生成视频自定义缩略图的代码——此钩子在 Manim 用于其他 Python 脚本的情况下更具相关性)。
在运行这三个方法之后,动画已完全渲染,Manim 调用 CairoRenderer.scene_finished()
以优雅地完成渲染过程。这会检查是否播放了任何动画——如果是,它会告诉 SceneFileWriter
关闭输出文件。如果不是,Manim 假设应该输出一个静态图像,然后它通过调用渲染循环(参见下文)一次来使用相同的策略进行渲染。
回到我们的示例, Scene.render()
的调用首先触发 Scene.setup()
(它只包含 pass
),然后调用 Scene.construct()
。此时,我们的*动画脚本*开始运行,从 orange_square
的初始化开始。
Mobject 初始化¶
简而言之,Mobject 是表示我们希望在场景中显示的所有*事物*的 Python 对象。在我们跟随调试器深入 Mobject 初始化代码之前,先讨论 Manim 不同类型的 Mobject 及其基本数据结构是有意义的。
Mobject 到底是什么?¶
Mobject
代表*数学对象 (mathematical object)* 或*Manim 对象 (Manim object)*(取决于您问谁😄)。Python 类 Mobject
是所有应在屏幕上显示的对象的基本类。查看 Mobject 的初始化方法,您会发现其中并没有发生太多事情:
分配了一些初始属性值,例如
name
(使渲染日志提及 Mobject 的名称而不是其类型)、submobjects
(最初是空列表)、color
和其他一些属性。然后,调用了两个与*点*相关的方法:
reset_points
,接着是generate_points
,最后,调用
init_colors
。
深入挖掘,您会发现 Mobject.reset_points()
只是将 Mobject 的 points
属性设置为一个空的 NumPy 向量,而另外两个方法 Mobject.generate_points()
和 Mobject.init_colors()
只是被实现为 pass
。
这是有道理的:Mobject
不应被用作在屏幕上显示的*实际*对象;事实上,摄像机(我们稍后将更详细地讨论它;对于 Cairo 渲染器,它负责“拍摄”当前场景的图片)不以任何方式处理“纯”Mobject
,它们甚至*不能*出现在渲染输出中。
这就是不同类型的 Mobject 发挥作用的地方。粗略地说,Cairo 渲染器设置知道三种可以渲染的不同类型 Mobject:
ImageMobject
,表示您可以在场景中显示的图像,PMobject
,这是用于表示点云的非常特殊的 Mobject;本指南中不再详细讨论,VMobject
,即*矢量化 Mobject*,由通过曲线连接的点组成。它们几乎无处不在,我们将在下一节中详细讨论它们。
……什么是 VMobject?¶
如前所述,VMobject
代表矢量化 Mobject。要渲染 VMobject
,摄像机查看 VMobject
的 points
属性,并将其分成每组四个点。然后,这些组中的每一个都用于构建一条三次贝塞尔曲线,其中第一个和最后一个条目描述曲线的端点(“锚点”),第二个和第三个条目描述中间的控制点(“控制手柄”)。
提示
要了解有关贝塞尔曲线的更多信息,请参阅 A Primer on Bézier curves 这本优秀的在线教科书,作者是 Pomax – 在 §1 中有一个表示三次贝塞尔曲线的游乐场,红色和黄色的点是“锚点”,绿色和蓝色的点是“控制手柄”。
与 Mobject
不同,VMobject
可以在屏幕上显示(尽管从技术上讲,它仍被视为基类)。为了说明点是如何处理的,请看以下一个 VMobject
的简短示例,它有 8 个点(因此由 8/4 = 2 条三次贝塞尔曲线组成)。生成的 VMobject
以绿色绘制。控制手柄绘制为红点,并用线连接到其最近的锚点。
示例: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)
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
方法中设置)。
警告
在某些情况下,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
不同,此VMobject
类**不会**预先生成圆上的点。
之后,事情就没那么令人兴奋了:TipableVMobject
再次设置了一些与添加箭头尖端相关的属性,然后传递到 VMobject
的初始化方法。从那里,Mobject
被初始化,并调用 Mobject.generate_points()
,它实际运行了 Arc.generate_points()
中实现的方法。
在 orange_square
和 blue_circle
都初始化之后,正方形实际上被添加到场景中。Scene.add()
方法实际上做了一些有趣的事情,因此值得在下一节深入探讨一下。
将 Mobject 添加到场景¶
接下来运行的 construct
方法中的代码是:
self.add(orange_square)
从高层次来看,Scene.add()
将 orange_square
添加到应该渲染的 Mobject 列表中,该列表存储在场景的 mobjects
属性中。但是,它以一种非常谨慎的方式这样做,以避免 Mobject 被多次添加到场景的情况。乍一看,这听起来像是一个简单的任务——问题在于 Scene.mobjects
不是一个“扁平”的 Mobject 列表,而是一个可能包含 Mobject 本身,等等的 Mobject 列表。
逐步查看 Scene.add()
中的代码,我们看到它首先检查我们当前是否正在使用 OpenGL 渲染器(我们没有)——将 Mobject 添加到场景中对于 OpenGL 渲染器来说略有不同(而且实际上更容易!)。然后,进入 Cairo 渲染器的代码分支,并将所谓的前景 Mobject 列表(它们渲染在所有其他 Mobject 的顶部)添加到传入的 Mobject 列表中。这是为了确保前景 Mobject 即使在添加新 Mobject 之后也始终位于其他 Mobject 的上方。在我们的例子中,前景 Mobject 列表实际上是空的,所以什么也没有改变。
接下来,调用 Scene.restructure_mobjects()
,并将要添加的 Mobject 列表作为 to_remove
参数传入,这最初听起来可能很奇怪。实际上,这确保了 Mobject 不会重复添加,如前所述:如果它们之前存在于场景的 Scene.mobjects
列表中(即使它们作为其他 Mobject 的子级包含在内),它们会首先从列表中移除。Scene.restructure_mobjects()
的工作方式相当激进:它始终对给定的 Mobject 列表进行操作;在 add
方法中出现了两个不同的列表:默认列表 Scene.mobjects
(没有传递额外的关键字参数)和 Scene.moving_mobjects
(我们稍后会更详细地讨论)。它遍历列表中的所有成员,并检查 to_remove
中传递的任何 Mobject 是否作为子级(在任何嵌套级别)包含在内。如果是,则**它们的父 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 列表“重组”之后,要添加的 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
了。
动画与渲染循环¶
初始化动画¶
在我们跟随调试器跟踪之前,让我们简要讨论一下(抽象)基类 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
的初始化方法。动画的其他基本属性(如其 run_time
、rate_func
等)在那里被处理——然后动画对象被完全初始化并准备好播放。
play
调用:准备进入 Manim 的渲染循环¶
我们终于到了,渲染循环触手可及。让我们来看看调用 Scene.play()
时运行的代码。
提示
请记住,本文专门讨论 Cairo 渲染器。到目前为止,OpenGL 渲染器的情况也或多或少相同;虽然某些基本 Mobject 可能不同,但 Mobject 的控制流和生命周期仍然或多或少相同。在渲染循环方面,存在更实质性的差异。
正如您在检查该方法时会看到的,Scene.play()
几乎立即传递给渲染器的 play
方法,在我们的例子中是 CairoRenderer.play
。Scene.play()
处理的一件事是您可能已传递给它的副标题管理(更多信息请参阅 Scene.play()
和 Scene.add_subcaption()
的文档)。
警告
如前所述,场景和渲染器之间的通信目前状态不佳,因此,如果您不运行调试器并亲自逐步调试代码,以下段落可能会令人困惑。
在 CairoRenderer.play()
内部,渲染器首先检查是否可以跳过当前 play 调用的渲染。例如,当将 -s
传递给 CLI 时(即只渲染最后一帧),或者当传递 -n
标志且当前 play 调用超出指定渲染边界时,可能会发生这种情况。“跳过状态”以调用 CairoRenderer.update_skipping_status()
的形式进行更新。
接下来,渲染器要求场景处理 play 调用中的动画,以便渲染器获取所需的所有信息。更具体地说,调用了 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()
,基本上它会检查是否存在任何时间相关的更新器函数等等)。最后,该方法确定 play 调用的总运行时间(此时计算为传入动画运行时间的最大值)。这存储在场景的
duration
属性中。
在场景编译动画数据后,渲染器继续准备进入渲染循环。它现在检查之前确定的跳过状态。如果渲染器可以跳过这个 play 调用,它就会这样做:它将当前的 play 调用哈希值(我们稍后会回到这一点)设置为 None
,并将渲染器的时间增加确定的动画运行时间。
否则,渲染器会检查是否应使用 Manim 的缓存系统。缓存系统的思想很简单:对于每个播放调用,都会计算一个哈希值,然后存储起来,并在重新渲染场景时再次生成该哈希值并与存储的值进行比较。如果相同,则重用缓存的输出,否则会再次完全重新渲染。我们在此不详细介绍缓存系统;如果您想了解更多信息,get_hash_from_play_call()
函数在 utils.hashing
模块中基本上是缓存机制的入口点。
如果动画必须渲染,渲染器会要求其 SceneFileWriter
打开一个输出容器。该过程通过调用 libav
启动,并打开一个容器,渲染的原始帧可以写入其中。只要输出打开,就可以通过文件写入器的 output_container
属性访问容器。在写入过程就绪后,渲染器会要求场景“开始”动画。
首先,它通过调用所有动画的设置方法(Animation._setup_scene()
, Animation.begin()
)来*开始*它们。这样做,动画新引入的 Mobject(例如通过 Create
等)被添加到场景中。此外,动画会暂停对其 Mobject 调用的更新器函数,并将其 Mobject 设置为与动画第一帧对应的状态。
在当前 play
调用中的所有动画都执行此操作后,Cairo 渲染器确定场景中的哪些 Mobject 可以静态绘制到背景,哪些 Mobject 必须每帧重新绘制。它通过调用 Scene.get_moving_and_static_mobjects()
来完成此操作,并将 Mobject 的结果分区存储在相应的 moving_mobjects
和 static_mobjects
属性中。
注意
确定静态和移动 Mobject 的机制是 Cairo 渲染器特有的,OpenGL 渲染器的工作方式不同。基本上,移动 Mobject 是通过检查它们、它们的任何子对象,或它们“下方”的任何 Mobject(在场景中 Mobject 的处理顺序意义上)是否附加了更新函数,或者它们是否出现在当前的某个动画中来确定的。有关更多详细信息,请参阅 Scene.get_moving_mobjects()
的实现。
直到目前为止,我们实际上还没有从场景中渲染任何(部分)图像或电影文件。然而,这种情况即将改变。在我们进入渲染循环之前,让我们简要回顾一下我们的示例,并讨论通用 Scene.play()
调用设置在那里是什么样子。
对于播放 ReplacementTransform
的调用,没有需要处理的副标题。然后渲染器要求场景编译动画数据:传入的参数已经是动画(无需额外准备),也无需处理任何关键字参数(因为我们没有为 play
指定任何额外的参数)。绑定到动画的 Mobject orange_square
已经存在于场景中(所以再次,没有采取任何行动)。最后,提取运行时间(3 秒长)并存储在 Scene.duration
中。然后渲染器检查是否应该跳过(不应该),然后检查动画是否已缓存(未缓存)。确定相应的动画哈希值并将其传递给文件写入器,文件写入器然后也调用 libav
开始写入过程,等待库渲染的帧。
场景随后*开始*动画:对于 ReplacementTransform
来说,这意味着动画会填充所有相关的动画属性(即起始 Mobject 和目标 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。它只需获取最新的静态帧,并在整个动画中显示它。
注意
只有当播放静态的 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)
。换句话说,时间进程持有时间戳(相对于当前动画,因此从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
命令行标志,或者某些内容已缓存,或者因为我们可能处于跳过渲染的 *Section* 中),更新器函数仍能正确运行,并且 *已* 渲染的第一帧的状态保持一致。
为了渲染图像,场景调用其渲染器的相应方法,CairoRenderer.render()
并仅传递 *移动mobject* 的列表(请记住,*静态mobject* 假定已静态绘制到场景背景)。所有艰巨的工作都在渲染器通过调用 CairoRenderer.update_frame()
更新其当前帧时发生。
首先,渲染器通过检查自身是否已存储了非 None
的 static_image
来准备其 Camera
。如果是,它通过 Camera.set_frame_to_background()
将图像设置为相机的 *背景图像*,否则它仅通过 Camera.reset()
重置相机。然后通过调用 Camera.capture_mobjects()
要求相机捕获场景。
事情在这里变得有点技术性,在某些时候,深入实现会更有效率——但以下是相机被要求捕获场景时发生的事情的总结:
首先,创建一个扁平的mobject列表(因此子mobject从其父级中提取出来)。然后,此列表按相同类型的mobject分组处理(例如,一批矢量化mobject,然后一批图像mobject,再然后是更多的矢量化mobject等——在许多情况下,只会有一批矢量化mobject)。
根据当前处理批次的类型,相机使用专用的 *显示函数* 将
Mobject
Python对象转换为存储在相机pixel_array
属性中的NumPy数组。在此上下文中最重要的例子是矢量化mobject的显示函数,Camera.display_multiple_vectorized_mobjects()
,或者更具体的(如果您没有为您的VMobject
添加背景图像),Camera.display_multiple_non_background_colored_vmobjects()
。此方法首先获取当前的Cairo上下文,然后,对于批次中的每个(矢量化)mobject,调用Camera.display_vectorized()
。在那里,mobject的实际背景描边、填充,然后是描边被绘制到上下文中。有关更多详细信息,请参阅Camera.apply_stroke()
和Camera.set_cairo_context_color()
——但深入程度不会超过此,在后一种方法中,会绘制由mobject点决定的实际贝塞尔曲线;这就是与Cairo进行底层交互的地方。
处理完所有批次后,相机拥有当前时间戳的场景图像表示,以NumPy数组的形式存储在其 pixel_array
属性中。渲染器随后获取此数组并将其传递给其 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帧已传递给文件写入器。
Completing the render loop¶
在 Scene.play_internal()
调用中最后几个步骤并不太激动人心:对于每个动画,都会调用相应的 Animation.finish()
和 Animation.clean_up_from_scene()
方法。
注意
请注意,作为 Animation.finish()
的一部分,会调用 Animation.interpolate()
方法,参数为1.0 – 您可能已经注意到,动画的最后一帧有时会有点偏差或不完整。这是当前的设计使然!渲染循环中渲染的最后一帧(并在渲染视频中显示时长为 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渲染流程,有一个相当不错的了解。