猫茶の研究日誌

ゲーム開発などの技術や、そのほか趣味などの雑記。

【Unity/InputSystem】新・操作中のコントローラーの種類を識別する

はじめに

Unityを使ったPCゲームで、上動画のように、

  • PS5コントローラなら「×で決定」
  • Xboxコントローラなら「Aで決定」
  • SwitchのProコンなら「Bで決定」
  • マウス&キーボードなら「左クリックで決定」

と、自動で表示を切り替えたいとき、ありますよね?
(余談ですが、私が学生時代で制作したゲームたちには、ほぼ100%この手の仕組みを入れてました。こだわり。)

そこで、「今使っている入力デバイスを識別できるようにしよう!」という内容の記事です。

本記事は、以前に書いた記事のリメイクです。
2年近く前に書いた「Unityでコントローラーの種別を識別する(InputSystem)」の内容から、いろいろ追加・書き直しをした内容です。
qiita.com 対応デバイスの追加、実装のアプローチの変更、デバイス変更時のイベント処理など、変更点盛り沢山。

動作確認環境

  • Unity 2022.3.18f1
  • Windows 11 22H2

サンプルソース

とりあえず早速、サンプルソース貼ります。

InputDeviceManager

今回の記事の内容のすべてのソースです。

シングルトンクラスとして実装してみました。
シングルトンなので、シーンに空のゲームオブジェクトを1つ作って本コンポーネントをセットしておく必要があります。

using System.Collections;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.InputSystem;

public class InputDeviceManager : MonoBehaviour
{
    // シングルトン
    public static InputDeviceManager Instance { get; private set; }

    /// <summary>
    /// 入力デバイスの種別
    /// </summary>
    public enum InputDeviceType
    {
        Keyboard,   // キーボード・マウス
        Xbox,       // Xboxコントローラー
        DualShock4, // DualShock4(PS4)
        DualSense,  // DualSense(PS5)
        Switch,     // SwitchのProコントローラー
    }

    // 直近に操作された入力デバイスタイプ
    public InputDeviceType CurrentDeviceType { get; private set; } = InputDeviceType.Keyboard;

    // 各デバイスのすべてのキーを1つにバインドしたInputAction(キー種別検知用)
    private InputAction keyboardAnyKey = new InputAction(type: InputActionType.PassThrough, binding: "<Keyboard>/AnyKey", interactions: "Press");
    private InputAction mouseAnyKey = new InputAction(type: InputActionType.PassThrough, binding: "<Mouse>/*", interactions: "Press");
    private InputAction xInputAnyKey = new InputAction(type: InputActionType.PassThrough, binding: "<XInputController>/*", interactions: "Press");
    private InputAction dualShock4AnyKey = new InputAction(type: InputActionType.PassThrough, binding: "<DualShockGamepad>/*", interactions: "Press");
    private InputAction detectDualSenseAnyKey = new InputAction(type: InputActionType.PassThrough, binding: "<DualSenseGamepadHID>/*", interactions: "Press");
    private InputAction switchProControllerAnyKey = new InputAction(type: InputActionType.PassThrough, binding: "<SwitchProControllerHID>/*", interactions: "Press");

    // 入力デバイスタイプ変更イベント
    public UnityEvent OnChangeDeviceType { get; private set; } = new();

    private void Awake()
    {
        // シングルトン
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
        }

        // キー検知用アクションの有効化
        keyboardAnyKey.Enable();
        mouseAnyKey.Enable();
        xInputAnyKey.Enable();
        dualShock4AnyKey.Enable();
        detectDualSenseAnyKey.Enable();
        switchProControllerAnyKey.Enable();
    }

    
    private void Start()
    {
        // 初回のみ、必ず入力デバイスの種別検知を行ってコールバック発火
        StartCoroutine(InitializeDetection());
    }

    private void Update()
    {
        // 検知の更新処理
        UpdateDeviceTypesDetection();
    }

    /// <summary>
    /// 入力デバイスの種別検知を初期化する
    /// </summary>
    /// <returns></returns>
    IEnumerator InitializeDetection()
    {
        // 入力デバイスの種別検知を更新
        UpdateDeviceTypesDetection();
        // 1フレーム待機
        yield return null;
        // イベント強制発火
        OnChangeDeviceType.Invoke();
    }

    
    /// <summary>
    /// 入力デバイスの種別検知を更新する
    /// </summary>
    public void UpdateDeviceTypesDetection()
    {
        var beforeDeviceType = CurrentDeviceType;

        if (xInputAnyKey.triggered)
        {
            CurrentDeviceType = InputDeviceType.Xbox;
        }

        // DualSense(PS5)は、DualShock4(PS4)としても認識される。
        // つまり、DualSenseを操作しているときは、DualSchock4とDualSenseの両方が検知される。
        // DualSenseとDualShockの両方から同時に入力検知した場合は、DualSenseとして扱うようにする。
        if (dualShock4AnyKey.triggered)
        {
            CurrentDeviceType = InputDeviceType.DualShock4;
        }
        if (detectDualSenseAnyKey.triggered)
        {
            CurrentDeviceType = InputDeviceType.DualSense;
        }

        if (switchProControllerAnyKey.triggered)
        {
            CurrentDeviceType = InputDeviceType.Switch;
        }

        if (keyboardAnyKey.triggered || mouseAnyKey.triggered)
        {
            CurrentDeviceType = InputDeviceType.Keyboard;
        }

        // 操作デバイスが切り替わったとき、イベント発火
        if (beforeDeviceType != CurrentDeviceType)
        {
            OnChangeDeviceType.Invoke();
        }
    }
}

