エンジニアの合田です。
前回の「VRChatに簡単なワールドを作る」の続き記事となります。
前回はVRChat(以下VRC)のセットアップの話題が中心でしたが、今回は実際にワールドの中身を作る情報についてまとめたいと思います。

1-10ワールド

今回作ったワールドではワントゥーテンの東京オフィス8Fを再現してみました。その中で過去にワントゥーテンが作ったクリエイティブが出現したり、パーティクルなどの演出が加わることでメタバースならではの非現実なシチュエーションが味わえる空間となっています。

ワールドはこちらのリンクから入れるのでぜひ遊びに来てください。

1→10ワールド Part1
1→10ワールド Part2
実際の天王洲オフィス(8F)の様子

以下でこのワールドを作るときに得られた知見をまとめたいと思います。

対応デバイスを決める

VRCのワールドを作るとき、まず最初に決めるのはMetaQuestに対応するかです。

MetaQuestはスタンドアロン型のVRデバイスです。
現在のコンシューマデバイスの主流になっており、こちらに対応すると人が来やすいワールドになります。対応しない場合はPC接続したデバイスのみ来場可能になります。

ただし、Quest対応ワールドには制限が入り、チューニングが必要になるため用途を見極めて決定してみてください。ちなみに今回の1-10ワールドはPC専用になっています。

3Dモデルを置いてワールドを作る

ワールドに3Dモデルを置いてみましょう。
シェーダー、ポストプロセス、ライティング、パーティクルに縛りもないので通常のUnity開発と同じプロセスで行ってもらって大丈夫です。今回はこんな感じに作ってみました。

次に空間に当たり判定を付けていきます。
床や壁、テーブルなどプレイヤーにすり抜けてほしくない場所を遮っていきます。

最後にプレイヤーの現れる座標(Spawn Point)を設定します。
今回は会議室のこの辺りに設定してみました。

これでワールドの見た目は完成です。あっさりですね。

インタラクションを作る

作ったワールドに簡単なインタラクションを導入してみましょう。
今回は「人がエリアに入ったら音が鳴って看板が表示される」という簡単なものを考えてみましょう。

このインタラクションを実現するには、当たり判定を利用した簡単なプログラムを組む必要があります。

VRCワールドをプログラミングするには「Udon」という独自言語を使います。なぜ独自言語が採用されているかは置いといて、Udonで今回のロジックを構築すると以下のようになります。

.data_start

    .export Panel
    
    __instance_0: %UnityEngineGameObject, this
    __value_0: %SystemBoolean, null
    __instance_1: %UnityEngineAudioSource, null
    __instance_2: %UnityEngineGameObject, this
    __type_0: %SystemType, null
    __Type_0: %SystemType, null
    __instance_3: %UnityEngineGameObject, this
    __value_1: %SystemBoolean, null
    __instance_4: %UnityEngineGameObject, this
    __value_2: %SystemBoolean, null
    Panel: %UnityEngineGameObject, this

.data_end

.code_start

    .export _onPlayerTriggerEnter
    
    _onPlayerTriggerEnter:
    
        PUSH, Panel
        PUSH, __instance_0
        COPY
        PUSH, __instance_0
        PUSH, __value_0
        EXTERN, "UnityEngineGameObject.__SetActive__SystemBoolean__SystemVoid"
        PUSH, Panel
        PUSH, __instance_2
        COPY
        PUSH, __Type_0
        PUSH, __type_0
        COPY
        PUSH, __instance_2
        PUSH, __type_0
        PUSH, __instance_1
        EXTERN, "UnityEngineGameObject.__GetComponent__SystemType__UnityEngineComponent"
        PUSH, __instance_1
        EXTERN, "UnityEngineAudioSource.__Play__SystemVoid"
        JUMP, 0xFFFFFFFC
    
    .export _start
    
    _start:
    
        PUSH, Panel
        PUSH, __instance_3
        COPY
        PUSH, __instance_3
        PUSH, __value_1
        EXTERN, "UnityEngineGameObject.__SetActive__SystemBoolean__SystemVoid"
        JUMP, 0xFFFFFFFC
    
    .export _onPlayerTriggerExit
    
    _onPlayerTriggerExit:
    
        PUSH, Panel
        PUSH, __instance_4
        COPY
        PUSH, __instance_4
        PUSH, __value_2
        EXTERN, "UnityEngineGameObject.__SetActive__SystemBoolean__SystemVoid"
        JUMP, 0xFFFFFFFC
    

