btofbi 发表于 2020-11-26 14:50

Unity中结合IK实现Lookat

前言

之前只是花个周末复读了一篇育碧的演讲,讲到一些周边系统,程序化镜头,口型匹配,角色补光,景深,Lookat,我都玩过一点,很多在2U引擎里都算是标配;本以为投靠Unreal可以缓解大部分周边系统开发的压力,结果原生方案的对接使用工作还是费了一番功夫;而鉴于一些历史遗留问题,Unity的原生方案一般都有些暗坑,加上开发环境不同,项目的需求也不会在乎什么叫标准方案,大部分方案我们都改过.项目里的代码没有脱敏不能拿出来看,这次把在家里码的 Lookat拿来当例子讲一下.


IK

反向动力学(Inverse Kinematics),简称IK,泛指一系列在骨骼动画里不依赖结构树顺序的计算方案.概念上与之相对应的是正向动力学(Forward Kinematics),但它俩只是字面上相反,实际关系并没有那么工整对仗.
IK的难点主要是需求困难而且和一般骨骼动画方案的设计冲突.一般骨骼动画的解算流程,计算代码独立计算完成当前节点的数据,每个节点的数据依附于父节点的计算结果,像Unity这样的,解算流程都在追求内存紧密,层级信息都在外层逻辑里.比如Biped人形骨架方案的骨骼树根节点是屁股,腰腿等骨骼节点的TRS(位置,旋转,缩放)都是基于屁股的TRS的相对数据.依照这个流程,屁股的TRS传递到最后影响了脑袋的TRS,理所当然的,脑袋无论怎么摇摆,都不会改变屁股的TRS.
如果想让脑袋决定屁股要怎么办呢?方法有很多,最简单的方法就是让脑袋成为屁股层级树结构里的父节点就行了,不过这个方法有很多问题,一来程序解算骨骼树倾向于一套稳定的结构,二来所有需求都新建一套骨架树维护更新工作令人绝望,况且如果同时出现四肢都有控制需求的时候,这套框架也不能满足.那么是否可以新增一个无视骨架结构树的流程,让脑袋决定屁股呢?这就是IK了.
一般IK计算流程都会安排在动画计算流程之后,我们假定动画采样赋值执行完成后,骨架树结构处于一个"符合预期"的状态,而在IK就是在这个状态的基础上执行,保持状态"符合预期"的同时,去修改部分骨骼的TRS,从而满足一些特定的需求.比如游戏<ICO>围绕着男孩拉着女孩的手这个主题,全篇幅都在计算如何让双方的手拉在一起(当然有时候会松开).
即使在项目成品里没有使用动画IK相关的技术,如今的动画制作软件里,已经集成了许多IK功能,来帮助美术工作者提高效果和效率.层出不穷的工具很多都是加入了更大成分的程序解算去替代人肉机械式劳动.


Lookat

就是人脑袋和眼睛看向一个目标,一般是相机和其他角色.
Unity中实现Lookat功能

