编写自己的动画
前言
在前面几章的教程中,我们学习了 manim 是如何处理和生成动画的,也学习了 updater 该怎么编写。既然我们也了解了动画,那么也该开始造一点轮子了。
Animation 与 Scene
之前的文章中,我们也稍微提到了一些 Animation 和 Scene 的细节,我们再来复习一下。
准备工作1
将写在一个 play
当中的所有动画抽取出来,将没有实例化的动画都实例化2。当然,有些动画的参数被写在了 play
中,而没有写入动画的实例中,因此也需要分别注入。
开启动画
Scene 的工作
- 调用该
play
中的所有animations
的begin
方法(即跳转到下面 Animation 的工作) - 将不在场景中的
mobject
都添加到场景中
Animation 的工作
因为动画应当是持续一段时间的,所以需要明确这段动画的起止时间点 time_span
- 这个
time_span
是可以人为指定的,例如整个play
持续 3 秒,但是只需要某个动画在第 1 秒结束时开始,到第 2 秒结束时终止,那么time_span
可以指定为(1, 2)
不会吧不会吧不会还有人不知道有这个属性吧 - 一般地,我们不指定
time_span
时,默认的起止时间点就是 当前时间点 和 加上run_time
之后的时间点
如果上一步我们指定了 time_span
,也就是说在动画的 3 秒过程中,只有 1 秒的时间是给这个动画的,那么该怎么实现呢?答案是用 rate_func
.
我们将 smooth
函数变换到右边的时间轴上,缺失值分别用 start
和 end
所对应的值代替。之后的 rate_func
就使用这个变换后的了。
接下来是设置当前物件的状态为正在运行动画,相当于是一个互斥锁了,后面好像只是在 shader 渲染的部分用到了一下。这部分的源码暂时还没看,OpenGL 的内容太多了
然后是创建一个物件的初始状态 starting_mobject
,这是为了之后方便计算而生的。
suspend_mobject_updating
这个参数指定了,在这个动画中,是否允许 updater
生效,当为 True
时,那么就不允许 updater
参与这段动画。
families
成员其实我也没怎么看懂,似乎是用于嵌套的 Mobject
,对于含有子物件的 Mobject
,或者说是更广义一点的 Group
。当遇到一个组合物件的动画时,那么就对每个子物件都执行这段动画。
最后,把物件的呈现状态设置为运动的开始,即 anim.interpolate(0)
动画的过程
Scene 所做的工作
在 progress_through_animations
这个方法中,首先根据动画的时长,生成一段时间轴,能够刚好填入 play
中的所有动画实例。
我们知道,视频是由很多个帧组成的,那么动画生成的原理就很简单,对于这个时间片段中,每秒采样 次, 即为帧率。计算出每一帧的画面,然后将得到的结果输出给图形接口即可。
def progress_through_animations(self, animations: Iterable[Animation]): last_t = 0 # 对每一帧进行采样 for t in self.get_animation_time_progression(animations): dt = t - last_t last_t = t # 动画的处理 for animation in animations: animation.update_mobjects(dt) alpha = t / animation.run_time animation.interpolate(alpha) # 更新相机拍摄到的画面,以及刷新缓冲之类的操作 self.update_frame(dt) # 输出给图形接口 self.emit_frame()
Animation 所做的工作
上面的代码中,animation
做了两件事。第一个是 update_mobjects
,它的作用是按照 mobject
包含的 updater
来更新,updater
也就是在这里完成了它的职能。
第二个是 interpolate
,其实就是动画实际执行的部分了。在这一步中,alpha
经过 rate_func
的变换之后,传入了 interpolate_mobject
,方便之后可能会用到对嵌套物件进行递归处理。其中调用的 interpolate_submobject
是空的,需要我们后续去实现。
def interpolate_submobject( self, submobject: Mobject, starting_submobject: Mobject, alpha: float): # Typically ipmlemented by subclass pass
实现方法其实也很简单,就是根据已有的参数 submobject
, starting_submobject
, alpha
参数,以及可能会用到的成员变量,更改 submobject
这个对象,让它成为动画过程中对应时间点理应成为的状态。
Note
Grant 在这里巧妙地使用了 *mobs
将这个元组展开,简直就是黑魔法,在这里看上去是把一个二元组展开为 submobject
和 starting_submobject
两个元素,但是在函数重载之后,会有更加巧妙的用法。
def interpolate_mobject(self, alpha: float) -> None: for i, mobs in enumerate(self.families): sub_alpha = self.get_sub_alpha(alpha, i, len(self.families)) self.interpolate_submobject(*mobs, sub_alpha)
动画的结束
Scene 其实要做的事情很简单,就是调用每个 animation
的 finish
方法,并将可能需要移除的物件都从场景中移除。
Animation 的工作其实也不多,就是将 alpha
设置为结束时的值,并且解除正在运行动画的状态。如果前面有禁用 updater
的物件,那么就把它的 updater
恢复回来。
浅析 Transform 的原理
我们常用的 Transform 一般来说是这么用的(虽然用的更多的是 ReplacementTransform
,但只是看动画原理的话差别不大)
class ExampleScene(Scene); def construct(self): # ... self.play(Transform(A, B))
初始化
这里高亮的语句中,其实创建了 Transform
动画的实例,也就是说隐含的调用了它的 __init__
方法,做了一些初始化。
- 调用父类
Animation
的构造函数 - 将
target_mobject
加入到成员变量中,以便动画中操作 - 初始化
path_func
,会根据path_arc
参数的值来计算- 如果不指定
path_arc
,那么路径将会是一条直线 - 如果指定了,那么路径将会是圆心角为
path_arc
的圆弧
- 如果不指定
动画开始
首先是做一些检查、拷贝之类的准备工作,避免在某些函数调用中会更改本来不应该更改的成员属性。
self.mobject.align_data_and_family(self.target_copy)
这一行是为了 Transform 的核心——插值——而生的。我们在前面也学习到,所有的 VMobject
都是由许多贝塞尔曲线构成的,而这些贝塞尔曲线又少不了它的控制点。所以如果直接操作这些控制点(锚点),那么就可以直接改变呈现出的物件形状。
而 Transform 的本质,实际上也就是将两个物件对应的锚点一一匹配起来,然后按照给定的轨迹,从一端运动到另一端。align_data_and_family
这个方法就是让物件的初始状态和终止状态的锚点等属性相互匹配,以便每个配对都可以按照给定的规则来执行插值。
Quote
在我之前的视频 3:00 处提到的“对于锚点较少的那个图形的锚点进行插值”其实也不能说很准确,但其实也就是上面的意思。
在调用父类的 begin
函数之后,有这样一个操作
if not self.mobject.has_updaters: self.mobject.lock_matching_data( self.starting_mobject, self.target_copy, )
这个 lock_matching_data
是为了改善计算时的性能而存在的,避免在后面的 interpolate
中进行过多的重复运算。
动画过程
我们前面说到动画过程只需要关注 interpolate_mobject
和 interpolate_submobject
,我们来看一下 Transform 对它的实现。
def interpolate_submobject( self, submob: Mobject, start: Mobject, target_copy: Mobject, alpha: float): submob.interpolate(start, target_copy, alpha, self.path_func) return self
欸,我记得父类的这个方法接收的是 3 个参数啊,为什么在这里多出来了一个 target_copy
呢?实际上,这就是父类 interpolate_mobject
方法中 *mobs
这个展开式的“灵活应用”。
在 Transform 中,重载的不仅仅是插值函数,还有 get_all_families_zipped
,这个函数就负责创建 families
成员。我们来看看它的重载实现。
def get_all_families_zipped(self) -> zip[tuple[Mobject]]: return zip(*[ mob.get_family() for mob in [ self.mobject, self.starting_mobject, self.target_copy, ] ])
可以看到,其实这里的 families
就变成了三元组的列表了,而不是前面提到的二元组列表。这样,上面 interpolate_submobject
方法也不能说是 override,而是重新创建了一个新的同名不同形参的函数。
在这个方法里面,实际上还是调用了 submob
的 interpolate
方法,而它本质上还是直接修改 submob
的属性,达到那种补间的效果。
def interpolate( self, mobject1: Mobject, mobject2: Mobject, alpha: float, path_func: Callable[[np.ndarray, np.ndarray, float], np.ndarray]): for key in self.data: # ... 一些性能优化和预处理 ... if key in ("points", "bounding_box"): func = path_func else: func = interpolate
self.data[key][:] = func( mobject1.data[key], mobject2.data[key], alpha ) for key in self.uniforms: self.uniforms[key] = interpolate( mobject1.uniforms[key], mobject2.uniforms[key], alpha ) return self
上面代码中高亮的部分,实际上就是直接修改 Mobject 的属性,是非常简单的 interpolate
.
动画的结束
剩余的清扫工作其实非常简单,就是调用父类的 finish
方法,并解除 locked_data
.
现在,如果读者现在回看我之前写到的 Write 的工作原理,应该就相当明了了吧。
编写自定义的动画
正篇终于开始了。
旋转淡入
如果有看过我做过的视频教程的读者,应该对 turn_animation_into_updater
还有印象,但这次我们不用它,而使用纯写动画的方法。
初始化
首先是它该如何构造,我们很轻松地就能写出它的 __init__
函数。
class RotateFadeIn(Animation): def __init__( self, mobject: Mobject, # 需要淡入的物件 angle: float = PI, # 旋转的角度 axis: np.ndarray = UP, # 绕哪个轴旋转 **kwargs ): self._angle = angle self._axis = axis super().__init__(mobject, **kwargs)
Caution
至于为什么我不用 CONFIG
字典来写入参数,是因为在撰写这篇文章的时候,Grant 已经在一个 PR 中把 CONFIG
字典给扬了。为了避免各种版本之间的冲突,我就直接用成员变量了。
begin
想要物件最终旋转到正确的角度,那么必须在一开始,让初始状态向反方向旋转相应的角度,那么也就有了这样的实现。
# 使用 `rotate` 方法def begin(self): self._cached_angle = 0 super().begin() self.mobject.rotate(-self._angle, self._axis)
# 使用 `become` 方法def begin(self): super().begin() self.starting_mobject.rotate(-self._angle, self._axis)
interpolate
在对 alpha 的每个采样时,都先让 mobject
恢复初态,这里我采用的是旋转回到原来的位置,当然,如果大家对 API 比较熟悉的话,可以直接用 become
这个方法。
# 使用 `rotate` 方法def interpolate_mobject(self, alpha: float): self.mobject.rotate(-self._cached_angle, self._axis) # 向原来的方向旋转 self._cached_angle = alpha * self._angle # 计算新的角度 self.mobject.rotate(self._cached_angle, self._axis) self.mobject.set_opacity(alpha)
# 使用 `become` 方法def interpolate_mobject(self, alpha: float): self.mobject.become(self.starting_mobject) # 用初始状态覆盖当前状态 self.mobject.rotate(alpha * self._angle, self._axis) # 旋转对应角度 self.mobject.set_opacity(alpha)
finish
其实甚至都不用写,因为 interpolate 最后一步已经基本上都做完了,交给父类去做就可以了。
测试
class RotateFadeInExample(Scene): def construct(self) -> None: r = Text("Rotate", font="Jetbrains Mono", slant=ITALIC).scale(3) self.play(RotateFadeIn(r, run_time=3))
一个复杂一点的例子
——这真的是用 manim 写出来的吗?
——是的,源码在 GitHub 仓库。
因为我懒,所以感兴趣的读者直接看源码吧,当时也只是灵感乍现随便写的,各种参数调的还不是很好,如果之后有机会的话那我就把这个坑填上吧。
Footnotes
#manim
#动画
#updater