原文: Animation Graphs - YouTube
作者: AAA 级游戏动画系统资深开发者(约十年 3A 游戏经验)
翻译风格: 科技类学术风格,忠实原文,保留演讲口吻与技术细节。


大家好。那么,开始吧。让我确认一下麦克风音量是否合适。有段时间没做这个了,希望一切还能正常运作。今天的内容会比较多,我希望不会拖到三个小时,但我不做任何保证。那么,我们直接开始吧。在直播中反复做自我介绍有点奇怪,不过还是为 newcomers 简单介绍一下我的背景。我在3A级游戏开发领域工作了大约十年,主要专注于游戏玩法系统、动画、AI、3Cs(角色/控制/镜头)等方面,也做了大量引擎相关的工作。实际上,我职业生涯的大部分时间都专注于动画领域,幸运的是,我曾多次从零搭建动画图和动画系统,并且让团队成员在实际开发中测试这些系统,最终在正式产品中使用。每次我重复做这件事时,最终都会删掉大量代码、大量功能、大量东西,因为我意识到自己要么过度设计了某些东西,要么把它搞得太复杂了,要么根本没人需要那个工具,要么它从未被使用过,或者只是让人困惑。因此,本次分享将主要解释什么是动画图,如何构建它,我将介绍我自家引擎中的动画图版本。这与我工作中构建的版本非常相似,而那个版本正在各种3A级项目中使用。大致就是这样。

本次分享的概述将主要包括:如何构建一个图运行时。我们将讨论如何构建一个层次化有限状态机。它将是一个非常灵活、可扩展的图,具有高性能。我们将讨论如何将物理和IK集成到图中,以及如何利用后置计算做LOD相关的事情。这基本上会给你一个构建动画图的高层概览,如果你想做的话。我会想办法分享我的代码,但这比较棘手,因为它是我引擎的一部分。我可能会做的是把它剥离出来,放到GitHub上。它可能无法编译,但可以作为参考供大家使用。如果有人感兴趣,可以在Twitter上戳我,我会想办法解决这个问题。

那么,先来谈谈游戏玩法动画。我之前做过一期关于动画基础的直播,建议先看那个。但这里我还是要从根本原理上讨论动画在游戏中是如何使用的,因为动画图系统本质上就是我们如何将那些底层的东西呈现给用户——既呈现给游戏侧的开发者,也呈现给最终产品中的实际应用。基本流程是:游戏代码运行某种游戏模拟——AI、玩家输入等——这些被转换为一种游戏玩法状态,然后传递给动画系统。动画系统接收这些输入,进行处理,然后输出若干结果。大家最先想到的主要输出是姿态,也就是角色的实际动画。但还有另外两个非常重要的输出:一个是根运动,也就是这些输入导致了什么样的结果?它使角色产生了什么样的移动?其次,如果有任何事件,比如——有接触事件吗?要不要播放视觉特效?要不要收起武器?我的动作完成了吗?是否触发伤害事件?这些是从动画本身产生的信息。这些信息会回到游戏代码中。姿态会被应用到3D骨骼网格上,另外两样东西会回到游戏代码中,然后游戏代码会说:”好的,我知道动画做完了,我有根运动数据了,那么我来移动游戏中的实际模拟对象——胶囊体,或者你在用的任何碰撞体。”然后它还会触发任何其他需要做的事情,比如音频事件、视觉特效、伤害碰撞体等等。

那么,一个动画系统应该做什么呢?它应该为可动画对象生成一个姿态。它应该采样给定更新帧所需的所有动画事件。它需要提供关于根运动的信息。对吧?这是它需要做的基本三件事。还有一个附加功能:它需要提供关于当前正在做什么的信息,这个我们稍后再详细讲。它不应该做什么呢?它不应该直接修改游戏玩法状态,也不应该直接在游戏中移动物体。我见过很多次这样的错误——动画和游戏玩法之间没有界限。结果在动画更新过程中,你同时修改了游戏场景。这样一来,不仅是游戏玩法向动画系统提供输入,动画系统也在游戏玩法不知情的情况下改变了游戏状态。最终你会把动画系统和游戏代码纠缠在一起,形成一团混乱的依赖关系,根本无法调试。因为可能某个动画师修改了一些东西,然后游戏程序员就懵了:”我不明白为什么东西没有正确同步?发生了什么?我在这里设置了参数,现在它却移动了。”动画系统从根本上说应该只是一个黑盒。应该是:给你一些输入,告诉我你移动了多少,你的姿态是什么,事件是什么,然后我来决定下一步怎么做。

接下来聊聊图。我们来简单回顾一下历史,看看过去——算起来有15年左右,可能更久——这些东西是如何演变的。在很久以前,事情要简单得多。基本上直接在游戏代码里说:”嘿,播放这个动画。”比如,我在跑步,就播放这个跑步动画。如果有三个跑步动画,我就设置混合权重,然后说:”好了,这三个动画,混合它们。”所以一切都是代码控制的。每当切换游戏状态时,就直接说”播放动画”,然后选一个新的动画。有趣的是,我觉得很多老派程序员至今仍然认为事情就是这样运作的,他们基于这种理解来做引擎层面关于动画的决策。但实际上,事情早就远远超越了那个阶段。

显然,随着游戏复杂度的增长,这种方式根本行不通了。它变得无法管理。你基本上是在代码里手写混合树。每当有人想调整东西或集成新动画时,就需要改代码,诸如此类。这完全是一团糟。

所以,第一个主要的范式转变就是走向混合树。混合树本质上只是一个单一的树结构,允许你指定:这里是一些动画片段,这里是我们想对它们应用的操作。比如你想说:”哦,我想混合慢跑和跑步,然后再混合方向进去。”或者某种程度上的操作组合。这基本上仍然允许游戏侧说:”嘿,我现在处于运动位移状态,所以播放这个混合树。”然后当我切换到另一个状态时,就播放另一个混合树。对吧?所以很多状态管理仍然在代码中完成。有些地方以一种——我称之为激进的方式——扩展了这种做法,他们基本上构建了一个处理所有事情的巨型混合树。我记得一些UE3项目最初就是这样做的,因为当时没有状态机,只有混合树。所以他们就像幻灯片上展示的那样,基本上把所有不同的动画状态放在一个巨大的混合树里,然后代码控制权重的进出。他们试图通过手动操作来模拟状态机:”好的,现在淡入踢击,然后淡出其他所有东西。然后,好了,我现在需要做拳击,淡出踢击,淡入拳击。”他们手动管理这些权重,这用起来非常痛苦,极难管理。而且再次强调,这严重依赖代码。

