文章 | 机核 GCORES ( ) • 2024-05-10 12:39
在早前的文章中,我们用特别笨的办法来实现了帧动画(或者说“逐帧动画”)。
玩家朋友想必有所耳闻,游戏看似连贯、流畅的画面实际上是GPU(或者CPU)在不断地做算术题,然后把得到的图片不断放到显示器上,这个过程是如此之快,以至于我们会认为看到的本来就是连贯的动画。帧动画也是类似地不断替换显示的图片来达到动画的效果。不过可以预见帧动画需要艺术家绘制足够多的帧才能达到流畅的效果。
在使用数字技术制作动画时,除了帧动画之外还有一种常用的动画技术就是关键帧(key frame或keyframe,也作动词)动画。在以骨骼(rig)控制的动画中尤为常见。实际上关键帧动画不只是在游戏、动画中常见,在各种UI开发工具、Web开发中也大量应用。
关键帧,顾名思义是一段动画中关键时刻对应的帧。在关键帧动画中,我们通常会确定一段动画的始末时间点,然后确定这两个时间点上某个(或某些)属性变化前后的值。而在这两个时间点中间的值,就交给计算机给我们算出来。
Blender动画制作界面掠影,我们稍后会在Godot中看到极为相似的界面 Blender动画制作界面掠影,我们稍后会在Godot中看到极为相似的界面

插值

在2D空间中,假设我要你在点(0, 0)和(2, 2)之间画一条曲线段(直线段也是曲线段的一种),你会怎么画?
毫无疑问你有无数种画法。不过假设,我需要你求得在x为0.5、0.25、0.75、1.25等值时,在你所画的曲线上的点(的y值)——你可能就想画点更规矩的曲线了。

线性插值

于是乎,你在这两点之间画了一条直线(段)——一条足够简单的曲线。你用上了小学学到的知识,求得这条直线的表达式为y=x。现在在0到2的范围内,你可以根据给定的x和这个表达式求得对应的在这条线上的任意一个点(也就是对应的y值)。
我们根据给定的两个点,然后在这两个点之间(按照某种特定方法)找到了更多的点。恭喜你,你刚刚完成了人力线性插值。

这和动画有什么关系呢?

为了做一个简单水平平移动画,比如两秒内把一个东西在水平方向上从50移动到150。那么我们的问题就转化成了这样:为了把position.x从50变到150,在两秒内的每一帧中应该将position.x设置为多少?
问题进一步转化,假设在二维坐标系中,x代表的是帧(或者说帧对应的时间),y代表的是某个东西的水平位置,并且我们知道过程中水平位置的始末状态,如果要在两秒内把50变成150,在x为0到2的任意一个值时,y应该为多少?于是我们用刚才学到的简单的线性插值办法,作过两点的直线,然后代入时间求得y值即可。
在实践中,我们可以用GDScript的lerp(linear interpolation的缩写)函数来进行线性插值:
看起来似乎有点复杂。不过这里做的事情就是把这个sprite在两秒钟内向右平移100个单位(从50到150)。
首先解释一下赋值用的等号右边的反斜杠是什么意思。如果你发现你的表达式太长太复杂,写在一行里面特别长的话,就可以用反斜杠来换行。下一行的内容会被视作同一行的内容。
此外还需要注意,lerp函数可以接受各种类型的参数(其参数类型为Variant),但是你必须要保证各参数类型相同可以插值。你也可以选择用lerpf等变体来确保参数类型。
lerp函数有三个参数,前两个参数分别对应插值的起点和终点,最后一个函数在GDScript中称为weight(权重),某些地方又称为alpha。但是无论叫什么,它都是代表“现在要在哪里插入一个值”。
这里我想要的效果是在“两秒钟内”进行插值。因此每次进行插值时,我通过Time.get_ticks_msec函数获得游戏启动至今经过了多少“毫秒”。msec代表millisecond即毫秒,1秒为1000毫秒。lerp函数的第三个参数权重一般来说应当是一个从0到1的值,为0时得到起始值,为1时得到终点值。所以这里除以2000(2秒)并通过clamp函数限定取值范围。也就是说这里是在求得“现在在0到2秒范围内的哪里”,进而进行线性插值求得当前的位置应该走到哪里。
于是乎我们就得到了这样一个小动画:

