找回密码
 立即注册
查看: 344|回复: 3

Unity动画TA:FinalIK之FullBodyBipedIK实现算法流水账级 ...

[复制链接]
发表于 2023-3-13 09:30 | 显示全部楼层 |阅读模式
个人的FinalIK FullbodyBipedIK算法实现学习笔记。因为几乎是逐行解释代码,所以对照FinalIK源代码更容易明白笔记在说些啥。如有错别字、名词概念使用错误或者其他技术性错误请勿怪罪。评论或私信提出,我在下一次上线的时候改掉。
大致流程

大致分为编辑器内序列化数据、运行时初始化、帧内的准备阶段、迭代阶段、帧内的结束阶段。
编辑器内阶段

调用层次

主要逻辑在FullBodyBipedIKInspector类内。该类是FullBodyBipedIK组件的自定义编辑器类。
FullBodyBipedIKInspector继承于其父类IKInspector。在IKInspector的OnInspectorGUI()内
调用了关键函数AddInspector(),因此AddInspector()被每次自定义编辑器更新时调用一次。调用之后紧接着应用被修改的序列化属性。
FullBodyBipedIKInspector重写了关键函数AddInspector()。其通过调用AddModifiedInspector(references),应用其references的修改,并返回reference是否被修改。如果被修改了,则重新初始化一次,即调用Initiate()函数。
这里出现的reference,是FullBodyBipedIK组件上一个类型为BipedReferences的reference属性的SerializedProperty。BipedReferences类型定义了FullbodyBipedIK会使用到的全部骨骼结构(rootNode除外),在FullBodyBipedIK的Inspector面板上显示为如下部分。


  编辑器内通过获取其SerializedProperty的方式实现序列化属性的修改。
在initiate()函数中Reference首先会对自己的数据合法性进行检查。检查通过后,进入关键部分。
Initiate()的关键部分是调用IKSolverFullBodyBiped类中的SetToReferences方法。
// Initiate
               script.solver.SetToReferences(script.references,
  script.solver.rootNode);
序列化数据的初始化

SetToReferences()内做了一系列的序列化数据初始化:
1、设置所有的骨骼链(FBIKChain类型)
共5条骨骼链,存储在chain数组中。第0条是spine,骨骼链内只有一个rootNode。1到4分别是左臂、右臂、左腿、右腿,每条骨骼链默认各三个Node。如果各个骨骼链的长度不符合1、3、3、3、3,则IKSolverFullBodyBiped.IsValid()会返回false。
接下来它把四肢的骨骼链设置为了spine的子链。
2、设置所有的effector
设置每个effector所影响的bone,对左手、右手、左脚、右脚这四个effector还要设置其三个planeBone,如左手的planeBone分别设置为左上臂、右上臂和rootNode。按照不共线的三点唯一确定一个平面定理,三个planeBone可以决定一个对effector产生影响的平面。
在FinalIK的实现中,由三根骨骼组成的plane经常被用于驱动特定的旋转或位置。
  举例如,记不共线的三个点位置分别为p1、p2和p3,旋转q,则以p2-p1为forward,以p3-p2为up做LookRotation运算可以确定一个旋转qplane。qplane可以视为“平面空间”的旋转,此时qplane^-1q就是q在平面空间下的局部旋转。
  在运动过程中,三个点的位置分别变换到了p1'、p2'、p3',让q跟随这三个点变化得到相应的q',这三个新点做LookRotation得到了新的旋转qplane',则q'=qplane' qplane^-1q就是被“平面空间”驱动的旋转。
  如果想用这个方法驱动方向,则把旋转q换成向量v。
  接下来可以看到FinalIK对这个算法的具体使用方式。
3、设置所有的childConstraint
对于spine来说,只计算四个子链对rootNode的推拉是不够的,子链之间也要维持特定的位置关系。于是它给四个子链设置了四个约束(FBIKChain.ChildConstraint类型):
左上臂到右大腿的距离不小于正常距离。
右上臂到左大腿的距离不小于正常距离。
左上臂到右上臂的距离等于正常距离。
左大腿到右大腿的距离等于正常距离。
// Child Constraints
               chain[0].childConstraints
  = new
  FBIKChain.ChildConstraint[4] {
                    new
  FBIKChain.ChildConstraint(references.leftUpperArm, references.rightThigh, 0f,
  1f),
                    new
  FBIKChain.ChildConstraint(references.rightUpperArm, references.leftThigh, 0f,
  1f),
                    new
  FBIKChain.ChildConstraint(references.leftUpperArm, references.rightUpperArm),
                    new FBIKChain.ChildConstraint(references.leftThigh,
  references.rightThigh)
                    
               };
这些约束是通过初始化ChildConstraint时传入的pushElasticity(推弹性)和pullElasticity(拉弹性)两个参数确定的。当两个参数都为0时,约束完全没有弹性。pullElasticity为1相当于约束长度大于正常长度时不做任何处理。代码中通过pushElasticity=0,pullElasticity=1来设置左上臂到右大腿这一躯干对角线上的两个Node始终相斥。个人认为这是从美术效果出发做的细节调整,目的是整个躯干在解算时不会瘪下去。


红色为严格约束距离的ChildConstraint,绿色为只向外推的ChildConstraint。
4、设置IKMapping
接下来设置了IKMappingSpine、四个IKMappingLimb和至多一个IKMappingBone,这些是IKMapping的子类。IKMapping的作用是提供骨架结构到基于Node的解算器的内部数据结构的映射,承担桥梁功能。它最重要的功能是把IK解算结果写回骨骼。
在之后运行时每帧需要读取动画Pose的时候,IKMapping中的ReadPose和FBIKChain的ReadPose一同使用才能完整地读取Pose,但是IKMapping的ReadPose和FBIKChain的ReadPose各有侧重。
  FBIKChain读取的是解算Node所需的信息,侧重于确定各Node的位置。而IKMapping中的ReadPose则是侧重于记录WritePose所需的其他信息,如骨骼在它的plane空间下的局部旋转。
这一步之后,编辑器内的数据准备就完成了。
运行时初始化

找到IKSolverFullbodyBiped的父类的父类:IKSolver类。
IKSolver存有一个bool标记firstInitiation,默认为true,在为true时说明还没有初始化。在Update()中判断该标记,如果为true,则初始化,调用Initiate(),接下来将标记置为false。也就是说,在第一次Update时调用一次Initiate。
在Initiate函数中记录了初始化时的函数调用顺序。
OnInitiate();
               StoreDefaultLocalState();
接下来找到IKSolverFullbodyBiped的直接父类IKSolverFullbody,可以看到它如何重写OnInitiate()函数。
首先,调用所有骨骼链的初始化函数;
接着,调用所有effector的初始化函数;
最后,调用所有IKMapping的初始化函数。
protected override void OnInitiate() {
               // Initiate chain
               for (int i = 0; i <
  chain.Length; i++) {
                    chain.Initiate(this);
               }
   
               // Initiate effectors
               foreach (IKEffector e in effectors)
  e.Initiate(this);
               
               // Initiate IK mapping
               spineMapping.Initiate(this);
               foreach (IKMappingBone
  boneMapping in
  boneMappings) boneMapping.Initiate(this);
               foreach (IKMappingLimb
  limbMapping in
  limbMappings) limbMapping.Initiate(this);
           }
骨骼链的初始化

骨骼链中的每个Node在数据序列化的阶段就已经有了对应骨骼的引用。所以首先它对于每个Node直接记录了骨骼的位置,作为Node的SolverPosition。
接下来,调用CalculateBoneLengths(solver);来记录所有的骨骼长度。从第0个Node到倒数第二个Node,每个Node的长度是下一个Node的骨骼到它的Node的骨骼的距离。最后一个Node不做处理。
同时累加起所有骨骼的长度作为整条骨骼链的总长度,记录在骨骼链的length属性中。
如果它检查到了骨骼链中有三根骨骼,则会记录第一根骨骼长度的平方、第二根骨骼长度的平方,前后两根骨骼的平方差。记录这些数据是为了之后 “解三角形”的阶段直接套余弦定理的公式,而不用每次迭代都再算一遍。
这是骨骼链对人形角色肢体的特殊处理。实际上,从之前初始化的时候可以看到FBBIK的骨骼链只有长度为1和长度为3两种情况。如果我们想直接从骨骼链上扩展让它支持四骨骼(如解算四足动物的腿要加上一节Toe)乃至更多骨骼,很难复用这些特殊处理。
接下来初始化骨骼链中的所有ChildConstraint。
ChildConstraint的数据序列化过程中,记录了ChildConstraint中两根骨骼各自所在的骨骼链在solver里的index。所以接下来,就用这两个index来访问solver中对应的骨骼链约束中第0个Node。
首先记录这两个Node之间的距离,作为约束的正常距离。
接下来判断它是不是rigid约束。对于rigid约束,它使用被约束的两根骨骼链的pull属性计算了渐变值(crossFade和inverseCrossFade)。在接下来的约束解算中,渐变值决定了约束后的结果朝哪边偏得更多。如果chain1的pull比较大,则crossFade会小于0.5,inverseCrossFade会大于0.5,则约束后的chain1距离约束前的chain1更近,约束后的chain2距离约束前的chain2更远。这部分具体在ChildConstraint.Solve()函数中。
Effector的初始化

记录它直接影响的骨骼在solver中的chainIndex和在骨骼链中的nodeIndex,存下来,以便之后访问该Node。
如果该effector影响子骨骼,则把所有子骨骼的chainIndex和nodeInedx记录到数组中。在FBBIK的数据序列化过程中,可以看到只有body effector影响两个子骨骼,分别是左大腿和右大腿。
最后如果该effector存在三个planeNode,则设置usePlaneNode = true。这一标记说明之后要每帧计算planeRotationOffset,该属性将用于修改对应肢体的bendDirection。
在数据序列化阶段,左手、右手、左脚、右脚的effector的planeNode分别是躯干上部的三角形和躯干下部的三角形。



在不强制指定肘部弯曲方向的hint时,IK解算导致的身体上部三角形的旋转,会牵动自然状态的肘部弯曲方向,这是一个让IK看起来更符合美术正确的处理。同理,下半部分三角形的旋转也会影响膝关节的弯曲方向。
初始化IKMapping

分为初始化spineMapping、初始化boneMapping、初始化limbMapping三部分。
BoneMap类
BoneMap类是包含单个骨骼到单个Node映射信息的类。例如,里面存储有该bone对应的Node在solver内的所属boneChain的chainIndex,该node在boneChain内部的boneIndex,通过这两个index可以在solver内找到Node。另外BoneMap类存储有部分其他信息,如它的plane定义所需的三个Node的chainIndex和boneIndex,以及WritePose所需的位置旋转相关信息,如DefaultLocalPosition和DefaultLocalRotation等。
初始化spineMapping
IKMappingSpine类里面定义了两个系列的BoneMap。
第一个系列是所有的脊椎骨,从盆骨开始到最后一节脊椎,每个都存在叫spine的BoneMap数组中。
rootNode是躯干上下两个三角形的交汇处的骨骼。
脊椎中除了rootNode以外,其他的BoneMap在solver中没有对应的Node。因此除了rootNode的chainIndex和nodeIndex都是0以外,其他boneMap存储的chainIndex和nodeIndex都是-1。
boneMap中的isNodeBone访问器即据此判断,返回的是nodeIndex!=-1。
于是,唯一isNodeBone的脊椎必是rootNode,此处即以此为判断依据记录rootNode在整个脊椎中的index。
另一个系列的BoneMap是躯干的四个角:大腿、小腿、左上臂、右上臂。这一部分boneMap对应的Node和rootNode对应的Node会参与在基于Node的迭代解算。
相比之下,前一系列spine数组内的BoneMap在IKMappingSpine.WritePose()调用时,根据之前解算出的大腿、小腿、左上臂、右上臂以及rootNode这五个Node位置,进一步计算各个脊椎骨骼的具体位置和旋转,不在IKSolverFullbody基于Node解算的范围内,不再影响四肢,属于IKMappingSpine自身的细节处理。
所有的BoneMap初始化以后,对spine数组内的每个BoneMap调用一次SetIKPosition(),使其ikPosition属性与骨骼位置同步。ikPosition属性会在IKMappingSpine.WritePose()调用时参与脊椎每根骨骼的位置和旋转的计算。
接下来从第0根骨骼(一般是hips)循环到倒数第二根骨骼,计算其骨骼长度(到spine内下一根骨骼的距离)、localSwingAxis和localTwistAxis。
LocalSwingAxis是在骨骼的local空间内,指向spine内下一根骨骼的方向。在IKMappingSpine.WritePose()调用时,遵循这个轴仍然应该指向下一根脊椎所在位置的原则,使用FromToRotation就可以计算出该骨骼的摆动。
LocalTwistAxis与LocalSwingAxis垂直,用来计算摆动之外的扭转。代码中设置为右上臂骨骼位置指向左上臂骨骼位置的向量与LocalSwingAxis垂直的单位化分量(代码中做了一次OrthoNormalize)。到WritePose阶段时,用解算后的右上臂到左上臂的向量,以世界空间下的swingAxis为法线单位正交化,再把LocalTwistAxis转到世界空间,然后同样使用FromToRotation,可以得到世界空间下的扭转。具体实现在IKMapping的Twist函数中。
Spine的第0根骨骼的plane定义为躯干下方的三角形,Spine的最后一根骨骼的plane定义为躯干上方的三角形。在WritePose阶段,这两根脊椎骨骼的旋转完全跟随plane的旋转。
初始化boneMapping
这一部分很简单,因为boneMapping顶多只有一个Head,而且不在任何一个骨骼链内,几乎相当于什么都没做。
初始化limbMapping
IKMappingLimb是为三根骨骼组成的肢体做的特制版Mapping。其中的boneMap没有用数组收纳起来,而是直接定义了boneMap1、boneMap2、boneMap3三个字段。这三个骨骼往往自成一个平面,因此boneMap1和boneMap2的plane都设置为limb自身三根骨骼形成的平面,当然顺序有所不同。前两个骨骼决定了LookRotation的Forward方向,因此boneMap1的plane的forward方向要看向boneMap2,于是顺序为boneMap1、boneMap2、boneMap3,boneMap2的plane的forward方向要看向boneMap3,于是顺序为boneMap2、boneMap3、boneMap1。至于boneMap3,就是肢体末端的手或者脚,其旋转不需要看向子骨骼,因此不需要设置plane。
左臂和右臂的parentBone在数据序列化阶段分别被设置为左锁骨和右锁骨。在WritePose阶段,会对锁骨做一些调整,如旋转以保持相同方向对准上臂Node的solverPosition。左大腿和右大腿的parentBone被设置为null。
StoreDefaultLocalState

