基于Unity的程序性动画入门
雅雅雅雅各布
2020年11月08日 14:47

介绍

什么是程序性动画?

简单地说,程序性动画是由代码驱动而非关键帧生成的动画。在制作角色动画的情况下,可以广泛使用于一些简单的动画过渡,比如根据角色的速度决定的两种动画的混合,完全受程序动画系统的控制而不受预生成数据的影响。

在本教程中,我们将探索程序性动画的简单实现,基于用在项目(https://weaverdev.itch.io/bonehead)的动画系统,不过所有的概念都和传统关键帧动画类似。教程将侧重于程序性动画元素的应用上而非工作原理,如果你对其中的数学非常感兴趣,可以看看这个项目的解释(https://www.alanzucconi.com/2017/04/17/procedural-animations/)。

基本原理

Forward Kinematic(FK)

FK使用父级对象的旋转来导出子集对象的位置和方向。链上的每个子集对象都会重复父级对象的操作,因此任何给定骨骼都会影响其下方层级的所有骨骼。这仅需要访问关节间的坐标变换,并且在大多数游戏引擎中的默认情况下是公开的。

Inverse Kinematic (IK)

IK和FK相比,采用一个目标位置和一个极向量(“肘”方向)作为输入然后旋转链中的骨骼以使其最终位于输入位置。这种方法通常用于在身体移动时使腿保持在地面上的固定位置,或用于手臂抓住骨骼层级以外的物体对象时。关于Two bone IK的资料非常丰富和容易搜到,所以在这个教程中将不讨论数学的具体细节。

Particles/Verlets/Rigidbodies

这种方法通常用于软体生物或者基于物理的布娃娃,通常使用任意浮动的速度驱动的对象(不共享父/子层级结构)来生成姿势。通过添加约束,比如最大角度和距离,以在运行时计算出复杂的肢体位置,就像一个人从楼梯上掉下来。尽管这超出了这个教程的范围,不过还是提及一下,因为它确实是种很常用的技术,并且不需要基于FK或者IK的那种构建。

初始准备

首先,你需要一些整理好的骨骼层级结构。Unity默认不会区分什么是骨骼关节什么是常规的物体,所以这种情况下我们得自己用基本形状制作骨架来使用。

可以在https://www.patreon.com/posts/29351985下载准备好的骨骼文件

为了让之后的事情更简单,一定要确定你的关节点都以确定的方式定位,比如所有的轴都朝向同一方向,并且默认姿势的局部旋转都为0。这将使推理出骨骼链的行为变得容易的多。

注意在本教程中将假定骨骼的轴的方向为Z向前和Y向上,如果不是这种情况,需要为每个骨骼添加偏移量,才能使用Unity的内置函数,这会使问题变得麻烦而且难以快速调试,所以确保你设置了正确的骨骼。

头部骨骼

首先我们需要创建一个组件来执行所有的逻辑。所以创建一个MonoBehavior类的脚本,然后在里面定义目标物体和壁虎的颈部骨骼。

public class GeckoController : MonoBehaviour {     //将要跟踪的物体     [SerializeField] Transform target;     //壁虎的脖子的引用     [SerializeField] Transform headBone;     private void LateUpdate()     {         //操纵骨骼的代码写在这里!     } }

拖拽物体到脚本的引用上

我们这一部分的目标是导出一个Quaternion(四元数),表示从头部到指向目标物体的旋转。四元数是个相当复杂的东西,不过你现在并不需要理解其中的原理,简单起见,在接下来的教程中我们只将它看做是一个用来取三维方向的黑盒。

首先从目标位置减去头部的位置,这会给我们一个从目标点到头部当前位置的向量。

        Vector3 towardObjectFromHead = target.position - headBone.position;

为了让面部能朝向目标,使用Quaternion.LookRotation方法,这个方法取一个forward和一个up方向,应用在物体上,输出旋转变换,调整变换的方向,使其Z轴面朝forward,Y轴朝着up的方向。

注意:参数up的建议,最终的参数up将会被调整为与forward参数相距90度,这里先用物体的up向量也是可以的(transform.up),即使注视方向可能会从XZ平面离开。

        headBone.rotation = Quaternion.LookRotation(towardObjectFromHead, transform.up);

于是现在头部骨骼可以自己改变方向,注视的方向是从头部骨骼到目标物体,由于之前的Quaternion.LookRotation方法里用的是transform.up,所以GeckoController组件在这种情况需要放在模型的根,使up方向不会随头部的旋转而改变。

这是个好的开始,不过仍然存在一系列问题,跟踪是瞬时的,看起来很不自然,而且转向时也没有角度限制。为了使跟踪更平滑,我们需要添加一些阻尼。许多人也许会先想到用下面这种简易的办法来移动目标:

        current = Mathf.Lerp(         current,         target,         speed * Time.deltaTime         );

然后这种简单的实现会依赖帧率给出不连续的结果,所以我们要替换插值参数实现一个独立于帧率的阻尼函数(http://www.rorydriscoll.com/2016/03/07/frame-rate-independent-damping-using-lerp/),代码是这样:

        current = Mathf.Lerp(         current,         target,         1 - Mathf.Exp(-speed * Time.deltaTime)         );

应用在头部骨骼上,则要对四元数进行插值。四元数可以被数或向量插值,但因为四元数代表了旋转,我们将用Slerp(Spherecal Linear Interpolation),它和Lerp很类似,除了它沿着的是球体的表面而不是在球体上连一条到目标的线段,所以插值结果会更连续。

        Quaternion targetRotation = Quaternion.LookRotation(towardObjectFromHead, transform.up);         headBone.rotation = Quaternion.Slerp(headBone.rotation, targetRotation, 1 - Mathf.Exp(-speed * Time.deltaTime));

speed=3.0

不过现在还差角度约束没有做。为此,我们要用一个角度表示在头部默认的0旋转角度的任意方向的最大值。这可以通过将朝向目标的方向向量从world space转换到头部的local space来完成。

注意:就像旋转肩膀或肘部时一样,手在世界上的位置和旋转会发生改变但在局部的位置和旋转(即相对于肘部)保持不变。

向本地坐标的转换是相对于对象的直接父级对象而言的,所以我们用头部的父级做转换,首先,获得头部骨骼的父级获得引用,然后对它用InverseTransformDirection,它能把方向由世界空间转换到本地空间。

        Vector3 targetLocalLookDir = headBone.parent.InverseTransformDirection(targetWorldLookDir);

注意:用头部的父级转换方向等同于先将头部的旋转置零,然后用头部骨骼本身做旋转变换。

一旦获得了目标的本地空间方向,我们就可以用Vector.RotateTowards来限制旋转的角度。这个方法采用一个起始方向,一个结束方向,弧度表示的两个方向的距离和弧度的最大长度。最终从这个方法获得输出并用Quaternion.LookRotation方法得到最终的头部旋转。

 //储存当前的头部旋转因为我们将要重置它         Quaternion currentLocalRotation = headBone.localRotation;         //重置头部旋转因为之后空间到本地转换需要头部的旋转为0         //NOTE:Quaternion.identity = "Zero&#​34;         headBone.localRotation = Quaternion.identity;         Vector3 targetWorldLookDir = target.position - headBone.position;         Vector3 targetLocalLookDir = headBone.InverseTransformDirection(targetWorldLookDir);         //应用角度限制         targetLocalLookDir = Vector3.RotateTowards(Vector3.forward, targetLocalLookDir, Mathf.Deg2Rad * headMaxTurnAngle, 0);         //利用本地方向向量在LookRotation获得本地旋转         Quaternion targetLocalRotation = Quaternion.LookRotation(targetLocalLookDir, Vector3.up);         //应用平滑         headBone.localRotation = Quaternion.Slerp(currentLocalRotation, targetLocalRotation, 1 - Mathf.Exp(-headTrackingSpeed * Time.deltaTime));

speed=3,angle=45

眼球跟踪

下一步我们想要添加眼球的跟踪。在这之前,不如把代码整理一下,根据控制的功能不同,分别放在自己的更新方法里,以便后面能看的的更清楚。

    void HeadTrackingUpdate()     {         //之前的代码放在这里     }     void EyeTrackingUpdate()     {         //马上要开始的代码写这里     }

注意:在LateUpdate里,函数的顺序很重要,因为眼球是头部的子级,任何头部的旋转都会影响到眼球,所以我们要确保头部先更新,然后才是眼球。

我们会用到跟之前控制头部旋转一样的途径控制眼球的旋转,但不同的是这里的角度约束。我们不想对两只眼睛用相同的角度限制因为眼球的运动是不对称的。简单起见,旋转的约束仍然是绕着Y轴。

然后是声明需要用到的变量,我们想引用的是眼球本身、与头部跟踪速度分离的的跟踪速度、每只眼睛分离的最小及最大旋转。

    [SerializeField] Transform leftEyeBone;     [SerializeField] Transform rightEyeBone;     public float eyeTrackingSpeed;     public float leftEyeMaxYRotation;     public float leftEyeMinYRotation;     public float rightEyeMaxYRotation;     public float rightEyeMinYRotation;

我们可以像对头部那样,获得光滑的到目标的旋转。

        //NOTE:这里用了头部的位置的原因是壁虎斜视时看起来并不好,为了真正关联的是眼球,下面减去的应该是眼球的位置而不是头部         Quaternion targetEyeRotation = Quaternion.LookRotation(target.position - headBone.position, transform.up);         leftEyeBone.rotation = Quaternion.Slerp(leftEyeBone.rotation, targetEyeRotation, 1 - Mathf.Exp(-eyeTrackingSpeed * Time.deltaTime));         rightEyeBone.rotation = Quaternion.Slerp(rightEyeBone.rotation, targetEyeRotation, 1 - Mathf.Exp(-eyeTrackingSpeed * Time.deltaTime));

我们需要用一种不同的途径限制眼球,因为只想固定在一个坐标轴,可以用欧拉角(Euler angles),这也是通过定义一系列度数代表物体绕着三个坐标轴的其中之一做某种三维旋转的方法。它允许我们只对向量的单一分量进行操作在本地空间控制一个轴。

注意:尽管欧拉角可能更直观,但它可能引出一些四元数能轻松解决的问题。

角度制的度数每间隔360度是一个循环,也就是说-90、270、630度都是一样的,在Unity的eulerAngles和localEulerAngles取的度数在0-360度范围内,但是控制角度的时候,我们希望的范围是-180-180度的循环,这意味着当角度超过180时,它应该绕道-180而不是继续增加到360,所以通过减去360达到这个目的。

        float leftEyeCurrentYRotation = leftEyeBone.localEulerAngles.y;         float rightEyeCurrentYRotation = rightEyeBone.localEulerAngles.y;         //移动角度到-180-180的范围内         if (leftEyeCurrentYRotation > 180)             leftEyeCurrentYRotation -= 360;         if (rightEyeCurrentYRotation > 180)             rightEyeCurrentYRotation -= 360;         //限制Y轴的旋转         float leftEyeClampedYRotation = Mathf.Clamp(leftEyeCurrentYRotation, leftEyeMinYRotation, leftEyeMaxYRotation);         float rightEyeClampedYRoation = Mathf.Clamp(rightEyeCurrentYRotation, rightEyeMinYRotation, rightEyeMaxYRotation);         //应用限制过的Y轴旋转         leftEyeBone.localEulerAngles = new Vector3(leftEyeBone.localEulerAngles.x, leftEyeClampedYRotation, leftEyeBone.localEulerAngles.z);         rightEyeBone.localEulerAngles = new Vector3(rightEyeBone.localEulerAngles.x, rightEyeClampedYRoation, rightEyeBone.localEulerAngles.z);

腿部步伐

如简介中所说,我么们不会深入讲解IK背后的数学原理,只需要知道如何使用它,如果你还没有一个可用的IK系统,那么在GitHub或者Unity的Asset Store里都有很多免费的插件。

在这一节,我们选择使用的是two bone IK,带有一个end point target和一个pole vector target。如果你对这些名词非常陌生,可以阅读一下简洁里关于反向动力学的链接。

通常pole vector的控制是独立的,但由于我们不会有任何复杂的膝关节动作,所以可以就让pole vector和IK链的根骨骼共享一个父级,这意味着它会随身体旋转且和IK链的根保持一个位置,从而给出一致的肘关节方向。

pole vector的在层级见的位置,注意:大腿和pole vector共享Spine2作为父级

同样的,我们还需在世界空间创建target end point(没有任何父级)然后把他们放在脚掌上的合适位置,当所有的部位都连接上IK解算器后,我就可以开始啦!

注意,所有pole vector是父级,会随骨骼一起移动

注意:关于IK设置的部分很简短,因为设置步骤会因IK解算器的不同而异。查看IK解算器的文档可以获得实现的详细说明。

值得庆幸的是,一只行动缓慢的壁虎的踏步循环非常简单。有着更复杂的行走循环的角色通常会使用关键帧作为运动本身而仅把程序化动画作为辅助,但我们可以用从A到B的简单脚本摆脱这个问题。

为了确定脚的位置,我们将对每只脚设置一个home position。这个位置位于腿的可到达范围的区域的中心,并且和肩膀或者大腿的父级骨骼共享一个父级,就和pole vector targets一样。我们还会用它控制脚的旋转,所以需要确定其方向以便在静止时它和脚是同一方向的。

End point target绕着大概的home position运动

我们准备用home position决定什么时候移动end point target到什么地方,当腿超过了范围,就触发一个步伐将其回到home position。

让我们创建一个新的脚本叫做LegStepper控制脚的移动逻辑,然后挂载在end point terget物体上,然后引用一个homeTransform物体和一些关于踏步的参数。

public class LegStepper : MonoBehaviour {     //我们想保持位置和旋转所在的范围     [SerializeField] Transform homeTransform;     //留在这个范围内的距离     public float desireStepDst;     //完成一步的时间     public float moveDuration;     //腿是否正在移动     public bool isMoving; }

因为我不想踏步是瞬时的,所以会用到协程的办法。协程允许你在方法中的一段时间内暂停代码的执行,所以在这种情况下,可以实现步伐循环的每一帧后暂停一帧,逐渐的到达目标点。

    IEnumerator MoveToHome()     {         isMoving = true;         Quaternion startRot = transform.rotation;         Vector3 startPoint = transform.position;         Quaternion endRot = homeTransform.rotation;         Vector3 endPoint = homeTransform.position;         float timeGone = 0;         //这里使用do-while循环所以标准化时间在上一次迭代会超过1,         //在结束前设置结束位置         do         {             timeGone += Time.deltaTime;             float normalizedTime = timeGone / moveDuration;             //对位置和旋转插值             transform.position = Vector3.Lerp(startPoint, endPoint, normalizedTime);             transform.rotation = Quaternion.Slerp(startRot, endRot, normalizedTime);             //等待一帧             yield return null;         }         while (timeGone < moveDuration);         isMoving = false;     }

这段代码将腿部在moveDuration的时间内从当前位置和旋转移动到home position的位置和旋转。为了触发学成,我么你还需要在update里距离检车,然后在超出范围时运行它。

    private void Update()     {         if (isMoving)             return;         float dstFromHome = Vector3.Distance(transform.position, homeTransform.position);         if (dstFromHome > desireStepDst)             StartCoroutine(MoveToHome());     }

能用,但是还不够好

运作正常,但是看起来并不那么真实。想要润色,可以让抬腿的动作有一定的弯曲,并且超出home position时进行更大幅度的运动。为了实现弯曲我们会用到嵌套lerp的二次贝塞尔曲线。

 IEnumerator Move()     {         isMoving = true;         Vector3 startPoint = transform.position;         Quaternion startRot = transform.rotation;         Quaternion endRot = homeTransform.rotation;         //脚部到home position的方向向量         Vector3 towardHome = (homeTransform.position - transform.position);         //超出的总距离         float overshootDistance = desireStepDst * stepOvershootFraction;         Vector3 overshootVector = towardHome * overshootDistance;         //因为在这个简要实现里没有真的地面的点,         //所以通过投影过冲向量在世界的XZ平面上来限制它在地面的水平面上         overshootVector = Vector3.ProjectOnPlane(overshootVector, Vector3.up);         //应用过冲         Vector3 endPoint = homeTransform.position + overshootVector;         //我们想传递的中心点         Vector3 centerPoint = (startPoint + endPoint) / 2;         //还需要抬起         centerPoint += homeTransform.up * Vector3.Distance(startPoint, endPoint) / 2f;         float timeGone = 0;         do         {             timeGone += Time.deltaTime;             float normalizedTime = timeGone / moveDuration;             //二次贝塞尔曲线             transform.position = Vector3.Lerp(Vector3.Lerp(startPoint, centerPoint, normalizedTime), Vector3.Lerp(centerPoint, endPoint, normalizedTime), normalizedTime);             transform.rotation = Quaternion.Slerp(startRot, endRot, normalizedTime);             yield return null;         }         while (timeGone < moveDuration);         isMoving = false;     }

很好,但还有点不足

动作看起来还是有些不自然。需要做的最后一步是Easing(https://easings.net/en)这个动作。Easing的实现同样非常普遍,比如这个(https://gist.github.com/Fonserbc/3d31a25e87fdaa541ddf),所以我们假设你手上已经有一个可用的Easing。然后将它应用在步伐,在贝塞尔曲线用到之前,更新normalizedTime。

            normalizedTime = Easing.Cubic.InOut(normalizedTime);

非常好

看起来不错,不过还有一个问题。那就是每一只腿在移动时只考虑自己的状态,有时会出现多只腿同时悬空的情况。

为了解决这个问题,我们需要把所有的腿部控制放在一起,当移动时,壁虎会移动对角线成对的腿,也就是每次移动的目标就只是左前腿和右后腿,或者右前腿和左后腿。

现在检测腿部是否要移动的函数在LegStepper的update里,但我们想要转换它到GeckoController脚本中。为了做到这一点,先随意地把LegStepper脚本的update函数重新命名,并改为公开,比如public boid TryMove()。

   public void TryMove()     {         if (isMoving)             return;         float dstFromHome = Vector3.Distance(transform.position, homeTransform.position);         if (dstFromHome > desireStepDst)             StartCoroutine(Move());     }

回到GeckoController脚本,在里面为每一只腿引用变量。

    [Header("Legs")]     [SerializeField] LegStepper frontLeftStepper;     [SerializeField] LegStepper frontRightStepper;     [SerializeField] LegStepper backLeftStepper;     [SerializeField] LegStepper backRightStepper;

然后,为了驱动腿的运动,我们会用到协程处理迈腿的顺序,尝试交替成对的移动腿。

    //只允许对角线成对迈腿     IEnumerator LegUpdateCoroutine()     {         while (true)         {             do             {                 frontLeftStepper.TryMove();                 backRightStepper.TryMove();                 //等待一秒                 yield return null;                 //当腿是移动的时会留在这个循环                 //如果只有一只腿正在移动,在另一腿想要移动的时候                 //也会调用TryMove()             } while (backRightStepper.isMoving || frontLeftStepper.isMoving);             //另一对也同理             do             {                 frontRightStepper.TryMove();                 backLeftStepper.TryMove();                 yield return null;             } while (backLeftStepper.isMoving || frontRightStepper.isMoving);         }     }

注意:这种实现方法可能导致移动速度太快而腿无法跟上,出现跳出循环的情况。

躯干运动

成功了!

接下来剩下的只有移动了。我们用最小和最大距离参数让头部骨骼在远离目标时靠近,而在太近的时候退后。

        Vector3 targetVelocity = Vector3.zero;         //如果背对着目标,不要移动只要原地旋转         if (Mathf.Abs(angToTarget) < 90)         {             float dstToTarget = Vector3.Distance(transform.position, target.position);             if (dstToTarget > maxDstToTarget)                 targetVelocity = moveSpeed * towardTargetProjected.normalized;             else if (dstToTarget < minDstToTarget)                 targetVelocity = moveSpeed * -towardTargetProjected.normalized;         }         currentVelocity = Vector3.Lerp(currentVelocity, targetVelocity, 1 - Mathf.Exp(-moveAcceleration * Time.deltaTime));         transform.position += currentVelocity * Time.deltaTime;

最后,别忘了在LateUpdate函数里调用RootMotionUpdate!因为头部的跟踪依赖于躯干的方向,所以要把RootMotionUpdate放在前面。

    private void LateUpdate()     {         RootMotionUpdate();         HeadTrackingUpdate();         EyeTrackingUpdate();     }

结语

还有许多可以改善的地方,但教程到这里就是结束了。这个教程所介绍的都是纯骨骼控制,但是相当大的一部分程序性动画是和关键帧动画结合一起使用的。

如果想获得更多关于这个话题的知识,可以查看David Rosen的GDC演讲(https://www.youtube.com/watch?v=LNidsMesxSE)以及Joar Jackobsson和James Therrien的关于Rain world的程序性动画演讲(https://www.youtube.com/watch?v=sVntwsrjNe4)。