游戏引擎:我如何实现骨架动画

2021-04-04 08:10:46

在2020年,我开始在C ++中从头开始撰写3D游戏引擎。这是一个关于我如何为其添加骨架动画的帖子。德雷克在它中,所以扣了起来。

我认为支持发动机中的动画模型真的很酷。毕竟,如果你能让人们四处走动,那就感觉更像是比赛,鸟儿周围飞行等等!

骨骼动画基本上是指模型具有一组骨骼,垂直其常规顶点数据。每个顶点都可以受到一个或多个骨骼的影响。这意味着当您在骨骼周围移动时,链接到它们的顶点也会移动。移动手臂骨头,角色的手臂移动!这些动作是由艺术家创建的。搅拌机并保存为型号的动画。

该系统非常酷和直观,但它也有其缺点,因为您需要将变换矩阵上传到每个帧的每个骨骼的GPU。考虑到每个型号可以有60多个骨骼,这可以通过更大的场景真正征税。

有一个关于巨人鱿鱼的家伙如何在阿兹的动画制作的一个非常有趣的视频。我建议看着它,但TL;博士是他们发现骨骼动画太慢,去了程序动画,并混合形状,以动画成千上万的鱼和海带。当谈到创建动画时,我可能会在创建动画时展示这一点,我实际上是在游戏中使用,但是让我们现在试试骨骼动画!

首先,重要的是要了解我们的所有动画数据都将相对于我们的绑定姿势,这是有趣的看起来的T形姿势视频游戏字符有时会出现问题。认为cyberpunk。

动画基本上是一组关键帧,告诉我们如何转换一组关键帧的每个点。所以,在$ t = 0.0 $我们将在绑定姿势中,以$ t = 0.2 $我们移动一点点,以$ t = 0.4 $左右移动,我们将移动更多,等等。

假设我们正试图弄清楚动画的位置$ x_1 $ of我们的角色前臂上的Vertex $ x_0 $。我们假设顶点只与一块骨骼相连,这是手腕的一个。

首先,我们需要得到骨头的位置,所以手腕的位置。以下是:要计算骨骼的位置,我们需要首先计算所有父母的位置!这只有意义,因为骨骼的位置总是相对于其父母,这是使骨骼动画成为可能的。

在我们的动画中,每个骨骼都有一个转换矩阵,由它在一个特定帧上发生的翻译,旋转和缩放构成。让我们称之为这个矩阵$ a_n $。如果我们从肩部开始为简单起见,我们可以为手臂提供$ A_0 $,然后是Forearm的转换$ A_1 $开始,然后$ A_2 $拨打。将它们放在一起(在OpenGL矩阵乘法顺序中,如此右到左转!)放弃$ A_2 * A_1 * A_0 $。这给了我们一个转变手腕位置的矩阵。

接下来,我们需要顶点位置。我们的顶点$ x_0 $相对于世界来源定位。但是,对于我们的转型工作,我们需要将$ X_0 $转换为相对而不是世界来源,而是对父母骨骼进行转换。执行该变换的矩阵称为“逆绑定姿势矩阵”,每根骨骼都有一个。我把它写成$ bp ^ { - 1} _2 $。幸运的是,该矩阵通常由建模软件预估并存储在模型文件中。

如果我们一起将这一切放在一起,我们已经有了骨转换矩阵$ t $,它可以将任何顶点转换为其新位置!

当然,在实践中,一个顶点可能会受到许多骨骼的影响。我们为我们的三个骨骼中的每一个创建骨转换矩阵$ T $。

$$ \ begin {对齐} t_0& = a_0 * bp ^ { - 1} _0 \ _0 \\ t_1& = a_0 * a_1 * bp ^ { - 1} _1 \\ t_2& = a_0 * a_1 * a_2 * bp ^ { - 1} _2 \结束{align} $$

为了计算某个顶点的位置,我们可以创建这些矩阵的加权平均值。

而且,我们已经将顶点从原来的位置转变为动画位置!如果我们为所有顶点这样做,我们应该得到一个很好的动画。让我们开始编程这一点。

要启动问题,我们需要一个带有动画的模型。当我们实际制作游戏时,我们显然不会使用此模型,我们只想有一些东西要测试。 mixamo.com拥有一些免费型号,有趣的动画,所以我当然选择了弓箭手模型,并用“嘻哈跳舞”动画。我已经包括动画在我们完成后应该是什么视频。