参数化曲线

从另一个角度来理解一下插值。
经过(0, 50)和(2, 150)两点的直线方程为y=50x+50——这是所谓直线的斜截式方程,可能中小学数学课一开始讲直线方程的时候就是这种形式。
直线有什么特点呢,那就是它的斜率为常量(不变)。y=50x+50的斜率为50,它的方程用另一种形式来表达就是:
等号右边的值实际上就是斜率。直线的斜率实际上就是直线上两点的坐标分量分别相减然后相除,上式也可以写成:
如果(0, 50)视作起点,那么任意一个点的斜率都应该和(150, 2)所在处的斜率相同。
根据分式、乘除法的运算法则,我们交换左右的这两个部分的位置:
这种写法基本上就是所谓的“两点式”。由于这是一个等式,我们再加一个变量t扩展等式。这个t就是参数。现在直线的方程可以写成这样:
或者换个写法:
这就是它的参数化方程。这个t就是lerp函数的第三个参数。对于整条直线来说,t可以为任意实数。如果限制它的取值范围为0到1,那么得到的对应的点就限制在x为0到2的范围内,也可以说就是这个线段内。
如果把x=2t的2除到左边去,就相当于我们在调用lerp时最后传入weight参数时在做的的事情。
你可能会问为什么Godot文档中为什么用这种形式解释呢?
interpolation = A * (1 - t) + B * t
这里的A是插值起点,B是终点,那么我们代入我们的起点和终点也就得到了y=50(1 - t) + 150t,化简一下也就是y=100t+50。
如果从坐标(向量)的形式思考其实更直接。这个式子又可以视作(x, y) = (1 - t)(0, 50) + t(2, 150)。如果把直线的方向视作从(0, 50)到(2, 150)的话,它的方向向量就是两点相减(2, 100)(没事做可以把它单位化)。直线上任意一点和起点相减(连线)的方向向量应该和直线本身的方向向量平行,由此又可以得到一个方程然后参数化。从抽象上,我们可以想象这是一个滑条,最左边就是起点,最右边就是终点,t就是滑到哪里了。
怎么样,数学很有意思吧。稍后我们还会看到参数方程。

不同的插值方法

正如我们前面问你的那样,我们在两点间可以画出各种不同的曲线。插值也有各种不同的方法来进行。线性插值蕴含了“按恒定速率插值”的意思——我们在两点间画了一条直线,它的斜率是恒定的(它的导函数是常值函数)。我们会在后面的内容中讲到,一些插值方式可以获得变化的插值速率,从而可以得到“淡入淡出”的效果。
可以“被插值”的数据类型也不止标量类型,例如我们已经接触了很久的Vector2也可以进行前面介绍过的线性插值,然后可以将刚才的代码改成这样:
不同类型的数据实际上会用不同的算法进行插值。像用来表示旋转和方位的四元数又会以一种不同的方法插值。

用于制作关键帧动画的节点

Godot中的AnimationPlayer节点可以用于制作关键帧动画。你可以像我一样做一个简单的新场景来体验它的功能。
无论如何,首先要给场景加入一个AnimationPlayer节点。现在注意到下方面板中的Animation,这个面板是专为AnimationPlayer准备的:
目前动画编辑器中什么都没有。和之前做帧动画的AnimatedSprite的动画编辑器类似,我们可以在这里管理不同的动画片段。要新建一段动画,点击Animation按钮选择New:
如果你使用过其它的视频/动画编辑器,这个界面可能也不会太陌生,这里借用一下官方文档中标记好了的截图:
中间的区域是时间线,蓝色的箭头加竖线,某些软件中会称其为Playhead,为了方便就叫它播放头好了)代表着当前所在的时间。右侧的秒表图标旁边的输入框用于指定动画的长度(秒为单位)。默认为1秒。时间旁边的垃圾回收(循环)图标可以控制动画自动循环播放。
左上方的播放控制按钮太常见,不赘述。
下方的Snap(吸附)功能默认启用,右侧的输入框可以调整吸附功能的基准,默认为0.1秒,播放头只会停留在0.1秒的整数倍时间上。
放大镜可以缩放时间线的数轴。
最左侧的轨道区域我们便操作边了解。

