DomDomm 发表于 2022-6-14 07:23

DynamicBone(动态骨骼)源码赏析(1)

前言:

长文预警。
最近在搞DynamicBone(后文简称DB),虽然这是在UnityAssetStore中一款经典插件的名称,但我们更多用其描述于“物理模拟计算实现动态的骨骼动画”。
而实际上,至今市面上现有的轮子,除了【DynamicBone】之外,还有在“UnityChanModel”包里面的【SpringBone】,以及搭载了“JobSystem”与“BurstCompiler”的强大插件【MagicaCloth】。
如果是想高效快速的实现动态骨骼的话,推荐且仅推荐【MagicaCloth】,【DynamicBone】已经完成了他的历史使命,该退环境啦!
你在知乎上一搜,就能发现各位大牛针对【DynamicBone】做的优化,在Github上也有对【DynamicBone】Job并行优化的代码,就知道大家对【DynamicBone】的性能怨念了。
只不过,【DynamicBone】的算法源码也算得上经典,故本文基于【DynamicBone】源码开始,逐步分析其逻辑思想与优化方向。
一、 DynamicBone源码赏析

对DB物理模拟算法感兴趣的小伙伴,可以参考一下这篇文章《FrankZhou:动态骨骼Dynamic Bone算法详解》。
本文就不班门弄斧了,仅在源码实现层面进行讲解。至于源码资源的获取,我相信读者有能力搞到。
(注:笔者使用的DB版本是旧版代码,在2022.1.30版DB中,加入了多线程优化)
导入Package后,我们可以看见,DB的源码文件实际上非常简单:



核心就是DynamicBone.cs

【DynamicBone.cs】:核心代码,是骨骼物理模拟计算的单元组件。
【DynamicCollider.cs】:球/胶囊体碰撞体组件,用于物理计算防止穿模。
【DynamicColliderBase.cs】:碰撞体基类,定义了一些基本参数与虚函数。
【DynamicBonePlaneCollider.cs】:平面类型的碰撞体,笔者还没用过。
后面3个碰撞体相关的代码,纯粹就是一些数学计算,不作讲述。
下面来逐步赏析【DynamicBone.cs】代码。
1. 数据结构定义

在【DynamicBone.cs】中,前100行代码左右,基本上都是这样的格式:
#if UNITY_5_3_OR_NEWER
       
#endif
    public Transform m_Root = null; //执行动态骨骼的根节点
       
#if UNITY_5_3_OR_NEWER
       
#endif
    public float m_UpdateRate = 60.0f; //一条骨骼的更新速率
这个【#if #endif】预编译指令纯粹是为了解决Unity版本兼容性问题,这种写法看起来好像【Tooltip】这个特性是在Unity5.3之后的版本才实现,没用的知识增加了呢,兼容性的历史包袱令人无奈。
之后,定义了一系列基本的模拟计算量与质点结构:
    public Transform m_ReferenceObject = null; //裁切参照
    public float m_DistanceToObject = 20; //裁切距离

    public Vector3 m_LocalGravity = Vector3.zero;//本地坐标下的重力方向
    public Vector3 m_ObjectMove = Vector3.zero;//两帧之间实际移动方向
    public Vector3 m_ObjectPrevPosition = Vector3.zero; //上帧坐标
    public float m_BoneTotalLength = 0; //骨骼总长度
    public float m_ObjectScale = 1.0f; //物体全局缩放系数
    public float m_Time = 0; //单次模拟计时统计
    public float m_Weight = 1.0f; //骨骼权重
    public bool m_DistantDisabled = false; //是否进行距离裁切

    class Particle //质点结构
    {
      public Transform m_Transform = null; //质点对应的trans
      public int m_ParentIndex = -1; //质点父节点序号
      public float m_Damping = 0; //damping系数
      public float m_Elasticity = 0; //elasticity系数
      public float m_Stiffness = 0; //stiffness系数
      public float m_Inert = 0; //惯性系数
      public float m_Friction = 0; //摩擦系数
      public float m_Radius = 0; //碰撞半径
      public float m_BoneLength = 0; //骨骼长度
      public bool m_isCollide = false; //是否发生了碰撞

      public Vector3 m_Position = Vector3.zero; //当前位置坐标
      public Vector3 m_PrevPosition = Vector3.zero; //上一帧位置坐标
      public Vector3 m_EndOffset = Vector3.zero; //虚拟尾结点偏移
      public Vector3 m_InitLocalPosition = Vector3.zero; //初始位置坐标
      public Quaternion m_InitLocalRotation = Quaternion.identity; //初始旋转
    }
