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

Unity动画TA:ConfigurableJoint自制Ragdoll,并开发配套的 ...

[复制链接]
发表于 2021-12-21 19:14 | 显示全部楼层 |阅读模式
写在前面

接上一篇Unity动画TA:详解ConfigurableJoint,并参照UE4的Constraint画上容易看懂的Gizmo
上一篇相当于准备了自制Ragdoll工具链中的第一环:能看懂的约束。
实际上,用Unity自带的ConfigurableJoint加上CapsuleCollider制作Ragdoll可以靠纯堆人力凑出Ragdoll效果而不借助辅助工具。但是十分难用,恐怕一年才能凑出个效果正确的Ragdoll。
Ragdoll部分还有几个一定要准备的工具链功能写在编辑器里面。包括但不限于在角色骨架上:
1、自动创建刚体
2、自动创建约束
3、自动创建胶囊碰撞体
自动创建刚体

确切地说,自己的“自动创建”都是半自动创建,和其他的引擎(比如UE4创建约束和胶囊体)一样。自动创建完的结果不那么准确,但是可以大大节省手工创建所需工时。
给角色模型添加一个自制的PhysicsAnimation组件。目前,这个组件里什么内容都没有。在写它的代码之前,先给它写自定义Editor,做好几个方便编辑的自动创建功能。
为了看上去一目了然知道哪些骨骼上有刚体、碰撞体或约束组件,且能快捷删除不必要的刚体、碰撞体和约束,该组件的大部分地方显示了Hierarchy面板一样的可卷展骨骼树。点击叉号,直接删除对应组件。

自定义Component的树状骨骼
https://www.zhihu.com/video/1453449902735450112
树状结构是每次该组件的编辑器类OnEnable的时候生成的。之后每次该树如果检查发现树状结构和真实的树状结构对不上了,会自动重新生成树状结构。因为这个编辑器效果不太好看,且没有用到奇怪的技术,此处略过代码。
选择过滤在哪些骨骼上自动生成刚体,目前用到的是骨骼尺寸,即骨骼到它的父骨骼之间的距离。首先定义一个预备要生成刚体的骨骼列表,初始值为空。然后从物理动画需要的根骨骼向下遍历子骨骼,依次查看是否符合条件。
1、如果距离小于给定值,代码认为它无足轻重,默认不生成刚体,不加入列表中。
2、如果某一根骨骼符合条件,代码把它加入列表中,同时检查它的父骨骼有没有被加入列表中。如果没有。即使父骨骼不满足骨骼尺寸够大的条件,也加入列表中,然后对父骨骼的父骨骼重复这一步。这样可以保证任何有刚体的骨骼其向上的骨骼链上每一根骨骼都有刚体,生成约束的时候,就可以从根骨骼直接生成到底。
    void GenerateBodies(float minBoneScale) {
        if (boneData.Length == 0) return;
        List<int> indiecs_need_to_add_rb = new List<int>();
        for (int i = 0; i < boneData.Length; i++) {
            PhysicalBoneEditorData data = boneData;
            if (data.parentIndex == -1) continue;
            Transform bone = data.BoneTransform;
            Transform parentBone = boneData[data.parentIndex].BoneTransform;
            float boneSacle = (bone.position - parentBone.position).magnitude;
            if (boneSacle < minBoneScale) continue;
            else{
                indiecs_need_to_add_rb.Add(i);
                int parentIndex = data.parentIndex;
                while (parentIndex > -1) {
                    if (indiecs_need_to_add_rb.Contains(parentIndex)) break;
                    indiecs_need_to_add_rb.Add(parentIndex);
                    PhysicalBoneEditorData parentBoneData = boneData[parentIndex];
                    parentIndex = parentBoneData.parentIndex;
                }
            }
        }
        foreach (int i in indiecs_need_to_add_rb) {
            PhysicalBoneEditorData data = boneData;
            data.BoneTransform.gameObject.AddComponent<Rigidbody>();
        }
        RegenerateInspectorSkeletonHierarchy();
    }
有同学会觉得,骨骼在蒙皮里实际影响的范围往往是它到它的子骨骼之间的部分,而不是到父骨骼之间的部分。这诚然正确,但是计算到父骨骼之间的距离往往也有意义,而且更简单,而且的而且UE4也是这么理解骨骼的Scale的,所以直接用了到父骨骼之间的距离。如果是接下来自动生成胶囊体的部分,就不得不从蒙皮里获取骨骼的影响范围了。
在最小骨骼长度设置为0.05的时候,自动生成刚体的情形:


https://www.zhihu.com/video/1453464461813768192
可以看到一些无关紧要的骨骼,如IK骨骼、twist骨骼和手指被生成了刚体,而重要骨骼头部则没有生成,可能因为脖子太短。接下来,需要点叉号删除无关紧要的刚体,并且把头部的刚体补上。
在神秘海域的分享中,为了获得更好的物理动画效果,应该让Rigidbody从父到子质量逐渐递减,比如1->0.8->0.64->0.512……之类。这个逻辑如果需要的话可以加在这一部分。
自动创建约束

只要父骨骼和子骨骼都存在刚体,且两个骨骼之间原本没有没有连接的Constraint,则在父骨骼上创建Constraint。Joint在父骨骼上,但Anchor在子骨骼的位置。这部分的概念如有不懂可以参考上一篇。
程序很难知道每个Constraint应该有什么样的约束设置,所以先默认设置Linear都是Locked,Angular都是Limited,所有在轴向的旋转都上下活动最多45°。接下来需要自己一个个调了。

自动创建约束的效果
https://www.zhihu.com/video/1453472442023211008
显示出来的全都是标准圆锥,因为swing范围(YZ周旋转)都是上下45°。twist(X轴旋转)也是45°,这个显示为绿色扇形。总体来说,有点UE4的物理资产内味了。
调整后的约束和自动创建出来的约束略有不同。下图是手动调整约束后的约束显示。



自骨骼上的约束

代码参考如下。代码里做了较多照顾UnrealConstraint类的处理,看起来比较冗杂,建议阅读时跳过这部分。
    void GenerateConstraints() {
        if (boneData.Length == 0) return;
        Transform rootBone = (target as PhysicalAnimation).physicsSkeletonRoot;
        if (!rootBone) return;

        for (int i = 0; i < boneData.Length; i++) {
            PhysicalBoneEditorData currentBoneData = boneData;
            if (currentBoneData.parentIndex == -1) continue;
            PhysicalBoneEditorData parentBoneData = boneData[currentBoneData.parentIndex];
            if (currentBoneData.constraints.rigidbody && parentBoneData.constraints.rigidbody) {
                UnrealConstraint[] parentConstraints = parentBoneData.constraints.constraints;
                bool constraint_found = false;
                for (int j = 0; j < parentConstraints.Length; j++) {
                    ConfigurableJoint joint = parentConstraints.Joint;
                    if (joint.connectedBody == currentBoneData.constraints.rigidbody) {
                        constraint_found = true;
                        break;
                    }
                }
                if (!constraint_found) {
                    GenerateConstraintOnParent(parentBoneData.constraints.rigidbody, currentBoneData.constraints.rigidbody);
                }
            }
        }
    }

    void GenerateConstraintOnParent(Rigidbody parentRB, Rigidbody childRB, ConfigurableJointMotion defaultAngularMotion = ConfigurableJointMotion.Limited, float defaultAngularLimit = 45) {
        GameObject parent = parentRB.gameObject;
        ConfigurableJoint joint = parent.AddComponent<ConfigurableJoint>();
        joint.connectedBody = childRB;
        joint.anchor = childRB.transform.localPosition;
        joint.autoConfigureConnectedAnchor = true;
        joint.connectedAnchor = Vector3.zero;
        joint.xMotion = ConfigurableJointMotion.Locked;
        joint.yMotion = ConfigurableJointMotion.Locked;
        joint.zMotion = ConfigurableJointMotion.Locked;

        joint.angularXMotion = defaultAngularMotion;
        joint.angularYMotion = defaultAngularMotion;
        joint.angularZMotion = defaultAngularMotion;

        joint.secondaryAxis = joint.transform.InverseTransformDirection(childRB.transform.right).normalized;
        Vector3 zAxis = Vector3.ProjectOnPlane(joint.transform.InverseTransformDirection(parent.transform.position - childRB.transform.position), joint.secondaryAxis);
        if (zAxis.sqrMagnitude > 0.001f)
        {
            Vector3 xAxis = Vector3.Cross(joint.secondaryAxis, zAxis).normalized;
            joint.axis = xAxis;
        }

        SoftJointLimit highAngularXLimit = new SoftJointLimit();
        highAngularXLimit.limit = defaultAngularLimit;
        joint.highAngularXLimit = highAngularXLimit;

        SoftJointLimit lowAngularXLimit = new SoftJointLimit();
        lowAngularXLimit.limit = -defaultAngularLimit;
        joint.lowAngularXLimit = lowAngularXLimit;

        SoftJointLimit angularYLimit = new SoftJointLimit();
        angularYLimit.limit = defaultAngularLimit;
        joint.angularYLimit = angularYLimit;

        SoftJointLimit angularZLimit = new SoftJointLimit();
        angularZLimit.limit = defaultAngularLimit;
        joint.angularZLimit = angularZLimit;

        UnrealConstraint constraint = parent.AddComponent<UnrealConstraint>();
        constraint.InitConstraintData();

        SerializedObject s_o_constraint = new SerializedObject(constraint);
        SerializedProperty s_p_bakedConstraintData = s_o_constraint.FindProperty("bakedConstraintData");
        s_p_bakedConstraintData.FindPropertyRelative("configurableJoint").objectReferenceValue = joint;

        Transform childTransform = childRB.transform;
        Transform parentTransform = childRB.transform;
        Quaternion constraintRotationInConnectedSpace;
        Quaternion constraintWorldRotation = parentRB.transform.rotation;

        if (childTransform)
        {
            constraintRotationInConnectedSpace = Quaternion.Inverse(childTransform.rotation) * constraintWorldRotation;
            s_p_bakedConstraintData.FindPropertyRelative("initialRotationOffset").quaternionValue = constraintRotationInConnectedSpace;
        }
        Quaternion constraintRotationInParentSpace = Quaternion.Inverse(parentTransform.rotation) * constraintWorldRotation;
        s_p_bakedConstraintData.FindPropertyRelative("initalLocalRotation").quaternionValue = constraintRotationInParentSpace;

        s_o_constraint.ApplyModifiedProperties();
    }