IKSolverFullBody中重写了父类的StoreDefaultLocalState()。这次不涉及effector和骨骼链,仅调用了spineMapping、limbMapping和boneMapping的同名方法。这个方法用于被恢复写入姿势轻微修改的部分。如,盆骨层级以下骨骼的localPosition在大部分情况下是不会变化的,但是FBBIK在WritePose阶段为了迁就解算结果给骨骼重新写入了position。虽然一帧的修改很小以至于看不出来,但是一些角色动画去掉了骨骼的position曲线,动画不直接控制骨骼的位置,因此很多帧的位置误差累加起来以后可能会造成严重的变形。因此需要存储默认状态下的localPosition和localRotation,以便在下次动画更新之前从这里覆盖上一帧的修改。
BoneMap. StoreDefaultLocalState()做的就是以上工作,即记录单个骨骼的localPosition和localRotation。
spineMapping调用了所有的脊椎骨骼的StoreDefaultLocalState()。
limbMapping调用了三根骨骼的StoreDefaultLocalState(),在parentBone不为空的情况下也调用parentBone的StoreDefaultLocalState()。
boneMappings一般来说整个数组里只有一个head,或者什么都没有。在有head的情况下,调用这唯一的boneMap的StoreDefaultLocalState()。
存储下来的localPosition和localRotation被名为FixTransform()的方法层层调用,最后在FullBodyBipedIK的父类的父类SolverManager中可以找到调用的方式。
void FixedUpdate() {
               if
  (skipSolverUpdate) {
                    skipSolverUpdate
  = false;
               }
   
               updateFrame
  = true;
   
               if (animatePhysics
  && fixTransforms) FixTransforms();
           }

void Update() {
               if
  (skipSolverUpdate) return;
               if (animatePhysics) return;
   
               if (fixTransforms)
  FixTransforms();
           }
这两处调用巧妙地保证了,无论动画在Update循环中更新还是在FixedUpdate循环中更新,FixTransform做的修改都可以被动画覆盖掉,如果动画师想通过修改骨骼的localPosition做一个路飞一样的手臂拉伸效果,FixTransform的调用不会吞掉这个效果。但如果动画没有控制一些不需要被修改的属性(如动画片段去掉了大部分骨骼的position曲线的情况),那么它在FixTransform的作用下会持续保持正常的值。
一般来说,我们可能希望最好不要有FixTransform这样的函数存在,一些想把FinalIK改成纯Animation Job实现的大佬可能也会发现这个FixTransfrom放在哪都不合适。在理想情况下,如果在解算IK之后,不写入除了盆骨以外的骨骼的position,所有被IK修改的rotation和盆骨的position都在下一帧被动画覆盖掉,这样FixTransform就可以被省掉了。在动画资源规范的情况下(应该可以满足大部分情况)的大多数情况下,这个条件可以满足。但是作为考虑通用性的插件,FinalIK不得不考虑一些特殊情况,如关闭动画更新的时候,让IK仍然正确稳定工作,而不至于逐帧累积误差。做了这个看起来有点不够优雅的处理,也是在情理之中。
一帧之内的准备阶段

上面的FixTransform可以看作一帧最开始的处理。它调用的时机随Animator组件的Update Mode变化,而IK解算的主要部分都是在LateUpdate完成的,所以不一定FixTransform调用一次接着就会IK解算一次,因为它们可能跑在不同的循环里。
在IKSolverFullBody类中找到关键函数OnUpdate()。虽然它叫“OnUpdate”,但它确实是LateUpdate才被调用的。
protected override void OnUpdate() {
               if (IKPositionWeight
  <= 0) {
                    // clear effector positionOffsets so they would not
  accumulate
                    for (int i = 0; i <
  effectors.Length; i++) effectors.positionOffset = Vector3.zero;
   
                    return;
               }
   
               if (chain.Length ==
  0) return;
   
               IKPositionWeight
  = Mathf.Clamp(IKPositionWeight, 0f, 1f);
   
               if (OnPreRead != null) OnPreRead();
   
               // Phase 1: Read the pose of the biped
               ReadPose();
   
               if (OnPreSolve != null) OnPreSolve();
   
               // Phase 2: Solve IK
               Solve();
   
               if (OnPostSolve != null) OnPostSolve();
   
               // Phase 3: Map biped to its solved state
               WritePose();
   
               // Reset effector position offsets to Vector3.zero
               for (int i = 0; i <
  effectors.Length; i++) effectors.OnPostWrite();
           }
最最开始到ReadPose(),都属于一帧之内的准备阶段,这个阶段只用来准备数据,尚未开始解算。Solve()函数属于迭代阶段,是产生四肢与躯干互相牵拉效果的关键阶段。WritePose()属于一帧内的结束阶段,基于Node的解算已经完成,要把这些解算结果以某种方式合理地写入骨骼。
接下来介绍准备阶段。IKPositionWeight是IKSolverFullBody的主位置权重,它可以从外部直接被设置。
首先,当这个位置权重小于等于0时,清除所有effector的positionOffset。每个effector的positionOffset可以在该帧提供一个基于目前effector位置的偏移,不累积到下一帧,在只想给角色的姿势做一些偏移效果的时候会比较有用。
在想模拟角色轻微受击肢体稍微颤动的情况下,每帧设置effector的positionOffset比每帧设置effector的position更加清晰易懂(虽说也没省多少事)。
  在使用FinalIK的FullBodyBipedIK的时候,如果我们想修改effector的任何信息,推荐的做法是让自己的组件继承OffsetModifier,重写它的OnModifyOffset()函数,在该函数中对effector进行修改。OffsetModifier会把调用OnModifyOffset()函数的ModifyOffset()函数加入solver的OnPreUpdate委托。该委托在每帧调用OnUpdate()之前被调用。
  这样也就是说,我们在OnUpdate()之前修改effector,在OnUpdate()的开头部分,首先就把一些肯定不影响结果的修改给清除了。
如果solver中没有任何骨骼链,说明它不需要做任何解算,于是return。
把IKPositionWeight到限制到0和1之间。觉得此处它更应该用Mathf.Clamp01(float)。
OnPreRead委托,在不另加FBBIKHeadEffector组件的情况下,不会做什么工作。
ReadPose()函数准备阶段的关键函数。它读取骨骼的动画姿势以备接下来的解算之用。
读取人形的姿势

在IKSolverFullBody.ReadPose()函数中,可以看到它首先对所有的effector做了处理,然后调用所有骨骼链的ReadPose(),最后调用所有IKMapping的ReadPose()。
protected virtual void ReadPose() {
               // Making sure the limbs are not inverted
               for (int i = 0; i <
  chain.Length; i++) {
                    if
  (chain.bendConstraint.initiated)
  chain.bendConstraint.LimitBend(IKPositionWeight,
  GetEffector(chain.nodes[2].transform).positionWeight);
               }
   
               // Presolve effectors, apply effector offset to the
  nodes
               for (int i = 0; i <
  effectors.Length; i++) effectors.ResetOffset(this);
               for (int i = 0; i <
  effectors.Length; i++) effectors.OnPreSolve(this);
   
               // Set solver positions to match the current bone
  positions of the biped
               for (int i = 0; i <
  chain.Length; i++) {
                    chain.ReadPose(this, iterations >
  0);
               }
   
               // IKMapping
               if (iterations >
  0) {
                    spineMapping.ReadPose();
                    for (int i = 0; i <
  boneMappings.Length; i++) boneMappings.ReadPose();
               }
   
               for (int i = 0; i <
  limbMappings.Length; i++) limbMappings.ReadPose();
           }
限制关节的弯曲角度在离默认90°弯曲的最多90°范围内

在每个骨骼链中都有bendConstraint这样一个成员,是用于修正三骨骼组成的骨骼链的弯曲方向的IKBendConstraint类型,但它在骨骼数量不为3的时候不会被初始化。
IKBendConstraint.LimitBend的作用是,在肢体“反关节”的时候把它掰到正确的范围内。人形生物的肢体大致可以看成顶多拉直,而不能让小腿往膝盖前翻。所以此处的限制大致是把小腿或者小臂限制到默认的90°弯曲的肢体姿势的不超过90°旋转的范围内。
肢体在90°弯曲时的最舒服的弯曲方向,在bendConstraint初始化的时候存在defaultLocalDirection属性中。找到IKBendConstraint.Initiate(IKSolverFullBody solver)函数,可以详细回顾在运行初始化的时候如何计算这些方向。
direction
  = OrthoToBone1(solver, OrthoToLimb(solver,
  bone2.position - bone1.position));
   
              if (!limbOrientationsSet) {
                    // Default bend direction relative to the first node
                    defaultLocalDirection
  = Quaternion.Inverse(bone1.rotation) * direction;
   
                    // Default plane normal
                    Vector3
  defaultNormal = Vector3.Cross((bone3.position - bone1.position).normalized,
  direction);
                    
                    // Default plane normal relative to the third node
                    defaultChildDirection
  = Quaternion.Inverse(bone3.rotation) * defaultNormal;
               }
计算direction的过程中进行了两次单位正交化,具体来说是调用了两次Vector3.OrthoNormalize(ref Vector3 normal, ref Vector3 tangent)函数。
OrthoToLimb(solver, bone2.position - bone1.position)语句,在三个骨骼的位置不共线的情况下,返回bone2.position-bone1.position在以bone3.position-bone1.position为基准的标准正交化之后的向量。在图中,两个蓝色箭头分别代表两个输入的向量,相当于返回的是红色向量的单位化向量。


外层OrthoToBone1把红色向量作为输入,让它与bone2.position-bone1.position垂直。在下图中,相当于返回了绿色的向量。



绿色向量单位化以后就是该处所求direction,是和bone2.position-bone1.positon垂直的向量,看上去从肘部指向外,或从膝盖指向前方。该向量取反,就是前臂或者小腿在弯曲90°时指向的方向。
defaultLocalDirection是direction转换到bone1的局部空间下的方向。
私下里觉得这里两次OrthoNormalize可以简化成一次。也就是写成:
  direction = OrthoToBone1(solver,
  bone1.position - bone3.position);
  经过实验这个写法没有问题。
  Unity的Vector3.OrthoNormalize(ref
  Vector3 normal, ref Vector3 tangent)在输入的tangent和normal平行的情况下,也会生成一个尽量合理的tangent,而不会让tangent返回包含NaN的向量。所以在初始化的时候,即使三根骨骼共线,它仍然可以返回一个direction。但这个direction不一定合理。实际上在编辑器中,在肢体完全伸直的情况下初始化被序列化数据时,Inspector会出现“不知道朝哪边弯曲”的警告。