最终,我想选择一个文件格式来坚持,并写一个解析器直接解析它。但是,现在,我们只是在尝试,因此让我们使用ASSIMP库加载此模型。它附带的额外奖金是在2021年的热闹和有点不幸的名称。

与大多数程序中的编程一样,最重要的任务将是理解和构建数据。我们希望在模型中加载三件事:

对于每个顶点,它受到它受影响的骨头,哪个骨骼的影响力。

这个容易。 Assimp给了我们一系列Aianimation结构。动画具有每个骨骼的Ainodeanim,它包含该骨骼的所有关键镜。这是它看起来的简化版本:

我们只需要记住所有这些数据并将其保存在动画列表中。我们会稍后考虑如何使用它。

我们需要的下一件事是骨骼的实际列表。不仅仅是这样,我们需要存储骨骼层次结构,那么哪个骨头有哪个父母。

arimp只是向我们提供数据存储在文件中的数据,因此节点的分层树。由于两个原因,这对我们的目的非常糟糕。

首先,我们从ASSIMP获得的数据并不告诉我们所有节点实际上是骨骼,哪些是网格!这意味着我们需要弄清楚至少发现根骨的方式,所以我们可以遍历它的树。这是我弄清楚如何做到的最简单的方式:

查找根的每个第一级后期(在我们的情况下,标有“臀部”和“akai”的节点)。

如果我们发现整个后代树不包含任何网格的第一级节点,那可能是我们的根骨。在我们的情况下,您可以看到它确实是“HIPS”节点。

其次,甚至更重要的是,我们得到了一个看起来这样的结构树:

为什么这么糟糕?想象一下,我让你在这种结构中找到一定的父母。你必须做一些树遍历寻找我要求的节点,然后以某种方式记住你是如何在那里到达的,也许是用递归函数。

首先,这使得CPU很难缓存此数据,因为我们使其在此树周围不可预测地跳转,因此必须转到相当随机内存地址以查找下一个节点。这意味着我们的代码将不必要地慢。

但重要的是,我们只是不能困扰那个代码。谁有时间?如果我们有这样的东西,它不会更好吗?

这是如此简单 - 右翼的父母是3(Rightupleg),其父母为0(臀部)。但是有更好的东西。你有没有意识到它是什么?

请记住如何计算某个骨骼的变换,我们必须计算所有父级转换?嗯,我们的阵列以特殊的方式排序。所有父母都在孩子面前来。这意味着,如果我们通过阵列顶部到底,我们知道在我们到达节点x之前,我们知道我们已经计算了所有节点X的父母!这使得一切超级简单 - 我们只是遍历X的父母并将他们的矩阵乘以。

考虑到这一点,我写了一个小功能来遍历分层树并将其转换为此简单的数组。然后我们是e y e y r a o y o o o o。

接下来,我们需要将哪些骨头存储在每个单个顶点上,以及这种影响的重量。现在,我们的顶点只有位置,正常和tex_coords。让我们添加骨骼影响数据。这很容易 - 如果我们希望每个顶点支持4个骨骼,我们可以将此存储为两个vec4s。

BONE_IDXS包含影响此顶点的骨骼的索引,BONE_WEIGHTS包含一组数字,如0.2或0.6,这是每根骨骼的重量。 BONE_WEIGHTS需要加入1.0。

从模型中获取这种情况也非常简单。 Assimp的每个目标都让我们有一个MBONES数组,它可以包含aibones,它看起来像这样:

查找具有索引mvertexID的顶点,因为这是我们将数据保存到的东西,

发现我们骨骼阵列中的骨头(我们必须通过Mname进行慢的字符串比较,但是没关系,我们只做一次),

您可能会注意到AIBONE也有一个名为Moffsetmatrix的东西。事实上,这正是我们早先谈过的逆绑定姿势矩阵$ bp ^ { - 1} $。在我们经历这些AIBONE时,让我们在美丽的骨骼阵列中保存此矩阵。

在这一点上,我们已经填补了我们的骨骼以及我们的顶点结构,所以我们已准备好继续下一步!

