yu00’s blog

プログラミングに関する備忘録です

VRChatワールド開発テスト環境構築の例

VRChatの開発ツール導入ではなく、UdonSharpで開発・テストする上での
ノウハウをまとめた記事になります。
独学なのでこれが正しい・最適とは限らないことに注意です。
VRChatの説明ですが、ほかのプラットフォームでも使えるはずです。

方針

  • Unityはクラッシュする前提で構築する
    • クラッシュするとシーン・プレハブが壊れることがある
    • クラッシュしても大丈夫なように、シーン・プレハブはEditorで自動生成する
  • テスト用と製品用のスクリプトを分ける
    • テスト用はブレイクポイントが使えるようにVRChatによならいコードにする
    • テスト用から製品用のスクリプトを自動生成する

開発手順図

  1. テスト用スクリプトMockScriptを作成し、シーンMockSceneに配置する
  2. MockSceneのテストMockTestを行う
  3. MockScriptをビルドし製品用スクリプトProductScriptを作成、シーンProductSceneに配置する
  4. ProductSceneのテストをClientSimで行う
  5. ProductSceneをビルドしVRChatアプリを作成、ローカルテストを行う
  6. ProductSceneをアップロードしオンラインテストを行う

次の項から開発手順を詳しく説明していきます。

Assembly definitionの作成

プロジェクトの設定が終わったら、最初にAssembly definitionを作成します。
テスト用スクリプトのための「Mocksフォルダ」、
製品用スクリプトのための「Scriptsフォルダ」、
Editor拡張用のための「Editorフォルダ」以下に下記
Assembly definitionを作成します。

まず、Mocksフォルダ以下にAssembly definitionを作成します。
手順

  1. Create > Assembly definition
    • MockAssemblyという名前で保存する

次に、Scriptsフォルダ以下にAssembly definitionと
U# Assembly Definitionを作成します。
手順

  1. Create > Assembly definition
    • ProductAssemblyという名前で保存する
  2. Assembly Definition References
    • VRC.SDK3
    • VRC.SDKBase
    • VRC.Udon
    • UdonSharp.Runtime
  3. Apply
  4. Create > U# Assembly definition
  5. Source Assembly : ProductAssembly
  6. Apply

最後に、UdonSharpのEditorユーティリティを使えるようにするため、
Editorフォルダ以下にAssembly definition referenceを作成します。
手順

  1. Create > Assembly Definition Reference
  2. Assembly Definition : UdonSharp.Editor を選択
  3. Apply

テスト・製品用スクリプトの初期生成

Editor拡張でファイル名を入力すると、テスト・製品用スクリプト
自動生成するスクリプトを作成しました。
テスト用スクリプトは「Mocks/Mockスクリプト名.cs」、製品用スクリプト
「Scripts/スクリプト名.cs」という名前で配置するようにしました。

スクリプトは以下です。

using System.IO;
using UdonSharp;
using UnityEditor;
using UnityEngine;

namespace UdonSharpEditor
{
    internal class CreateScript : EditorWindow
    {
        public string Text;

        string TemplatePath = @"C:\Program Files\Unity\Hub\Editor\2019.4.31f1\Editor\Data\Resources\ScriptTemplates\81-C# Script-NewBehaviourScript.cs.txt";

        [MenuItem("My Window/My Editor")]
        public static void MyEditorWindow()
        {
            GetWindow(typeof(CreateScript));
        }
        void OnGUI()
        {
            Text = EditorGUILayout.TextField("Script", Text);
            if (GUILayout.Button("Create Script"))
            {

                CreateUSharpScript($"Assets/Scripts/{Text}.cs");
                ProjectWindowUtil.CreateScriptAssetFromTemplateFile(TemplatePath, $"Assets/Mocks/Mock{Text}.cs");
            }
        }

        private static void CreateUSharpScript(string chosenFilePath)
        {
            // Packages\com.vrchat.udonsharp\Editor\Editors\UdonSharpBehaviourEditor.cs CreateUSharpScript() を参考に作成
        }
    }
}