所以,下一个合乎逻辑的步骤显然是走向状态机。有些公司已经在做了。他们每个状态使用一个混合树,然后通过代码切换状态。现在他们把状态放到数据中。这大大改善了情况,因为很清楚——哦,如果我在运动位移状态,混合树做这个,我不需要关心任何资产。如果我在跳跃状态,跳跃状态做这个,我也不需要关心。所以这非常有帮助。但是随着极大的复杂度、分层以及许多其他因素的加入,这种方式也会很快崩溃。最终你要么得到极其复杂的混合树,要么得到那种大家都见过的怪物级状态机——来自地狱的蓝图之类的。你会看到那种疯狂的动画状态机,所有东西都引用所有东西,到处都是箭头,完全不可读。这就是这种做法的终极痛点。例如,一个非常简单的设置:你有运动位移状态,攻击状态,受击反应状态,跳跃状态,诸如此类。然后开始添加过渡逻辑,最终得到像这样的一团乱麻。这行不通。这根本无法扩展。我觉得很多人到了这个阶段,意识到它不能扩展,然后就完全否定了状态机。而如果我们沿着这条逻辑路径继续走下去,下一步就是在图中引入层次结构。这大大简化了事情。它让推理变得容易得多,因为你仍然有高层状态——比如运动位移、攻击、受击反应等。那些过渡变得简单了,因为无非就是——我在运动位移中,或者在执行攻击,或者在受击反应中。理解当前状态容易得多。然后在受击反应内部,我可能有多个子状态,那些过渡也只是——我在畏缩中,还是被击退中,或者其他什么。这样你就把决策拆分成多个层次,使得推理更容易,调试也更容易,诸如此类。 它也让参数管理变得更容易,因为现在动画系统的参数被这些高层状态自然地分隔开了。你会有一组交互参数,一组运动位移参数,一组攻击参数,诸如此类。这样一来,谁设置了什么、参数是否正确,这些问题都变得清晰明了。那么,对于动画图来说,假设我们有一个状态机。你看到状态机时,第一反应可能是:好,它包含了选择和播放动画所需的所有逻辑,对吧?我想更进一步说,它的目标是生成一个角色姿态。并不一定要播放动画。在某种程度上,我们需要停止把动画系统仅仅看作是在采样和播放动画片段这种角色。在某些设置下,确实就是那样工作的,但在很多情况下,我们是将动画源作为输入,供给其他操作,以过程式的方式生成姿态。它向游戏逻辑反馈角色当前处于什么动画状态。这一点非常重要——我需要知道是否应该结束一个游戏状态,是否应该过渡,还是应该继续等待,诸如此类。很多时候我们会说:”嘿,我需要你执行一个攻击。”然后游戏代码在等待,问:”攻击开始了吗?好,开始了。”于是我们等待它结束。攻击结束后,我才能在游戏逻辑中进行过渡。或者换句话说:”好,我现在处于什么状态?”如果我当前处于这个状态,我可以做出不同的游戏内决策。这也回到了我之前提到的事件机制。它向游戏逻辑和其他后续系统反馈信息,用于实现视觉效果之类的东西。那么,在我看来,它应该如何构建呢?基本上,你有一组控制参数。这些是图的输入。它们输入一个黑盒,然后你得到事件、根运动增量,以及——我称之为姿态。在我的设置中,姿态实际上并不是由图计算出来的。我稍后会解释这一点,但本质上就是这三样东西。对吧?这才是它应该做的事。它就是一个黑盒。你输入一些东西,得到一些输出,然后你自己决定如何处理这些输出。它不做任何决策。它什么都不做。所以,我主要有两种类型的节点。一种我称之为姿态节点。我以前叫它们动画节点。命名上确实有点微妙。基本上,它们是负责管理时间的东西。它们负责采样动画片段,然后发回一个命令说:”嘿,我已经采样了这个动画来计算出姿态。”它们会从动画片段中读取根运动,采样事件,然后把这些信息向上传递。简单说,它们是输出姿态的节点。我这里说的姿态,指的是一种姿态结果。我们稍后会看到它是如何被使用的。这类节点的输出只能进入一个输入。另外,我还有值节点。这些基本上就是整数、浮点数之类的东西。它们没有真正的状态,只是一个值。它们可以简单地连接到多个目标,这没问题。值节点还有一个按需计算的机制。你可能有一整条数学运算链——取两个浮点数相加,再乘以一堆东西,诸如此类。我忘了在截图中放进去。抱歉。但基本上,它的工作原理是——我后面会展示一个在引擎中的例子——当你向一个节点请求它的值时,如果它在这一帧还没有被计算过,它会惰性地计算自己的值然后返回。之后任何对该值的请求都只会拿到缓存的值,因为在单次更新的上下文中,没有任何东西会改变——你有一组固定的输入,你调用更新。在这次更新过程中,一切都是不可变的。所以我们可以缓存结果,跳过整个长长的计算链。稍后你会看到,当有大量复杂过渡时,这会带来巨大的性能提升。我有一组基本的值类型:布尔值(显然),ID(实际上就是字符串ID),整数,浮点数,向量,目标,以及骨骼遮罩。我们先来谈谈目标。这是一种自定义类型。很多时候,我们需要告诉图:”嘿,我需要你跳转到这个点”,或者”我需要你与某个位置的物体交互”,或者”嘿,我需要你偏移一个IK目标”,或者”嘿,这是你的实际IK目标”。所以我们需要以有意义的方式把这个信息传递给图。很多时候它就是一个变换,但其他时候它可能是一个骨骼ID,因为我们想要定位姿态中的某块特定骨骼。因此,目标本质上是一个包装器,让我可以指定某个位置、变换等,而其来源可以多种多样——可以是一个变换,也可以告诉系统:”嘿,查看你实际的姿态,从姿态中获取这个值。”这在后面讲到姿态如何计算时非常有用,因为它还允许我在姿态操作链中控制该目标——无论它是从上一帧的姿态中读取的,还是从当前的实际姿态中读取的。控制参数非常简单。它们就是值节点。我们不允许把骨骼遮罩作为控制参数。它们被设置一次。你调用图的更新。你可以读回这些值。但我觉得这没什么意义——既然你已经设置了它们,你应该已经有这些值了。它们有唯一的名称,游戏代码通过名称绑定到这些参数。也就是说,它们有一个图实例,然后说:”嘿,我需要参数 speed。”然后得到一个索引,它们可以缓存这个索引,之后每一帧都可以通过这个索引来设置 speed。我们还有一个功能叫虚拟参数。很多时候我们会从游戏逻辑中输入一些东西,比如:”嘿,这是你的速度,这是你的朝向,你应该跳或者应该落下,或者这是你在空中的速度。”诸如此类。很多时候,技术动画师需要从一组参数中生成另一个新的复合参数。他们可能会说:”嘿,我在空中的速度是这个,我的速度是那个,我在空中已经这么久了,所以我想要一个布尔值来表示’我正在快速下落’。”于是他们通过一个虚拟参数来定义它:”嘿,以特定方式组合这些控制参数,输出一个值,然后我可以把它当作图中的一个控制参数来使用。”游戏代码当然也可以做这件事,但很多时候让技术动画师来管理会更方便,因为很多时候这些值是为了视觉效果的变化,而且完全取决于图是如何构建的。所以我们给了他们这个功能。这又回到了值缓存的作用上——当该参数第一次被访问时,我们计算它的值。之后任何请求都直接读取这个值,不再需要计算。所以即使值参数的计算非常复杂,也没关系——它每帧只运行一次。这就是值节点的大致样子。我只有一些类型验证的代码,但非常简单。我说过,代码是可以公开的。有一点值得注意,你会看到这些虚函数。我相信很多人已经在想:什么?——所有东西都用虚函数和多态?但等你看到性能数据时,你会感到惊讶的。接下来讲姿态节点。这些是主要的节点类型,用于实现你在概念上理解的动画图或混合操作。它们按节点跟踪时间,管理同步——这在动画基础部分已经讲过了。所以基本上,它还是一个时间更新机制。然后它们注册姿态任务。当你采样一个动画片段时,它实际上并不读取姿态。它更像记录一条命令,类似于GPU命令缓冲区,说:”嘿,你需要在这个时间点采样这个动画。”它把这条命令记录在某种命令队列中。然后它可能会采样并修改根运动增量,获取事件,等等。所以你可以把每个姿态节点看作一个动画。它有时长,有同步轨道,有上一帧时间和当前时间。大致就是这样的。我觉得代码本身某种程度上没什么好看的。但这里值得注意的重点是,我们为每个姿态节点跟踪的信息,本质上就是循环次数、时长、上一帧时间,以及当前的位置。如果你看”姿态节点结果”——也就是那里定义的第一个结构体——你会看到有一个任务索引,该节点采样到的增量,以及采样到的事件范围。我们有一个巨大的事件缓冲区,在遍历图的过程中不断填充,每个节点会记录:”嘿,这是我采样到的事件范围。”这个信息由更新函数传递回来。每当一个节点被更新,它会返回它所做的事情。然后这些信息被处理、修改,并向上传递。所以,在更新子节点的任意时刻,你都能知道它采样了哪些事件,子节点的根运动是什么,等等。姿态节点还有两种更新方式。一种是未同步更新,它只取当前更新的时间增量,然后说:”这就是你步进的步长。”所以节点用这个时间增量来更新自己的时间。另一种是同步更新,它会得到本次更新的显式时间范围。也就是说,它不跟踪上一帧和下一帧,而是被告知:”你从这里开始,到这里结束。”这就是你的更新区间。这就是我们实际管理同步的方式——我们通过某个主节点(姑且称之为主要节点)来推导时间,然后其他所有节点都根据这个时间更新来驱动。 实际上,触发同步更新的节点并不多。从根本上说,要么是转换,要么是混合。至于层,层本质上就是混合。因此,这三种机制主要负责在非同步与同步状态之间切换。如前所述,姿态节点会注册姿态任务、修改时间增量、获取事件。我们还有一个概念需要提及:所有节点在被更新时,都会接收到一个图上下文。这个上下文提供了该实例执行所需的全部数据。例如,它会包含:你正在注册任务的任务系统、需要填充已采样事件的事件缓冲区、角色当前帧的世界变换、上一帧的姿态,诸如此类的信息。这些信息会被传递并被每个节点使用。此外,它还会追踪数据集——关于这一点我们稍后会详细讨论。

图中最后一个主要概念是我称之为源节点的东西。它们本质上就是一种插槽,用于接入数据。对我而言,图本身只是逻辑。它们不包含任何对数据的引用,仅仅是逻辑运算。比如,我有两个动画片段在这里,它们按某种方式混合,然后结果被送入某个层进行进一步修改。我们不直接引用任何数据。数据是从外部按实例提供的,并通过上下文传入。例如,当我们创建一个节点时,我们会说:”这是你的数据。”然后它就在那个时刻填充插槽。当你想要在同一张图上实现多种变体时,这种做法非常有用。这主要通过我称之为动画数据集的东西来实现。我以前叫它变体,但后来变得复杂了。基本上,想象我有一个非常简单的混合树,包含大约六个动画。图是逻辑,我有那些插槽。于是,我创建我的图,然后进入一个数据库,填写那些条目,比如:”嘿,左转跑步是这个动画,向前跑步是这个动画,右转跑步是这个动画。”当我运行图时,我就说:”嘿,用这个数据集运行这张图。”当我们创建实例时,我们就填充那些节点。

这样做的好处是,我们可以实现层次化派生。我们有一个始终存在的默认数据集。然后,我们可以从中派生,做一些类似的事情:”嘿,我现在想要一个步兵。”步兵会覆盖一组动画数据。然后,从步兵出发,可能还有不同的变体,比如动力装甲,他有不同的步行循环。于是,我只想覆盖那个步行循环。这样,我们就可以构建这种派生数据集。当它们被编译时,会被展平成一个扁平列表。因此,当我们实例化一个节点时,我们只需说:”好的,这是这张图配上这个变体或这个数据集,把它填好就行。”

让我展示一下它实际的样子。例如,这是我们的示例图,我有一个默认变体,实际上只填了两个动画,其他都是空的。如果我切换到步兵,你可以看到我已经在那个版本中覆盖了这些动画。如果我需要,我可以在这里添加一个覆盖,也可以重置回默认值。如果需要,我还可以在派生版本中清除已设置的动画。例如,我可以到这里直接覆盖它。哎呀。这种方式基本上让我能够控制每个原型设置了哪些数据。例如,我的状态机中可能有一部分逻辑是针对某个非常特定的用例的,而这个用例也许只有我的十个人工智能原型才需要。但我不想把它拆分到一个外部图中,做一堆乱七八糟的事情。所以,我就把它留在图中,只清除数据,只有需要那个特性的原型才会有数据集。这样,我就不需要加载任何不需要的动画了。因此,这是管理数据加载以及原型引用关系的一种好方法。

另一个我提到过的事情是,节点可以修改根运动。基本上,节点所做的就是,当它采样根运动时——根运动只能来自图中一个被称为动画片段的节点,那是唯一真正操作动画片段的节点。其他所有节点都只是接收其子节点的结果,然后可以获取那个根运动增量并对其进行修改。例如,我们这里有一个根运动覆盖节点,它允许我们从子节点获取根运动并覆盖其某些分量。你可能看不清代码,但之后幻灯片或其他资料会公开,你可以仔细研究。基本上,它的逻辑是:”我要获取这个根运动增量,修改其朝向,调整速度。”或者:”我要完全移除面朝方向的Z分量。”或者:”我要将平移量乘以某个系数。”这允许你在图内部应用这些修改,然后将结果传递给父节点。

