【Unity】 Animation Riggingでカスタムコンストレイントを実装し、ランタイムでアニメーションを修正してみた

はじめに

モーキャプでとったアニメーションをAnimation Riggingでプロシージャルに修正する必要があったのですが、用意されているコンストレイントだけでは上手くいかなかったのでカスタムコンストレイントを実装してみました。

 

Animation Riggingとは

アニメーション作成時やランタイムで使えるリグを組めるやつです(Animation Rigging でアニメーションをレベルアップさせる方法を学ぼう | Unity Blog )。独自のコンストレイントを作れるなど拡張性も売りにしています。

似たような使い方をされるFinalIKと比較すると、アニメーション編集時でもIKやAimコンストレイントなどを使えるのが明確な利点だと思います。あと無料です。ただ、必要最小限のコンポーネントしか提供されていないため、例えばFinalIKのVRIKに相当するものをAnimation Riggingで実装しようとすると手間がかかります。

また、Animation RiggingはAnimation C# Jobs(Animation C# Jobs | Unity Blog)を使用していてパフォーマンスが良い一方で、コンストレイントの処理の間にMonobehaviorスクリプトの処理は挟めないようになっています(Question - Script Execution Order - Unity Forum)。ここらへんはちょっとややこしかったです。

UnityフォーラムにAnimation Riggingの情報まとめ(Official - Compilation of resources for Animation Rigging - Unity Forum)が投稿されていたのを今見つけたのですが、もっと早く見つけたかった……

使用場面

モーキャプのデータでキャラクターを動かす必要があったのですが、このように腕が机を貫通する問題が発生していました。(このgifで使ってるモーションはMixamoのやつで、実際に使ったやつとは違いますが)

モーションを入れただけ

前から

モーションを撮り直したり手作業で修正する手もありましたが、実際に動かすキャラクターの総数は数十体で、かつ仕様もはっきりと決まっていなかったため、Animation Riggingのコンストレイントを使ってプロシージャルに貫通を防ぐリグを作ろうと考えました。

しかし、用意されているコンストレイントの組み合わせでは上手くいかず、なぜかメモリリークが発生するリグさえ出来上がってしまいました。そこで、専用のカスタムコンストレイントを作りました。

カスタムコンストレイントの実装

TwoBoneIKコンストレイントを拡張した、HeightLimitedTwoBoneIKConstraintというコンストレイントを実装しました。次のような処理を行っています。

  1. TwoBoneIKのtargetが制限高より低くならないように位置を修正する
  2. TwoBoneIKの処理を行う
  3. midが制限高より高くなるように、tipの位置を維持したまま(tip-rootを軸にして)rootを回転させる

横から見た図

 

コード(最後に載せています)の説明は省略しますが、カスタムコンストレイントの書き方はこの動画で説明されています。

youtu.be

 

作ったカスタムコンストレイントを使ってリグを組みます。

リグの構成

HeightLimitedTwoBoneIKConstraintのプロパティは、Limit Heightを机と同じくらいの高さにし、RootやTarget等はTwoBoneIKConstraintと同じように設定します。ただし、このTargetはMulti-PositionConstraintでHandボーンと同じ位置になるように拘束しています。

コンストレイントの実行順は上から下の順で処理が行われるので、Multi-PositionConstraintを上に、HeightLimitedTwoBoneIKConstraintを下にすることで、(Animatorで計算された手の位置を取得)→(手の位置の修正)という風に適切に実行できます。

 

という感じで、腕が机を貫通しないリグを組むことができました。

カスタムコンストレイントを使った場合

前から

体格の違うキャラクターであっても、異なるアニメーションであってもこのリグを流用するだけでいいので便利です。

おわりに

最初はAnimation RiggingをFinalIKのようなものと考えていたので苦戦しましたが、使いこなせたらかなり強いツールだと感じました。

参考

Animation Rigging でアニメーションをレベルアップさせる方法を学ぼう | Unity Blog

Animation C# Jobs | Unity Blog

Question - Script Execution Order - Unity Forum

(79) Extending the Animation Rigging package with C# - Unite Copenhagen - YouTube

Official - Compilation of resources for Animation Rigging - Unity Forum

Mixamo

おまけ(コード)

汚くて恥ずかしいですが、一応コードを載せときます。

rootの回転角度の計算はもっと賢いやり方がありそうですが思いつきませんでした。

 

using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Animations.Rigging;