接下来调用了约束方向的关键函数,其定义为V3Tools.ClampDirection(Vector3 direction, Vector3 normalDirection, float clampWeight, int clampSmoothing, out bool changed)。
其中,direction是准备被clamp的方向,normalDirection是clamp的基准方向,clampWeight描述了允许direction的方向和normalDirection之间的最大角度。如clampWeight为1,则direction必须和normalDirection平行。clampWeight为0,则不会改变direction。clamp为0.5表示最多direction最多允许和normalIDirection的方向有90°的夹角。clampSmoothing是“平滑的迭代次数”,具体是什么意思只能靠分析代码理解。Changed是表示direction是否被clamp修改过的标记。
接下来解释V3Tools.ClampDirection的具体实现。
public static Vector3
  ClampDirection(Vector3 direction, Vector3 normalDirection, float clampWeight, int clampSmoothing, out bool changed) {
               changed
  = false;
   
               if (clampWeight
  <= 0) return
  direction;
   
               if (clampWeight
  >= 1f) {
                    changed
  = true;
                    return normalDirection;
               }
               
               // Getting the angle between direction and
  normalDirection
               float angle =
  Vector3.Angle(normalDirection, direction);
               float dot = 1f - (angle
  / 180f);
   
               if (dot >
  clampWeight) return
  direction;
               changed
  = true;
               
               // Clamping the target
               float targetClampMlp =
  clampWeight > 0? Mathf.Clamp(1f - ((clampWeight - dot) / (1f - dot)), 0f,
  1f): 1f;
               
               // Calculating the clamp multiplier
               float clampMlp =
  clampWeight > 0? Mathf.Clamp(dot / clampWeight, 0f, 1f): 1f;
               
               // Sine smoothing iterations
               for (int i = 0; i <
  clampSmoothing; i++) {
                    float sinF = clampMlp *
  Mathf.PI * 0.5f;
                    clampMlp
  = Mathf.Sin(sinF);
               }
               
               // Slerping the direction (don't use Lerp here, it
  breaks it)
               return
  Vector3.Slerp(normalDirection, direction, clampMlp * targetClampMlp);
           }
如果clampWeight为0,不做任何clamp,直接返回direction。
如果clampWeight为1,任何方向都会被clamp到normalDirection,因此直接返回normalDirection。
clampWeight在0和1之间的话,就是要认真计算clamp的情况。此时它先计算了normalDirection到direction的角度。在一个需要被clamp为大约90°的示意图中表示如下。



接下来出现的dot并非点积,而是下图中灰色角与黑色角的比值。



与之相似,clampWeight就可以解释为下图浅灰色角和黑色角的比值。



显然,在dot大于clampWeight的时候,direction在安全范围内,不需要被clamp。反之则需要被clamp。确定了需要被clamp之后,出现了一个看起来很长的计算Mathf.Clamp(1f - ((clampWeight - dot) / (1f - dot)), 0f, 1f)。实际上化简一下它可以写成Mathf.Clamp01((1f-clampWeight)/(1f-dot))。也就是下图灰角和黑角的比值。



计算后赋值给targetClampMlp。这个比值已经可以表示direction到normalDirection的角度所需的缩放比例。如果没有平滑迭代处理,其实可以直接做向量的球面插值了。
但是接下来为了计算平滑,又计算了一个clampMlp。迭代之前的clampMlp是下图灰角和黑角的比值。



这个数值随着被clamp的angle增加而减小。
设想如果direction恰好为-normalDirection,我们没法确定一个稳定的clamp后的方向,在这个情况下clampMlp为0,乘在targetClampMlp上以后,最后返回的就是normalDirection。否则direction在-normalDirection附近时,一个很小的扰动就会造成clamp结果的巨大变化。
接下来是“正弦平滑迭代“,这部分在IKConstraintBend里面没有用到,因为传入的迭代次数为0。
但是看代码就是把不断计算y = sin(πx/2)然后把计算结果赋值给x。
迭代2次以后,clampMlp如果本来接近于1,那么它会更加接近1。这样在direction离clamp角度相对更近的时候,迭代次数越多它就越接近原始的直接clamp。而direction离clmap角度更远的时候,也就是更接近-normalDirection的时候,clampMlp可以保持为0,保持了稳定性。直观理解的化可以参考下图。绿色直线为迭代0次的clampMlp输出函数图像,蓝色曲线为迭代1次的clampMlp输出函数图像,红色曲线为迭代2次的clampMlp输出函数图像。
如果不使用clampMlp,相当于最终输出的clampMlp始终为1,可以想象一个y=1的水平直线。



所以这是一个牺牲准确性保稳定性的处理方式。
最后一步求向量之间的球面插值。球面插值可以保证插值后的向量之间的角度符合传入的比值t,而线性插值不保证角度均匀。
以上是ClampDirection的实现。在solverWeight为1的时候,传入的clampWeight为clampF * solverWeight为0.505,相当于最大clamp到89.9°,比90°小了那么肉眼无法辨别的一点点。我认为这是出于不想让肢体完全伸直考虑,因为如果肢体完全伸直,则无法从骨骼的位置判断弯曲方向。
ClampDirection结束之后,做一次FromToRotation,乘到上臂或者小腿上,就完成了这次防止膝盖外翻或者肘外翻的处理。
接下来,逻辑里又接上了另一套处理,考虑了配置的时候手臂或者腿完全伸直,导致normalDirection并不是正常情况下的肢体弯曲90°所在姿势的情况:把肢体强行到符合normalDirection的方向。如果配置时肢体存在正确的弯曲,这一步其实没做什么处理。
最后保持手或者脚的rotation在世界空间下不变。
以上是LimitBend函数做的工作。
设置offset以及effector的其他预处理

回到IKSolverFullBody.ReadPose()。
接下来是effector两个方法调用。
首先ResetOffset()函数对于每个effector,清零它们在solver里对应的Node的offset。此处没有清零effector自己的offset,毕竟刚刚设置过还没使用。如果effector影响子骨骼链,则带着子骨骼链的所有Node一起清零offset。
OnPreSolve()方法从前到后做了几个工作:
1、 计算每个Node受Effector影响的实际权重。方法是把每个effector自己的positionWeight和rotationWeight乘上“master weight“,也就是solver的IKPositionWeight。
2、 把effector的offset乘上masterWeight后累加到Node。如果effector影响了子Node,则也把offset加到子Node的offset上。这一步处理的效果是如果某个Node不仅是某effector的子Node,还有自己的effector,则offset最后会是所有影响到它的offset的和。在FBBIK的实现中,就是左大腿和右大腿的offset会收到bodyEffector的影响,同时也受其本身effector的影响。
值得注意的是,offset在Effector的positionWeight为0时有最大的影响,positionWeight为1且solver.IKPositionWeight为1时效果会被effector自身的position全部覆盖(之后设置position的时候会有覆盖)。Effector的positionWeight为0时,offset效果仅受solver.IKPositionWeight影响。这方便了使用者在原动画的基础上通过设置一次offset来叠加偏移。
3、 更新planeNode。之前有过叙述,三个planeNode主要使用LookRotation来确定一个旋转,此处就根据三个骨骼的还没被处理过的位置,即动画位置,来更新animatedPlaneRotation。解算之后,这个rotation会和解算后的三个Node确定的另一个旋转一起参与计算对四肢弯曲方向的修正。
4、 设置firstUpdate标记。此处容易被误导,因为这个firstUpdate在FBBIK实现中并不用来标记“仅在开始运行时第一个Update执行一次”,而是每帧在ReadPose的阶段都会被重置为true的标记,下一帧的第一个Update又是一个“首次Update”。在Solve()函数内,一个effector.Update()可能会被调用十几次,具体次数取决于迭代次数,其中的第一个Update()被用来执行firstUpdate为true时的逻辑。IKEffector类在firstUpdate为true时,它的Update会更新被它影响的骨骼的动画位置,并设置firstUpdate为false。
所以这个firstUpdate叫“firstUpdateInOneFrame”更加合适。
骨骼链读取姿势

接下来,solver中所有的骨骼链都执行一次ReadPose()。
这个骨骼链中首先设置了所有Node的solverPosition,在此处,上一步设置过的offset被派上用场。所有node的solverPosition被设置为骨骼的位置加上offset。
for (int i = 0; i <
  nodes.Length; i++) {
                    nodes.solverPosition
  = nodes.transform.position + nodes.offset;
               }
在offset不为0的情况下,这些初始的solverPosition可能不满足正常的人体结构(比如单独给手部effector加了一万米的offset,则手部Node的solverPosition在原本身体位置的十公里之外),要依赖接下来的若干次迭代把这些SolverPosition拉回相互关系合理的位置。
接下来计算所有的骨骼长度。CalculateBoneLengths(solver);这一语句在初始化的时候就执行过一次,在骨骼链的初始化中已经分析过它的实现。每帧重新更新骨骼的长度即考虑了动画对骨骼长度的修改。一般情况下,人形动画不会改变骨骼长度(root到hips除外),但如果美术想通过骨骼长度变化来做出《猫和老鼠》动画一样的夸张效果,多处理一步是有意义的。
在需要解算fullBody时(迭代次数为0视为不解算fullBody,四肢和躯干之间没有互相牵拉效果),以下部分会在ReadPose时被执行。
childConstraint.OnPreSolve()也在初始化的时候被调用过一次。在骨骼链的初始化中已经分析过它的实现。此处调用以每帧更新更新childConstraint的正常距离以及渐变值。
接下来的部分用于计算骨骼链和所有子骨骼链之间的约束。对于FBBIK来说,四肢的骨骼链是躯干骨骼链的子链,则四肢骨骼链的第一个Node(两个上臂和两个大腿)到rootNode在solver中对应的Node之间的距离应该维持稳定,在骨骼链中这个距离的概念是“骨骼链的根的长度”,属性名为rootLength。如果距离存在偏差,在迭代过程中会让每个子链的第0个Node和父链的最后一个Node做一次距离约束,每个子链根据它的pull值对父链的最后一个Node产生不同程度的牵拉效果(在FBBIK中,就是四肢会牵拉脊椎骨骼链的唯一一个Node)。
这就是接下来计算crossFades数组的作用,crossFades数组之和不超过1。子链的crossFade值越大,解算约束后的新位置就越偏向于子链的第0个节点的位置,否则就更偏向于父链的最后一个节点的位置。类比到PBD解算的话,crossFade的作用类似于各个子链第0个Node的质量。具体解算在讲到迭代中的Stage2函数时会介绍。
(这是对五个Node的Gauss Seidel的解算)



这四个父到子链的约束和四个子链之间的约束在迭代中保证了躯干不会明显变形。
接下来计算了pullParentSum这一属性,该属性在之后的迭代中可用来计算所有子链对父链的最后一个节点的平均拖拽。每次迭代都把这个平均拖拽累加到父链的最后一个节点。具体解算在讲到迭代中的Stage1函数时会介绍。
对于三骨骼的骨骼链计算reachForce。这个属性会在当前姿势的基础上尝试把四肢朝外拽。大概是微调美术效果用的。四肢默认reach只有0.1,把它调成0也没有很影响效果。
最后计算distance,在骨骼链多于1根骨骼的时候,计算从第0个Node的骨骼到最后一个Node的骨骼的距离,如左大腿到左脚的动画距离。
IKMapping读取姿势

骨骼链的读取姿势只涉及骨骼的位置相关信息,这些信息不足以把解算后的Node位置直接换算为每根骨骼的旋转并写入骨骼,因此IKMapping也有需要有自己的ReadPose()函数。
脊椎映射(spineMapping)读取姿势
// IKMapping
               if (iterations >
  0) {
                    spineMapping.ReadPose();
                    for (int i = 0; i < boneMappings.Length;
  i++) boneMappings.ReadPose();
               }
在迭代次数为0的情况下,四肢不会与躯干产生互相牵拉的效果,脊椎姿势不改变,无需读取姿势。否则执行ReadPose()。
对脊椎的第0根骨骼(一般是盆骨)的BoneMap执行UpdatePlane(),是更新骨骼和它周围的三根骨骼的位置和旋转关系。之前提到了通过三根骨骼确定一个旋转的方式,在IKMapping中有一个lastAnimatedTargetRotation的访问器对此实现得尤其清晰:
/*
                * Rotation of plane nodes in the animation
                * */
               private Quaternion
  lastAnimatedTargetRotation {
                    get {
                        if
  (planeBone1.position == planeBone3.position) return Quaternion.identity;
                        return Quaternion.LookRotation(planeBone2.position
  - planeBone1.position, planeBone3.position - planeBone1.position);
                    }
               }
