3D动画概述暨骨骼动画实现

引言

本文论述了3D领域内的常见动画类型的运作机制。不同于其他文章简单的罗列和介绍每种类型的3D动画,本文尝试以一种优化演进的思路对动画运作机理进行递进式推演,在这个过程中自然而然的推导出常见的几种3D动画类型,以此证明其出现的必要性和合理性。本文尽量以平实简明的语言来阐述讲解,不过如果阅读者具备初级3D知识,对顶点,矩阵变换,着色器等有一定认识,阅读效果会更好。另外,本文聚焦在相对宏观的机制层面,对细节不做过多描述。

动画宏观分类

3D领域内,刨除粒子等特殊的动画效果,对于一个3D物体来说,动画效果可以概括性分为两类:纹理变化效果顶点变化效果,两者可以模拟出的动画效果基本互不相交。

纹理变化

纹理变化可以营造出物体表面图案不断变化的效果,更进一步,如果结合透明/光照等高级纹理类型,可以实现更加丰富的效果。纹理变化通过改变纹理采样坐标来实现动画效果,自然不会引起模型的形变,想象一个静止的,不断变换表面颜色的球体。纹理变化方案对应的动画类型就是UV动画,该动画类型擅长模拟水流,岩浆等流体效果,虽然效果做不到非常逼真,但是性价比很高,因为UV动画的性能损耗极低,在一些远景或者对视觉要求不是非常高的情况下可以被大量使用。UV动画的机理非常简单, 不再做过多展开。下图是一个UV动画模拟瀑布的效果:

顶点变化

顶点变化则是通过物体模型顶点的位移来营造出物体不断形变(严格的说是形变和位移,但是在3D范畴内,可以笼统的统称为形变)的效果,相对的就不会导致物体表面纹理变化。下图的头盔合上动作就是一个简单的顶点变化效果:

顶点动画

综合上面的描述和效果,可以看出两者的效果互不重叠,互相补充,各自有各自的适用场景。不过显然顶点变化效果在3D动画领域中的比重更大,想象如果3D世界中的物体都是静止没有动作的,那该是多么无聊的一个世界!因此下面重点探讨顶点变化动画效果的实现,既然是通过调整顶点位置来实现变化,那么如何调整顶点就是一个关键的问题,我们想要的某个动作效果不是对顶点随机调整一下就能出来的,顶点的移动需要遵循预设好的一套轨迹,才能达成我们要的效果。这样问题就变成了预设的动画轨迹如何表示和保存,以及如何应用到顶点上

任何问题的思考和解决都需要一个基准点,一般来说,这个基准点就是我们最直观(一般也是最野蛮)的想法:第一版方案出炉:假设模型的某个动画有30帧,那么只要把每一帧模型的所有顶点位置记录下来,然后在动画运行时根据之前记录的每帧模型顶点位置进行顶点位置更新即可实现动画效果。这个方法确实可以工作,并且直观便于理解。当然缺点也很明显:一个模型除了其纹理之外,顶点数据是最耗费存储空间的(越复杂的模型顶点越多,占的空间也越大),上面提到的保存每一帧模型的所有顶点位置,最终会导致模型文件变的非常庞大,进一步导致加载后占用的内存和显存也非常庞大(后两者是非常宝贵的资源)。最极端的情况下,一个动画多一些的复杂模型要求的存储甚至可能超过设备提供的内存容量。这个方案在性价比和扩展性上都比较糟糕,需要进一步优化。

一个明显的优化方向是尝试减少因为动画而引入的额外信息(主要是顶点数据),先观察上面那个头盔动画的每帧轨迹:

可以观察到第1帧和第10帧之间的8帧是遵循一定运动规律的(匀速的向下合)。似乎可以从第1帧和第10帧,即动画的起点和终点推导出中间某个时间点的模型顶点位置信息,这就引入了第一个优化手段:关键帧和插值计算,改进方案出炉了:首先,不再保存动画每一帧的模型顶点信息,取而代之的则是预先指定一系列关键帧,关键帧的数量视动画的复杂程度和插值需要而定,分散在整个动画的不同进度点上,动画在每个关键帧的顶点信息会被保存,当动画运行到某个时间节点时,取该时间节点前后两个关键帧的顶点信息进行线性插值计算(或者其他插值方式,不过一般是线性),得到的结果就是在动画在这个节点时的模型顶点信息。以上面序列例,我们只需要保存两个关键帧数据(第1帧和第10帧),其他帧的数据都可以根据时间进度结合这两个关键帧推导出来。