自动创建胶囊碰撞体

这是稍微需要一点技巧的部分,需要读取模型上的蒙皮信息。大体思路如下。
1、从SkinnedMeshRenderer的mesh列表上取得所有的BoneWeight列表,整理出哪一根骨骼影响了哪些顶点。
2、BakeMesh,从bake之后的mesh拿到顶点在目前姿势下的模型空间的位置。
3、对每个顶点:模型空间->世界空间->骨骼空间,最后,每个骨骼都知道了自己影响的所有顶点在骨骼空间的位置。
4、在骨骼空间下,整理出这些顶点的包围盒范围,包围盒最长的棱是胶囊体轴向和高度,剩下两个棱的长度决定胶囊体的半径。
Mannequin的蒙皮非常严谨,自动生成胶囊体的效果意外地不错。

自动生成胶囊碰撞体
https://www.zhihu.com/video/1453480145578942464
但还是有些需要调整的地方,比如要把Collider和不直接相连的刚体上的的Collider的重合部分去掉。调整之后的样子更加像UE4的物理资产。



调整后的自制Ragdoll

代码参考如下。
    void GenerateColliders() {
        if (boneData.Length == 0) return;
        Transform rootBone = (target as PhysicalAnimation).transform;
        //获取所有的SkinnedMeshRenderer
        SkinnedMeshRenderer[] smrs = rootBone.GetComponentsInChildren<SkinnedMeshRenderer>();
        //用来存储bone和它对应的顶点在骨骼空间的位置
        Dictionary<Transform, List<Vector3>> bone_vertex_pos_dict = new Dictionary<Transform, List<Vector3>>();

        foreach (SkinnedMeshRenderer smr in smrs)
        {
            Dictionary<Transform, List<int>> bone_vertex_dict = new Dictionary<Transform, List<int>>();
            Transform[] bones = smr.bones;
            //sharedMesh用来获取蒙皮权重信息
            Mesh sharedMesh = smr.sharedMesh;
            //bakedMesh用莱获取顶点位置
            Mesh bakedMesh = new Mesh();
            smr.BakeMesh(bakedMesh);
            BoneWeight[] boneWeights = sharedMesh.boneWeights;
            for (int i = 0; i < boneWeights.Length; i++)
            {
                Transform bone1 = bones[boneWeights.boneIndex0];
                Transform bone2 = bones[boneWeights.boneIndex1];
                Transform bone3 = bones[boneWeights.boneIndex2];
                Transform bone4 = bones[boneWeights.boneIndex3];
                Transform[] vertex_bones = new Transform[] { bone1, bone2, bone3, bone4 };
                foreach (Transform bone in vertex_bones)
                {
                    if (bone)
                    {
                        if (!bone_vertex_dict.ContainsKey(bone))
                        {
                            bone_vertex_dict.Add(bone, new List<int>());
                        }
                        bone_vertex_dict[bone].Add(i);
                    }
                }
            }
            //模型空间转世界空间的矩阵
            Matrix4x4 modelTransformationMatrix = smr.transform.localToWorldMatrix;
            Vector3[] vertWorldPos = new Vector3[bakedMesh.vertexCount];
            Vector3[] vertModelPos = bakedMesh.vertices;
            for (int i = 0; i < bakedMesh.vertexCount; i++)
            {
                Vector4 vert4 = vertModelPos;
                vert4.w = 1;  //齐次矩阵乘法带上位移的必要步骤
                vertWorldPos = modelTransformationMatrix * vert4;  //自动取前三位
            }
            foreach (KeyValuePair<Transform, List<int>> kvp in bone_vertex_dict)
            {
                Transform bone = kvp.Key;
                if (!bone_vertex_pos_dict.ContainsKey(kvp.Key))
                {
                    bone_vertex_pos_dict.Add(bone, new List<Vector3>());
                }
                foreach (int bone_index in kvp.Value)
                {
                    bone_vertex_pos_dict[bone].Add(bone.InverseTransformPoint(vertWorldPos[bone_index])); //世界空间转骨骼空间
                }
            }
        }

        for (int i = 0; i < boneData.Length; i++) {
            PhysicalBoneEditorData currentBoneData = boneData;
            if (!currentBoneData.constraints.rigidbody) continue;
            Transform bone = currentBoneData.BoneTransform;
            if (bone_vertex_pos_dict.ContainsKey(bone)) {
                List<Vector3> boneSpaceVertsList = bone_vertex_pos_dict[bone];
                if (boneSpaceVertsList.Count == 0)
                {
                    //
                }
                else {
                    Vector3 max = boneSpaceVertsList[0];  
                    Vector3 min = boneSpaceVertsList[0];
                    foreach (Vector3 boneSapceVert in boneSpaceVertsList)
                    {  //计算包围盒大小
                        max.x = Mathf.Max(max.x, boneSapceVert.x);
                        max.y = Mathf.Max(max.y, boneSapceVert.y);
                        max.z = Mathf.Max(max.z, boneSapceVert.z);
                        min.x = Mathf.Min(min.x, boneSapceVert.x);
                        min.y = Mathf.Min(min.y, boneSapceVert.y);
                        min.z = Mathf.Min(min.z, boneSapceVert.z);
                    }
                    CapsuleCollider capsuleCollider = bone.gameObject.AddComponent<CapsuleCollider>();
                    Vector3 size = max - min;
                    if (size.x >= size.y && size.x >= size.z)  //最长的边为胶囊体的方向
                    {
                        capsuleCollider.direction = 0;
                        capsuleCollider.height = max.x - min.x;
                        capsuleCollider.radius = (size.y + size.z) * 0.25f;  //剩下两个边平均一下是直径,再取一半为半径
                    }
                    else if (size.y >= size.z && size.y >= size.x)
                    {
                        capsuleCollider.direction = 1;
                        capsuleCollider.height = max.y - min.y;
                        capsuleCollider.radius = (size.x + size.z) * 0.25f;
                    }
                    else {
                        capsuleCollider.direction = 2;
                        capsuleCollider.height = max.z - min.z;
                        capsuleCollider.radius = (size.x + size.y) * 0.25f;
                    }
                    capsuleCollider.center = Vector3.Lerp(min, max, 0.5f);  //使用包围盒中心作为胶囊体中心
                }
            }
        }
    }