核心仍然是LookRotation,这样确定的旋转视为平面的旋转。对于盆骨来说,它的平面是躯干下部三角形(左大腿、右大腿、rootNode)确定的平面。UpdatePlane()把这个旋转转换到骨骼的local空间下,记录为defaultLocalTargetRotation。
planePosition实际上转换了从该骨骼到三角形的第一根骨骼一个方向到“平面空间”下,这个转换只需要知道品“平面空间”的旋转,而这个旋转就是被记录下来的lastAnimatedTargetRotation。
对脊椎的最后一根骨骼的boneMap也执行了UpdatePlane()。这根骨骼一般是胸骨,或者叫spine2或者spine3。它的平面是躯干上部三角形(左上臂、右上臂、rootNode)确定的平面。
在第0根骨骼和最后一根骨骼的UpdatePlane()之间,对从第0根骨骼到倒数第二根骨骼进行了其他属性的设置。设置骨骼长度为该骨骼到下一根骨骼的距离,设置localSwingAxis为该骨骼到下一根骨骼的方向转换到该骨骼的local空间下,设置localTwistAxis为左上臂到右上臂的方向正交于世界空间的swingAxis的分量归一化(用了OrthoNormalize)并转换到该骨骼的local空间下。这些属性将在WritePose()调用时发挥作用。
对最后一根脊椎骨,swingAxis使用了左前臂指向右前臂的向量方向。
实际上spineMapping在初始化时就做了一样的步骤,可结合前面讲的初始化spineMapping理解此处的处理,相关部分代码只有初始化时的SetPlane变成了ReadPose时的UpdatePlane的区别,而SetPlane函数内部实现就是在设置plane相关的所有引用之后,调用一次UpdatePlane。
骨骼映射(boneMapping)读取姿势
代码里右一次用了一个循环遍历所有的IKMappingBone。根据前面的数据序列化阶段我们知道这个循环顶多遍历一个元素,这个元素是head。它的ReadPose()实现也很简单,就是记录该骨骼在世界空间下的旋转。
肢体映射(limbMapping)读取姿势
即使迭代次数为0,四肢仍然可以进行它自己的IK解算,具体做法是在迭代结束之后,对每个肢体分别进行了一次解三角形,相当于身体上搭了四个TwoBoneIK。所以它在任何时候都要写回姿势,也因此不管iteration是几,它都要调用ReadPose()。
它的内部实现是,对bone1和bone2进行UpdatePlane。UpdatePlane方法在前面的脊椎映射(spineMapping)读取姿势有详细讲解。此处bone1和bone2的三根平面骨骼就是肢体自己的bone1、bone2和bone3。其顺序略有不同,bone1的三个平面骨骼的顺序是bone1、bone2、bone3,平面旋转是bone1看向bone2的旋转。bone2的三个平面骨骼顺序是bone2、bone3、bone1,平面旋转是bone2看向bone3的旋转。这部分在之前的初始化limbMapping中也有提及。
把权重clamp到0到1的范围内。作者像之前一样坚持使用了Mathf.Clamp(weight, 0 ,1)的写法。我觉得插件作者在写插件的时候不知道有Mathf.Clamp01这个函数。
对于boneMap3,也就是末端骨骼的BoneMap,记录骨骼在世界空间下的旋转。
到这里,一帧内的准备阶段就结束了。
一帧之内的迭代阶段

回到IKSolverFullBody.OnUpdate(),能看到在一个OnPreSolve()回调之后,下一步执行的函数是Solve(),这个函数是整个解算过程的核心函数。
protected virtual void Solve() {
               // Iterate solver
               if(iterations >
  0) {
                    for (int i = 0; i <
  (FABRIKPass? iterations: 1); i++) {
                        if (OnPreIteration
  != null)
  OnPreIteration(i);
                        
                        // Apply end-effectors
                        for (int e = 0; e <
  effectors.Length; e++) if (effectors[e].isEndEffector) effectors[e].Update(this);
                    
                        if (FABRIKPass) {
                            // Reaching
                            chain[0].Push(this);
   
                            // Reaching
                            if (FABRIKPass)
  chain[0].Reach(this);
                        
                            // Apply non end-effectors
                            for (int e = 0; e <
  effectors.Length; e++) if (!effectors[e].isEndEffector) effectors[e].Update(this);
                        }
   
                        // Trigonometric pass to release push tension from the
  solver
                        chain[0].SolveTrigonometric(this);
   
                        if (FABRIKPass) {
                            // Solving FABRIK forward
                            chain[0].Stage1(this);
   
                            // Apply non end-effectors again
                            for (int e = 0; e <
  effectors.Length; e++) if (!effectors[e].isEndEffector) effectors[e].Update(this);
   
                            // Solving FABRIK backwards
                            chain[0].Stage2(this, chain[0].nodes[0].solverPosition);
                        }
   
                        if (OnPostIteration
  != null)
  OnPostIteration(i);
                    }
               }
   
               // Before applying bend constraints (last chance to
  modify the bend direction)
               if (OnPreBend != null) OnPreBend();
   
               // Final end-effector pass
               for (int i = 0; i <
  effectors.Length; i++) if (effectors.isEndEffector) effectors.Update(this);
   
               ApplyBendConstraints();
           }
FBRIKPass和iteration

首先有一个有关FABRIKPass属性的判断。
在iteration>0时,如果FABRIKPass为false,计算四肢和躯干互相牵引的部分不会被执行。这个属性的注释就是“If false, will not solve a FABRIK pass and the arms/legs will not be able to pull the body.”我们可能会注意到,把iteration设置为0的话,效果也符合该注释的描述。
实际上iteration设置为0和把FABRIKPass设置为false存在多个不同之处。
首先,iteration为0在ReadPose()和WritePose()阶段会走一套简单得多的逻辑。spineMapping直接不进行ReadPose(),也不进行WritePose(),骨骼链的ReadPose()不计算任何chindConstraint和四肢对躯干的推拉相关的数据,反正也肯定用不到。四肢在进行WritePose()时不执行解算对四肢的parentBone和bone1的位置修改。
但是仅FABRIK设置为false而不把iteration设置为0的情况下,所有的ReadPose()和WritePose()走原本的逻辑,所以对身体相关的effector的修改会影响躯干,对leftShoulderEffector和rightShoulderEffector的修改会影响左上臂、右上臂的位置以及它们的父骨骼(一般是左锁骨和右锁骨)的旋转。但是又因为骨骼链之间的约束没有被解算,这些effector很有可能在一些离谱的位置上,所以最后可能会出现一些被严重扭曲的躯干形态。如果此时在leftShouderEffector上设置一个长度为1方向总体向外的offset,会发现左臂直接飞到离左肩位置约1米远处,躯干也朝左做了一点弯曲,但是不足以弥补左臂飞出去的距离。飞出去的左臂是IKMappingLimb.WritePose()做的处理,它写入了左臂的位置。躯干的弯曲是IKMappingSpine.WritePose()做的处理,它把左上臂、右上臂的Node位置作为输入做了自己的FABRIK。这个效果大概率不比把iteration设置为0美观。
其次,iteration设置为0的时候,则FABRIKPass不论为何值都不起效果。仅把FABRIKPass设置为false,会各执行了一次OnPreIteration和OnPostIteration回调。对于FBBIKHeadEffector这种使用了这些回调的“FullBobyBipedIK的配套外挂组件”来说,获得了一次迭代函数的执行机会。
最后再看Solve()函数内部,除了回调以外,仅把FABRIK设置为false,比iteration设置为0会另外对每个endEffector多执行一次IKEffector.Update(),并对所有骨骼链进行一次解三角形。
它们的功能会在接下来叙述。
进入一次迭代

OnPreIteration是每次迭代开始时的回调。在只使用FullBodyBipedIK组件而不外挂其他组件的时候不执行任何内容。
末端effector的Update

接下来对于所有的EndEffector执行Update。isEndEffector这一属性是在初始化的时候设置的bool属性,判断方式为是否使用了初始化的时候是否有planeBone,具体实现见IKEffecotor.Initiate(IKSolverFullbody solver)。这和在初始化所有Effector的时候,只有两只手和两只脚的Effector设置了planeBone有关系。
Update内部首先判断firstUpdate标记,如果是“第一个Update”,会更新animatedPosition为骨骼位置加上offset,并设置firstUpdate标记为false。这个firstUpdate标记就是上面提到的ReadPose()阶段会每帧重置为true的标记。
接下来一行是修改Solver中Effector对应的Node的Position的部分。
solver.GetNode(chainIndex,
  nodeIndex).solverPosition = Vector3.Lerp(GetPosition(solver, out
  planeRotationOffset), position, posW);
在骨骼链执行ReadPose的时候已经初步设置了solverPosition,骨骼链读取姿势的部分对此有叙述。这一步根据effector的positionWeight决定在多大程度上覆盖上一次设置的solverPosition。在posW为1,也就是effector自身的positionWeight 乘上solver的IKPositionWeight为1时,effector所影响的Node的位置被完全替换成effector的位置。否则会和原本的位置(或者被稍微修改过的原位置)进行插值。
GetPosition(solver, out planeRotationOffset)在大多数情况下会返回solver中原本的solverPosition,毕竟非末端的effector在九个中占了五个。末端effector在maintainRelativePositionWeight = 0的情况下返回animatedPosition,而animatedPosition正是它所影响的骨骼position加上node中被设置的offset,在实质上与Node中上一次设置的solverPosition相一致。
但是有的时候我们会手动修改maintainRelativePositionWeight属性。该属性可以起到的作用是,不管是通过offset还是通过直接设置position,你移动了躯干的一些Node的位置,某肢体的endEffector的maintainRelativePositionWeight属性为1,同时肢体的endEffector的positionWeight为0,则躯干会像FK一样驱动肢体最后一根骨骼跟随躯干运动。否则肢体末端还是会找世界空间下原动画的肢体末端位置,那样看上去躯干的运动没有影响肢体末端的运动。如果我们想用大腿的effector的offset做一个角色被击打的反应,同时保证脚基受地面摩擦的影响没有被击打撼动,则maintainRelativePositionWeight应保持为0。但是如果想通过设置上臂effector的offset模拟击打胸部去影响手的运动,则手部effector的maintainRelativePositionWeight应设置为1。
GetPosition(solver, out planeRotationOffset)的后面一大部分就是在做这个处理。可以看到它的实现原理是,先获取了原动画姿势中影响的bone的位置与第一个planeBone的位置的差,作为一个待旋转的向量。
注意,planeBone是骨骼Transform,而planeNode是solver内的Node。
然后获取了solver内影响该effector的三个planeNode形成的旋转。这些planeNode的位置有可能刚被各种offset设置和position设置修改过,所以这个旋转和对应的三个planeBone的位置形成的旋转可能有所不同。
effectors数组的最后四个是末端effector,在执行到他们的时候,所有planeNode的solverPosition都在前面已经被Update,所以maintainRelativePositionWeight不会出现有些planeNode还没有更新的情况,也不会在这一阶段出现一个末端肢体的maintainRelativePositionWeight会影响其他肢体的情况。
接下来做planeRotationOffset,这是一个可以把方向变换到三个planeBone形成的空间下,然后planeBone的空间通过旋转成为planeNode的空间,再将这个方向来转回世界空间的旋转。
换言之,在三个planeNode组成的空间中也要有一个合适的位置,这个位置与planeNode形成的空间的关系,与末端骨骼动画位置在三个planeBone空间下关系是一致的,这个位置就是:
p = solver.GetNode(plane1ChainIndex,
  plane1NodeIndex).solverPosition + planeRotationOffset * dir;
p+offset,就是完全被solver内的planeNode形成的平面空间锁驱动的solverPosition的修改后位置。如果不是完全驱动,就按照maintainRelativePositionWeight,和修改前的位置做个线性插值。
planeRotationOffset也在和Quaternion.identity做线性插值以后赋值给planeRotationOffset,函数内这个旋转是out修饰的参数,所以这个值将会被传出。
// Interpolate the rotation offset
               planeRotationOffset
  = Quaternion.Lerp(Quaternion.identity, planeRotationOffset,
  maintainRelativePositionWeight);
   
               return Vector3.Lerp(animatedPosition,
  p + solver.GetNode(chainIndex, nodeIndex).offset,
  maintainRelativePositionWeight);
GetPosition的分析结束,回到IKEffector.Update(IKSolverFullBody solver)。
最后部分是effector对它影响的child施加的影响。虽然有child的effector不会是endEffector,这部分在此处不会执行,但是在之后对非endEffector进行Update的时候会执行这一部分。
for (int i = 0; i <
  childBones.Length; i++) {
                    solver.GetNode(childChainIndexes,
  childNodeIndexes).solverPosition =
  Vector3.Lerp(solver.GetNode(childChainIndexes,
  childNodeIndexes).solverPosition, solver.GetNode(chainIndex,
  nodeIndex).solverPosition + localPositions, posW);
               }