另一个节点示例是速度缩放。这是一个非常常用的工具,用于调整某些动画的速度——比如它们跑得多快之类的事情。基本上,它的运作方式非常简单:它说:”好的,我的增量时间是多少?我显式地乘以增量时间,然后用修改后的增量时间更新我所有的子节点,最后再重置增量时间。”这是来自我的自研引擎,所以我就直接修改了上下文。在工作中,我曾构建了一个守卫值,以确保人们不会意外忘记重置它。如果你在生产环境中构建这类功能,你可能也想这样做。你会注意到,这里的同步更新会抛出一个警告。同步,正如我提到的,是一种你明确控制时间的更新模式:”你从这个点开始,到这个点结束。你无法缩放它,也无法修改它。它必须是精确指定的。”因为这是在同步模式下运行。不过,这个警告可能有点过于激进,因为有很多用例会在同步更新中运行节点。例如,你可能在同步模式下运行一个混合树几帧,然后再切换回非同步模式。所以,你会看到那个警告出现几帧,然后它就会消失,因为又回到了非同步更新。这种情况通常发生在转换期间。你可能开始一个同步转换,当转换结束时,你又回到了非同步运行。所以,那个警告可能确实有点过度了。

接下来,我们谈谈混合。很多动画图通常都有大量不同的混合节点。有些甚至有混合空间以及其他各种混合机制。多年来,我经历过所有这些。我深受构建二维混合空间之苦,甚至一度尝试过三维混合空间——我不推荐。但总的来说,多年下来,我基本上已经放弃了所有这些东西。我认为它们都只是在为技术寻找问题。从根本上说,混合的顺序会改变你的姿态。这一点对于任何做过动画混合的人来说应该都是显而易见的。你执行某些混合的顺序会改变最终结果。混合空间本质上是试图为这些动画构建一个空间,并基于某个参数定义它们之间的混合权重。问题在于,如果你自动化地做这件事,动画师总会在某个时候问你:”嘿,我实际上想以不同的方式混合这三个动画。”一旦出现这种情况,你就得提供绘制工具来让动画师手动编辑混合空间。因为我已经不能自动生成它了,所以现在我必须提供那个工具。而一旦他们开始这样做,那就变成了:这工具到底在干嘛?它到底帮了谁?实际情况是,你真正想做的基本上就是指定:我想如何将这六个动画混合在一起,以什么顺序,哪些动画能很好地混合在一起,哪些不能。动画师对此有直觉理解。那个工具通常只是个麻烦,因为现在他们得努力理解他们看到的东西,以及这些东西如何映射到他们脑海中本已直观理解的概念。我从未看到过这样做的好处。此外,它会让动画数据集覆盖变得极其复杂,因为参数化——如果你做自动化版本的话——通常是基于数据的。所以,你现在还需要在数据集内部存储参数化信息,这使编译步骤变得更加复杂。你还必须能够预览。它最终带来的是一大堆麻烦,而好处微乎其微。

那么,我用了什么来代替呢?我只用了一个范围混合。我有一个一维参数化混合。用户基本上只需要说:”这些是我的动画,这是参数映射到这些动画的范围。”然后它会自动计算混合权重。这基本上让动画师能非常轻松地说:”我有一个速度参数。我的慢跑在一米每秒,我的跑步在五米每秒,这个在六米每秒。”然后我就根据这个参数进行混合。这对他们来说非常直观。另外,可能有人会说:”等等,不同的原型有不同的速度,你不能直接把速度写在图里。”没错,确实不行。但游戏程序可以很容易地提供给你一个归一化速度。然后他们可以这样做:零是行走速度,一是冲刺速度,而跑步速度可能是0.75。你们可以约定一个度量标准,然后非常轻松地完成设置。你并不需要一大堆复杂的框架和样板代码来替你预测所有情况。我还有一个辅助节点,叫做基于速度的混合。这个节点主要是为了避免速度参数化的问题,因为它毕竟是一个非常常见的用例。 所以,它所做的事情本质上是一个一维混合,其参数化是根据你输入的动画自动计算出来的。它只是一种语法糖。以上就是仅有的两种混合节点。我在有些动画系统中见过这样的情况:你说要混合,结果有大概30个混合节点列在那里,因为每次有人需要某个特定功能时,就有程序员去硬编码一堆东西塞进一个混合节点里,而这些本可以用一套非常基础的工具就完成。或者,为了绕过系统中其他区域缺失的功能,他们搞出了一些稀奇古怪的定制混合节点,配上只有参与过某个项目的某个动画师才懂的莫名其妙的名字。我曾经在某个动画系统中工作过,里面有一半的节点没人知道是干什么的——因为它们是在另一个项目中构建的,然后这个动画系统被集成进来,所以没人知道它们是什么意思,于是大家干脆又自己建了一套功能类似的节点,结果就是一团乱麻。对用户来说,根本没法推理:”我到底该在什么时候用什么节点?”而按照这种思路,就只有一种混合节点。使用起来非常简单。大致就是如图所示的样子。你只需要提供任意数量的输入,为它们指定参数范围,然后一切就搞定了,非常简单,而且有效。

接下来谈谈状态机。如果我讲得有点快,请见谅——我还有很多幻灯片要过。正如我说过的,我采用的是层次化状态机。每个状态是一个混合树,而每个混合树内部又可以包含多个状态机。层次结构的深度没有限制,你可以任意嵌套。实际上,我想说的是,深层次的层次结构完全没有问题。只要它能解决问题、简化你的设置,需要多深就做多深。我之前的项目——那款游戏里——我们的层次结构曾经将近20层深,运行得非常好,没有任何问题。所以放心利用所有你手上的工具就行,只要能解决问题,往深了做也没有问题。

过渡节点,从根本上说,在一个状态机图中,它就是你的动画系统。它是一切的核心。而且它实际上是最复杂的节点,以我的情况为例,它的代码量大概有700行,主要是为了计算目标状态的初始起始点。而图中其他所有节点的代码量大概也就50、60行。过渡节点是真正的主力,因为它基本上是你主要在做的事情。它主要控制以下几个方面:首先,显然,过渡应该持续多久?通常过渡相对较短,大概六到七帧,0.2到0.3秒。其次,最关键的问题是:我应该从目标状态的哪个位置开始?这里有非常多的选项。我应该匹配事件吗?我应该匹配百分比吗?我应该从最开始播放吗?我应该做同步更新吗?诸如此类。同时,它还可以做时间同步的步进,比如说,如果我从一个循环动画(比如走路循环)混合到另一个(比如慢跑循环),它会以同步的方式进行混合。此外,它还能控制:”在过渡过程中,根运动怎么处理?是保留源状态和目标状态并把它们混合在一起?还是只使用目标状态的根运动?或者只使用源状态的根运动?”在很多情况下,这是确保角色最终落在正确位置所必需的,因为过渡过程中的混合可能会打乱你之前的预测。例如,如果你在一个状态中正在对某个特定动画做扭曲处理,然后你混合出去了,你会希望保留那个经过扭曲的根运动。所以,在过渡结束之前,你会忽略目标状态的位移。这样就能保证你最终落在正确的位置。

在我的引擎中,在图视图里,每一个过渡我都称之为一个”导管”。每个导管可以包含多个过渡。导管也可以为空,稍后你会看到为什么要这么做。你还会看到,我的界面中有一种双视图,我觉得这对处理图来说非常重要。我在其他地方很少看到这种做法,我也不知道为什么,因为它能极大地简化工作。所以,如果我打开一个状态机,我可以点击其中任意一个过渡,它们就会显示在底部。这样一来,我可以同时看到我的状态机和我正在编辑的内容,不会丢失当前操作的上下文。也就是说:”好的,我的’待机到走路’状态转换在这里,就是这个过渡,我可以在这里设置我需要的所有参数。”而在很多其他地方,你需要双击过渡,然后就会丢失自己在图中的位置。这种设计还允许你快速切换,说:”好的,我想从这里复制点东西,粘贴到那里,也许再到那里看一下设置。好了,让我检查一下这个。对,是一样的。OK。”你始终能知道自己正在编辑的上下文。这一点极其重要,但我发现很多地方都没有做到,这挺烦人的。

让我新建一个状态。还有其他一些情况我也会用到这个功能。这个界面的样子还请见谅,我没花太多时间把它做得很好看。让我想想,我在找什么?啊,抱歉,我是个笨蛋。选择器。你会注意到我还有一个选择器,本质上就是一个 if 语句。同样,我提到了这个双视图:当我点击选择器时,它会在底部显示出来,然后我就可以说:”我想用速度这个参数,如果速度大于零,就选选项一,否则选选项二。”就是这样。同样,因为有这个视图,当我点击选择器时,我能立刻看到条件是什么。我真的不明白为什么其他地方不这么做。我以前从没见过这种做法。如果说我希望有人从这次演讲中”偷走”一样东西,那就是这个。请一定实现这个功能。这对所有人都有巨大帮助。