.code_end

ちょっと難しいですね。
でも心配ありません。もっと簡単に作る方法があります。ノードを繋いでロジックを作る方法です。「Udon Node Graph」という機能を使います。

今回考えたインタラクションをノードで組むとこのような形になります。

やってることがかなり分かりやすくなったのではないでしょうか。
また「Udon Sharp」という機能を使って、通常のUnity開発で行われているC#プログラムみたいに作ることもできます。

using UdonSharp;
using UnityEngine;
using VRC.SDKBase;

public class Panel : UdonSharpBehaviour
{
    [SerializeField] private GameObject _panel;

    /// <summary>
    /// 起動時のイベント
    /// </summary>
    private void Start()
    {
        _panel.SetActive(false);
    }

    /// <summary>
    /// プレイヤーがエリアに入ってきたときのイベント
    /// </summary>
    public override void OnPlayerTriggerEnter(VRCPlayerApi player)
    {
        _panel.SetActive(true);
        var aSource = _panel.GetComponent<AudioSource>();
        aSource.Play();
    }
    
    /// <summary>
    /// プレイヤーがエリアから出て行ったときのイベント
    /// </summary>
    public override void OnPlayerTriggerExit(VRCPlayerApi player)
    {
        _panel.SetActive(false);
    }
}

ここまで色々なやり方に対応しているVRCはすごいですよね。

その中で個人的なおすすめは「UdonSharp + GitHub Copilot」です。
GitHub Copilotは日本語コメントを書くとコードを自動生成してくれるAIツールです。(有料のツールではあるのですが、弊社では会社からエンジニアに支給されています)

これはやってほしいことを言語化さえできればプログラムを生成できます。
以下は実際にこのインタラクションを作ってもらったときの動画です。

若干文法は違いますが、上で自分が書いたコードと内容は同じです。
すごいですね!

同期を考える

インタラクションを作ることができれば、自分の思い描いたアイデアを形にしてワールドを盛り上げることができます。ただここがVRChatということを思い返すと、もうひと手間あるとよいでしょう。

新たな例として緑のエリアに人が入ったとき、モニターに動画が流れるインタラクションを考えてみましょう。

それを実現するコードは以下のようになります。

using TMPro;
using UdonSharp;
using UnityEngine;
using VRC.SDK3.Components.Video;
using VRC.SDK3.Video.Components.AVPro;
using VRC.SDKBase;

public class Monitor : UdonSharpBehaviour
{
    private bool _isVideoReadied, _isVideoLoaded;

    [SerializeField] private VRCUrl _url;
    [SerializeField] private VRCAVProVideoPlayer _videoPlayer;
    [SerializeField] private MeshRenderer _renderer;

    /// <summary>
    /// 動画が読み込み終わったときのイベント
    /// 動画を再生する
    /// </summary>
    public override void OnVideoReady()
    {
        _videoPlayer.Play();
        _renderer.material.SetColor("_Color", Color.white);
        _isVideoLoaded = true;
    }

    /// <summary>
    /// プレイヤーがエリアに入ってきたときのイベント
    /// 動画の読み込みを開始する。
    /// </summary>
    public override void OnPlayerTriggerEnter(VRCPlayerApi player)
    {
        if (_isVideoReadied) return;
        _isVideoReadied = true;
        _videoPlayer.LoadURL(_url);
    }
}

このコードを使えばインタラクションを作ることができますが、実は少し問題があります。これを実際にワールドを公開したら、以下のように人によって動画の再生秒数が違って体験を共有できない問題が発生すると思います。

ややこしい話ですが、VRCのオブジェクトは基本的にそれぞれのプレイヤーの環境で勝手に動いています。なので同じワールドの同じ場所に立っていてもプレイヤーによって見えるものが違うということが起きます。今回で言うとエリアに人が入った時間の差分だけ動画の再生時間がズレています。

Player毎に別々の時間軸で動いている

その解決として同期という手法が使われます。
同期はある1人のプレイヤーをオーナーとして扱い、オーナーのパラメータをそのワールドにいるすべてのプレイヤーと共有します。それぞれで勝手に動いているオブジェクトを意図的に誰か1人と揃えるイメージです。

