自社製Webブラウザ用VRエンジンで3Dモーションライブを作った話

# はじめに

こんにちは。HIKKYのしらピーです。
Vket Cloudを使ったコンテンツ制作やコンテンツの監修の業務を行っております。

今年の3月に「3Dモデルのキャラクターが約13分ほどのライブイベントを行う」という案件があり、こちらをVket Cloudで実装した時の話を書きます。

![20241202_133537.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788807/500211af-2b6d-7f97-a174-7a3c256c0d0f.png)
※画像はイメージです

この記事は **VR法人HIKKY Advent Calendar 2024** の14日目の記事です。
明日は @otsuka_kenchan さんの【CloudFront の CORS の設定】です。

https://qiita.com/advent-calendar/2024/hikky

## Vket Cloudについて
弊社開発のWebブラウザ上で動作するVRエンジンです。

https://cloud.vket.com/

Webブラウザで動作するため、PCのみならず、携帯端末からでもURL1つでアプリインストール無し、アカウント作成無しでバーチャル空間にアクセスし、他プレイヤーと交流することが出来る特徴があります。

# 必須要件
イベントの仕様は下記のようなものでした。

![アドカレ2024_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788807/90b68783-4cad-3cc8-e56d-dc9947f3934b.png)


また、イベントの設計にあたり、以下の必須要件がありました。

![アドカレ2024_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788807/c0ded191-d69a-df38-439d-19da95d77a24.png)


Vket Cloudはブラウザ上で動作するメタバースなので、ブラウザの制約を受けてしまいます。
特にメモリ面の制約は厳しく、大量のアセットが同時にロードされた状態になっていると、ローディング時間が長くなってしまうほか、メモリ超過によるクラッシュが発生し低スペック端末では遊べなくなってしまいます。
また、空間への同時接続人数が増えれば増えるほどメモリにかかる負荷も大きくなります。

したがってメモリ負荷が最も少なくなり、動作が安定する実装方法を取る必要がありました。

# 要件を満たす実装

上記で記載した条件を満たす実装として考えたのが以下の実装です。

![アドカレ2024_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788807/6ad63c8b-0c7a-e10c-8438-78684dab08fb.png)


モーションを3秒おきに区切り、動的ローディングにより直近10データのみを読み込んだ状態にすることで、保持データ量を13分のデータ → 直近30秒のデータに抑えました。

また、場面にあった適切なモーションの再生制御には、動画の色情報を使用しました。
ワールド空間上からは確認できない場所にモーションの切り替わりと同じタイミングで切り替わる色情報を仕込んでおき、色情報から取得したIDをもとに、適切な演出を発生させるようにしました。
動画自体が配信で再生されることにより、途中入室したり、離脱から復帰した場合でも、空間内にいる他ユーザーと同じものが流れるため動作が同期され、その際適切な色情報も読み込まれるため、(色情報が2回切り替わる)6秒以内に適切なモーションの再生が始まるようになっています。

![アドカレ2024_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788807/0d7eb410-dfa3-fbc5-15df-743f4facd0fc.png)

上記画像の左上の5色部分が色情報です。
RGB値を各255段階で取得可能ですが、誤作動が発生しないよう、赤、青、緑、黄、白の5色を信号として使用しました。
モニターに投影するのが「映像」の部分のみになるよう、スクリーンのメッシュを調整することで、色情報がワールド内に表示されないようにしていました。

::: note
**実装紹介割愛事項**

『13分のモーションを3秒おきに区切る』ですが、これはエディタ拡張ツール制作が得意な方が「13分のHumanoid用アニメーション(.anim)から3秒おきに区切られたアニメーションファイル群を制作するツール」を作ってくださり、そちらを利用することで対応することができました。

色情報の取得についてはJavaScriptを使用し、本案件限定の実装を施しました。

一般公開されているVket Cloudでは実装できない手法を使用しているため、これらの詳しい実装紹介につきましては、本記事では割愛いたします。
:::

# 実際の実装に使用したスクリプトの紹介

ここから先は、Vket Cloudの専用スクリプト言語「HeliScript」にて、本ギミックを実装した際に使用した具体的なスクリプト内容について紹介します。

こちらを使用することで、3Dモーションライブギミックが作れます。ご活用いただければ幸いです。

### ①アバターに適切なモーションをセットする

HeliScriptでは、空間内に設置されたアバターを`hsItemGet()`関数を使用することで、Itemクラスで取得することが出来ます。
取得したアバターアイテムに対し、`LoadMotion()`関数を使うことでアバターが持つモーション情報を差し替えることが出来ます。