这个优化方案在大多数情况下比初始方案要节省很多的内存空间,以上面序列为例,可以节省80%的空间,以时间换取空间(插值是需要计算时间的),但是当前设备的计算能力远远大于插值计算的计算需求,可以接受。这种实现方案其实就是“顶点动画”,现在仍然在一些动画场景中得到应用。

刚性阶层动画

上面推导出的顶点动画方案似乎已经优化的比较充分了,但是优化之路还没有结束。考虑下顶点动画的短板,观察下面这个动画以及其相对的序列帧,一个小狗晃动着身体:

显然这个动画我们很难找到可以被插值求出来的帧,因为小狗的动作相对之前的头盔来说,没有运动规律可循。如果要使用顶点动画,就不得不将每一帧都做为关键帧,完全退化为了第一版方案(就像某些计算机算法,在一定的计算场景下复杂度退化很严重),顶点动画不适用这种应用场景,需要探索新的优化方案。先总结下这个动画场景的特点:模型由多个部件和关节构成,部件之间通过关节铰接。关节使得了每个部件可以有独立的运动轨迹,同时部件之间有可以通过关节进行联动影响,最终复合起来形成了上面的动画效果。

从上面的场景分析可以得出一种新思路:模拟关节体系(某种意义上是仿生学)。关节可以生效的前提是,原来的总体模型按照动作需求被拆分为子模型,接着用关节链接子模型。为了实现总体关节联动,子模型和关节一起构成一颗驱动树,在驱动树中,父节点通过关节驱动子节点运动,子节点进一步驱动更下级的节点,最终实现了上面场景的动画效果。这种方案被称为“刚性阶层动画”,“刚性”指的是子模型本身不会形变,只会被关节带动做移动旋转等动作,因此子模型也被称做”刚体”。”阶层”指的则是上面提到的驱动树模型代表的层级关系,有了层级关系才能进行整体联动。到了这一步,可以看出来,我们构造的这棵树其实近似于是一棵“骨骼树”了,刚体就是骨骼,关节则连接和驱动骨骼。下图就是一个简单的刚体阶层模型的示意图,可以看出,模型被拆分为了若干的子模型,通
过关节连接构成驱动树:

新驱动模型下不需要保存每个顶点的动画信息了。顶点在这个模型中被按需组织整合为骨骼,只需要移动骨骼就可以实现想要的动画效果。换而言之,顶点的动作轨迹被取代为了骨骼的运动轨迹。很显然,骨骼的数量要比顶点数量少几个数量级(举个不太恰当的比喻,模型的顶点就如同人体的细胞,模型的骨骼对应人体的骨骼,显然骨骼要比细胞少很多),这就克服了保存巨量顶点信息的缺点,取而代之的是只需要维护骨骼体系和每个骨骼在关键帧的变换矩阵即可,运算量也从对每个顶点做插值计算减少到对骨骼树遍历矩阵变换

骨骼动画

到了这一步,似乎已经接近终点,刚性阶段动画看上去可以很好的满足关节联动的动画场景。遗憾是,还不够,刚性阶段动画通过引入关节连接刚体骨骼的概念在宏观粗粒度上满足了关节联动的需求,但是也恰恰是这种实现导致了刚性阶段动画的一个瑕疵:观察上图红圈标记的部分,你会发现两个刚体骨骼之间有着明显的“空隙”,究其原因,是刚体本身没有被形变,而刚体是通过关节连接的,有连接就会有空隙。对于某些模型,比如机器人或者机械/节肢类物体,空隙的存在是符合现实观察结果的。但是呢,对于有表皮的生物体来说,比如人体,这就显得非常不真实,人体的骨骼关节在扭动时,会有皮肤包裹,不会有视觉上的空隙,只有从X光机观察时,才能看到骨骼之间的空隙。