[DisallowMultipleComponent, AddComponentMenu("Animation Rigging/Height Limited Two Bone IK Constraint")]
public class HeightLimitedTwoBoneIKConstraint : RigConstraint<
HeightLimitedTwoBoneIKConstraintJob,
HeightLimitedTwoBoneIKConstraintData,
HeightLimitedTwoBoneIKConstraintJobBinder<HeightLimitedTwoBoneIKConstraintData>
>
{
protected override void OnValidate()
{
base.OnValidate();
m_Data.hintWeight = Mathf.Clamp01(m_Data.hintWeight);
m_Data.targetPositionWeight = Mathf.Clamp01(m_Data.targetPositionWeight);
m_Data.targetRotationWeight = Mathf.Clamp01(m_Data.targetRotationWeight);
}
}


[Unity.Burst.BurstCompile]
public struct HeightLimitedTwoBoneIKConstraintJob : IWeightedAnimationJob
{
public ReadWriteTransformHandle root;
public ReadWriteTransformHandle mid;
public ReadWriteTransformHandle tip;

public ReadOnlyTransformHandle hint;
public ReadOnlyTransformHandle target;

public AffineTransform targetOffset;

public FloatProperty targetPositionWeight;
public FloatProperty targetRotationWeight;
public FloatProperty hintWeight;

public FloatProperty limitHeight;

public bool isLeftArm;

public FloatProperty jobWeight { get; set; }

public void ProcessRootMotion(AnimationStream stream) { }

public void ProcessAnimation(AnimationStream stream)
{
float w = jobWeight.Get(stream);
if (w > 0f)
{
HeightLimitedSolveTwoBoneIK(
stream, root, mid, tip, target, hint,
targetPositionWeight.Get(stream) * w,
targetRotationWeight.Get(stream) * w,
hintWeight.Get(stream) * w,
targetOffset,
limitHeight.Get(stream),
isLeftArm
);
}
else
{
AnimationRuntimeUtils.PassThrough(stream, root);
AnimationRuntimeUtils.PassThrough(stream, mid);
AnimationRuntimeUtils.PassThrough(stream, tip);
}
}

public static void HeightLimitedSolveTwoBoneIK(
AnimationStream stream,
ReadWriteTransformHandle root,
ReadWriteTransformHandle mid,
ReadWriteTransformHandle tip,
ReadOnlyTransformHandle target,
ReadOnlyTransformHandle hint,
float posWeight,
float rotWeight,
float hintWeight,
AffineTransform targetOffset,
float limitHeight,
bool isLeftArm
)
{
Vector3 aPosition = root.GetPosition(stream);
Vector3 bPosition = mid.GetPosition(stream);
Vector3 cPosition = tip.GetPosition(stream);
target.GetGlobalTR(stream, out Vector3 targetPos, out Quaternion targetRot);

//targetの高さが指定よりも低ければ調整
if (targetPos.y < limitHeight)
{
targetPos.y = limitHeight;
}

//TwoBoneIKの処理
Vector3 tPosition = Vector3.Lerp(cPosition, targetPos + targetOffset.translation, posWeight);
Quaternion tRotation = Quaternion.Lerp(tip.GetRotation(stream), targetRot * targetOffset.rotation, rotWeight);
bool hasHint = hint.IsValid(stream) && hintWeight > 0f;

Vector3 ab = bPosition - aPosition;
Vector3 bc = cPosition - bPosition;
Vector3 ac = cPosition - aPosition;
Vector3 at = tPosition - aPosition;

float abLen = ab.magnitude;
float bcLen = bc.magnitude;
float acLen = ac.magnitude;
float atLen = at.magnitude;

float oldAbcAngle = TriangleAngle(acLen, abLen, bcLen);
float newAbcAngle = TriangleAngle(atLen, abLen, bcLen);

Vector3 axis = Vector3.Cross(ab, bc);
float k_SqrEpsilon = 1e-8f;
if (axis.sqrMagnitude < k_SqrEpsilon)
{
axis = hasHint ? Vector3.Cross(hint.GetPosition(stream) - aPosition, bc) : Vector3.zero;

if (axis.sqrMagnitude < k_SqrEpsilon)
axis = Vector3.Cross(at, bc);

if (axis.sqrMagnitude < k_SqrEpsilon)
axis = Vector3.up;
}
axis = Vector3.Normalize(axis);

float a = 0.5f * (oldAbcAngle - newAbcAngle);
float sin = Mathf.Sin(a);
float cos = Mathf.Cos(a);
Quaternion deltaR = new Quaternion(axis.x * sin, axis.y * sin, axis.z * sin, cos);
mid.SetRotation(stream, deltaR * mid.GetRotation(stream));

cPosition = tip.GetPosition(stream);
ac = cPosition - aPosition;
root.SetRotation(stream, QuaternionExt.FromToRotation(ac, at) * root.GetRotation(stream));

if (hasHint)
{
float acSqrMag = ac.sqrMagnitude;
if (acSqrMag > 0f)
{
bPosition = mid.GetPosition(stream);
cPosition = tip.GetPosition(stream);
ab = bPosition - aPosition;
ac = cPosition - aPosition;

Vector3 acNorm = ac / Mathf.Sqrt(acSqrMag);
Vector3 ah = hint.GetPosition(stream) - aPosition;
Vector3 abProj = ab - acNorm * Vector3.Dot(ab, acNorm);
Vector3 ahProj = ah - acNorm * Vector3.Dot(ah, acNorm);

float maxReach = abLen + bcLen;
if (abProj.sqrMagnitude > (maxReach * maxReach * 0.001f) && ahProj.sqrMagnitude > 0f)
{
Quaternion hintR = QuaternionExt.FromToRotation(abProj, ahProj);
hintR.x *= hintWeight;
hintR.y *= hintWeight;
hintR.z *= hintWeight;
hintR = QuaternionExt.NormalizeSafe(hintR);
root.SetRotation(stream, hintR * root.GetRotation(stream));
}
}
}

tip.SetRotation(stream, tRotation);

//midが制限高より低い場合に、tipの位置を維持してrootを回転させる

if (mid.GetPosition(stream).y >= limitHeight) return;

Vector3 rootPosition = root.GetPosition(stream);
Vector3 midPosition = mid.GetPosition(stream);
Vector3 tipPosition = tip.GetPosition(stream);

Vector3 mPosition = (rootPosition + tipPosition) * 0.5f;

Vector3 normal = (tipPosition - rootPosition).normalized;

Vector3 rVector = midPosition - mPosition;
float radius = rVector.magnitude;

Vector3 xVector = rVector;
Vector3 yVector = xVector.magnitude > Mathf.Epsilon
? Vector3.Cross(normal, xVector)
: Vector3.Cross(normal, Vector3.up);

xVector.Normalize();
yVector.Normalize();

float rotateDirection = isLeftArm ? -1 : 1;

float angle = 0;
Vector3 point = mPosition + radius * xVector;
while (point.y < limitHeight && angle <= 180f)
{
angle += 0.1f;
float theta = rotateDirection * Mathf.Deg2Rad * angle;
point = mPosition + radius * (xVector * Mathf.Cos(theta) + yVector * Mathf.Sin(theta));
}

Quaternion rotation = Quaternion.AngleAxis(rotateDirection * angle, normal);
root.SetRotation(stream, rotation * root.GetRotation(stream));

Quaternion inverse = Quaternion.Inverse(rotation);
tip.SetRotation(stream, inverse * tip.GetRotation(stream));
}

private static float TriangleAngle(float aLen, float aLen1, float aLen2)
{
float c = Mathf.Clamp((aLen1 * aLen1 + aLen2 * aLen2 - aLen * aLen) / (aLen1 * aLen2) / 2.0f, -1.0f, 1.0f);
return Mathf.Acos(c);
}
}

