骨架动画压缩指南


本文将简要概述如何实现简单的动画压缩方案和一些相关概念。在这件事上,我绝不是专家,但是关于这个主题的信息很少,而且是零散的。如果您想阅读有关此主题的更深入的文章,那么建议您转到以下链接:


在我们开始之前,值得简要介绍一下骨骼动画及其一些基本概念。

动画和压缩基础


如果您忘记蒙皮,则骨骼动画是一个非常简单的主题。我们有一个骨架的概念,其中包含角色骨骼的变形。这些骨骼转换以分层格式存储。实际上,它们存储为全局位置和父级位置之间的增量。这里的术语令人困惑,因为在游戏引擎中本地通常被称为模型/角色空间,而全局是世界空间。在动画术语中,局部称为骨骼父空间,而全局则是角色空间或世界空间,具体取决于是否存在根部骨骼的运动。但是我们不用担心那么多。重要的是,骨骼变形相对于其父母是本地存储的。这具有许多优点,尤其是在混合(混合)时:如果两个位置的混合是全局的,则它们将在该位置线性插值,这将导致骨骼的增加和减少以及角色的变形。而且,如果您使用增量,则混合是从一个差异到另一个差异,因此,如果两个姿势之间一个骨骼的增量转换相同,则骨骼的长度将保持不变。我认为用这种方式最简单(但并非完全准确):使用增量会导致混合过程中骨骼位置的“球形”运动,而全局变换的混合会导致骨骼位置的线性运动。

骨骼动画只是具有(通常)恒定帧频的关键帧的有序列表。关键帧是骨骼姿势。如果要在关键帧之间摆姿势,我们将采样两个关键帧并在它们之间进行混合,使用它们之间的时间比例作为混合的权重。下图显示了以30fps创建的动画。动画总共有5帧,我们需要在开始后获得0.52 s的姿势。因此,我们需要在第1帧中的姿势和第2帧中的姿势进行采样,然后以约57%的混合权重在它们之间进行混合。


一个5帧动画的示例,并在一个中间帧时间请求一个姿势

有了以上信息,并认为内存对我们来说不是问题,姿势的顺序保存将是存储动画的理想方式,如下所示:


简单的动画数据存储

为什么如此完美?对任何关键帧进行采样归结为一个简单的memcpy操作。对中间姿势进行采样需要进行两次记忆操作和一次混合操作。从高速缓存的角度来看,我们使用memcpy顺序复制了两个数据块,也就是说,在复制了第一帧之后,其中一个高速缓存将已经具有第二帧。你可以说:等等,当我们混合时,我们需要混合所有骨头;如果大多数帧之间没有变化怎么办?将骨骼存储为记录并仅混合已更改的转换会更好吗?好吧,如果实现了这一点,那么在读取单个记录时可能会导致更多的缓存丢失,然后您将需要跟踪需要混合的转换,以此类推...混合似乎是很多耗时的工作,但从本质上讲,它是将一条指令应用于已在缓存中的两个内存块。另外,混合代码相对简单,通常只是一组SIMD指令而没有分支,而现代处理器将在短时间内处理它们。

这种方法的问题在于,它占用了极大的内存,尤其是在以下条件适用于95%的数据的游戏中。

  • 骨头的长度恒定
    • 大多数游戏中的角色不会伸展骨骼,因此,在同一动画中,变换的记录是恒定的。
  • 我们通常不去骨头。
    • 缩放很少在游戏动画中使用。它在电影和VFX中非常活跃,但在游戏中却很少使用。即使使用,也通常使用相同的刻度。
    • 实际上,在我在运行时创建的大多数动画中,我都利用了这一事实,并将整个骨骼转换保留在8个float变量中:4个旋转四元数,3个移动四元和1个缩放。这样可以在运行时显着减小姿势的大小,从而在混合和复制时提高生产率。

牢记所有这些,如果您查看原始数据格式,就会发现它浪费内存的效率如何。即使它们没有变化,我们也会复制每个骨骼的位移和比例值。而且这种情况很快就失控了。通常,动画师以30fps的频率创建动画,在AAA级游戏中,角色通常有大约100块骨头。根据这些信息量和8浮点格式,我们每个姿势大约需要3 KB,每秒动画需要94 KB。值迅速累积,在某些平台上很容易堵塞所有内存。

因此,让我们谈谈压缩;尝试压缩数据时,需要考虑几个方面:

  • 压缩率
    • 我们设法减少了多少占用的内存量
  • 质量
    • 我们从源数据中丢失了多少信息
  • 压缩率
    • .

