猫茶の研究日誌

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

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のAPIC言語による実装です。
なので、C++オブジェクト指向な形で良い感じにラッピングしてあげる必要があるでしょう。
…良い感じに…難しい…

参考にしたもの

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