轨道

一段动画包含多条轨道。类似于视频编辑软件,不同的轨道可以有不同的东西,在动画播放过程中,不同轨道的操作会同步进行。
一段动画至少要有一条轨道才可能有实质的内容。点击Add Track(添加轨道):
可以看到轨道分为若干类型。不同类型的轨道会根据关键帧的设置对对应的数据随时间变化进行插值。可以看到这里也有涉及3D场景数据的轨道,AnimationPlayer是一个2D和3D场景都可以用的节点。它本身派生自Node。
比如我们想要做一个简单让Sprite移动的动画,我们就需要改变它的position属性,那么我们需要选择Property Track(属性轨道)。在弹出的窗口中选择Sprite节点并选择position属性。现在动画编辑器中出现了一条轨道:
轨道栏展示了轨道控制的是哪个节点的哪个属性。最右侧的垃圾桶毫无疑问是删除轨道。

关键帧

既然是关键帧动画那么肯定需要指定关键帧。在时间线的0处单击右键选择Insert Key(插入关键帧),此时轨道对应位置上会出现一个菱形(也可能是矩形):
这就代表这里有一个关键帧。关键帧的图标和轨道对应属性名称左边的图标,以及新建轨道选择类型时左侧的图片都是对应的。
一个巴掌拍不响,关键帧动画会在每两个关键帧之间进行插值,因此我们在1秒处再插入一个关键帧:
现在的轨道是这个样子。如果播放动画,什么也不会发生,因为这两个关键帧上Sprite的位置都是一样的。实际上两个关键帧之间的直线就表示这段时间内这个属性没有发生变化。
由于目前Sprite的position属性受AnimationPlayer控制,所以虽然你可以直接在场景中移动节点或者修改它的position属性,但是它还是会变回轨道算出来的值。
轨道和关键帧都是和时间相关的。我们在编辑相关的值时要考虑当前所在时间。我们现在的问题是1秒时sprite的位置需要调整,那么我们可以先把播放头定位到1秒处。
接下来有两个方法可以编辑此时sprite的位置。首先我们依然可以在2D视口中移动或者编辑检视面板中的position属性。但是这里需要额外的操作来使得这个变化反映到关键帧中。
编辑完毕后看到检视面板的这里:
由于AnimationPlayer的存在,场景中各节点的检视面板中可以受其控制的属性编辑器右侧会出现一个钥匙图标。点击这个图标就会在当前播放头所在位置插入一个关键帧。由于1秒处已经有一个关键帧,所以这里实际上是更新这个关键帧对应的position属性值。另外提醒一句,如果你是在检视面板中调整位置,那么调整完毕后记得按一下回车,否则它会变回去。
另一种方法是直接在轨道上选中关键帧,在关键帧的检视面板中编辑对应的值:
编辑完毕后,可以发现两个关键帧之间的直线也消失了。现在播放动画就可以看到这个简单的动画的实际效果。

自动插入关键帧

如果你觉得这样编辑关键帧的值还是麻烦,那么可以使用录制功能自动插入关键帧。
有AnimationPlayer存在时,2D视口上方的工具栏中会多出几个图标:
当你在2D视口中对某个节点进行移动、旋转、缩放后,可以通过这几个按钮快速插入关键帧。
最左边的三个图标分别对应位置、旋转、缩放。只有点亮的图标对应的属性才会在插入关键帧时插入。这三个图标可以点亮复数个(但是也只有这三个属性显示在这里,毕竟你在2D视口中只能直接手动编辑这三个属性),也就是说可以一次性插入到多个轨道中,比较方便。
点击钥匙按钮即可把关键帧插入对应的轨道。不存在的轨道会自动提示新建。
启用录制(自动插入)功能后,一旦对应轨道的属性发生改变,关键帧会自动插入:

贝塞尔轨道

