1

最近自己在写动画系统,为了能够支持最基本的Locomotion,不得不实现BlendSpace。
为此深入研究了一下unity里的BlendSpace实现。
这个算法比我原本想象的要复杂,从网上也不大好找资料,所以我把这几个2D Space Blending的权重计算方法写出来。一篇写一个混合类型,今天就先从所谓的'Simple' Directional开始。

这个混合模式其实对我来说是最头疼的,因为真的从网上没找到任何有价值的资料。
纯靠写轮眼观察加猜,两只眼都瞪花了,用了我半天时间,最后才找到一个看起来应该是正确答案的算法。这也是为什么我要把算法写出来,因为好不容易实现出来我需要找个地方发泄啊。。

<--------- 正文开始 --------->

Simple Directional混合方式,首先会把所有动画节点按照旋转方向不同,把整个空间划分成为若干个扇区,然后取得在角度上距离采样点最近的两个点,对它们进行插值,在此基础上,再根据采样点到原点的距离远近,来进行第二次插值,最终得到混合权重。这里只是模糊的描述一下,下面在算法中会涉及到具体的规则。

基础程序框架:

为了能够较好的进行可视化,我们使用Sprite直接在场景中模拟Inspector中的BlendSpace面板。
首先准备两个脚本:

// 待混合的节点。创建一个Sprite,挂上这个组件,摆放在场景中作为待混合的节点。
public class BlendNode : MonoBehaviour {

    public float Weight;

    // 用于在SceneView中画出圆圈来表示此节点结算所得的权重。
    void OnDrawGizmos()
    {
        Gizmos.color = Color.cyan;

        var radiusFromWeight = Mathf.Sqrt(Weight);//From Unity Editor Source Code
        Gizmos.DrawWireSphere(transform.position, radiusFromWeight * 0.2f);
    }
}

// 采样点。给一个Sprite添加这个组件,摆放在场景中,随着拖拽这个组件可以实时的计算所有BlendNode的权重值。
[ExecuteInEditMode]
public class SampleNode : MonoBehaviour
{
    public BlendNode[] Nodes;

    private Vector3 mLastPosition;

    void Start ()
    {
        Nodes = FindObjectsOfType<BlendNode>();
    }
    
    void Update ()
    {
        var pos = transform.position;
        if(mLastPosition == pos) return;
        mLastPosition = pos;
        UpdateWeights();
    }

    [ContextMenu("Update Weights")]
    void UpdateWeights()
    {
        // 清空所有权重数据
        for (int i = 0; i < Nodes.Length; ++i) Nodes[i].Weight = 0;
        
        CalcWeightsSimpleDirectional();
    }

    void CalcWeightsSimpleDirectional()
    {
    }
}
其中空白的CalcWeightsSimpleDirectional()方法就是用于添加权重计算代码的地方。

场景摆放:

为了还原引擎中的混合算法,我们首先创建一个AnimatorController,然后使用我自己的组建在场景里创建出一模一样的结构。
图片描述
图片描述

算法思路

当开始真正讨论算法之前,我先建立一些最基本的思维模式。
算法的最终目的,是要把单位1的权重值,按照合理的方式分配给若干个动画节点。
如果理解这些权重为一杯水,那么也许节点A会分到40%,节点C会分到35%,剩下25%分配给节点F。

按照这个思路,我对单位1的权重首先进行第一次分配,分配给两个不同的部分:节点影响值和中心影响值。
分配的依据是:如果采样点距离原点很近,则中心影响值占据主导地位,反之则节点影响值占据主导地位。
然后再分别对节点影响值和中心影响值进行分配。

现在分配节点影响值。
2D Simple Directional主要还是一种以偏转角度作为插值依据的方法。
本方法把原点与动画节点连接成一条射线,平面空间中连接出来的若干射线会把平面切分成若干扇形区域。
然后确定采样点所在的区域,并根据采样点在角度上与哪个节点更近来决定如何分配这些影响值。
图片描述

中心影响值的分配方式有两种情况。如果存在一个在原点的动画节点,则所有的中心影响值都分配给这个节点,否则就将这些中心影响值平均分配给每一个节点。

代码实现

1 确定采样点所在的扇区

Simple Directional混合方式要把整个空间根据动画节点划分成为若干扇区,然后取得采样点所在扇区的两个节点来进行插值。
我们想要得到的这个扇区可以描述为一个三角形,它从原点开始,连接角度最接近采样点的两个动画节点。
所以可以对采样点和所有动画节点求出角度,并进行排序,就能得到我们想要的点。
这里使用到的的一个重要函数Mathf.Atan2(y, x),可以根据一个直角坐标系的坐标计算出极坐标系下的φ值,也就是我们想要的角度信息。

var inputPos = transform.position;
var inputPhi = Mathf.Atan2(inputPos.y, inputPos.x);

var nodeList = new List<BlendNode>(Nodes);
for (int i = nodeList.Count - 1; i >= 0; --i)
{
    var node = nodeList[i];
    var nodePos = node.transform.position;

    // 计算这个节点与input点的角度差。此时,所有phi值中,从0值顺时针遇到的第一个节点和逆时针遇到的第一个节点是所求节点。
    var phi = Mathf.Atan2(nodePos.y, nodePos.x) - inputPhi;
}

得到所有的角度值后,通过一些运算,然后排序,就能得到两个节点。

