Unity TimelineでVRMのBlendShapeを遷移させる

2022/01/21に公開

UnityのTimelineでBlendShape制御をしたい

VRMモデルを使用してTimeline制御に興味がでてきたので、勉強を兼ねて作成してみました。

以下が実際に組んだTimelineで作った動画になります。

https://twitter.com/KakiiroMate/status/1484362412174495747

今回の記事は、VRMを扱うSDKなどをUnityプロジェクト上で扱うためのセットアップが終了しているものとして話を進めます。

VRMの表情を変える

https://virtualcast.jp/wiki/vrm/setting/blendshap

主には公式ページの表情の設定を追加する方法を参考にしています。

Unity上で設定が可能なのと BlendShapeKey さえわかればC#からも制御できるので、そのまま使用しています。

Timelineの拡張について

https://light11.hatenadiary.com/entry/2019/05/16/214328

基本的に上記の記事を参考に実装しました。

実装したTrackが以下になります。

実装したコード(一部改変してます)
VrmBlendShapeChangeTrack.cs
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
using VRM;

[TrackBindingType(typeof(VRMBlendShapeProxy))]
[TrackColor(0, 1, 0)]
[TrackClipType(typeof(VrmBlendShapeChangeClip))]
public class VrmBlendShapeChangeTrack : TrackAsset
{
    
    public override UnityEngine.Playables.Playable CreateTrackMixer(PlayableGraph graph, GameObject director, int inputCount)
    {
        var playable = ScriptPlayable<VrmBlendShapeChangeBehaviour>.Create(graph, inputCount);
        foreach (var clip in GetClips())
        {
            if (clip.asset is VrmBlendShapeChangeClip changeClip)
            {
                changeClip.Proxy = GetBindingComponent<VRMBlendShapeProxy>(director);
            }
        }
        return playable;
    }
    
    public static T GetBindingComponent<T>(this TrackAsset asset, GameObject gameObject) where T : class
    {
        if (gameObject == null) return default;
        
        var director = gameObject.GetComponent<PlayableDirector>();
        if (director == null) return default;

        var binding = director.GetGenericBinding(asset) as T;
        
        return binding switch
        {
            { } component => component,
            _ => default
        };
    }
}

VrmBlendShapeChangeClip.cs
using System.Linq;
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
using VRM;

[System.Serializable]
public class VrmBlendShapeChangeClip : PlayableAsset, ITimelineClipAsset
{

    public string BlendShapeKeyName { get; set; }
    public VRMBlendShapeProxy Proxy { get; set; }
    public ClipCaps clipCaps => ClipCaps.Blending | ClipCaps.SpeedMultiplier;

    public override UnityEngine.Playables.Playable CreatePlayable(PlayableGraph graph, GameObject owner)
    {
        var playable = ScriptPlayable<VrmBlendShapeChangeBehaviour>.Create(graph);
        var behaviour = playable.GetBehaviour();
        behaviour.BlendShapeKeyName = BlendShapeKeyName;
        behaviour.Proxy = Proxy;
        return playable;
    }
}
    
    
#if UNITY_EDITOR
    
[CustomEditor(typeof(VrmBlendShapeChangeClip))]
public class VrmBlendShapeChangeClipEditor : Editor
{
    private VrmBlendShapeChangeClip clip;
    private string selectBlendShapeKeyName;
    private readonly string selectedIndexKey = $"{nameof(VrmBlendShapeChangeClip)}.{nameof(selectBlendShapeKeyName)}";
    private readonly string emptyString = ".Not Select BlendShape Key";

    void OnEnable()
    {
        clip = (VrmBlendShapeChangeClip)target;
    }

    public override void OnInspectorGUI()
    {
        DrawDefaultInspector();
        
        EditorGUI.BeginChangeCheck();

        var key = $"#{selectedIndexKey}.#{clip.GetHashCode()}";

        selectBlendShapeKeyName = EditorPrefs.GetString(key);
        
        if (clip == null) return;
        
        var clipsList = clip.Proxy.BlendShapeAvatar.Clips.Select(x => x.Key.ToString()).ToList();
        var selectedIndex = clipsList.IndexOf(selectBlendShapeKeyName);
        
        var index = EditorGUILayout.Popup("Select BlendShape", selectedIndex, clipsList.ToArray());
        if (index == -1) return;
        var selected = clipsList[index];
        
        if (!EditorGUI.EndChangeCheck() || selectBlendShapeKeyName == emptyString || selectBlendShapeKeyName == selected) return;
        Undo.RecordObject(clip, nameof(VrmBlendShapeChangeTrack));
        EditorPrefs.SetString(key, selected);
        clip.BlendShapeKeyName = selected;
    }
}
    
#endif

VrmBlendShapeChangeBehaviour.cs
using UnityEngine.Playables;
using VRM;

[System.Serializable]
public class VrmBlendShapeChangeBehaviour : PlayableBehaviour
{
	public string BlendShapeKeyName { get; set; }
	public VRMBlendShapeProxy Proxy { get; set; }

	public override void ProcessFrame(UnityEngine.Playables.Playable playable, FrameData info, object playerData)
	{
        if (Proxy == null) return;
        Proxy.ImmediatelySetValue(BlendShapeKey.CreateUnknown(BlendShapeKeyName), info.weight);
    }
}

一部Editor拡張を入れていますが後ほど画像も加えて説明します。

VrmBlendShapeChangeTrack

Trackは基本的にClipに対して必要な値を入れています。

        foreach (var clip in GetClips())
        {
            if (clip.asset is VrmBlendShapeChangeClip changeClip)
            {
                changeClip.Proxy = GetBindingComponent<VRMBlendShapeProxy>(director);
            }
        }

GetBindingComponent に関しては TrackAsset の拡張メソッドとして手元では実装しています。何だかんだnullチェックをきちんとさせる周りは共通化して損ないので…

VrmBlendShapeChangeClip

ここではClip単位で必要な情報を持たせているのとEditor拡張をして扱いやすいようにしています。

個人的なところですがインスペクターでプリミティブな型で扱うよりは、ものによっては予期せぬ値などを避けるためにpopup選択に変換したりとEditor拡張した方がいいかなと思っているので上記のような選択が可能にできるよう拡張しました。

        var clipsList = clip.Proxy.BlendShapeAvatar.Clips.Select(x => x.Key.ToString()).ToList();
        var selectedIndex = clipsList.IndexOf(selectBlendShapeKeyName);
        
        var index = EditorGUILayout.Popup("Select BlendShape", selectedIndex, clipsList.ToArray());

大体上記の処理がメインになります。

VrmBlendShapeChangeBehaviour

処理はシンプルですがTimelineで制御する挙動を扱う箇所です。

        Proxy.ImmediatelySetValue(BlendShapeKey.CreateUnknown(BlendShapeKeyName), info.weight);

こちらの処理はclipで選択したBlendShapeの再生を行うのとTimeline上でmixしているclipの重みを反映させています。

赤い枠の部分は左右のBlendShapeの重みを変えて表情に反映させています。
動画の目を開けている瞬間などがそうです。目を閉じたものから目を開いたclipに遷移する際にweightをもらったまま反映することで実現しています。

パッと切り替えたい場合はclipを離すことで制御できます。

さいごに

上記のコードはまだ自分自身でもnullチェックなどの考慮が足りてないとは思っていますが、表情を制御するためには問題ないと思ったので何かの参考になると嬉しいです。

VRMの処理を呼び出すのもシンプルに使いやすかったので助かりました。

Discussion