正如我提到的,我的过渡——确切说是我的导管——可以包含多个过渡。这常常是必要的,因为在两个状态之间,基于当前的游戏状态或图的状态,你可能需要不同类型的过渡。所以,有时我可能有一个默认过渡,也可能有一个非常快的紧急过渡。例如,默认过渡会等待动画播放到某个位置、匹配到一个事件后再执行过渡;而紧急过渡则会要求”立刻进入目标状态,马上”。此时它会直接触发另一个不同的过渡——也许是一个弹起姿态加上零帧过渡之类的。有时候,出于同步的原因,或者仅仅是试图匹配脚部事件(即使没有同步),你也需要控制从动画的哪个位置开始进入。我这里有一个非常简单的例子,来自我之前做过的演讲。我们有一个非常简单的跳跃动作。出于某种原因,我们决定让跳跃以根运动驱动。于是,我们的跳跃动画基本上包括一个起跳、空中飞行,然后在距离起跳最高点15米处落地。那么,我们如何在图中构建这个呢?首先,我们很可能会把它拆成两段:起跳段和落地段。然后有人会说:”跳跃能跳15米固然好,但因为它是由动画驱动的,而我又想控制它——比如我在两个平台之间跳跃,两个平台相距只有5米,我就只想跳5米,不想跳15米。或者如果距离是10米,我就想跳10米,不想跳15米。这跟我想要的不一样。”所以,我们可以在这两个状态之间创建多个过渡。我们可以分别创建5米、10米和15米的过渡。然后,我们会为该动画创建同步周期——基本上就是做一个周期覆盖前5米,一个覆盖第二个5米,一个覆盖剩下的部分。这样这个动画就有了三个同步事件。而我们的过渡要做的就是:如果我只想跳5米,就跳过前两个同步周期;10米的过渡跳过第一个;15米的过渡什么都不做。你会注意到过渡节点上有一个同步偏移参数。你可以直接告诉它:”进入下一个状态时,把初始时间偏移一个同步周期。”还有其他方式来控制目标状态最终落在哪里。你可以匹配一个同步事件,也可以匹配通过同步事件的百分比,等等。所以对于如何从一个状态进入下一个状态,你有很多控制手段。

这里你还可以看到另一个例子:过渡条件可能会复用很多信息。比如,ID 比较被用在了三个过渡中,目标信息也被用在了三个过渡中。这里再次体现了值缓存的作用:例如,当我在某一帧第一次求值5米过渡的条件时,ID 比较和目标信息会被缓存下来,10米和15米的过渡就不需要再做同样的工作了——直接拿值用就行。在我以前工作过的动画引擎中,光是求值过渡条件,往往就是运行时最大的开销之一。而在我这里,这不算大问题,主要就是因为我会缓存值。在很多地方,每个过渡都在反复做同样的操作。而这种设计则允许技术动画师几乎像在做函数式编程——当然不是真正的函数式编程——但他们可以用类似函数的方式去编程。他们会构建一组条件,然后配合一些额外参数,把这些条件喂给多个过渡使用。这又是一种非常简化的做法,而且我很惊讶居然没什么人在做类似的事情。

过渡还有一个概念。想象我有两个状态,它们可以在彼此之间互相转换。 假设当前处于状态A,开始向状态B过渡,这个过渡可能很长——比如出于某些原因需要半秒或一秒。但在过渡到B的过程中,突然收到一个信号:”嘿,你得回到状态A。”虽然这种情况并不常见,但确实存在足够多的案例需要你处理。那么,有哪些可选方案呢?

有些动画系统会动态创建节点。每当你进入某个状态,它们就会实例化该状态的一个新实例,创建节点,触发迁入过渡,然后执行相应操作。在这类系统中,这么做是可行的,因为你基本上只需根据需要不断实例化这些状态。你会处于A状态,拥有A的一个实例。然后过渡到B的实例。哦,得回到A,那就再创建一个A的实例。于是你现在有两个A的实例在运行。然后如果又要回到B,好吧,可以再创建一个B的实例,于是每个状态都有两个实例,伴随着多条过渡。我个人觉得,这非常愚蠢。第一,这极其昂贵——你不停地重新创建节点、状态以及整个子层级,这简直是疯了。考虑到这些图表的规模和体量,这么做完全不现实。第二,你不停地分配内存又释放内存,在很多情况下会导致内存碎片化。第三,可能引发无限循环最终耗尽内存。这种情况在多个项目中出现过:动画师搞乱了过渡逻辑,结果两秒内内存就爆了,因为那个东西已经生成了——不知道——五千万个这两棵树的实例。

在我的动画系统中,我们预分配一切。每个节点在启动时就实例化完毕,占用固定的一块内存,不会发生任何变动。运行时不会有任何分配或重新分配。我的过渡也是节点,因此它们同样在启动时实例化。以我的情况为例,有A和B两个状态。如果从A过渡到B,两个状态都在运行,各自维护着自己的状态。如果此时想从B回到A,那么——老实说——我就有点麻烦了,因为A已经拥有了自己的状态,它已经在运行了。我无法重新启动它,也无法管理两套状态。

我的做法是引入一种称为”强制过渡”(force transition)的机制。默认情况下,在这种场景中,我直接在图表中阻止该过渡,并告知:”这不是你的可选操作。你不能回到你当前所在的状态。”在特殊情况下——比如由于复制(replication)或其他原因——这个操作必须发生,那么我们可以启用强制过渡模式。但你必须手动设置,因为它在底层会做一系列额外的处理。

其工作原理是:当触发过渡时,我们开始在A处缓存一个姿态(pose)。也就是说,过渡本身会声明:”嘿,我要缓存我的源姿态。”在计算和执行所有混合操作的同时,我会缓存该值。如果之后需要从B回到A,我们会停止A状态——直接将其关闭——然后启动一个从B到A的常规过渡,就像A从未运行过一样。但A到B的过渡仍需继续进行。因此,我们使用上一帧的缓存姿态,并在A到B过渡期间持续维护该缓存姿态。我不确定这对每个人来说是否清楚。简单来说,每当发现过渡时,我们关闭A,保留A的最后一帧结果,并继续执行从A到B的混合过渡。本质上,在这几帧内,我们相当于在用一个固定姿态进行混合。

嗯,我差点忘了我还准备了这些幻灯片。正如我所说,当我们从B过渡回A时,立刻会有人质疑:”等等,你现在丢失了运动信息,因为你的缓存姿态不会更新。它是一个固定姿态。”关键在于:随着A到B过渡的进行,源端的权重在持续下降。在基本情况下,当我从A过渡到B时,混合权重从B的零贡献逐渐变为完全贡献,而A则从1降到0。因此,随着过渡的推进,源姿态的权重不断减小。如果我现在还同时运行一个远离该状态的二次过渡,那么权重会再次以指数方式衰减——因为B的整体权重在下降,这进而影响了A的源端权重。所以,它会迅速从最终姿态的实际贡献中淡出。此外,我们的过渡通常非常短暂,因此这几乎是完全不可察觉的。

实际上,说到下一张幻灯片——我们曾有过一次有趣的讨论。有一种概念叫做”惯性化”(initialization),它本质上追踪每一帧的各种速度。当出现上述情况时,它会基于这些速度继续执行过渡。我记得曾和一些技术人员争论过,他们坚持认为:”我们需要这个功能,因为我在GDC演讲上看到过它,云云。”我当时反对了这个提议,原因如下:第一,这很昂贵——实现起来非常复杂,因为你基本上是在以某种方式半运行一个物理模拟;第二,对于快速动画,即使肢体不应该朝某个方向运动,它也会让肢体继续朝那个方向运动。对吧?假设动画开始时手臂从侧面这样过来,然后在某个特定帧突然翻转向左,那么你只会让那只手臂直直地向前伸。对吧?你失去了控制。你只有一个速度,用这个速度驱动一切,运行一些参数,然后祈祷效果看起来不错。如果混合时间很长,它还会给根运动(root motion)带来问题——因为,嗯,你该怎么处理根运动?是保留上一个增量并持续应用它吗?这可能会给你的图表造成很大损害。此外,它实际上是在解决错误的问题。如果我没记错那个演讲,他们提出这种方法也是为了规避非常缓慢的过渡。他们的动画系统在评估图表等方面极其缓慢。所以这是一种快速方案——”当我做过渡时,直接杀掉源数据,我就不用评估所有那些东西了,也就不必承担那部分开销了。这对我们来说更便宜。”在我的系统中,评估极其轻量,这根本就不是问题。实际上,”惯性化”的开销可能远大于直接运行实际源更新的成本。

至于我们实现的缓存姿态(cache pose),根本没有人能察觉出来,因为归根结底,我们是在强行处理一种异常情况。而且代价只有两三帧。再加上混合权重等因素,你根本注意不到它。同时,你也不会有”现在做了错误操作”的问题——比如”我正在混合一个奇怪的姿态”或”根运动出问题了”之类。所以,这又是一个典型的例子——”嗯,这是个花哨的功能,但你想解决什么问题?你实际要解决的根本问题是什么?”

接下来——分层(Layers)。这是另一个非常基础的概念。每个好的动画系统都需要分层机制。你必须有一种通过层来修改姿态的方式。这是不可或缺的功能。在我的系统中,我们复用了状态机来实现分层流程。我们没有专门为分层定制的工具。我们有一个节点,它的作用就是:”嘿,我是一个层节点(layer node),我允许你将一个状态机分层叠加到任何你想要的东西上。”你可以指定层数,不限。你可以在层上叠加层。你可以拿一个层节点,把它放进另一个层节点中。没有任何形式的限制。分层是在局部层面完成的,而不是图表的全局属性——不是说图表本身有层。基本上,一旦你看到UI,就会明白我的意思。

举个例子,我可能有一段动画,然后我说:”好吧,在这个混合树(blend tree)中,我有这段动画,我想在上面叠加一些内容。”于是我直接放入一个层混合(layer blend)节点,再插入一个包含若干状态机的层,来表示:”这就是两个层。”每个层都是一个独立的节点。它本身不做任何操作。这纯粹是一个UI工具,让你可以设置一些参数并复制粘贴该节点,从而能够移动整个层的设置、快速调整或重新排序,而无需通过层混合的属性面板进行操作。再说一次,我的很多解决方案本质上是通过工具(tooling)而非技术(tech)来完成的——因为最简单的方法通常最有效。

我的层有一些有趣的设计。你会注意到,在分层时,我们只允许你将状态机作为分层对象。这是出于一个非常明确的考虑。在每个状态内部,你可以添加额外的信息——可以添加层权重(layer weight)和骨骼遮罩(bone mask)。当你点击某个状态时,会在二级视图中看到该层数据。当该状态机在分层上下文中使用时,这些数据会被读取并使用;不在分层场景中时,则被忽略。这样做的优势在于:你可以在每个状态级别上指定层权重,也可以在每个状态级别上指定骨骼遮罩。