贝塞尔轨道就是用贝塞尔曲线进行插值的轨道。那什么是贝塞尔曲线,贝塞尔曲线是一种参数化曲线,利用它可以构造处各种平滑且千变万化的曲线。实际上设计行业的朋友估计很难说没听说过贝塞尔曲线。
一般常见的二次(二阶)贝塞尔曲线由三个(不共线)的点确定。假设这三个点为P0、P1、P2,贝塞尔曲线的参数方程就是:
粗体在很多时候用来表示向量,这里的P都是二维空间的点(二维向量)。
光看这个方程有点让人摸不着头脑,实际上对给定的参数t,二次贝塞尔曲线上的点就是先用t在P0和P1、P1和P2上分别插值得到Q1和Q2,然后再用t在Q1和Q2之间插值,实际上上面的方程是化简形式,把它写开来就是:
我承认这两个公式是我搬的维基百科的,我就懒得打了。
类似地,三次贝塞尔曲线由四个点确定,然后给定t又先两两线性插值得到三个点,然后三个点再进行二次插值。
为什么不提“一阶贝塞尔曲线”呢?因为在一阶情况下就是简单的线性插值得到的一条直线:
创建贝塞尔轨道的方法和其它类型轨道类似。这里我们以sprite的rotation属性为例进行学习。新建贝塞尔轨道,或者点击Sprite2D节点的rotation属性旁的钥匙。如果你点击的是钥匙图标,那么在弹出的对话框中记得勾选Use Bezier Curves以新建一个贝塞尔轨道(右边的选项不变)。
接着分别在0秒、0.5秒、1秒创建三个关键帧,对应的rotation值分别通过检视面板设置为0、90、0度。现在看起来应该是这样:
目前来说除了图标是蓝色的之外和一般的轨道没有什么区别。接下来点击下方这个按钮:
切换至贝塞尔曲线编辑器模式。默认情况下你的动画编辑器在贝塞尔曲线模式下看起来应该是这样:
此模式下只会显示贝塞尔轨道,这没有问题。但是右边怎么看都只有一条直线,说好的贝塞尔曲线呢。注意到时间线纵轴标线上显示了一个100,表示这里默认缩放到了可以显示到100左右的范围。然而,右下方的缩放滑条却只能缩放横轴,这是我不太理解的。
要缩放纵轴,需要按下Ctrl+Alt的同时用鼠标滚轮缩放。缩放到大概1.5左右就能看到曲线的真面目了:
缩放过程中你可能发现曲线变到下面去了,这个时候按住鼠标中键移动可以平移时间线。
当然你可能有个疑问,检视面板里面的Rotation不是以度为单位吗,90度怎么要缩放到这么小才看得到。你可能还记得rotation属性在GDScript中实际上是以弧度存储的,所以0.5秒处的1.571弧度大约就是90度。
可以看出,贝塞尔曲线比起线性插值的直线要平滑不少。你还可以用上面的控制点来手动编辑贝塞尔曲线来达到想要的效果(此时最好关闭吸附功能)。右键菜单中也有常见的贝塞尔曲线编辑选项。这里你可以自行实验,不赘述。
现在你就可以尝试做一些自己想象中的小动画了!

复位动画

每次我们打开有AnimationPlayer的场景都会看到受动画控制的节点好好地呆在原位,保持着一个起始状态——即使我们之前做了若干编辑,或者把播放头放在动画的某个中间位置。
这实际上是一个特殊的动画在起作用。这个动画是自动为我们创建的RESET动画(这个名字是大小写敏感的,比如叫reset不会被视作这个特殊动画)。场景重新打开时,会根据这个动画设置的值,来重置相关受动画控制的属性值。
如果你关闭场景重新打开,选中AnimationPlayer,可以看到当前激活的动画就是RESET动画:
这个动画基本上就只有一瞬间,且只有一个关键帧,这个关键帧上的属性值就相当于初始值。你也可以像编辑一般的动画那样编辑这个关键帧的值来调整初始状态。
为一个尚不存在对应轨道的属性创建轨道或关键帧时会弹出这个窗口:
Create RESET Track(创建RESET轨道)选项就是指的要不要在RESET动画中也给它创建一个轨道,也就是说要不要在必要时复位这个属性。

控制动画播放

AnimationPlayer的动画可以在游戏中自动播放,点亮这个按钮后,启动场景你就可以看到动画自动播放了:
但是你不一定需要这样,我们自然也可以在代码中控制它的播放。AnimationPlayer和AnimatedSprite类似,提供了一个play方法,传入动画的名称即可播放:

