LoginSignup
4
2

More than 1 year has passed since last update.

VRM1.0でランタイム実行時にカスタムクリップを追加できるようにする

Last updated at Posted at 2023-02-19

はじめに

 プライベートで開発している、VRMのポーズ・モーションウェブアプリVRMViewMeister。それをVRM1.0に対応中である。
 VRM1.0に対応するにあたり、問題にしたのが、 ブレンドシェイプが軒並み動かなくなる という問題である。すべて動かなくなるならまだしも、動くシェイプも残る。これが非常に厄介。

 ブレンドシェイプをブレンドシェイプのまま使えるように動かすのは、今後のVRM1.xの世界ではきっとだんだん必要とされない機能だろう。
 とすれば、新しい機能 Expression でどうにかする他ない。

 そこで今回は、VRM1.0でカスタムクリップをランタイム実行時(動的)に追加して使えるようにする方法を調べて試したみたので紹介したい。

VRM1.0のExpression

 Unityでは標準でブレンドシェイプが機能として備わっている。VRM1.0では諸々の事情があったそうで、名称を改めて実装したとのこと。

詳しくは下記のサイトが詳しい。

Expressionの設定 - VRMドキュメント

VRMC_vrm.expressions - GitHub

有志なら下記のサイト様が非常に詳しい。

VRM1.0の表情について - Brand-new TOKYO

カスタムクリップ

 VRM0.xの頃から存在したが、VRMを作る際に独自のブレンドシェイプ(Expression)を追加できる。
ここでその作り方は追わないが、VRMのサンプルのSeedSanのサンプルパッケージを見てみるとわかりやすい。
 このExpressionはUnityEditorで 作成 から作ることができる。

  1. Preview Prefabを選ぶ
  2. 該当のモデルのメッシュに存在するブレンドシェイプが列挙されるので動かしたいシェイプを指定する
  3. VRMとして出力する

非常に大雑把だがこうしてやればVRMを出力する際に独自のシェイプを組み込める。

このカスタムクリップ、このままランタイム実行時に動的に追加することはできるのだろうか?
それができれば、アプリやゲーム側で全VRMに共通のシェイプを持たせて扱えるようになって活用のしがいがあるのでないかと。

結論からいうと、現状のVRM1.0ライブラリのままではできない。

VRM10RuntimeExpressionを改造する

Expressionを扱うソースは複数箇所ある。

  • VRM10Expression.cs
  • VRM10ObjectExpression.cs
  • Vrm10RuntimeExpression.cs

 今回見ていくのは Vrm10RuntimeExpression.cs である。ここが、ランタイム実行時にExpressionを参照したり適用する処理である。

Vrm10RuntimeExpressionで見るべきポイント

ソースを見ると、同クラスでは初期化時にVRMInstance.Vrm.ExpressionにあるClipsから対象のExpressionを読み込んでいる。

Vrm10RuntimeExpression.cs

private Dictionary<ExpressionKey, float> _inputWeights = new Dictionary<ExpressionKey, float>();
private Dictionary<ExpressionKey, float> _actualWeights = new Dictionary<ExpressionKey, float>();

internal Vrm10RuntimeExpression(Vrm10Instance target, ILookAtEyeDirectionProvider eyeDirectionProvider, ILookAtEyeDirectionApplicable eyeDirectionApplicable)
{
    Restore();

    _merger = new ExpressionMerger(target.Vrm.Expression, target.transform);
    //Expression = VRM10ObjectExpression
    _keys = target.Vrm.Expression.Clips.Select(x => target.Vrm.Expression.CreateKey(x.Clip)).ToList();
    var oldInputWeights = _inputWeights;
    _inputWeights = _keys.ToDictionary(x => x, x => 0f);
    foreach (var key in _keys)
    {
        // remain user input weights.
        if (oldInputWeights.ContainsKey(key)) _inputWeights[key] = oldInputWeights[key];
    }
    ...
}
public float GetWeight(ExpressionKey expressionKey)
{
    if (_inputWeights.ContainsKey(expressionKey))
    {
        return _inputWeights[expressionKey];
    }

    return 0f;
}
public void SetWeight(ExpressionKey expressionKey, float weight)
{
    if (_inputWeights.ContainsKey(expressionKey))
    {
        _inputWeights[expressionKey] = weight;
    }
    Apply();
}

 実際にweightを取得したりセットするのは _inputWeights という辞書型なのがわかる。初期化時にVRMに存在するブレンドシェイプを列挙し、Presetの内容に応じて VRM10ObjectExpressionから Vrm10RuntimeEpxressionに転送している。
 この_inputWeights、見てわかるようにprivateなのでアクセスは原則禁止。

カスタムクリップを動的に使えるようにするには、この _inputWeightsにセットできればよい

 初期化時以外にこの _inputWeightsを VRM10ObjectExpressionから読み込んでいないということが、本改造の発端とするところである。

 ちなみにVRM10ObjectExpression.Clipsはこうなっている。

