找回密码
 立即注册
查看: 351|回复: 0

Unity动画TA:写出自己的BakeMesh函数来验证经典蒙皮 ...

[复制链接]
发表于 2022-1-6 21:48 | 显示全部楼层 |阅读模式
写在前面

虽然不论在任何引擎里平时不需要手撕自己的蒙皮,但是了解蒙皮的算法仍然用处巨大。比如,刚接触蒙皮问题的同学有可能搞不懂“Candy Wrapping”问题的来源,即蒙皮的关节处扭转180°会在中间扭出一个很细的结,如同糖纸包装方式。它的解决方式通常是加twist骨骼,为什么加上以后就能掩盖这个问题,也需要从蒙皮的算法中寻找答案。
另外,线性混合蒙皮算法可以成为一种强有力的思维模式,帮助我们解决蒙皮之外的问题。举例如,在重定向表情动画的时候,在现有骨骼和蒙皮结构上算出一个框架结构称之为“笼子”,每个面部骨骼在“笼子”的范围内确定自己的运动,可以避免上下眼皮穿插的问题,此时表情骨骼的位置就是“笼子”确定的边界位置的线性混合结果。另外,经典Motion Warping算法中的Simple Warping可能会造成运动轨迹圆滑的地方出现尖锐的拐角,但如果把运动轨迹看成Mesh,Warping Point看成运动轨迹蒙皮的骨骼,由此得到的新的算法则只要“蒙皮权重”的变化是平滑的,就不会在Warping Point处出现不必要的拐角。
参考

[1] Gregory J , 叶劲峰. 游戏引擎架构[M]. 电子工业出版社, 2014.
狂推的唯一参考。其11.5部分标题为《蒙皮矩阵及生成矩阵调色板》,讲解得平白易懂,就算没有任何图形和动画基础的同学(比如公司里的法务、市场和文案)看完这一章也能完全明白最经典的蒙皮算法的原理。其中做为示例的数据结构和Unity的Mesh中的数据结构不一样,这主要因为Unity没有把一个顶点上的位置、法线、切线和蒙皮权重信息存成一个struct,而是存了好几个数组,每个索引对应一个顶点的信息。这很容易互相转化。
如果有同学对矩阵和变换比较糊涂,翻前边甚至还可以就近补矩阵相关知识。
线性混合蒙皮的本质

蒙皮的诀窍:顶点只有一个关节(即常说的骨骼,但是《游戏引擎架构》喜欢把它叫关节)影响时,顶点在关节空间中始终不变。
在被多个关节影响时,对于每个关节空间都会有这么一个不变的顶点位置。把这些顶点位置转化到模型空间,然后按权重对它们进行加权平均(线性混合),就可以得到模型空间下的顶点。于是,蒙皮效果就出现了。
这一步也可以让它直接转换到世界空间以节省一次矩阵乘法。但是如果场景中需要大量的实例,先转换到模型空间是更优的选择。
对每个关节而言,把顶点从bindpose变化到当前姿势的矩阵为蒙皮矩阵。每个顶点对应多个蒙皮矩阵,他们组成了蒙皮矩阵调色板。
在Unity中验证算法

提取信息

首先,写一个自定义Component,在Component里写上一个提取SkinnedMeshRenderer中所有必要信息的函数,序列化到Component上。
编辑器里右键菜单,一键提取所有相关信息,包括顶点、法线、切线、三角面、bindpose、骨骼等。如果想用这信息完全复原之前的Mesh,还可以加上UV信息。
提取信息之后,SkinnedMeshRenderer就可以删掉了。等会可以从这些信息里面把蒙皮后的Mesh的效果复原出来。
    [HideInInspector]
    [SerializeField]
    public Matrix4x4[] bindPoses;
    [HideInInspector]
    public Vector3[] vertices;
    [HideInInspector]
    public Vector4[] tangents;
    [HideInInspector]
    public Vector3[] normals;
    [HideInInspector]
    public BoneWeight[] boneWeights;
    [HideInInspector]
    public int[] triangles;

    [HideInInspector]
    public Transform[] bones;

    public SkinnedMeshRenderer target;


    [ContextMenu("Extract Bind Pose")]
    void ExtractBindPose() {
        if (target && target.sharedMesh) {
            bindPoses = target.sharedMesh.bindposes;
            vertices = target.sharedMesh.vertices;
            boneWeights = target.sharedMesh.boneWeights;
            normals = target.sharedMesh.normals;
            tangents = target.sharedMesh.tangents;
            triangles = target.sharedMesh.triangles;
            bones = target.bones;
        }
    }



一键提取蒙皮网格信息

生成蒙皮矩阵

Unity中的Mesh会有一个bindPose成员,是一个Matrix4x4类型的数组,每个参与蒙皮的骨骼按顺序对应其中的一个矩阵。官方文档里只说它是bindPose,没有解释得非常清楚(也许是觉得这东西属于先验知识不需要解释)。实际上,它是蒙皮姿势下所有的骨骼从Mesh Space转换到Local Space的变换矩阵组成的数组。Local Space也可以说成是Bone Space以帮助理解。
我打算跳过转换到模型空间的一步,直接让所有顶点转换到世界空间。因此对每根骨骼而言,实现蒙皮矩阵是,当前骨骼的LocalToWorldMatrix * bindPose。
    /// <summary>
    /// 蒙皮矩阵:每根骨骼对应一个蒙皮矩阵,把其影响的顶点从bindpose转换到目前的pose
    /// </summary>
    /// <returns></returns>
    Matrix4x4[] SkinningMatrices() {
        Matrix4x4[] skinningMatrices = new Matrix4x4[bindPoses.Length];
        for (int i = 0; i < bindPoses.Length; i++) {
            Transform bone = bones;
            Matrix4x4 currentBoneWorldTransformationMatrix;
            if (bone)
            {
                currentBoneWorldTransformationMatrix = bone.localToWorldMatrix;
            }
            else {
                currentBoneWorldTransformationMatrix = target.transform.localToWorldMatrix * bindPoses.inverse;
            }
            skinningMatrices = currentBoneWorldTransformationMatrix * bindPoses;
        }
        return skinningMatrices;
    }