使い方説明のサンプルソース

下画像のように、操作中のデバイスが変わったときに、そのデバイスの種別をコンソールに出力するサンプルです。

using UnityEngine;

public class InputDeviceDetectionExample : MonoBehaviour
{
    private void Start()
    {
        // イベントハンドラの登録
        InputDeviceManager.Instance.OnChangeDeviceType.AddListener(OnChangeDeviceTypeHandler);
    }

    private void OnDestroy()
    {
        // イベントハンドラの解除
        InputDeviceManager.Instance.OnChangeDeviceType.RemoveListener(OnChangeDeviceTypeHandler);
    }

    private void OnChangeDeviceTypeHandler()
    {
        // 入力デバイスの種別が変更されたときの処理
        Debug.Log("入力デバイスの種別が変更されました。\n現在の入力デバイスの種別:" + InputDeviceManager.Instance.CurrentDeviceType);
    }
}

解説

ざっくりとだけ解説しておきます。

「いま使われているか?」は、直前のフレームでいずれかのボタンが押されたかで判定します。
このために、「毎フレーム、それぞれのデバイスについて、すべてのボタンの状態を確認する」 ということをやっていきます。

"いずれかのキー"が入力されたかの監視

すべてのキーを1つの入力と捉えるInputActionを作成

デバイス種ごとに、すべてのキーを1つの入力と捉えるInputActionを作成します。
これにより、各デバイス種ごとに「いずれかのキーが押されたか?」を確認できるようになります。

InputActionのコンストラクタでbindingに"<デバイス名>/*"を指定することで、そのデバイスのすべての入力を対象にしたアクションを作成できます。

...
    // 各デバイスのすべてのキーを1つにバインドしたInputAction(キー種別検知用)
    private InputAction mouseAnyKey = new InputAction(type: InputActionType.PassThrough, binding: "<Mouse>/*", interactions: "Press");
...

キーボードには「AnyKey」がもとからあるのでそれを使います。

   private InputAction keyboardAnyKey = new InputAction(type: InputActionType.PassThrough, binding: "<Keyboard>/AnyKey", interactions: "Press");