上述应用场景的特色是“表皮”,整个模型都被一层柔性表皮包裹,这样在内部刚体骨骼扭动时,关节处的表皮被拉伸,从而避免了视觉上空隙的出现。那么问题就变成了如何通过程序和建模模拟这层表皮?先回顾刚体骨骼导致空隙的原因是刚体骨骼本身在扭动时没有形变,导致关节处的空隙不能被覆盖,如果有办法把刚体在关节处的顶点进行形变拟合,就可以模拟这层表皮。其中的关键点是,关节处的这些顶点,将不再只属于某个刚体骨骼,而是该关节处所有刚体骨骼在这个点位置的共同部分。独立的刚体骨骼模型已经不能满足这个需求了,因为独立刚体各自的顶点都是专属的,不能被共享。

这样就需要一种新的模型组织形式,新的组织形式既要能保持刚性阶层动画的层级和关节特性,又要能体现出关节处顶点是被多个刚体骨骼共同影响的结果,前者限定了新的表述形式还是骨骼驱动树加关节,后者要求我们打破刚体的物理限制,否则还是不能实现顶点的“共享”。矛盾的焦点就集中在了刚体骨骼上,在打破其物理限制的同时又要保留其概念,解决这个问题的常用的手法就是“虚拟化”:往深一层看,刚体骨骼在我们方案中扮演的是一个“顶点包裹”的统合性角色, 以前包裹的实现形式是建模时的模型拆分(比如分拆为多个Mesh), 属于物理分割。这次换一种实现方式: 把每个顶点属于哪个包裹(骨骼)都记录下来,那么在内存中我们完全可以以此信息构造出一个虚拟的骨骼,而因为这次模型本身没有拆分,因此顶点之间是没有界限的,一个顶点可以归属(依附)于复数个骨骼,这就达成了上面提到了顶点共享的目的。涉及到动画骨骼驱动,顶点只被共享还不够,还要体现出“共同影响”这一点,同一个顶点被复数根骨骼影响,每根骨骼对其会有不一样的影响力度,这个信息也需要被记录下来,体现为权重值。下图就显示了新的骨骼树模型,可以看到,模型本身没有被拆分,在其内部虚拟化了若干根骨骼(蓝色部分):

通过这个新的表现方式,关节处的顶点在扭动过程中将被关节的所有骨骼综合影响,最终移动到一个合适的新位置来避免间隙的出现,就像之前的提到的柔性表皮的作用一样。下图就展示了模拟表皮的效果,可以看到,模型的关节部分没有出现空隙:

这也就是骨骼动画为什么全名被称为“骨骼蒙皮动画”的原因,“蒙皮”指的就是上述解决方案。骨骼蒙皮动画如其名,擅长的就是有骨骼关节参与的动画过程,因此也有一定的局限性。比如下图的人类面部表情动画,骨骼蒙皮就不太适用,因为人类脸部的肌肉数量很多,层级也复杂,每块肌肉都需要一根骨骼对应,最终的计算量会比较庞大,骨骼的变换矩阵调整对建模师要求也较高。相对的顶点动画则更适合这种场景。

动画方案总结

至此,我们的方案探讨基本完毕,总结来看,在动画优化的过程中引入了关键帧,插值,关节,骨骼驱动树,虚拟化等手段。除了从模型操作层面理解这几种优化手段外,还可以从数据压缩的角度进行理解,整个动画的优化过程实质是一个数据压缩的过程:

  • 最初方案中每一帧保存所有的顶点数据,数据量最庞大,但形变自由度也是最高的,没有任何约束,任意一个顶点的位置可以任意调整。但很多时候动画并不需要这种高自由度,而是多少遵循一些限制或者规则,这种情况下最初方案引入的全额数据就显得冗余,需要进行压缩。
  • 顶点动画的关键帧技术实质就是顶点数据压缩,通过插值求中间结果这一规则限制,将原来保存每帧顶点数据压缩到只需要保存几个关键帧的顶点信息。
  • 骨骼动画同理,为自己施加了骨骼驱动树和只能调整关节等限制,限制力度在大多时候比顶点动画还要强,因此取得了更高的动画数据压缩率

骨骼动画的实现

下面简述骨骼动画的落地实现,不涉及太多细节,知晓了机理之后,实现即是水到渠成。

首先从骨骼动画的机制推导出驱动骨骼动画需要的数据:

  • 模型骨骼树的结构数据,描述模型都有哪些骨骼,以及骨骼之间的连接关系。
  • 每个顶点需要这些额外数据: 该顶点依附于哪些骨骼(即被哪些骨骼影响),以及每根骨骼的影响权重。
  • 骨骼动画的关键帧数据,每个关键帧数据会保存每个骨骼在这个关键帧时间节点时相对父骨骼节点的变换矩阵, 每个关键帧还会保存自己对应的时间点。
  • 动画通用数据: 名称, 持续时间等。