烘焙现有姿势

需要变换的是顶点位置、法线和切线,其他的信息如三角面、UV等完全不会变。因此只Bake这三个数组即可。
用上面的函数求出的蒙皮矩阵,乘上这些信息即可得到世界空间下的位置、旋转和缩放。
有以下几个注意点。
位置信息需要转换为齐次坐标再做乘法,否则变换时会丢失位移。
法线不转化为齐次坐标,因为它只需要变换旋转。
切线的默认类型是Vector4,最后一位是标记位,作用是在模型存在负缩放的部分情形(x × y × z < 0)下翻转副切线。我暂时用不到它,因此前三位当成方向处理,最后一位照抄。
实现的时候复原的是Unity默认自带的4bone效果。
    void BakeOnCurrentPose(out Vector3[] poseVerts, out Vector3[] poseNormals, out Vector4[] poseTangents) {

        int numVerts = vertices.Length;
        poseVerts = new Vector3[numVerts];
        poseNormals = new Vector3[numVerts];
        poseTangents = new Vector4[numVerts];
        Matrix4x4[] skinningMatrices = SkinningMatrices();

        for (int i = 0; i < numVerts; i++) {
            BoneWeight boneWeight = boneWeights;
            Vector4 vert = vertices;
            vert.w = 1;

            Matrix4x4 skinningMatrix0 = skinningMatrices[boneWeight.boneIndex0];
            Matrix4x4 skinningMatrix1 = skinningMatrices[boneWeight.boneIndex1];
            Matrix4x4 skinningMatrix2 = skinningMatrices[boneWeight.boneIndex2];
            Matrix4x4 skinningMatrix3 = skinningMatrices[boneWeight.boneIndex3];

            float weight0 = boneWeight.weight0;
            float weight1 = boneWeight.weight1;
            float weight2 = boneWeight.weight2;
            float weight3 = boneWeight.weight3;

            Vector3 pos0 = skinningMatrix0 * vert;
            Vector3 pos1 = skinningMatrix1 * vert;
            Vector3 pos2 = skinningMatrix2 * vert;
            Vector3 pos3 = skinningMatrix3 * vert;

            Vector3 pos = pos0 * weight0 + pos1 * weight1 + pos2 * weight2 + pos3 * weight3;

            Vector3 norm = normals;

            Vector3 normal0 = skinningMatrix0 * norm;
            Vector3 normal1 = skinningMatrix1 * norm;
            Vector3 normal2 = skinningMatrix2 * norm;
            Vector3 normal3 = skinningMatrix3 * norm;

            Vector3 normal = normal0 * weight0 + normal1 * weight1 + normal2 * weight2 + normal3 * weight3;

            Vector4 tan = tangents;

            Vector3 tangent0 = skinningMatrix0 * tan;
            Vector3 tangent1 = skinningMatrix1 * tan;
            Vector3 tangent2 = skinningMatrix2 * tan;
            Vector3 tangent3 = skinningMatrix3 * tan;

            Vector4 tangent = tangent0 * weight0 + tangent1 * weight1 + tangent2 * weight2 + tangent3 * weight3;
            tangent.w = tan.w;

            poseVerts = pos;
            poseNormals = normal;
            poseTangents = tangent;
        }
    }
效果

已经删掉了SkinnedMeshRenderer,但是从之前序列化存储的信息里面还可以看到Mesh的样子。
在OnDrawGizmo里面写了一些用来可视化验证成果的脚本。此脚本每帧运算极端消耗形能,不建议使用。
    private void OnDrawGizmos()
    {

        Vector3[] bakedVerts;
        Vector3[] bakedNormals;
        Vector4[] bakedTangents;

        BakeOnCurrentPose(out bakedVerts, out bakedNormals, out bakedTangents);

        if (drawEdges)
        {
            Gizmos.color = Color.gray;
            for (int i = 0; i < triangles.Length; i += 3)
            {
                int vertIndex0 = triangles;
                int vertIndex1 = triangles[i + 1];
                int vertIndex2 = triangles[i + 2];
                Gizmos.DrawLine(bakedVerts[vertIndex0], bakedVerts[vertIndex1]);
                Gizmos.DrawLine(bakedVerts[vertIndex1], bakedVerts[vertIndex2]);
                Gizmos.DrawLine(bakedVerts[vertIndex0], bakedVerts[vertIndex2]);
            }
        }
        if (drawTangents) {
            Gizmos.color = Color.blue;
            for (int i = 0; i < bakedVerts.Length; i++) {
                Gizmos.DrawRay(bakedVerts, bakedTangents * 0.1f);
            }
        }
        if (drawNormals) {
            Gizmos.color = Color.green;
            for (int i = 0; i < bakedVerts.Length; i++)
            {
                Gizmos.DrawRay(bakedVerts, bakedNormals * 0.1f);
            }
        }
    }
可以分别开启画边、画法线、画切线三个选项,查看CPU做此次蒙皮运算的结果。



画边

动作用的是从ALSV4里导出的蹲伏动作。每一个顶点没一条边都在预期的位置上,因此这个bake初步是成功的。


法线的炸毛形状很明显,切线也在贴着模型表面走,因此虽然场景里没有任何Mesh和MeshRenderer,但它仍然可以还原Mesh的形状。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-5-14 05:21 , Processed in 0.091603 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表