首先,Lookat有很多方案,比如八方向动画混合,和最简单的,只重新计算头部节点旋转.在Lookat功能里使用IK功能的目标有如下几条.
更多可看向角度更少美术资源需求影响到更多骨骼从而更自然的控制转向控制单个节点的旋转强度避免过强的视觉冲击效果特殊应用场景偶尔奢侈一下追求效果
技术上的几个重点
逆层级传递解算目标做好插值避免动画突变演示出"令人满意"的效果暴露变量可以调整出"令人满意"的效果
因为Unity本身已经封装了比较通用的四元数操作函数,大部分的基础计算都不用额外写计算代码,只需要着重设计方案即可.不过,代码计算过程中还是要注意到可能会造成的三角函数,矩阵计算,还有在操作Transform对象时可能造成的脏标记清理和同步等待问题.
关于IK相关的流程代码,在Monobehaviour里面主要是Update,OnAnimatorIK,LateUpdate三个触发函数.
OnAnimatorIK: 依附于Animator模块,需要状态机层上勾选IK才会广播的消息,鉴于内部暗坑不明,不敢用Update: 初学者函数,不过写上去的数据会被动画模块覆盖,等于没用LateUpdate: 动画模块结束,渲染还没开始,规划上算逻辑业务层,放这写差不多
先看看在Unity里最简单的 Lookat怎么写.
using UnityEngine;
public class SimpleLookatBehavior : MonoBehaviour
{
    public Transform target;
    void LateUpdate()
    {
      if (target)
      {
            this.transform.LookAt(target);
      }
    }
}
显然,如果就这么写 Lookat的话是完全用不到IK的,在 Lookat计算里引入IK的初衷,当然是让计算流程迭代到多个骨骼层级上.下面展示一种可用的迭代方案.
每个层级计算前,根据当前头部节点,目标位置和本层级节点位置,用方向向量的叠加把头部节点的运动目标,转变为本层级节点的运动目标,用初中知识点来讲就是构建相等三角形转移求角目标.
    private void IterateTarget(int i, LookatData.BoneConfig firstBoneConfig, Bone firstBone,
      out Vector3 forwardDirection, out Vector3 lookatDirection)
    {
      //每次计算结束后直接赋值,头部坐标对应改变
      var firstBonePosition = firstBone.transform.position;
      var length = Vector3.Distance(lookatPosition, firstBonePosition);
      var vtPosition = firstBonePosition +
            firstBoneConfig.forward.GetDirection(firstBone.transform) * length;
      var nextBone = boneArray;
      var nextData = lookatData.boneConfig;
      var nextBonePosition = nextBone.transform.position;
      var vtDirection = vtPosition - nextBonePosition;
      var finalDirection = lookatPosition - nextBonePosition;
      forwardDirection = nextData.forward.GetDirection(nextBone.transform);
      lookatDirection = Quaternion.FromToRotation(vtDirection, finalDirection)
            * forwardDirection;
    }
关于旋转角度自然,这里介绍两种方案.一种是非常直接的三向旋转阈值限制,一种是平滑旋转轴.


三向旋转阈值限制

类似欧拉角定义旋转为x,y,z三个分量,开放配置每个分量的旋转角度阈值.通过一些需求分析定制化求值目标,三个分量定义为,竖直空间,水平面,自旋转.
   
    public int limitHori;
   
    public int limitVert;
   
    public float limitSelf;
    private void ClampAnglePerBone(int index, Vector3 forwardDirection, Vector3 lookatDirection,
      out bool finish)
    {
      var config = lookatData.boneConfig;
      var bone = boneArray;
      var position = bone.transform.position;
      finish = true;
      var limitHori = config.limitHori < 90;
      var limitVert = config.limitVert < 90;
      var limitSelf = config.limitSelf != 0;
      if (!limitHori && !limitVert && !limitSelf)
      {
            var lookRotation = Quaternion.FromToRotation(forwardDirection, lookatDirection);
            bone.transform.rotation = lookRotation * bone.transform.rotation;
            return;
      }
      if (lookatDirection.y == 1)
      {
            Debug.Assert(true,"正头顶");
      }
      var forwardHoriDirection = forwardDirection;
      forwardHoriDirection.y = 0;
      Quaternion rotation = Quaternion.identity;
      if (limitHori)
      {
            //归一化后统一y轴分量
            var xzScale = Mathf.Sqrt(1 /
                (lookatDirection.x * lookatDirection.x + lookatDirection.z * lookatDirection.z));
            var horiDirection = new Vector3(xzScale * lookatDirection.x,
                0, xzScale * lookatDirection.z);
            var horiAngle = Vector3.Angle(forwardHoriDirection, horiDirection);
            var horiAxis = Vector3.Cross(forwardHoriDirection, horiDirection);
            if (horiAngle > config.limitHori)
            {
                finish = false;
                horiAngle = config.limitHori;
            }
            rotation = Quaternion.AngleAxis(horiAngle, horiAxis);
      }
      if (config.limitVert < 90)
      {
            var vertDirection = rotation * forwardHoriDirection;
            //归一化后统一y轴分量
            var xzScale2 = Mathf.Sqrt((1 - lookatDirection.y * lookatDirection.y) /
                (vertDirection.x * vertDirection.x + vertDirection.z * vertDirection.z));
            var vertDirection2 = new Vector3(xzScale2 * vertDirection.x,
                lookatDirection.y, xzScale2 * vertDirection.z);
            var vertAngle = Vector3.Angle(vertDirection, vertDirection2);
            if (vertAngle > config.limitVert)
            {
                finish = false;
                vertAngle = config.limitVert;
            }
            rotation = Quaternion.AngleAxis(vertAngle,
                Vector3.Cross(vertDirection, vertDirection2)) * rotation;
      }
      var boneRotation = bone.transform.rotation;
      if (!finish && config.limitSelf != 0)
      {
            //TOTEST 以后尝试搞搞新房招牌扭头
            var inverseForwardRotation = Quaternion.FromToRotation(forwardDirection, Vector3.forward);
            var oriSelfRotation = inverseForwardRotation * boneRotation;
            float oriSelfAngle;
            Vector3 oriSelfAxis;
            oriSelfRotation.ToAngleAxis(out oriSelfAngle, out oriSelfAxis);
            var actualDirection = rotation * forwardDirection;
            var selfRotation = Quaternion.AngleAxis(oriSelfAngle * config.limitSelf,
                actualDirection);
            rotation = selfRotation * rotation;
      }
      bone.transform.rotation = rotation * boneRotation;
    }