想象一下,我们在一个状态机中有一组差异很大的层。我构建了一个状态机,然后说:”我不想专门创建一个上半身层,再创建一个全身层,再创建一个那边那个层。我只想有一个层,里面放一堆动作。”于是,我的换弹(reload)状态会指定:”嘿,我有一个全身骨骼遮罩。”所以当我进入该状态时,我会完全覆盖整个姿态。我还有一个退缩(flinch)状态——当我进入它时,它虽然是全身的,但其层权重会根据某些参数动态调整。例如,它会快速融入又快速淡出。这样我就能控制该状态对基础姿态的贡献程度。我可能还有一个”开门”状态,它现在是动态的,因为骨骼遮罩也可以在运行时通过一系列节点进行操作。 我们可以做的是,先在上半身进行混合,同时保持下半身处于基础状态,然后在该状态下再对下半身进行混合。这样,我们就能在每个状态层面进行控制。投掷手榴弹可能只需要右手参与。这赋予了动画师在每个状态下精细控制骨骼遮罩和图层权重的能力。再次强调,这是我尚未在其他系统中见过的设计,我也不清楚原因何在。这个方案非常简单,却能提供极大的灵活性,从而实现一些非常强大的配置。此外,状态机中还有一个名为”关闭状态”的概念。这意味着我们可以通过一个空状态来开启或关闭各个状态。具体做法是编写基本的状态机转场逻辑:当需要开启某个图层时,只需从关闭状态转场到该图层;之后可以返回,或从一个图层状态转场到另一个图层状态,也可以完全关闭某个图层。这对动画师——实际上不只是动画师,对所有人来说——都非常容易理解,因为逻辑很直观:”我想开启这个图层”。那么,该图层是处于关闭状态,还是处于某个特定状态?这非常易于调试和推理。在过去,你需要在不同层级管理图层权重,或者从外部传入参数,这简直是一场噩梦。正如我所说,一旦将其与按状态的骨骼遮罩和图层权重结合使用,效果会非常强大。这里有一点我没有特别提及:在该状态下,如果有额外的图层位于其下方(例如额外的状态机及其图层权重),这些权重会向上传播并进行混合。因此,你可以在多个层级深度上定义图层权重。这些权重会被遇到的第一个图层节点消费。但如果它们没有遇到图层节点,该信息便会传回至状态机层级,并继续向上传播。这再次让我们能够以更灵活的方式进行控制。

接下来,我们来谈谈工具——这在整个系统中是非常重要的一部分。很多团队之所以放弃状态机,正是因为他们觉得——由于糟糕的工具——这个范式本身就有缺陷。大家经常会举这样的例子来证明”状态机很烂”,对吧?因为当我构建了这样的系统后,完全无法管理。项目推进困难,没人能修改任何东西,一切随时都在崩溃——是的,如果你构建了这样的系统,确实会遇到这些问题。但这些问题有简单的解决办法。其一是使用层级结构——这属于常识,也是一种特性:也许一开始就不该这样构建系统。其二是工具:如果提供一些非常基础的工具,就能解决这整个混乱局面。接下来我们介绍两个能够解决大部分问题的要素。第一个叫做”入口状态覆盖”。每个状态机都有一个默认的入口状态——即启动时进入的状态。但有时这并非我们想要的行为,我们希望直接进入另一个状态。因此,我们提供了一种机制来覆盖这个入口状态。假设你有一个滑行状态机,其中包含一个滑行循环和一个滑行退出状态(后者必须总是被播放)。但根据我在动画图中的来源位置,你需要选择进入哪个基础状态。如果来自站立状态,则进入站立;如果来自下蹲状态,则进入下蹲;如果当前正在坠落,则直接进入坠落状态。我们可以用简单的逻辑来处理,比如”如果我有某个特定的状态标签,就直接进入指定状态”。这将极大简化你的配置,因为在我之前参与过的某些动画系统中,要进入特定状态,你总是必须从同一个入口状态开始,然后通过转场到达中间状态,再转场回滑行状态——这使得系统过于复杂,并迫使你必须经过一个糟糕的起始状态,有时甚至会破坏姿态。我在这里提到了状态标签,后面会详细讨论。目前你只需要知道,它是一种让你了解”当前在动画图中处于哪个状态”的机制。这里有个例子:可能有一个空闲状态、一个行走状态和一个奔跑状态。如果启用入口状态覆盖——它是状态机中的一个节点,你可以直接选中它,它会显示在辅助视图中——你可以在其中配置:”如果我的速度在0.1到2.5米/秒之间,进入行走状态;如果速度大于2.5,直接进入奔跑状态;如果没有速度(即小于0.1),则进入空闲状态。”这是默认行为。通过这种方式,可以非常简单地消除因强行设置转场而引入的大量重复性复杂度。

另外,经常会有这样的场景:假设你在做一个格斗游戏(比如清版动作类),你可能有一个包含10种攻击动作的攻击状态机,每种攻击都可以衔接任意其他攻击——也就是说,每个状态都可以转场到任意其他状态。这样就产生了极其复杂的”面条式”连线。我们有一个非常简单的机制来解决这个问题:只需在单个状态机上下文中设置一组”全局转场”。假设有一个基础配置,A、B、C可以互相转场,但C只能被E跟随,D只能转场到A、B、C——还有一些额外的约束,并非任意到任意。我们的做法是:对于所有状态都可以转场到的目标状态,创建一个全局转场。全局转场主要由一个小闪电箭头标记。它的含义是:全局转场已为此状态定义,所有状态都可以转场到该状态。你需要查看全局转场来了解具体条件,但任何状态都可以转到这个状态。不过,你还会看到一些红色箭头,表示需要阻止某个转场。也就是说,即使存在全局转场(任何状态都可以进入D),但如果当前在C状态,到D的转场被标记为阻止,那么该阻止会覆盖全局转场。在E的情况下,你还会看到白色箭头——这些是特定的覆盖转场,表示虽然存在到E的全局转场,但从C到E需要一个非常具体的转场,必须使用一组不同的设置。因此,这个功能允许你指定”所有状态都可以转场到所有状态”,同时也允许你阻止某些转场或覆盖特定转场。我想我可以在引擎中实际演示一下。这是一个非常基础的战斗状态机示例:有一组攻击动作,每种攻击都可以衔接任意其他攻击,有大量的转场。在设置这些转场时——虽然我这里没有设置具体的条件——很多时候这些转场条件都是彼此复制的。例如,A到C的条件是”如果我的攻击ID是C”,E到C的条件也是”如果我的攻击ID是C”。你需要在各处反复复制粘贴大量转场条件,这极易出错——每次要修改什么,就得修改15个不同的地方,非常容易犯错,根本无法扩展。使用全局转场后,情况就简化为:在全局转场中设置转场节点,定义你想要的转场逻辑和条件。你可以清楚地看到,D有一个全局转场,因此在当前状态机上下文中,所有状态都可以转场到D,没有任何问题。如果想阻止从D到E的转场,只需创建一个空的 conduits(连接器),表示有人明确创建了这个连接器——那么当我在D中想转到E时,就不再存在全局转场。如果我想覆盖它,只需在这里创建一个新的转场,颜色会发生变化,现在我就有了一个明确的显式转场。这样既保留了手动转场的全部灵活性,又能阻止转场,同时消除了50个地方中那些可怕的冗余和条件重复。仅这一项功能,就能解决人们在使用状态机时遇到的90%的问题——他们抱怨”太复杂了,无法管理,简直一团糟”,其实只需要用简单的方案通过工具来修复即可。

现在我们来看另一个常见的痛点:动画与游戏逻辑之间的通信。游戏逻辑通常需要知道动画图当前处于什么状态,因为它们会发出命令,比如”你需要执行跳跃”。但游戏逻辑并不确切知道跳跃何时结束,甚至不知道动画图是否处于跳跃循环中——它需要这些信息才能做出响应,发送不同的指令,或以不同的方式处理物理。在一些动画图中,你必须显式地绑定到节点,这非常笨拙:需要声明”我需要访问这个节点”,写出完整的名称、路径和整个层级结构才能获取该节点,然后查询该节点”你是否处于激活状态?当前是什么状态?”等等。我们采用了一种不同的机制,称为”状态事件”。简单来说,它是一个由状态发出的ID。该ID会被发送回游戏代码,游戏代码通过查看该ID就能理解动画图当前所处的状态。 而且这并不绑定到动画本身,因为你可以说,”嗯,我可以用动画事件。”但是,随着数据集的增长,你不断地切换动画。可能有十种不同的变体。如果要去每个动画里加一个事件说”嘿,我现在处于某某状态”,这变得极其容易出错。所以,思路是:如果你把这个放在状态里,那么你就不关心里面有什么数据,那个状态在做什么,或者其他任何东西。它只是说,”这个状态会在特定条件下发射这些事件。有四种不同类型。一种是在进入状态时发射的。一种是在完全进入状态后发射的——也就是说没有转换处于活跃状态。一种是在离开状态时发射的。还有定时事件,基本上会根据我在一个状态中已经停留的时间,或者在一个状态中还剩余多少时间来触发。你可以为这四个类别中的每一个设置多个ID。每当这些条件满足时,这些值就会从图中发送出去。所以,如果游戏逻辑想知道我们是否处于行走状态,他们会问,”嘿,我有行走的状态标签吗?”有,我有。好,那就意味着这个图正在行走。我不需要知道这个图包含什么,它是如何构建的,拓扑结构是什么。我不关心。我甚至不关心,它可能实际上并不是在行走。它可能是别的什么东西,但它告诉我它在行走。所以,这就是我需要知道的。这完全与数据集解耦。

状态事件还有一个巨大的优势:它们可以在图内部使用,以了解我们在哪里以及当前正在做什么。所以,在我之前提到的入口状态覆盖的例子中,它们基本上会检查这些状态标签。我们会评估状态机,然后获取那个事件。例如,如果我在状态A,我会先评估状态A,然后触发一个转换去状态B。但状态A已经被评估过了。在状态B中,可能有一个状态机,它会检查它的入口覆盖,然后可以询问图:”嘿,这一帧有没有采样到任何状态标签?”我在想有没有一个例子来说明这个。我没有。但基本上,思路是图会通知图中后续的部分。抱歉,我这次直播的准备不如平时充分。我同时在做很多其他事情,我只是想尽快把这个讲完。