テスト用スクリプトの生成はProjectWindowUtil.CreateScriptAssetFromTemplateFile
を使用しました。
UdonSharpの自動生成は「Packages\com.vrchat.udonsharp\Editor\Editors\UdonSharpBehaviourEditor.cs CreateUSharpScript()」
を参考にしました。

テスト用スクリプトの記述

ゲームのロジック部のスクリプトを記述します。
できるだけテスト用と製品用どちらでも動作するように、
基本的にUdonSharpに依存しないようにスクリプトを書いていきます。
もしテスト用と製品用でコードを分けたい場合は以下のように
PRODUCT defineを使用して分岐します。
PRODUCT defineは後述するように製品用スクリプトだけで定義されます。

#if !PRODUCT
    // テスト用コード
#else
    // 製品用コード
#endif

製品用スクリプトの生成

テスト用スクリプトから製品用スクリプトを生成します。
基本的にテスト用スクリプトをそのままコピーして製品用スクリプトにします。
足りない部分はUdonSharp専用の処理を追加します。

以下で詳しい手順を説明します。

Makeファイルの作成

変更したファイルのスクリプトだけを生成するため、
Makeファイルを作成するようにしました。
MakeファイルはPythonで作成しました。

import os
from glob import glob
import pathlib
import textwrap

PROJECT_NAME = 'MyProject'
mock_files_abs_path = glob(f'{PROJECT_NAME}/Assets/Mocks/*.cs')
mock_files = [
    pathlib.Path(file).relative_to(f'{PROJECT_NAME}')
    for file in mock_files_abs_path
]
script_files = []
for mock_file in mock_files:
    mock_file_base = os.path.basename(mock_file)
    script_file_base = mock_file_base.replace('Mock', '')
    script_files += [f'Assets/Scripts/{script_file_base}']

makefile = f"""
.PHONY: all clean
all: {' '.join(script_files)}
"""

for mock_file, script_file in zip(mock_files, script_files):
    makefile += textwrap.dedent(f"""
    {script_file}:{mock_file} make.py
    \tpython make.py $@ $<
    """)

with open(f'{PROJECT_NAME}/Makefile', 'w') as f:
    f.write(makefile)

上記を実行すると、以下のようなMakeファイルが作成されます。

.PHONY: all clean
all: Assets/Scripts/MyScript1.cs Assets/Scripts/MyScript2.cs

Assets/Scripts/MyScript1.cs:Assets\Mocks\MockMyScript1.cs make.py
    python make.py $@ $<

Assets/Scripts/MyScript2.cs:Assets\Mocks\MockMyScript2.cs make.py
    python make.py $@ $<

上記は、MockMyScript1.csからMyScript1.csを、
MockMyScript2.csからMyScript2.csをMakeする例です。

Makeコマンドの実行

作成したMakeファイルに対してMakeコマンドを実行すると以下の
「make.py」が実行されます。

import os
import re
from glob import glob
import sys

script_file_path = sys.argv[1]
mock_file_path = sys.argv[2]

def get_mock_classes():
    files = glob(f'{module_dir}/Assets/Mocks/*.cs')
    classes = {}
    for file in files:
        _class = os.path.splitext(os.path.basename(file))[0]
        target_class = _class.replace('Mock', '')
        classes[_class] = target_class
    return classes
mock_classes = get_mock_classes()

with open(mock_file_path, encoding='utf-8') as f:
    mock_strs = f.read()

mock_strs = '''
#define PRODUCT
using UdonSharp;
using VRC.SDKBase;
using VRC.Udon;
using VRC.Udon.Common.Interfaces;
using VRC.SDK3.Components;
''' + mock_strs

