HumanoidPoseをシリアライズする
HumanoidPoseをSerializeする
よんどころない事情により、UnityのHumanoidRigに対するアニメーションをシリアライズする方法を考えます。
1フレーム、つまり現在のポーズをシリアライズすることを考えます。
シリアライズ方法考察
HumanBodyBones
まず思いつくのは素直に各BoneのTransformからPositionとRotationをシリアライズすることでしょうか。
// 長さは55
var boneTransforms = Enum.GetValues(typeof(HumanBodyBones))
.Cast<HumanBodyBones>()
.Where(b => b != HumanBodyBones.LastBone)
.Select(animatorFrom.GetBoneTransform)
.ToArray();
Vector3が55個、Quaternionもこれもオイラー角にするにしてもVector3が55個、Vector3が110個ということはfloatが330個ということでつまりは1320byte。シリアライズするにはあまりにも大きすぎる値です。
HumanPose
UnityにはHumanoidRigを共通で動かす仕組みがあります。
HumanPoseHandlerを介して取得できるHumanPoseというstructでHumanoidアバターの姿勢を取得/設定することができます[1]。
HumanPoseはこんな構造のstructです。
public struct HumanPose
{
public Vector3 bodyPosition;
public Quaternion bodyRotation;
// Length 95
public float[] muscles;
}
これをMemoryPackでSerializeしてみます。
// 413byte
var length = HumanPose.ToSerializable().Serialize().Length
結果は413byte。
……もういいじゃん、とか思ってはいけません。まだ先があります。
Muscles
HumanPoseのうち、95個もあるmusclesを圧縮することを考えます。
musclesはfloatの-1〜1で表される関節の曲がり具合です。範囲外の値を設定することもできますが、ポキポキポキポキ……ってなるだけなので考えません。
値の精度も小数点第二位まであれば十分です。それ以下の誤差を人間の目で判別することはできないでしょう。
ところでここに-128 ~ 127のsbyteという数値型がいます。おサイズたったの1byte。シリアライズ時にmusclesの値を* 100して、デシリアライズ時に/ 100してやれば置き換えが可能です。
// 128byte
var length = HumanPose.ToSerializableByte().Serialize().Length
これで切りよく128byteになりました。
その他
汎用的にHumanPoseサイズを削減できるのはこれくらいですが、アプリ個別の用途に合わせるならこんなシリアライズが考えられます。
Quaternion BodyRotationをVector3 BodyRotationEulerにする
たかが4byte、されど4byte。
Apply Root Motionをfalseにする
BodyPositionとBodyRotationがなくなれば、そもそもMemoryPackすらいらない素のシリアライズで95byteになります。
指を限定する
95あるmusclesのうち、指の表現が40を占めています。そんなに細かく指の情報はいらないことがほとんどだと思われるので、そこを特定のパターンに限定することで更に減らせます。
一の位を右手、十の位を左手、百の位をパターンとすると1byteで右手6、左手6、それが3パターンずつあるので左右それぞれ18パターン。これで足りることがほとんどではないでしょうか。
// 元データ
byte handPoseData = 255;
// 右手
var right = handPoseData % 10;
// 左手
var left = (handPoseData / 10) % 10;
// パターン
var pattern = handPoseData / 100;
// 5 - 5 - 2 となる
Debug.Log($"{right} {left} {pattern}");
差分だけ送る
変化した値のindexとその値、前回との差分だけを送ります。
実装そのものは簡単ですが、体ってけっこう動くので、差分だけ送るのもフルで送るのも大して変わらないんじゃないかと思います。
実演
mixamoから[2]取ってきた17秒ほどのアニメーションを30FPSでシリアライズすると66KBのファイルになりました。
それを再生するとこんな感じです。

