A deep dive into Manim’s internals
深入了解Manim的内部
作者:Benjamin Hackl
免责声明
本指南反映了从v0.16.0版开始的库的状态,并主要讨论Cairo渲染器。最新版本的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))
在我们深入细节甚至查看该场景的渲染输出之前,让我们首先口头描述一下在该模拟中发生了什么。在构造construct
方法的前三行中,初始化正方形Square
和圆形Circle
,然后将正方形添加到场景中。因此,渲染输出的第一帧应该显示一个橙色正方形。
然后,实际的动画发生了:正方形首先变换成一个圆,然后创建一个点Dot
(当点第一次添加到场景中时,你猜它位于哪里?回答这个问题已经需要有关渲染过程的详细知识)。圆点上附有一个更新程序,当圆向右移动时,圆点也随之移动。最后,所有的mobject都消失了。
实际渲染代码会生成以下视频:
对于这个例子,输出(幸运的)与我们的期望相符。
概述
由于本文中有大量信息,因此这里对以下章节的内容进行了简要概述,从很高的层次上进行了讨论。
-
预备:在本章中,我们将介绍为渲染场景做准备的所有步骤;直到运行用户重写的构造
construct
方法为止。本文简要讨论了使用Manim的CLI与其他渲染方法的比较(例如,通过Jupyter笔记本,或通过自己调用Scene.render()
方法在Python脚本中)。 -
Mobject初始化:在第二章中,我们将深入讨论创建和处理Mobject,这是场景中应该显示的基本元素。我们讨论了
Mobject
基类,讨论了基本上有三种不同类型的Mobject,然后讨论了其中最重要的一种,矢量化Mobject。特别是,我们描述了内部点数据结构,该结构控制负责将矢量化移动对象绘制到屏幕的机制如何设置相应的Bézier曲线。我们以Scene.add()
结束本章,簿记机制,控制应该呈现哪些mobject。 -
动画和渲染循环:最后,在最后一章中,我们介绍了动画
Animation
对象的实例化(包含渲染循环运行时应如何修改移动对象的信息的蓝图),然后研究了臭名昭著的Scene.play()
调用。我们将看到场景中有三个相关部分。play()调用;处理和准备传递的动画和关键字参数的部分,然后是实际的“渲染循环”,在该循环中,库逐步通过时间线并逐帧渲染。最后一部分进行一些后处理,以保存短视频片段(“部分电影文件”)并进行清理,以便下次调用Scene.play()
。最后,在所有的场景Scene.construct()
之后已运行,库将部分电影文件合并到一个视频中。
接下来,让我们进入medias res。
预备工作
导入库
独立于您告诉系统渲染场景的确切方式,即是否运行manim -qm -p file_name.py ToyExample
,或者是否通过类似的代码片段直接从Python脚本渲染场景
with tempconfig({"quality": "medium_quality", "preview": True}):
scene = ToyExample()
scene.render()
或者,无论您是在Jupyter笔记本中呈现代码,您仍然在告诉python解释器导入库。通常的模式是
from manim import *
这(虽然通常是一种有争议的策略)导入了库附带的许多类和函数,并使它们在您的全局名称空间中可用。我明确避免声明它导入库的所有类和函数,因为它没有这样做:Manim使用Python教程第6.4.1节中描述的实践,并且在运行*
-import时应向用户公开的所有模块成员都在模块的__all__
变量中显式声明。
Manim在内部也使用这种策略:查看在调用导入时运行的文件,即__init__.py
(请参见此处),您会注意到该模块中的大多数代码都与从不同的子模块导入成员有关,再次使用*
-导入。
暗示
如果您想为Manim贡献一个新的子模块,那么主__init__.py
是必须列出的位置,以便在导入库后用户可以访问其成员。
然而,在该文件中,文件开头有一个特定的导入,即:
from ._config import *
这初始化了Manim的全局配置系统,该系统用于整个图书馆的各个地方。库运行此行后,将设置当前配置选项。其中的代码负责读取.cfg
文件中的选项(所有用户至少都有库附带的全局选项)以及正确处理命令行参数(如果使用CLI进行渲染)。
您可以在相应的主题指南中阅读有关配置系统的更多信息,如果您有兴趣了解更多关于配置系统内部以及如何初始化配置系统的信息,请遵循从配置模块的init文件开始的代码流。
现在库已经导入,我们可以将注意力转移到下一步:阅读场景代码(这并不特别令人兴奋,Python只是基于我们的代码创建了一个新的类ToyExample
;Manim实际上不参与该步骤,但ToyeXsample
从场景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的每种方法最终都会沿着这些路线进行操作,库始终实例化场景对象,然后调用其渲染方法。为了说明情况确实如此,让我们简要看看渲染场景的两种最常见的方法:
命令行界面。使用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笔记本。在Jupyter笔记本中,与库的通信由%%manim
magic命令处理,该命令在manim.utils.ipython_magic
模块中实现。有一些文档some documentation
可用于magic命令,创建场景类并调用其渲染方法的代码位于此处。
既然我们知道了这两种方式中的任何一种,场景Scene
对象都是创建的,那么让我们研究一下Manim在发生这种情况时会做什么。实例化场景对象时
scene = ToyExample()
鉴于我们没有实现自己的初始化方法,因此调用了Scene.__init__
方法。检查相应的代码(请参见此处)可以发现该场景Scene.__init__
首先设置场景对象的多个属性,这些属性不依赖于配置中设置的任何配置选项。然后,场景检查config
的值。渲染器,并根据其值实例化CairoRenderer
或OpenGLRenderer
对象,并将其分配给其渲染器renderer
属性。
然后,场景要求其渲染器通过调用
self.renderer.init_scene(self)
检查默认Cairo渲染器和OpenGL渲染器表明,init_scene
方法有效地使渲染器实例化SceneFileWriter
对象,该对象基本上是Manim与ffmpeg
的接口,并实际写入电影文件。Cairo渲染器(请参阅此处的实现)不需要任何进一步的初始化。OpenGL渲染器进行了一些额外的设置,以启用实时渲染预览窗口,我们在此不再详细介绍。
警告
目前,场景及其渲染器之间存在很多相互作用。这是Manim当前架构中的一个缺陷,我们正在努力减少这种相互依赖性,以实现更少复杂的代码流。
在渲染器实例化并初始化其文件编写器后,场景会填充更多初始属性(值得一提的是:mobjects属性,它跟踪已添加到场景中的mobject)。然后完成实例化并准备呈现。
本文的其余部分涉及我们的玩具示例脚本中的最后一行:
scene.render()
这就是真正的魔法发生的地方。
检查渲染方法的实现后发现,有几个挂钩可用于场景的预处理或后处理。不出所料,Scene.render()
描述场景的完整渲染周期。在此生命周期中,有三个自定义方法的基本实现为空,可以覆盖它们以满足您的目的。按照调用顺序,这些可自定义方法包括:
Scene.setup()
,用于为动画准备和设置场景(例如,添加初始移动对象,为场景类指定自定义属性等),Scene.construct()
,它是屏幕播放的脚本,包含动画的编程描述,以及Scene.tear_down()
,用于在渲染最后一帧后可能希望在场景上运行的任何操作(例如,这可以运行一些代码,根据场景中对象的状态为视频生成自定义缩略图-此挂钩更适用于在其他Python脚本中使用Manim的情况)。
运行这三种方法后,动画已完全渲染,Manim调用CairoRenderer.scene_finished()
以优雅地完成渲染过程。这会检查是否播放了任何动画,如果播放了,它会告诉SceneFileWriter
关闭ffmpeg
的管道。如果没有,Manim假设应该输出静态图像,然后通过调用渲染循环(见下文)一次,使用相同的策略进行渲染。
回到我们的玩具示例中,调用Scene.render()
首先触发Scene.setup()
(仅包含pass
),然后调用Scene.construct()
。此时,我们的动画脚本将运行,从初始化orange_square开始。
Mobject初始化
简而言之,Mobjects是Python对象,表示我们希望在场景中显示的所有内容。在我们深入了解mobject初始化代码之前,有必要讨论Manim的不同类型的mobject及其基本数据结构。
什么是Mobject?
Mobject
代表数学对象或Manim对象(取决于你问谁😄). Python类Mobject
是应该显示在屏幕上的所有对象的基类。查看Mobject
的初始化方法initalization method
,您会发现其中发生的事情并不多:
- 分配了一些初始属性值,如名称
name
(使渲染日志提及mobject的名称而不是其类型)、子mobjectsubmojects
(最初为空列表)、颜色color
等。 - 然后,调用与点相关的两种方法:重置_点
reset_points
,然后生成_点generate_points
, - 最后,调用
init_colors
。
深入挖掘,你会发现那个Mobject.reset_points()
简单地将mobject的points
属性设置为空的NumPy向量,而其他两个方法是Mobjectgenerate_points()
和Mobject.init_colors()
只是作为pass
实现的。
这很有道理:Mobject
不应该用作屏幕上显示的实际对象;事实上,摄影机(我们将在后面更详细地讨论;对于Cairo渲染器,它是负责“拍摄”当前场景的类)不会以任何方式处理“纯”Mobject
,它们甚至不能出现在渲染输出中。
这就是不同类型的mobject发挥作用的地方。粗略地说,Cairo渲染器设置知道可以渲染的三种不同类型的mobject:
ImageMobject
,表示可以在场景中显示的图像,PMobject
,这是用于表示点云的非常特殊的对象;我们将在本指南中不再进一步讨论,VMobject
是矢量化的移动对象,即由通过曲线连接的点组成的移动对象。这些几乎无处不在,我们将在下一节详细讨论。
…什么是VMobjects?
如前所述,VMobjects
表示矢量化的mobject。要渲染VMobject
,相机会查看VMobject
的“点”points
属性,并将其分为四个点集。然后,使用这些集合中的每一个来构造三次Bézier曲线,第一个和最后一个条目描述曲线的端点(“锚点”),第二个和第三个条目描述中间的控制点(“手柄”)。
暗示
要了解更多关于Bézier曲线的信息,请阅读由Pomax编写的优秀在线教科书a Primer on Bémier
curves–在§1中有一个代表立方Béjier曲线的操场,红色和黄色点是“锚”,绿色和蓝色点是“手柄”。
与Mobject
不同,VMobject
可以显示在屏幕上(尽管从技术上讲,它仍然被视为基类)。为了说明点是如何处理的,请考虑以下具有8个点(因此由8/4=2个三次Bézier曲线组成)的VMobject
的简短示例。生成的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)
警告
通常不鼓励手动设置VMobject
的点;有一些专门的方法可以为您解决这一问题,但在实现您自己的自定义VMobject
时可能会用到。
正方形和圆形:回到我们的玩具示例,Squares and Circles: back to our Toy Example
对不同类型的移动对象有了基本的了解,并了解了矢量化移动对象是如何构建的,现在我们可以回到我们的玩具示例和执行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
,因此下一个方法称为rectandle.__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
),等等。在深入了解调试程序之前,让我们先观察构造的多边形会发生什么:剩下的两条线拉伸多边形以适合指定的宽度和高度,从而创建一个具有所需测量值的矩形。
多边形的初始化函数特别简单,它只调用其父类Polygram
的初始化函数。在这里,我们几乎已经走到了这条链的尽头:Polygram
继承自VMobject
,其初始化函数主要设置一些属性的值(与Mobject.__init__
非常相似,但更具体地说,是组成Mobject的Bézier曲线)。
在调用VMobject
的初始化函数后,Polygram
的构造函数也做了一些奇怪的事情:它设置点(您可能记得上面的内容,实际上应该在polygram
的相应generate_points
方法中设置)。
警告
在一些情况下,mobjects的实现并没有真正坚持到Manim接口的所有方面。这是不幸的,增加一致性是我们积极努力的事情。欢迎帮助!
在不太详细的情况下,Polygram
通过VMobject.start_new_path()
设置其点points
属性,VMobject.add_point_as_corners()
,用于适当设置锚点和控制柄的四倍。设置点后,Python继续处理调用堆栈,直到到达第一次调用的方法;正方形Square
的初始化方法。在此之后,初始化正方形并将其分配给orange_square
变量。
blue_circle
的初始化类似于orange_square
的初始化,主要区别在于圆Circle
的继承链不同。让我们简要介绍一下调试器的跟踪:
Circle.__init__
的实现,立即调用圆弧Arc
的初始化方法,因为Manim中的圆只是一个角度为
τ
=
2
π
\tau=2\pi
τ=2π的圆弧。初始化圆弧时,设置一些基本属性(如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()
的方法中,实际上做了一些有趣的事情,因此值得在下一节中深入挖掘。
将移动对象添加到场景中,Adding Mobjects to the Scene
接下来运行的构造construct
方法中的代码是
self.add(orange_square)
从高层次的角度来看,Scene.add()
将橙色方形orange_square
添加到应渲染的移动对象列表中,该列表存储在场景的“移动对象”mobjects
属性中。然而,它以非常谨慎的方式这样做,以避免将mobject多次添加到场景中的情况。乍一看,这听起来像是一项简单的任务——问题是Scene.mobject
不是mobject的“平面”列表,而是可能包含mobject本身的mobject列表,等等。
在Scene.add()
中逐步执行代码,我们看到,首先检查我们当前是否正在使用OpenGL渲染器(我们没有使用),将mobjects添加到场景中的效果稍有不同(实际上更容易!)对于OpenGL渲染器。然后,输入Cairo渲染器的代码分支,并将所谓的前景mobject列表(在所有其他mobject之上渲染)添加到传递的mobject的列表中。这是为了确保前景移动对象将保持在其他移动对象之上,即使在添加新的移动对象之后。在我们的例子中,前景mobject列表实际上是空的,没有任何变化。
接下来是场景。使用要添加为to_remove
参数的mobject列表调用Scene.restructure_mobjects()
,一开始听起来可能很奇怪。实际上,这确保了移动对象不会像上面提到的那样添加两次:如果它们出现在场景中。mobject列表之前(即使它们作为其他mobject的子对象包含),它们首先从列表中删除。Scene.restrucutre_mobjects()
的工作方式相当激进:它总是对给定的mobject列表进行操作;在add方法中,会出现两个不同的列表:默认列表,Scene.mobjects
(不传递额外的关键字参数)和场景。moving_mobjects(我们将在后面更详细地讨论)。它遍历列表的所有成员,并检查传递给_remove的任何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
list实际上是空的,因此restructure_mobjects
方法实际上什么都不做。orange_square
只是简单地添加到Scene.mobjects
中,以及上述场景。此时,moving_mobjects列表也仍然为空,没有任何事情发生和Scene.add()
返回。
在讨论渲染循环时,我们将听到更多关于moving_mobject
列表的信息。在此之前,让我们看看玩具示例中的下一行代码,其中包括动画类的初始化,
ReplacementTransform(orange_square, blue_circle, run_time=3)
因此,现在是讨论动画的时候了。
动画和渲染循环
初始化动画
在跟踪调试器之前,让我们简要讨论(抽象)基类动画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
然后设置属性,这些属性控制开始移动对象的点如何变形为目标移动对象的点状,然后传递到动画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()
内部,渲染器首先检查是否可以跳过当前播放调用的渲染。例如,当将-s
传递给CLI时(即,只应渲染最后一帧),或者当传递了-n
标志且当前播放调用超出指定的渲染边界时,可能会发生这种情况。“跳过状态”以调用CairoRenderer.update_skipping_status()
的形式更新。
接下来,渲染器要求场景在播放调用中处理动画,以便渲染器获得其所需的所有信息。更具体地说,调用Scene.compile_animation_data()
,然后处理以下几件事:
- 该方法处理传递到初始
Scene.play()
的所有动画和关键字参数调用。特别是,这意味着它确保传递给play调用的所有参数实际上都是动画(或.animate
语法调用,在该点上也被组装为实际的动画Animation
对象)。它还传播传递给场景的任何与动画相关的关键字参数(如run_time
或rate_func
)。播放每个单独的动画。然后,处理后的动画存储在场景的“动画”(animations
)属性中(渲染器稍后读取该属性…)。 - 它将播放的动画绑定到的所有mobject添加到场景中(前提是该动画不是引入动画的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调用更新程序函数,并将其mobObject设置为对应于动画第一帧的状态。
在当前播放play
调用中的所有动画都发生这种情况后,Cairo渲染器确定哪些场景的mobject可以静态绘制到背景中,哪些必须在每一帧重新绘制。它通过调用Scene.get_moving_and_static_mobjects()
来实现,得到的mobject分区存储在相应的moving_mobject
和static_mobjects
属性中。
笔记
确定静态和移动对象的机制特定于Cairo渲染器,而OpenGL渲染器的工作方式不同。基本上,移动移动对象是通过检查它们、它们的任何子对象或它们“下面”的任何移动对象(从场景中处理移动对象的顺序来看)是否附加了更新功能,或者它们是否出现在当前动画之一中来确定的。参见场景的实现Scene.get_moving_mobjects()
。获取更多详细信息。
到目前为止,我们实际上还没有从场景中渲染任何(部分)图像或电影文件。然而,这种情况即将改变。在进入渲染循环之前,让我们简要回顾一下我们的玩具示例,并讨论如何创建通用Scene.play()
调用设置如下所示。
对于播放ReplacementTransform
的调用,没有需要处理的子选项。然后,渲染器要求场景编译动画数据:传递的参数已经是动画(不需要额外准备),不需要处理任何关键字参数(因为我们没有指定要播放play
的任何其他参数)。绑定到动画的mobject,orange_square,已经是场景的一部分(同样,没有采取任何行动)。最后,提取运行时间(3秒长)并存储在Scene.duration
中。渲染器然后检查是否应该跳过(不应该),然后检查动画是否已经缓存(不应该)。确定相应的动画散列值并将其传递给文件编写器,然后文件编写器也会调用ffmpeg
以开始写入过程,该过程等待来自库的渲染帧。
然后场景开始动画:对于置换变换(ReplacementTransform
),这意味着动画填充其所有相关动画属性(即,开始和目标移动对象的兼容副本,以便可以安全地在两者之间插值)。
确定静态和移动移动对象的机制考虑了所有场景移动对象(此时只有orange_square
),并确定orange_square
绑定到当前播放的动画。因此,该广场被归类为“移动物体”。
渲染一些帧的时间。
渲染循环(这次是真实的)
如上所述,由于确定场景中静态和移动移动对象的机制,渲染器知道可以将哪些移动对象静态绘制到场景背景中。实际上,这意味着它会部分渲染场景(以生成背景图像),然后在动画的时间进程中迭代时,仅在静态背景的顶部重新绘制“移动对象”。
渲染器调用CairoRenderer.save_static_frame_data()
,它首先检查当前是否存在任何静态mobject,如果存在,则更新帧(仅使用静态mobjects;更多关于如何在瞬间准确工作的信息),然后在static_image
属性中保存表示渲染帧的NumPy数组。在我们的玩具示例中,没有静态mobject,因此static_image
属性仅设置为None
。
接下来,渲染器询问场景当前动画是否为“冻结帧”动画,这意味着渲染器实际上不必在时间进程的每个帧中重新绘制移动的mobject。然后,它可以只获取最新的静态帧,并在整个动画中显示它。
笔记
如果仅播放静态等待Wait
动画,则动画被视为“冻结帧”动画。请参见上面的Scene.compile_animation_data()
描述,或Scene.should_update_mobjects()
实现的更多细节。
如果不是这样(就像在我们的玩具示例中一样),渲染器然后调用Scene.play_internal()
方法,它是渲染循环的组成部分(在该循环中,库逐步遍历动画的时间进程并渲染相应的帧)。
在Scene.play_internal()
,执行以下步骤:
-
场景通过调用
scene.get_run_time()
确定动画的运行时间。该方法基本上采用传递到场景的所有动画的最大运行时间run_time
属性Scene.play()
调用。 -
然后通过(内部)场景构建时间序列
Scene._get_animation_time_progression()
方法,用于包装实际场景。get_time_progression()
方法。时间进程是np.arange(0, run_time, 1/config.frame_rate)
上迭代器的tqdm
进度条。换言之,时间进度保留了时间线的时间戳(相对于当前动画,因此从0开始,到总动画运行时间结束,步长由渲染帧速率确定),新动画帧应在其中渲染。 -
然后场景在时间序列上迭代:对于每个时间戳
t
,Scene.update_to_time()
,其中… -
- …首先计算自上次更新以来经过的时间(可能为0,尤其是对于初始调用),并将其引用为
dt
,
- …首先计算自上次更新以来经过的时间(可能为0,尤其是对于初始调用),并将其引用为
-
- 然后(按照动画传递到
Scene.play()
的顺序)调用Animation.update_mobjects()
触发附加到各个动画的所有更新程序函数,但动画的“主mobject”除外(例如,用于转换Transform
开始和目标mobject的未修改副本–请参阅Animation.get_all_mobjects_to_update()
更多详细信息),
- 然后(按照动画传递到
-
- 然后计算相对于当前动画的相对时间进度(
alpha=t / Animation.run_time
),然后使用该时间进度通过调用animation.interpolate()更新动画的状态。
- 然后计算相对于当前动画的相对时间进度(
-
- 处理完所有传递的动画后,将运行场景中所有mobject、所有网格以及最后附加到场景本身的那些mobject的更新程序函数。
此时,所有mobject的内部(Python)状态都已更新,以匹配当前处理的时间戳。如果不应跳过渲染,那么现在是拍照的时候了!
笔记
内部状态的更新(在时间进程上的迭代)总是发生一次。输入Scene.play_internal()
。这确保了即使不需要渲染帧(例如,因为传递了-n
CLI标志,缓存了某些内容,或者因为我们可能在跳过渲染的部分中),更新程序功能仍能正确运行,并且渲染的第一个帧的状态保持一致。
为了渲染图像,场景调用其渲染器的相应方法CairoRenderer.render()
并仅传递移动移动对象的列表(请记住,假设静态移动对象已静态绘制到场景背景中)。然后,当渲染器通过调用CairoRenderer.update_frame()
更新其当前帧时,所有的困难工作都会发生:
首先,渲染器通过检查渲染器是否具有与已存储的图像不同的静态_图像static_image
来准备其相机Camera
。如果是,则通过Camera.set_frame_to_background()
将图像设置为摄像机的背景图像,否则它只会通过camera.reset()
重置摄像机。然后要求摄像机通过调用camera.camture_mobjects()
捕捉场景。
在这里,事情变得有点技术性,在某种程度上,深入研究实现更为有效——但以下是对要求摄像机捕捉场景后发生的情况的总结:
- 首先,创建一个mobject的平面列表(这样子mobject就可以从其父对象中提取出来)。然后按相同类型的移动对象分组处理该列表(例如,一批矢量化的移动对象,然后是一批图像移动对象,接着是更多的矢量化移动对象,等等–在许多情况下,将只有一批向量化移动对象)。
*根据当前处理批次的类型,摄像机使用专用的显示功能将Mobject
Python对象转换为存储在摄像机像素数组pixel_array
属性中的NumPy数组。在这种情况下,最重要的例子是矢量化移动对象的显示功能,即Camera.display_multiple_vectorized_mobjects()
或更具体的(如果您没有将背景图像添加到VMobject
中)Camera.display_multiple_non_background_colored_vmobjects()
。该方法首先获取当前Cairo上下文,然后对批处理中的每个(矢量化)mobject调用Camera.display_vectoriated()
。在那里,实际的背景笔划、填充,然后将mobject的笔划绘制到上下文中。参见Camera.apply_stroke()
和Camera.set_cairo_context_color()
以获取更多详细信息–但它不会比这更深,在后一种方法中,将绘制由mobject点确定的实际Bézier曲线;这就是与Cairo进行低级别交互的地方。
在处理完所有批次后,摄像机在当前时间戳处具有场景的图像表示,其形式为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()
的一部分,Animation.interpolate()
使用参数1.0调用方法–您可能已经注意到,动画的最后一帧有时可能有点偏离或不完整。这是当前的设计!渲染循环中渲染的最后一帧(并在渲染视频中显示1/frame_rate
秒的持续时间)对应于动画结束前1/frame_rate
秒的状态。为了在视频中也显示最终帧,我们需要在视频中附加1/frame_速率秒,这意味着1秒渲染的Manim视频将略长于1秒。我们在某个时候决定不这么做。
最后,时间进程在终端中关闭(完成显示的进度条)。随着时间进程的结束, Scene.play_internal()
调用完成后,我们返回渲染器,渲染器现在命令SceneFileWriter
关闭为此动画打开的电影管道:写入部分电影文件。
这差不多结束了一个Scene.play
的漫游,实际上对于我们的玩具示例也没有太多要说的:此时,已经编写了一个表示播放ReplacementTransform
的部分电影文件。点Dot
的初始化类似于上面讨论的blue_circle
的初始化。Mobject.add_updater()
调用实际上只是将一个函数附加到小点的updaters
属性。还有剩下的Scene.play()
和Scene.wait()
调用遵循与上面渲染循环部分中讨论的完全相同的过程;每个这样的调用都会生成相应的部分电影文件。
一旦Scene.construct()
方法已完全处理(因此所有相应的部分电影文件都已写入),场景调用其清理方法scene.tear_down()
,然后要求其渲染器完成场景。反过来,渲染器要求其场景文件编写器通过调用SceneFileWriter.finish()
来包装内容,它会将部分电影文件组合到最终产品中。
瞧瞧!这或多或少是对Manim如何在引擎盖下工作的详细描述。虽然我们在本演练中没有详细讨论每一行代码,但它仍然可以让您很好地了解库的一般结构设计,尤其是Cairo渲染流程。
前一篇:
Configuration