はじめに
UnityでAvatarを扱ったことがあるでしょうか。Avatar (Humanoid形式)とは、Mechanimとも呼ばれるアニメーションシステムの一要素で、人間型モデルに簡単にポーズをつけることなどができますし、Animator Controllerからのアニメーション制御につなげることができます。ボーンやウエイトが特定の形式になっているモデルをモデリングソフトで作成し、fbxなどのファイルとしてUnityにインポートすると、InspectorでHumanoid形式を選択することができます。
通常、Unity EditorのGUIから扱う場合が多いと思いますが、スクリプトから見るとどのようになっているのか、見ていきます。
また、モデルの作りによらず、Avatar (Humanoid) であれば統一的にスクリプトから回転をつけることができるコンポーネント AvatarBoneRotator
を紹介します。
対象読者
本記事は、Humanoid Avatarってなんかスクリプトから扱いにくくない? っていう方が対象です。
「Unityで普通にHumanoid Avatarを扱いたい」という方向けではありませんのでご注意ください。 (そのような方は、 Unity Avatar Humanoid
などで検索されるといろいろ情報があります)
Avatar (Humanoid) の役割
さて、Avatar (Humanoid) の役割は、主に以下のものがあります。
- (A) 各ボーンを人型スケルトン構造にマッピングする
- (B) 初期ボーンの角度をTポーズになるよう補正する
- (C) マッスル値によるボーン制御を行う
特にこの(A)と(B)によって、様々なモデリングアプリで作成された、ポーズが異なるモデルを統一した方法で扱うことができるようになりす。
(C) によって、大きく形状の異なるアバターに対しても、同じモーションデータを適用したとき、おおむね似通った印象になる適用結果を得ることができます。
この記事では主に(B)について扱います。
アバターモデル間の違い
ボーンの軸の違い
各ボーンの軸を可視化しました。Tポーズ状態であっても個々のボーンの軸の向きが全然異なることがわかります。
ボーンの初期角度の違い
ためしにHumanoidを構成する各ボーンのlocalRotationをQuaternion.identityにしてみました。
右のモデル(VRoid Studioのサンプルモデル)は変化がないので、もとからこの状態のメッシュとして作成されているようです。一方、Unityちゃんモデルのほうはおかしなことになりました。
アバターモデルにTポーズをさせるためには、モデルによって異なる値を与える必要があることがわかります。
UnityのAvatar(Humanoid)は、これらの情報を持っていることになります。
Avatarクラスの持つ情報について
Avatarクラスのオブジェクトは、Animator.avatarから取得することができます。Avatarにはどのような情報があるでしょうか。
https://docs.unity3d.com/ja/2019.4/ScriptReference/Avatar.html
Humanかどうか、それとHumanである場合の情報が取得できるようです。
Avatar.humanDescription について
HumanDescriptionを見てみましょう。
https://docs.unity3d.com/ja/2019.4/ScriptReference/HumanDescription.html
人型アバターに関する結構細かい値が入っているようです。
特に気になるのは human (HumanBone[]型) と skeleton (SkeletonBone[]型)です。
HumanDescription.skeleton について
skeleton に注目し、SkeletonBoneを調べましょう。
https://docs.unity3d.com/ja/2023.2/ScriptReference/SkeletonBone.html
SkeletonBoneのインスタンスには、1つのボーンの情報が入っており、position, rotation, scaleは、Tポーズにするための差分の情報が入っていることがわかります。
HumanDescription.human について
humanのほうのHumanBoneをみてみます。
https://docs.unity3d.com/ja/2019.4/ScriptReference/HumanBone.html
以下の情報を持っていることがわかります。
- Avatarと実モデル間のボーン名のマッピング情報
- 各ボーンの可動域の情報
これだけわかればプログラムに利用可能になってくるでしょう。
Azure Kinectサンプルの解析
参考にしやすいコードとして、azure kinectサンプル内のコードがあります。
このサンプルは、Azure Kinectで取得したポーズをUnityのHumanoid Avatarに適用するというものです。
https://github.com/microsoft/Azure-Kinect-Samples/tree/master/body-tracking-samples/sample_unity_bodytracking
特に、PuppetAvatar.cs 内 GetSkeletonBone を見ると、avatar.humanDescription.skeleton から該当名称のボーンについての情報を引き出しています。
foreach (SkeletonBone sb in animator.avatar.humanDescription.skeleton)
{
if (sb.name == boneName || sb.name == cloneName.ToString())
{
return animator.avatar.humanDescription.skeleton[count];
}
count++;
}
Start()では、このボーンの情報を利用して、モデルにTポーズをさせるための回転情報absoluteOffsetMap[]
を生成しています。
Transform transform = PuppetAnimator.GetBoneTransform(hbb);
Quaternion absOffset = GetSkeletonBone(PuppetAnimator, transform.name).rotation;
// find the absolute offset for the tpose
while (!ReferenceEquals(transform, _rootJointTransform))
{
transform = transform.parent;
absOffset = GetSkeletonBone(PuppetAnimator, transform.name).rotation * absOffset;
}
absoluteOffsetMap[(JointId)i] = absOffset;
このTポーズを基準として、Kinectのボーン値を反映させることになります。
LateUpdate() では、absoluteOffsetMap[]
と Kinectによるボーンの回転情報を利用して、最終的なボーンの回転を生成しています。
Quaternion absOffset = absoluteOffsetMap[(JointId)j];
Transform finalJoint = PuppetAnimator.GetBoneTransform(MapKinectJoint((JointId)j));
finalJoint.rotation = absOffset * Quaternion.Inverse(absOffset) * KinectDevice.absoluteJointRotations[j] * absOffset;
さて、するっと行きましたが、ここが問題のポイントです。これは一体何をしているのでしょうか?
finalJoint.rotation = absOffset * Quaternion.Inverse(absOffset) * KinectDevice.absoluteJointRotations[j] * absOffset;
計算の一部をRとしてみます。
finalJoint.rotation = absOffset * R;
としてみれば、absOffsetによってTポーズになり、RはTポーズから目的の姿勢への「ローカルボーン座標系での」回転ということになります。
一方、Rは以下のようになります。
R = Quaternion.Inverse(absOffset) * KinectDevice.absoluteJointRotations[j] * absOffset
absOffsetの逆変換を先に掛けて、最後にabsOffsetをかけています。
これは、Tポーズをしているローカルボーンの座標系とモデルルート座標系との変換に相当します。
つまり、Rは、KinectDevice.absoluteJointRotations[j] をモデルルート座標系で適用し、それをローカルボーンの座標系用に変換したものです。
これが実は重要なことです。ローカルボーンの座標系は、モデルの作り方やモデリングソフトによって違いがあります。Kinectから得られた回転情報を、モデルルート座標系、しかもTポーズ時の回転として扱うことで、ローカルボーンの座標系の違いに左右されずに回転を行うことができます。
ボーンに指定の回転をさせるコンポーネント AvatarBoneRotator
上記の情報などをもとにして、特定ボーンに特定のローカル回転を指定することができるコンポーネントを作成しました。Humanoid Avatar設定ができていれば、モデルによらず動作します。
利用方法:
- アバターモデルのAnimatorコンポーネント内、
Animator Controller
値はNoneにしておいてください - AvatarBoneRotatorコンポーネントについて
- どこに置いても大丈夫ですが、アバターモデルのトップに置くとわかりやすいでしょう
- avatarAnimator : アバターモデルのAnimatorコンポーネントを指定してください
- skeletonRoot : アバターモデル内のHipのボーンのGameObjectを指定してください
AvatarBoneRotator.cs
( gist からダウンロードもできます)
using System;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Avatarのボーンを、モデルによらず同様に回転させる
/// </summary>
public class AvatarBoneRotator : MonoBehaviour
{
[SerializeField]
private Animator avatarAnimator;
public Animator AvatarAnimator => avatarAnimator;
[SerializeField]
private Transform skeletonRoot;
public Transform SkeletonRoot => skeletonRoot;
private readonly Dictionary<string, Quaternion> skeletonBoneToBasePoseTable = new Dictionary<string, Quaternion>();
private void Start()
{
// 180度回転を与えているのは、根本を180度回転させておかないと後ろを向いてしまうため
StoreBasePoseRecursive(skeletonRoot, Quaternion.Euler(0f, 180f, 0f));
}
/// <summary>
/// 指定ボーン配下の各ボーンのBasePose(モデルルート座標系)を保存する。
/// </summary>
/// <param name="trans"></param>
/// <param name="parentRotation"></param>
private void StoreBasePoseRecursive(Transform trans, Quaternion parentRotation)
{
var rotation = StoreBasePoseForABone(trans, parentRotation);
foreach(Transform child in trans)
{
StoreBasePoseRecursive(child, rotation);
}
}
/// <summary>
/// 指定ボーンのBasePose(モデルルート座標系)を保存する
/// 親ボーンまでのBasePoseがわかっている場合に利用できる
/// </summary>
/// <param name="trans"></param>
/// <param name="parentRotation">親ボーンまでのBasePose</param>
/// <returns></returns>
private Quaternion StoreBasePoseForABone(Transform trans, Quaternion parentRotation)
{
Debug.Log($"checking bone: {trans.name}");
var targetBoneInfo = FindSkeletonBone(trans.name);
if (!targetBoneInfo.HasValue)
{
return parentRotation;
}
Quaternion rotation = parentRotation * targetBoneInfo.Value.rotation;
skeletonBoneToBasePoseTable[trans.name] = rotation;
return rotation;
}
/// <summary>
/// avatar.humanDescription.skeleton内から、指定名称のボーンの情報をとりだす
/// </summary>
/// <param name="skeletonBoneName"></param>
/// <returns></returns>
private SkeletonBone? FindSkeletonBone(string skeletonBoneName)
{
foreach (var skeletonBone in avatarAnimator.avatar.humanDescription.skeleton)
{
if (skeletonBone.name == skeletonBoneName)
{
return skeletonBone;
}
}
return null;
}
/// <summary>
/// スケルトンの指定ボーンを回転させる
/// Translation指定はHipの場合のみ適用される
/// </summary>
/// <param name="trans"></param>
/// <param name="targetRotation"></param>
/// <param name="targetTranslation"></param>
/// <returns></returns>
public bool RotateBone(Transform trans, Quaternion targetRotation, Vector3? targetTranslation = null)
{
SkeletonBone? skeletonBone = FindSkeletonBone(trans.name);
if (skeletonBone == null)
{
Debug.LogWarning($"Not a skeleton bone : {trans.name}");
return false;
}
var localBaseRotation = skeletonBone.Value.rotation;
var rootRotationCorrection = (trans == skeletonRoot) ? Quaternion.Euler(0f, 180f, 0f) : Quaternion.identity;
if (skeletonBoneToBasePoseTable.TryGetValue(trans.name, out Quaternion basePose))
{
// targetRotationをモデルルートの座標系でのTポーズ状態での回転として扱い、これをローカルボーン座標系の回転に変換する
var rotationInLocalBone = Quaternion.Inverse(basePose) * targetRotation * basePose;
// Tポーズへの回転と、Tポーズから目的の状態への回転を適用する
trans.localRotation = rootRotationCorrection * localBaseRotation * rotationInLocalBone;
// positionはHipsだけを受け入れることにする(ややこしくなるので)
if (targetTranslation.HasValue && (trans == skeletonRoot) )
{
trans.localPosition = targetTranslation.Value;
}
return true;
}
return false;
}
/// <summary>
/// HumanBodyBonesに対応するボーンを回転させる
/// Translation指定はHipの場合のみ適用される
/// </summary>
/// <param name="bone"></param>
/// <param name="targetRotation"></param>
/// <param name="targetTranslation"></param>
/// <returns></returns>
public bool RotateBone(HumanBodyBones bone, Quaternion targetRotation, Vector3? targetTranslation = null)
{
Transform trans = avatarAnimator.GetBoneTransform(bone);
if (trans == null)
{
return false;
}
return RotateBone(trans, targetRotation, targetTranslation);
}
}
AvatarBoneRotatorの使い方
前へならえ
肩のところで腕を回して、「前へならえ」をさせてみます。
回転はTポーズ状態で考えるのですが、根本でY軸まわりに180度回転させた状態で考えるので注意してください。 (後述「注意点」参照)
左上腕はY軸まわりに 90 度、左上腕はY軸まわりに -90 度まわします。
手首をひねるのは、左肘、右肘両方ともX軸まわりに 90度まわします。
public class RotateBoneSample : MonoBehaviour
{
[SerializeField] private AvatarBoneRotator rotator;
void Update()
{
rotator.RotateBone(HumanBodyBones.Hips, Quaternion.Euler(0, 180, 0)); // 回転0だとデフォルトから180度ずれるので回しておく
// 上腕
rotator.RotateBone(HumanBodyBones.LeftUpperArm, Quaternion.Euler(0, 90, 0));
rotator.RotateBone(HumanBodyBones.RightUpperArm, Quaternion.Euler(0, -90, 0));
// 腕の回転は下腕で行う
rotator.RotateBone(HumanBodyBones.LeftLowerArm, Quaternion.Euler(90, 0, 0));
rotator.RotateBone(HumanBodyBones.RightLowerArm, Quaternion.Euler(90, 0, 0));
}
}
全体的に回転をかけてみる例
次に、全体的に回転をかけてみる例です。ローカルボーンの方向も図示していますが、かなり構成が異なるモデルでも同じような動きをしているのがわかります。
void Update()
{
var angle = Mathf.Sin(Time.time) * 15.0f;
Debug.Log($"Angle: {angle}");
var rotation = Quaternion.Euler(angle, angle, angle);
foreach(HumanBodyBones bone in Enum.GetValues(typeof(HumanBodyBones)))
{
if (bone == HumanBodyBones.LastBone)
{
continue;
}
rotator.RotateBone(bone, rotation);
}
}
AvatarBoneRotator
の注意点
少し追い込みが足りてないせいかもしれないのですが、いろいろ実験する中で、Hipのところに、Avatar情報よりも180度回転を加えたほうが問題がないことが多かったので、そのようにしています。その結果、Hipに回転0を与えると後ろを向く状態になっています。
ただこうすることで、回転0を指定すると後ろを向いてしまいます。そういうこともあり、この実装が絶対正しいということではないです。
ライセンス
-
AvatarBoneRotatorについて
自由に使っていただいて大丈夫です。 -
ユニティちゃんモデルについて
© Unity Technologies Japan/UCL