アバターはモーション情報をリスト形式で複数個保持することが出来ますが、リストの1番目はデフォルトモーションとなっており、差し替えた瞬間に再生されてしまう特殊なモーションとなっているため、11個のリストを用意しておき、2番目～11番目を差し替え/再生に使用します。

::: note
モーションリストは予めアバターアイテムのモーション設定で作成したものを使用します。
※モーションリストをHeliScript上で作成することはできません。
また、こちらのモーションリストに現状入っているモーションの番号情報を管理するため、HeliScript上では別途int型のリストも作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788807/088d2dfa-4919-8a28-1b9f-65203e11e7c0.png)

:::

::: note
**Item hsItemGet(string itemName)**
引数で指定された名称のアイテムを取得します。

**bool Item.LoadMotion(string index, string fileName, bool isLoop)**
第1引数で指定したアイテムのモーションインデックスIDに、
第2引数で指定したファイル名のモーションファイルをロードします。
(もともと入っていたモーションファイルはアンロードされます)
第3引数はモーションをループ再生するか否かの設定です。
:::

以上をHeliScript上で実装したものが下記になります。

``` C#
/*
アバターのモーションを差し替えるメソッド

引数
actorItem : ライブ演者アバターアイテム。hsItemGet()関数で予め指定されたものを使用
motionSlot : 現状アバターアイテムが持つモーションID情報を格納するためのリスト
indexStr : 色情報を基にした再生地点のID情報。3秒おきの連番。
　　　　　　"00014"のような5桁の数字情報が入る想定
length : 1ライブで使用するアニメーションの総数。モーションライブは複数個種類があり、
　　　　　対応するために引数定義
*/
private void SetActorMotion(Item actorItem, list<int> motionSlot,
    string indexStr, int length) {

    int indexInt = indexStr.ToInt();

    // 演者アバターが読み込まれていなければ読み込む
    if(!actorItem.IsLoaded()){
        actorItem.Load();
    }

    // 3つ先のモーションまでを予め差し替えておくようにする
    for(int i=0; i<3; i++){
        // IDが10で割り切れる場合、11番目のスロットにモーションをセットする
        if((indexInt + i) % 10 == 0){
            CheckSlotReplaceMotion(10, actorItem, motionSlot, indexInt, length);
        }
        // IDが10で割り切れない場合、IDを10で割った余り+1番目のスロットにモーションをセット
        else{
            CheckSlotReplaceMotion((indexInt + i) % 10, actorItem,
            motionSlot, indexInt, length);
        }
    }

    // 現在のIDが10で割り切れる場合、11番目のモーションを再生
    if(indexInt % 10 == 0){
        //SetMotionFromIndex()はモーション再生用関数。後述。
        SetMotionFromIndex(actorItem, "10");
    }
    // 現在のIDが10で割り切れない場合、ID+1番目のモーションを再生
    else{
        SetMotionFromIndex(actorItem, "%d" % (indexInt % 10));
    }
}

// スロットの中身を確認し、必要であればモーションを差し替えるメソッド
void CheckSlotReplaceMotion(int slotIndex, Item actorItem, list<int> motionSlot,
    int indexInt, int length){
    // すでに適切なモーションが入っているか、指定したIDがライブ長を超えている場合、
    // 処理をスキップする
    if(motionSlot[slotIndex] != indexInt + i && (indexInt + i) < length){
        // ID+1番目にMotionフォルダ内のモーションファイル「(演者アバター名)_(ID).hem」を入れる
        actorItem.LoadMotion( "%d" % slotIndex,
        "Motion/%s_%d.hem" % actorItem.GetName() % (indexInt + i) , false);
        // モーションスロットリストの現在のモーション情報を更新
        motionSlot[slotIndex]=indexInt + i;
    }
}
```

### ②アバターに設定されたモーションを再生する

アバターに設定されたモーションの再生には、`Item.ChangeMotion()`関数を使用します。
第2引数にミリ秒を設定することにより、アニメーションをブレンド再生することが出来ます。

Vket Cloudにおけるアバターのモーション再生では、
直前フレームから大きなTransformの変動が発生すると、モデルが乱れるという現象が発生します。
ひと繋ぎのモーションファイルを3秒ごとに区切っているため、理想の挙動は大きなTransformの変動は発生しないのですが、再生開始までに不定期なタイムラグが発生してしまった場合、大きなTransformの変動が発生してしまいます。

特に、アバターに揺れ物が付いている場合は『3秒おきに揺れ物が暴発する』という現象が発生してしまいます。

![アドカレ2024_5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788807/919a3f1b-0820-eaac-751a-8769fea8e9e5.png)
暴発が発生した時の例