2. Start()

基本数据定义好之后,一次模拟流程是:
    void Start()
    {
      SetupParticles(); //设置质点参数、系数
    }
启动时,初始化质点信息。
因为我们是在一条骨骼链的根骨骼上绑定的DB,所以开始时要沿着这条骨骼链,初始化其所有子节点(质点)信息,这个函数实际上做了:
    public void SetupParticles() //设置质点系数(初始化)
    {
      m_Particles.Clear(); //清除质点列表
      if (m_Root == null) //检测根节点情况
            return;

      m_LocalGravity = m_Root.InverseTransformDirection(m_Gravity); //世界坐标->局部坐标转换,获取本地重力方向
      m_ObjectScale = Mathf.Abs(transform.lossyScale.x); //全局缩放
      m_ObjectPrevPosition = transform.position; // 初始化上一帧位置
      m_ObjectMove = Vector3.zero; //初始化移动方向
      m_BoneTotalLength = 0; //初始化骨骼链总长度
      AppendParticles(m_Root, -1, 0); //递归添加子节点质点信息
      UpdateParameters(); //更新参数
    }
在【AppendParticles】方法中,重要的有:
   void AppendParticles(Transform b, int parentIndex, float boneLength) //递归方法
    {
      Particle p = new Particle(); //实例化新质点
      p.m_Transform = b; // 绑定trans
      p.m_ParentIndex = parentIndex; //父节点index赋值
      if (b != null) //初始化质点信息
      {
            p.m_Position = p.m_PrevPosition = b.position; //当前位置
            p.m_InitLocalPosition = b.localPosition; //本地坐标
            p.m_InitLocalRotation = b.localRotation; //本地旋转
      }
      else         // end bone
      {
         //...一系列虚拟尾点的初始化设置
      }

      if (parentIndex >= 0)
      {
            //计算两个质点之间的骨骼长度,叠加统计
            boneLength += (m_Particles.m_Transform.position - p.m_Position).magnitude;
            p.m_BoneLength = boneLength; //一个质点的BoneLength就是自己到根节点的总长度。
            //更新骨骼总长度
            m_BoneTotalLength = Mathf.Max(m_BoneTotalLength, boneLength);
      }

      int index = m_Particles.Count;
      m_Particles.Add(p); //trick ,index是新增p的下标

      if (b != null)
      {
            for (int i = 0; i < b.childCount; ++i) //遍历子节点
            {
                bool exclude = false;
                if (m_Exclusions != null) //遍历排除节点
                {
                   //排除节点的遍历处理...
                }
                if (!exclude)
                  AppendParticles(b.GetChild(i), index, boneLength); //节点包含,递归添加质点
                else if (m_EndLength > 0 || m_EndOffset != Vector3.zero)
                  AppendParticles(null, index, boneLength); //节点被排除,递归null,执行尾结点生成
            }
            //虚拟尾点处理...
      }
    }
总的来说,【AppendParticles】负责初始化质点坐标/旋转信息,统计骨骼总长度,统计质点局部骨骼长度等信息。
然后,调用了【UpdateParameters】,他长这样:
    public void UpdateParameters() //更新质点参数
    {
      if (m_Root == null)
            return;

      m_LocalGravity = m_Root.InverseTransformDirection(m_Gravity); //本地重力方向计算

      for (int i = 0; i < m_Particles.Count; ++i) //遍历质点列表
      {
            Particle p = m_Particles;
            p.m_Damping = m_Damping;
            p.m_Elasticity = m_Elasticity;
            p.m_Stiffness = m_Stiffness;
            p.m_Inert = m_Inert;
            p.m_Friction = m_Friction;
            p.m_Radius = m_Radius;

            if (m_BoneTotalLength > 0) //统计的骨骼总长度大于0
            {
                float a = p.m_BoneLength / m_BoneTotalLength; //计算质点骨骼长度与总长度的比值
                //执行曲线赋值,通过这个【比值】实现根节点到尾结点的曲线参数赋值。
                if (m_DampingDistrib != null && m_DampingDistrib.keys.Length > 0)
                  p.m_Damping *= m_DampingDistrib.Evaluate(a);
                if (m_ElasticityDistrib != null && m_ElasticityDistrib.keys.Length > 0)
                  p.m_Elasticity *= m_ElasticityDistrib.Evaluate(a);
                if (m_StiffnessDistrib != null && m_StiffnessDistrib.keys.Length > 0)
                  p.m_Stiffness *= m_StiffnessDistrib.Evaluate(a);
                if (m_InertDistrib != null && m_InertDistrib.keys.Length > 0)
                  p.m_Inert *= m_InertDistrib.Evaluate(a);
                if (m_FrictionDistrib != null && m_FrictionDistrib.keys.Length > 0)
                  p.m_Friction *= m_FrictionDistrib.Evaluate(a);
                if (m_RadiusDistrib != null && m_RadiusDistrib.keys.Length > 0)
                  p.m_Radius *= m_RadiusDistrib.Evaluate(a);
            }

            //一个简单的参数合法性约束
            p.m_Damping = Mathf.Clamp01(p.m_Damping);
            p.m_Elasticity = Mathf.Clamp01(p.m_Elasticity);
            p.m_Stiffness = Mathf.Clamp01(p.m_Stiffness);
            p.m_Inert = Mathf.Clamp01(p.m_Inert);
            p.m_Friction = Mathf.Clamp01(p.m_Friction);
            p.m_Radius = Mathf.Max(p.m_Radius, 0);
      }
    }