这里有一个更好的例子。实际上我在幻灯片里做了,而不是在引擎里。我们从起始状态发射某些标签。例如,当我们进入滑铲状态机时,我们可以查询并说:”嘿,我们之前是蹲姿还是站姿?”因为我们只需要去查询那些标签。基于此,我们会选择后续的入口状态。有评论说全局转换类似于”任意转换”。是的,确实如此。我不太了解Unity的Mecanim。区别在于,在我的实现中,那些全局转换是局部于一个状态机的。它们不是对整个图全局的。我知道在其他动画系统中,它们有”任意转换”,但那是全局于整个状态机的。在我的实现中,它被限制在单个状态机内部。所以,你可以有一个全局转换到该状态机内的任何状态,但不能到其他任何地方。所以,一切都是非常局部化的,层级系统也是如此。这使得推理变得容易。我知道有一个地方他们用了全局转换,但后来变得无法管理,因为转换会从一个状态机的深处跑到另一个状态机的深处。要搞清楚为什么会发生这种情况,你不得不翻阅一个庞大的列表——人们长期以来创建的大约300个全局转换。而我发现,将其局部化要容易得多。

此外,这些状态事件可以用来阻止某些转换。动画师基本上可以说,”嘿,我不希望这个状态在采样到该事件时发生转换。”然后他可以设置一个定时事件,说”从状态结束前0.4秒开始一直发射这个事件”。这样,他们就可以指定我们称之为”剪切时间”之类的东西。它基本上是说,”只在动画的特定时间点范围内触发转换。”这允许你在状态机逻辑层面做到这一点,而不必将这些信息嵌入到实际的动画本身中。你基本上是在说,”嘿,在这些时间段内不要转换出去。”或者,你可能会说,”我在一个状态里至少需要播放七帧才能离开。”那么你所要做的就是设置一个阻塞转换事件,配合一个定时事件说,”嘿,如果经过的时间小于0.4秒或一秒或其他值,阻塞所有转换。”然后你所有的转换都会检查,”嘿,如果我有这个状态事件,这个转换就不被允许。”再次强调,这只是额外的工具,用来基本上提示图该往哪里走,如何转换,以及诸如此类的事情。同时也提供游戏逻辑信息,这意味着你不需要关心——技术动画师可以去重构整个图。你在游戏逻辑端不必关心。你关心的只是事件被正确发射了。

有时,我们仍然需要把这些信息放在动画事件里。状态事件是为了更高层次的东西。我是在行走吗?我是在奔跑吗?我处于停止状态吗?我当前是在跳跃吗?这类事情。很多时候,我们仍然需要在动画中放置事件,比如在这里触发一个VFX,或者在这个特定时段阻塞转换——这可能会因动画而异。例如,不同的角色原型可能有不同的缓冲动画或不同的时间节奏,诸如此类。所以,你会做的是,在那些动画中放置特定的动画事件,然后图会检查这些事件,因为图仍然可以访问采样到的动画事件。所以它可以检查并说,”嘿,这个转换只有在我有某个类型的事件时才允许。”或者,如果我有一个类型为”阻塞任何事件”的动画事件,就阻塞这个转换——比如”阻塞转换”之类的。构建这些图的动画师对事情何时发生拥有绝对的控制权。这本就应当如此。

这样做的缺点在于,这在很大程度上变成了编程。而这并不一定是很多动画师熟悉的心态或技能。所以需要大量的教育和引导,让他们进入那种思维模式,说:”嘿,想想如何布局你的状态机。有时你把所有状态放在一个层级上。也许更好的做法是将其反转,开始创建更多的层级。或者有时你想去掉层级,创建一个独立的——就像你重构代码时那样。”这通常不是他们习惯的做法。所以他们最终往往会试图强行塞入他们最初的想法。有时你必须和他们坐下来,说:”等等,给我解释一下发生了什么。”然后说,”好,你有没有考虑过反转这些状态的放置位置?也许你需要把它们放在更高的层级,而把这些放在低的层级。”然后他们就会说,”哦,那就简单多了。”然后他们就把结构理清了。这就是编程。你必须这样去思考它。

现在,这些图会增长到非常非常庞大的规模。在我上一家公司,图的规模达到了大约六万个节点,有二十个层级,里面包含数十个状态机。而且没有人抱怨复杂性,或者不理解事情是如何运作的。或者每次出现bug,他们都知道去哪里找。非常简单清晰。根本原因在于,工具在帮助他们理解发生了什么。他们可以看到转换为什么会触发,他们可以通过全局转换或入口状态等方式进行非常简化的设置。当确实发生奇怪的事情时,通常我只需要十到十五分钟来调试,然后说:”好,看,是这个设置有问题。或者,实际上是游戏逻辑端喂给了你一些垃圾数据。所以我会在那一边修复。”

你知道,这让我想到了一个我不打算多讲的话题——运动匹配。我在这场运动的早期就参与其中,和Michael Boothman他们一起。从根本上看,我大概是那个仍然认为——你知道——我认为它是一个好工具,但只适用于非常特定的情况。那些完全押注于这(运动匹配)就是动画系统的人,我认为这是个错误。在我看来,这很像是一个”寻找问题的解决方案”,那些人对好的状态机工具没有经验,或者来自非常糟糕的基于状态机或者根本没有状态机的系统。而(运动匹配)看起来是一个非常非常好的解决方案。此外,它通常非常复杂。在编程方面需要巨大的时间投入。它是一个黑盒——如果出了问题要调试、打磨、调整,或者游戏中出现了故障,那祝你好运。然后他们说,”我们来修复这个故障。”而你说,”我,我,我不知道怎么修。我们必须重新训练它,我们必须调整数据等等。”很多时候,一旦你用这些东西进入生产阶段,你最终会构建一个伪状态机,因为你开始给你的动画打标签,把它们分开到不同的桶里,然后你在那些桶上进行运动匹配。这里有一大堆东西——从根本上说,我知道它试图解决什么问题,那就是状态机的复杂性问题。但我的反驳是:如果你有好的工具和好的设置,那这个问题很容易就能解决,而且每个人都有明确的控制权。所以,如果一个动画总监过来说,”我需要一个非常具体的转换在这两个东西之间。”你直接做就行了。所以,如果你正在构建一个动画系统,运动匹配可以是图中的一个节点。但我的建议是:你从图开始,先构建图,确保它能正常工作,因为这样你就已经掌握了主动权。 你们已经确保了项目和工具的安全性。目前没有风险,现在可以把时间投入到运动匹配(Motion Matching)上,进行实验并探索其适用场景。但若完全押注于此,声称动画图就是过时的东西,那将是一个错误。真正有趣且精彩的部分,在于后处理任务(Post Tasks)。如前所述,动画图本身并不生成姿态。动画图只输出根运动和事件。它所做的,是向指令缓冲区填充指令,例如采样、混合、运行IK、进行某些物理计算等。出于多种原因,我们将这一部分分离了出来。最明显的原因是物理模拟,稍后我会说明为何必须为此拆分。但拆分还带来了LOD与性能方面的额外优势。下面来看一些基本示例。

后处理任务本身,是一种原子化的姿态操作——它仅创建或修改一个姿态,别无其他。输入姿态或零输入,输出姿态,仅此而已。它可能依赖于物理系统。某些任务必须在物理模拟之前运行,某些任务则必须在物理模拟之后运行。对于任何存在依赖关系的任务,它会显式声明:”我有两个依赖项”或”我有一个依赖项”。这些任务是多态的。同样,我们采用了派生和虚函数机制。动画图中的一切皆由虚函数等构成。原因很简单:存在大量共享组件,且这种方式足够简洁。

让我们看一个非常基础的混合树示例。假设我读取两段动画,将它们混合,再将结果与另一段动画混合——那么我们会生成一个任务,大致逻辑是:采样动画A和B,然后注册一个混合任务,其依赖项为任务一和任务二。可以看到,采样任务没有依赖项,因为它们是数据源。接着采样C,再进行混合,此时依赖项为任务三和任务四。在遍历动画图中的节点时,当我们输出姿态结果时,返回的是该特定节点的任务索引。例如,在混合节点中,我们更新子节点A得到索引一,更新子节点B得到索引二,然后注册一个混合任务,关联索引一和索引二。第二个混合节点也是如此——先询问混合节点三的任务索引(比如索引三),更新C得到索引四,以此作为依赖项,最后注册该混合任务。

任务系统是注册任务的地方,并持有一个姿态缓冲池。这对性能有显著提升。例如,任务会在彼此之间请求、释放和传递姿态缓冲区。如果请求更多姿态,缓冲池会增长;但平均而言,每个池大约只需要五个姿态。这是一项巨大的优势,因为姿态是预分配的,不需要动态分配。具体是如何工作的呢?以采样动画A的简单混合示例来说:采样动作说”我需要采样一段动画,给我一个缓冲区”,得到缓冲区;采样B同样需要一个缓冲区;混合节点则说”我需要读取这两个结果,但只对外暴露一个缓冲区,我不需要自己的缓冲区。因此我将X转移给自己,现在由我拥有X,然后释放Y。”也就是说,它读取X和Y的结果,将结果写入X,然后释放缓冲区Y。这样做的效果是,在遍历过程中不断复用缓冲区。例如,如果一个节点只是修改姿态,我就把缓冲区从采样节点转移到修改节点,修改节点直接修改姿态,然后继续向下传递。