replace_items = [
    (rf'class {config["mock_prefix"]}(\w+)', r'class \g<1>'),
    ('MonoBehaviour', 'UdonSharpBehaviour'),
    (r'Debug.Assert\((.+)\)', r'if (!(\g<1>)) Debug.LogError($"Assert {name} (\g<1>) (at __FILE__)")'),
]
for replace_item in replace_items:
    mock_strs = mock_strs.split("\n")
    replace_item1 = replace_item[1].replace("__FILE__", script_file_path)
    for i,mock_str in enumerate(mock_strs):
        mock_strs[i] = re.sub(replace_item[0], replace_item1, mock_str)
    mock_strs = "\n".join(mock_strs)
for _class, target_class in mock_classes.items():
    mock_strs = mock_strs.replace(_class, target_class)

if not os.path.isfile(script_file_path):
    raise FileNotFoundError(script_file_path)
with open(script_file_path, 'w', encoding='utf-8') as f:
    f.write(mock_strs)

「make.py」はテスト用スクリプトをコピーして
製品用スクリプトを作成した後、以下のようなUdonSharp用の
変更を行っています。

PRODUCT defineの追加

先頭行に「#define PRODUCT」を追加します。

usingの追加

UdonSharpのusingを追加します。

Assertの変更

VRChatではAssertが使えないため、
「Debug.Assert」を「Debug.LogError」に変更しています。

シーンの生成

テスト用シーン「MockScene.unity」、製品用シーン「ProductScene.unity」
をEditor拡張で自動生成するようにしました。
GameObjectはテスト・製品用シーンで共通の構造とし、Prefab化することで
通化しました。
次からEditor拡張でシーンを生成する手順を説明します。

シーンを開く

まずプレハブを作るために、テスト用シーンを開きます。

string scenePath = "Assets/MockScene.unity";
var scene = EditorSceneManager.OpenScene(scenePath);

環境プレハブの作成

次に環境をまとめたプレハブ「Environment.prefab」を作成します。

static GameObject AddPrefab(string path, string name, out GameObject prefab, GameObject parent=null)
{
    prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
    Debug.Assert(prefab != null, $"{path} {name}");
    GameObject obj = PrefabUtility.InstantiatePrefab(prefab) as GameObject;
    //Undo.RegisterCreatedObjectUndo(obj, "Create GameObject");
    if (parent != null) obj.transform.SetParent(parent.transform, false);
    obj.name = name;
    return obj;
}

var Environment = AddEmptyGameObject("Environment");
AddPrefab("Packages/com.vrchat.worlds/Samples/UdonExampleScene/Prefabs/VRCWorld.prefab", "VRCWorld", out _, Environment);

ルートGameObjectの作成

次にテスト・製品用ルートGameObject「Mocks」・「Products」を作成します。

static GameObject AddEmptyGameObject(string name, GameObject parent=null)
{
    GameObject obj = new GameObject(name);
    //Undo.RegisterCreatedObjectUndo(obj, "Create GameObject");
    if (parent != null) obj.transform.SetParent(parent.transform, false);
    return obj;
}
var Mocks = AddEmptyGameObject("Mocks");
var Products = AddEmptyGameObject("Products");

共通プレハブの作成

次にテスト・製品用共通プレハブ「SceneMgr.prefab」を作成します。

var SceneMgr = AddEmptyGameObject("SceneMgr");

// SceneMgr以下にGameObjectの作成、マテリアルの設定、オーディオの設定などを行う

string SceneMgrPrefabPath = "Assets/Prefabs/SceneMgr.prefab";
PrefabUtility.SaveAsPrefabAssetAndConnect(SceneMgr, SceneMgrPrefabPath, InteractionMode.AutomatedAction);
DestroyImmediate(SceneMgr);

スクリプトのAddComponent

次にテスト・製品用のSceneMgr.prefabにそれぞれのスクリプトをAddComponentします