VRM10ObjectExpression.cs
public IEnumerable<(ExpressionPreset Preset, VRM10Expression Clip)> Clips
{
    get
    {
        if (Happy != null) yield return (ExpressionPreset.happy, Happy);
        if (Angry != null) yield return (ExpressionPreset.angry, Angry);
        if (Sad != null) yield return (ExpressionPreset.sad, Sad);
        if (Relaxed != null) yield return (ExpressionPreset.relaxed, Relaxed);
        if (Surprised != null) yield return (ExpressionPreset.surprised, Surprised);
        if (Aa != null) yield return (ExpressionPreset.aa, Aa);
        if (Ih != null) yield return (ExpressionPreset.ih, Ih);
        if (Ou != null) yield return (ExpressionPreset.ou, Ou);
        if (Ee != null) yield return (ExpressionPreset.ee, Ee);
        if (Oh != null) yield return (ExpressionPreset.oh, Oh);
        if (Blink != null) yield return (ExpressionPreset.blink, Blink);
        if (BlinkLeft != null) yield return (ExpressionPreset.blinkLeft, BlinkLeft);
        if (BlinkRight != null) yield return (ExpressionPreset.blinkRight, BlinkRight);
        if (LookUp != null) yield return (ExpressionPreset.lookUp, LookUp);
        if (LookDown != null) yield return (ExpressionPreset.lookDown, LookDown);
        if (LookLeft != null) yield return (ExpressionPreset.lookLeft, LookLeft);
        if (LookRight != null) yield return (ExpressionPreset.lookRight, LookRight);
        if (Neutral != null) yield return (ExpressionPreset.neutral, Neutral);
        foreach (var clip in CustomClips)
        {
            if (clip != null)
            {
                yield return (ExpressionPreset.custom, clip);
            }
        }
    }
}

CustomeClipsにさえ追加すれば、後は常に動的に取得して返してくれる。

メソッドを追加する

 初期化時に入念な準備をしてくれているので、その処理をコピーし、次のような感じで新しいメソッドを作ってやればよい。

Vrm10RuntimeExpression.cs
/// <summary>
/// reload RuntimeExpression for the custom clips (add 2023/02/18)
/// </summary>
/// <param name="target"></param>
public void ReloadCustomClip(Vrm10Instance target)
{
    //Restore();
    //---視線を制御する_eyeDirection~系いじりたくないのでRestoreから
    //_mergerだけを持ってくる
    _merger?.RestoreMaterialInitialValues();
    _merger = null;

    _merger = new ExpressionMerger(target.Vrm.Expression, target.transform);
    _keys = target.Vrm.Expression.Clips.Select(x => target.Vrm.Expression.CreateKey(x.Clip)).ToList();
    var oldInputWeights = _inputWeights;
    _inputWeights = _keys.ToDictionary(x => x, x => 0f);
    foreach (var key in _keys)
    {
        // remain user input weights.
        if (oldInputWeights.ContainsKey(key)) _inputWeights[key] = oldInputWeights[key];
    }
    _actualWeights = _keys.ToDictionary(x => x, x => 0f);
    _validator = ExpressionValidatorFactory.Create(target.Vrm.Expression);

}

 やってることは初期化そのままである。つまり、初期化を好きなタイミングで実行できるようにする。

 _inputWeightsだけを初期化しても他のプロパティに影響が出るので、その周辺の初期化も合わせて再度させればよいことになる。

 ちなみに本当ならきちんと各プロパティを破壊しておくことをおすすめする。

自アプリ側で対応すること

 VRM1.0のライブラリとしては改修は上記だけである。
 次に自身のアプリ側ですることは、次のことだ。

  • VRM10Expressionをスクリプトから生成する
  • 適用するVRMのブレンドシェイプのあるメッシュとその数・位置をすべてきちんと把握しておく

 上記の通り、VRM10ExpressionはUnityEditor上で作ることを基本的には想定されていると思われるが、次のようにすれば動的に作成できる。うっかり変更されてしまうassetとして作っておく必要もない。