ちゃんと動いて……いやなんかたまにおかしいか……? たまにおかしいですがちゃんと動いています。
コード
使うときはSerializeFieldをすべて埋めた上でエディタの実行中に使ってください。
ContextMenuでRecordが保存でPlayが再生です。
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cysharp.Threading.Tasks;
using SerializeAnimation.PoseAccessor;
using SerializeAnimation.Serializer;
using UnityEditor;
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;
namespace SerializeAnimation.Example
{
public sealed class ExampleManager : MonoBehaviour
{
[SerializeField]
private AnimationClip animationClip;
[SerializeField]
private HumanPoseAccessor humanPoseAccessor;
[SerializeField]
private Animator animator;
#if UNITY_EDITOR
[ContextMenu(nameof(Record))]
private async void Record()
{
if (animationClip == null)
{
return;
}
humanPoseAccessor.Init(animator);
var playableGraph = PlayableGraph.Create("Playable");
playableGraph.SetTimeUpdateMode(DirectorUpdateMode.Manual);
try
{
var clipPlayable = AnimationClipPlayable.Create(playableGraph, animationClip);
var playableOutput = AnimationPlayableOutput.Create(playableGraph, "Animation", animator);
playableOutput.SetSourcePlayable(clipPlayable);
const int fps = 30;
const float frameTime = 1f / fps;
var frameCount = (int)(animationClip.length * fps);
var listPose = new List<HumanPose>(frameCount);
for (int i = 0; i < frameCount; i++)
{
playableGraph.Evaluate(frameTime);
humanPoseAccessor.GetPose(out var humanPose);
listPose.Add(humanPose);
}
var dir = new DirectoryInfo(Application.dataPath).Parent.CreateSubdirectory("serialized");
if (!dir.Exists)
{
dir.Create();
}
var file = new FileInfo(Path.Combine(dir.FullName, animationClip.name));
await using var fileStream = file.Open(FileMode.OpenOrCreate, FileAccess.Write);
await using var binaryWriter = new BinaryWriter(fileStream);
foreach (var p in listPose)
{
binaryWriter.Write(p.ToSerializableByte().Serialize());
}
fileStream.Close();
EditorUtility.RevealInFinder(file.FullName);
}
finally
{
playableGraph.Destroy();
}
}
[ContextMenu(nameof(Play))]
private async void Play()
{
var filePanel = UnityEditor.EditorUtility.OpenFilePanel("Load Serialized Clip", Application.dataPath, null);
if (!File.Exists(filePanel))
{
return;
}
if (!humanPoseAccessor.Initialized)
{
humanPoseAccessor.Init(animator);
}
humanPoseAccessor.GetPose(out var samplePose);
var serializedLength = samplePose.ToSerializableByte().Serialize().Length;
var serializedData = await File.ReadAllBytesAsync(filePanel, destroyCancellationToken);
var serializedAnimations = serializedData
.Select((b, i) => new { b, i })
.GroupBy(arg => arg.i / serializedLength)
.Select(g => g.Select(x => x.b))
.Select(bytes => HumanPoseSerializableByte.Deserialize(bytes.ToArray()));
const int fps = 30;
const float frameTime = 1f / fps;
var timeSpan = TimeSpan.FromSeconds(frameTime);
foreach (var poseSerializable in serializedAnimations)
{
await UniTask.Delay(timeSpan, cancellationToken: destroyCancellationToken);
var pose = poseSerializable.HumanPose;
humanPoseAccessor.SetPose(ref pose);
}
}
#endif
}
}
まとめ
本当はARFoundationのBody trackingをリアルタイムにFusionで配信するために作っていて、ものは完成していたんですが記事を書く前に手持ちのiPadのライトニング端子が壊れて充電できなくなっちゃって書けなくなって……なので、ひとまず手頃に再生できるAnimationClipで記事を書くことにしました。MediaPipeとかで頑張ればいい説もあります。つらいのでやらない。
ネットワーク越しに送るデータは小さければ小さいほどいいものです。工夫によっては100byteを切るのはリアルタイムに送るデータのシリアライズとしては十分じゃないでしょうか? 少なくとも自分は満足しました。
おしまい。
参考
Discussion