Unityでのマルチプレイアプリ開発を便利にしてくれるEditor拡張-ParrelSyncを深ぼってみる

はじめに

こんにちはエンジニアの吉田です。
Synamon Advent Calendar 20212日目は、UnityのPhotonなどを使ったマルチプレイアプリ開発で必須のEditor拡張「ParrelSync」について、その実装を深ぼってみる中で知れた内容をまとめました。ParrelSyncは使っているけど、その仕組みについてもう少し理解しておきたいという人におすすめの記事となっております。

なお、ParrelSyncの使い方はここでは説明しませんので、使い方を知りたい方はREADMEや日本語の解説記事を検索して読んでください。


ParrelSync

github.com

通常、Unityでは一つのプロジェクトを複数のUnityEditorで開くことはできません。もちろんプロジェクトフォルダを丸ごとコピーして別プロジェクトとすれば開けますが、これでは片方のプロジェクトの変更がもう片方に反映されないため不便です。ParrelSyncはシンボリックリンク等(後述するが正確にはOSによってリンクの貼り方が異なる)を使うことによってこの問題を解決しており、オリジナルのプロジェクトの変更をコピー先にも自動で反映してくれます。

ちなみに、一つのプロジェクトを複数のUnityEditorで開くのがどんな場面で便利かというと、Photonなどを使ったマルチプレイアプリ開発の効率化にとても役立ちます。通常複数人のプレイを実際に再現するにはスタンドアロンビルドし、そのビルドを複数立ち上げる必要があります。しかし、同一プロジェクトで複数のUnityEdtiorを立ち上げることができれば、各Editorでゲームを再生すればそれが再現できてしまうのです。

複数人コラボレーションができるNEUTRANSを開発しているSynamonではそれなりにお世話になっているツールですが、GitHubのスター数が1,372個あるので世の中的にもそれなりにメジャーではあるようです。(2021/11/13日現在)

これは余談なのですが、自分はParrelSyncをPallalelSyncだとずっと勘違いしてました。 ググったところ"Parrel"というのは、こんな感じの玉がつながってるものを意味してるらしいです。


動作環境

  • UnityEditor:2020.3.16f1
  • ParrelSync:ver1.5.0(リポジトリのReleaseページよりunitypackageをダウンロードしてインポートした)
  • OS:Windows10


本題

それでは中身を調べてみてわかったことについて書いていきます。

ParrelSync自体はとても小さなプロジェクトでC#のファイルは12個しかありません。また、ClonesManager.csというクラスが中核でこのクラスを見れば大体の処理内容がわかります。

クローン方法について

まず、メインの機能であるプロジェクトのクローン方法について見ていきます。 クローン方法の概要はREADMEに書いてあります。

...Asset, Packages, ProjectSettingsのコピーとシンボリックリンクを使ったオリジナルプロジェクトへの参照をつくる。Library, Temp, objといった他のフォルダは各クローンごとに独立させる。... (筆者訳)

実際にクローンをつくってみます。 新しいUnityプロジェクトを「DigParrelSync」という名前つくり、そのクローンを1つ作成してみました。 クローンされたプロジェクトの名前は「DigParrelSync_clone_0」になっており、中身は以下の画像にあるとおりです。

f:id:kkkkkkssssss:20211115110709p:plain
クローン直後

〇クローンされたプロジェクトの中身

  • (リンク)Assets
  • Library
  • Packages
  • (リンク)ProjectSettings
  • .clone
  • .parrelsyncarg
  • collabignore.txt

順番に見ていきます。


リンクが貼られるもの:Assets, ProjectSettings, (AutoBuild, LocalPackages)

まず、リンクが貼られるものについて見ていきます。対象は、Assets, ProjectSettingsフォルダ、そしてもし存在する場合にはAutoBuild, LocalPackagesフォルダも対象となります。

これらはリンクによってオリジナルのプロジェクトと同期されるものです。リンク方法はOSによって異なっており以下のようになっています。

// メソッドの中身はそのままですが、メソッドの順番は本物と異なります。
// 日本語のコメントは適宜筆者が追加してます。