test.cs
public void SetupAdditionalExpression ()
{
    VRM10Expression exp1 = ScriptableObject.CreateInstance<VRM10Expression>()
    exp1.MorphTargetBindings = new MorphTargetBinding[] {
        new MorphTargetBinding("Face", 28, 1.0f), //Fcl_MTH_Angry
    };
    exp1.name = "angry_mth";
    VRM10Expression exp2 = ScriptableObject.CreateInstance<VRM10Expression>()
    exp2.MorphTargetBindings = new MorphTargetBinding[] {
        new MorphTargetBinding("Face", 6, 1.0f), //Fcl_BRW_Angry
        //new MorphTargetBinding("Face", 17, 1.0f), //Fcl_EYE_Joy
        new MorphTargetBinding("Face", 20, 1.0f) //Fcl_EYE_Sorrow
    };
    exp2.name = "funangry";
    //vrminstance は Vrm10Instance 
    vrminstance.Vrm.Expression.AddClip(ExpressionPreset.custom, exp1);
    vrminstance.Vrm.Expression.AddClip(ExpressionPreset.custom, exp2);
    //RuntimeExpressionを再読み込みする
    vrminstance.Runtime.Expression.ReloadCustomClips(vrminstance);

詳しく見ていく。
VRM10ExpressionのMorphTargetBindingsは、実際に動かすメッシュのブレンドシェイプのことである。
UnityEditor上だとこれにあたる。
image.png

 上図の場合、SeedSanをPreviewPrefabにしたので、 head というメッシュオブジェクト固定になってしまっているが、これを動的に指定するのが、 ("Face" の部分である。

パラメータは string path だ。
実際にはMorphTargetBindingMergerというクラスでこのように参照されている。

MorphTargetBindingMerger.cs
public MorphTargetBindingMerger(Dictionary<ExpressionKey, VRM10Expression> clipMap, Transform root)
{
    foreach (var kv in clipMap)
    {
        foreach (var binding in kv.Value.MorphTargetBindings)
        {
            if (!m_morphTargetSetterMap.ContainsKey(binding))
            {
                // このbinding.RelativePathがそう 
                var _target = root.Find(binding.RelativePath);
                SkinnedMeshRenderer target = null;
                if (_target != null)
                {
   ...

 ここはVRMに存在するであろう、SkinedMeshRendererのあるGameObjectの名前にする。もちろん、ブレンドシェイプが存在するのが必須条件だ。

 次のパラメータである。 6,20, などのインデックス値は、ブレンドシェイプの位置となる。

image.png

 Fcl_BRW_Angryを動かしたければ、 6, となる。

 最後のパラメータは、実際に適用する値である。これは特に気にせず 1.0f でよい。度合いが気になれば 0.5f などと調整すれば良い。

 そして exp.name = でシェイプの名前をつける。

 最後に AddClip(ExpressionPreset.custom, exp1) してやれば、無事に動的にカスタムクリップを追加完了だ。
 その際、 ExpressionPreset.customを指定するのを間違えないよう。

アプリで実際に使ってみる

test.cs
public void callTest() 
{
    changeExpressionByName("fungry",1.0);
    // or
    changeExpressionByName("angry_mth",1.0);
}
public void changeExpressionByName(string name, float value)
{
    //名前からExpressionを検索する場合の例
    ExpressionKey ret = new ExpressionKey(ExpressionPreset.custom,"d%d"); //null代わりのダミーキー
                
    for (int e = 0; e < vrminstance.Runtime.Expression.ExpressionKeys.Count; e++)
    {
        if (vrminstance.Runtime.Expression.ExpressionKeys[e].Name.IndexOf(name) > -1)
        {
            changeBlendShape(vrminstance.Runtime.Expression.ExpressionKeys[e],value);
            break;
        }
    }
}
public void changeBlendShape(ExpressionKey shape, float value)
{
    vrminstance.Runtime.Expression.SetWeight(shape, value);
}

適用例

  • 戦艦少女Rのアトランタに angryの眉+funの目 をするExpression を SetWeightした
    image.png

  • アビス・ホライズンのスラヴァに funの口 をする Expressionを SetWeight した
    image.png

いずれも、デフォルトで自動的に追加されるExpressionとしては存在しないものである。

気をつけること

 今回の例では MorphTargetBinding に "Face" と固定で渡したが、実際に使われるVRMに SkinedMeshRendererを持つ、"Face"というGameObjectが存在する必要がある。 もし "Face"というGameObjectがなければ、weightの値は変更されるが、実際には動かない

 アプリやゲームで使われるVRMを作成者限らず集める場合、たとえばVRoidStudioで最初は必ず作っておくこと、などと決めておくべきだろう。
 VRoidStudio製VRMはブレンドシェイプのキーが最初から多く揃っているので、ベースにするのがおすすめだ。

 あと、再読み込みというか、実際には内部的なオブジェクトを破壊して作成しなおしているので、 それまでにExpressionを適用して使っている場合は全部途切れてしまう だろう。
 なので、使い所としてはアプリやゲームの初期化時、VRMを最初に読み込む時だけにするのが一番無難だ。

終わりに

ポイントをまとめると、次のとおりである。

  • Vrm10RuntimeExpressionに、いつでも初期化できる メソッドを追加する
  • アプリ側のスクリプトで VRM10Expressionを生成する
  • MorphTargetBindingの第一パラメータはSkinedMeshRendererのあるGameObject名を指定する
  • 同上の第二パラメータはブレンドシェイプのインデックスを指定する
  • リロードメソッドを呼び出す

 ライブラリの改修としては1箇所だけでいいので導入が楽だと思うが、もちろん、公式のバージョンが上がったときは変更がなかったことになるので、ずっと使うかどうかはよく考えてからにしたい。

 ご利用は自己責任で!

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2