public interface IHeightLimitedTwoBoneIKConstraintData
{
Transform root { get; }
Transform mid { get; }
Transform tip { get; }
Transform target { get; }
Transform hint { get; }

bool maintainTargetPositionOffset { get; }
bool maintainTargetRotationOffset { get; }

string targetPositionWeightFloatProperty { get; }
string targetRotationWeightFloatProperty { get; }
string hintWeightFloatProperty { get; }
string limitHeightFloatProperty { get; }
bool isLeftArm { get; }
}

[System.Serializable]
public struct HeightLimitedTwoBoneIKConstraintData : IAnimationJobData, IHeightLimitedTwoBoneIKConstraintData
{
[SerializeField] Transform m_Root;
[SerializeField] Transform m_Mid;
[SerializeField] Transform m_Tip;

[SyncSceneToStream, SerializeField] Transform m_Target;
[SyncSceneToStream, SerializeField] Transform m_Hint;
[SyncSceneToStream, SerializeField, Range(0f, 1f)] float m_TargetPositionWeight;
[SyncSceneToStream, SerializeField, Range(0f, 1f)] float m_TargetRotationWeight;
[SyncSceneToStream, SerializeField, Range(0f, 1f)] float m_HintWeight;

[NotKeyable, SerializeField] bool m_MaintainTargetPositionOffset;
[NotKeyable, SerializeField] bool m_MaintainTargetRotationOffset;

[SyncSceneToStream, SerializeField] float m_LimitHeight;
[SerializeField] private bool m_IsLeftArm;

public Transform root { get => m_Root; set => m_Root = value; }
public Transform mid { get => m_Mid; set => m_Mid = value; }
public Transform tip { get => m_Tip; set => m_Tip = value; }
public Transform target { get => m_Target; set => m_Target = value; }
public Transform hint { get => m_Hint; set => m_Hint = value; }

public float targetPositionWeight { get => m_TargetPositionWeight; set => m_TargetPositionWeight = Mathf.Clamp01(value); }
public float targetRotationWeight { get => m_TargetRotationWeight; set => m_TargetRotationWeight = Mathf.Clamp01(value); }
public float hintWeight { get => m_HintWeight; set => m_HintWeight = Mathf.Clamp01(value); }

public bool maintainTargetPositionOffset { get => m_MaintainTargetPositionOffset; set => m_MaintainTargetPositionOffset = value; }
public bool maintainTargetRotationOffset { get => m_MaintainTargetRotationOffset; set => m_MaintainTargetRotationOffset = value; }

public bool isLeftArm { get => m_IsLeftArm; set => m_IsLeftArm = value; }

string IHeightLimitedTwoBoneIKConstraintData.targetPositionWeightFloatProperty => ConstraintsUtils.ConstructConstraintDataPropertyName(nameof(m_TargetPositionWeight));
string IHeightLimitedTwoBoneIKConstraintData.targetRotationWeightFloatProperty => ConstraintsUtils.ConstructConstraintDataPropertyName(nameof(m_TargetRotationWeight));
string IHeightLimitedTwoBoneIKConstraintData.hintWeightFloatProperty => ConstraintsUtils.ConstructConstraintDataPropertyName(nameof(m_HintWeight));

string IHeightLimitedTwoBoneIKConstraintData.limitHeightFloatProperty => ConstraintsUtils.ConstructConstraintDataPropertyName(nameof(m_LimitHeight));

bool IAnimationJobData.IsValid() => (m_Tip != null && m_Mid != null && m_Root != null && m_Target != null && m_Tip.IsChildOf(m_Mid) && m_Mid.IsChildOf(m_Root));

void IAnimationJobData.SetDefaultValues()
{
m_Root = null;
m_Mid = null;
m_Tip = null;
m_Target = null;
m_Hint = null;
m_TargetPositionWeight = 1f;
m_TargetRotationWeight = 0f;
m_HintWeight = 1f;
m_MaintainTargetPositionOffset = false;
m_MaintainTargetRotationOffset = false;
m_LimitHeight = 0f;
m_IsLeftArm = true;
}
}