在【DynamicBone.cs】的Inspector中,不是有许多参数/参数曲线的设置吗?如何实现“根节点到子节点的参数曲线变化”,这里给出了答案。
p.m_Damping = m_Damping; //首先进行基本的参数赋值
float a = p.m_BoneLength / m_BoneTotalLength; //计算质点骨骼长度与总长度的比值
//执行曲线赋值,通过这个【比值】实现根节点到尾结点的曲线参数赋值。
if (m_DampingDistrib != null && m_DampingDistrib.keys.Length > 0)
    p.m_Damping *= m_DampingDistrib.Evaluate(a); //通过骨骼长度比值,来累乘参数。
p.m_Damping = Mathf.Clamp01(p.m_Damping);//最后,进行一次参数合法性约束
至此,在Start()方法中,DB的基本初始化就完毕了。
3. Update()

在Update()中,他是这样写的
    void Update() //帧更新
    {
      if (m_UpdateMode != UpdateMode.AnimatePhysics)
            PreUpdate(); //预备更新
    }
【PreUpdate】是什么呢?
    void PreUpdate()
    {
      //骨骼权重大于0且未实行距离裁切
      if (m_Weight > 0 && !(m_DistantDisable && m_DistantDisabled))
            InitTransforms(); //赋值生成信息(原坐标/原旋转)
    }
    void InitTransforms() //重置骨骼初始坐标信息
    {
      // 这个时刻,质点的transform信息很可能已经改变了,我们重置transform信息,准备手动计算新的坐标
      // 而不是依赖Unity自己的transform结果。
      for (int i = 0; i < m_Particles.Count; ++i)
      {
            Particle p = m_Particles;
            if (p.m_Transform != null)
            {
                p.m_Transform.localPosition = p.m_InitLocalPosition; //设置初始坐标
                p.m_Transform.localRotation = p.m_InitLocalRotation; //设置初始旋转
            }
      }
    }



这段代码,就是负责图中状态(2)的模拟

4. LateUpdate()

    void LateUpdate() //渲染更新
    {
      if (m_Weight > 0 && !(m_DistantDisable && m_DistantDisabled)) //权重、距离裁切检测
      {
            float dt = m_UpdateMode == UpdateMode.UnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime;//获取deltaTime
            UpdateDynamicBones(dt); //更新骨骼,开始物理模拟计算
      }
    }
    //执行骨骼更新(重点)
    void UpdateDynamicBones(float t) //参数: deltaTime
    {
      if (m_Root == null)
            return;

      m_ObjectScale = Mathf.Abs(transform.lossyScale.x); //全局缩放
      m_ObjectMove = transform.position - m_ObjectPrevPosition; //记录移动方向
      m_ObjectPrevPosition = transform.position; //帧位置赋值

      Vector3 force = m_Gravity; //重力
      Vector3 fdir = m_Gravity.normalized; //重力单位方向
      Vector3 rf = m_Root.TransformDirection(m_LocalGravity); //本地重力转到的世界坐标
      //本地重力与全局重力的投影计算。
      Vector3 pf = fdir * Mathf.Max(Vector3.Dot(rf, fdir), 0);        // project current gravity to rest gravity
      //计算实际作用重力(是同向的分量)
      force -= pf;        // remove projected gravity
      //重力分量与恒力的合力计算
      force = (force + m_Force) * m_ObjectScale; //乘缩放系数,得到最终【合力】数据

      int loop = 1; //模拟循环次数
      //统计loop数
      if (m_UpdateRate > 0) //帧率
      {
            float dt = 1.0f / m_UpdateRate; //单帧耗时
            m_Time += t; //物理模拟耗时累计,加上实际的deltaTime
            loop = 0;//重置loop数,根据实际deltaTime与帧速率重统计loop数。

            while (m_Time >= dt) //如果实际deltaTime 大于预计 单帧耗时
            {
                m_Time -= dt; //减去单帧耗时
                if (++loop >= 3) //累计loop数,3次是上限
                {
                  m_Time = 0;
                  break;
                }
            }
      }

      if (loop > 0) //检测loop数(要么1次,要么根据deltaTime与UpdateRate的关系统计的loop数)
      {
            for (int i = 0; i < loop; ++i) //loop循环
            {
                UpdateParticles1(force); //根据合力,使用【模式1】更新质点
                UpdateParticles2(); //使用【模式2】更新质点
                m_ObjectMove = Vector3.zero; //更新完毕,重置move。
            }
      }
      else //loop数为0,跳过物理模拟。
      {
            SkipUpdateParticles();
      }

      ApplyParticlesToTransforms(); //应用模拟结果
    }
