2023/7/12:此脚本已过时,表情导出逻辑现已集成至赛马娘查看器,推荐使用:

https://github.com/croakfang/UmaViewer (可以直接导出整个模型,附带表情,骨骼,所有UV)

2022/9/19更新: 在@LC_ilmlp的提醒下注意到了Maya与unity直接的旋转问题,trsarray里记录的旋转值为maya的旋转,其旋转顺序与unity不同,直接给unity赋值就会出现问题。脚本已更新

2022/8/23更新:突然发现每个类型的表情的首个表情是默认表情,在应用表情之前首先要应用默认表情,否则某些马娘的表情会出现错误(比如有虎牙的角色虎牙会错位)。

之前写过一个工具来提取赛马娘的表情(上一篇文章地址),但无论如何调整参数,某些骨骼总是有些许错位,总是不能达成想要的效果,究其原因是MMD和PMD没有骨骼缩放,以及它们的长度单位、坐标轴与Unity有所不同。

MMD的骨骼只有旋转和位移
Unity拥有旋转、位移和缩放

所以想要完美地导出赛马娘的表情,还是需要在其他3D软件上进行处理才行。这里我就选择用Unity来进行提取和导出了(毕竟赛马娘是Unity游戏,我对Unity也比较熟悉)。

1.数据结构分析

上一篇文章说过,赛马娘是使用脚本控制骨骼实现的表情,每个头的模型都有一个对应的MonoBehaviour脚本,命名为ast_chr角色编号_服装编号_facial_target,通过AssetStudio可以将脚本以JSON格式导出。

储存表情信息的MonoBehavior

查看导出的JSON文件我们可以知道赛马娘人物表情信息的数据结构。

根目录下的_eyeTargeteyebrowTarget以及mouthTarget就是我们的目标,展开其中一个Target,可以看到里面包含了若干个_faceGroupInfo数组,展开 faceGroupInfo ,能看到里面包含两个_trsArray数组( mouthTarget 里只有一个),最后展开 _trsArray数组 ,就能看到若干个骨骼名、旋转位移缩放等信息。

通过分析得知,表情分为三类,分别为眼睛、眉毛和嘴巴表情、每一类就是一个Target,而每一类里面有若干个表情,每一个表情就是一个 faceGroupInfo ,每个表情内又分左表情和右边表情,每一边就是一个 trsArray (嘴巴不分左右,所以只有一个 trsArray ),每一边表情都由多个骨骼控制,所以 trsArray 里包含了若干个骨骼的信息。

2.脚本的编写

分析完数据结构,我们就对表情的控制有了大致的思路——对于一个表情,遍历它TrsArray里的所有的骨骼,对于每一个骨骼,我们在人物模型上找到对应名称的骨骼,并根据骨骼信息进行旋转位移缩放即可。

首先是定义数据结构

[Serializable]
public class Morph
{
    public int index;
    public string name;
    public enum morphType
    {
        EyeBrow,
        Eye,
        Mouth
    }
    public morphType type;
    public List<Bone> bones = new List<Bone>();
}

[Serializable]
public class Bone
{
    public string Bonename;
    public bool isValidScale;
    public bool isOverride;
    public Vector3 pos, rot, sca;
}

然后是解析JSON文件的核心代码

JObject jObject = (JObject)JsonConvert.DeserializeObject(FacialTarget.text);
        if (jObject == null) return;
        for (int f = 0; f < typeList.Length; f++)
        {
            JArray jArray = (JArray)jObject[typeList[f]];

            for (int i = 0; i < jArray.Count; i++)
            {
                JArray Group = (JArray)jArray[i]["_faceGroupInfo"];
                for (int j = 0; j < Group.Count; j++)
                {
                    Morph morph = new Morph();
                    morph.type = (Morph.morphType)f;
                    JArray trsArray = (JArray)Group[j]["_trsArray"];
                    

                    for (int n = 0; n < trsArray.Count; n++)
                    {
                        Bone bone = new Bone();
                        bone.Bonename = trsArray[n]["_path"].ToString();

                        bone.pos = new Vector3(
                           Convert.ToSingle(trsArray[n]["_position"]["x"]), 
                           Convert.ToSingle(trsArray[n]["_position"]["y"]), 
                           Convert.ToSingle(trsArray[n]["_position"]["z"]));

                        bone.rot = new Vector3(
                           Convert.ToSingle(trsArray[n]["_rotation"]["x"]), 
                           Convert.ToSingle(trsArray[n]["_rotation"]["y"]), 
                           Convert.ToSingle(trsArray[n]["_rotation"]["z"]));

                        bone.sca = new Vector3(
                            Convert.ToSingle(trsArray[n]["_scale"]["x"]), 
                            Convert.ToSingle(trsArray[n]["_scale"]["y"]), 
                            Convert.ToSingle(trsArray[n]["_scale"]["z"]));

                        bone.isValidScale = (int)trsArray[n]["_isValidScaleTransform"] == 1;
                        bone.isOverride = (int)trsArray[n]["IsOverrideTarget"] == 1;

                        if (i == 0)
                        {
                            bone.isOverride = true;
                            morph.name = ((Morph.morphType)f).ToString() + "_Base" + 
                                (Group.Count < 2 ? "" : (j == 0 ? "_R" : "_L"));
                        }
                        else
                            morph.name = ((Morph.morphType)f).ToString() + "_" + i + 
                                (Group.Count < 2 ? "" : (j == 0 ? "_R" : "_L"));

                        morph.bones.Add(bone);
                    }
                    morphs.Add(morph);
                }
                
            }
        }