我主要关注的是质量和速度,而不关注内存。此外,我使用游戏动画,并且可以利用以下事实:实际上,为了减少内存负载,我们不必在数据中使用位移和缩放。因此,我们可以避免由于帧数的减少和其他有损失的解决方案而导致的质量下降。

还要特别注意的一点是,您不能低估动画压缩对性能的影响:在我以前的项目之一中,采样率降低了约35%,并且还存在一些质量问题。

当我们开始处理动画数据压缩时,需要考虑两个主要的重要方面:

  • 我们可以多快地在关键帧中压缩单个信息元素(四元数,浮点数等)。
  • 我们如何压缩关键帧的序列以删除冗余信息。

数据离散化


几乎所有这部分都可以简化为一个原则:离散化数据。

离散化是说我们要将一个连续区间的值转换成离散值集的一种困难方式。

离散浮点数


在对浮点值进行采样时,我们努力采用该浮点值,并使用更少的位将其表示为整数。诀窍是整数可能实际上并不代表源编号,而是一个离散间隔中的值映射到连续间隔。通常使用非常简单的方法。要采样值,我们首先需要为原始值设置一个时间间隔;收到此间隔后,我们将对此间隔的初始值进行归一化。然后,将该归一化值乘以所需给定输出大小(以位为单位)可能的最大值。也就是说,对于16位,我们将值乘以65535。然后将结果值舍入到最接近的整数并存储。在图像中清楚地显示了这一点:


将32位浮点数采样到无符号16位整数的示例

为了再次获得原始值,我们只需以相反的顺序执行操作。在这里需要注意的重要一点是,我们需要记录该值的初始间隔。否则,我们将无法解码采样值。采样值中的位数决定了归一化间隔中的步长,因此也决定了原始间隔中的步长:解码后的值将是该步长的倍数,这使我们能够轻松计算由于采样过程而产生的最大误差,因此我们可以确定位数我们的应用程序所需。

我不会提供源代码示例,因为有一个相当方便和简单的库可以执行基本的采样操作,这是有关此主题的一个很好的资源:https : //github.com/r-lyeh-archived/quant(我会说(您不应该使用它的四元数离散化功能,而稍后再使用)。

四元数压缩


四元数压缩是一个经过充分研究的主题,因此,我不会重复其他人更好地解释的内容。以下是快照压缩文章的链接,该文章提供了有关此主题的最佳描述:https : //gafferongames.com/post/snapshot_compression/

但是,我对此话题有话要说。关于四元数压缩的bitsquid文章建议使用每个四元数分量大约10位的数据将四元数压缩为32位。这正是Quant所做的,因为它基于bitquid帖子。在我看来,这种压缩太大了,在我的测试中,它引起了强烈的晃动。也许作者使用的角色层次结构不太深,但是如果您从我的动画示例中将15个以上的四元数相乘,则组合误差会变得非常严重。在我看来,绝对精度的最小值是每个四元数48位。

由于采样而缩小


在开始考虑不同的压缩方法和记录排列之前,让我们看看如果简单地在原始电路中应用离散化,将会得到哪种类型的压缩。我们将使用与之前相同的示例(一个100块骨头的骨架),因此,如果每个四元数使用48位(3 x 16位),移动48位(3×16),缩放16位,则总共进行转换我们需要14个字节而不是32个字节。这是原始大小的43.75%。也就是说,对于频率为30FPS的1秒钟动画,我们将音量从大约94 KB减少到大约41 KB。

这一点也不错,离散化是一种相对低成本的操作,因此不会过多影响拆包时间。我们找到了一个很好的起点,在某些情况下,这甚至足以在资源预算内实现动画并确保出色的质量和性能。

记录压缩


这一切都变得非常复杂,尤其是当开发人员开始尝试减少关键帧,曲线拟合等技术时。同样在这个阶段,我们真的开始降低动画的质量。

在几乎所有此类决策中,都假设每个骨骼的特性(旋转,位移和比例)都存储为单独的记录。因此,我们可以像我之前展示的那样翻转电路:


将骨骼数据保存为记录

在这里,我们简单地按顺序保存所有记录,但是也可以将所有旋转,位移和比例记录分组。基本思想是,我们从存储每个姿势的数据到存储记录。

完成此操作后,我们可以使用其他方法进一步减少占用的内存。首先是开始丢帧。注意:这不需要记录格式,并且可以在以前的方案中应用此方法。此方法有效,但会导致动画中的微小移动丢失,因为我们丢弃了大部分数据。这项技术在PS3上得到了积极的应用,有时我们不得不弯腰以达到极低的采样频率,例如每秒高达7帧(通常用于不太重要的动画)。对此我有不好的回忆,作为动画程序员,我清楚地看到了丢失的细节和表现力,但是如果您从系统程序员的角度来看,我们可以说动画“几乎”相同,因为总体而言,运动是保留下来的,但同时我们节省大量内存。