public class HeightLimitedTwoBoneIKConstraintJobBinder<T> : AnimationJobBinder<HeightLimitedTwoBoneIKConstraintJob, T>
where T : struct, IAnimationJobData, IHeightLimitedTwoBoneIKConstraintData
{
public override HeightLimitedTwoBoneIKConstraintJob Create(Animator animator, ref T data, Component component)
{
var job = new HeightLimitedTwoBoneIKConstraintJob();

job.root = ReadWriteTransformHandle.Bind(animator, data.root);
job.mid = ReadWriteTransformHandle.Bind(animator, data.mid);
job.tip = ReadWriteTransformHandle.Bind(animator, data.tip);
job.target = ReadOnlyTransformHandle.Bind(animator, data.target);

if (data.hint != null)
job.hint = ReadOnlyTransformHandle.Bind(animator, data.hint);

job.targetOffset = AffineTransform.identity;
if (data.maintainTargetPositionOffset)
job.targetOffset.translation = data.tip.position - data.target.position;
if (data.maintainTargetRotationOffset)
job.targetOffset.rotation = Quaternion.Inverse(data.target.rotation) * data.tip.rotation;

job.targetPositionWeight = FloatProperty.Bind(animator, component, data.targetPositionWeightFloatProperty);
job.targetRotationWeight = FloatProperty.Bind(animator, component, data.targetRotationWeightFloatProperty);
job.hintWeight = FloatProperty.Bind(animator, component, data.hintWeightFloatProperty);

job.limitHeight = FloatProperty.Bind(animator, component, data.limitHeightFloatProperty);
job.isLeftArm = data.isLeftArm;

return job;
}

public override void Destroy(HeightLimitedTwoBoneIKConstraintJob job)
{
}
}