FBBIK初始化的时候,设置了左大腿Node和右大腿Node是bodyEffector的childNode。按照这个实现,在bodyEffector的posW为1时,之前在ReadPose阶段对左大腿和右大腿施加的offset就会完全无效,它们的solverPosition在bodyEffector的Update执行以后,会完全跟随rootNode在solver中对应的Node(也就是spine骨骼链唯一的Node)的solverPosition。但是按照几个effector在effectors数组中的顺序来看,bodyEffector是第0个,左大腿和右大腿的effector在接下来仍然可以通过position对左大腿和右大腿产生影响。
骨骼链的push和reach

在FullBodyBipedIK的inspector面板上,四肢的骨骼链都有四个float参数和两个枚举参数用来在迭代阶段计算推拉。


其中pull参数在上文的骨骼链读取姿势部分有介绍,它被用来计算crossFade数组。Push和pushParent属性在骨骼链的Push()方法中被使用,reach属性在ReadPose阶段被用来计算reachForce,reachForce在骨骼链的Reach()方法中被使用。reachSmoothing和pushSmoothing分别在Reach方法Push方法之内用来做效果的平滑。
Push
按照调用顺序,首先找到FBIKChain.Push(IKSolverFullBody solver),分析其实现。
Push计算的是手或者脚把上臂或大腿朝里推的效果,其返回值就是把上臂或大腿在这次迭代中推了多远。默认参数push和pushParent都是0,也就是如果不改这两个参数,四肢不会有任何向里推的效果。手动调整这两个参数才能参照其效果理解Push的作用。
Vector3
  sum = Vector3.zero;
   
               // Get the push from the children
               for (int i = 0; i <
  children.Length; i++) {
                    sum
  += solver.chain[children].Push(solver) *
  solver.chain[children].pushParent;
               }
   
               // Apply the push from a child
               nodes[nodes.Length
  - 1].solverPosition += sum;
开头就是一个递归调用,父链调用所有子链的Push方法。把它们的返回值乘上各自的pushParent,累加到sum,作为子链对父链最后一个节点的推效果。从FullBoduyBipedIK的实现来看,就是大腿和上臂对躯干节点的推效果。每条骨骼链的pushParent默认为0。那么全部使用默认值的情况下,即使push不为0,sum怎么算都是零向量,子链对父链在Push调用的阶段不产生推的效果。实际上,在pushParent为0的时候,仅仅push不为0也可以对父链的位置产生较弱的影响,这一点影响是之后的约束求解阶段产生的。如果同时设置了pushParent为正数,则可以看到肢体明显地推动了整个躯干的位置。有意思的是pushParent可以被设置为负数,容易想像是在肢体朝里推的时候,躯干反而倾向于挤压肢体。这也许对一些特殊美术效果来说有用。
接下来计算solverDirection,即在骨骼链内的第0个Node指向最后一个Node的向量,对于一条腿来说,就是solver内被处理过的脚的位置减去solver内被处理过的大腿位置。solverLength是solverDirection的长度。
接下来计算推力:f = 1f - (solverLength / distance);其中distance是输入动画姿势中第0根骨骼到最后一根骨骼的距离,对于一条腿来说,就是未被处理的脚的位置到未被处理的大腿的位置的距离。solverLength被压缩得越短,f就越大,易于理解就是推力越大。如果f为负,说明骨骼链不仅没被缩短反而有所拉长,这就不是push的处理范围了,于是直接return。
solverLength和distance是正数,因为solverLength等于0的情况在前面被return掉了。接下来是pushSmoothing的应用。
// Push smoothing
               switch (pushSmoothing) {
               case Smoothing.Exponential:
                    f
  *= f;
                    break;
               case Smoothing.Cubic:
                    f
  *= f * f;
                    break;
               }



绿线是Smoothing.None的效果,即使f较小也能推一点是一点。橙线是Smoothing.Exponential的效果,名为“指数“,但其实是二次。紫线是Smoothing.Cubic的效果。Smoothing不为None的情况下,较小的推力影响会进一步减弱,如果f在0附近震荡,对结果产生的影响会变小。
// The final push force
               Vector3
  p = -solverDirection * f * push;
               
               nodes[0].solverPosition
  += p;
               return p;
结束阶段,用推力乘上push来推动骨骼链中第0个节点。注意在push为1的情况下,它起到的作用也不是简单地把第0个Node推动到以末端骨骼为基准向内移动distance长度会达到的位置,而是用solverDirection进行计算。这意味着在肢体开头结尾之间被压缩得很短时反而不会产生很强的推动效果。一方面这利于解算稳定,另一方面它带有真实性,因为在真实世界肢体蜷曲程度很大时想支撑起来会用不上劲。把推动的向量返回,Push结束。
Reach
回到IKSolverFullBody.Solve(),可以看到Push结束之后又判断了一次FABRIKPass,为true的时候执行Reach。这个判断的存在个人不是很理解,因为上一步的Push没有修改FABRIKPass属性,应该是可以省掉的。
找到FBIKChain.Reach(IKSolverFullBody solver),分析其实现。
首先也是递归调用所有子骨骼链的Reach,与Push稍有不同,这次子骨骼链的Reach不对父骨骼链产生影响,只是单纯的调用。
reachForce是readPose时计算的,对于三骨骼的骨骼链,reachForce是末端Node的effectorPositionWeight乘reach。effectorPositionWeight是肢体的末端effector的positionWeight乘以整个solver的master weight,也就是solver的IKPositionWeight。这意思是说,对应的末端IK位置权重越大,reachForce越大。如果不是三根骨骼组成的骨骼链,reachForce为0。这样的话在执行完所有子骨骼链的Reach以后就会return掉。这也就是说,在FullBodyBipedIK的实现里,调用躯干骨骼链的Reach作用就是调用四肢的Reach。
接下来计算solverDirection。和Push方法内的solverDirection一样,是在骨骼链内的第0个Node指向最后一个Node的向量,solverLength是solverDirection的长度。
//Reaching
               Vector3
  straight = (solverDirection / solverLength) * length;
Straight是把整个肢体沿着solverDirection的方向“捋直”得到的向量。Length是肢体各节骨骼的总长度。
float delta =
  Mathf.Clamp(solverLength / length, 1 - reachForce, 1 + reachForce) - 1f;
               delta
  = Mathf.Clamp(delta + reachForce, -1f, 1f);
计算delta的第一句,写成Mathf.Clamp((solverLength-length) / length, - reachForce, reachForce)可能会更好理解,是solverLength与length的差占length的比值clamp到一个范围内。接下来delta + reachForce无论如何不会是负数,毕竟inspector面板上reach的取值范围就是0到1,没有负数部分,所以写成Mathf.Clamp01(delta + reachForce)结果一样。
接下来对delta进行平滑处理。此处和Push函数对f的处理完全相同,可以参考Push。
接下来计算offset。
Vector3 offset = straight *
  Mathf.Clamp(delta, 0f, solverLength);
又做了一次clamp。想吐个槽,这句似乎会造成不同缩放的统一体型reach效果不同……而这应该只是一个安全处理。一般成年人的腿是有一米左右长的,如果是蚁人,那样Reach效果会被Clamp到几乎没有。但是总之它用这个方式计算出了offset,这是把肢体整个向外移的偏移值。
写下来把偏移值叠加到骨骼链的第0个Node的solverPosition和最后一个Node的solverPosition。
nodes[0].solverPosition
  += offset * (1f - nodes[0].effectorPositionWeight);
               nodes[2].solverPosition
  += offset;
它在每次迭代都会执行一次,每执行一次都会给肢体叠加一个向外的偏移,所以做一个实验,把某个肢体末端effector的positionWeight调整为1,再把reach调整为1,并增加迭代次数,会发现随着迭代次数的增加,躯干向末端effector被拽得越来越狠。现在可以联想到offset在此前做了一个Clamp到solverLength的操作。躯干越朝endEffector的方向偏,整个肢体的长度就越短,solverLength也因此越短,这样迭代次数高的时候会让后来累加的偏移变得越来越小。可以理解但觉得不太优雅。
解算后明显被影响的位置只有骨骼链的第0个Node的对应的骨骼,也就是大腿或者上臂。至于每次都完整地叠加了offset的末端Node影响的骨骼,也就是手或者脚,没有明显向外偏移。这是Solve的最后所有的endEffector又执行了Update修改了对应Node的solverPosition,并对肢体进行解三角形的结果。
非末端effector的更新

对于非末端effector,Update的作用是把solverPosition和effector的position按positionWeight和solver的IKPositionWeight的乘积posW进行插值。迭代次数越多,插值的次数就越多。如果posW小于1,迭代次数越多, effector的position对最终输出的姿势的影响就越大,被effector影响的骨骼就会更接近effector位置。
这与endEffector完全不同,endEffector的Update从IKEffector. GetPosition(IKSolverFullBody solver, out Quaternion planeRotationOffset)的实现中,可以看出它不和Node的solverPosition做插值,而是与animatedPosition做maintainRelativePosition处理后的结果做插值。所以,迭代次数不影响左手与leftHandEffector的接近程度,但是会影响左上臂与leftShoulderEffector的接近程度。
对于有childBone的effector,它的更新会根据posW在一定程度上覆盖childBone所对应Node的solverPosition。这部分的分析在之前的末端effector的Update部分中已经提及。
解三角形

简单地理解,FBIKChain.SolveTrigonometric(IKSolverFullBody solver, bool calculateBendDirection)的作用是对所有包含三根骨骼的骨骼链做一次权重为1的two bone IK解算。让第二根骨骼对应的Node的solverPosition处在与前后两个Node的solverPosition的距离符合动画输入姿势的位置上,且骨骼链的弯曲方向可以得到控制。四肢骨骼链在这样一步解三角形计算之后,不再需要参与FABRIK的迭代求解才接近合适位置。
public void
  SolveTrigonometric(IKSolverFullBody solver, bool calculateBendDirection = false) {
               if (!initiated) return;
   
               // Solve children first
               for (int i = 0; i <
  children.Length; i++) solver.chain[children].SolveTrigonometric(solver,
  calculateBendDirection);
               
               if (nodes.Length !=
  3) return;
   
               // Direction of the limb in solver
               Vector3
  solverDirection = nodes[2].solverPosition - nodes[0].solverPosition;
   
               // Distance between the first and the last node solver
  positions
               float solverLength =
  solverDirection.magnitude;
               if (solverLength ==
  0f) return;
   
               // Maximim stretch of the limb
               float maxMag =
  Mathf.Clamp(solverLength, 0f, length * maxLimbLength);
               Vector3
  direction = (solverDirection / solverLength) * maxMag;
   
               // Get the general world space bending direction
               Vector3
  bendDirection = calculateBendDirection && bendConstraint.initiated?
  bendConstraint.GetDir(solver): nodes[1].solverPosition -
  nodes[0].solverPosition;
   
               // Get the direction to the trigonometrically solved
  position of the second node
               Vector3
  toBendPoint = GetDirToBendPoint(direction, bendDirection, maxMag);
   
               // Position the second node
               nodes[1].solverPosition
  = nodes[0].solverPosition + toBendPoint;
           }
首先它递归调用了所有子骨骼链的SolveTrigonometric,然后对于node数量不为3的骨骼链直接return。
然后又见到了在Push函数和Reach函数中一模一样的solverDirection和solverLength计算,solverDirection是在骨骼链内的第0个Node指向最后一个Node的向量,solverLength是solverDirection的长度。
接下来maxMag是把solverDirection的长度Clamp到“三角形两边之和大于第三边”的合理范围,也就是限制到不超过肢体伸直时的总长度。实际上它为了让肢体不完全伸直,在动画长度上乘了一个maxLimbLength,这个值默认是0.9999。这样的做法和ReadPose阶段调用LimitBend函数限制不能完全伸直目的一样,为了在几乎伸直的情况下保留一点点弯曲方向的踪迹。
direction是接下来会用到的node[0]指向node[2]的向量。
bendDirection在WritePose前最后一次解三角形之前都不重要,因为在迭代过程中,任何一个bendDirection都能把骨骼链中的压力释放掉,而最后一次计算bendDirection则会覆盖掉之前可能不合理的bendDirection。因此可以看到SolveTrigomometric在迭代中的调用使用了caculateBendDirection = false,在迭代之外,IKSolverFullBody.Solve()的最后一行有一个ApplyBendConstraints()函数调用,那一步真正决定了最终WritePose阶段的肢体弯曲方向。但是为了完整地解释SolveTrigonometric会做哪些事,在此处展开分析bendDirection的计算。
Vector3 bendDirection =
  calculateBendDirection && bendConstraint.initiated?
  bendConstraint.GetDir(solver): nodes[1].solverPosition -
  nodes[0].solverPosition;