考虑一个更复杂的场景——运行大量层、过渡等等——最终得到一个相当复杂的混合树。实际上需要多少个姿态才能解析它?看起来有1、2、3、4……12个采样和大量混合。实际上只需要四个姿态。整个系统仅需四个姿态即可运行。姿态池也是我们追踪任务依赖关系的原因——因为需要在姿态之间传递缓冲区。你可能会觉得构建这个系统相当复杂,但实际上它异常简单。例如,这是一个采样任务——这就是任务的全部内容。执行函数说”给我一个姿态缓冲区”,然后说”动画,给出姿态,放入那个缓冲区”。这就是采样。混合任务呢?看起来应该很复杂?其实不然。我转移第一个缓冲区,访问目标数据,混合两者,然后释放目标缓冲区。这就是我的混合任务。同样非常简单,不需要太多复杂性。而这个缓冲区系统在性能方面节省了大量开销——无需分配大量姿态,而是复用它们。

接下来讨论IK和物理系统,这基本上是构建姿态缓冲系统的主要原因之一。我不会深入IK的过多细节。简而言之,就是获取一个姿态,然后修改该姿态。通常涉及将脚附着于地面或将手锁定到世界中的特定点等操作。IK通常被视为后处理步骤。稍后我们会回到这个问题。讨论IK时,还需要讨论游戏逻辑如何计算IK目标。通常的做法是:先运行模拟,移动角色;然后计算动画——动画本质上是实际模拟对象上的一层蒙皮,因为我们已经移动了模拟对象;最后将IK作为后处理运行。这种方式之所以可行,是因为移动角色后动画可能发生滑动,但它仍然绑定到模拟对象,然后基于该位置运行IK——一切正常。

当进入动画驱动(Anim Driven)模式时,情况变得复杂一些。因为角色的物理模拟不再直接移动角色,而是执行一个预测步骤:”我认为你需要这样做”,将其输入动画图。动画图计算出一个姿态,同时获得一个根运动增量(Root Motion Delta)。然后你必须用这个根运动增量来移动模拟对象,再将IK作为后处理运行。你必须记住:IK必须在移动角色之后运行,否则在世界中指定的任何点(尤其是涉及脚步的点)都可能不准确。你可能认为角色在某一个位置,计算了目标,结果角色最终出现在完全不同的位置——那么你的目标计算就错了。因此必须非常谨慎地安排IK的执行时机。

我个人认为,将IK作为后处理是不正确的。这是一种非常老派的做法——先做动画,再运行IK。这种做法存在很多问题,因为你最终基本上是在一次求解中试图解决整个IK问题,这本身就是有问题的。举一个非常基础的例子:一个群众角色在场景中行走,可能需要看向世界中的不同物体。当目标变化时,如何混合目标?谁来做平滑过渡?”你之前在看X,现在要看Y,谁负责确保IK过渡平滑且不会导致头部突然跳跃或沿怪异路径运动?”这些现在都必须在IK系统中处理。代码中的某个人必须管理:”目标变化了,我要缓慢混合它,确保它沿着良好路径运动,以免头部异常动作。”因为如果潜在目标分别位于镜头前和另一侧,最短路径可能穿过头部——试图直接跳跃目标可能会产生怪异的IK结果。因此通常需要以合理方式驱动IK,这本身就很复杂。

此外,如果角色处于特定状态修改了姿态,IK计算器以什么为参照?身体可能倾斜,因此无法在局部空间中准确计算目标。这增加了大量复杂性。还有一个问题:如果同一身体部位接收到多个请求怎么办?该采用哪个?如何混合?是否应该混合?还是直接覆盖?这本身就成了一个完整的系统,需要管理所有这些复杂逻辑。坦率地说,你根本不希望代码来处理所有这些——因为某个程序员必须去搞清楚”我有一个注视目标,还有一个战斗注视目标,脚部在做IK,但又得做另一个IK,于是不得不在这里关闭它”——这完全是一团糟。而动画师会不断抱怨:”这不是我想要的,关掉IK。”

还有更好的解决方案。我的做法是倾向于构建更定制化的IK求解器,解决非常具体的问题——不是通用情况,不是全身求解,而是解决特定的局部问题。然后为这些求解器在动画图中创建节点。我们还从外部提供目标。例如,如果同时存在多个注视目标——环境注视集、反应注视集和战斗注视集——可能发生的情况是:我是一个群众角色,在场景中行走,被指示”看这个广告牌”,于是开始看广告牌。突然间,玩家扔了一颗手雷(或制造了某种声响),于是我被指示对噪音做出反应。 所以我对注视系统有不同的看法。到最后,就像是:”好了,我的注视进入了反应状态,现在结束了。我必须回到待机状态。”你不希望有一个代码系统来管理所有这些事情,这很烦人。你基本上只希望游戏代码说:”你有一个待机注视目标。我会告诉你它什么时候消失。哦,你还有一个反应注视目标。给你一个反应目标。”然后动画图会决定:”嘿,我有所有这些设置项。根据我当前的状态,哪些我能执行,哪些不能,哪个更合理?”诸如此类。你让图来处理视觉层面的东西。代码只需要告诉你:”这里是所有的信息。你来决定什么合理。”所以,我最终的做法是为这些东西创建节点。我们在图中会有多个IK节点。例如,我会有一个待机节点,它有一个注视逻辑,基本上就是:”嘿,对于待机姿态,执行这个注视。”然后当我们过渡到一个反应状态时,反应状态可能有它自己的注视逻辑在那里执行自己的操作。最终的结果是,你会立即解算IK,然后直接做一个混合。一般来说,你已经做了两次注视,所以你有了两个看向正确位置的姿态,你只是在从一个姿态混合到另一个姿态。这是一个定义良好的操作。你知道它会是什么样子。所以现在你不需要处理那种疯狂的”哦,我需要以特定速度在空间中混合我的目标”。你不需要关心这个。你有两个看向正确位置的姿态,你只需要混合这些姿态。这在任务系统中的表现是这样的:你有一个采样节点、一个IK采样节点、IK节点、混合节点。非常简单。你可能会想:”好吧,但IK可能很昂贵。”但再次强调,你的IK不是全身的。你的IK在做一件非常定制的事情。所以编写一个注视IK并不是一个很复杂的事情。你不需要做全身解算,你不需要做复杂的模拟。我写过一些IK,基本上只修改胸腔、头部、脊柱之类的,只需要几微秒。

如果我们再看注视这个例子,如果你不按照我这种方式拆分东西,另一个可能让你头疼的问题是:如果你把IK嵌入到图中,你会遇到这样的问题——当我评估IK时,角色是否处于正确的位置?如果你没有任务系统,答案是否定的。因为动画运行在我预测运动和实际执行运动之间。所以在图执行期间,我的角色位置是错的。它在上一个位置。这就是为什么IK过去是作为一个后处理来运行的。所以,在我的设置中,事情很简单:图会生成后处理任务。它会说:”嘿,你需要做IK。”注册IK任务。它会计算根运动。它会移动角色,等等。然后你会在角色在这一帧的最终位置上运行后处理任务。这就是为什么根运动来自图,而不是由姿态计算出来的。一开始,在我早期迭代的系统中,后处理任务也在计算根运动,但这导致了一个问题:IK和物理之间存在差异。所以根运动现在来自图,坦白说我认为这才是正确的做法,我不知道为什么我花了这么长时间才意识到这一点。因为它还有其他一些好处。好处是,当你这样做时,当你运行后处理任务时,你仍然是在单独计算姿态,应用正确的IK,混合姿态,而不是混合IK目标。你没有帧延迟,没有过度延伸,因为你处在正确的位置,诸如此类。这解决了很多头疼的问题。

它还允许你将IK用于动画合成。在很多情况下,我们通过IK来生成动画。我们会创建一个断开的骨架,因为我们会偏移身体的某些部位,然后运行IK解算来重新连接和修复骨架。我们基本上是在图中使用IK程序化地生成动画,比如高度调整、左右倾斜之类的。这是在每个状态级别上完成的。所以当我们混合出去时,这是一件很简单的事情,因为我们有一个固定的姿态,是完全没有问题的,我们只是做了一个动画混合。这也意味着你可以在需要的时候修复IK。例如,在处理武器的情况下,我可能有某种武器,需要根据武器的长度程序化地调整双手之间的距离。然后我需要在不同的武器处理状态或武器的不同位置之间进行混合。直接修复双手,然后混合到另一个双手已修复的姿态,要比试图在整个混合过程中持续修复要容易得多。你最终会有更多的控制权。这是另一件事——他们可以在图中根据需要开启或关闭IK。他们可以按需将IK淡入或淡出。如果他们不想要,他们可以忽略它。他们可以使用动画事件,根据动画正在做什么,在特定状态上开启或关闭IK。比如他们会说:”嘿,对于动画的这一部分,我不想要IK。”所以他们把一个事件反馈给IK节点。相比在末端有一个最终解算器而不得不显式地驱动它,他们对此有更细粒度的控制。抱歉,我的过敏症现在有点难受。我们继续讲物理。这是另一个有趣的话题。

在我的设置中,布娃娃实际上被集成到了图中。它们也不是后处理。我们也不是每个角色只有一个布娃娃。我们所做的是在需要时动态创建布娃娃。我们为一个给定的状态创建一个模拟布娃娃。所以我们可以创建非常定制化的布娃娃设置,比如:”嘿,这是一个只有上半身的布娃娃。”或者:”嘿,这是一个只有下半身的布娃娃。”或者这是一个全身的布娃娃,等等。每当我们激活那个特定节点时,我们就实例化那个布娃娃。如果你使用PhysX的articulations功能,它出奇地廉价,非常非常便宜,而且你能得到非常好的效果。这也是后处理任务系统发挥作用的地方,它允许我们在物理前后拆分任务。例如,我们先做一个简单的两个动画的混合,将其输入到一个动力布娃娃中,然后我们可以在结果上层叠动画。所以另一件很酷的事情是,我可以获取布娃娃的结果,在其上应用一个混合动画层,或者做一个IK处理,或者其他什么。甚至,我可以把它与另一个动画状态混合。所以在任务中发生的事情是,那个动力布娃娃节点会注册两个任务:一个在物理前运行,一个在物理后运行。正如你所见,这就在物理tick前后分割了任务列表。这部分相对不言自明。正如我所说,你可以在上面链式地添加IK,做任何你想要的混合,都没关系。它只是作为图的一部分运行。这使我们能够将物理与正向动力学和反向动力学交错在一起。这也意味着我们可以用一个非常复杂的动画状态来驱动一个动力布娃娃。然后我们可以在物理模拟的布娃娃上做接触处理。例如,我们可以得到一些次级运动,并仍然在其上做一个IK修正处理。这也意味着我们可以在布娃娃之间进行混合。这是一件有趣的事情。你不再需要在布娃娃层面上处理混合轮廓之类的东西。你只需生成两个布娃娃,然后从一个布娃娃混合到另一个布娃娃。这是一个定义非常清晰的操作,因为你只是在混合动画姿态。你在运行两个完全不同的布娃娃模拟,具有完全不同的设置和非常特定的配置,然后你只是混合它们的结果。那么这是如何工作的呢?很简单。你有一个状态带有一个布娃娃,另一个状态带有另一个布娃娃。你做一个混合。所以任务列表基本上说:设置期望值(这是物理前的任务)。物理后,我们读取两个结果并将它们混合。这又得益于姿态系统和图的构建方式。