给主菜单添加动画

我们试着给主菜单添加一个动画。比如我们想让标题有一个从画面外落下来的感觉,那么我们就可以对它的位置做一个简单的动画,有了前面的铺垫,这里确实足够简单,也不需要新知识,所以我就不细说了。
此外我们可以给菜单按钮做一个逐渐显现的效果——也就是调节它的透明度。那么透明度是哪个属性呢?实际上CanvasItem都有一个Modulate属性,它实际上就是一个颜色属性可以直接“添加到”CanvasItem自身的颜色上。
Godot中的Color类型就是用来表示颜色的。Color的RGBA四个属性对应的就是RGBA格式的颜色。其中的A就是alpha,相当于是透明度。
将modulate的颜色的A值调整为0就可以看到整个VBoxContainer就看不见了。Modulate属性的调整会影响到该节点的子节点——这是我们想要的效果。如果你不想影响子节点,那么就去调整Self Modulate。
自然,我们在动画结束时把A值拉满。
用AnimationPlayer对颜色进行插值时,时间线上会显示对应的颜色变化情况,这是一个不错的功能。

直接用代码做简单动画

也许你的动画足够简单,没必要在编辑器里点来点去,又或者有些关键帧的值你无法提前确定。这个时候我们可以考虑直接在代码里实现动画。
当然我的意思不是用各种函数自己写一堆插值代码(当然也可以),此时Tweener会帮你的忙。
Tween来自inbetween一词,指的就是在动画制作过程中绘制中间帧的过程。
Godot中的Tween要比AnimationPlayer更加轻量化,更适合用代码来制作一些简单的动画(或者单纯地进行插值)。
要获得一个Tween对象,Godot主要提供了两种方法。一个是SceneTree.create_tween,一个是Node.create_tween。前者在调用此方法的节点被释放时依然会继续运作,而后者则不会。但是无论如何Tween都是场景树控制的,一个tween可以对不同的节点进行控制。Node.create_tween等价于把调用tween的bind_node方法绑定到这个节点上。结合TweenProcessMode的设置,可以让部分节点暂停时保持另一些动画独立播放。
当然这里暂时不需要那么细致地控制,我们在根节点上的脚本就简单调用create_tween好了(即Node的版本)。光是创建tween没有什么用,我们需要给它添加若干Tweener,tweener是表示具体的动画操作的对象。
Tween的tween_property方法会产生一个PropertyTweener,顾名思义这是用来控制节点的属性的。例如,这段代码会让sprite移动到(100, 200)处:
第一个参数是要对哪个节点进行控制——准确地说它不一定是一个节点,它可以是任意Object,这就是为什么说这些所谓用来做动画的东西也不一定就真的在做一些视觉效果,也可以单纯地用来插值。第二个参数是属性的路径,虽然它是NodePath类型(使用$语法引用节点时也是用的NodePath),但是传入字符串会自动转换。NodePath相关的语法可以查看文档中的NodePath部分。
这里传入的是position属性。如果你只需要控制位置的某个分量,那么需要用冒号来访问属性的属性:
第三个参数是最终值。这里我们可以注意到它和AnimationPlayer的区别,使用Tween时我们不用关心起始值,并且可以随时指定任意最终值,因此对于运行时要播放的动画来说是很方便的。但是要注意属性和最终值的类型要匹配,否则运行时会报错。
最后一个参数是时间(动画长度),秒为单位。
实际上现在运行场景这个动画就会自动播放。Tween的设计决定了创建它之后动画就会自动播放,并且tween不应该被重复使用。
此外,默认情况下在tween上调用的各种tween方法是按顺序执行的,而不是同时进行。例如:
是在sprite移动到位后才会开始旋转。如果要让tween上的tweener同时进行,可以调用Tween.set_parallel让所有tweener并行执行。或者在调用tweener前先调用parrallel。

配置Tweener