然后是控制表情的方法

public void ChangeMorph(Morph morph)
    {
        FacialReset();
        foreach (Bone bone in morph.bones)
        {
            var tran = objs.Find(ani => ani.name.Equals(bone.Bonename));
            if (tran)
            {
                if (bone.isOverride)
                {
                    tran.localRotation = Quaternion.Euler(bone.rot);
                    tran.localPosition = bone.pos;
                    tran.localScale = bone.sca;
                }
                else
                {
                    var tmp = tran.localRotation.eulerAngles;
                    tmp += bone.rot;
                    tran.localRotation = Quaternion.Euler(tmp);
                    tran.localPosition += bone.pos;
                    tran.localScale += bone.sca;
                }
            }
        }
    }

至此,我们已经成功将表情信息提取出来,并在Unity中成功预览。

3.表情的导出

通过前面我们知道,模型和表情是分离的,而如果想要方便地在其他软件上使用表情,比较好的方法就是使用BlendShape,所以我们要将表情信息导入进模型的BlendShape里。

Unity里有对FPX操作的API,只需要我们在导入模型中开启Read/Write enable,并使用mesh里的AddBlendShapeFrame方法即可添加一个BlendShape表情到模型里。使用 AddBlendShapeFrame 需要传入变形的顶点、法线等信息,我们需要先调用上面预览表情的方法,再调用 AddBlendShapeFrame 方法。代码如下:

private void RecordBlendshape()
    {
        eyebrowMesh.sharedMesh.ClearBlendShapes();
        faceMesh.sharedMesh.ClearBlendShapes();
        foreach (Morph m in morphs)
        {
            ChangeMorph(m);
            switch (m.type)
            {
                case Morph.morphType.EyeBrow:
                case Morph.morphType.Eye:
                case Morph.morphType.Mouth:
                    Mesh facemesh = new Mesh();
                    faceMesh.BakeMesh(facemesh);
                    faceMesh.sharedMesh.AddBlendShapeFrame(m.name, 1,
                        CalDelta(faceMesh.sharedMesh.vertices, facemesh.vertices),
                        CalDelta(faceMesh.sharedMesh.normals, facemesh.normals),
                        CalDelta(Vec4ToVec3(faceMesh.sharedMesh.tangents), Vec4ToVec3(facemesh.tangents)));
                    break;
            }
            FacialReset();
        }
    }
开启模型的Read/Write enable ,否则无法对模型进行修改

导出表情后,我们打开模型的SkinMeshRenderer,里面应该已经有相应的BlendShape了,这时表情信息已经存进模型里了。

4.模型的导出

最后我们只需要将模型导出就可以了,这里我选择的是使用官方的FPX Exporter插件,直接去Package Manager里安装即可,安装后在场景目录下的模型上右键选择导出FBX即可。

导出选项除了导出目录外不做任何修改,点击导出

导出后可以用其他的软件打开模型(比如Blender),并查看表情

至此,赛马娘提取表情并导出的步骤就全部完成了,这次应该是100%还原表情了QVQ。

5.后记

这次在Unity上实现表情导出还是耗费了我不少的时间的,尤其是在骨骼的位移旋转缩放相对还是绝对的处理,如何导出到BlendShape上走了不少弯路,幸好最后的成果十分不错,也算是了却了我的一个心愿吧。

最后附上全部代码(后缀改成.cs即可作为Unity脚本使用):