当然有一个明显的限制:你不能把布娃娃链式连接在一起。但同样,这不是问题。没有这样的用例需要你获取一个布娃娃的物理结果并输入到另一个布娃娃中去驱动它。这根本说不通。此外,这种设置会被图编译检查。所以图通常会抛出一个错误,说:”嘿,你不能把这些节点链式连接在一起。”或者:”在一个混合路径中有多个布娃娃节点,这是不允许的。”然后你可以修正它。

另一个对工具链和复杂性非常有帮助的东西是我所说的外部图。我不知道有多少人熟悉智能对象或智能地形点(或者你们公司怎么称呼它们),但基本上就是放置在世界中的对象,它们绑定了一组动画。比如,你想放置一个公园长椅,角色可以坐在上面;或者一个棋盘,他们可以下棋;或者一个ATM机,他们可以使用。这些非常具体,动画绑定到那个特定对象上,而不是绑定到角色上。简单粗暴的做法是,有些人会把所有动画都放到主角色图中,这有点疯狂,因为你必须加载所有数据,即使那些对象不存在。而且随着规模扩大,这种做法效果很不好。所以我们用外部图来解决这个问题——你基本上为每一个这样的设置创建一个图,然后在运行时将其注入到主图中。例如,你会做的是在你的主图中设置一些插槽,说:”嘿,这是一个智能对象插槽,你可以在这里注入一个智能对象。”通常你会设置多个插槽,并在它们之间做混合,这样你就可以从一个智能对象直接过渡到另一个智能对象。然后游戏逻辑会说:”嘿,你想使用一个智能对象?好的。我会加载包含该智能对象和所有所需动画的图给你,创建实例,一切就绪。” “然后我将其注入到槽位中,图形便会驱动起来。”因此,游戏逻辑会驱动那个外部图形——因为它已经加载了该图形并知晓其存在。它可以管理并判断:”这是一个非常简单的智能对象图形,我只需注入它、让它运行,它会在完成时通知我,然后我将其移除。”或者它会判断:”这是一个炮塔,所以在使用炮塔期间,我会运行另一段游戏逻辑代码来管理目标选择、射击、装弹等,并通过参数驱动那个外部图形。”而此时的主图形只表示:”我处于一个槽位中,我只是输出该槽位所产生的结果。我并不关心具体内容。我的参数没有发挥作用,我只是在执行槽位中的内容。”这种方法还允许我们在需要时为这些注入的图形从高层指定控制参数。这些参数不必与主图形匹配,不必做任何特定的事情——它们是完全解耦的。一旦采用这种方式,你就能有效管理加载多少数据、何时加载等诸如此类的问题。同时,你也不会用各种可能放置在特定关卡中的随机内容或一次性逻辑等疯狂复杂性来污染你的主图形。此外,它还提供了并行工作流的能力。你可以让一大群人完全并行地构建这些独立的图形,并并行地进行测试。你不需要修改主图形。这是一种非常基础的做法。我认为很多系统都在某种程度上具备这一功能,尽管其中许多实现非常笨拙或无端复杂。这应该作为系统的默认内建机制,应该是一个人人都能理解的简单机制。并且,它依然是可控的。

对于过场动画来说也是如此,同样是通过图形中的槽位来实现:”这里有一个槽位,本质上是一个源姿态提供者。我们会切换到那个槽位,然后让过场动画系统向其中输入一个姿态,而不是试图从主图形切换到其他东西再切换回来。”在我的系统中,一切皆通过图形完成。图形就是动画系统,没有其他东西。不存在切换出图形的情况。所有外部内容仍然通过图形连接进来。

我们快接近尾声了。接下来谈谈性能,因为这是很多人的关注点:”你说一切都是虚函数,看起来非常面向对象等等。”我先展示一些数据,然后再讨论性能。这些数据来自一款旧时代的游戏主机,基本上来自PS4——因为这是在PS4 Pro甚至还未问世之前的数据。该图形大约包含16K个节点,且未做任何优化。这只是我在项目中期自行检查时记录的数据。图形本身的运行时间大约在20到60微秒之间——这是对约16K个节点的完整图形求值结果。而任务本身(包含大量混合操作——通常我们每帧混合四到五个对象,因为我们分层叠加了大量内容)大约在30到50微秒。因此,在PS4上,完整的动画更新大约在50到100微秒之间,其中包括采样、解压缩、根运动、事件采样等全部内容。再次强调,这些是非常老旧的数据,使用的是该系统的稍差版本——因为自那以后我对很多方面进行了优化和改进。但这些才是动画更新应有的性能指标。而不是某些运动匹配系统那种半毫秒、两毫秒的离谱数据。它的量级应该是——在当今本世代主机上,这些数字甚至更低,因为Xbox One和PS4的Jaguar CPU确实非常慢。所以现在的数据会低得多,但我没有可以展示的具体数据——你知道,NDA之类的原因。

那么,我们是如何获得这种性能的呢?首先,没有内存分配。图形在实例化时只实例化一次。我们不在运行时分配任何内存。如果有一些动态数组,通常也是内联的——我们牺牲一点额外的内存,但基本上不使用动态数组,因此不会每次访问数组时都跳转到不同的内存区域。扭曲(warping)会分配内存——这是唯一一个需要分配内存的场景:如果需要做扭曲,我们必须为根运动轨迹分配内存。而布娃娃系统,显然在实例化时也会分配内存。这是运行时仅有的两个分配内存的领域,而且非常有限。节点被布局在单一内存块中,并按遍历顺序排列——子节点紧跟在父节点之后。因此,当我在父节点中调用子节点时,后者通常就在内存中紧随父节点的位置。所以,这是一种更具缓存友好性的构建方式。我们还只求值我们关心的节点,不需要求值整个图形的大部分内容。我们只向下钻取到需要的部分,求值这些节点,然后就完成。再次强调,一切都是虚函数,一切都是简单的面向对象机制。我没有进行任何微优化层面的复杂操作,但性能已经相当不错。我认为它目前优于许多其他生产级动画系统。

此外,一个非常有帮助的做法是我们采用了两种节点格式。我们有编辑器专用节点——它们的表示更为重量级,可能包含独立的布尔变量、更多的枚举以及大量对用户有益的设置。但这些节点会被编译成更为紧凑的运行时格式。例如,我会将10到15个布尔量进行位打包,放入一个字节数组;将大量枚举、布尔量等压缩成一个16位的位字段。简而言之,我们拥有两种格式,这使得我们既能提供用户所需的便捷工具,又能拥有高度优化的运行时格式。我们将节点的设置与其实例数据分离开来。加载图形时,我们加载的实际上是节点的定义——它指明”这是一个某种类型的节点,具有相应的设置”。然后我们创建实例,该实例引用回那些设置数据。因此,当实例需要查询”用户创作的参数是什么”时,它会直接引用设置数据。这样,这些参数在内存中只有一份拷贝,所有实例都引用同一份数据。这同样使得图形实例保持相对轻量。如我所说,我们在初始内存块中使用placement new来构造节点,然后基于该内存块建立各种指针。我们本质上是在做指针修正——因为我们在序列化时存储的是索引,例如”节点17的第一个子节点是5,第二个子节点是15”,然后在实例化阶段修正这些指针,之后就可以直接运行图形了。

将任务列表拆分也是一个很好的做法。现在,在图形求值时,根运动已经分离出来——这意味着在很多情况下,姿态求值完全是可选的。如果角色距离非常远,我就不需要求值其动画姿态。我也可以对后处理任务进行一次遍历,并决定:”比如,所有布娃娃任务我都剔除掉,我们不关心这些。”如果角色在屏幕外或距离很远,就不需要运行那些任务。将姿态任务列表作为一个独立的部分,使得游戏逻辑在功能上看起来一切正常,但我们不需要求值任何实际的、昂贵的IK后处理任务。然而,通过状态事件等机制,我们仍然知道当前所处的状态。我们拥有所有的动画事件和根运动。这也为我们提供了在不改变任何图形逻辑的情况下处理数据的巨大机会。这非常有用,因为我们可以运行一种优化模式:例如,”对于这一组角色,我采用LOD方案,基本上不运行任何后处理任务——比如人群角色,一旦他们离开屏幕,就不需要计算姿态。但对于AI角色,我们可能仍然需要计算姿态,因为我们需要更新碰撞盒等;如果在服务器端运行,我们可能不需要运行头部注视等一系列昂贵的操作。”我们在那里拥有调整的自主权。

以上就是我长篇大论的分享。我感觉这次准备得不够充分——抱歉。我目前手头事情千头万绪,但我还是想尽量把这些内容讲清楚。如果有任何问题——目前看来好像没有太多——你可以随时在Twitter上找我。如果你想要我的相关源代码,我很乐意分享,但请注意它还没有在我的个人引擎中经过充分测试或压力测试,所以比较粗糙。我之后会抽空整理,确保百分之百可用,但目前我不能做任何保证。不过,至少它是一个不错的参考,可以让你了解它是如何构建和运作的。就是这样。如果有什么问题——目前似乎没有——欢迎在Twitter上联系我。祝大家周末愉快,保重。