var inputPhi = Mathf.Atan2(inputPos.y, inputPos.x);
for (int i = nodeList.Count - 1; i >= 0; --i)
{
    var node = nodeList[i];
    var nodePos = node.transform.position;

    // 计算这个节点与input点的角度差。此时,所有phi值中,从0值顺时针遇到的第一个节点和逆时针遇到的第一个节点是所求节点。
    var phi = Mathf.Atan2(nodePos.y, nodePos.x) - inputPhi;
    // 把角度差值映射到0~2PI区间内。这样,最接近0值和最接近2PI值的两个节点就是所求节点。
    node.AngularDistance = Mathf.Repeat(phi, Mathf.PI * 2);
}
// 排序并取出节点。 这里31830(=100000/PI)是一个魔法数字,因为Compare要求返回一个整数
nodeList.Sort((a, b) => (int)((a.AngularDistance - b.AngularDistance)*31830));
var node0 = nodeList.First();
var node1 = nodeList.Last();

2 计算节点影响值的分配比例

var pos0 = node0.transform.position;
var pos1 = node1.transform.position;

我们把pos0和pos1视为向量的话,那么采样点inputPos和它们的关系一定有

inputPos = t0 * pos0 + t1 * pos1

其中t0和t1最容易理解的一种特殊情况是 (t0=1, t1=0) ,在这种情况下,inputPos和pos0是重合的。把t0和t1的值颠倒过来,也是同理。
从一般情况下看,可以粗略的认为:t0越大,权重应该更加倾斜向node0,t1越大,权重应该更加倾斜向node1。
所以node0和node1的节点影响力为:

influence0 = t0 / (t0 + t1)
influence1 = t1 / (t0 + t1)

求解t0和t1就是一个求解二元一次方程的过程。我为了代码简洁,使用了矩阵来做此项运算。

// 把节点坐标视为向量的话,一定有inputPos = t0 * pos0 + t1 * pos1,求解(t0, t1)
Matrix4x4 mat = Matrix4x4.identity;
mat.SetColumn(0, pos0);
mat.SetColumn(1, pos1);
var t = mat.inverse * inputPos;

// 我们预期input点会在 (0,0), pos0, pos1 组成的三角形内。也就是说t0和t1都不会是负值。
// 如果其中一项为负值,则说明在早期的步骤中并没能成功匹配到一个三角形扇区。
// 对于这种情况,我们认为pos0和pos1各自享有50%的影响值
var nodeInfluence0 = 0.5f;
var nodeInfluence1 = 0.5f;
if(t.x >= 0 && t.y >= 0)
{
    var sum = t.x + t.y;
    nodeInfluence0 = t.x / sum;
    nodeInfluence1 = t.y / sum;
}

3 计算中心影响值和节点影响值分别能得到的权重

图片描述

看上图,如果把node0和node1连接起来,可以得到绿色线段。
绿色线段上的任何一点的坐标,可以用这样的等式来表示pos' = pos0 * t0' + pos1 * t1',并且有 t0'+t1'=1
同理对于红色线段上的任何一点,有 't0'+t1'=t0+t1'
由inputPos决定的红色线断越接近原点,则 t0+t1 越接近0,该红色线断越接近绿色线段,则 t0+t1 越接近1。
所以可以直接拿 t0+t1 作为比例来计算权重。

// 节点影响值。根据input点在原点与pos0, pos1的连线之间的倾向值来决定。
// 如果input点更接近与原点,则中心影响值占据主导地位,如果input点更接近pos0,pos1的连线,则节点影响值占据主导地位。
// 这个数字可以根据t来计算。我们知道在pos0, pos1的连线上=>{t0+t1=1},而在原点>={t0+t1=0},所以很容易得到答案。
var nodeInfluence = Mathf.Clamp01(t.x + t.y);
node0.Weight = nodeInfluence * nodeInfluence0;
node1.Weight = nodeInfluence * nodeInfluence1;

4 搜索原点是否存在动画节点

为了对中心影响值进行分配,需要先寻找中心动画节点。这个部分的代码应该加入到步骤1的遍历中。
而且一旦某一个动画节点位置是在原点的,也就无法对这个节点计算角度,所以需要把这个节点从列表中移除。

BlendNode centerNode = null;
var inputPhi = Mathf.Atan2(inputPos.y, inputPos.x);
for (int i = nodeList.Count - 1; i >= 0; --i)
{
    var node = nodeList[i];
    var nodePos = node.transform.position;

    if(nodePos == Vector3.zero)
    { 
        centerNode = node;
        nodeList.RemoveAt(i);
        continue;
    }

    // 计算这个节点与input点的角度差。此时,所有phi值中,从0值顺时针遇到的第一个节点和逆时针遇到的第一个节点是所求节点。
    var phi = Mathf.Atan2(nodePos.y, nodePos.x) - inputPhi;
    // 把角度差值映射到0~2PI区间内。这样,最接近0值和最接近2PI值的两个节点就是所求节点。
    node.AngularDistance = Mathf.Repeat(phi, Mathf.PI * 2);
}

5 中心影响值

最后对中心影响值进行分配,这部分很简单。

// 中心影响值。如果存在一个中心节点centerNode,则所有中心影响值分配给该节点,否则将中心影响值平均分配给所有节点。
if(nodeInfluence < 1)
{
    var centerInfluence = 1 - nodeInfluence;
    if(centerNode != null)
    {
        centerNode.Weight = centerInfluence;
    }
    else
    {
        centerInfluence /= Nodes.Length;
        for (int i = 0; i < Nodes.Length; ++i) Nodes[i].Weight += centerInfluence;
    }
}

大功告成

图片描述

点此下载完整代码


迷途吧
145 声望25 粉丝

看见好文章就翻译一下,发现一些细节就分享一下,也许没什么技术,只希望能被需要的人搜到。