上述数据就足以驱动一个骨骼动画了,下面是在OpenGL环境下驱动骨骼动画的大致流程:

  1. 首先对骨骼进行一次总体索引,为每根骨骼顺序分配一个数值ID,这个ID很重要,是后面运转过程中索引该骨骼数据的唯一ID,还要保存骨骼名称和ID的映射表,这是为了在遍历骨骼树能根据骨骼名称得到骨骼ID进而索引到骨骼数据。因为一般从模型读取出来的骨骼会有名称,但是不会有ID。
  2. 顶点的位移需要在顶点着色器中完成,顶点依附于哪些骨骼(骨骼ID)和这些骨骼的影响权重都要作为顶点属性传输到顶点着色器。需要注意的是,GPU显存是有限的,一个顶点被越多的骨骼影响,它所需要传输的骨骼属性就越多。由于着色器的特殊性,骨骼属性的大小取决于依附最多骨骼顶点的骨骼数量。因此顶点能最多能依附几根骨骼是需要考虑的。
  3. 对骨骼树进行遍历,结合当前的时间点得到骨骼在其动画运行轨迹中的前后关键帧,然后插值计算出在这个时间点骨骼的相对父类变化矩阵(一般可能会被拆分为平移/旋转/缩放三个子轨迹),和父类的模型变化矩阵相乘后即可得到骨骼在当前时间点的模型变换矩阵,同样流程处理该骨骼的子节点骨骼,直到算出所有骨骼在该时间节点的模型变换矩阵
  4. 每个骨骼的世界坐标变换矩阵也需要传输/更新给顶点,因为顶点着色器的特殊性,我们不可能给顶点只发送影响它的骨骼的矩阵,只能将所有的骨骼的矩阵作为uniform矩阵数组进行传输,然后顶点根据自己的骨骼ID从全局uniform数组中取出影响自己的骨骼的矩阵信息。需要注意的是,OpenGL着色器中uniform数组不是无限大的,这意味着骨骼的总数是受限的,在制作模型时需要考虑这一点。
  5. 真正的骨骼位移运算发生在顶点着色器中,有了前面几步提供的数据,只需要根据该顶点的骨骼权重结合骨骼变化矩阵就能得到符合当前动画轨迹的模型变换矩阵,作用到该顶点上即可。
  6. 步骤3~5不断重复,就可以观察到骨骼动画效果。

下面贴出上述流程使用的示例顶点着色器源码,该示例限定了模型最多有50根骨骼(MAX_BONES), 一个顶点最多被4根骨骼影响(通过vec4属性类型体现)。

// attribute
attribute vec3 attPosition;
attribute vec2 attUV;
attribute vec4 attBoneIds;      // 该顶点被哪些骨骼所影响
attribute vec4 attBoneWeights;  // 每根骨骼的影响权重

// uniform
const int MAX_BONES = 50;          // 模型最多有50根骨骼
uniform mat4 bonesMat[MAX_BONES];  // 骨骼变换矩阵数据
uniform mat4 mvpMat;

void main()
{
    // 基于每根骨骼的权重和变换矩阵求出在这些骨骼的综合影响下的骨骼变换矩阵
    mat4 boneTransform = bonesMat[int(attBoneIds.x)] * attWeights.x;
    boneTransform += bonesMat[int(attBoneIds.y)] * attWeights.y;
    boneTransform += bonesMat[int(attBoneIds.z)] * attWeights.z;
    boneTransform += bonesMat[int(attBoneIds.w)] * attWeights.w;

    gl_Position = mvpMat * boneTransform * vec4(attPosition, 1.0);

    textureCoords   = attUV;
}

结语

至此,3D领域动画论述基本结束,一个概括性结论:3D动画的本质还是操作数据,各种动画类型对应了不同的数据操作理念和手法。本文涉及到的只是一些基本的3D动画类型,在工业级应用中,会出现更多的专业细分领域和动作驱动机制。不过对于相对轻量简单的应用场景,比如手机摄像头特效之类的应用,上述动画类型基本可以覆盖大部分需求。

发布了426 篇原创文章 · 获赞 49 · 访问量 64万+
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览