private static void CreateLinkWin(string sourcePath, string destinationPath)
{
    // /Jオプションはジャンクションなので、正確にはシンボリックリンクじゃない!
    string cmd = "/C mklink /J " + string.Format("\"{0}\" \"{1}\"", destinationPath, sourcePath);
    Debug.Log("Windows junction: " + cmd);
    ClonesManager.StartHiddenConsoleProcess("cmd.exe", cmd);
}

private static void CreateLinkMac(string sourcePath, string destinationPath)
{
    sourcePath = sourcePath.Replace(" ", "\\ ");
    destinationPath = destinationPath.Replace(" ", "\\ ");
    var command = string.Format("ln -s {0} {1}", sourcePath, destinationPath);

    Debug.Log("Mac hard link " + command);

    ClonesManager.ExecuteBashCommand(command);
}

private static void CreateLinkLinux(string sourcePath, string destinationPath)
{
    sourcePath = sourcePath.Replace(" ", "\\ ");
    destinationPath = destinationPath.Replace(" ", "\\ ");
    var command = string.Format("ln -s {0} {1}", sourcePath, destinationPath);           

    Debug.Log("Linux Symlink " + command);

    ClonesManager.ExecuteBashCommand(command);
}

// 長いのでここでは中身を省略。どちらもSystem.Diagnostics.Processをつかってcmd.exeやbashを実行してます。
private static void StartHiddenConsoleProcess(string fileName, string args){/*省略*/}
private static void ExecuteBashCommand(string command){/*省略*/}

それぞれSystem.Diagnostics.Processを使い、cmd.exeやbashを別プロセスで起動させリンクをつくっています。MacとLinuxは同じコマンドで、Windowsは異なります。

コードを見るとわかるのですが、MacとLinuxは文字通りシンボリックリンクなのに対し、Windowsではジャンクションを使っています。Windowsでは"ハードリンク"、"シンボリックリンク"、"ジャンクション"という3種類のリンクが区別されるので、正確にいうならシンボリックリンクではないです。 コード内でもDebug.Log("Windows junction: " + cmd);と書いてるのでシンボリックリンクではなくジャンクションにしいるのは意図的なようです。

Windowsでジャンクションではなくシンボリックリンクを使った場合どうなるかを試してみたところ、リンクをつくることができませんでした(↑コードのCreateLinkWin()内のmklink /Jの部分をmklink /Dに置き換えて試した)。シンボリックリンクがつくれない正確な理由はわかりませんが、Windowsではシンボリックリンクをつくるには管理者権限が必要なのが原因なのではないかなと思いました。

ちなみに管理者権限でUnityを起動してどうなるか試したかったのですがやり方がわかりませんでした。Unity.exeやUnityHubを管理者権限で実行してもなぜか管理者権限にならない...

まぁ、ジャンクションだからといって問題があるわけではないので、これは放っておきます。

あと細かいところですが、Mac用のメソッド内にDebug.Log("Mac hard link " + command);と書かれており、実際はシンボリックリンク使ってるのに"hard link"と書かれていてちょっと紛らわしいです。ここは修正のプルリクを出すことにします。

// Link Folders
// それぞれの変数が表すパスは以下。
// assetPath = "/Asset", projectSettingPath ="/ProjectSettigns", autoBuildPath = "/AutoBuild", locakPackages = "/LocalPackages"
ClonesManager.LinkFolders(sourceProject.assetPath, cloneProject.assetPath);
ClonesManager.LinkFolders(sourceProject.projectSettingsPath, cloneProject.projectSettingsPath);
ClonesManager.LinkFolders(sourceProject.autoBuildPath, cloneProject.autoBuildPath);
ClonesManager.LinkFolders(sourceProject.localPackages, cloneProject.localPackages);

また、READMEには書かれていませんが、プロジェクトフォルダ直下にAutoBuild, LocalPackagesという名前のフォルダがある場合はそれらについてもリンクが張られる仕様がコードでは見られました。AutoBuildって何に使うフォルダなんですかね?もし知ってる人いたら教えてください。


最初だけコピーがつくられるもの:Library, Packages

次はリンクは貼られないが、クローン作製時にのみ一度だけオリジナルからコピーが作られるものを見ていきます。対象はLibrary, Packagesフォルダです。