この現象を回避するために、「もし何らかの理由でアニメーションの再生が遅れてしまった場合、繋ぎ目部分では次に再生するアニメーションと現在再生中のアニメーションの中間のアニメーションを再生する」という実装を行う必要があり、その実装にアニメーションのブレンド再生を使用する、といった形式になります。

また、現在再生中のアバターアニメーションの再生時間は`GetPlayTime()`関数で取得可能です。

::: note
**bool Item.ChangeMotion(string index, float blendTime)**
アイテムが持つ、index+1番目のモーションを再生します。
現在アイテムが別のモーションを再生中の場合、blendTimeで指定したミリ秒の間は現在再生中のアニメーションとの中間アニメーションを再生します。

**float Item.GetPlayTime()**
アイテムが再生中のアニメーションの経過時間をミリ秒単位で取得します。
:::

``` C#
/*
アバターのモーションを再生するメソッド

引数
actor：演者アバターのアイテム
index：再生するモーションのインデックス番号
*/
public void SetMotionFromIndex(Item actor, string index)
{
    // モーションの再生経過時間を取得し、3秒未満の場合、差分時間のブレンド再生を行う
    if (actor.GetPlayTime() < 3000.0f) {
        actor.ChangeMotion(index, (3000.0f - actor.GetPlayTime()));
    }
    // そうでない場合、通常再生
    else {
        actor.ChangeMotion(index);
    }
}
```

### ③アバターの口パク・表情変化を実装する
公式マニュアルに掲載されていませんが、Itemクラスには`SetBlendShapeRate()`という関数が存在し、これを使うことでアバターの表情をHeliScriptからコントロールすることが出来ます。


::: note
**bool Item.SetBlendShapeRate(string Name, float Rate)**
アバターアイテムの表情を変更します。
Name：表情の指定です。こちらで指定した表情へ変化します。
入力可能な表情は「A」、「Blink」、「Joy」、「Angry」、「Sorrow」、「Fun」の6種類です。
(口開け、目閉じ、喜怒哀楽)
Rate：表情の変化段階です。0.0 ～ 1.0で指定します。
1.0に近いほど指定した表情になり、0.5などの中間パラメータを指定すると遷移途中の表情となります。

※実行時に即時的に切り替わります。
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788807/93a7dc72-29c8-09a9-8763-8d3245f2fa3e.png)
↑「A」、「Joy」を1.0に設定した満面の笑みのVketちゃん

今回の実装では、追加の色情報で4桁の数字(0～4)を取得し、「表情の指定」と「指定表情の度合い」の制御を行いました。
口パクは常時行うため、上2つの色情報を口パク制御用に、下2つの色情報を口パク以外の表情制御用に使用しました。

![アドカレ2024_6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788807/7b417c7d-a808-ff8d-81b8-b926410e20c3.png)

それぞれの下側の色情報は表情変化の度合いの制御に使用しました。

```C#
/*
アバターの表情を制御するメソッド

引数
actor：演者アバターのアイテム
faceType：表情指定(色識別の都合上、0～5までしか使えなかったので、angry未使用)
Param：表情の変化段階指定(色情報をもとに0～4までの指定がある)
usedParam：実際に表情変化Rateとして使用する値
           ref指定なので、ここに指定したグローバル変数に数値を反映する
*/

public void SetActorFace(Item actor, string colorIndex, ref float usedParam){
    // colorIndexには"01"のような2桁の数字が入っている
    // 左の数がfaceType、右の数がparamに対応

    int faceType;
    float param;

    faceType = colorIndex.SubString(0,1).ToInt();
    param = colorIndex.SubString(1,1).ToFloat();

    //色情報が現在のRateパラメータより大きい時、現在のRateパラメータを少しだけ増加
    if(param*0.25f > usedParam){
        usedParam += 0.05f;
    }
    //色情報が現在のRateパラメータより小さい時、現在のRateパラメータを少しだけ減少
    else if(param*0.25f < usedParam){
        usedParam -= 0.05f;
    }

    // 表情指定を数字から具体的な内容に変更
    // 表情指定が「5」だった場合、初期化
    switch (faceType){
        case 0:
            actor.SetBlendShapeRate("A", usedParam);
            break;
        case 1:
            actor.SetBlendShapeRate("Blink", usedParam);
            break;
        case 2:
            actor.SetBlendShapeRate("Joy", usedParam);
            break;
        case 3:
            actor.SetBlendShapeRate("Sorrow", usedParam);
            break;
        case 4:
            actor.SetBlendShapeRate("Fun", usedParam);
            break;
        case 5:
            actor.SetBlendShapeRate("A", 0);
            actor.SetBlendShapeRate("Blink", 0);
            actor.SetBlendShapeRate("Joy", 0);
            actor.SetBlendShapeRate("Sorrow", 0);
            actor.SetBlendShapeRate("Fun", 0);
            break;
    }
}
```