让我们忽略这种方法(在我看来,它太具有破坏性),并考虑其他可能的选择。另一种流行的方法是为每条记录创建一条曲线,并减少曲线上的关键帧,即删除重复的关键帧。从游戏动画的角度来看,通过这种方法,运动和缩放记录得到了完美的压缩,有时会减少到一个关键帧。该解决方案是非破坏性的,但是需要解压缩,因为每次我们需要进行转换时,我们都必须计算曲线,因为我们不能再仅仅访问内存中的数据了。如果仅在一个方向上计算动画,则可以稍微改善这种情况并为每个骨骼存储每个动画的采样器状态(即从何处获得曲线的计算),但是您必须为此付出代价,因为它增加了内存并大大增加了代码复杂度。在现代动画系统中,我们通常不会从头到尾播放动画。通常,它们会在特定的时间偏移处过渡到新的动画,这要归功于同步混合或相位匹配之类的东西。通常我们会采样单个但不是连续的姿势来实现诸如混合瞄准/注视对象之类的动作,并且经常以相反的顺序播放动画。因此,我不建议您使用这种解决方案,因为它不值得因为复杂性和潜在的错误而造成麻烦。

还有一个概念,不仅删除曲线上的相同关键点,而且指定删除相似关键点的阈值。这导致动画变得更加淡入淡出,类似于丢弃帧的方法,因为最终结果在数据方面是相同的。经常使用动画压缩方案,其中为每个记录设置压缩参数,并且动画制作者不断受到这些值的折磨,试图同时保持质量和减小尺寸。这是一个痛苦而又压力大的工作流程,但是如果您使用的是较早版本的控制台,则内存有限。幸运的是,今天我们的内存预算很大,我们不需要这些可怕的东西。

所有这些方面都在Riot / BitSquid和Nicholas的帖子中进行了披露(请参阅本文开头的链接)。我不会详细讨论它们。相反,我将谈论有关压缩记录的

决定... 我...决定不压缩记录。

在开始挥手之前,请允许我解释一下……

将数据保存到记录中时,将存储所有帧的旋转数据。关于移动和缩放,我跟踪压缩过程中移动和缩放是否是静态的;如果是,则每条记录仅保存一个值。也就是说,如果记录沿X移动,但不沿Y和Z移动,那么我保存沿X移动记录的所有值,但仅保留沿Y和Z移动记录的一个值。

在我们95%的动画中,大多数骨骼都会出现这种情况,因此最终我们可以显着减少占用的内存,而绝对不会损失质量。从内容创建(DCC)的角度来看,这需要做一些工作:我们不希望骨骼在动画创建工作流程中出现轻微的移动和缩放,但是这样做值得付出额外的费用。

在我们的动画示例中,只有两条带移动的记录,没有比例记录。然后,对于1秒钟的动画,数据量从41 KB减小到18.6 KB(即,原始数据量的20%)。随着动画持续时间的增加,情况会变得更好,我们仅在录制转弯和动态运动上花费资源,静态录制的成本保持恒定,这在长动画中可以节省更多。而且,我们不必经历因采样而导致的质量损失。

考虑到所有这些信息后,我的最终数据架构如下所示:


压缩动画数据方案的示例(每条记录3帧)

此外,我将偏移量保存在数据块中以启动每个骨骼的数据。这是必要的,因为有时我们只需要采样一根骨骼的数据而无需读取整个姿势。这为我们提供了一种直接访问记录数据的快速方法。

除了存储在一个内存块中的动画数据之外,我还为每个记录提供了压缩选项:


来自我的Kruger引擎的记录的压缩参数示例

这些参数存储我需要解码所有记录的采样值的所有数据。它们还监视记录的静态性,以便我在采样时偶然发现静态记录时知道如何处理压缩数据。

您还可以注意到每个记录的离散化是单独的:在压缩过程中,我跟踪每个记录的每个特征的最小值和最大值(例如,沿X方向移动),以确保数据在最小/最大间隔内离散化并保持最大准确性。我认为创建全局采样间隔通常不会破坏数据(当值超出间隔时)并且不会造成重大错误是不可能的。

顺便说一下,这是我愚蠢的尝试实现动画压缩的简要摘要:最后,我几乎使用了压缩。

All Articles