4步流程是:

[*]首先计算重力与恒力的合力信息。
[*]根据Time.deltaTime计算Loop次数(3次上限)。
[*]在Loop中,执行模拟计算,其中【UpdateParticles1】计算【惯性运动与受力运动】;【UpdateParticles2】计算【弹性运动与刚性运动】。
[*]【ApplyParticlesToTransforms】应用模拟结果。
【UpdateParticles1】
    //质点更新模式1(惯性运动与受力运动)
    void UpdateParticles1(Vector3 force)
    {
      for (int i = 0; i < m_Particles.Count; ++i) //i=0,包含根节点
      {
            Particle p = m_Particles;
            if (p.m_ParentIndex >= 0)
            {
                // verlet integration
                Vector3 v = p.m_Position - p.m_PrevPosition; //【质点】实际移动方向
                Vector3 rmove = m_ObjectMove * p.m_Inert; //【根节点】移动方向乘以惯性系数。
                p.m_PrevPosition = p.m_Position + rmove; //记录当前帧【质点】坐标= 原位置+根运动 作为【上一帧】信息
                float damping = p.m_Damping; //计算damping
                if (p.m_isCollide) //如果质点碰撞(即质点与碰撞体发生接触)
                {
                  damping += p.m_Friction; //damping需要累加摩擦力
                    if (damping > 1) //参数约束
                        damping = 1;
                  p.m_isCollide = false; //重置碰撞检测
                }
                //实际坐标 = 【质点】运动方向 * (1-damping系数) + 合力方向 + 惯性方向
                p.m_Position += v * (1 - damping) + force + rmove; //【质点】实际位置模拟计算。
            }
            else //
            {
                p.m_PrevPosition = p.m_Position;
                p.m_Position = p.m_Transform.position;
            }
      }
    }
他们实际就是朴实无华的公式的使用:



惯性运动



阻尼运动



受力运动