入力デバイス名の調べ方
InputActionのコンストラクタで指定するデバイス名は、以下の手順で調べることができます。
下動画と併せてご参考ください…👀
  1. なんでもいいのでInputActionの設定を開く。
  2. 任意のBindで、確認したい対象のデバイスのボタンを登録する。
    (下動画ではListenボタンを使って登録してます)
  3. 「T」ボタンを押して、登録されたPathを確認する。
    (冒頭の「<>」で囲まれた部分がデバイス名です。「<DualSenseGamepadHID>/dpad/down」なら、コンストラクタに登録するPathは「<DualSenseGamepadHID>/*」です。)

作成したInputActionの有効化

Awake関数内で、各InputActionを有効化しておくことをお忘れなく。

        // キー検知用アクションの有効化
        keyboardAnyKey.Enable();
        mouseAnyKey.Enable();
        xInputAnyKey.Enable();
        dualShock4AnyKey.Enable();
        detectDualSenseAnyKey.Enable();
        switchProControllerAnyKey.Enable();

入力されたか判定

あとは、各デバイスについて、毎フレームtriggeredを確認するだけです。
trueになっていれば、何かしら操作がされたということです。

if (xInputAnyKey.triggered)
{
    // XInputデバイスに、"いずれかのキー"の入力があった!
}

毎フレーム操作されたデバイスを確認する

さきほどの方法で、毎フレームすべての入力デバイスについて確認していくだけです。

...
        if (xInputAnyKey.triggered)
        {
            CurrentDeviceType = InputDeviceType.Xbox;
        }

        if (switchProControllerAnyKey.triggered)
        {
            CurrentDeviceType = InputDeviceType.Switch;
        }
...
        if (keyboardAnyKey.triggered || mouseAnyKey.triggered)
        {
            CurrentDeviceType = InputDeviceType.Keyboard;
        }
...

DualSenseコントローラーについて

…が、DualSense(PS5コントローラー)について、注意点があります。

DualSenseは、Unity上ではDualShock4(PS4コントローラー)としても認識されます。

つまり、DualSenseで〇ボタンを押したら、同時にDualShock4でも〇ボタンが押されたと認識されます。
そのため、必ずDualShock4の判定を終えてから、DualSenseの判定を行う必要があります。 (DualSenseが入力中は、DualShock4が入力されていても無視する)

...
        // DualSense(PS5)は、DualShock4(PS4)としても認識される。
        // つまり、DualSenseを操作しているときは、DualSchock4とDualSenseの両方が検知される。
        // DualSenseとDualShockの両方から同時に入力検知した場合は、DualSenseとして扱うようにする。
        if (dualShock4AnyKey.triggered)
        {
            CurrentDeviceType = InputDeviceType.DualShock4;
        }
        if (detectDualSenseAnyKey.triggered)
        {
            CurrentDeviceType = InputDeviceType.DualSense;
        }
...

デバイスが切り替わった瞬間の通知

毎フレームの各デバイスについての入力されたか判定の際に、最後のフレームから変わったかを確認しておきます。

この際、変わったときのイベント等を用意しておくと便利です。

 // 入力デバイスタイプ変更イベント
public UnityEvent OnChangeDeviceType { get; private set; } = new();
...
        var beforeDeviceType = CurrentDeviceType;
...(各デバイスについての入力されたか判定)
        // 操作デバイスが切り替わったとき、イベント発火
        if (beforeDeviceType != CurrentDeviceType)
        {
            OnChangeDeviceType.Invoke();
        }
...

初期化処理的なもの

初回フレームで、「操作中のデバイスの確認」、「強制的に変更時イベントを発火」をしておく必要があります。

これをしておかないと、デバイスの切り替え検知が正しく動かないことがあるので…

   private void Start()
    {
        // 初回のみ、必ず入力デバイスの種別検知を行ってコールバック発火
        StartCoroutine(InitializeDetection());
    }

    /// <summary>
    /// 入力デバイスの種別検知を初期化する
    /// </summary>
    /// <returns></returns>
    IEnumerator InitializeDetection()
    {
        // 入力デバイスの種別検知を更新
        UpdateDeviceTypesDetection();
        // 1フレーム待機
        yield return null;
        // イベント強制発火
        OnChangeDeviceType.Invoke();
    }

使い方

シングルトンクラスなので、シーンに空のゲームオブジェクト作ってInputDeviceManagerコンポーネント付けてあげれば、シーンのどこからでも使える。

いま使っているデバイス種を知りたければ、以下で取ってこれます。

InputDeviceManager.Instance.CurrentDeviceType

変更した際のイベントを登録しておけば、キーガイドのアイコンを切り替えたりするときに便利。

    private void Start()
    {
        // イベントハンドラの登録
        InputDeviceManager.Instance.OnChangeDeviceType.AddListener(OnChangeDeviceTypeHandler);
    }

おわりに

以前の記事では、すべてのキーに対してfor文で認識してましたが、今回はすべての入力をまとめてバインドしたInputActionを作成するというアプローチに変えてみました。

マウス移動やアナログパッドのような入力も取ってこれる上に、今後新たなコントローラに対応したくなった際にも、デバイス名を確認すれば理論上いくらでも可能だったりと、メリットの多い方式だと思います。
ちょっと実装長くなっちゃうけど。

とりあえず、いつか書き直したい記事ナンバーワンを書き直せて満足です。
(こんどは実際にキーアイコンを切り替えるところの解説書きたいなぁ…書いてる時間あるかなぁ…)

…あと、本記事冒頭に出てきたドタバタ対戦ゲーム「Prank Heart」は絶賛販売中!
特にフレンドと遊ぶと盛り上がると評判なので、ぜひ遊んでみてね。(宣伝)

store.steampowered.com

【Unity】ProBuilderの挙動が何故かおかしい

はじめに・症状

まずはこのgifを見てください。

ProBuilderでエッジループを追加(Insert Edge Loop)しようとしただけなのに…

怒涛のエラー

NullReferenceException: Object reference not set to an instance of an object
UnityEngine.ProBuilder.ProBuilderMesh.get_edgeCount ()
GUI Error: You are pushing more GUIClips than you are popping. Make sure they are balanced.
UnityEngine.GUIUtility:ProcessEvent (int,intptr,bool&)

面や辺が欠ける…

仕舞いには、Undo/Redoもめっちゃぶっ壊れてます。

エッジループに限らず、とにかくProBuilderの様子がおかしい。
とても使えるとは言えない。

試したこと

以下の2つでは、問題なく動作することを確認しました。

  • 違うPCで問題発生しているプロジェクトを開く
  • 新規プロジェクトを作成して試してみる

ということは、プロジェクトのLibraryフォルダ以下全削除で直るのでは…?と思い試したましたが、全く直らず。

なんやこれ。

解決法

Localization Scene ControlsのTrack Changesのチェックを外す。
それだけです。

考察

考察ってほどのものでもないですが…

Localization Scene ControlsのTrack Changesにチェックが入っている状態で選択したゲームオブジェクトに変更があると、
自動で「GameObjectLocalizer」コンポーネントが勝手に付きます。

docs.unity3d.com

明らかにこいつが狂わせています。
(というか当たり前だけどこれのせいでエディタ全体の挙動がおかしかった)

これでとてつもなく長い時間が吸われました。
ProBuilderの不調の原因が、Localizationで不要な設定をしたことであるなんて想像しなかったから…
英語で検索掛けても、該当する情報が見当たらず、本当に絶望してました…

Localization Scene ControlsのTrack Changesには間違えてチェックを入れないように、以後気を付けます。
以上。

SourceTreeが起動しなくなったので直した「Unable to load MEF components」

現象

記事名のとおり、SourceTreeが起動しなくなってしまいました。
Twitterで周囲でSourceTreeが起動しないという声をちらほら見かけたけど、同じ現象?

具体的には、 SourceTreeを起動ようとすると、スプラッシュ画面が表示だけしたのちに、
何かエラー画面を出すということもなく落ちるようになりました。

多分、2023年6月のWindows Updateを適用したのがキッカケ。
(KB5027119とKB5027231)

ログファイル( %LOCALAPPDATA%\Atlassian\SourceTree\sourcetree.log )を確認してみると、

「Unable to load MEF components」

とか言ってる。

解決法

ここに書いてました。

community.atlassian.com

以下の2つのファイルを削除すればOK。

  • %LOCALAPPDATA%\Atlassian\SourceTree.exe_<UID>\3.4.9.0\Assemblies.cache
  • %LOCALAPPDATA%\Atlassian\SourceTree.exe_<UID>\3.4.9.0\Composition.cache

※<UID>はユニークなID。適宜読み替えてください。
※3.4.9.0はSourceTreeのバージョン番号。インストールしてるバージョンによって違います。適宜読み替えてください。

見た感じ、WindowsUpdateで.NETの何かしらに変更が入って、SourceTreeのキャッシュとの整合性が取れなくなった・・・
ということなのかなぁと思ってます。

HLSL→WGSLへの変換をやってみる(SPIR-V経由)

目次

はじめに

DirectX向けにHLSL言語で書かれたシェーダプログラムを、WebGPUで使えるWGSLへと変換してみる記事です。
いわゆるトランスパイルというやつです。

実験してみたものの、すぐに忘れそうなので備忘録を兼ねて記事にしておきます。

SPIR-VとTintを使います。(詳細は後述)

余談ですが、WebGPUについて、C++でブラウザ向けに使う入門記事を先日書いたので、試してみたい方はぜひご参照ください。

zenn.dev

また、今回はWindows環境で解説します。
そのほかの環境は以下の通りです。

DirectX Shader Compiler(dxcompiler.dll: 1.7 - 1.7.2212.40 (e043f4a12); dxil.dll: 1.7(101.7.2212.36))
Tint (mainブランチ、 コミット 4ed79e689a0632837d3d956038ef7d78206dc22d(2023年4月上旬ごろ))
ninja 1.11.1
CMake 3.24.0

変換の流れ

HLSL→WGSLの直接変換する手段は、私が知る限り存在しません。
しかし、世の中には「 SPIR-V 」という便利な中間言語があります。

SPIR-Vは、シェーディング言語に特化した中間言語で、多くの環境に向けて使うことができます。
HLSLやWGSLも、SPIR-V形式での入出力に対応しています。
こちらのSPIR-Vを中継して、変換していきます。

つまり、 HLSL→SPIR-V→WGSL という流れでの変換になります。

HLSL→SPIR-V

DirectX Shader Compiler(DXC)を使います。
Microsoftが提供するオープンソースのHLSLコンパイラです。
ShaderModel 6.0以降にも対応しています。
SPIR-Vへ変換する機能もあるため、こちらを使います。

ちなみに、HLSL→SPIR-Vの変換ができるソフトウェアは、
UnityTechnologiesの HLSLcc (HLSLCrossCompiler のFork)や、
GoogleのShaderc などもあります。

SPIR-V→WGSL

Tint を使います。
Tintは、Googleによって、Chromium向けのWebGPUの実装「Dawn」とともに開発されている、シェーダーコンパイラです。

スタンドアロンで、exe形式として使ったり、ライブラリとして組み込むなどの使いかたもできます。

環境構築

DirectX Shader Compiler(DXC)

GitHubにビルドされた状態でReleaseされてるので dxc_xxxxxxxx.zip をDLしてきます。

https://github.com/microsoft/DirectXShaderCompiler/releases

DLできたら、適当なところに解凍します。

あとは、環境に合ったdxc.exeのある場所にPATHを通してください。
(比較的基礎的な部分なので、PATHの通し方は本記事では触れません)

私はx64環境なので、[DXCを解凍したディレクトリ]\bin\x64 にPATHを通しました。

任意のディレクトリで、

dxc --version

を実行して正常に動作すればOKです。

Tint

Tintはバイナリ形式では配布されていないので、自力でビルドする必要があります。

depot-toolsのインストール

Chromiumの開発で必要なツール群です。
公式のドキュメント(https://dawn.googlesource.com/tint/+/refs/heads/main#building)いわく、Chromiumの依存関係管理まわりが必要だそうなので、入れておきます。

公式のセットアップページ(http://commondatastorage.googleapis.com/chrome-infra-docs/flat/depot_tools/docs/html/depot_tools_tutorial.html#_setting_up)
にアクセスして、

WINDOWS
Download the depot_tools bundle and extract it somewhere.

と書いているあたりのリンクから、Windows用のzipファイル(depot_tools.zip)をDLしてきてください。

DLできたら、解凍、適当な場所に配置します。
こちらも同様にPATHを通しておきます。

Tintのビルド

copy standalone.gclient .gclient

gclient sync

# 出力先ディレクトリを作る
md out\Debug\
cd out\Debug\

cmakeとninjaを使ってビルドをします。
SPIR-Vを読み込みできるように、 TINT_BUILD_SPV_READER オプションを指定しておきます。

(ninjaをインストールしていない場合は、ninjaをダウンロードしてきて、PATHを通してください。
https://github.com/ninja-build/ninja/releases )

cmake -GNinja ../.. TINT_BUILD_SPV_READER
# ビルド
ninja

ついでなので、ninjaを使わずに、CMakeとmakeでビルドする方法も書いておきます。

cmake ../.. TINT_BUILD_SPV_READER
# ビルド
make

Tintのインストール

ビルドに成功すると、
out/Debugにtint.exeファイルなどが沢山できているので、
これらを適当なディレクトリに配置し、これにもPATHを通しておきます。

任意のディレクトリで、

tint --help

を実行して正常に動作すればOKです。

変換をする

変換前のHLSL形式のShaderはこちらです。
ごくシンプルな、頂点カラーそのままを出力するFragmentShader(PixelShader)です。

struct VSOut {
        float4 Pos : SV_Position;
        float3 Color : TEXCOORD0;
};

float4 main(VSOut In) : SV_Target0
{
    return float4(In.Color, 1.0f);;
}

HLSL→SPIR-V(DXCを使用)

dxc.exe を使います。

dxc -spirv -T ps_6_0 -E main FragmentTest.hlsl -Fo FragmentTest.spv

指定しているオプションについては、下表のとおりです。
dxcのオプションの詳細は、公式ドキュメント か dxc --help コマンドから確認してください。

オプション 説明
-spirv SPIR-V形式に出力
-T ps_6_0 ターゲット。
シェーダーステージ(vs/ps/csなど)や、
シェーダーモデル(6_0など)を指定。
ここでは、PixelShaderとShaderModel 6.0を指定しています。
-E main エントリポイントの関数名を指定します。
FragmentTest.hlsl コンパイル元のhlslファイル。
-Fo FragmentTest.spv 出力先のSPIR-V形式のファイル。

成功すれば、FragmentTest.spvが出力されます。

SPIR-V→WGSL(Tintを使用)

tint.exeを使います。

tint FragmentTest.spv -o FragmentTest.wgsl

無事、WGSL形式のシェーダーが出力されました。

var<private> in_var_TEXCOORD0 : vec3<f32>;

var<private> out_var_SV_Target0 : vec4<f32>;

fn main_1() {
  let x_13 : vec3<f32> = in_var_TEXCOORD0;
  out_var_SV_Target0 = vec4<f32>(x_13.x, x_13.y, x_13.z, 1.0f);
  return;
}

struct main_out {
  @location(0)
  out_var_SV_Target0_1 : vec4<f32>,
}

@fragment
fn main(@location(0) in_var_TEXCOORD0_param : vec3<f32>) -> main_out {
  in_var_TEXCOORD0 = in_var_TEXCOORD0_param;
  main_1();
  return main_out(out_var_SV_Target0);
}

おわりに

HLSL→SPIR-V→WGSLの変換をやりました。

エンジン開発などでのクロスプラットフォーム対応や、HLSLのincludeなどのプリプロセッサ機能を使いたい場合などに活きそうな技術です。

あと、ゲームコンソールでもSDKとかにトランスパイル出来る仕組みってあるのかなぁってふと疑問を抱いたりしてます。NDAに触れちゃうであろう事項なので、いまのただの学生という身分では知りようがないのですが。

これからも、WebGPUなど色々と調査・研究していきます。

参考にしたもの

HLSL を SPIR-V 経由で GLSL に変換してみた - Qiita

SPIR‐V CodeGen · microsoft/DirectXShaderCompiler Wiki · GitHub

refs/heads/main - tint - Git at Google

http://commondatastorage.googleapis.com/chrome-infra-docs/flat/depot_tools/docs/html/depot_tools_tutorial.html#_setting_up

MonoでC#スクリプトをC++から実行入門

この記事は、GameEngineDev Advent Calendar 2022の24日目の記事です。

目次

はじめに

開発中の自作ゲームエンジンでは、Monoを使ってC#スクリプトに対応させています。
ところが、英語記事ですらとても情報が少ない...まずは私が入門記事書いちゃいます! あと、この記事はあくまでも入門で分かりやすさ優先です。きれいな実装とかでは全くないのでご了承くださいまし…

記事の内容をざっくりと説明すると、UnityのC#スクリプトと同じことをやります。
C#にC++を埋め込む(P/Invoke)のではなく、「C++にC#を埋め込み」をします。
この手法の利点は、C++側から、C#の型情報やインスタンス生成とかVMなどを弄れることです。

UnityはもともとMonoを使っていたので、この記事を読むと、少しだけUnityの気持ちが分かるようになる…かも…?知らんけど。

サンプルソース

今回の内容のサンプルソースをGitHubに置いておきました。

よろしければ、ご参考までにどうぞ。

github.com

Monoとは

Monoは、クロスプラットフォームで動作する、.NET Framework互換のフレームワークです。オープンソースです。

github.com

あと、PS4のようなゲームコンソールにも対応しているので、ゲームに向いているとも言えるかも?
実際、UnityはIL2CPP以前はMono(をForkして改造したやつ)を使ってC#スクリプトを走らせてました。

ちなみに、.NET 「Framework」互換なので、C#のバージョン7.0の一部機能までしかカバーしません。
本記事執筆地点(2022年12月)で、最新の.NET 7でC#11をサポートしてるので、正直少し古さはあります。
(私の知る限りでは、どうにかしたければVMやIL2CPPを自作するしかなさそう?)

C#スクリプトを実行する流れ

今回やる内容は、だいたい下の図のようになってます。

C#スクリプトを事前にIL(中間言語)に変換して、dll形式にします。
実行時、dllの中にあるILを、
C++に埋め込まれたMonoのVM(仮想マシン)上で動かします。

JITコンパイルについて

これは、JIT(Just In Time)コンパイルという手法です。
JITコンパイルについては、この記事が分かりやすいです。
いま出てきたILやVMについても理解できると思います。

qiita.com

あるいは、「JVM」とかでググるとこのあたりの概念が説明されてます。
Java言語ですが、同じことです。

(記事を書く時間が足りず、JITコンパイルの説明雑くなっちゃいました…スミマセン)

具体的な流れ

こんな感じです。
こうやって並べてみると、案外シンプルです。

  1. C#スクリプトのアセンブリをビルドしておく
    【ここからC++側】
  2. Monoの初期化
  3. アセンブリの読み込み
  4. クラス型情報の取得
  5. クラスのインスタンス生成
  6. 関数の情報の取得
  7. 関数の呼び出し

環境

念のため書いときます

Windows 11 Pro 21H2  
Mono 6.12.0  
Visual Studio Community 2022 17.2.6  

1.Monoをインストール(環境構築)

Monoの開発環境の構築は、今回は(多分)一番お手軽な方法で行きます。

Mono公式サイトから、「64bit」版のインストーラーをダウンロードしてください。

Download - Stable | Mono

次に、DLしたインストーラーを起動し、画面の指示に従ってインストールをしてください。
インストール先の場所は、あとで使うので覚えておいてください。
(インストーラーの操作は今回は割愛します)

2.C#スクリプトのアセンブリ作成

アセンブリとは、C#スクリプトをIL(中間言語)にビルドした状態のことです。
今回はdllファイルの形式です。

今回は、VisualStudioでビルドをします。

ソリューションとプロジェクトの作成

VisualStudioでソリューションを作成します。
プロジェクトテンプレートは、「クラスライブラリ(.NET Framework)」です。
「.NET Framework」と書いてるほうにしてください。(Monoが.NET Framwork互換なので、合わせます)

プロジェクト名は「CSScript」とでもしておきます。
今回は「ソリューションとプロジェクトを同じディレクトリに配置する」を有効にしておきます。

クラスの関数を実装

プロジェクト作ったときに自動で「Class1.cs」生成されていると思うので、今回はそれ使っちゃいます。

こんな感じの関数を実装します。
Monoを使ってC++側から呼び出したり、C#の関数を呼び出したりする関数です。
(Multiply関数に書いてる属性については、あとで解説します)

using System;
using System.Runtime.CompilerServices;

namespace CSScript
{
    public class Class1
    {
        private void PrintMessage()
        {
            Console.WriteLine("Hello, Mono!!!");
        }

        // C++の関数(内部呼び出し)
        [MethodImpl(MethodImplOptions.InternalCall)]
        private extern static int Multiply(int a, int b);

        // C++の関数を呼び出す版
        private void PrintMessage2()
        {
            // C++の関数を内部呼び出し
            Console.WriteLine("2 * 3 = " + Multiply(2, 3));
        }
    }
}

ビルド

ビルドします。
正常にビルドができていればOKです。

3.C++側とMonoのAPI等準備

ソリューションとプロジェクトの作成

C++側のソリューションとプロジェクトです。 プロジェクトテンプレートは、「コンソール アプリ」です。

名前は「NativeApplication」にしておきます。 こちらも、今回は「ソリューションとプロジェクトを同じディレクトリに配置する」を有効にしておきます。

Monoのライブラリ導入

1でインストールした場所にあるやつを使います。
デフォルト設定でインストールしたなら、C:/Program Files/Mono/ にあるかと思います。

※この節では、先ほど作成したNativeApplication.slnとかNativeApplication.cppがあるディレクトリを「プロジェクトディレクトリ」と呼びます。

include用ヘッダファイル

{Monoインストール先}/include/mono-2.0/mono フォルダごと、
プロジェクトディレクトリにコピーしてください。

VisualStudioのプロジェクト設定で、追加のインクルードディレクトリに「./」を設定しておきます。

libファイル

{Monoインストール先}/Lib/mono-2.0-sgen.libを、
プロジェクトディレクトリにコピーしてください。

dllファイル

{Monoインストール先}/bin/mono-2.0-sgen.dllを、
プロジェクトディレクトリにコピーしてください。

C#のライブラリ群

プロジェクトディレクトリ内に、MonoAssembly/bin/mono フォルダを作成してください。
{Monoインストール先}/Lib/mono/4.5/ フォルダごと、
プロジェクトディレクトリ/MonoAssembly/bin/mono/ 内にコピーしてください。

設定ファイル

プロジェクトディレクトリ内に、MonoAssembly/etc フォルダを作成してください。
{Monoインストール先}/etc/mono フォルダごと、
プロジェクトディレクトリ/MonoAssembly/etc 内にコピーしてください。

ビルドしたC#スクリプト

1でビルドしておいたやつです。
(MonoのAPIとかではありませんが、一緒にやっちゃいます。)
CSScript.dllを、
プロジェクトディレクトリにコピーしてください。
CSScript.dllは、{CSScriptのディレクトリ}/bin/Debug(またはRelease)にあるかと思います。

4.C++からC#スクリプトを呼び出す

Monoのインクルードとリンク

面倒なので、先にすべてIncludeとかリンクを書いてしまいます。 NativeApplication.cpp

// Mono
#pragma comment (lib, "mono-2.0-sgen.lib")
#include <mono/jit/jit.h>
#include <mono/metadata/assembly.h>
#include <mono/metadata/object.h>
#include <mono/metadata/appdomain.h>
#include <mono/metadata/debug-helpers.h>
#include <mono/metadata/exception.h>

Monoの初期化と終了

初期化&終了処理です。
MonoDomainは、Monoの仮想マシン上でのアプリケーションの処理の単位です。
OSにおけるプロセスとほぼ同じものです。

int main()
{
    // Monoのアセンブリと設定ファイルのディレクトリをセットする
    mono_set_dirs("./MonoAssembly/bin/", "./MonoAssembly/etc/");
    
    // ドメイン(OSにおけるプロセスのようなもの)
    MonoDomain* domain = nullptr;
    // Monoの初期化
    domain = mono_jit_init("CSScriptTest");
    if (!domain)
    {
        printf("Monoの初期化に失敗\n");
        return 1;
    }

    // Monoの終了処理
    mono_jit_cleanup(domain);

    return 0;
}

スクリプトのアセンブリのロード

C#スクリプトのアセンブリを読み込みます。
アセンブリとは、中間言語(IL)の状態に変換したC#のことです。
つまり、さっきビルドしたdllファイルです。

つづいて、アセンブリのImageを取得します。
Imageには、C#コードの情報が格納されています。

// スクリプトのアセンブリ(中間言語の状態に変換したC#)
MonoAssembly* assembly = nullptr;
// スクリプトのアセンブリ(DLL)をロード
assembly = mono_domain_assembly_open(domain, ".\\CSScript.dll");
if (!assembly)
{
    printf("スクリプトのアセンブリのロードに失敗\n");
    mono_jit_cleanup(domain);
    return 1;
}
// アセンブリのイメージ(アセンブリ内のコード情報を実際に保持しているもの)
MonoImage* assemblyImage = nullptr;
assemblyImage = mono_assembly_get_image(assembly);
if (!assemblyImage)
{
    printf("スクリプトのアセンブリイメージの取得に失敗\n");
    mono_jit_cleanup(domain);
    return 1;
}

クラスの読み込み

先述のように、アセンブリのImageにはC#コードの情報が格納されているので、
そこからクラスの型情報を貰ってきます。

// クラスの型
MonoClass* mainClass = nullptr;
mainClass = mono_class_from_name(assemblyImage, "CSScript", "Class1");
if (!mainClass)
{
    printf("クラスの型取得に失敗\n");
    mono_jit_cleanup(domain);
    return 1;
}

クラスのインスタンス化

クラスの情報をもとに、インスタンス化します。

// クラスのインスタンスを作成
MonoObject* classInstance = nullptr;
classInstance = mono_object_new(domain, mainClass);
if (!classInstance)
{
    printf("クラスのインスタンス生成に失敗\n");
    mono_jit_cleanup(domain);
    return 1;
}

クラスの関数読み込み

クラスの型情報から、関数の情報を取得します。

検索するためには、まず検索条件情報を作ってあげる必要があります。 MonoMethodDesc(定義情報)を作成します。
mono_method_desc_new関数の第一引数の文字列は、「名前空間名.クラス名::関数名」を書きます。

次に、作成したMonoMethodDescをもとに、mono_method_desc_search_in_classで検索します。

// 関数情報定義
MonoMethodDesc* methodDesc = nullptr;
methodDesc = mono_method_desc_new("CSScript.Class1::PrintMessage()", true);
if (!methodDesc)
{
    printf("関数情報の定義作成に失敗\n");
    mono_jit_cleanup(domain);
    return 1;
}

// スクリプトの関数
MonoMethod* method = nullptr;
// 関数情報定義をもとに、クラス内の関数を検索
method = mono_method_desc_search_in_class(methodDesc, mainClass);
if (!method)
{
    printf("関数取得に失敗\n");
    mono_jit_cleanup(domain);
    return 1;
}

クラスの関数呼び出し

mono_runtime_invoke関数で、関数を呼び出します。
ちなみに、staticな関数なら、第二引数で渡すクラスのインスタンスはnullptrでもOKです。

関数実行時の例外は、第四引数で受け取れます。
例外は文字列で渡されますが、変換してあげる必要があります。

// 関数実行時の例外情報
MonoObject* excObject = nullptr;
// 関数を呼び出し
mono_runtime_invoke(method, classInstance, nullptr, &excObject);
if (excObject)
{
    MonoString* excString = mono_object_to_string(excObject, nullptr);
    const char* excCString = mono_string_to_utf8(excString);
    printf("関数実行時例外%s:\n", excCString);
    mono_jit_cleanup(domain);
    return 1;
}

実行結果を確認すると、C#で定義した内容が出力されていることが確認できます。

実行結果
Hello, Mono!!!

5.C++の関数をC#から呼び出す(内部呼び出し)

「C++から呼び出した、C#の関数の中で、C++の関数を使う」
ということもやってみます。

これで、例えばゲームエンジンで、「シーン情報をC++で管理していて、C#スクリプトからもシーン情報取得したい」というニーズにも対応できるようになります。

「内部呼び出し(Internal Call)」という方式です。
ちなみに、公式ドキュメンテーションによると、
内部呼び出しは、MonoでC(C++)コードを呼び出す手段としては、最もオーバーヘッドが少ないです。

今回は掛け算をするC++の関数を呼び出してみます。

C++の関数(呼ばれる側)

なんの変哲もない、普通の掛け算です。

// C#側から呼び出される関数
int32_t Multiply(int32_t a, int32_t b)
{
    return a * b;
}

C#の関数(呼び出し側)

P/Invokeと大体同じで、externなstatic関数に属性を指定してあげます。
この属性を使うには、System.Runtime.CompilerServices をusingしてあげる必要があるので注意。

// C++の関数(内部呼び出し)
[MethodImpl(MethodImplOptions.InternalCall)]
private extern static int Multiply(int a, int b);

これで、C#側からC++の関数を呼べるようになります。

private void PrintMessage2()
{
    // C++の関数を内部呼び出し
    Console.WriteLine("2 * 3 = " + Multiply(2, 3));
}

C++の関数を、内部呼び出しの対象として登録する

最後に、C++の関数をmono_add_internal_call で登録してあげます。
第一引数は、登録先のC#の関数の「名前空間.クラス名::関数名」形式の文字列です。
第二引数は、登録するC++の関数ポインタです。

// C++の関数を、内部呼び出し対象として登録
mono_add_internal_call("CSScript.Class1::Multiply", &Multiply);

あとは、さきほどと同様に、C++の関数を呼び出すC#の関数(PrintMessage2)を呼び出してみましょう。
呼び出しの実装はさきほどと何も変わらないので、割愛します。

PrintMessage2関数を実行すると、正しくC++の関数が呼び出されたことが確認できます。

実行結果
2 * 3 = 6

おわりに

ね、簡単でしょ? …と言いたいところですが、これは入門編。
ここからが本番です。

今回は関数の呼び出しだけやりました。
他にも、フィールドやプロパティ、カスタム属性、GCなど、出来ることは大量にあります。
(ホットリロードも一応できますが、かなりクセがありました。分かるわけないやんあんな仕様 )

私自身、研究しながら使っています。
何かしら、小ネタとか含めてこのブログで発信出来たらなぁ…やりたいなぁって感じです。

そして、Monoはやはりネット上の情報はとても少ないです。
私がよく参考にしているところを紹介しておきます。

なお、お気づきかもしれませんが、MonoのAPIはC言語による実装です。
なので、C++でオブジェクト指向な形で良い感じにラッピングしてあげる必要があるでしょう。
…良い感じに…難しい…

参考にしたもの

Mono Documentation
Monoの埋め込みのサンプルソース

猫茶、個人ブログはじめました

こんにちは、猫茶(ねこちゃ)です。
自作ゲームエンジンなどなど色々開発している専門学生です。
最近はシステム寄り方面な開発を中心にやってます。

時折ゲームも開発してます。今年はXboxとWindows PC向けにフリーゲームをリリースしました(開発期間2か月)。

本記事タイトルに書いているように、これまで
Twitter(@mewmew_tea)とか
Qiita(mewmew_tea - Qiita)とか
GitHub(mewmew-tea (Nekocha) · GitHub)
で普段から発信を続けてきたけど、個人ブログも始めることにしました。

こんな感じにソースコードも載せやすそうだし、Markdown使えるしで、はてなブログで。

// 弾を発射するPlayerのスクリプト
using System.Collections.Generic;
using MiyadaikuEngine;

// (中略)
// 初期化。Runtime(C++)が自動で呼び出してくれる。
 private void Init()
 {
     // find bullet gameobjects
     GameObject[] gameObjects = gameObject.GetAll();
     bullets = new List<GameObject>();
     foreach (GameObject obj in gameObjects)
     {
         if (obj.GetComponent<BulletController>() != null)
         {
             bullets.Add(obj);
         }
     }
 }
// (以下略)

設立したのは、だいたいこんな理由

  • 「猫茶という個人」が主役の場所でも書きたい。
  • 汎用的でない記事など、Qiitaには書きづらいやつも書きたい。
  • ポエムなども自由に書きたい。
  • 技術以外の趣味も自由に書きたい。
  • とにかく自由に書きたい。
  • 個人技術ブログってかっこええやん?

Qiitaの良さもあるので使い分けていくことになりそう。
てなわけで、今後ともよろしくお願いします。

ブログ名について

ブログかなーり名迷った。
おしゃれーな感じのやつも考えたけど、「研究日誌」にしてみました。
ネーミングセンスが欲しい。

あと、ブログ名の話題からは離れますが、このブログの外見はいかがでしょうか。 良い感じのテーマに設定したり、CSS弄ってみたりしてみました。
(フォントとか読みやすく調整したいなぁ)

おまけ:今後書こうと思ってる記事

こんなこと書きたいな。

  • 学生が就活作品としてエンジン開発したという知見の共有
  • Unityで作ったゲームをXboxでリリースしたときの、共有できそうなノウハウ
  • Discord のサーバー運営(200人規模)、運営補助用のbot開発
  • 技術ほぼ関係ない雑記。最近遊んだゲームとか絵画のような個人的な趣味とかなんでも。

一番上のヤツは、Qiita Advent Callender 2022 のGameEngineDevの12月25日(最終日)分の記事として書く予定です。