今回だと動画の再生時間を同期対象にすることができればすべてのプレイヤーが同じ場面を見ることができるようになります。

その考えに沿って、先ほどのコードを同期に対応させたら以下のようになります。オーナーが動画時間を管理し、それ以外のプレイヤーはその値やイベントに従う流れになっています。

Player1の時間に他プレイヤーが合わせる。
using UdonSharp;
using UnityEngine;
using VRC.SDK3.Video.Components.AVPro;
using VRC.SDKBase;
using VRC.Udon.Common.Interfaces;

// このスクリプトは同期を行うことを表す。
// 同期タイミングは手動で、RequestSerializationのタイミングで行われるようにする
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class SyncMonitor : UdonSharpBehaviour
{
    private bool _isVideoReadied, _isVideoLoaded;

    // 動画再生時間を表す同期用変数
    // オーナーの値で更新される
    [UdonSynced(UdonSyncMode.None)] private float _mvTime;

    [SerializeField] private VRCUrl _url;
    [SerializeField] private VRCAVProVideoPlayer _videoPlayer;
    [SerializeField] private MeshRenderer _renderer;

    /// <summary>
    /// Update
    /// 毎フレームでオーナーの動画再生時間をすべてのプレイヤーに伝える。
    /// </summary>
    private void Update()
    {
        if (!_isVideoLoaded) { return; }
        if (!Networking.IsOwner(gameObject)) { return; }
        _mvTime = _videoPlayer.GetTime();
        RequestSerialization();
    }

    /// <summary>
    /// 動画が読み込み終わったときのイベント
    /// 再生開始時間をオーナーと合わせて再生開始する。
    /// </summary>
    public override void OnVideoReady()
    {
        _videoPlayer.Play();
        _videoPlayer.SetTime(_mvTime);
        _renderer.material.SetColor("_Color", Color.white);
        _isVideoLoaded = true;
    }

    /// <summary>
    /// 動画の再生が終了したときのイベント
    /// オーナーが再生終了したとき、すべてのプレイヤーの再生時間を0秒で揃える。
    /// </summary>
    public override void OnVideoEnd()
    {
        if (!Networking.IsOwner(gameObject)) { return; }
        SendCustomNetworkEvent(NetworkEventTarget.All, "ResetTime");
    }

    /// <summary>
    /// 動画の再生時間を0秒に戻す
    /// </summary>
    public void ResetTime()
    {
        if (!_isVideoLoaded) return;
        _videoPlayer.SetTime(0);
    }

    /// <summary>
    /// プレイヤーがエリアに入ってきたときのイベント
    /// 動画の読み込みを開始する。
    /// </summary>
    public override void OnPlayerTriggerEnter(VRCPlayerApi player)
    {
        if (_isVideoReadied) return;
        _isVideoReadied = true;
        _videoPlayer.LoadURL(_url);
    }
}

なかなか複雑になりましたね。
オーナーとそれ以外のプレイヤーの挙動を意識し、どこで動画のタイミングを合わせるかを考える必要があります。

ただ今回は説明のためにあえて動画再生を扱いましたが、実は簡単な同期であれば特にこの辺りを考える必要はありません。たとえば、今回のワールドでは金魚の座標も同期を取る設定になっていますが、これは動画再生に比べて非常に簡単に実現されています。

金魚に「VRC Object Sync」というコンポーネントをアタッチすれば内部的に先ほど同じことをやって座標や回転を勝手に同期してくれます。

同期を上手く利用して複雑なシーンをみんなで楽しむことができればより賑やかなワールドになると思うのでぜひ活用してみてください。

ちなみにマルチプレイをテストする際は、ビルド画面のローカルテストクライアント数を複数にすれば動画のように簡単にできます。

Number of Clientsを複数に設定

まとめ

今回はVRChatのワールド開発の一部をご紹介しました。
VRCの洗練された仕組みとその中で自分のアイデアを実現していく楽しみが少しでもお伝えできていれば幸いです。



■ワントゥーテンでは中途採用募集中です!

1→10(ワントゥーテン)のカルチャーや、作品のクリエイティブに共感し、自身のより高い成長を求めている方からのご応募をお待ちしています!