【UpdateParticles2】
    //质点更新模式2(弹性运动与刚性运动,碰撞计算)
    void UpdateParticles2()
    {
      for (int i = 1; i < m_Particles.Count; ++i) // i = 1,不包含根节点,只计算子节点
      {
            Particle p = m_Particles; //【质点】p
            Particle p0 = m_Particles; //【质点】父节点p0

            float restLen;//父子节点骨骼长度
            if (p.m_Transform != null)
                restLen = (p0.m_Transform.position - p.m_Transform.position).magnitude; //计算骨骼长度
            else //虚拟尾端空节点,直接用 m_EndOffset
                restLen = p0.m_Transform.localToWorldMatrix.MultiplyVector(p.m_EndOffset).magnitude;

            // keep shape //stifness实际计算。
            float stiffness = Mathf.Lerp(1.0f, p.m_Stiffness, m_Weight);
            if (stiffness > 0 || p.m_Elasticity > 0)
            {
                Matrix4x4 m0 = p0.m_Transform.localToWorldMatrix; //m0为父节点的本地到世界矩阵
                m0.SetColumn(3, p0.m_Position); //设置第四列向量为【父节点】【p0】的坐标
                //对第四列列向量设置之后,m0变成了子节点局部坐标到世界坐标的矩阵,这是“正向运动学”公式。
                Vector3 restPos; //回归坐标定义
                if (p.m_Transform != null) //非虚拟尾端
                  //把【子节点】【p】的本地坐标使用父节点仿射矩阵进行转换(结果为子节点的世界坐标)
                  restPos = m0.MultiplyPoint3x4(p.m_Transform.localPosition);
                else //是虚拟尾端
                  restPos = m0.MultiplyPoint3x4(p.m_EndOffset);

                Vector3 d = restPos - p.m_Position; //方向:(子节点世界坐标 - 子节点合力计算坐标)
                p.m_Position += d * p.m_Elasticity; //应用 【子节点】m_Elasticity 系数

                if (stiffness > 0) //刚性运动模拟
                {
                  d = restPos - p.m_Position; //计算方向
                  float len = d.magnitude; //计算实际长度
                  float maxlen = restLen * (1 - stiffness) * 2; //计算理论maxLen
                  if (len > maxlen) //如果长度大于maxLen
                        p.m_Position += d * ((len - maxlen) / len); //执行坐标偏移
                }
            }

            // collide
            if (m_Colliders != null) //骨骼碰撞列表非空
            {
                float particleRadius = p.m_Radius * m_ObjectScale; //计算质点碰撞体大小
                for (int j = 0; j < m_Colliders.Count; ++j)
                {
                  DynamicBoneColliderBase c = m_Colliders; //获取碰撞体
                  if (c != null && c.enabled)                  
                        p.m_isCollide |= c.Collide(ref p.m_Position, particleRadius); //碰撞检测                  
                }
            }

            // keep length
            Vector3 dd = p0.m_Position - p.m_Position; //子节点更新后的骨骼方向
            float leng = dd.magnitude; //更新后的骨骼长度
            if (leng > 0)
                p.m_Position += dd * ((leng - restLen) / leng); //更新坐标,保持骨骼长度一致
      }
    }
他的流程是:

[*]计算骨骼长度,应用【elasticity】【stiffness】同时“KeepShape”
[*]计算碰撞
[*]再“KeepShape”一次。
来,上公式:



弹性运动



刚性运动



维持节点间理想距离

最后,我们的【ApplyParticlesToTransforms】方法
    //模拟计算完毕,应用计算结果(质点坐标-->实际trans)
    void ApplyParticlesToTransforms()
    {
      //子节点遍历
      for (int i = 1; i < m_Particles.Count; ++i)
      {
            Particle p = m_Particles;
            Particle p0 = m_Particles;
            //不处理分叉,做且只做一个子节点处理
            if (p0.m_Transform.childCount <= 1)                // do not modify bone orientation if has more then one child
            {
                Vector3 v; //子节点trans局部坐标
                if (p.m_Transform != null)
                  v = p.m_Transform.localPosition;
                else //虚拟尾端
                  v = p.m_EndOffset;
                Vector3 v2 = p.m_Position - p0.m_Position; //子父方向向量                               
                Quaternion rot = Quaternion.FromToRotation(p0.m_Transform.TransformDirection(v), v2);
                p0.m_Transform.rotation = rot * p0.m_Transform.rotation; //处理父节点旋转。
            }

            if (p.m_Transform != null) //非虚拟尾端。
                p.m_Transform.position = p.m_Position; //应用计算结果
      }
    }
值得注意的是这一句话“if(p0.m_Transform.childCount <=1)”
DB处理且只处理单链骨骼,对于那种一个父节点有分叉骨骼链的情况,是不会处理旋转信息的。
在最后将计算得到的节点变换同步到附属节点前,DynamicBone还会根据各级节点间的相对变换做一次旋转修正,每对父子节点只要父节点仅有一个子节点就都会让父节点旋转至与子节点的相对旋转的初始值相同的状态。


修正节点旋转

二、 总结

这一套流程下来,看见了吗?整体思想非常地平铺直叙,朴实无华,但是有用。



基本流程

好,我们基本已经对DB了如指掌了。下一节,我们将介绍使用JobSystem对DB并行化处理来提高性能。
参考


[*]^1https://assetstore.unity.com/packages/tools/animation/dynamic-bone-16743
[*]^2https://assetstore.unity.com/packages/3d/characters/unity-chan-model-18705
[*]^3https://assetstore.unity.com/packages/tools/physics/magica-cloth-160144
[*]^4https://zhuanlan.zhihu.com/p/49188230

mypro334 发表于 2022-6-14 07:25

大佬 我今天刚在做dynamic bone就看到大佬发的文章了 缘分hh
页: [1]
查看完整版本: DynamicBone(动态骨骼)源码赏析(1)