READMEでは「クローン毎に独立させる」と書いてあったLibraryフォルダですが、リンクは貼られないものの、クローン作製時にはオリジナルのものがコピーされるようです。(コード該当箇所

また、READMEには書かれていませんでしたが、PackagesフォルダもLibraryフォルダと同様にリンクが貼られないもののクローン作製時にはオリジナルのものがコピーされています。そしてLibarryフォルダとの違いは、各クローンプロジェクトの起動時にその中身がオリジナルプロジェクトのものと一致しているかの確認と、異なっている場合はコピーし直しがされていました(コード該当箇所

Libraryフォルダが初回だけコピーされる理由やTempフォルダがコピーもリンクもつくられない理由については、以下の記事が参考になります。 (記事の中ではLibraryフォルダもシンボリックリンクする方法が紹介されている点がParrelSyncのやり方と異なるので注意です。)

tsubakit1.hateblo.jp

Unityが特定のプロジェクトを複数起動できない最大の理由はTempフォルダの存在です。このフォルダは「起動時・終了時に削除される」事が期待されており、この中のファイルはゲーム再生時やビルド時の一時フォルダとして使用されています。

Tempフォルダは、プロジェクトを起動する各EditorごとにそのEditorが起動中に管理しておきたい情報を一時保存する場所なので、クローンにはコピーされないようですね。

LibraryフォルダはTempとは異なり、長期的なキャッシュを行うフォルダです。このフォルダにも様々なファイルが格納されますが、概ねファイルと対になるので、Assetsが同一ならば共有してもたぶん問題にはなりません。

LibraryフォルダはTempフォルダとは異なり長期的なキャッシュを保持する場所のようですね。

また上記記事の中ではLibraryフォルダもシンボリックリンクで共有していますが、ParrelSyncでは行われていません。どちらが良いのか断定はできませんが、両方のやり方をしたことがある自分の経験ではLibraryフォルダはリンクしないParrelSyncの方が安定している気がしました。Libraryフォルダをリンクしていたときは時々原因不明のエラー(Libraryをリンクしているのが原因かも不明)がでてクローン側のプロジェクトを立ち上げ直す必要があったためです。


新たにつくられるもの:.clone, .parrelsyncarg, collabignore.txt

次はオリジナルプロジェクトには存在しておらず、クローン側にだけつくられるものたちを見ていきます。対象は、.clone, .parrelsyncarg, collabignore.txtファイルです。

.cloneファイルは、ParrelSyncがプロジェクトがオリジナルなのかクローンなのかを識別するために使われるファイルです。そのため絶対に消してはいけないファイルです。誤って消してしまわないように気をつけましょう。

.parrelsyncargファイルは、クローンプロジェクトに特定の文字列を受け渡すためのファイルです。中身はただのテキストです。Documentでも説明されている通り、ParrelSyncではクローンプロジェクト側に特定の文字列を受け渡しClonesManagerクラス経由でコードからそれを取得できる仕組みが用意されています。自分はまだ使ったことがありませんが、マルチプレイアプリを複数立ち上げる際に、ユーザー名などをここで受け渡すと便利かもしれませんね。

collabignore.txtファイルはUnity Collaborateを使っている場合に、クローンプロジェクトがクラウドにつながってしまうのを防ぐためのファイルです。

docs.unity3d.com


その他勉強になったこと

クローン側ではアセットの変更を不可能にする方法

ParrelSyncではおかしな挙動を防ぐためにクローン側ではAssetの変更が不可能になっています。クローン側でアセットを変更して保存しようとするとこんなダイアログが表示されて変更が保存されません。

f:id:kkkkkkssssss:20211115110919p:plain
クローン側のプロジェクトはAssetの変更を保存できない

これどうやって実現してるのか気になって調べたのですが、UnityEditor.AssetModificationProcessorを使っていました。このクラスを継承していれば、Assetの変更保存時にOnWillSaveAssets(string[] paths)が呼ばれます。ここで引き数のpathsには変更のあったAssetのパス一覧が渡され、それを加工して戻り値として返すことができます。クローンプロジェクトの場合は、↑のダイアログを出しつつ、ここでパス一覧を上書きして空にしているようです。こんなAPIがあるんですね、知らなかった。

public class ParrelSyncAssetModificationProcessor : UnityEditor.AssetModificationProcessor
{
    public static string[] OnWillSaveAssets(string[] paths)
    {
        if (ClonesManager.IsClone() && Preferences.AssetModPref.Value)
        {
            if (paths != null && paths.Length > 0 && !EditorQuit.IsQuiting)
            {
                EditorUtility.DisplayDialog(
                    // 長いのでここでは中身省略させてもらいます。
                );
                foreach (var path in paths)
                {
                    Debug.Log("Attempting to save " + path + " are blocked.");
                }
            }
            return new string[0] { };
        }
        return paths;
    }
}


ファイル操作

ParrelSyncではフォルダをコピーしたりリンクを貼ったりしていますが、OSに依らずそのやり方は次の2通りです。

どちらも自分はあまり使ったことはありませんが、存在をしっておくといつか便利そうだなと思いました。

また一つ気になった点として、クローンプロジェクトの削除の際、MacとLinuxではUnityEditor.FileUtil.DeleteFileOrDirectoryを使っているのに対し、Windowsではコマンドで削除しています。

// クローンプロジェクトの削除処理をプラットフォームによって変化させているところを抜粋。
// 見やすさのためコードコメントはここでは消去した。

switch (Application.platform)
{
    case (RuntimePlatform.WindowsEditor):
        Debug.Log("Attempting to delete folder \"" + cloneProjectPath + "\"");

        identifierFile = Path.Combine(cloneProjectPath, ClonesManager.ArgumentFileName);
        File.Delete(identifierFile);

        // ここがWindowsだけ異なる。FileUtil.DeleteFileOrDirectory()を使わずコマンドで削除している。
        args = "/c " + @"rmdir /s/q " + string.Format("\"{0}\"", cloneProjectPath);
        StartHiddenConsoleProcess("cmd.exe", args);

        break;
    case (RuntimePlatform.OSXEditor):
        Debug.Log("Attempting to delete folder \"" + cloneProjectPath + "\"");

        identifierFile = Path.Combine(cloneProjectPath, ClonesManager.ArgumentFileName);
        File.Delete(identifierFile);

        FileUtil.DeleteFileOrDirectory(cloneProjectPath);

        break;
    case (RuntimePlatform.LinuxEditor):
        Debug.Log("Attempting to delete folder \"" + cloneProjectPath + "\"");
        identifierFile = Path.Combine(cloneProjectPath, ClonesManager.ArgumentFileName);
        File.Delete(identifierFile);

        FileUtil.DeleteFileOrDirectory(cloneProjectPath);

        break;
    default:
        Debug.LogWarning("Not in a known editor. Where are you!?");
        break;
}

このようにWindowsだけ別の方法を使っている明確な理由は、コミット履歴などをたどってみても不明でした。

ただ試しに自分の手元のWindowsでUnityEditor.FileUtil.DeleteFileOrDirectoryを用いてジャンクションになっているフォルダを削除してみたところ、ジャンクションフォルダが削除されるだけでなく、オリジナルフォルダの中身も消えてしまいました!それが仕様なのかどうかは調べても情報がでてこず、もしかしたらUnityのAPI側のバグかもしれないなーと思いました。ここについては調べだすとParrelSyncというテーマから離れてしまうので一旦深追いはしませんが、いずれ何かわかったらブログに書きたいなと思います。


おわりに

今回は、Unityを使ったマルチプレイゲーム開発で必須のEditor拡張「ParrelSync」について、その中身を詳しく調査しました。

ただ使用するだけなら中身を詳しく知っていなくても特に問題はないのですが、少しでも中身を知っている方が安心して使えますね。また他の方がつくられたコードを読むことで、各種機能の実装方法を調べることでコードの書き方や知らなかったAPIを知れて、自分の勉強にもなりました。


宣伝

Synamon2021年アドベントカレンダーやります!
Unity/C#/XR関連の情報についてたくさん発信するので是非ウォッチしておいてください!

qiita.com

Synamonではエンジニア絶賛採用強化中です!少しでもご興味ある方はカジュアル面談や採用ページを覗いてみてください!

twitter.com

meety.net

herp.careers