平滑旋转轴

Unity用四元数表示旋转,即一个旋转坐标系和对应的旋转角度,省略复杂部分约等于一个旋转轴三维向量和一个角度浮点数.通过一个可配置参数把四元数的旋转轴向Vector3.up插值,可以降低其表示旋转在竖直平面的分量. 通过一个可配置参数限制旋转角度,另外插值后的旋转在水平面的分量会变大,需要一定的弱化手段.
   
    public int limitAngle;
   
    public float limitWeight;
    private void LerpAxisPerBone(int i, Vector3 forwardDirection, Vector3 lookatDirection,
            out bool finish)
    {
      var config = lookatData.boneConfig;
      var bone = boneArray;
      var rotation = Quaternion.FromToRotation(forwardDirection, lookatDirection);
      var position = bone.transform.position;
      if (config.limitWeight > 0)
      {
            Vector3 axis;
            float angle;
            rotation.ToAngleAxis(out angle, out axis);
            Vector3 up = Vector3.up;
            var newAxis = Vector3.Lerp(axis, up, config.limitWeight);
            var newAngle = Mathf.Min(angle - Vector3.Angle(axis, newAxis), config.limitAngle);
            var lookRotation = Quaternion.AngleAxis(newAngle, newAxis);
            bone.transform.rotation = lookRotation * bone.transform.rotation;
            finish = false;
      }
      else
      {
            var lookRotation = rotation;
            bone.transform.rotation = lookRotation * bone.transform.rotation;
            finish = true;
      }
    }


平滑计算结果

缓存上一帧的最终结果,通过控制插值系数平滑过渡到计算结果上.
    private void Smooth(int boneLimit)
    {
      for (var i = 0; i < boneLimit; i++)
      {
            var data = lookatData.boneConfig;
            var bone = boneArray;
            var result = bone.transform.localRotation;
            var angle = (int)Quaternion.Angle(result, bone.lastRotation);
            if (angle > 0)
            {
                bone.smoothWeight += Mathf.Min(lookatData.rotateSpeed, data.angularSpeed * Time.deltaTime / 90f);
                result = Quaternion.Lerp(
                  bone.lastRotation, result, bone.smoothWeight);
                bone.transform.localRotation = result;
            }
            else
            {
                bone.smoothWeight = 0;
            }
            bone.lastRotation = result;
      }
    }


杂谈

和大多数数学功能类似,你也不知道为什么这么写看起来效果好(虽然可以吹).Unity这个演示模型的手挂在肩上lookat只是需求比较可怕,单纯看公式的话,这种程度的解算比较小儿科的,只是单纯的旋转.为了保证代码可读加上线性代数学的不好,有些计算可能有多余的步骤.在Transform赋值同步黑盒边缘试探,可能还得考虑下修改迭代方法,最好是全程都没有这部分性能开销.花时间考虑下辅助线,能帮助解决很多bug,顺带看着也很厉害.
页: [1]
查看完整版本: Unity中结合IK实现Lookat