Manim文档及源码笔记-CE文档-主题化指南2深入了解Manim的内部

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的值。渲染器,并根据其值实例化CairoRendererOpenGLRenderer对象,并将其分配给其渲染器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
示例: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.radiusArc.Arc_centerArc.start_angleArc.angle),然后设置其父类TipableVMobject的初始化方法,被调用(这是一个相当抽象的基类,用于可以附加箭头的mobject)。请注意,与Polygram不同,此类不会优先生成圆的点。

在那之后,事情就不那么令人兴奋了:TipableVMobject再次设置一些与添加箭头提示相关的属性,然后传递到VMobject的初始化方法。从那里初始化Mobject,然后执行Mobject.generate_points(),它实际上运行在Arc.generate_points()中实现的方法。

初始化orange_squareblue_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_mobjectAnimation.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设置为TrueTransform然后设置属性,这些属性控制开始移动对象的点如何变形为目标移动对象的点状,然后传递到动画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_timerate_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_mobjectstatic_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开始,到总动画运行时间结束,步长由渲染帧速率确定),新动画帧应在其中渲染。

  • 然后场景在时间序列上迭代:对于每个时间戳tScene.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的更新程序函数。

此时,所有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

下一篇:
Rendering Text and Formulas

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值