初步效果

这三步做完,就相当于做出了一个勉勉强强能是那么回事的Ragdoll。目前只调整了肘关节和膝关节的约束,其他的部分大致没管……
在运行时,需要把相邻带有约束关系的碰撞体全部忽略碰撞。
如果想做出更加自然的Ragdoll效果,需要进一步调整约束,并且给适当的关节加上一些驱动力。

粗糙的Ragdoll效果
https://www.zhihu.com/video/1453485831998230528
如果目标只是自己拼一个Ragdoll,做到这一步算是有了初步的效果。
但目标不能只停在Ragdoll上。下一步是模拟物理驱动的动画效果。
物理动画参考

实现物理动画的参考除了UE的实现方式以外,还有顽皮狗的神秘海域4物理动画GDC分享视频,标题为 Physics Animation in Uncharted 4- A Thief's End - YouTube。这个分享大概是业界物理动画的“万恶之源”。已经有大佬把这个视频搬上了b站,因此不用用去油管,在B站搜索这个标题也能看到。此外,EA制作星战和一系列体育游戏的GDC分享也很有营养,值得一看。



神秘海域4分享的物理动画名场面:坐在车上晃

制作完基础效果之后,被大佬告知Unity有一个常用插件叫PuppetMaster有类似功能。如果重新制作的话,我一定要参考这个插件的成熟实现。

本帖子中包含更多资源

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

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

本版积分规则

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

GMT+8, 2024-11-22 18:59 , Processed in 0.201973 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

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