在calculateBendDirection为false或bendConstraint没有被初始化的情况下,bendDirection是第0个Node的solverPosition指向第一个Node的solverPosition。如果这条骨骼链是腿,那么计算的就是solver中大腿指向膝盖的方向。这样解三角形后和解三角形前三个Node都维持在同一平面上。
在需要认真计算bendDirection的情况下,调用IKConstraintBend.GetDir(IKSolverFullBody solver)。
IKConstraintBend.GetDir
进入该函数,开头如下。
if (!initiated) return Vector3.zero;
   
               float w = weight *
  solver.IKPositionWeight;
在bendConstraint未被初始化的情况下,返回零向量。正常情况下它总是会被初始化。如果bendDirection真的是零向量的话,接下来LookRotation的upVector为零向量,没法控制延lookDirection的旋转,但也不会出现NaN。然后计算权重,这个权重用来把外部设置的bendDirection和自然状态下的bendDirection做插值。
// Apply the bend goal
               if (bendGoal != null) {
                    Vector3
  b = bendGoal.position - solver.GetNode(chainIndex1,
  nodeIndex1).solverPosition;
                    if (b !=
  Vector3.zero) direction = b;
               }
               if (w >= 1f) return
  direction.normalized;
接下来判断bendGoal是否存在。类比到dcc内的角色绑定中,bendGoal是控制膝盖或肘部弯曲的hint物体,表现上肢体会与该物体在同一平面,且在平面内弯向更接近bendGoal的一侧。所以这个情况下计算第一个Node(大腿或上臂Node)指向solverPosition的向量,赋值给direction属性。
如果没有bendGoal,direction是可以从外部手动设置的,这是外部程序直接控制肘部弯曲方向的方法。在权重为1的时候无需插值,直接返回direction。否则进入下一部分,做自然状态下的bendDirection的计算。
Vector3
  solverDirection = solver.GetNode(chainIndex3, nodeIndex3).solverPosition -
  solver.GetNode(chainIndex1, nodeIndex1).solverPosition;
   
               // Get rotation from animated limb direction to solver
  limb direction
               Quaternion
  f = Quaternion.FromToRotation(bone3.position - bone1.position,
  solverDirection);
   
               // Rotate the default bend direction by f
               Vector3
  dir = f * (bone2.position - bone1.position);
这一步计算了一个熟悉的solverDirection,和之前的所有solverDirection一样,是在骨骼链内第0个Node指向最后一个Node的向量,虽然此处不是在FBIKChain内计算的而是在IKConstraintBend内计算的,但实质相同。
下一句计算了动画内两个Node对应的骨骼之间的向量,这两个向量做FormToRotation得到的结果f,反映了从动画姿势到Node解算的姿势肢体的总体旋转。原本动画中的bendConstraint可以表示为bone2.position - bone1.position,这是大腿指向膝盖的向量后者上臂指向肘的向量。既然肢体解算后总体出现了旋转,那么动画bendDirection也跟着旋转即可。于是初步的自然状态下的bendDirection,赋值给dir。
一般来说,简单的全身IK的自然方向计算走到这一步就结束了。但是FinalIK的FullBodyBipedIK为了更自然的美术效果,做了一些其他处理。首先就是肢体末端的effector的旋转可以影响整个肢体的bendDirection。现实中的人如果把脚朝外八字撇,膝盖朝向也会跟着脚朝外转,这样才会有卓别林的标志性动作。
// Effector rotation
               if (solver.GetNode(chainIndex3,
  nodeIndex3).effectorRotationWeight > 0f) {
                    // Bend direction according to the effector rotation
                    Vector3
  effectorDirection = -Vector3.Cross(solverDirection,
  solver.GetNode(chainIndex3, nodeIndex3).solverRotation * defaultChildDirection);
                    dir
  = Vector3.Lerp(dir, effectorDirection, solver.GetNode(chainIndex3,
  nodeIndex3).effectorRotationWeight);
               }
要理解这个Cross计算,首先回到IKConstraintBend的Initiate(IKSolverFullBody solver)函数,看看初始化的时候defaultChildDirection是什么。
direction
  = OrthoToBone1(solver, bone1.position - bone3.position);
   
               if
  (!limbOrientationsSet) {
                    // Default bend direction relative to the first node
                    defaultLocalDirection
  = Quaternion.Inverse(bone1.rotation) * direction;
   
                    // Default plane normal
                    Vector3
  defaultNormal = Vector3.Cross((bone3.position - bone1.position).normalized,
  direction);
                    
                    // Default plane normal relative to the third node
                    defaultChildDirection
  = Quaternion.Inverse(bone3.rotation) * defaultNormal;
               }
OrthoToBone1(solver, bone1.position - bone3.position)计算的是图中把蓝色向量的以bone1方向为normal正交化以后,得到的红色向量的单位化向量。



接下来的defaultNormal是蓝色向量的逆向量单位化以后叉乘红色向量,得的到一个垂直于图的平面向外的向量。可以视为肢体所在平面的法线。DefaultChildDirection是把这个法线方向转换到了末端骨骼空间下。
回去看effectorDirection的计算,solverDirection叉乘把defaultChildDirection从node3的local空间下转到世界空间下的向量再取反,如果三个Node的solverPosition和动画位置之间保持不变且node3的旋转也没变,在动画本身不随意改变关节约束情况下,它可以还原红色向量中与蓝色向量垂直的分量。如果node3有所旋转,得到的effectorDirection会随之旋转。在这个基础上和原本的dir进行插值,可以起到手部旋转影响肘部朝向的效果。
最后计算rotationOffset的影响。
// Rotation Offset
               if (rotationOffset
  != Quaternion.identity) {
                    Quaternion
  toOrtho = Quaternion.FromToRotation(rotationOffset * solverDirection,
  solverDirection);
                    dir
  = toOrtho * rotationOffset * dir;
               }
rotationOffset是肢体末端effector的maintainRelativePositionWeight大于0的产物,具体可见之前提到的末端effector的Update部分,它对肢体的整体旋转即是此处的rotationOffset。此处而是通过一次FromToRotation运算,把rotationOffset围绕solverDirection旋转的部分去掉,只剩下摆动solverDirection的那一部分旋转,赋值给toOrtho,这一部分旋转是最后一次effector.Update()执行之后,由于IK解算,在rotationOffset基础上又叠加的肢体整体旋转。这一部分的影响也应该算在最后对dir的影响之内。
然后使用toOrtho和rotationOffset一起来旋转dir。这一步之后的dir,就是外部对bendDirection不做任何处理时的,自然状态bendDirection。然后把它和外部决定的direction按权重进行插值,得到最终的bendDirection。
FBIKChain.GetDirToBendPoint
回到FBIKChain.SolveTrigometric函数内,下一步调用了解三角形的关键函数FBIKChain.GetDirToBendPoint(Vector3 direction, Vector3 bendDirection, float directionMagnitude)。在这个函数中会根据余弦定理解三角形。
protected Vector3
  GetDirToBendPoint(Vector3 direction, Vector3 bendDirection, float
  directionMagnitude) {
               float x = ((directionMagnitude
  * directionMagnitude) + sqrMagDif) / 2f / directionMagnitude;
               float y = (float)Math.Sqrt(Mathf.Clamp(sqrMag1
  - x * x, 0, Mathf.Infinity));
   
               if (direction ==
  Vector3.zero) return
  Vector3.zero;
               return
  Quaternion.LookRotation(direction, bendDirection) * new Vector3(0f, y, x);
           }
我们从动画输入已经知道一个肢体的第一节和第二节的长度,这个函数被SolveTrigonometric调用的时候是想让一个肢体总体伸出maxMag那么长。前面已经说过maxMag是solver中的第0个Node到最后一个Node的距离Clamp到三角形的合理范围内。知道三角形的三边长以后,根据余弦定理可以计算出三角形的角相关信息。
解三角形的示意图如下。


DirectionMagnitude是图中c边长。SqrMagDif在CalculateBoneLengths函数中被计算,是a^2-b^2。套余弦定理,(c^2+a^2-b^2)/2 =a*cos(∠Node1),即x为边a在边c上的投影长度。
第二步由正弦和余弦平方和为1变形,计算的是a*sin(∠Node1),即y为三角形以c为底的高。
最后一步LookRotation是把解三角形结果转换回世界空间的巧妙处理。构造了一个Vector,其y分量为刚才计算出的y,z分量为刚才计算出的x。在Unity中y是up,z是forward,这个向量表示的是,在以Node3-Node1方向为z轴,Node2-Node1与z轴正交化之后的方向为y轴构建左手3d坐标系,该坐标系下Node1到Node2的向量。这一次LookRotation之后,三角形的forward和up分别与给定的世界空间下的forward和up对齐,得到了解算后世界空间下的Node1到Node2的向量。这个向量返回后被赋值给toBendPoint,顾名思义Node1是起始点,Node2是bend point。
在SolveTrigonometric的最后一行,就是用Node1加上toBendPoint计算了Node2的合理位置。
以上是解三角形的全部实现。如果单把这一部分拿出来,会得到一个非常清晰的two bone ik的思路。
FABRIK forward

回到IKSolverFullBody.Solve()继续朝下看,会看到接下来调用到了chain[0].Stage1(this);这一步让所有骨骼链与其子链组成的树形结构向子节点伸展。下面分析FBIKChain. Stage1(IKSolverFullBody solver)的实现。
public void
  Stage1(IKSolverFullBody solver) {
               // Stage 1
               for (int i = 0; i <
  children.Length; i++) solver.chain[children].Stage1(solver);
               
               // If is the last chain in this hierarchy, solve
  immediatelly and return
               if (children.Length
  == 0) {
                    ForwardReach(nodes[nodes.Length
  - 1].solverPosition);
                    return;
               }
   
               Vector3
  centroid = nodes[nodes.Length - 1].solverPosition;
               
               // Satisfying child constraints
               SolveChildConstraints(solver);
   
               // Finding the centroid position of all child chains
  according to their individual pull weights
               for (int i = 0; i <
  children.Length; i++) {
                    Vector3
  childPosition = solver.chain[children].nodes[0].solverPosition;
   
                    if
  (solver.chain[children].rootLength > 0) {
                        childPosition
  = SolveFABRIKJoint(nodes[nodes.Length - 1].solverPosition,
  solver.chain[children].nodes[0].solverPosition, solver.chain[children].rootLength);
                    }
                        
                    if (pullParentSum
  > 0) centroid += (childPosition - nodes[nodes.Length - 1].solverPosition)
  * (solver.chain[children].pull / pullParentSum);
               }
               
               // Forward reach to the centroid (unless pinned)
               ForwardReach(Vector3.Lerp(centroid,
  nodes[nodes.Length - 1].solverPosition, pin));
           }
与Push、Reach和SolveTrigonometric函数一样,开头都是递归调用子链的该方法。
FBIKChain.ForwardReach
如果子链是叶子节点,会在阶段调用ForwardReach以后立刻return。ForwardReach实现了经典的单链FABRIK的forward reach阶段。
public void ForwardReach(Vector3
  position) {
               // Lerp last node's solverPosition to position
               nodes[nodes.Length
  - 1].solverPosition = position;
               
               for (int i = nodes.Length
  - 2; i > -1; i--) {
                    // Finding joint positions
                    nodes.solverPosition
  = SolveFABRIKJoint(nodes.solverPosition, nodes[i + 1].solverPosition,
  nodes.length);
               }
           }

private Vector3
  SolveFABRIKJoint(Vector3 pos1, Vector3 pos2, float length) {
               return pos2 + (pos1 -
  pos2).normalized * length;
           }