上記スクリプトに於いて、Rate値が徐々に変化するようにしているのは、指定の表情に徐々に変化していくという挙動を実装するためです。

### ④ 作成したメソッドを使用しHeliScriptを構成
①～③で作成したメソッドをUpdate関数上に配置し、ギミックスクリプトを構築します。

```C#
// 上記で紹介した各種メソッドについては紹介を中略しています

extern {

    // 色情報を取得し、モーション制御に適切な形式に変更するメソッド
    string GetMotionIndexFromColor();

    // 色情報を取得し、口パク制御に適切な形式に変更するメソッド
    string GetMouthIndexFromColor();

    // 色情報を取得し、まばたき制御に適切な形式に変更するメソッド
    string GetBlinkIndexFromColor();
}

component LivePerformer{

    // 演者アバター、モーションスロットの定義
    Item _actorVketChan;
    list<int> _motionSlot;

    // 色情報の格納(取得はJavaScriptで行う)、直前の色情報の格納、口パク、まばたき
    string _colorIndex, _lastIndex, _mouthIndex, _blinkIndex;

    // アニメーションファイル数(今回は100に設定)
    const int _AnimationLength;

    // 口の開き具合、目の開き具合のパラメータ
    float _mouthRate, _blinkRate;

    public LivePerformer(){
        _actorVketChan = hsItemGet("Actor_VketChan");
        _motionSlot = new list<int>(11);
    }

    public void Update()
    {
        // 色情報の取得
        _colorIndex = GetMotionIndexFromColor();
        _mouthIndex = GetMouthIndexFromColor();
        _blinkIndex = GetBlinkIndexFromColor();

        // 最後の色情報が新しく取得したものと異なる場合、モーション切り替え発生
        if(_lastIndex != _colorIndex){
            _lastIndex = _colorIndex;
            // 演者アバターのモーションを設定
            SetActorMotion(_actorVketChan, _motionSlot, _colorIndex, _AnimationLength);
        }

        // 演者アバターの口パクを設定
        SetActorFace(_actorVketChan, _mouthIndex , _mouthRate);
        // 演者アバターのまばたきを設定
        SetActorFace(_actorVketChan, _blinkIndex , _blinkRate);
    }

    private void SetActorMotion(Item actorItem, list<int> motionslot,
    string indexStr, int length) {
        // 中身省略、内部でSetMotionFromIndex()を使用
    }

    private void SetMotionFromIndex(Item actor, string index){
        // 中身省略
    }

    public void SetActorFace(Item actor, int faceType, float param, ref float usedParam){
        // 中身省略
    }
}
```

以上のスクリプトにより、低スペック環境でも正常に動作する、途中入室を考慮したモーションライブギミックを実装することができ、何もトラブルが起きることなく無事イベントを行うことが出来ました。

# Vketちゃんダンスワールド

前述の通り、初の3Dモーションライブギミックを実装したワールドは現在はクローズしているため、動画による動作制御が施されたギミックを体験することが出来ませんが、簡易版のライブギミックを搭載した[「ブイブイ言わせるVケットラック！！」ダンスワールド](https://myvketofficial.cloud.vket.com/worlds/CE8jN8dn/main.html?worldid=CE8jN8dn)は記事執筆時点では常時公開コンテンツとなっています。

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788807/b83189ce-fddc-38af-6bb2-5165c32c041c.png)](https://myvketofficial.cloud.vket.com/worlds/CE8jN8dn/main.html?worldid=CE8jN8dn)

こちらのワールドはボタンを押すと音楽が変わりモーション再生が始まります。
音楽再生開始からの経過時間をもとに、3秒おきにモーションの切り替えを行っています。
※こちらは各プレイヤーごとの発火タイミングで見え方が異なる、非同期ギミックとなります。

![20241202_141644.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3788807/51476299-1897-6712-ea1e-70002696ddb5.png)

# まとめ

Vket Cloudでは低スペック端末でも正常に動作する、10分越えのモーションライブギミックを作ることも出来ます。

動画の色情報やギミック開始からの経過時間の取得情報を基に、3秒おきに区切ったモーションから適切なモーションを再生することで、モーションライブギミックを作成しました。

動画を配信で流し、動画内の色情報を動作制御情報として扱うことにより、ギミックの動作状況を空間内の全ユーザーに同期させることを実現しました。