static MonoBehaviour AddScriptComponent(object root, string objPath, string path, string objType, string scriptPathPrefix, bool enabled = false)
{
    string _path = scriptPathPrefix + path;
    GameObject _root = null;
    if (root is GameObject) _root = root as GameObject;
    else _root = (root as MonoBehaviour).gameObject;
    Debug.Assert(root != null, $"{root.GetType().Name} {objPath} {_path}");
    GameObject obj;
    if (objPath == "")
    {
        obj = _root;
    }
    else
    {
        Transform transform = _root.transform.Find(objPath);
        Debug.Assert(transform != null, $"Transform Not Found {root.GetType().Name} {objPath} {_path}");
        obj = _root.transform.Find(objPath).gameObject;
    }
    Debug.Assert(obj != null, $"{objPath} {_path}");
    var script = AssetDatabase.LoadAssetAtPath<MonoScript>(_path);
    Debug.Assert(script != null, $"Script Not Found {objPath} {_path}");
    MonoBehaviour component;
    if (objType == "Mock")
    {
        //component = (MonoBehaviour)Undo.AddComponent(obj, script.GetClass());
        component = (MonoBehaviour)obj.AddComponent(script.GetClass());
        component.enabled = enabled;
    }
    else
    {
        //var udonSharpComponent = UdonSharpEditor.UdonSharpUndo.AddComponent(obj, script.GetClass());
        var udonSharpComponent = obj.AddUdonSharpComponent(script.GetClass());

        udonSharpComponent.enabled = enabled;
        component = udonSharpComponent;
    }
    return component;
}

foreach (var objType in new string[] { "Mock", "Product" })
{
    var root = (objType == "Mock") ? Mocks : Products;
    var scriptPefix = (objType == "Mock") ? "Mock" : "";
    var prefabPefix = (objType == "Mock") ? "Mock" : "Product";
    var dir = (objType == "Mock") ? "Mocks" : "Scripts";
    var scriptPathPrefix = $"Assets/{dir}/{scriptPefix}";

    var SceneMgr = AddPrefab("Assets/Prefabs/SceneMgr.prefab", "SceneMgr", out _, root);

    AddScriptComponent(SceneMgr, "", "SceneMgr.cs", objType, scriptPathPrefix, true);
    AddScriptComponent(SceneMgr, "MyObj", "MyObj.cs", objType, scriptPathPrefix);
}

UIの設定

次に製品用のGameObjectにボタンなどのUIを設定します。
詳しくはUdonSharpのボタンクリックイベントをUnityEditor拡張で登録するをご覧ください。

さらに製品用のGameObjectにVRCUiShapeなどUdon独自のUIを設定します。

var MyUi = SceneMgr.transform.Find('MyUi').gameObject;
MyUi..AddComponent<VRCUiShape>();
var MyAudio = SceneMgr.transform.Find('MyAudio').gameObject;
MyAudio.AddComponent<VRCSpatialAudioSource>();

プレハブ・シーンの保存

最後に作成したGameObjectをプレハブ・シーンとして保存します。

PrefabUtility.SaveAsPrefabAssetAndConnect(Environment, "Assets/Prefabs/Environment.prefab", InteractionMode.AutomatedAction);

PrefabUtility.SaveAsPrefabAssetAndConnect(Products, "Assets/Prefabs/Products.prefab", InteractionMode.AutomatedAction);

EditorSceneManager.SaveOpenScenes();

テスト用シーンのテスト

UnityのPlayを使いテスト用シーンのテストを行います。
不具合が見つかった場合はブレイクポイントを使い調査します。

製品用シーンのテスト

ClientSimを使い製品用シーンのテストを行います。
不具合が見つかった場合はInspectorからpublic変数の値を見て調査します。

ローカルVRChatアプリのテスト

VRChat SDKのLocal Testingを使いテストを行います。
この時Number of Clientsに2以上を指定して同期のテストを行います。
不具合が見つかった場合はDebug.LogErrorを使い調査します。

オンラインVRChatアプリのテスト

プライベートパブリッシュを行いテストを行います。
Quest対応を行う場合はここでテストを行います。

バージョン

  • Unity : 2019.4.31f1
  • VRChat SDK - Worlds : 3.2.3
  • UdonSharp : 1.1.9