先把最后一个Node直接移动到目标位置。
把移动后的最后一个Node和倒数第二个Node连线。
让倒数第二个Node沿着这条线,滑到和倒数第一个Node距离符合初始距离的位置上。
连线倒数第二个Node和倒数第三个Node,重复以上操作……
沿着链条不断往前,直到最后一个Node也完成forward reach。
这一步的算法可以套用在任何长度的链条上。但是FBBIK的实现里链条长度只有1根到3根。这容易让人联想,可不可以套这个框架直接扩展多骨骼肢体呢?实际上。如果想使用这一特点改插件,擅自扩展骨骼链长度,之前介绍的很多特供三骨骼长度骨骼链的处理会没法用。比如自己写适用于多骨骼的IKConstraintBend就会很麻烦,没法简单直白地套用三角形的处理方式。
如果要使用FinalIK提供的FullBodyIK做一个让狼人使用的“FullBodyWerewolfIK”,私下建议,最简单的思路是先让狼人的腿部动画重定向到人形腿部动画骨骼上,这一步可以用Animation Rigging,或者使用FinalIK的LimbIK并让它的WritePose在FullBodyBipedIK的ReadPose之前就执行,或者自己重新写two bone IK。解算完以后,再使用FABRIK,把人形腿部动画重定向到狼人上。或不用FABRIK而使用其他的重定向处理,比如参考堡垒之夜的Orient and Scale模式重定向分享,育碧的IK Rig分享之类。
在之前的解三角形阶段没有改变肢体末端Node的位置,因此拉伸状态还是还有可能存在。如果肢体末端Node距离肢体开端的Node距离不超过肢体总长度,在解三角形的阶段肢体骨骼链内的压力被完全释放,这一步forward reach不改变任何Node的位置。但是如果存在拉伸,在解三角形的阶段肢体也会被几乎拉直,这一步的forward reach最终会把整条肢体向外拔到末端Node,最终会影响骨骼链的开端骨骼Node,也就是上臂或大腿。
FBIKChain.SolveChildConstraints
如果骨骼链存在子链,则执行接下来的部分。
接下来执行SolveChildConstraints(solver)。ChildConstraints的初始化在设置所有的childConstraint部分就有讲过,且当时讲了四条childConstraint分别会起什么作用。此处进入FBIKChain. SolveChildConstraints(IKSolverFullBody solver)分析其实现。
SolveChildConstraints的内容就是遍历所有childConstraint,挨个调用其Solve方法。前一个childConstraint的解算结果会直接影响后一个,所以这算是Gauss Seidel迭代1次。
进入FBIKChain.ChildConstraint. Solve(IKSolverFullBody solver),分析其实现。
public void
  Solve(IKSolverFullBody solver) {
                    if (pushElasticity
  >= 1 && pullElasticity >= 1) return;
   
                    Vector3
  direction = solver.chain[chain2Index].nodes[0].solverPosition -
  solver.chain[chain1Index].nodes[0].solverPosition;
   
                    float distance =
  direction.magnitude;
                    if (distance ==
  nominalDistance) return;
                    if (distance == 0f) return;
   
                    float force = 1f;
   
                    if (!isRigid) {
                        float elasticity =
  distance > nominalDistance? pullElasticity: pushElasticity;
                        force
  = 1f - elasticity;
                    }
                    
                    force
  *= 1f - nominalDistance / distance;
   
                    Vector3
  offset = direction * force;
                    
                    solver.chain[chain1Index].nodes[0].solverPosition
  += offset * crossFade;
                    solver.chain[chain2Index].nodes[0].solverPosition
  -= offset * inverseCrossFade;
               }
最前面是pushElasticity和pullElasticity的合法性判断,大于1直接return。前面已经讲过pushElasticity为0相当于不能该childConstraint不能缩得更短,而为1说明该childContraint可以任意缩短,pullElasticity则在限制的效果上起作用,它们的起作用方式在接下来的实现中可以看到。
计算direction为被约束的第一个Node到被约束的第二个Node的距离。如果distance已经符合正常距离,无需解算,return。如果distance为0,无法解算,return。
在需要被解算的情况下,定义一个force作为待会儿要应用push或pull效果的程度大小。
isRigid属性可能会在一帧中被外部更新。
虽然实际上我们也不会去更新这些childConstraint的pushElasticity或pullElasticity,插件的使用者根本不需要知道childConstraint的存在。但是插件还是出于可能会被更新的考虑,每帧在PreSolve阶段重新计算了isRigid。如果该帧的pullElasticity和pushElasticity均为0,则isRigid为true。
isRigid为false的时候,首先计算要使用哪一边的弹性,如果比原长更长用pull,否则用push。
计算force为1-elasticity。意思是elasticity越接近于1,force越小,结果越倾向于无约束。
接下来1f - nominalDistance / distance计算的是Node之间在目前的基础上恢复原长要缩短的比例。如果其实是拉长,那就是负数缩短比例。offset可以理解为,如果只缩第一个Node以达到约束后长度需要给第一个Node移动多少。
crossFade和inverseCrossFade在设置所有的childConstraint部分有讲过,决定了被约束之后的两个Node在原基础上的偏向,两者之和为1,它们的作用是让两个Node按比例均分offset。
子链对父链的牵拉
接下来的循环是FABRIK算法中常见骨骼结构存在分叉时的forward reach阶段解决方案。所有子链对父链最后一个节点解算一次牵拉,然后把不同子链牵拉造成的偏移取平均或加权平均,最终的平均偏移应用到父链的最后一个节点上,作为父链本次forward reach的结果。
此处是根据不同子链的pull属性取加权平均。这样确定了骨骼链最后一个Node的位置以后,就可以从后到前地执行ForwardReach()。
在FBBIK的实现中,这次的ForwardReach()调用不会起作用,因为躯干骨骼链只有一个Node。
到这里,Stage1函数,也就是一次迭代中的FABRIK的forward reach阶段结束了。
FABRIK backward

在进入Stage2之前,所有非末端effector又进行了一次Update。如果effector有positionWeight的话,它就又一次强化了effector对对应Node的牵引。
接下来进入Stage2,就是FABRIK算法中的backward reach阶段的实现。
public void
  Stage2(IKSolverFullBody solver, Vector3 position) {
               // Stage 2
               BackwardReach(position);
   
               int it =
  Mathf.Clamp(solver.iterations, 2, 4);
   
               // Iterating child constraints and child chains to make
  sure they are not conflicting
               if (childConstraints.Length
  > 0) {
                    for (int i = 0; i < it;
  i++) SolveConstraintSystems(solver);
               }
   
               // Stage 2 for the children
               for (int i = 0; i <
  children.Length; i++) solver.chain[children].Stage2(solver,
  nodes[nodes.Length - 1].solverPosition);
           }
Stage2接收一个position参数。它对所有存在父链的骨骼链来说,表示骨骼链的父链的最后一个节点在此位置上。如果骨骼链是chain[0]没有父链,可以看到给这个position是chain[0]的第0个Node的位置。结合该骨骼链的rootLength为0,chain[0]的第0个Node不会做任何变化。
FBIKChain.BackwardReach
backward reach就是逆过来执行的forward reach。
private void
  BackwardReach(Vector3 position) {
               // Solve forst node only if it already hasn't been
  solved in SolveConstraintSystems
               if (rootLength >
  0) position = SolveFABRIKJoint(nodes[0].solverPosition, position,
  rootLength);
               nodes[0].solverPosition
  = position;
   
               // Finding joint positions
               for (int i = 1; i <
  nodes.Length; i++) {
                    nodes.solverPosition
  = SolveFABRIKJoint(nodes.solverPosition, nodes[i - 1].solverPosition,
  nodes[i - 1].length);
               }
           }
FBIKChain.BackwardReach(Vector3 position)是Stage2传入的position参数的具体使用者。
对于其他有父链的子链来说,其第0个Node会先与其父链的最后一个Node连直线,延该直线移动到与父链最后一个Node相距rootLength的地方,rootLength也就是动画输入姿势中两个骨骼的距离。
紧接着开始从前到后的循环,后一个Node依次改变自己的位置,以保持与前一个节点相对方向不变的基础上恢复动画输入中的距离。
FBIKChain.SolveConstraintSystem
FBIKChain.SolveConstraintSystems(IKSolverFullBody solver)的作用是把该骨骼链和子链相关的约束全部解算一遍。
在FBBIK的实现中,一共会解算八个约束,其中四个是childConstraint,它们调用SolveChildConstraints解算即可。另外四个是rootNode对应的node到两个上臂和两个大腿的距离,这四个约束的解算使用了SolveLinearConstraint方法。
FBIKChain.SolveLinearConstraint(IKSolver.Node node1, IKSolver.Node node2, float crossFade, float distance)方法会把作为参数输入的两个Node强制拉到给定的distance参数那么长的距离,crossFade参数决定解算后的结果朝哪边偏得更多。它与childConstraint的solve()函数的思路区别是,SolveLinearConstraint函数不考虑弹性问题。
前面在骨骼链读取姿势的部分介绍过,骨骼链的crossFades数组中的值是在骨骼链的ReadPose阶段根据不同子骨骼链的pull属性计算的。此处就用crossFades数组中对应的值来给相应子骨骼链第0个Node与父骨骼链最后一个Node通过调用SolveLinearConstraint做线性约束的时候做为crossFade参数传入。
求解约束系统在一次迭代中就被重复了2到4次,是大迭代中套用小迭代的结构。FBBIK的默认参数是迭代4次,那么在每次迭代4次SolveConstraintSystems之后,所有子链和父链之间的相对关系都会被恢复到没有约束压力的位置上。在FBBIK中,可以认为这一步之后,躯干的形状是已经是舒适的,四肢可以放心地向躯干做backward reach。
最后递归调用所有子链的Stage2。Backward reach阶段结束。
在最原始的FABRIK算法中,只有forward reach和backward reach的简单重复,没有类似于求解childConstraint、Push和Reach的部分。这些复杂的额外附加计算是作者为了让FABRIK算法适用人形角色所做的修改。
回到IKSolverFullBody函数,可以看到迭代循环的最后,执行OnPostIteration委托,这次迭代就结束了。
迭代结束后对肢体弯曲方向的修改

IKSolverFiullBody.Solve()函数到此仍然没有结束,最后一小部分这是迭代已经结束而WritePose尚未开始的阶段,对四肢的弯曲方向进行修改。
先调用OnPreBend回调,正常情况下它什么都不会执行。
然后所有的endEffector执行一帧最后的一次Update,确保把肢体末端的solverPosition精确拉到它应该在的位置。在末端effector的Update这一部分中对末端effector的Update已经有过分析。
最后是真正应用四肢弯曲方向的一行:ApplyBendConstraints()。函数内只有chain[0].SolveTrigonometric(this, true);这一行,其中的calculateBendDirection参数为true表示“这次要认真计算bendDirection了”。这一部分的所有处理,包括解三角形和bendDirection是如何认真计算出来的,在解三角形部分有详细的介绍。
一帧之内的结束阶段

整个IK解算的重头IKSolverFullBody.Solve()结束了,回到IKSolverFullBody.OnUpdate(),可以看到,调用了OnPostSolve()回调以后,调用了把解算结果写回骨骼的重要函数IKSolverFullBody.WritePose()。接下来详细分析这个函数的实现。
protected virtual void WritePose() {
               if (IKPositionWeight
  <= 0f) return;
   
               // Apply IK mapping
               if (iterations >
  0) {
                    spineMapping.WritePose(this);
                    for (int i = 0; i <
  boneMappings.Length; i++) boneMappings.WritePose(IKPositionWeight);
               }
   
               for (int i = 0; i <
  limbMappings.Length; i++) limbMappings.WritePose(this, iterations > 0);
           }
判断总体权重是否为0,如果为0则不进行写入。
接下来是判断总体迭代次数是否为0,前面提到过迭代次数为0时,躯干的状态完全不会被改变,在此处可以看到直接原因是spineMapping.WritePose没有被调用。但我们需要FullBodyIK的原因是,我们希望四肢与躯干产生互相影响的效果,这是它诞生的目的,否则FullBodyIK远不如在四肢上解算two bone IK来得简单。
在这个情况下,spineMapping.WritePose()会被执行。
IKMappingSpine.WritePose

分析IKMappingSpine.WritePose(IKSolverFullBody solver)的实现。
Vector3
  firstPosition = spine[0].GetPlanePosition(solver);
               Vector3
  rootPosition = solver.GetNode(spine[rootNodeIndex].chainIndex,
  spine[rootNodeIndex].nodeIndex).solverPosition;
               Vector3
  lastPosition = spine[spine.Length - 1].GetPlanePosition(solver);