pfew,好吧!我们得到了所有数据。老实说,大多数工作都完成了。通过我们的新信息,我们只需步步到每个骨骼,然后计算其转换矩阵。

首先,我们在几秒钟内获得动画的持续时间,以及我们当前的动画中的当前时间点。

接下来,我们希望计算动画矩阵,该矩阵决定了每个骨骼的转换,旋转和缩放,以便我们的特定时间点。为此,我们需要实际上弄清楚我们所在的动画的关键帧。让我们立即编写Get_Anim_Channel_Position_Index()的使用代码,然后稍后留下实际功能。

我们计算了一切!现在我们只需要将矩阵乘以将我们的矩阵乘以。如果您对此感到困惑,请再次检查帖子的开头。请记住,我们将骨骼的动画变换与所有父母的动画转换相结合,以及逆绑定姿势矩阵(命名为Bone->这里的偏移),这将我们的顶点从世界空间转变为骨骼空间。

要换句话说,我们可以编写实际上弄清楚我们在动画中的功能。我已包括我们的关键帧时序所需的示例。基本上,我们想根据我们当前的游戏时间来确定自我动画以来的时间。

如果我们的动画持续1.5秒,我们的当前时间为1.75,这意味着我们将0.25s进入动画。最近的关键帧为0.2s的关键帧。这就是代码的样子。

只是在开玩笑!您认为我们是否会在每一帧的每一个动画中循环每一个关键帧?这将是缓慢的。相反,我们将记住我们在阵列中的最后一个地方,下次继续从那里继续。几乎每次我们检查时,我们都应该得到相同的立场,或者下一个位置。

这意味着我们完成了我们在GPU上所做的所有计算。但是,由于我们正在进行所谓的GPU Sulning,一旦我们使用我们的制服将这些矩阵上载到GPU,我们需要实际执行我们谈论的加权乘法,以转换每个顶点。我们在Wastex着色器中这样做:

完毕!让我们加载模型,运行我们的发动机,并在我们美丽动画的荣耀中晒太阳。这真的很棒

不好了!我们应该看着一个弓箭手,但它是...意大利面条?我们制作了意大利面?!显然有些东西会非常错误。

这一点让我挠头一点点,但实际上这个问题很简单。你看,我们场景中的每个网格也有自己的转型。这种转变很重要 - 它有助于正确地定位世界上的模型的每个部分,等等。在将此转换为GPU之前,我现有的代码将此转换应用于顶点数据。但是,骨骼计算需要未转化的数据!如果我们将骨转换应用于已转换的数据,我们会得到,嗯,意大利面!

修复很简单。应用场景的根转换矩阵的倒数,然后进行我们的转换,然后应用根转换矩阵。这不是一个完美的解决方案,但现在并不重要。

我们得到了这项工作吗?我将添加我在发动机工作的其他场景,并给她跳舞的东西。

真的,这个动画几乎是完美的。缺少一件事。你有没有注意到她的动作看起来有点波涛汹涌?这是因为我们的动画中只有这么多的关键帧。在我们的情况下,我们有165个关键帧。这绝对不足以给我们一个漂亮而平滑的4秒动画。我们需要在关键帧之间编写一点点代码来插值。也就是说,当我们在两个关键帧之间,我们需要计算在该中间点中会发生的转换,而不是仅捕捉到最近的关键帧。

为此,我们只需替换较早的转换()代码具有类似的版本,首先在上一个关键帧之间进行混合(),具体取决于我们当前的动画时间。

旋转和缩放的代码几乎相同。 让我们试试看。 我们动画的光滑度很美。 我们真的这样做了。 我们没有任何东西开始,并以骨骼动画系统结束。 恭喜! 你应该对自己感到高兴。 我有很多乐趣实施动画,包括几个晚上熬夜凌晨5点来弄清楚我的程序出现了什么问题。 我希望你觉得自己学到了一些东西,或者至少你笑了。 我会发布更多关于发动机的信息 - 一个我真正想写的一篇文章是关于我如何让你在背景中看到的水! 直到那时,小心。 弗拉德是一位生活在瑞士的程序员,他通过抚摸猫来探望他的时间。 当他不是,你知道,做他的实际工作,他教导了一个名为Clumsy Computer的YouTube频道的编程,并在他的游戏引擎上工作。