tween_property会返回一个PropertyTweener,通过这个对象实例我们可以进行更进一步的控制。
from方法可以配置Tweener控制的属性的起始值——如果你不希望从当前值开始插值的话。
set_delay是在开始执行tweener的操作之前延迟多少秒。
set_easy和set_trans可能是最有用的两个方法,它们两个的组合可以控制动画的插值函数和淡入淡出效果。
Tweener的方法都会返回修改后的Tweener,这样你就可以连续调用多个方法,比如:
我们就得到了一个设置了过渡效果的动画:
这两个调整过渡效果的方法的参数是一系列枚举值,放一个cheatsheet在这里:

相对值

默认情况下PropertyTweener会把tween_property的第三个参数作为插值的最终结果。而PropertyTweener的as_relative会讲这个参数的值视作相对值,也就是说最终值实际上是根据其实值加上这个值求得的。
例如这段代码就利用这一方法做了个有意思的小动画:
思考一下再写代码验证猜想!

其它Tweener

CallbackTweener就是在动画中间调用某个函数,通过tween_callback方法创建,传入一个Callable。
IntervalTweener会在动画中插入指定的时间间隔,由tween_interval创建。
MethodTweener由tween_method创建,这个看似是最复杂的Tweener:
tween_method的第一个参数是一个Callable,第二、三个参数分别是插值的起点和终点,最后一个参数依然是时间。它的作用就是在每次插值时,以中间值为参数,调用传入的方法。
你可能会问,如果想要调用的参数有一个以上的参数怎么办?第一,用lambda包装一下:
第二,用Callable的bind方法绑定参数。注意,GDScript的bind会在Callable调用后再传入绑定的参数,也就是说顺序在这里很重要。tween_method以中间值调用方法,才会依次传入用bind绑定的参数:

练习使用Tween

下面又来做个小练习。我们来为之前拾取道具提示做一个小动画吧!
首先要考虑的是什么时候播放。常见的效果是UI元素显示出来的时候播一个小动画,消失的时候也可以播一段。这里我就只演示一半,做一个显示出来的时候播的小动画。
接下来,我们要考虑在谁身上tween,tween哪个属性。我们的PickupTip控件为了方便,根节点是一个单独的铺满整个画面的节点,因此这里只Tween一下容纳信息的VBoxContainer。先添加对它的引用。
为了有那种“显现出来的效果”,这里以调整其scale属性为例。当然你也可以按照自己的喜欢添加其它效果。
我们在主场景中的脚本是通过设置它的visible属性来达到显示/隐藏效果的,当然第一次显示的时候需要实例化场景。我们希望在显示出来的时候播放这样一个放大出来的小动画,因此我们就连接上定义在CanvasItem上的visibility_changed信号,当发现visible变为true时,我们就tween一下:
这里我们把scale在0.3秒内从(0, 0)变到(1, 1)。为了便于开发过程中查看我们不直接设置它的scale初始值为0,而是在这里调用from指定。
现在启动游戏,尝试走到樱桃上……
没错,游戏遇到错误中断了:
错误信息说的是无法在null上调用from。实际上可以在右边看到除了self实际上我们那些引用的各节点都是null。
实际上,这是在我们第一次需要展示PickupTip刚实例化出来添加到场景中发生的错误。虽然此时场景构造了出来并且添加到了主场景中,但是此时它还没有ready!(你可以在做个响应信号的方法中print一下is_node_ready方法的返回值)因此很多操作在这时无法安全运行。
这里我们可以简单地处理一下,直接用await等待ready信号发出再tween:
如果发现没有ready,我们就等待ready信号。还记得await的用法吗?
现在我们就得到了这样一个小动画:
你可能觉得有点奇怪,它看起来是从左上角变出来的——当然如果你接受这种效果也没问题。
要调整这种行为,我们需要修改Control的Transform下的Pivot Offset(支点偏移)。这个属性就是控制rotation和scale属性是以哪个点为中心。对于Control来说它的默认值是左上方,所以这个动画播出来就是这种效果。如果想让它看起来是从中间飞出来,我们就把它的x坐标改成宽度的一半:
注意,那个加号就是当前支点的位置。
现在我们就得到了这样的效果:
你也可以根据自己的需要调整为其它值。
这一篇文章不知不觉写了这么多,但实际上也很难完全覆盖这两个方面的所有内容。如果你觉得哪里不清楚或者一篇里写这么多内容不太合适,又或者有其它建议,也欢迎评论指出。