FirstPosition是脊椎的第0根骨骼被它所属的平面驱动后到达的位置,一般是盆骨。其所属的平面是躯干下部三个Node确定的三角形平面,三个Node即左大腿Node、右大腿Node、rootNode对应的Node。
lastPosition是脊椎的最后一根骨骼被它所属的平面驱动后达到的位置,可能叫chest或者spine3。其所属的平面是躯干上部三个Node确定的三角形平面,即左上臂Node、右上臂Node、rootNode对应的Node。
RootPosition,是rootNode对应的Node在解算后的位置。rootNode一般被设置为整条脊椎接近中间部分的某一根骨骼,少数情况下被设置成盆骨或胸骨也能正常解算。
接下来判断是否需要解算FABRIK。useFABRIK变量在运行时初始化的时候,由UseFABRIK函数判断一次,运行过程中不再更改。这个函数大致想做的事情是,如果spine中的rootNode和脊椎的第一根骨骼或脊椎的最后一根骨骼不直接相邻,就使用FABRIK。而如果直接相邻,全部的三根骨骼的位置被上面那三个位置直接确定,不需要使用FABRIK。它没判断全部的spine只有两根以下骨骼的情况,不过也不会影响表现。
在正式解算之前,先把整个骨骼的所有位置按照rootNode在解算后产生的偏移应用到所有脊椎的boneMap上,正式的FABRIK算法部分会从这个偏移后的位置开始解算。
for (int i = 0; i <
  iterations; i++) {
                        ForwardReach(lastPosition);
                        BackwardReach(firstPosition);
                        spine[rootNodeIndex].ikPosition
  = rootPosition;
                    }
这次FABRIK解算不同于Solve()函数内的FABRIK,它不再基于骨骼链解算Node位置,而是在脊椎所有BoneMap组成的数组上进行单链条的FABRIK解算。迭代次数是spineMapping自己的迭代次数,默认值为3。每次迭代的最后,它都会把rootNode的boneMap中的位置同步成solver中rootNode对应的Node的位置,这样可以保证在bodyEffector的positionWeight够大的时候,输出姿势的rootNode足够贴近bodyEffector的位置,而不会在WritePose阶段产生明显误差。
在不需要解算的情况下,盆骨位置直接使用firstPosition,rootNode位置直接使用rootPosition。不论是否使用FABRIK,脊椎的最后一根骨骼位置都会使用lastPosition。这一步还没有具体设置到transform,而是设置到了骨骼BoneMap的ikPosition,留待函数最后一行再决定如何把这些位置写到骨骼Transform。IKMappingSpine.WritePose的最后一行是MapToSolverPositions(solver);
IKMappingSpine.MapToSolverPositions(IKSolverFullBody solver)是spineMapping在写回姿势阶段真正干脏活累活的函数,负责了具体修改所有骨骼Transform的位置和旋转。
private void
  MapToSolverPositions(IKSolverFullBody solver) {
               // Translating the first bone
               // Note: spine here also includes the pelvis
               spine[0].SetToIKPosition();
               spine[0].RotateToPlane(solver,
  1f);
   
               // Translating all the bones between the first and the
  last
               for (int i = 1; i <
  spine.Length - 1; i++) {
                    spine.Swing(spine[i
  + 1].ikPosition, 1f);
   
                    if (twistWeight >
  0) {
                        float bWeight = (float)i / ((float)spine.Length -
  2);
   
                        Vector3
  s1 = solver.GetNode(leftUpperArm.chainIndex,
  leftUpperArm.nodeIndex).solverPosition;
                        Vector3
  s2 = solver.GetNode(rightUpperArm.chainIndex,
  rightUpperArm.nodeIndex).solverPosition;
                        spine.Twist(s1
  - s2, spine[i + 1].ikPosition - spine.transform.position, bWeight *
  twistWeight);
                    }
               }
               
               // Translating the last bone
               spine[spine.Length
  - 1].SetToIKPosition();
               spine[spine.Length
  - 1].RotateToPlane(solver, 1f);
           }
首先,脊椎的第0根骨骼位置完全被设置到了ikPosition,旋转则被设置为被所影响平面驱动的旋转。盆骨在此就像它的平面三角的子物体一样。
对于脊椎第一根骨骼和最后一根骨骼之间的骨骼,此处没有直接设置为上一步FABRIK解算出的位置,而是只进行了swing和twist修改其旋转让它接近上一步解算的结果。
前面讲到在一帧开始的时候,每一根脊椎骨骼会被设置它的localSwingAxis。除了最后一根脊椎骨以外,设置为spine骨骼的旋转的逆乘上这一根spine指向下一根spine的向量。如果骨架中的spine,严格遵循上一根spine是下一根spine的父骨骼这样的层级结构,且没有意外的缩放,这里相当于把下一根spine的localPosition存到了上一根骨骼的localSwingAxis。
Swing这一步传入了新的下一根spine的位置且权重为1,这是要保证父骨骼自身看向子骨骼的方向不变,即让localSwingAxis变换回世界空间后指向下一根脊椎骨的BoneMap的ikPosition。如此一来,脊椎就还是连贯的。
下一步是Twist,作用是沿着刚才变换后的swingAxis做扭转。twistWeight默认为1,但是作者在注释里说,希望我们把它设置成0,因为有点“expensive”。于是在Profiler看看到底有多Expensive。



Twist和计算Swing所需时长差不多。但是Swing属于影响最后姿势的关键,不得不计算,Twist在躯干扭转不明显的情况下,如果不计算,可以在这个循环的阶段省一半时间。但是全部使用默认参数的情况下,它的时间开销和前面的IKSolverFullBody.Solve()相比,也就是几百分之一。正常游戏中人形角色的脊椎只有三根,一帧会计算两次twist。不计算twist会省一点牙缝里的时间。
在每帧readPose的时候,spine的localTwistAxis被设置为右前臂指向左前臂的向量转换到spine空间下与localSwingAxis垂直的分量,这个方向在写入姿势阶段被转回世界空间作为起始方向。解算后得到了右前臂指向左前臂新的方向,新方向再次与localSwingAxis转换到世界空间下以后求解正交化,得到了结束方向。这两个方向做FromToRotation,就得到了完全被左上臂和右上臂驱动的扭转。脊椎从下到上扭转的应用权重逐渐增加,看上去就从盆骨到胸骨有自然的扭转过渡。
脊椎的最后一根骨骼完全被它的平面三角形驱动,和对开头骨骼的处理一样,直接修改了它的位置和旋转。
整条脊椎的姿势写入到此结束。
IKMappingBone.WritePose

在Solve阶段,boneMappings中的骨骼没有参与迭代解算。默认情况下,IKMappingBone在此处没有做任何事情,顶多把ReadPose阶段读取的localRotation在WritePose阶段原封不动地写回去。如果同时在外部挂载了FBBIKHeadEffector组件,则它会起到作用。此处暂不分析FBBIKHeadEffector都做了些啥。
IKMappingLimb.WritePose

在weight为0的情况下不会被执行。IKMappingLimb的weight是在写入limb姿势之前的最后一个权重调节。之前骨骼链的positionWeight影响解算的过程,IKMappingLimb的这个weight却只影响写入过程。在FullBodyBipedIK的inpector面板上,可以看到每个肢体有一个叫Mapping Weight的滑动条,默认为1,操作的即为该属性。


除此之外,四肢的姿势写入是不论iteration是否为0都会被执行。 它接收一个fullBody参数来判断自身对躯干是否存在牵拉可能。Iteration为0表示完全没有牵拉的可能性,因此它不需要修改肢体第一根骨骼的位置。否则,肢体第一根骨骼的位置会按照解算结果被修改,此时肢体位置的合理程度,取决于之前的Solve()阶段是否得出了足够满足各种约束的结果。这也是之前提到的,如果FBIKPass被设置为false,肢体可能会飞出躯干之外的直接原因。
在迭代次数不为0的情况下,如果肢体存在parentBone,则parentBone会旋转以保持看向肢体的开端骨骼的方向不变。在FullBodyBipedIK的实现中,左臂和右臂的parentBone分别是左锁骨和右锁骨,所以,锁骨会跟着上臂的位置而旋转,但是锁骨的长度,也就是锁骨到上臂的距离可能会被改变。
肢体的前两根骨骼通过RotateToPlane的调用,就能确定其正确旋转。在读取姿势阶段,它把骨骼在其平面空间下的旋转用QuaTools.RotationToLocalSpace存了下来。
public static Quaternion
  RotationToLocalSpace(Quaternion space, Quaternion rotation) {
               return
  Quaternion.Inverse(Quaternion.Inverse(space) * rotation);
           }
其实写成Quaternion.Inverse(rotation) * space,不仅节省一次求逆,而且更好理解。
在两次RotateToPlane()调用中,三个plane bone的顺序很重要。boneMap1的顺序是bone1、bone2、bone3,这样求出的平面旋转是bone1看向bone2的,对于一条腿而言,就是动画中大腿到膝盖的方向。RotateToPlane中计算基于Node的平面旋转是bone1对应的node看向bone2对应的node的,对于一条腿而言,是解算后的大腿到膝盖的方向。这样,旋转前后bone1始终看向bone2。Bone3决定了LookRotation的upVector。在之前的解三角形过程中,膝盖是不被完全伸直的(默认顶多伸直到99.99%),因此还能算出解算后的upVector。这样保证了大腿在看向膝盖的同时,不会随意扭转。
BoneMap2的顺序是bone2、bone3、bone1,易知是为了保证bone2看向bone3的方向,并用bone1影响upVector。
最后两行决定末端骨骼的旋转。末端骨骼的旋转由三个可能的因素影响,分别是:
1、 未经处理的旋转,完全由FK决定,它和上一根骨骼保持动画中的自然状态。
2、 世界空间下的旋转,可以保持末端肢体的朝向与动画一致。
3、 末端Effector旋转。在我们想具体修改末端肢体的旋转时,可以让effector的rotation weight大于0,并给effector赋予想要的rotation。
// Rotate the third bone to the rotation it had before
  solving
               boneMap3.RotateToMaintain(maintainRotationWeight
  * weight * solver.IKPositionWeight);
               
               // Rotate the third bone to the effector rotation
               boneMap3.RotateToEffector(solver,
  weight);
这两行分别处理第1和2的插值,上一行的结果和3的插值。
maintainRotationWeight决定了肢体末端在多大程度上维持在世界空间下的旋转不变。FullBodyBipedIK的inspector上,在Mapping Weight滑动条之下有个叫Maintain Hand Rot或者Maintain Foot Rot的滑动条。




如果调整为1同时effector的rotationWeight为0,则无论肢体如何弯曲,末端的旋转岿然不动,不受其父骨骼影响。这一步将旋转写入了transform。
接下来一行就是在以上基础上的旋转和effector的旋转根据rotation weight做插值。这个插值仍然受solver.IKPositionWeight的影响,因为在OnPreSolve函数调用的时候,末端Node的effectorRotationWeight的值为effector的rotationWeight 与solver的 IKPositionWeight相乘。在RotateToEffector函数中,获取的是Node的effectorRotationWeight。如此一来,solver.IKPositionWeight同时控制了IK总体效果的Position和Rotation权重,是真正的“master weight”。
WritePose全部的WritePose阶段到此就结束了。回到IKSolverFullBody.OnUpdate(),它的最后一行是把所有effector的offset置为0,因而上一帧的offset不会累积到下一帧。再沿着调用栈朝上,找到IKSolver.Update(),可以看到紧接着它触发了OnPostUpdate委托,这个委托允许我们在IK解算的结果上继续做自己的处理,FinalIK的很多组件使用了这个委托,比如在FullBodyBipedIK基础上继续做效果修正的FBBIKArmBending组件。想把人腿的动画重定向到狼人的腿上也可以使用这一委托做处理。但FullBodyBipedIK本身没有使用这一委托。继续沿着调用栈朝上,会找到IK和SolverManager这层,然后就看到熟悉的LateUpdate()。这样就可以确认整个帧的处理都结束了。
总结

整体来说算法朴实无华,少数能简化运算的地方不简化。IKSolverFullBodyBiped继承IKSolverFullBody,更像是把具体数据设置的层和负责运算的层分开,而不是做一个通用的FullBodyIK的具体适用两足生物的实现,因为IKSolverFullBody和它调用的FBIKChain、IKMappingSpine、IKMappingLimb类做了很多只适合二足四肢生物的处理,从这一层它可扩展灵活性就大大降低了。
但是从很多细节上还能看出来,作者确实有,或者曾经有让FullBodyIK适配其他体型的野心(比如boneMappings明明只有一个head,但它却用了一个循环来迭代长度顶多为1的数组)。在之后的版本里,作者会让FullBodyIK支持更灵活的体型适配也说不定。
FinalIK的优势在于它为美术效果考虑得够多,以及稳定性足够高。这两点确定了它在Unity的所有IK插件中泰山北斗的地位。

本帖子中包含更多资源

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

×
发表于 2023-3-13 09:32 | 显示全部楼层
收藏了就是会了
发表于 2023-3-13 09:37 | 显示全部楼层
[惊喜]感觉写的比文档都详细
发表于 2023-3-13 09:41 | 显示全部楼层
爱了爱了
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

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

GMT+8, 2024-11-16 16:29 , Processed in 0.080537 second(s), 23 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

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